feat: channels/audit UI unify, apply flow hardening, bump v1.1.12

This commit is contained in:
2026-03-10 03:32:40 +08:00
parent 52b0d742a7
commit 8b2557b2bf
15 changed files with 2311 additions and 262 deletions

282
templates/channels.html Normal file
View File

@@ -0,0 +1,282 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>渠道配置 - 虾记</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f0f2f5; color: #333; min-height: 100vh; }
.header { background: linear-gradient(135deg, #ff6b6b, #ee5a24); color: #fff; padding: 20px; position: sticky; top: 0; z-index: 100; box-shadow: 0 2px 10px rgba(0,0,0,.15); display:flex; justify-content:space-between; align-items:center; gap:10px; }
.header a { color: #fff; text-decoration:none; background: rgba(255,255,255,.2); padding:6px 10px; border-radius:8px; font-size:13px; }
.header a:hover { background: rgba(255,255,255,.35); }
.wrap { max-width: 600px; margin: 0 auto; padding: 15px; }
.toolbar { display:flex; gap:8px; margin-bottom:10px; flex-wrap:wrap; }
.tip { color:#6b7280; font-size:12px; margin-bottom:10px; }
.card { background:#fff; border-radius:12px; padding:14px; margin-bottom:10px; box-shadow:0 1px 4px rgba(0,0,0,.05); }
.row { display:flex; gap:8px; align-items:center; margin:8px 0; flex-wrap:wrap; }
.row label { width:110px; color:#4b5563; font-size:13px; }
.row input, .row textarea { flex:1; min-width:220px; padding:8px; border:1px solid #ddd; border-radius:8px; font-size:13px; }
.row textarea { min-height:74px; }
.btns { display:flex; gap:8px; margin-top:8px; flex-wrap:wrap; }
button { border:none; border-radius:8px; padding:8px 12px; cursor:pointer; color:#fff; font-size:13px; }
button:disabled { opacity:.65; cursor:not-allowed; }
.btn-apply, .btn-save, .btn-publish, .btn-test, .btn-reload, .btn-enable { background:#ee5a24; }
.btn-apply:hover, .btn-save:hover, .btn-publish:hover, .btn-test:hover, .btn-reload:hover, .btn-enable:hover { background:#d63031; }
.btn-disable { background:#9b2c2c; }
.btn-disable:hover { background:#7f1d1d; }
.badge { display:inline-block; font-size:12px; border-radius:999px; padding:2px 8px; }
.state { display:inline-block; font-size:12px; border-radius:999px; padding:2px 8px; margin-left:6px; }
.ok { background:#dcfce7; color:#166534; }
.error { background:#fee2e2; color:#991b1b; }
.disabled { background:#e5e7eb; color:#374151; }
small { color:#6b7280; }
@media(max-width:640px){
.header { padding: 14px 12px 10px; align-items:flex-start; flex-direction:column; }
.header > div:last-child { display:flex; gap:8px; }
.wrap { padding:12px; }
.toolbar button { flex: 1 1 calc(50% - 4px); }
.row label { width:100%; }
.row input, .row textarea { min-width:100%; }
.btns button { flex: 1 1 calc(50% - 4px); }
}
</style>
</head>
<body>
<div class="header">
<div>🦞 渠道配置中心(草稿/发布) · {{.version}}</div>
<div><a href="/">返回首页</a><a href="/logout">退出</a></div>
</div>
<div class="wrap">
<div class="toolbar">
<button class="btn-disable" onclick="disableAll()">一键全部关闭</button>
<button class="btn-reload" onclick="reloadRuntime()">热加载运行参数</button>
</div>
<div class="tip">默认推荐:直接点“保存并立即生效”。高级场景再用“保存草稿 / 发布草稿 / 热加载”。</div>
<div id="app"></div>
</div>
<script>
const app = document.getElementById('app');
function renderError(msg) {
app.innerHTML = `<div class="card" style="border:1px solid #fecaca;background:#fef2f2;color:#991b1b;">${msg}</div>`;
}
function pretty(objStr) {
try { return JSON.stringify(JSON.parse(objStr || '{}'), null, 2); } catch { return '{}'; }
}
function statusBadge(status) {
const s = (status || 'disabled');
const cls = s === 'ok' ? 'ok' : (s === 'error' ? 'error' : 'disabled');
return `<span class="badge ${cls}">${s}</span>`;
}
function runtimeState(ch) {
if (!ch.enabled) return '<span class="state disabled">已关闭</span>';
if ((ch.status || '').toLowerCase() === 'ok') return '<span class="state ok">运行中</span>';
if ((ch.status || '').toLowerCase() === 'error') return '<span class="state error">配置异常</span>';
return '<span class="state disabled">待检测</span>';
}
function parseJSONSafe(text) {
try { return JSON.parse(text || '{}'); } catch { return null; }
}
async function fetchChannels() {
const r = await fetch('/api/v1/admin/channels');
if (!r.ok) {
throw new Error('加载渠道失败: HTTP ' + r.status);
}
const data = await r.json();
if (!Array.isArray(data)) {
throw new Error('渠道返回格式异常');
}
return data;
}
function render(channels) {
app.innerHTML = channels.map(ch => {
const draftCfg = pretty(ch.draft_config_json || ch.config_json);
const draftSec = pretty(ch.draft_secrets || ch.secrets);
return `<div class="card" data-platform="${ch.platform}">
<h3>${ch.name || ch.platform} ${statusBadge(ch.status)} ${runtimeState(ch)} ${ch.has_draft ? '<span class="badge" style="background:#fef3c7;color:#92400e">draft</span>' : ''}</h3>
<small>平台:${ch.platform} 发布:${ch.published_at || '-'} 最近检测:${ch.last_check_at || '-'}</small>
<div class="row"><label>启用</label><input type="checkbox" class="enabled" ${ch.enabled ? 'checked' : ''}></div>
<div class="row"><label>显示名称</label><input class="name" value="${(ch.name||'').replace(/"/g,'&quot;')}"></div>
<div class="row"><label>草稿 config(JSON)</label><textarea class="config">${draftCfg}</textarea></div>
<div class="row"><label>草稿 secrets(JSON)</label><textarea class="secrets">${draftSec}</textarea></div>
<div class="btns">
<button class="btn-apply" onclick="applyNow('${ch.platform}')">保存并立即生效</button>
<button class="btn-save" onclick="saveDraft('${ch.platform}')">保存草稿</button>
<button class="btn-publish" onclick="publishDraft('${ch.platform}')">发布草稿</button>
<button class="btn-test" onclick="testConn('${ch.platform}')">测试连接</button>
${ch.enabled ? `<button class="btn-disable" onclick="toggleChannel('${ch.platform}', false)">关闭通道</button>` : `<button class="btn-enable" onclick="toggleChannel('${ch.platform}', true)">开启通道</button>`}
</div>
<small class="msg"></small>
</div>`;
}).join('');
}
function collectChannelForm(platform) {
const card = document.querySelector(`[data-platform="${platform}"]`);
const name = card.querySelector('.name').value.trim();
const enabled = card.querySelector('.enabled').checked;
const configText = card.querySelector('.config').value;
const secretsText = card.querySelector('.secrets').value;
const msg = card.querySelector('.msg');
const config = parseJSONSafe(configText);
const secrets = parseJSONSafe(secretsText);
if (!config || !secrets) {
msg.textContent = 'JSON 格式错误,请检查 config/secrets';
return null;
}
return { card, msg, payload: { name, enabled, config, secrets } };
}
async function apiJSON(url, options = {}) {
const r = await fetch(url, options);
const out = await r.json().catch(() => ({}));
if (!r.ok) {
throw new Error(out.error || ('HTTP ' + r.status));
}
return out;
}
function msgOf(platform) {
return document.querySelector(`[data-platform="${platform}"] .msg`);
}
function setCardBusy(card, busy) {
if (!card) return;
card.querySelectorAll('button').forEach(btn => { btn.disabled = busy; });
}
async function applyNow(platform) {
const f = collectChannelForm(platform);
if (!f) return;
const { card, msg, payload } = f;
const applyBtn = card.querySelector('.btn-apply');
const oldText = applyBtn ? applyBtn.textContent : '';
setCardBusy(card, true);
if (applyBtn) applyBtn.textContent = '生效中...';
try {
msg.textContent = '保存并生效中...';
await apiJSON('/api/v1/admin/channels/' + platform + '/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
msg.textContent = '已生效';
await reload();
} catch (e) {
msg.textContent = '失败:' + (e && e.message ? e.message : e);
} finally {
setCardBusy(card, false);
if (applyBtn) applyBtn.textContent = oldText || '保存并立即生效';
}
}
async function saveDraft(platform) {
const f = collectChannelForm(platform);
if (!f) return;
const { msg, payload } = f;
try {
await apiJSON('/api/v1/admin/channels/' + platform, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
msg.textContent = '草稿已保存';
await reload();
} catch (e) {
msg.textContent = '保存失败:' + (e && e.message ? e.message : e);
}
}
async function publishDraft(platform) {
const msg = msgOf(platform);
try {
await apiJSON('/api/v1/admin/channels/' + platform + '/publish', { method: 'POST' });
msg.textContent = '发布成功,建议点“热加载运行参数”';
await reload();
} catch (e) {
msg.textContent = '发布失败:' + (e && e.message ? e.message : e);
}
}
async function testConn(platform) {
const msg = msgOf(platform);
try {
msg.textContent = '正在测试...';
const out = await apiJSON('/api/v1/admin/channels/' + platform + '/test', { method: 'POST' });
msg.textContent = `测试结果:${out.status} ${out.detail ? ' / ' + out.detail : ''}`;
await reload();
} catch (e) {
msg.textContent = '测试失败:' + (e && e.message ? e.message : e);
}
}
async function toggleChannel(platform, enable) {
const msg = msgOf(platform);
try {
msg.textContent = enable ? '正在开启...' : '正在关闭...';
await apiJSON('/api/v1/admin/channels/' + platform + (enable ? '/enable' : '/disable'), { method: 'POST' });
msg.textContent = enable ? '已开启(请点热加载生效)' : '已关闭(请点热加载生效)';
await reload();
} catch (e) {
msg.textContent = (enable ? '开启失败:' : '关闭失败:') + (e && e.message ? e.message : e);
}
}
async function reloadRuntime() {
try {
const out = await apiJSON('/api/v1/admin/channels/reload', { method: 'POST' });
alert('热加载成功:' + (out.detail || 'ok'));
await reload();
} catch (e) {
alert('热加载失败:' + (e && e.message ? e.message : e));
}
}
async function disableAll() {
if (!confirm('确认要关闭所有通道吗?')) return;
try {
const out = await apiJSON('/api/v1/admin/channels/disable-all', { method: 'POST' });
alert('已关闭通道数:' + (out.affected || 0) + ',请点热加载生效。');
await reload();
} catch (e) {
alert('批量关闭失败:' + (e && e.message ? e.message : e));
}
}
async function reload() {
try {
const channels = await fetchChannels();
render(channels);
} catch (e) {
console.error(e);
renderError('页面加载失败:' + (e && e.message ? e.message : e));
}
}
window.addEventListener('error', (e) => {
renderError('前端脚本异常:' + (e && e.message ? e.message : 'unknown'));
});
window.addEventListener('unhandledrejection', (e) => {
const msg = e && e.reason && e.reason.message ? e.reason.message : String(e.reason || 'unknown');
renderError('前端请求异常:' + msg);
});
reload();
</script>
</body>
</html>