2026-05-25 17:06:22 +08:00

629 lines
24 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FLNXI 素材系统 - 管理面板</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 8px;
padding: 32px;
max-width: 520px;
width: 100%;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.header {
text-align: center;
margin-bottom: 24px;
}
h1 {
color: #333;
font-size: 20px;
font-weight: 600;
margin-bottom: 4px;
}
.subtitle {
color: #666;
font-size: 14px;
}
.tabs {
display: flex;
border-bottom: 1px solid #e9ecef;
margin-bottom: 20px;
}
.tab {
flex: 1;
padding: 12px;
text-align: center;
cursor: pointer;
color: #666;
font-size: 14px;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab:hover { color: #1890ff; }
.tab.active {
color: #1890ff;
border-bottom-color: #1890ff;
font-weight: 500;
}
.tab-content { display: none; }
.tab-content.active { display: block; }
.info-card {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
padding: 16px;
margin-bottom: 20px;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 6px 0;
font-size: 13px;
border-bottom: 1px solid #e9ecef;
}
.info-row:last-child { border-bottom: none; }
.info-label { color: #666; }
.info-value { color: #333; font-weight: 500; }
.form-group { margin-bottom: 16px; }
label {
display: block;
margin-bottom: 6px;
color: #333;
font-size: 14px;
font-weight: 500;
}
.label-hint {
font-weight: normal;
color: #999;
font-size: 12px;
}
input, select {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s;
}
input:focus, select:focus {
outline: none;
border-color: #1890ff;
}
input::placeholder { color: #bbb; }
.btn {
width: 100%;
padding: 12px;
background: #1890ff;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
margin-top: 8px;
}
.btn:hover { background: #40a9ff; }
.btn:disabled { background: #d9d9d9; cursor: not-allowed; }
.btn-success { background: #52c41a; }
.btn-success:hover { background: #73d13d; }
.btn-warning { background: #faad14; }
.btn-warning:hover { background: #ffc53d; }
.progress { display: none; margin-top: 20px; }
.progress-bar {
height: 6px;
background: #f0f0f0;
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #1890ff;
width: 0%;
transition: width 0.3s;
}
.progress-text {
margin-top: 8px;
color: #666;
font-size: 13px;
text-align: center;
}
.status {
padding: 12px;
border-radius: 6px;
margin-top: 16px;
display: none;
font-size: 14px;
}
.status.success { background: #f6ffed; color: #52c41a; border: 1px solid #b7eb8f; }
.status.error { background: #fff2f0; color: #ff4d4f; border: 1px solid #ffccc7; }
.status.info { background: #e6f7ff; color: #1890ff; border: 1px solid #91d5ff; }
.tip {
font-size: 12px;
color: #999;
text-align: center;
margin-top: 16px;
}
.version-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
margin-left: 8px;
}
.version-current { background: #e6f7ff; color: #1890ff; }
.version-new { background: #f6ffed; color: #52c41a; }
.update-available {
background: #fffbe6;
border: 1px solid #ffe58f;
border-radius: 6px;
padding: 12px;
margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
.update-info { font-size: 13px; color: #d48806; }
.container-list {
margin-top: 16px;
}
.container-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
margin-bottom: 8px;
}
.container-name { font-weight: 500; color: #333; }
.container-status {
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
}
.container-status.running { background: #f6ffed; color: #52c41a; }
.container-status.stopped { background: #fff2f0; color: #ff4d4f; }
.container-actions button {
padding: 4px 12px;
font-size: 12px;
margin-left: 8px;
border: 1px solid #d9d9d9;
background: white;
border-radius: 4px;
cursor: pointer;
}
.container-actions button:hover { border-color: #1890ff; color: #1890ff; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>FLNXI 素材系统</h1>
<p class="subtitle">管理面板</p>
</div>
<div class="tabs">
<div class="tab active" data-tab="status">系统状态</div>
<div class="tab" data-tab="install">安装</div>
<div class="tab" data-tab="update">更新</div>
</div>
<!-- 系统状态 Tab -->
<div class="tab-content active" id="tab-status">
<div class="info-card" id="sysInfo">
<div class="info-row">
<span class="info-label">系统信息</span>
<span class="info-value">加载中...</span>
</div>
</div>
<div class="container-list" id="containerList">
<div style="text-align: center; color: #999; padding: 20px;">加载中...</div>
</div>
</div>
<!-- 安装 Tab -->
<div class="tab-content" id="tab-install">
<form id="installForm">
<div class="form-group">
<label>许可证密钥 <span class="label-hint">(可选,留空免费使用)</span></label>
<input type="text" id="licenseKey" placeholder="XXXX-XXXX-XXXX-XXXX">
</div>
<div class="form-group">
<label>数据存储路径</label>
<input type="text" id="dataPath" value="/opt/flnxi" placeholder="/opt/flnxi">
</div>
<div class="form-group">
<label>Web 端口</label>
<input type="number" id="webPort" value="3000" min="1" max="65535">
</div>
<button type="submit" class="btn" id="installBtn">开始安装</button>
</form>
<div class="progress" id="installProgress">
<div class="progress-bar"><div class="progress-fill" id="installProgressFill"></div></div>
<p class="progress-text" id="installProgressText">准备中...</p>
</div>
<div class="status" id="installStatus"></div>
<p class="tip">安装过程需要几分钟,请耐心等待</p>
</div>
<!-- 更新 Tab -->
<div class="tab-content" id="tab-update">
<div class="info-card" id="versionInfo">
<div class="info-row">
<span class="info-label">当前版本</span>
<span class="info-value" id="currentVersion">检测中...</span>
</div>
<div class="info-row">
<span class="info-label">最新版本</span>
<span class="info-value" id="latestVersion">检测中...</span>
</div>
</div>
<div class="update-available" id="updateAvailable" style="display: none;">
<span class="update-info">🎉 发现新版本,建议更新</span>
</div>
<div class="form-group">
<label>目标版本</label>
<select id="targetVersion">
<option value="">检测中...</option>
</select>
</div>
<button class="btn btn-success" id="updateBtn" disabled>检查更新中...</button>
<div class="progress" id="updateProgress">
<div class="progress-bar"><div class="progress-fill" id="updateProgressFill"></div></div>
<p class="progress-text" id="updateProgressText">准备中...</p>
</div>
<div class="status" id="updateStatus"></div>
<p class="tip">更新过程中服务会短暂中断,请确保没有用户正在使用</p>
</div>
</div>
<script>
const API = '';
let currentVersion = '';
let latestVersion = '';
// Tab 切换
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
});
});
// 获取系统信息
async function getSystemInfo() {
try {
const res = await fetch(API + '/api/info');
const data = await res.json();
document.getElementById('sysInfo').innerHTML = `
<div class="info-row">
<span class="info-label">操作系统</span>
<span class="info-value">${data.os} / ${data.arch}</span>
</div>
<div class="info-row">
<span class="info-label">Docker</span>
<span class="info-value">${data.dockerVersion}</span>
</div>
<div class="info-row">
<span class="info-label">CPU</span>
<span class="info-value">${data.cpus} 核</span>
</div>
<div class="info-row">
<span class="info-label">内存</span>
<span class="info-value">${(data.memTotal / 1024 / 1024 / 1024).toFixed(1)} GB</span>
</div>
`;
} catch (e) {
document.getElementById('sysInfo').innerHTML = `
<div class="info-row">
<span class="info-label">状态</span>
<span class="info-value" style="color:#ff4d4f">无法连接到 Agent</span>
</div>
`;
}
}
// 获取容器列表
async function getContainers() {
try {
const res = await fetch(API + '/api/containers');
const containers = await res.json();
if (containers.length === 0) {
document.getElementById('containerList').innerHTML = `
<div style="text-align: center; color: #999; padding: 20px;">
未检测到已安装的服务,请先安装
</div>
`;
return;
}
let html = '<h4 style="margin-bottom: 12px; color: #333; font-size: 14px;">运行中的服务</h4>';
containers.forEach(c => {
const name = c.name.replace(/^\//, '');
const isRunning = c.state === 'running';
html += `
<div class="container-item">
<div>
<span class="container-name">${name}</span>
<span class="container-status ${isRunning ? 'running' : 'stopped'}">${isRunning ? '运行中' : '已停止'}</span>
</div>
<div class="container-actions">
${isRunning
? `<button onclick="restartContainer('${name}')">重启</button>`
: `<button onclick="startContainer('${name}')">启动</button>`
}
</div>
</div>
`;
// 获取素材系统版本
if (name === 'flnxi-material' && c.image) {
const match = c.image.match(/:([^:]+)$/);
currentVersion = match ? match[1] : 'unknown';
}
});
document.getElementById('containerList').innerHTML = html;
} catch (e) {
document.getElementById('containerList').innerHTML = `
<div style="text-align: center; color: #ff4d4f; padding: 20px;">
获取容器列表失败
</div>
`;
}
}
// 检查版本更新
async function checkVersion() {
try {
// 获取当前版本
await getContainers();
document.getElementById('currentVersion').innerHTML = currentVersion
? `${currentVersion} <span class="version-badge version-current">当前</span>`
: '未安装';
// 从 Agent API 获取可用版本列表
let versions = [];
latestVersion = '';
try {
const versionRes = await fetch(API + '/api/versions');
if (versionRes.ok) {
const versionData = await versionRes.json();
if (versionData.versions && versionData.versions.length > 0) {
versions = versionData.versions;
}
if (versionData.latest) {
latestVersion = versionData.latest;
}
}
} catch (e) {
console.warn('获取版本列表失败:', e);
}
document.getElementById('latestVersion').innerHTML = latestVersion
? `${latestVersion} <span class="version-badge version-new">最新</span>`
: '未配置';
const select = document.getElementById('targetVersion');
select.innerHTML = versions.map(v =>
`<option value="${v}" ${v === latestVersion ? 'selected' : ''}>${v}${v === latestVersion ? ' (推荐)' : ''}</option>`
).join('');
const btn = document.getElementById('updateBtn');
if (versions.length === 0) {
btn.textContent = '未配置版本来源';
btn.disabled = true;
btn.classList.remove('btn-success');
return;
}
if (currentVersion && latestVersion && currentVersion !== latestVersion) {
document.getElementById('updateAvailable').style.display = 'flex';
btn.textContent = '立即更新';
btn.disabled = false;
} else if (currentVersion && latestVersion && currentVersion === latestVersion) {
btn.textContent = '已是最新版本';
btn.disabled = true;
btn.classList.remove('btn-success');
} else {
btn.textContent = '开始更新';
btn.disabled = false;
}
} catch (e) {
console.error('检查版本失败:', e);
}
}
// 执行更新
document.getElementById('updateBtn').addEventListener('click', async () => {
const version = document.getElementById('targetVersion').value;
if (!version) return;
const btn = document.getElementById('updateBtn');
const progress = document.getElementById('updateProgress');
const status = document.getElementById('updateStatus');
btn.disabled = true;
btn.textContent = '更新中...';
progress.style.display = 'block';
status.style.display = 'none';
try {
await fetch(API + '/api/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ version, authToken: '' })
});
pollUpdateStatus();
} catch (e) {
showUpdateError('更新请求失败: ' + e.message);
btn.disabled = false;
btn.textContent = '重试更新';
}
});
async function pollUpdateStatus() {
try {
const res = await fetch(API + '/api/update/status');
const data = await res.json();
document.getElementById('updateProgressFill').style.width = data.progress + '%';
document.getElementById('updateProgressText').textContent = data.message || data.step;
if (data.status === 'running') {
setTimeout(pollUpdateStatus, 1000);
} else if (data.status === 'success') {
showUpdateSuccess();
} else if (data.status === 'failed') {
showUpdateError(data.error);
}
} catch (e) {
showUpdateError('获取状态失败');
}
}
function showUpdateSuccess() {
const status = document.getElementById('updateStatus');
status.className = 'status success';
status.innerHTML = '✓ 更新完成!页面将在 3 秒后刷新...';
status.style.display = 'block';
setTimeout(() => location.reload(), 3000);
}
function showUpdateError(msg) {
const status = document.getElementById('updateStatus');
status.className = 'status error';
status.textContent = msg;
status.style.display = 'block';
document.getElementById('updateBtn').disabled = false;
document.getElementById('updateBtn').textContent = '重试更新';
}
// 容器操作
async function restartContainer(name) {
if (!confirm(`确定要重启 ${name} 吗?`)) return;
try {
await fetch(API + `/api/containers/${name}/restart`, { method: 'POST' });
alert('重启成功');
getContainers();
} catch (e) {
alert('重启失败: ' + e.message);
}
}
async function startContainer(name) {
try {
await fetch(API + `/api/containers/${name}/start`, { method: 'POST' });
alert('启动成功');
getContainers();
} catch (e) {
alert('启动失败: ' + e.message);
}
}
// 安装表单
document.getElementById('installForm').onsubmit = async (e) => {
e.preventDefault();
const btn = document.getElementById('installBtn');
const progress = document.getElementById('installProgress');
const status = document.getElementById('installStatus');
btn.disabled = true;
progress.style.display = 'block';
status.style.display = 'none';
try {
await fetch(API + '/api/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
licenseKey: document.getElementById('licenseKey').value || '',
dataPath: document.getElementById('dataPath').value,
webPort: parseInt(document.getElementById('webPort').value)
})
});
pollInstallStatus();
} catch (e) {
showInstallError('安装请求失败: ' + e.message);
btn.disabled = false;
}
};
async function pollInstallStatus() {
try {
const res = await fetch(API + '/api/install/status');
const data = await res.json();
document.getElementById('installProgressFill').style.width = data.progress + '%';
document.getElementById('installProgressText').textContent = data.message || data.step;
if (data.status === 'running') {
setTimeout(pollInstallStatus, 1000);
} else if (data.status === 'success') {
showInstallSuccess();
} else if (data.status === 'failed') {
showInstallError(data.error);
}
} catch (e) {
showInstallError('获取状态失败');
}
}
function showInstallSuccess() {
const status = document.getElementById('installStatus');
const port = document.getElementById('webPort').value;
status.className = 'status success';
status.innerHTML = `
<div style="margin-bottom: 12px; font-weight: 500;">✓ 安装完成!</div>
<div style="background: #fff; padding: 12px; border-radius: 4px; margin-bottom: 12px;">
<div style="margin-bottom: 8px; color: #333;">访问地址:<a href="http://${location.hostname}:${port}" target="_blank" style="color: #1890ff;">http://${location.hostname}:${port}</a></div>
<div style="border-top: 1px solid #e9ecef; padding-top: 8px; margin-top: 8px;">
<div style="color: #666; font-size: 12px; margin-bottom: 4px;">管理员账号说明:</div>
<div style="color: #333;">初始管理员信息请以当前安装流程返回结果为准。</div>
<div style="color: #333;">系统不再内置固定默认密码。</div>
</div>
</div>
<div style="font-size: 12px; color: #52c41a;">首次登录后请立即完成密码修改</div>
`;
status.style.display = 'block';
document.getElementById('installBtn').disabled = false;
getContainers();
}
function showInstallError(msg) {
const status = document.getElementById('installStatus');
status.className = 'status error';
status.textContent = msg;
status.style.display = 'block';
document.getElementById('installBtn').disabled = false;
}
// 初始化
getSystemInfo();
getContainers();
checkVersion();
</script>
</body>
</html>