refactor(admin): 把单文件 admin.html (1075 行) 拆成多页 SPA,URL 路由切换
原 admin.html 是 1075 行的单文件 SPA:登录/仪表盘/音色/设置 4 个面板
挤在一个 Vue 实例里 tab 切换(login -> app shell -> 3 个 section)。
文件太大,样式 + script + template 混在一起,改一个面板要 scroll 整页找位置。
拆分后:
router/admin.html 5.4 KB Dashboard (重定向目标,默认页)
router/admin-login.html 2.1 KB 登录页 (无 sidebar)
router/admin-voices.html 13.3 KB 音色管理 + Add modal
router/admin-settings.html 14.1 KB 系统设置
router/admin.css 19.8 KB 共享样式 (4 个页面 + admin-shell.js 共用)
router/admin-shell.js 6.9 KB 共享:http client (401 自动清 key)、toast 系统、
login helper、sidebar 渲染、format helpers
最大的页面 14 KB,平均 10 KB,各页只负责自己逻辑。
技术细节:
- 栈保持: Vue 3 + Axios 走 CDN,纯静态 HTML,零构建
- URL 路由: /admin /admin/login /admin/voices /admin/settings,
router.go 加 4 条 ServeFile 路由 + 2 条静态资源路由
(/admin/admin.css /admin/admin-shell.js)
- 抽 serveAdmin(body []byte) http.HandlerFunc helper 避免 4 段重复
- 鉴权: 各页 onMounted 检 sessionStorage.ttsAdminKey,无则 location.href
跳 /admin/login;http client 401 响应拦截器清 key,下个请求被跳到 login
- Sidebar 一致: shell.js mountShell({activeNav, overview, voiceCount, ...})
渲染 <aside> + 高亮 active 项,各页只传 activeNav 不重复维护 DOM
- 静态资源 Content-Type 显式设置:text/css / application/javascript
未变更:
- 不动 /api/* 后端接口
- 不动 /setup /dashboard /health /v1/audio/speech 路由
- 鉴权语义: HTML 公开 / API 鉴权 (与原版一致)
- 不引入构建工具 / 不引入新依赖
验证 (PowerShell 起真 server + setup API + curl):
/admin 200 5433 bytes text/html
/admin/login 200 2082 bytes text/html
/admin/voices 200 13282 bytes text/html
/admin/settings 200 14110 bytes text/html
/admin/admin.css 200 19815 bytes text/css
/admin/admin-shell.js 200 6887 bytes application/javascript
/api/admin/overview (无 auth) 401
/api/admin/overview (有 auth) 200
各页内容 marker 命中 (filteredVoices / corsForm / voice_count / login-card)
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>管理后台 · 登录</title>
|
||||
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
|
||||
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
|
||||
<link rel="stylesheet" href="/admin/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="login-screen">
|
||||
<div class="login-card">
|
||||
<div class="login-logo">TTS</div>
|
||||
<h1>管理后台</h1>
|
||||
<div class="sub">输入 OPENAI_TTS_API_KEY 登录</div>
|
||||
<input v-model="keyInput" type="password" placeholder="API Key" @keyup.enter="onLogin" autofocus class="login-input">
|
||||
<button class="btn btn-primary btn-lg" @click="onLogin" :disabled="!keyInput" style="width: 100%;">登 录</button>
|
||||
<div v-if="loginErr" class="login-err">⚠ {{ loginErr }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/admin/admin-shell.js"></script>
|
||||
<script>
|
||||
const { createApp, ref } = Vue;
|
||||
const { http, setKey, clearKey, getKey } = window.admin;
|
||||
createApp({
|
||||
setup() {
|
||||
const keyInput = ref('');
|
||||
const loginErr = ref('');
|
||||
|
||||
// 已登录则直接跳 dashboard
|
||||
if (getKey()) {
|
||||
http.get('/admin/overview').then(() => {
|
||||
location.href = '/admin';
|
||||
}).catch(() => { clearKey(); });
|
||||
}
|
||||
|
||||
const onLogin = async () => {
|
||||
loginErr.value = '';
|
||||
try {
|
||||
await window.admin.login(keyInput.value);
|
||||
location.href = '/admin';
|
||||
} catch (e) {
|
||||
loginErr.value = '鉴权失败: ' + (e.response?.data?.error?.message || e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return { keyInput, loginErr, onLogin };
|
||||
},
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,270 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>火山 TTS · 系统设置</title>
|
||||
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
|
||||
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
|
||||
<link rel="stylesheet" href="/admin/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="app-shell">
|
||||
<aside id="admin-sidebar"></aside>
|
||||
|
||||
<main class="main">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h1 class="page-title">系统设置</h1>
|
||||
<p class="page-sub">鉴权、上游凭证、默认路由与跨域</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="settings && !settings.cors_configured" class="alert alert-warning">
|
||||
<span class="alert-icon">⚠</span>
|
||||
<div class="alert-body">
|
||||
<div class="alert-title">跨域 CORS 未配置</div>
|
||||
<div class="alert-desc">同源可用,跨域请求会被 403。在下方「跨域 CORS」卡片中配置允许的来源。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-grid">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<div class="card-title">鉴权 Key</div>
|
||||
<div class="card-sub">客户端调用 <code>/v1/audio/speech</code> 与登录 /admin 共用</div>
|
||||
</div>
|
||||
<span class="pill pill-accent">敏感</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>当前 Key</label>
|
||||
<div class="input-static">{{ settings.auth_key || '(未设置)' }}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>替换为新 Key</label>
|
||||
<div class="input-group">
|
||||
<input type="password" v-model="authKeyInput" :placeholder="settings.auth_key_set ? '不改留空' : '输入新 Key'">
|
||||
<button class="btn btn-primary" @click="saveAuthKey" :disabled="!authKeyInput">更新</button>
|
||||
</div>
|
||||
<div class="hint">改了之后,你的浏览器 401 自动跳回登录页,用新 Key 重登。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<div class="card-title">火山 TTS 上游凭证</div>
|
||||
<div class="card-sub">调火山 v3 上游用的凭证与默认路由</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>当前 API Key</label>
|
||||
<div class="input-static">{{ settings.api_key || '(未设置)' }}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>替换为新 API Key</label>
|
||||
<div class="input-group">
|
||||
<input type="password" v-model="apiKeyInput" :placeholder="settings.api_key_set ? '不改留空' : '输入新 API Key'">
|
||||
<button class="btn btn-primary" @click="saveApiKey" :disabled="!apiKeyInput">更新</button>
|
||||
</div>
|
||||
<div class="hint">独立端点 PUT /api/settings/api-key,与其他字段分开传输</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label>默认资源 ID</label>
|
||||
<input v-model="settingsForm.default_resource_id" placeholder="seed-icl-2.0">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>默认音色 (name)</label>
|
||||
<select v-model="settingsForm.default_speaker">
|
||||
<option value="">— 选择 —</option>
|
||||
<option v-for="v in voices" :key="v.name" :value="v.name">{{ v.name }}</option>
|
||||
</select>
|
||||
<div class="hint">必须是「音色」tab 里存在的 name</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>默认输出格式</label>
|
||||
<select v-model="settingsForm.default_format">
|
||||
<option value="mp3">mp3</option>
|
||||
<option value="wav">wav</option>
|
||||
<option value="opus">opus (ogg_opus)</option>
|
||||
<option value="pcm">pcm</option>
|
||||
<option value="aac">aac</option>
|
||||
<option value="flac">flac</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>采样率 (Hz)</label>
|
||||
<input type="number" v-model.number="settingsForm.sample_rate" min="8000" max="48000" step="1000">
|
||||
</div>
|
||||
<div class="field" style="grid-column: 1 / -1;">
|
||||
<label>Model (可选)</label>
|
||||
<input v-model="settingsForm.model" placeholder="seed-tts-2.0-standard">
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn" @click="resetSettingsForm">重置</button>
|
||||
<button class="btn btn-primary" @click="saveSettings" :disabled="savingSettings">{{ savingSettings ? '保存中...' : '保存设置' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<div class="card-title">跨域 CORS</div>
|
||||
<div class="card-sub">控制哪些前端域名能跨域调 <code>/v1/audio/speech</code></div>
|
||||
</div>
|
||||
<span class="pill" :class="settings.cors_configured ? 'pill-success' : 'pill-mute'">
|
||||
<span class="pill-dot"></span>
|
||||
{{ settings.cors_configured ? '已配置' : '未配置' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="check">
|
||||
<input type="checkbox" v-model="corsForm.allow_all">
|
||||
<span>允许所有来源 (<code>*</code>)</span>
|
||||
</label>
|
||||
<div class="hint">勾上后下面白名单失效。仅测试用</div>
|
||||
</div>
|
||||
<div class="field" v-if="!corsForm.allow_all">
|
||||
<label>允许的来源</label>
|
||||
<textarea v-model="corsForm.origins" rows="4" placeholder="https://app.example.com https://admin.example.com" class="textarea"></textarea>
|
||||
<div class="hint">一行一个。当前: <span class="text-accent mono">{{ corsCurrentLabel }}</span></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" @click="saveCors" :disabled="savingCors">{{ savingCors ? '保存中...' : '保存 CORS' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/admin/admin-shell.js"></script>
|
||||
<script>
|
||||
const { createApp, ref, computed, onMounted } = Vue;
|
||||
const { http, getKey, setKey, clearKey, toast, mountShell } = window.admin;
|
||||
createApp({
|
||||
setup() {
|
||||
const overview = ref({});
|
||||
const voices = ref([]);
|
||||
const settings = ref({});
|
||||
const settingsForm = ref({ default_resource_id: '', default_speaker: '', default_format: 'mp3', sample_rate: 24000, model: '' });
|
||||
const apiKeyInput = ref('');
|
||||
const authKeyInput = ref('');
|
||||
|
||||
const corsForm = ref({ allow_all: false, origins: '' });
|
||||
const savingCors = ref(false);
|
||||
const corsCurrentLabel = computed(() => {
|
||||
if (!settings.value) return '未配置';
|
||||
if (settings.value.cors_allow_all) return '允许所有来源(*)';
|
||||
if (settings.value.cors_origins) return settings.value.cors_origins;
|
||||
return '未配置(同源可用,跨域会被 403)';
|
||||
});
|
||||
|
||||
const savingSettings = ref(false);
|
||||
|
||||
const loadSettings = async () => {
|
||||
const r = await http.get('/settings');
|
||||
settings.value = r.data;
|
||||
settingsForm.value = {
|
||||
default_resource_id: r.data.default_resource_id || '',
|
||||
default_speaker: r.data.default_speaker || '',
|
||||
default_format: r.data.default_format || 'mp3',
|
||||
sample_rate: r.data.sample_rate || 24000,
|
||||
model: r.data.model || '',
|
||||
};
|
||||
corsForm.value = {
|
||||
allow_all: !!r.data.cors_allow_all,
|
||||
origins: r.data.cors_origins || '',
|
||||
};
|
||||
};
|
||||
const loadVoices = async () => {
|
||||
const r = await http.get('/voices');
|
||||
voices.value = r.data.voices || [];
|
||||
};
|
||||
const loadOverview = async () => {
|
||||
overview.value = (await http.get('/admin/overview')).data;
|
||||
};
|
||||
const reloadAll = () => { loadSettings(); loadVoices(); loadOverview(); };
|
||||
|
||||
const saveCors = async () => {
|
||||
savingCors.value = true;
|
||||
try {
|
||||
await http.put('/settings/cors', { allow_all: corsForm.value.allow_all, origins: corsForm.value.origins });
|
||||
await loadSettings();
|
||||
toast('ok', 'CORS 配置已保存');
|
||||
} catch (e) {
|
||||
toast('err', '保存 CORS 失败: ' + (e.response?.data?.error?.message || e.message));
|
||||
} finally { savingCors.value = false; }
|
||||
};
|
||||
const saveSettings = async () => {
|
||||
savingSettings.value = true;
|
||||
try {
|
||||
await http.put('/settings', {
|
||||
default_resource_id: settingsForm.value.default_resource_id,
|
||||
default_speaker: settingsForm.value.default_speaker,
|
||||
default_format: settingsForm.value.default_format,
|
||||
sample_rate: settingsForm.value.sample_rate,
|
||||
model: settingsForm.value.model,
|
||||
});
|
||||
await loadSettings();
|
||||
await loadOverview();
|
||||
toast('ok', '设置已保存');
|
||||
} catch (e) {
|
||||
toast('err', '保存失败: ' + (e.response?.data?.error?.message || e.message));
|
||||
} finally { savingSettings.value = false; }
|
||||
};
|
||||
const saveApiKey = async () => {
|
||||
if (!apiKeyInput.value) return;
|
||||
try {
|
||||
await http.put('/settings/api-key', { api_key: apiKeyInput.value });
|
||||
apiKeyInput.value = '';
|
||||
await loadSettings();
|
||||
toast('ok', 'API Key 已更新');
|
||||
} catch (e) {
|
||||
toast('err', '更新失败: ' + (e.response?.data?.error?.message || e.message));
|
||||
}
|
||||
};
|
||||
const saveAuthKey = async () => {
|
||||
if (!authKeyInput.value) return;
|
||||
try {
|
||||
await http.put('/settings/auth-key', { auth_key: authKeyInput.value });
|
||||
authKeyInput.value = '';
|
||||
setTimeout(() => {
|
||||
clearKey();
|
||||
location.href = '/admin/login';
|
||||
}, 200);
|
||||
toast('ok', 'Auth Key 已更新,请用新 Key 重新登录');
|
||||
} catch (e) {
|
||||
toast('err', '更新失败: ' + (e.response?.data?.error?.message || e.message));
|
||||
}
|
||||
};
|
||||
const resetSettingsForm = () => { loadSettings(); };
|
||||
|
||||
onMounted(() => {
|
||||
if (!getKey()) { location.href = '/admin/login'; return; }
|
||||
mountShell({
|
||||
activeNav: 'settings',
|
||||
overview: overview.value,
|
||||
corsConfigured: !!(settings.value && settings.value.cors_configured),
|
||||
onReload: reloadAll,
|
||||
});
|
||||
reloadAll();
|
||||
});
|
||||
|
||||
return {
|
||||
overview, voices, settings, settingsForm, apiKeyInput, authKeyInput,
|
||||
savingSettings, savingCors,
|
||||
corsForm, corsCurrentLabel, saveCors,
|
||||
saveSettings, saveApiKey, saveAuthKey, resetSettingsForm,
|
||||
reloadAll,
|
||||
};
|
||||
},
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,170 @@
|
||||
/* admin 共享脚本 — 多个 admin-* 页面共用
|
||||
加载方式: <script src="/admin/admin-shell.js"></script>
|
||||
暴露到 window.admin: { http, getKey, setKey, clearKey, toast, dismissToast, mountShell,
|
||||
formatUptime, shortPath, formatBytes, formatStart, logout }
|
||||
用法:
|
||||
const { http, toast, mountShell } = window.admin;
|
||||
const { apiKey, overview } = mountShell({ activeNav: 'voices' });
|
||||
*/
|
||||
(function () {
|
||||
const KEY_STORAGE = 'ttsAdminKey';
|
||||
|
||||
// === Key 持久化(sessionStorage,跨页面同标签页共享)===
|
||||
const getKey = () => sessionStorage.getItem(KEY_STORAGE) || '';
|
||||
const setKey = (k) => { if (k) sessionStorage.setItem(KEY_STORAGE, k); else sessionStorage.removeItem(KEY_STORAGE); };
|
||||
const clearKey = () => sessionStorage.removeItem(KEY_STORAGE);
|
||||
|
||||
// === HTTP client(401 自动清 key,业务层 redirect 到登录)===
|
||||
const http = axios.create({ baseURL: '/api' });
|
||||
http.interceptors.request.use(c => {
|
||||
const k = getKey();
|
||||
if (k) c.headers.Authorization = 'Bearer ' + k;
|
||||
return c;
|
||||
});
|
||||
http.interceptors.response.use(r => r, err => {
|
||||
if (err.response && err.response.status === 401) {
|
||||
clearKey();
|
||||
}
|
||||
return Promise.reject(err);
|
||||
});
|
||||
|
||||
// === Toasts(全局单例)===
|
||||
let toasts = [];
|
||||
let toastSeq = 0;
|
||||
let toastStackEl = null;
|
||||
const ensureToastStack = () => {
|
||||
if (toastStackEl) return toastStackEl;
|
||||
toastStackEl = document.createElement('div');
|
||||
toastStackEl.className = 'toast-stack';
|
||||
document.body.appendChild(toastStackEl);
|
||||
return toastStackEl;
|
||||
};
|
||||
const renderToasts = () => {
|
||||
const stack = ensureToastStack();
|
||||
stack.innerHTML = toasts.map(t => `
|
||||
<div class="toast toast-${t.type}">
|
||||
<span class="toast-icon">${t.type === 'ok' ? '✓' : t.type === 'err' ? '!' : '⚠'}</span>
|
||||
<span class="toast-msg">${escapeHTML(t.msg)}</span>
|
||||
<button class="toast-close" data-id="${t.id}">×</button>
|
||||
</div>
|
||||
`).join('');
|
||||
stack.querySelectorAll('.toast-close').forEach(btn => {
|
||||
btn.addEventListener('click', () => dismissToast(Number(btn.dataset.id)));
|
||||
});
|
||||
};
|
||||
const toast = (type, msg, ttl) => {
|
||||
const id = ++toastSeq;
|
||||
toasts.push({ id, type, msg });
|
||||
renderToasts();
|
||||
if (ttl !== 0) setTimeout(() => dismissToast(id), ttl || 3200);
|
||||
};
|
||||
const dismissToast = (id) => {
|
||||
toasts = toasts.filter(t => t.id !== id);
|
||||
renderToasts();
|
||||
};
|
||||
|
||||
// === HTML escape(toast msg 兜底)===
|
||||
const escapeHTML = (s) => String(s).replace(/[&<>"']/g, c => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
|
||||
// === format helpers ===
|
||||
const formatUptime = (s) => {
|
||||
if (!s) return '—';
|
||||
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
};
|
||||
const shortPath = (p) => p ? p.split(/[\\/]/).pop() : '';
|
||||
const formatBytes = (n) => {
|
||||
if (n === null || n === undefined) return '—';
|
||||
if (n < 1024) return n + ' B';
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
||||
return (n / 1024 / 1024).toFixed(2) + ' MB';
|
||||
};
|
||||
const formatStart = (iso) => {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
} catch (e) { return '—'; }
|
||||
};
|
||||
|
||||
// === Sidebar 渲染(每个 admin-* 页面共用)===
|
||||
// 占位符 <aside id="admin-sidebar"></aside> 由 shell 填入。
|
||||
// options: { activeNav: 'dashboard' | 'voices' | 'settings', overview, voiceCount, corsConfigured, onReload, onLogout }
|
||||
const mountShell = (options = {}) => {
|
||||
const slot = document.getElementById('admin-sidebar');
|
||||
if (!slot) return {};
|
||||
const activeNav = options.activeNav || '';
|
||||
const ov = options.overview || {};
|
||||
const voiceCount = options.voiceCount != null ? options.voiceCount : '';
|
||||
const corsConfigured = !!options.corsConfigured;
|
||||
slot.outerHTML = `
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand-logo">TTS</div>
|
||||
<div>
|
||||
<div class="brand-name">控制台</div>
|
||||
<div class="brand-version">v${escapeHTML(ov.version || '—')} · ${escapeHTML(ov.mode || '...')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<div class="nav-group">
|
||||
<div class="nav-label">概览</div>
|
||||
<a class="nav-item${activeNav === 'dashboard' ? ' active' : ''}" href="/admin">
|
||||
<span class="nav-icon">▦</span><span>仪表盘</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-group">
|
||||
<div class="nav-label">管理</div>
|
||||
<a class="nav-item${activeNav === 'voices' ? ' active' : ''}" href="/admin/voices">
|
||||
<span class="nav-icon">♪</span><span>音色</span>
|
||||
<span class="nav-badge">${voiceCount}</span>
|
||||
</a>
|
||||
<a class="nav-item${activeNav === 'settings' ? ' active' : ''}" href="/admin/settings">
|
||||
<span class="nav-icon">⚙</span><span>设置</span>
|
||||
${corsConfigured ? '' : '<span class="nav-dot" title="CORS 未配置"></span>'}
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sidebar-foot">
|
||||
<button class="btn-icon" id="btn-reload" title="刷新">↻ 刷新</button>
|
||||
<button class="btn-icon" id="btn-logout" title="登出">⏻ 登出</button>
|
||||
</div>
|
||||
</aside>`;
|
||||
const btnReload = document.getElementById('btn-reload');
|
||||
const btnLogout = document.getElementById('btn-logout');
|
||||
if (btnReload) btnReload.addEventListener('click', () => {
|
||||
if (typeof options.onReload === 'function') options.onReload();
|
||||
else location.reload();
|
||||
});
|
||||
if (btnLogout) btnLogout.addEventListener('click', () => {
|
||||
clearKey();
|
||||
if (typeof options.onLogout === 'function') options.onLogout();
|
||||
else location.href = '/admin/login';
|
||||
});
|
||||
return {};
|
||||
};
|
||||
|
||||
// === Login helper(login 页用)===
|
||||
const login = async (key) => {
|
||||
setKey(key);
|
||||
try {
|
||||
await http.get('/admin/overview');
|
||||
return true;
|
||||
} catch (e) {
|
||||
clearKey();
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
// 暴露
|
||||
window.admin = {
|
||||
http, getKey, setKey, clearKey,
|
||||
toast, dismissToast,
|
||||
mountShell, login,
|
||||
formatUptime, shortPath, formatBytes, formatStart,
|
||||
escapeHTML,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,260 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>火山 TTS · 音色管理</title>
|
||||
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
|
||||
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
|
||||
<link rel="stylesheet" href="/admin/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="app-shell">
|
||||
<aside id="admin-sidebar"></aside>
|
||||
|
||||
<main class="main">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h1 class="page-title">音色管理</h1>
|
||||
<p class="page-sub">管理火山 TTS 音色及其启用状态</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" @click="openAdd">+ 新增音色</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<div class="search">
|
||||
<span class="search-icon">⌕</span>
|
||||
<input v-model="voiceSearch" placeholder="搜索名称、Speaker、资源 ID...">
|
||||
</div>
|
||||
<div class="seg">
|
||||
<button class="seg-btn" :class="{active: voiceFilter===null}" @click="voiceFilter=null">全部</button>
|
||||
<button class="seg-btn" :class="{active: voiceFilter==='on'}" @click="voiceFilter='on'">启用</button>
|
||||
<button class="seg-btn" :class="{active: voiceFilter==='off'}" @click="voiceFilter='off'">停用</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<span v-if="voiceSearch || voiceFilter !== null">{{ filteredVoices.length }} / {{ voices.length }}</span>
|
||||
<span v-else>共 {{ voices.length }} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="actionErr" class="alert alert-danger">
|
||||
<span class="alert-icon">⚠</span>
|
||||
<div class="alert-body">
|
||||
<div class="alert-title">操作失败</div>
|
||||
<div class="alert-desc">{{ actionErr }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="data-card">
|
||||
<table class="data-table" v-if="filteredVoices.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>Speaker</th>
|
||||
<th>资源 ID</th>
|
||||
<th>Model</th>
|
||||
<th>状态</th>
|
||||
<th class="th-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="v in filteredVoices" :key="v.name">
|
||||
<td>
|
||||
<div class="cell-name">{{ v.name }}</div>
|
||||
<div v-if="v.description" class="cell-sub">{{ v.description }}</div>
|
||||
</td>
|
||||
<td><span class="mono text-dim">{{ v.speaker }}</span></td>
|
||||
<td><span class="mono text-dim">{{ v.resource_id }}</span></td>
|
||||
<td><span class="mono text-dim">{{ v.model || '—' }}</span></td>
|
||||
<td>
|
||||
<span class="pill" :class="v.enabled ? 'pill-success' : 'pill-mute'">
|
||||
<span class="pill-dot"></span>
|
||||
{{ v.enabled ? '启用' : '停用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="th-actions">
|
||||
<label class="switch" :title="v.enabled ? '点击停用' : '点击启用'" style="margin-right: 12px;">
|
||||
<input type="checkbox" :checked="v.enabled" @change="toggle(v)">
|
||||
<span class="switch-track"></span>
|
||||
</label>
|
||||
<button class="btn btn-sm btn-danger-ghost" @click="remove(v)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-else-if="voices.length === 0" class="empty">
|
||||
<div class="empty-icon">♪</div>
|
||||
<div class="empty-title">还没有音色</div>
|
||||
<div class="empty-desc">点击右上角「+ 新增音色」开始添加第一个火山 TTS 音色</div>
|
||||
<button class="btn btn-primary" @click="openAdd">+ 新增音色</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty">
|
||||
<div class="empty-icon">⌕</div>
|
||||
<div class="empty-title">没有匹配的音色</div>
|
||||
<div class="empty-desc">尝试调整搜索词或筛选条件</div>
|
||||
<button class="btn" @click="voiceSearch=''; voiceFilter=null">清除筛选</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Add modal -->
|
||||
<teleport to="body">
|
||||
<transition name="modal">
|
||||
<div v-if="showAdd" class="modal-backdrop" @click.self="showAdd=false">
|
||||
<div class="modal" @click.stop>
|
||||
<div class="modal-head">
|
||||
<h2>新增音色</h2>
|
||||
<button class="btn-icon" @click="showAdd=false" title="关闭" style="flex: 0; padding: 4px 10px;">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label>对外名 (voice) <span class="req">*</span></label>
|
||||
<input v-model="form.name" placeholder="qian" class="mono">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Speaker (火山音色 ID) <span class="req">*</span></label>
|
||||
<input v-model="form.speaker" placeholder="S_xxx" class="mono">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>资源 ID <span class="req">*</span></label>
|
||||
<input v-model="form.resource_id" placeholder="seed-icl-2.0" class="mono">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Model</label>
|
||||
<input v-model="form.model" placeholder="seed-tts-2.0-standard" class="mono">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Language</label>
|
||||
<input v-model="form.language" placeholder="zh" class="mono">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Description</label>
|
||||
<input v-model="form.description" placeholder="可选">
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="addErr" class="alert alert-danger" style="margin-top: 14px;">
|
||||
<span class="alert-icon">⚠</span>
|
||||
<div class="alert-body">
|
||||
<div class="alert-title">提交失败</div>
|
||||
<div class="alert-desc">{{ addErr }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn" @click="showAdd=false">取消</button>
|
||||
<button class="btn btn-primary" @click="submitAdd" :disabled="adding">{{ adding ? '提交中...' : '提交' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</teleport>
|
||||
</div>
|
||||
|
||||
<script src="/admin/admin-shell.js"></script>
|
||||
<script>
|
||||
const { createApp, ref, computed, onMounted } = Vue;
|
||||
const { http, getKey, toast, mountShell } = window.admin;
|
||||
createApp({
|
||||
setup() {
|
||||
const overview = ref({});
|
||||
const voices = ref([]);
|
||||
const actionErr = ref('');
|
||||
|
||||
const voiceSearch = ref('');
|
||||
const voiceFilter = ref(null);
|
||||
const filteredVoices = computed(() => {
|
||||
const q = voiceSearch.value.trim().toLowerCase();
|
||||
return voices.value.filter(v => {
|
||||
if (voiceFilter.value === 'on' && !v.enabled) return false;
|
||||
if (voiceFilter.value === 'off' && v.enabled) return false;
|
||||
if (!q) return true;
|
||||
return (v.name || '').toLowerCase().includes(q)
|
||||
|| (v.speaker || '').toLowerCase().includes(q)
|
||||
|| (v.resource_id || '').toLowerCase().includes(q);
|
||||
});
|
||||
});
|
||||
|
||||
const showAdd = ref(false);
|
||||
const adding = ref(false);
|
||||
const addErr = ref('');
|
||||
const form = ref({ name: '', speaker: '', resource_id: 'seed-icl-2.0', model: 'seed-tts-2.0-standard', language: '', description: '' });
|
||||
|
||||
const loadOverview = async () => {
|
||||
try { overview.value = (await http.get('/admin/overview')).data; }
|
||||
catch (e) { actionErr.value = '加载概览失败: ' + e.message; }
|
||||
};
|
||||
const loadVoices = async () => {
|
||||
try { const r = await http.get('/voices'); voices.value = r.data.voices || []; }
|
||||
catch (e) { actionErr.value = '加载音色失败: ' + e.message; }
|
||||
};
|
||||
const reloadAll = () => { loadOverview(); loadVoices(); };
|
||||
|
||||
const openAdd = () => {
|
||||
form.value = { name: '', speaker: '', resource_id: 'seed-icl-2.0', model: 'seed-tts-2.0-standard', language: '', description: '' };
|
||||
addErr.value = '';
|
||||
showAdd.value = true;
|
||||
};
|
||||
const submitAdd = async () => {
|
||||
addErr.value = '';
|
||||
if (!form.value.name || !form.value.speaker || !form.value.resource_id) {
|
||||
addErr.value = '请填写名称、Speaker、资源 ID';
|
||||
return;
|
||||
}
|
||||
adding.value = true;
|
||||
try {
|
||||
await http.post('/voices', form.value);
|
||||
showAdd.value = false;
|
||||
await loadVoices();
|
||||
await loadOverview();
|
||||
toast('ok', '音色已添加');
|
||||
} catch (e) {
|
||||
addErr.value = e.response?.data?.error?.message || e.message;
|
||||
} finally { adding.value = false; }
|
||||
};
|
||||
const toggle = async (v) => {
|
||||
try { await http.patch(`/voices/${v.name}/toggle`, { enabled: !v.enabled }); await loadVoices(); }
|
||||
catch (e) {
|
||||
actionErr.value = '切换失败: ' + (e.response?.data?.error?.message || e.message);
|
||||
toast('err', actionErr.value);
|
||||
}
|
||||
};
|
||||
const remove = async (v) => {
|
||||
if (!confirm(`确定删除音色 "${v.name}" 吗?`)) return;
|
||||
try { await http.delete(`/voices/${v.name}`); await loadVoices(); await loadOverview(); toast('ok', '音色已删除'); }
|
||||
catch (e) {
|
||||
actionErr.value = '删除失败: ' + (e.response?.data?.error?.message || e.message);
|
||||
toast('err', actionErr.value);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (!getKey()) { location.href = '/admin/login'; return; }
|
||||
mountShell({
|
||||
activeNav: 'voices',
|
||||
overview: overview.value,
|
||||
voiceCount: voices.value.length,
|
||||
onReload: reloadAll,
|
||||
});
|
||||
reloadAll();
|
||||
});
|
||||
|
||||
return {
|
||||
overview, voices, actionErr,
|
||||
voiceSearch, voiceFilter, filteredVoices,
|
||||
showAdd, form, addErr, adding, openAdd, submitAdd, toggle, remove,
|
||||
reloadAll,
|
||||
};
|
||||
},
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,415 @@
|
||||
/* admin 共享样式 — 多个 admin-* 页面共用
|
||||
加载方式: <link rel="stylesheet" href="/admin/admin.css">
|
||||
*/
|
||||
|
||||
:root {
|
||||
--bg: #0a0e1a;
|
||||
--bg-sidebar: #0c111e;
|
||||
--bg-card: #131826;
|
||||
--bg-elev: #1a2138;
|
||||
--bg-hover: #1f2740;
|
||||
--border: #232b42;
|
||||
--border-strong: #2e3754;
|
||||
--text: #e4e7ee;
|
||||
--text-dim: #94a3b8;
|
||||
--text-mute: #64748b;
|
||||
--accent: #00d4ff;
|
||||
--accent-hover: #33ddff;
|
||||
--accent-2: #7b2ff7;
|
||||
--accent-soft: rgba(0, 212, 255, 0.10);
|
||||
--accent-glow: rgba(0, 212, 255, 0.35);
|
||||
--success: #22c55e;
|
||||
--success-soft: rgba(34, 197, 94, 0.12);
|
||||
--warning: #f59e0b;
|
||||
--warning-soft: rgba(245, 158, 11, 0.12);
|
||||
--danger: #ef4444;
|
||||
--danger-soft: rgba(239, 68, 68, 0.12);
|
||||
--radius: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.4);
|
||||
--shadow: 0 4px 16px rgba(0,0,0,0.25);
|
||||
--shadow-lg: 0 20px 50px rgba(0,0,0,0.5);
|
||||
--sidebar-w: 240px;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
background-image:
|
||||
radial-gradient(ellipse 70% 50% at 20% 0%, rgba(0,212,255,0.05), transparent),
|
||||
radial-gradient(ellipse 50% 40% at 100% 100%, rgba(123,47,247,0.04), transparent);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
#app { min-height: 100vh; }
|
||||
|
||||
/* === Login === */
|
||||
.login-screen {
|
||||
min-height: 100vh;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 24px; position: relative;
|
||||
}
|
||||
.login-screen::before {
|
||||
content: ''; position: absolute; inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 30% 20%, rgba(0,212,255,0.12), transparent 40%),
|
||||
radial-gradient(circle at 70% 80%, rgba(123,47,247,0.10), transparent 40%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.login-card {
|
||||
position: relative;
|
||||
width: 100%; max-width: 400px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 36px 32px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.login-logo {
|
||||
width: 52px; height: 52px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; font-size: 18px; color: #fff;
|
||||
margin: 0 auto 20px;
|
||||
box-shadow: 0 6px 24px var(--accent-glow);
|
||||
}
|
||||
.login-card h1 { font-size: 22px; font-weight: 600; text-align: center; margin-bottom: 6px; }
|
||||
.login-card .sub { color: var(--text-dim); font-size: 13px; text-align: center; margin-bottom: 24px; }
|
||||
.login-input {
|
||||
width: 100%; padding: 12px 14px; margin-bottom: 14px;
|
||||
background: var(--bg-elev); border: 1px solid var(--border);
|
||||
color: var(--text); border-radius: var(--radius); font-size: 14px;
|
||||
font-family: inherit; transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.login-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
.login-err {
|
||||
margin-top: 12px; padding: 10px 12px;
|
||||
background: var(--danger-soft); border: 1px solid rgba(239,68,68,0.3);
|
||||
color: var(--danger); border-radius: var(--radius); font-size: 12px;
|
||||
}
|
||||
|
||||
/* === App shell === */
|
||||
.app-shell { display: flex; min-height: 100vh; }
|
||||
.sidebar {
|
||||
width: var(--sidebar-w);
|
||||
background: var(--bg-sidebar);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column;
|
||||
position: sticky; top: 0; height: 100vh;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 12px; padding: 20px 18px; border-bottom: 1px solid var(--border); }
|
||||
.brand-logo {
|
||||
width: 36px; height: 36px; border-radius: 10px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; font-size: 13px; color: #fff;
|
||||
box-shadow: 0 2px 12px var(--accent-glow);
|
||||
}
|
||||
.brand-name { font-size: 14px; font-weight: 600; }
|
||||
.brand-version { font-size: 11px; color: var(--text-mute); margin-top: 2px; font-family: ui-monospace, monospace; }
|
||||
|
||||
.nav { flex: 1; padding: 16px 12px; overflow-y: auto; }
|
||||
.nav-group { margin-bottom: 20px; }
|
||||
.nav-label {
|
||||
font-size: 10px; font-weight: 600; color: var(--text-mute);
|
||||
text-transform: uppercase; letter-spacing: 0.8px;
|
||||
padding: 0 10px 8px;
|
||||
}
|
||||
.nav-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 9px 10px; border-radius: var(--radius);
|
||||
color: var(--text-dim); font-size: 13px;
|
||||
cursor: pointer; user-select: none;
|
||||
margin-bottom: 2px;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-item:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.nav-item.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
.nav-item.active::before {
|
||||
content: ''; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
|
||||
width: 3px; height: 16px; background: var(--accent); border-radius: 0 2px 2px 0;
|
||||
}
|
||||
.nav-icon { font-size: 16px; width: 18px; text-align: center; flex-shrink: 0; }
|
||||
.nav-badge {
|
||||
margin-left: auto; padding: 1px 7px; border-radius: 10px;
|
||||
background: var(--bg-hover); font-size: 11px; font-weight: 500;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.nav-item.active .nav-badge { background: rgba(0,212,255,0.2); color: var(--accent); }
|
||||
.nav-dot {
|
||||
margin-left: auto; width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--warning); box-shadow: 0 0 0 3px rgba(245,158,11,0.2);
|
||||
}
|
||||
|
||||
.sidebar-foot {
|
||||
display: flex; gap: 4px; padding: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.btn-icon {
|
||||
flex: 1; padding: 8px; background: transparent;
|
||||
border: 1px solid transparent; border-radius: var(--radius);
|
||||
color: var(--text-dim); cursor: pointer; font-size: 13px;
|
||||
font-family: inherit;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.btn-icon:hover { background: var(--bg-hover); color: var(--text); border-color: var(--border); }
|
||||
|
||||
/* === Main === */
|
||||
.main { flex: 1; min-width: 0; padding: 28px 32px; max-width: 1200px; }
|
||||
.page-head {
|
||||
display: flex; justify-content: space-between; align-items: flex-end;
|
||||
margin-bottom: 24px; gap: 16px; flex-wrap: wrap;
|
||||
}
|
||||
.page-title { font-size: 22px; font-weight: 600; letter-spacing: -0.01em; }
|
||||
.page-sub { color: var(--text-dim); font-size: 13px; margin-top: 4px; }
|
||||
.page-actions { display: flex; gap: 8px; align-items: center; }
|
||||
|
||||
/* === Buttons === */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 8px 14px; background: var(--bg-elev); border: 1px solid var(--border);
|
||||
color: var(--text); border-radius: var(--radius); cursor: pointer;
|
||||
font-size: 13px; font-weight: 500; font-family: inherit;
|
||||
transition: all 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:hover:not(:disabled) { border-color: var(--border-strong); background: var(--bg-hover); }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-primary { background: var(--accent); color: #00121a; border-color: var(--accent); }
|
||||
.btn-primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||
.btn-danger { color: var(--danger); border-color: var(--border); }
|
||||
.btn-danger:hover:not(:disabled) { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); }
|
||||
.btn-danger-ghost { color: var(--text-mute); border-color: transparent; background: transparent; padding: 5px 10px; }
|
||||
.btn-danger-ghost:hover:not(:disabled) { background: var(--danger-soft); color: var(--danger); border-color: transparent; }
|
||||
.btn-sm { padding: 6px 10px; font-size: 12px; }
|
||||
.btn-lg { padding: 11px 16px; font-size: 14px; }
|
||||
|
||||
/* === Card === */
|
||||
.card {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); padding: 20px; margin-bottom: 16px;
|
||||
}
|
||||
.card-head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; gap: 12px; }
|
||||
.card-title { font-size: 15px; font-weight: 600; }
|
||||
.card-sub { font-size: 12px; color: var(--text-dim); margin-top: 2px; }
|
||||
|
||||
/* === Stat grid === */
|
||||
.stat-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 14px; margin-bottom: 20px;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); padding: 18px 20px;
|
||||
transition: border-color 0.15s;
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.stat-card::before {
|
||||
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--border-strong), transparent);
|
||||
}
|
||||
.stat-card:hover { border-color: var(--border-strong); }
|
||||
.stat-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.stat-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; font-weight: 500; }
|
||||
.stat-value { font-size: 26px; font-weight: 700; letter-spacing: -0.02em; line-height: 1.1; }
|
||||
.stat-value .stat-unit { font-size: 14px; color: var(--text-mute); font-weight: 500; margin-left: 4px; }
|
||||
.stat-foot { font-size: 11px; color: var(--text-dim); margin-top: 6px; }
|
||||
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 2px 8px; border-radius: 999px;
|
||||
font-size: 11px; font-weight: 500;
|
||||
background: var(--bg-elev); color: var(--text-dim);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.pill-dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
|
||||
.pill-success { background: var(--success-soft); color: var(--success); border-color: rgba(34,197,94,0.3); }
|
||||
.pill-accent { background: var(--accent-soft); color: var(--accent); border-color: rgba(0,212,255,0.3); }
|
||||
.pill-warning { background: var(--warning-soft); color: var(--warning); border-color: rgba(245,158,11,0.3); }
|
||||
.pill-mute { background: var(--bg-elev); color: var(--text-mute); }
|
||||
|
||||
/* === KV list === */
|
||||
.kv { display: grid; grid-template-columns: 1fr 1fr; gap: 0; }
|
||||
.kv-row { display: contents; }
|
||||
.kv-row > dt { padding: 10px 16px 10px 0; font-size: 12px; color: var(--text-dim); border-bottom: 1px solid var(--border); }
|
||||
.kv-row > dd { padding: 10px 0; font-size: 13px; border-bottom: 1px solid var(--border); word-break: break-all; }
|
||||
.kv-row:last-child > dt, .kv-row:last-child > dd { border-bottom: none; }
|
||||
|
||||
/* === Toolbar === */
|
||||
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; gap: 12px; flex-wrap: wrap; }
|
||||
.toolbar-left { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
|
||||
.toolbar-right { font-size: 12px; color: var(--text-dim); }
|
||||
|
||||
.search { position: relative; flex: 0 0 280px; max-width: 100%; }
|
||||
.search input {
|
||||
width: 100%; padding: 8px 12px 8px 32px;
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
color: var(--text); border-radius: var(--radius); font-size: 13px;
|
||||
font-family: inherit; transition: border-color 0.15s;
|
||||
}
|
||||
.search input::placeholder { color: var(--text-mute); }
|
||||
.search input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
.search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--text-mute); font-size: 14px; pointer-events: none; }
|
||||
|
||||
.seg { display: inline-flex; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 2px; }
|
||||
.seg-btn {
|
||||
padding: 5px 12px; background: transparent; border: none;
|
||||
color: var(--text-dim); cursor: pointer; font-size: 12px; font-family: inherit;
|
||||
border-radius: 6px; transition: all 0.12s;
|
||||
}
|
||||
.seg-btn:hover { color: var(--text); }
|
||||
.seg-btn.active { background: var(--bg-elev); color: var(--accent); }
|
||||
|
||||
/* === Data table === */
|
||||
.data-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
|
||||
.data-table { width: 100%; border-collapse: collapse; }
|
||||
.data-table th {
|
||||
text-align: left; padding: 12px 16px; font-size: 11px; font-weight: 600;
|
||||
color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px;
|
||||
background: var(--bg-elev); border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.data-table td {
|
||||
padding: 14px 16px; font-size: 13px;
|
||||
border-bottom: 1px solid var(--border); vertical-align: middle;
|
||||
}
|
||||
.data-table tbody tr { transition: background 0.1s; }
|
||||
.data-table tbody tr:hover { background: rgba(255,255,255,0.02); }
|
||||
.data-table tbody tr:last-child td { border-bottom: none; }
|
||||
.th-actions { text-align: right; width: 1%; white-space: nowrap; }
|
||||
.data-table td.th-actions { text-align: right; }
|
||||
.cell-name { font-weight: 500; }
|
||||
.cell-sub { font-size: 11px; color: var(--text-mute); margin-top: 2px; }
|
||||
|
||||
.empty { padding: 64px 24px; text-align: center; color: var(--text-dim); }
|
||||
.empty-icon { font-size: 36px; opacity: 0.3; margin-bottom: 12px; }
|
||||
.empty-title { font-size: 15px; font-weight: 500; color: var(--text); margin-bottom: 6px; }
|
||||
.empty-desc { font-size: 13px; margin-bottom: 16px; }
|
||||
|
||||
/* === Switch === */
|
||||
.switch { position: relative; display: inline-block; width: 36px; height: 20px; cursor: pointer; vertical-align: middle; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
.switch-track {
|
||||
position: absolute; inset: 0;
|
||||
background: var(--bg-elev); border: 1px solid var(--border);
|
||||
border-radius: 999px; transition: all 0.18s;
|
||||
}
|
||||
.switch-track::before {
|
||||
content: ''; position: absolute; top: 2px; left: 2px;
|
||||
width: 14px; height: 14px; border-radius: 50%;
|
||||
background: var(--text-dim); transition: all 0.18s;
|
||||
}
|
||||
.switch input:checked + .switch-track { background: var(--accent); border-color: var(--accent); }
|
||||
.switch input:checked + .switch-track::before { transform: translateX(16px); background: #00121a; }
|
||||
|
||||
/* === Forms === */
|
||||
.field { margin-bottom: 14px; }
|
||||
.field label { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 6px; font-weight: 500; }
|
||||
.field label .req { color: var(--danger); margin-left: 2px; }
|
||||
.field input, .field select, .field textarea, .input-static, .textarea {
|
||||
width: 100%; padding: 9px 12px;
|
||||
background: var(--bg-elev); border: 1px solid var(--border);
|
||||
color: var(--text); border-radius: var(--radius); font-size: 13px;
|
||||
font-family: inherit; transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.field input:focus, .field select:focus, .field textarea:focus, .textarea:focus {
|
||||
outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.field select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'><path fill='%238a92a6' d='M6 8L1 3h10z'/></svg>");
|
||||
background-repeat: no-repeat; background-position: right 10px center; padding-right: 30px;
|
||||
}
|
||||
.input-static { font-family: ui-monospace, SFMono-Regular, monospace; color: var(--accent); padding: 8px 12px; }
|
||||
.textarea { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 12px; line-height: 1.6; resize: vertical; }
|
||||
.input-group { display: flex; gap: 8px; }
|
||||
.input-group input { flex: 1; }
|
||||
.hint { font-size: 11px; color: var(--text-mute); margin-top: 5px; line-height: 1.5; }
|
||||
.check { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 13px; color: var(--text); }
|
||||
.check input[type=checkbox] { width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer; }
|
||||
.divider { height: 1px; background: var(--border); margin: 20px 0; }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.grid-2 .field { margin-bottom: 0; }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border); }
|
||||
code { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.9em; background: var(--bg-elev); padding: 1px 5px; border-radius: 4px; color: var(--accent); }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, monospace; }
|
||||
|
||||
/* === Settings grid === */
|
||||
.settings-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); gap: 16px; align-items: start; }
|
||||
.settings-grid .card { margin-bottom: 0; }
|
||||
|
||||
/* === Alert / Banner === */
|
||||
.alert { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-radius: var(--radius); margin-bottom: 16px; font-size: 13px; }
|
||||
.alert-icon { font-size: 16px; flex-shrink: 0; }
|
||||
.alert-body { flex: 1; }
|
||||
.alert-title { font-weight: 500; }
|
||||
.alert-desc { font-size: 12px; color: var(--text-dim); margin-top: 2px; }
|
||||
.alert-warning { background: var(--warning-soft); border: 1px solid rgba(245,158,11,0.3); color: var(--warning); }
|
||||
.alert-warning .alert-title { color: var(--warning); }
|
||||
.alert-danger { background: var(--danger-soft); border: 1px solid rgba(239,68,68,0.3); color: var(--danger); }
|
||||
|
||||
/* === Toasts === */
|
||||
.toast-stack { position: fixed; top: 16px; right: 16px; z-index: 9999; display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
|
||||
.toast {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 14px; min-width: 240px; max-width: 380px;
|
||||
background: var(--bg-card); border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius); box-shadow: var(--shadow-lg);
|
||||
font-size: 13px; pointer-events: auto;
|
||||
border-left-width: 3px;
|
||||
}
|
||||
.toast-ok { border-left-color: var(--success); }
|
||||
.toast-err { border-left-color: var(--danger); }
|
||||
.toast-warn { border-left-color: var(--warning); }
|
||||
.toast-icon { font-size: 14px; font-weight: 700; }
|
||||
.toast-ok .toast-icon { color: var(--success); }
|
||||
.toast-err .toast-icon { color: var(--danger); }
|
||||
.toast-warn .toast-icon { color: var(--warning); }
|
||||
.toast-msg { flex: 1; }
|
||||
.toast-close { background: none; border: none; color: var(--text-mute); cursor: pointer; font-size: 16px; padding: 0 4px; font-family: inherit; }
|
||||
.toast-close:hover { color: var(--text); }
|
||||
.toast-enter-active, .toast-leave-active { transition: all 0.22s ease; }
|
||||
.toast-enter-from { opacity: 0; transform: translateX(20px); }
|
||||
.toast-leave-to { opacity: 0; transform: translateX(20px); }
|
||||
|
||||
/* === Modal === */
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0; z-index: 100;
|
||||
background: rgba(5, 8, 16, 0.7); backdrop-filter: blur(4px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.modal {
|
||||
background: var(--bg-card); border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-lg); box-shadow: var(--shadow-lg);
|
||||
width: 100%; max-width: 540px; max-height: 90vh; overflow: auto;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.modal-head { display: flex; justify-content: space-between; align-items: center; padding: 20px 24px; border-bottom: 1px solid var(--border); }
|
||||
.modal-head h2 { font-size: 16px; font-weight: 600; }
|
||||
.modal-body { padding: 20px 24px; }
|
||||
.modal-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 16px 24px; border-top: 1px solid var(--border); background: var(--bg-elev); border-bottom-left-radius: var(--radius-lg); border-bottom-right-radius: var(--radius-lg); }
|
||||
.modal-enter-active, .modal-leave-active { transition: opacity 0.2s; }
|
||||
.modal-enter-active .modal, .modal-leave-active .modal { transition: transform 0.2s, opacity 0.2s; }
|
||||
.modal-enter-from, .modal-leave-to { opacity: 0; }
|
||||
.modal-enter-from .modal, .modal-leave-to .modal { transform: scale(0.95) translateY(-10px); opacity: 0; }
|
||||
|
||||
/* === Utility === */
|
||||
.text-dim { color: var(--text-dim); }
|
||||
.text-mute { color: var(--text-mute); }
|
||||
.text-accent { color: var(--accent); }
|
||||
.text-success { color: var(--success); }
|
||||
.text-danger { color: var(--danger); }
|
||||
+65
-1034
File diff suppressed because it is too large
Load Diff
+39
-3
@@ -21,6 +21,21 @@ var setupHTML []byte
|
||||
//go:embed admin.html
|
||||
var adminHTML []byte
|
||||
|
||||
//go:embed admin-login.html
|
||||
var adminLoginHTML []byte
|
||||
|
||||
//go:embed admin-voices.html
|
||||
var adminVoicesHTML []byte
|
||||
|
||||
//go:embed admin-settings.html
|
||||
var adminSettingsHTML []byte
|
||||
|
||||
//go:embed admin.css
|
||||
var adminCSS []byte
|
||||
|
||||
//go:embed admin-shell.js
|
||||
var adminShellJS []byte
|
||||
|
||||
// Setup 返回主路由。
|
||||
// 中间件顺序(由外向内):
|
||||
// SecurityHeaders → InstallGuard → RateLimit → ConcurrencyLimit → Logger → handler
|
||||
@@ -64,9 +79,21 @@ func Setup() *mux.Router {
|
||||
// /admin 管理后台(M2);HTML 本身公开,鉴权由前端 JS 拦截
|
||||
// (sessionStorage 没 key 就显示登录页;有 key 调 /api/admin/overview 触发 401 跳登录)
|
||||
// API 端点(/api/admin/* /api/voices*)才需要 RequireAdmin。
|
||||
r.HandleFunc("/admin", func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(adminHTML)
|
||||
//
|
||||
// 拆分后 admin.html / admin-login.html / admin-voices.html / admin-settings.html
|
||||
// 各自独立,URL 路由切换;共享 admin.css + admin-shell.js 由 adminStatic 提供。
|
||||
r.HandleFunc("/admin", serveAdmin(adminHTML)).Methods("GET")
|
||||
r.HandleFunc("/admin/login", serveAdmin(adminLoginHTML)).Methods("GET")
|
||||
r.HandleFunc("/admin/voices", serveAdmin(adminVoicesHTML)).Methods("GET")
|
||||
r.HandleFunc("/admin/settings", serveAdmin(adminSettingsHTML)).Methods("GET")
|
||||
// admin 静态资源
|
||||
r.HandleFunc("/admin/admin.css", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
_, _ = w.Write(adminCSS)
|
||||
}).Methods("GET")
|
||||
r.HandleFunc("/admin/admin-shell.js", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
_, _ = w.Write(adminShellJS)
|
||||
}).Methods("GET")
|
||||
|
||||
// /api/admin/overview (鉴权)
|
||||
@@ -132,3 +159,12 @@ func acceptsHTML(accept string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// serveAdmin 返回一个把 embed 的 HTML bytes 以 text/html 写出的 handler。
|
||||
// 抽出来只为让 /admin/voices 等多条路由的注册保持单行,避免重复 4 段。
|
||||
func serveAdmin(body []byte) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user