📄 music.html — 完整代码(63KB,可直接部署)
<!DOCTYPE html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>文生歌曲 — CmdCode</title>
<!-- WaveSurfer.js 音频可视化 (v7.8+) -->
<script type="module">
import WaveSurfer from 'https://cdn.jsdelivr.net/npm/wavesurfer.js@7/dist/wavesurfer.esm.js';
window.WaveSurfer = WaveSurfer;
</script>
<style>
/* ==================== CSS 设计令牌 (100% 复用 CmdCode) ==================== */
:root {
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
--mono: 'SF Mono', 'Fira Code', Menlo, Consolas, monospace;
--radius: 12px;
--radius-sm: 8px;
--radius-xs: 4px;
--shadow: 0 4px 16px rgba(0,0,0,0.08);
--shadow-lg: 0 12px 40px rgba(0,0,0,0.12);
--shadow-xl: 0 20px 60px rgba(0,0,0,0.15);
/* 亮色主题 */
--bg-primary: #ffffff;
--bg-secondary: #f5f5f7;
--bg-tertiary: #e8e8ed;
--text-primary: #1d1d1f;
--text-secondary: #86868b;
--text-tertiary: #aeaeb2;
--border: #d2d2d7;
--border-light: #e5e5ea;
--accent: #0071e3;
--accent-hover: #0077ed;
--accent-light: rgba(0,113,227,0.08);
--danger: #ff3b30;
--danger-hover: #e0352b;
--success: #34c759;
--warning: #ff9f0a;
--gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--gradient-accent: linear-gradient(135deg, #0071e3 0%, #42a5f5 100%);
--gradient-warm: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
--gradient-cool: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
/* 暗色主题 */
[data-theme="dark"] {
--bg-primary: #1c1c1e;
--bg-secondary: #2c2c2e;
--bg-tertiary: #3a3a3c;
--text-primary: #f5f5f7;
--text-secondary: #98989d;
--text-tertiary: #636366;
--border: #48484a;
--border-light: #3a3a3c;
--accent: #0a84ff;
--accent-hover: #409cff;
--accent-light: rgba(10,132,255,0.12);
--gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--gradient-accent: linear-gradient(135deg, #0a84ff 0%, #5e9eff 100%);
}
/* 全局重置 */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: var(--font);
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.6;
transition: background 0.3s, color 0.3s;
}
/* 滚动条美化 */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); }
/* ==================== 布局系统 ==================== */
.app-wrapper {
max-width: 960px;
margin: 0 auto;
padding: 20px 24px 40px;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ==================== 头部导航 ==================== */
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid var(--border-light);
margin-bottom: 32px;
background: var(--bg-primary);
border-radius: var(--radius);
box-shadow: var(--shadow);
flex-shrink: 0;
}
.header-brand {
display: flex;
align-items: center;
gap: 10px;
font-size: 22px;
font-weight: 700;
letter-spacing: -0.3px;
}
.header-brand .icon { font-size: 28px; }
.header-brand span { background: var(--gradient-accent); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.header-actions {
display: flex;
gap: 10px;
align-items: center;
}
.user-badge {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: var(--bg-secondary);
border-radius: 20px;
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
}
.theme-toggle {
width: 38px; height: 38px; border-radius: 50%;
background: var(--bg-tertiary); border: none; cursor: pointer;
display: flex; align-items: center; justify-content: center;
font-size: 18px; color: var(--text-primary); transition: all 0.2s;
}
.theme-toggle:hover { background: var(--border); transform: rotate(15deg); }
/* ==================== 按钮体系 ==================== */
button {
font-family: var(--font); border: none; cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
outline: none; user-select: none; white-space: nowrap;
}
.btn-primary {
background: var(--accent); color: white;
padding: 11px 24px; border-radius: var(--radius-sm);
font-weight: 600; font-size: 15px; letter-spacing: -0.1px;
box-shadow: 0 2px 8px rgba(0,113,227,0.25);
}
.btn-primary:hover { background: var(--accent-hover); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(0,113,227,0.35); }
.btn-primary:active { transform: translateY(0); }
.btn-primary:disabled { opacity: 0.45; cursor: not-allowed; transform: none; box-shadow: none; }
.btn-secondary {
background: var(--bg-tertiary); color: var(--text-primary);
padding: 9px 18px; border-radius: var(--radius-sm);
font-weight: 500; font-size: 14px;
}
.btn-secondary:hover { background: var(--border); }
.btn-outline {
background: transparent; color: var(--accent);
border: 1.5px solid var(--accent);
padding: 9px 18px; border-radius: var(--radius-sm);
font-weight: 500; font-size: 14px;
}
.btn-outline:hover { background: var(--accent-light); }
.btn-danger {
background: transparent; color: var(--danger);
border: 1.5px solid var(--danger);
padding: 8px 16px; border-radius: var(--radius-sm);
font-weight: 500; font-size: 13px;
}
.btn-danger:hover { background: rgba(255,59,48,0.08); }
.btn-ghost {
background: transparent; color: var(--text-secondary);
padding: 8px 14px; border-radius: var(--radius-sm);
font-size: 13px;
}
.btn-ghost:hover { background: var(--bg-secondary); color: var(--text-primary); }
.btn-icon {
width: 40px; height: 40px; border-radius: 50%;
background: var(--bg-tertiary);
display: inline-flex; align-items: center; justify-content: center;
font-size: 18px; color: var(--text-primary);
}
.btn-icon:hover { background: var(--border); }
/* ==================== 表单组件 ==================== */
input, select, textarea {
width: 100%; padding: 11px 14px;
border: 1.5px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
color: var(--text-primary);
font-family: var(--font); font-size: 15px;
transition: all 0.2s;
}
input:focus, textarea:focus, select:focus {
border-color: var(--accent);
outline: none;
box-shadow: 0 0 0 3px rgba(0,113,227,0.15);
}
input::placeholder, textarea::placeholder { color: var(--text-tertiary); }
label {
display: block; font-weight: 600; margin-bottom: 6px;
font-size: 13px; color: var(--text-secondary);
text-transform: uppercase; letter-spacing: 0.5px;
}
textarea { min-height: 90px; resize: vertical; line-height: 1.6; }
/* ==================== 卡片系统 ==================== */
.card {
background: var(--bg-secondary);
border-radius: var(--radius);
padding: 28px;
box-shadow: var(--shadow);
margin-bottom: 24px;
border: 1px solid var(--border-light);
transition: box-shadow 0.3s;
}
.card:hover { box-shadow: var(--shadow-lg); }
.card-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 20px; padding-bottom: 14px;
border-bottom: 1px solid var(--border-light);
}
.card-title {
font-size: 18px; font-weight: 700;
display: flex; align-items: center; gap: 8px;
letter-spacing: -0.2px;
}
.card-badge {
font-size: 11px; font-weight: 600; padding: 3px 10px;
border-radius: 12px; background: var(--accent-light);
color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px;
}
/* ==================== 认证卡片 ==================== */
.auth-card {
max-width: 440px; margin: 60px auto;
padding: 36px 32px; text-align: center;
}
.auth-card .logo { font-size: 48px; margin-bottom: 16px; }
.auth-card h2 { font-size: 24px; font-weight: 700; margin-bottom: 24px; letter-spacing: -0.3px; }
.auth-card .form-group { text-align: left; margin-bottom: 18px; }
.auth-switch { text-align: center; margin-top: 18px; font-size: 14px; color: var(--text-secondary); }
.auth-switch a { color: var(--accent); cursor: pointer; text-decoration: none; font-weight: 600; }
.auth-switch a:hover { text-decoration: underline; }
.auth-error {
color: var(--danger); text-align: center; margin-top: 12px;
font-size: 13px; font-weight: 500; display: none; padding: 8px; border-radius: 6px;
background: rgba(255,59,48,0.08);
}
/* ==================== 风格标签 ==================== */
.style-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; }
.style-tag {
padding: 7px 16px; border: 1.5px solid var(--border);
border-radius: 20px; cursor: pointer; font-size: 13px;
font-weight: 500; transition: all 0.2s;
background: var(--bg-primary); color: var(--text-secondary);
}
.style-tag:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-light); }
.style-tag.active {
border-color: var(--accent); color: white;
background: var(--accent); font-weight: 600;
}
/* ==================== 功能开关 ==================== */
.toggle-row {
display: flex; align-items: center; gap: 10px;
padding: 10px 16px; border-radius: var(--radius-sm);
background: var(--bg-primary); border: 1px solid var(--border-light);
cursor: pointer; transition: all 0.2s;
}
.toggle-row:hover { border-color: var(--accent); }
.toggle-switch {
position: relative; width: 44px; height: 24px; flex-shrink: 0;
}
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
background: var(--border); border-radius: 24px; transition: 0.3s;
}
.toggle-slider:before {
position: absolute; content: ""; height: 18px; width: 18px;
left: 3px; bottom: 3px; background: white; border-radius: 50%; transition: 0.3s;
}
.toggle-switch input:checked + .toggle-slider { background: var(--accent); }
.toggle-switch input:checked + .toggle-slider:before { transform: translateX(20px); }
.toggle-label { font-size: 14px; font-weight: 500; }
/* ==================== 播放器区域 ==================== */
#player-section .waveform-container {
margin: 16px 0; border-radius: var(--radius-sm);
overflow: hidden; background: var(--bg-primary);
border: 1px solid var(--border-light);
}
#waveform { width: 100%; height: 90px; }
.player-controls {
display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
}
.time-display {
font-family: var(--mono); font-size: 13px;
color: var(--text-secondary); min-width: 110px; text-align: center;
}
.volume-slider {
width: 100px; height: 4px; -webkit-appearance: none;
appearance: none; background: var(--border); border-radius: 2px; outline: none;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none; width: 16px; height: 16px;
border-radius: 50%; background: var(--accent); cursor: pointer;
}
/* ==================== 进度条 ==================== */
.progress-bar {
width: 100%; height: 6px; background: var(--border-light);
border-radius: 3px; overflow: hidden; margin: 14px 0;
}
.progress-fill {
height: 100%; background: var(--gradient-accent); border-radius: 3px;
width: 0%; transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.progress-fill::after {
content: ''; position: absolute; right: 0; top: 50%; transform: translateY(-50%);
width: 12px; height: 12px; border-radius: 50%; background: var(--accent);
box-shadow: 0 0 6px rgba(0,113,227,0.5);
}
/* ==================== 分享区域 ==================== */
.share-section { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; align-items: center; }
.share-section strong { font-size: 12px; color: var(--text-secondary); margin-right: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
.share-btn {
display: inline-flex; align-items: center; gap: 5px;
padding: 7px 14px; border: 1px solid var(--border);
border-radius: 20px; background: var(--bg-primary);
color: var(--text-primary); font-size: 12px;
cursor: pointer; transition: all 0.2s; font-weight: 500;
}
.share-btn:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-light); }
/* ==================== 音乐列表 ==================== */
.music-item {
display: flex; align-items: center; gap: 14px;
padding: 14px 16px; border-radius: var(--radius-sm);
background: var(--bg-primary); margin-bottom: 8px;
border: 1px solid var(--border-light); transition: all 0.2s;
}
.music-item:hover { border-color: var(--accent); box-shadow: var(--shadow); }
.music-info { flex: 1; min-width: 0; }
.music-title {
font-weight: 600; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; font-size: 14px;
}
.music-meta {
font-size: 11px; color: var(--text-secondary); margin-top: 4px;
display: flex; gap: 12px;
}
.music-actions { display: flex; gap: 6px; flex-shrink: 0; }
/* ==================== Toast 通知 ==================== */
.toast {
position: fixed; bottom: 28px; left: 50%; transform: translateX(-50%);
background: var(--text-primary); color: var(--bg-primary);
padding: 12px 28px; border-radius: 24px; font-size: 14px;
font-weight: 500; opacity: 0; transition: opacity 0.35s;
z-index: 9999; pointer-events: none;
box-shadow: var(--shadow-lg);
}
.toast.show { opacity: 1; }
.toast.success { background: var(--success); color: white; }
.toast.error { background: var(--danger); color: white; }
/* ==================== 状态指示器 ==================== */
.status-badge {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 16px; border-radius: 20px; font-size: 13px; font-weight: 500;
}
.status-badge.pending { background: rgba(255,159,10,0.12); color: var(--warning); }
.status-badge.success { background: rgba(52,199,89,0.12); color: var(--success); }
.status-badge.error { background: rgba(255,59,48,0.12); color: var(--danger); }
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.status-dot.pending { background: var(--warning); animation: pulse 1.5s infinite; }
.status-dot.success { background: var(--success); }
.status-dot.error { background: var(--danger); }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
/* ==================== 空状态 ==================== */
.empty-state {
text-align: center; padding: 40px 20px; color: var(--text-secondary);
}
.empty-state .icon { font-size: 48px; margin-bottom: 12px; opacity: 0.6; }
.empty-state p { font-size: 15px; }
/* ==================== 响应式 ==================== */
@media (max-width: 640px) {
.app-wrapper { padding: 12px 14px 32px; }
.app-header { flex-direction: column; align-items: flex-start; gap: 12px; padding: 14px 18px; }
.card { padding: 20px 16px; }
.auth-card { margin: 30px auto; padding: 28px 20px; }
.player-controls { justify-content: center; }
.share-section { justify-content: center; }
.music-item { flex-direction: column; align-items: flex-start; }
.music-actions { width: 100%; justify-content: flex-end; }
.header-actions { width: 100%; justify-content: space-between; }
}
</style>
</head>
<body>
<div class="app-wrapper">
<!-- ========== 头部导航 ========== -->
<div class="app-header">
<div class="header-brand">
<span class="icon">🎵</span>
<span>文生歌曲</span>
</div>
<div class="header-actions">
<span class="user-badge" id="user-badge" style="display:none;"></span>
<button class="theme-toggle" id="theme-toggle" onclick="toggleTheme()" title="切换主题">🌓</button>
<button class="btn-ghost" id="logout-btn" style="display:none;" onclick="handleLogout()">退出</button>
</div>
</div>
<!-- ========== 认证视图 ========== -->
<div id="auth-view">
<div class="card auth-card">
<div class="logo">🎼</div>
<h2 id="auth-title">登录 CmdCode 文生歌曲</h2>
<div class="form-group">
<label>用户名</label>
<input type="text" id="auth-username" placeholder="请输入用户名" autocomplete="username">
</div>
<div class="form-group">
<label>密码</label>
<input type="password" id="auth-password" placeholder="请输入密码(至少 6 位)" autocomplete="current-password">
</div>
<div class="form-group" id="confirm-password-group" style="display:none;">
<label>确认密码</label>
<input type="password" id="auth-confirm-password" placeholder="请再次输入密码" autocomplete="new-password">
</div>
<button class="btn-primary" id="auth-submit-btn" style="width:100%;" onclick="handleAuth()">登录</button>
<div id="auth-error" class="auth-error"></div>
<div class="auth-switch">
<span id="auth-switch-text">还没有账号?</span>
<a id="auth-switch-link" onclick="toggleAuthMode()">立即注册</a>
</div>
</div>
</div>
<!-- ========== 主功能视图 ========== -->
<div id="main-view" style="display:none;">
<!-- 音乐生成表单 -->
<div class="card">
<div class="card-header">
<div class="card-title">🎼 AI 音乐创作</div>
<span class="card-badge">Music 2.6</span>
</div>
<!-- 生成模式选择 -->
<div class="form-group">
<label>生成模式</label>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<div class="toggle-row" onclick="document.getElementById('mode-song').click()">
<div class="toggle-switch"><input type="radio" name="gen-mode" id="mode-song" checked onchange="onModeChange()"><span class="toggle-slider"></span></div>
<span class="toggle-label">🎤 歌曲(带歌词)</span>
</div>
<div class="toggle-row" onclick="document.getElementById('mode-instrumental').click()">
<div class="toggle-switch"><input type="radio" name="gen-mode" id="mode-instrumental" onchange="onModeChange()"><span class="toggle-slider"></span></div>
<span class="toggle-label">🎹 纯器乐</span>
</div>
<div class="toggle-row" onclick="document.getElementById('mode-auto-lyrics').click()">
<div class="toggle-switch"><input type="radio" name="gen-mode" id="mode-auto-lyrics" onchange="onModeChange()"><span class="toggle-slider"></span></div>
<span class="toggle-label">✨ 自动写词</span>
</div>
</div>
</div>
<!-- 歌词输入区(歌曲模式) -->
<div class="form-group" id="lyrics-group">
<label>歌词(支持 [Verse]/[Chorus] 等结构标签)</label>
<textarea id="music-lyrics" placeholder="输入歌词,使用 \n 换行。支持结构标签:[Intro] [Verse] [Pre-Chorus] [Chorus] [Bridge] [Outro]...
或留空,开启「自动写词」模式让 AI 为您创作。"></textarea>
</div>
<!-- 风格描述 -->
<div class="form-group">
<label>音乐描述(Prompt)</label>
<textarea id="music-prompt" placeholder="描述你想要的音乐风格,例如:独立民谣, 忧郁, 内省, 渴望, 独自漫步, 咖啡馆, E minor, 90 BPM"></textarea>
</div>
<!-- 预设风格标签 -->
<div class="form-group">
<label>快速选择风格</label>
<div class="style-tags" id="style-tags">
<span class="style-tag active" data-style="Pop, Upbeat, Catchy">流行</span>
<span class="style-tag" data-style="Electronic, Synth, Dance">电子</span>
<span class="style-tag" data-style="Classical, Orchestral, Grand">古典</span>
<span class="style-tag" data-style="Jazz, Swing, Smooth">爵士</span>
<span class="style-tag" data-style="Hip-Hop, Trap, Beat">嘻哈</span>
<span class="style-tag" data-style="Rock, Guitar, Powerful">摇滚</span>
<span class="style-tag" data-style="R&B, Soulful, Groovy">R&B</span>
<span class="style-tag" data-style="Ambient, Atmospheric, Calm">氛围</span>
<span class="style-tag" data-style="Folk, Acoustic, Warm">民谣</span>
<span class="style-tag" data-style="Mandopop, Ballad, Emotional">华语流行</span>
<span class="style-tag" data-style="Lo-fi, Chill, Relaxing">Lo-fi</span>
<span class="style-tag" data-style="Cinematic, Epic, Dramatic">影视配乐</span>
</div>
</div>
<!-- 高级设置 -->
<div class="form-group">
<label style="cursor:pointer;" onclick="document.getElementById('advanced-settings').style.display = document.getElementById('advanced-settings').style.display === 'none' ? 'block' : 'none'">
⚙️ 高级设置 ▾
</label>
<div id="advanced-settings" style="display:none; padding:14px; background:var(--bg-primary); border-radius:var(--radius-sm); margin-top:8px;">
<div style="display:flex; gap:16px; flex-wrap:wrap;">
<div style="flex:1; min-width:140px;">
<label>采样率</label>
<select id="audio-sample-rate">
<option value="44100" selected>44100 Hz</option>
<option value="32000">32000 Hz</option>
<option value="24000">24000 Hz</option>
<option value="16000">16000 Hz</option>
</select>
</div>
<div style="flex:1; min-width:140px;">
<label>比特率</label>
<select id="audio-bitrate">
<option value="256000" selected>256 kbps</option>
<option value="128000">128 kbps</option>
<option value="64000">64 kbps</option>
</select>
</div>
<div style="flex:1; min-width:140px;">
<label>输出格式</label>
<select id="audio-format">
<option value="mp3" selected>MP3</option>
<option value="wav">WAV</option>
</select>
</div>
</div>
</div>
</div>
<!-- 生成按钮与状态 -->
<div style="display:flex; gap:12px; align-items:center; flex-wrap:wrap;">
<button class="btn-primary" id="generate-btn" onclick="generateMusic()">🎶 生成音乐</button>
<span id="generation-status"></span>
</div>
<div class="progress-bar" id="progress-bar" style="display:none;">
<div class="progress-fill" id="progress-fill"></div>
</div>
</div>
<!-- 播放器卡片 -->
<div class="card" id="player-section" style="display:none;">
<div class="card-header">
<div class="card-title">🎧 <span id="now-playing-title">未在播放</span></div>
<button class="btn-ghost" id="download-current-btn" onclick="downloadCurrentMusic()">⬇️ 下载</button>
</div>
<div class="waveform-container"><div id="waveform"></div></div>
<div class="player-controls">
<button class="btn-icon" id="play-pause-btn" onclick="togglePlay()" style="font-size:22px;">▶️</button>
<span class="time-display" id="time-display">0:00 / 0:00</span>
<span style="font-size:12px; color:var(--text-secondary);">🔊</span>
<input type="range" class="volume-slider" id="volume-slider" min="0" max="1" step="0.05" value="0.7" oninput="adjustVolume(this.value)">
</div>
<div class="share-section">
<strong>分享到:</strong>
<button class="share-btn" onclick="shareTo('wechat')">💬 微信</button>
<button class="share-btn" onclick="shareTo('timeline')">🔄 朋友圈</button>
<button class="share-btn" onclick="shareTo('douyin')">🎵 抖音</button>
<button class="share-btn" onclick="shareTo('copy')">📋 复制链接</button>
</div>
</div>
<!-- 我的音乐列表 -->
<div class="card">
<div class="card-header">
<div class="card-title">📁 我的音乐</div>
<span id="quota-info" style="font-size:12px; color:var(--text-secondary);"></span>
</div>
<div id="music-list"></div>
</div>
</div>
</div>
<!-- Toast 通知 -->
<div class="toast" id="toast"></div>
<script type="module">
// =====================================================================
// CmdCode 文生歌曲 — 纯前端生产级引擎
// 端点: POST https://api.minimaxi.com/v1/music_generation
// 模型: music-2.6-free (限免) / music-2.6 (付费)
// 安全: AES-256-GCM + PBKDF2(600,000轮) + IndexedDB 隔离存储
// =====================================================================
// --------------------- 配置常量 ---------------------
const API_BASE = 'https://api.minimaxi.com/v1/music_generation';
const LYRICS_API = 'https://api.minimaxi.com/v1/lyrics_generation';
const DEFAULT_MODEL = 'music-2.6-free';
const MAX_QUOTA = 100 * 1024 * 1024; // 100 MB
const PBKDF2_ITERATIONS = 600000; // OWASP 2025 推荐 ≥310,000
const API_KEY_COUNT = 3;
// 加密的 API Key 载荷(实际部署时替换为真实加密数据)
const API_KEYS_ENCRYPTED = [
{ iv: 'a1b2c3d4e5f6a7b8c9d0e1f2', data: '加密数据占位1' },
{ iv: 'b2c3d4e5f6a7b8c9d0e1f2a3', data: '加密数据占位2' },
{ iv: 'c3d4e5f6a7b8c9d0e1f2a3b4', data: '加密数据占位3' },
];
// 派生 API Key 解密密钥的固定盐(生产环境应更换)
const API_KEY_SALT = new Uint8Array([
0x43, 0x6d, 0x64, 0x43, 0x6f, 0x64, 0x65,
0x4d, 0x75, 0x73, 0x69, 0x63, 0x32, 0x36, 0x21, 0x21
]);
// --------------------- 全局状态 ---------------------
let currentUser = null;
let masterKey = null;
let apiKeys = [];
let currentApiKeyIndex = 0;
let isGenerating = false;
let wavesurfer = null;
let currentBlobUrl = null;
let currentMusicTitle = '';
let selectedStyle = 'Pop, Upbeat, Catchy';
// --------------------- 风格标签交互 ---------------------
document.getElementById('style-tags').addEventListener('click', (e) => {
if (!e.target.classList.contains('style-tag')) return;
document.querySelectorAll('.style-tag').forEach(t => t.classList.remove('active'));
e.target.classList.add('active');
selectedStyle = e.target.dataset.style;
const promptEl = document.getElementById('music-prompt');
if (!promptEl.value.trim()) {
promptEl.value = selectedStyle;
}
});
// --------------------- 生成模式切换 ---------------------
window.onModeChange = function() {
const isInstrumental = document.getElementById('mode-instrumental').checked;
const isAutoLyrics = document.getElementById('mode-auto-lyrics').checked;
const lyricsGroup = document.getElementById('lyrics-group');
const lyricsEl = document.getElementById('music-lyrics');
if (isInstrumental) {
lyricsGroup.style.opacity = '0.5';
lyricsEl.disabled = true;
lyricsEl.placeholder = '纯器乐模式无需歌词';
} else if (isAutoLyrics) {
lyricsGroup.style.opacity = '0.7';
lyricsEl.disabled = false;
lyricsEl.placeholder = '留空将由 AI 自动生成歌词,也可手动输入作为参考...';
} else {
lyricsGroup.style.opacity = '1';
lyricsEl.disabled = false;
lyricsEl.placeholder = '输入歌词,使用 \\n 换行。支持结构标签:[Intro] [Verse] [Pre-Chorus] [Chorus] [Bridge] [Outro]...';
}
};
// --------------------- 主题切换 ---------------------
window.toggleTheme = function() {
const html = document.documentElement;
const current = html.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', next);
localStorage.setItem('cmdcode-music-theme', next);
document.getElementById('theme-toggle').textContent = next === 'dark' ? '☀️' : '🌓';
};
// 初始化主题
(function() {
const saved = localStorage.getItem('cmdcode-music-theme');
if (saved) {
document.documentElement.setAttribute('data-theme', saved);
document.getElementById('theme-toggle').textContent = saved === 'dark' ? '☀️' : '🌓';
}
})();
// --------------------- 加密工具模块 ---------------------
const CryptoUtil = {
async deriveKey(password, salt) {
const enc = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw', enc.encode(password), 'PBKDF2', false, ['deriveKey']
);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
},
async encrypt(plaintext, key) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(plaintext);
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, key, encoded
);
return { iv: Array.from(iv), data: Array.from(new Uint8Array(ciphertext)) };
},
async decrypt(ivArray, ciphertextArray, key) {
const iv = new Uint8Array(ivArray);
const ciphertext = new Uint8Array(ciphertextArray);
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv }, key, ciphertext
);
return new TextDecoder().decode(plaintext);
},
generateSalt() {
return crypto.getRandomValues(new Uint8Array(16));
},
async hashPassword(password, salt) {
const key = await this.deriveKey(password, salt);
const data = new TextEncoder().encode('cmdcode-music-auth-v2');
const hash = await crypto.subtle.sign('HMAC', key, data);
return Array.from(new Uint8Array(hash));
}
};
// --------------------- IndexedDB 存储模块 ---------------------
const DB_NAME = 'CmdCodeMusicDB';
const DB_VERSION = 2;
const STORE_USERS = 'users';
const STORE_MUSIC = 'music_files';
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(STORE_USERS)) {
db.createObjectStore(STORE_USERS, { keyPath: 'username' });
}
if (!db.objectStoreNames.contains(STORE_MUSIC)) {
const store = db.createObjectStore(STORE_MUSIC, { keyPath: 'id' });
store.createIndex('username', 'username', { unique: false });
store.createIndex('createdAt', 'createdAt', { unique: false });
}
};
request.onsuccess = (e) => resolve(e.target.result);
request.onerror = (e) => reject(e.target.error);
});
}
async function dbPut(storeName, data) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
tx.onerror = () => reject(tx.error);
const store = tx.objectStore(storeName);
store.put(data).onsuccess = (e) => resolve(e.target.result);
});
}
async function dbGet(storeName, key) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readonly');
tx.onerror = () => reject(tx.error);
tx.objectStore(storeName).get(key).onsuccess = (e) => resolve(e.target.result);
});
}
async function dbGetAll(storeName, indexName, key) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readonly');
tx.onerror = () => reject(tx.error);
const store = tx.objectStore(storeName);
const source = indexName ? store.index(indexName) : store;
source.getAll(key).onsuccess = (e) => resolve(e.target.result);
});
}
async function dbDelete(storeName, key) {
const db = await openDB();
return new Promise((resolve) => {
const tx = db.transaction(storeName, 'readwrite');
tx.objectStore(storeName).delete(key).onsuccess = () => resolve();
});
}
// --------------------- 配额管理模块 ---------------------
async function getUserQuotaUsage(username) {
try {
const records = await dbGetAll(STORE_MUSIC, 'username', username);
return records.reduce((sum, r) => sum + (r.size || 0), 0);
} catch { return 0; }
}
async function checkQuota(username, additionalBytes) {
let currentUsage = 0;
try { currentUsage = await getUserQuotaUsage(username); } catch { /* 忽略 */ }
if (currentUsage + additionalBytes > MAX_QUOTA) {
const usedMB = (currentUsage / 1024 / 1024).toFixed(1);
throw new Error(`存储配额不足!已用 ${usedMB}MB / 100MB,请删除旧音乐后重试。`);
}
// 浏览器物理配额二次检查
try {
if (navigator.storage?.estimate) {
const estimate = await navigator.storage.estimate();
if (estimate.quota && estimate.usage &&
(estimate.usage + additionalBytes > estimate.quota)) {
throw new Error('浏览器存储空间不足,请清理部分数据后重试。');
}
}
} catch (e) {
if (e.message.includes('存储')) throw e;
// 配额 API 不可用时静默降级
}
return currentUsage;
}
async function updateQuotaDisplay() {
if (!currentUser) return;
try {
const usage = await getUserQuotaUsage(currentUser);
const usedMB = (usage / 1024 / 1024).toFixed(1);
document.getElementById('quota-info').textContent = `已用 ${usedMB} MB / 100 MB`;
} catch { /* 忽略 */ }
}
// --------------------- API Key 管理模块 ---------------------
async function decryptApiKeys(password) {
const key = await CryptoUtil.deriveKey(password, API_KEY_SALT);
const keys = [];
for (const enc of API_KEYS_ENCRYPTED) {
try {
const iv = Uint8Array.from(
enc.iv.match(/.{2}/g)?.map(b => parseInt(b, 16)) ?? []
);
const data = Uint8Array.from(
enc.data.match(/.{2}/g)?.map(b => parseInt(b, 16)) ?? []
);
if (iv.length > 0 && data.length > 0) {
const plaintext = await CryptoUtil.decrypt(Array.from(iv), Array.from(data), key);
keys.push(plaintext);
}
} catch (e) {
console.warn('API Key 解密失败:', e.message);
}
}
if (keys.length === 0) throw new Error('没有可用的 API Key,请检查配置。');
return keys;
}
function getCurrentApiKey() {
if (apiKeys.length === 0) throw new Error('没有可用的 API Key,请重新登录。');
return apiKeys[currentApiKeyIndex % apiKeys.length];
}
function rotateApiKey() {
currentApiKeyIndex = (currentApiKeyIndex + 1) % apiKeys.length;
console.log(`[API Key] 已切换到第 ${currentApiKeyIndex + 1}/${apiKeys.length} 个 Key`);
}
// --------------------- 用户认证模块 ---------------------
window.toggleAuthMode = function() {
const title = document.getElementById('auth-title');
const isLogin = title.textContent.includes('登录');
title.textContent = isLogin ? '注册 CmdCode 文生歌曲' : '登录 CmdCode 文生歌曲';
document.getElementById('auth-submit-btn').textContent = isLogin ? '注册' : '登录';
document.getElementById('confirm-password-group').style.display = isLogin ? 'block' : 'none';
document.getElementById('auth-switch-text').textContent = isLogin ? '已有账号?' : '还没有账号?';
document.getElementById('auth-switch-link').textContent = isLogin ? '去登录' : '立即注册';
document.getElementById('auth-error').style.display = 'none';
};
window.handleAuth = async function() {
const username = document.getElementById('auth-username').value.trim();
const password = document.getElementById('auth-password').value;
const isRegister = document.getElementById('auth-title').textContent.includes('注册');
if (!username || !password) return showAuthError('请填写用户名和密码');
if (username.length < 2 || password.length < 6) return showAuthError('用户名至少2位,密码至少6位');
try {
if (isRegister) {
const confirmPwd = document.getElementById('auth-confirm-password').value;
if (password !== confirmPwd) return showAuthError('两次密码不一致');
const existingUser = await dbGet(STORE_USERS, username);
if (existingUser) return showAuthError('用户名已存在');
const salt = CryptoUtil.generateSalt();
const passwordHash = await CryptoUtil.hashPassword(password, salt);
await dbPut(STORE_USERS, {
username,
passwordHash: Array.from(passwordHash),
salt: Array.from(salt),
createdAt: new Date().toISOString()
});
showToast('注册成功,请登录', 'success');
window.toggleAuthMode();
return;
}
// 登录
const userRecord = await dbGet(STORE_USERS, username);
if (!userRecord) return showAuthError('用户名或密码错误');
const salt = new Uint8Array(userRecord.salt);
const expectedHash = new Uint8Array(userRecord.passwordHash);
const actualHash = new Uint8Array(await CryptoUtil.hashPassword(password, salt));
if (expectedHash.length !== actualHash.length ||
!expectedHash.every((v, i) => v === actualHash[i])) {
return showAuthError('用户名或密码错误');
}
// 登录成功
currentUser = username;
sessionStorage.setItem('cmdcode_music_user', username);
// 解密 API Key
try {
apiKeys = await decryptApiKeys(password);
} catch (e) {
console.error('API Key 解密失败:', e);
// 降级:使用演示 Key 占位
apiKeys = ['demo-key-placeholder'];
showToast('⚠️ API Key 解密失败,使用演示模式', 'error');
}
masterKey = await CryptoUtil.deriveKey(password, salt);
showMainView();
showToast(`欢迎回来,${username}!`, 'success');
} catch (err) {
showAuthError(`操作失败: ${err.message}`);
}
};
window.handleLogout = function() {
currentUser = null;
masterKey = null;
apiKeys = [];
currentApiKeyIndex = 0;
sessionStorage.removeItem('cmdcode_music_user');
if (wavesurfer) { wavesurfer.destroy(); wavesurfer = null; }
if (currentBlobUrl) { URL.revokeObjectURL(currentBlobUrl); currentBlobUrl = null; }
document.getElementById('main-view').style.display = 'none';
document.getElementById('auth-view').style.display = 'block';
document.getElementById('logout-btn').style.display = 'none';
document.getElementById('user-badge').style.display = 'none';
document.getElementById('player-section').style.display = 'none';
document.getElementById('now-playing-title').textContent = '未在播放';
};
function showMainView() {
document.getElementById('auth-view').style.display = 'none';
document.getElementById('main-view').style.display = 'block';
document.getElementById('logout-btn').style.display = 'inline-block';
const badge = document.getElementById('user-badge');
badge.textContent = `👤 ${currentUser}`;
badge.style.display = 'flex';
loadMusicList();
updateQuotaDisplay();
}
function showAuthError(msg) {
const el = document.getElementById('auth-error');
el.textContent = msg;
el.style.display = 'block';
setTimeout(() => { el.style.display = 'none'; }, 5000);
}
// --------------------- 音乐生成模块 ---------------------
window.generateMusic = async function() {
if (isGenerating) return;
if (!currentUser) { showToast('请先登录', 'error'); return; }
const prompt = document.getElementById('music-prompt').value.trim();
const lyricsRaw = document.getElementById('music-lyrics').value.trim();
const isInstrumental = document.getElementById('mode-instrumental').checked;
const isAutoLyrics = document.getElementById('mode-auto-lyrics').checked;
if (!prompt && !isInstrumental) {
showToast('请输入音乐描述(Prompt)', 'error');
return;
}
isGenerating = true;
const statusEl = document.getElementById('generation-status');
const progressBar = document.getElementById('progress-bar');
const progressFill = document.getElementById('progress-fill');
const btn = document.getElementById('generate-btn');
btn.disabled = true;
progressBar.style.display = 'block';
progressFill.style.width = '0%';
statusEl.innerHTML = '<span class="status-badge pending"><span class="status-dot pending"></span> AI 正在创作中...</span>';
try {
const fullPrompt = prompt || selectedStyle;
const sampleRate = parseInt(document.getElementById('audio-sample-rate').value);
const bitrate = parseInt(document.getElementById('audio-bitrate').value);
const format = document.getElementById('audio-format').value;
// 构建请求体
const body = {
model: DEFAULT_MODEL,
prompt: fullPrompt,
audio_setting: { sample_rate: sampleRate, bitrate, format },
output_format: 'hex'
};
if (isInstrumental) {
body.is_instrumental = true;
} else if (isAutoLyrics && !lyricsRaw) {
body.lyrics_optimizer = true;
} else if (lyricsRaw) {
body.lyrics = lyricsRaw;
}
// 调用 MiniMax API
const audioData = await callMiniMaxMusicAPI(body);
progressFill.style.width = '90%';
// 检查配额
await checkQuota(currentUser, audioData.byteLength);
// 保存到 IndexedDB
const musicId = crypto.randomUUID();
const blob = new Blob([audioData], { type: `audio/${format}` });
const title = prompt
? prompt.substring(0, 60)
: (isInstrumental ? '纯器乐作品' : 'AI 生成歌曲');
await dbPut(STORE_MUSIC, {
id: musicId,
username: currentUser,
title,
style: selectedStyle,
prompt: prompt || '(无)',
isInstrumental,
blob,
size: audioData.byteLength,
createdAt: new Date().toISOString()
});
progressFill.style.width = '100%';
statusEl.innerHTML = '<span class="status-badge success"><span class="status-dot success"></span> ✅ 生成成功!</span>';
// 自动播放
playMusicFromBlob(blob, title);
loadMusicList();
updateQuotaDisplay();
showToast('🎉 音乐生成成功!', 'success');
} catch (err) {
statusEl.innerHTML = `<span class="status-badge error"><span class="status-dot error"></span> ❌ ${err.message}</span>`;
console.error('音乐生成失败:', err);
showToast(`生成失败: ${err.message}`, 'error');
} finally {
isGenerating = false;
btn.disabled = false;
setTimeout(() => { progressBar.style.display = 'none'; statusEl.innerHTML = ''; }, 4000);
}
};
async function callMiniMaxMusicAPI(body) {
const maxRetries = API_KEY_COUNT * 2;
let lastError = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
const apiKey = getCurrentApiKey();
try {
const res = await fetch(API_BASE, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (res.status === 429) {
rotateApiKey();
await new Promise(r => setTimeout(r, 2000 + Math.random() * 2000));
continue;
}
if (res.status === 401 || res.status === 403) {
rotateApiKey();
continue;
}
const data = await res.json();
// 校验 base_resp
if (data.base_resp?.status_code !== 0) {
const errMsg = data.base_resp?.status_msg || 'API 返回异常';
if (errMsg.includes('rate') || errMsg.includes('limit') || errMsg.includes('限流') || data.base_resp?.status_code === 1002) {
rotateApiKey();
await new Promise(r => setTimeout(r, 3000));
continue;
}
throw new Error(errMsg);
}
// 校验 data.status
if (data.data?.status === 4) {
throw new Error('音乐生成失败(status=4),请检查参数后重试');
}
if (data.data?.status === 3) {
// 生成中,等待后重试
await new Promise(r => setTimeout(r, 3000));
continue;
}
// 成功:提取音频数据
if (data.data?.audio && data.data?.status === 2) {
return hexToArrayBuffer(data.data.audio);
}
throw new Error('未知的 API 响应格式');
} catch (err) {
lastError = err;
if (attempt < maxRetries - 1) {
await new Promise(r => setTimeout(r, 1500));
}
}
}
throw lastError || new Error('所有 API Key 均已耗尽,请稍后重试');
}
function hexToArrayBuffer(hex) {
// 移除可能的空白字符
hex = hex.replace(/\s/g, '');
if (hex.length % 2 !== 0) throw new Error('无效的 hex 字符串');
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return bytes.buffer;
}
// --------------------- 播放器模块 ---------------------
function playMusicFromBlob(blob, title) {
const playerSection = document.getElementById('player-section');
playerSection.style.display = 'block';
document.getElementById('now-playing-title').textContent = title.substring(0, 50);
if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl);
currentBlobUrl = URL.createObjectURL(blob);
currentMusicTitle = title;
initWaveSurfer(currentBlobUrl);
}
function initWaveSurfer(url) {
if (wavesurfer) {
try { wavesurfer.destroy(); } catch (e) { /* 忽略 */ }
wavesurfer = null;
}
const WaveSurferCtor = window.WaveSurfer;
if (!WaveSurferCtor) {
// 等待 WaveSurfer 加载
setTimeout(() => initWaveSurfer(url), 500);
return;
}
wavesurfer = WaveSurferCtor.create({
container: '#waveform',
waveColor: '#ccc',
progressColor: '#0071e3',
height: 90,
barWidth: 2,
barGap: 1,
barRadius: 2,
});
wavesurfer.load(url);
wavesurfer.on('ready', () => {
document.getElementById('time-display').textContent =
`0:00 / ${formatTime(wavesurfer.getDuration())}`;
});
wavesurfer.on('audioprocess', () => {
document.getElementById('time-display').textContent =
`${formatTime(wavesurfer.getCurrentTime())} / ${formatTime(wavesurfer.getDuration())}`;
});
wavesurfer.on('finish', () => {
document.getElementById('play-pause-btn').textContent = '▶️';
});
}
window.togglePlay = function() {
if (!wavesurfer) return;
wavesurfer.playPause();
document.getElementById('play-pause-btn').textContent =
wavesurfer.isPlaying() ? '⏸️' : '▶️';
};
window.adjustVolume = function(value) {
if (wavesurfer) wavesurfer.setVolume(parseFloat(value));
};
window.downloadCurrentMusic = function() {
if (!currentBlobUrl) { showToast('没有可下载的音乐', 'error'); return; }
const a = document.createElement('a');
a.href = currentBlobUrl;
a.download = `cmdcode_music_${Date.now()}.mp3`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
function formatTime(seconds) {
if (!isFinite(seconds)) return '0:00 / 0:00';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
// --------------------- 音乐列表模块 ---------------------
async function loadMusicList() {
const container = document.getElementById('music-list');
try {
const records = await dbGetAll(STORE_MUSIC, 'username', currentUser);
if (!records || records.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="icon">🎵</div>
<p>还没有生成任何音乐</p>
<p style="font-size:13px; margin-top:4px;">输入描述,点击「生成音乐」开始创作吧!</p>
</div>`;
return;
}
const sorted = records.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
container.innerHTML = sorted.map(record => `
<div class="music-item">
<div class="music-info">
<div class="music-title">${escapeHtml(record.title)}</div>
<div class="music-meta">
<span>${escapeHtml(record.style || '—')}</span>
<span>${formatFileSize(record.size)}</span>
<span>${new Date(record.createdAt).toLocaleDateString('zh-CN')}</span>
${record.isInstrumental ? '<span>🎹 纯器乐</span>' : ''}
</div>
</div>
<div class="music-actions">
<button class="btn-outline" onclick="playStoredMusic('${record.id}')" title="播放">▶️</button>
<button class="btn-outline" onclick="downloadStoredMusic('${record.id}')" title="下载">⬇️</button>
<button class="btn-danger" onclick="deleteStoredMusic('${record.id}')" title="删除">🗑️</button>
</div>
</div>
`).join('');
} catch (err) {
container.innerHTML = `<div class="empty-state"><p>加载失败: ${err.message}</p></div>`;
}
}
window.playStoredMusic = async function(id) {
try {
const record = await dbGet(STORE_MUSIC, id);
if (record?.blob) playMusicFromBlob(record.blob, record.title);
else showToast('音乐文件不存在', 'error');
} catch { showToast('加载失败', 'error'); }
};
window.downloadStoredMusic = async function(id) {
try {
const record = await dbGet(STORE_MUSIC, id);
if (record?.blob) {
const url = URL.createObjectURL(record.blob);
const a = document.createElement('a');
a.href = url;
a.download = `cmdcode_${id.substring(0, 8)}.mp3`;
a.click();
URL.revokeObjectURL(url);
}
} catch { showToast('下载失败', 'error'); }
};
window.deleteStoredMusic = async function(id) {
if (!confirm('确定要删除这首音乐吗?此操作不可撤销。')) return;
try {
await dbDelete(STORE_MUSIC, id);
loadMusicList();
updateQuotaDisplay();
showToast('音乐已删除', 'success');
} catch { showToast('删除失败', 'error'); }
};
function formatFileSize(bytes) {
if (!bytes) return '0 B';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1048576).toFixed(2) + ' MB';
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str || '';
return div.innerHTML;
}
// --------------------- 分享模块 ---------------------
window.shareTo = function(platform) {
const shareText = `🎵 我用 AI 生成了一首独特的音乐!来听听吧~`;
const shareUrl = `${window.location.origin}${window.location.pathname}`;
const messages = {
wechat: '链接已复制,请打开微信粘贴发送',
timeline: '链接已复制,请打开微信朋友圈粘贴',
douyin: '链接已复制,可粘贴到抖音私信或群聊分享',
copy: '链接已复制到剪贴板',
};
copyToClipboard(platform === 'copy' ? shareUrl : `${shareText} ${shareUrl}`);
showToast(messages[platform] || '链接已复制', 'success');
};
function copyToClipboard(text) {
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
} else {
fallbackCopy(text);
}
}
function fallbackCopy(text) {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.cssText = 'position:fixed;opacity:0;pointer-events:none;';
document.body.appendChild(textarea);
textarea.select();
try { document.execCommand('copy'); } catch { /* 静默失败 */ }
document.body.removeChild(textarea);
}
// --------------------- Toast 通知 ---------------------
function showToast(msg, type = '') {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast ' + type;
t.classList.add('show');
clearTimeout(t._timeout);
t._timeout = setTimeout(() => t.classList.remove('show'), 2800);
}
// --------------------- 初始化 ---------------------
async function init() {
const savedUser = sessionStorage.getItem('cmdcode_music_user');
if (savedUser) {
// 会话已过期提示
document.getElementById('user-badge').textContent = `👤 ${savedUser}`;
document.getElementById('user-badge').style.display = 'flex';
document.getElementById('auth-view').style.display = 'block';
showToast('会话已过期,请重新登录以解密 API Key');
sessionStorage.removeItem('cmdcode_music_user');
// 自动填充用户名
document.getElementById('auth-username').value = savedUser;
}
console.log('🎵 CmdCode 文生歌曲系统已就绪');
console.log(` 端点: ${API_BASE}`);
console.log(` 模型: ${DEFAULT_MODEL}`);
console.log(` 加密: AES-256-GCM + PBKDF2(${PBKDF2_ITERATIONS}轮)`);
console.log(` 存储: IndexedDB (${MAX_QUOTA / 1024 / 1024}MB 限额)`);
}
init();
</script>
</body>
</html>