Files
ops-assistant/templates/channels.html
2026-03-19 21:23:28 +08:00

414 lines
16 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>渠道配置 - Ops-Assistant</title>
<style>
:root{
--bg:#f5f7fb;
--text:#222;
--card:#fff;
--border:#e5e7eb;
--muted:#6b7280;
--accent:#ee5a24;
--accent-hover:#d63031;
--header-bg:linear-gradient(135deg,#ff6b6b,#ee5a24);
}
[data-theme="dark"]{
--bg:#0f172a;
--text:#e5e7eb;
--card:#111827;
--border:#1f2937;
--muted:#9ca3af;
--accent:#f97316;
--accent-hover:#ea580c;
--header-bg:linear-gradient(135deg,#7c2d12,#ea580c);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
.header { background: var(--header-bg); color: #fff; padding: 18px 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,.header button { color: #fff; text-decoration:none; background: rgba(255,255,255,.2); padding:6px 10px; border-radius:8px; font-size:13px; border:none; cursor:pointer; }
.header button{display:inline-flex;align-items:center;justify-content:center}
.header button svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2}
.header a:hover { background: rgba(255,255,255,.35); }
.wrap { max-width: 760px; margin: 0 auto; padding: 14px; }
.toolbar { display:flex; gap:8px; margin-bottom:10px; flex-wrap:wrap; }
.tip { color:var(--muted); font-size:12px; margin-bottom:10px; }
.card { background:var(--card); border-radius:6px; padding:14px; margin-bottom:10px; border:1px solid var(--border); box-shadow:none; }
.row { display:flex; gap:8px; align-items:center; margin:8px 0; flex-wrap:wrap; }
.row label { width:140px; color:var(--muted); font-size:13px; }
.row input, .row textarea { flex:1; min-width:220px; padding:8px; border:1px solid var(--border); border-radius:6px; font-size:13px; background:var(--card); color:var(--text); }
.row textarea { min-height:74px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.btns { display:flex; gap:8px; margin-top:8px; flex-wrap:wrap; }
button { border:none; border-radius:6px; 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:var(--accent); }
.btn-apply:hover, .btn-save:hover, .btn-publish:hover, .btn-test:hover, .btn-reload:hover, .btn-enable:hover { background:var(--accent-hover); }
.btn-disable { background:#9b2c2c; }
.btn-disable:hover { background:#7f1d1d; }
.btn-ghost { background:transparent; color:var(--muted); border:1px solid var(--border); }
.btn-ghost:hover { background:rgba(0,0,0,.04); }
.badge { display:inline-block; font-size:12px; border-radius:6px; padding:2px 8px; }
.state { display:inline-block; font-size:12px; border-radius:6px; padding:2px 8px; margin-left:6px; }
.ok { background:#dcfce7; color:#166534; }
.error { background:#fee2e2; color:#991b1b; }
.disabled { background:#e5e7eb; color:#374151; }
small { color:var(--muted); }
.hidden{display:none !important;}
.advanced{border-top:1px dashed var(--border); margin-top:8px; padding-top:8px;}
@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); }
}
.theme-hidden{display:none !important;}
</style>
</head>
<body>
<div class="header">
<div>🦞 渠道配置中心(草稿/发布) · {{.version}}</div>
<div>
<button id="themeToggle" class="theme-hidden" title="切换主题" aria-label="切换主题">
<svg id="themeIcon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="2" x2="12" y2="4"></line>
<line x1="12" y1="20" x2="12" y2="22"></line>
<line x1="2" y1="12" x2="4" y2="12"></line>
<line x1="20" y1="12" x2="22" y2="12"></line>
<line x1="4.2" y1="4.2" x2="5.8" y2="5.8"></line>
<line x1="18.2" y1="18.2" x2="19.8" y2="19.8"></line>
<line x1="18.2" y1="5.8" x2="19.8" y2="4.2"></line>
<line x1="4.2" y1="19.8" x2="5.8" y2="18.2"></line>
</svg>
</button>
<a href="/">返回首页</a>
<a href="/logout">退出</a>
</div>
</div>
<div class="wrap">
<div class="toolbar">
<button id="btnDisableAll" class="btn-disable" onclick="disableAll()">一键全部关闭</button>
<button id="btnReload" class="btn-reload" onclick="reloadRuntime()">热加载运行参数</button>
</div>
<div class="tip">填写需要的参数即可(每项一个输入框)。高级 JSON 已折叠,默认不需要碰。</div>
<div id="app"></div>
</div>
<script>
let me=null;
const app = document.getElementById('app');
async function api(url, options = {}) {
const r = await fetch(url, options);
const out = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(out.message || ('HTTP ' + r.status));
return out?.data || {};
}
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
function can(k){return !!(me&&me.effective_capabilities&&me.effective_capabilities[k]);}
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; }
}
function getFieldDefs(platform){
if(platform==='telegram'){
return [
{key:'token', label:'Telegram Bot Token'}
];
}
if(platform==='qqbot_official'){
return [
{key:'appid', label:'QQ Bot AppID'},
{key:'secret', label:'QQ Bot Secret'}
];
}
if(platform==='feishu'){
return [
{key:'app_id', label:'飞书 AppID'},
{key:'app_secret', label:'飞书 AppSecret'},
{key:'verification_token', label:'飞书 VerificationToken可选'},
{key:'encrypt_key', label:'飞书 EncryptKey可选'}
];
}
return [];
}
async function fetchChannels() {
const out = await api('/api/v1/admin/channels');
const data = out?.channels;
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);
const secObj = parseJSONSafe(draftSec) || {};
const hasDraft = ch.has_draft ? '<span class="badge" style="background:#fef3c7;color:#92400e">draft</span>' : '';
const fields = getFieldDefs(ch.platform);
const fieldsHtml = fields.map(f => {
let v = secObj[f.key] || '';
if (String(v).trim() === '***') { v = ''; }
return `<div class="row"><label>${esc(f.label)}</label><input class="field" data-key="${esc(f.key)}" value="${esc(v)}" placeholder="留空表示不修改"></div>`;
}).join('');
return `<div class="card" data-platform="${esc(ch.platform)}">
<h3>${esc(ch.name || ch.platform)} ${statusBadge(ch.status)} ${runtimeState(ch)} ${hasDraft}</h3>
<small>平台:${esc(ch.platform)} 发布:${esc(ch.published_at || '-')} 最近检测:${esc(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="${esc(ch.name||'')}"></div>
${fieldsHtml}
<div class="btns">
<button class="btn-apply" onclick="applyNow('${esc(ch.platform)}')">保存并立即生效</button>
<button class="btn-save" onclick="saveDraft('${esc(ch.platform)}')">保存草稿</button>
<button class="btn-publish" onclick="publishDraft('${esc(ch.platform)}')">发布草稿</button>
<button class="btn-test" onclick="testConn('${esc(ch.platform)}')">测试连接</button>
${ch.enabled ? `<button class="btn-disable" onclick="toggleChannel('${esc(ch.platform)}', false)">关闭通道</button>` : `<button class="btn-enable" onclick="toggleChannel('${esc(ch.platform)}', true)">开启通道</button>`}
<button class="btn-ghost" onclick="toggleAdvanced('${esc(ch.platform)}')">高级 JSON</button>
</div>
<div class="advanced hidden">
<div class="row"><label>配置 JSON</label><textarea class="config">${draftCfg}</textarea></div>
<div class="row"><label>密钥 JSON</label><textarea class="secrets">${draftSec}</textarea></div>
</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 cfgText = card.querySelector('.config') ? card.querySelector('.config').value : '{}';
const secText = card.querySelector('.secrets') ? card.querySelector('.secrets').value : '{}';
const msg = card.querySelector('.msg');
const config = parseJSONSafe(cfgText) || {};
const secrets = parseJSONSafe(secText) || {};
if (config === null || secrets === null) {
msg.textContent = 'JSON 格式错误,请检查高级 JSON';
return null;
}
// 覆盖结构化字段,但保留未知字段
let hasSecretChange = false;
card.querySelectorAll('.field').forEach(input => {
const key = input.getAttribute('data-key');
const val = (input.value || '').trim();
if (val !== '') {
secrets[key] = val;
hasSecretChange = true;
}
});
const payload = { name, enabled, config };
if (hasSecretChange) payload.secrets = secrets;
return { card, msg, payload };
}
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 api('/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 api('/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 api('/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 api('/api/v1/admin/channels/' + platform + '/test', { method: 'POST' });
msg.textContent = `测试结果:${out.status || 'unknown'} ${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 api('/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 api('/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 api('/api/v1/admin/channels/disable-all', { method: 'POST' });
alert('已关闭通道数:' + (out.affected || 0) + ',请点热加载生效。');
await reload();
} catch (e) {
alert('批量关闭失败:' + (e && e.message ? e.message : e));
}
}
function msgOf(platform) {
return document.querySelector(`[data-platform="${platform}"] .msg`);
}
function toggleAdvanced(platform){
const card = document.querySelector(`[data-platform="${platform}"]`);
if(!card) return;
const adv = card.querySelector('.advanced');
if(adv) adv.classList.toggle('hidden');
}
async function loadMe(){
const r=await fetch('/api/v1/me');
const out=await r.json().catch(()=>({}));
if(!r.ok) throw new Error(out.message||'读取用户失败');
me=out?.data||{};
}
function initPermissionUI(){
document.getElementById('btnReload').classList.toggle('hidden',!can('can_edit_channels'));
document.getElementById('btnDisableAll').classList.toggle('hidden',!can('can_edit_channels'));
}
function applyTheme(theme){
if(theme==='dark'){
document.documentElement.setAttribute('data-theme','dark');
}else{
document.documentElement.removeAttribute('data-theme');
}
localStorage.setItem('theme',theme);
}
function toggleTheme(){
const cur=localStorage.getItem('theme')||'dark';
applyTheme(cur==='light'?'dark':'light');
}
function initTheme(){
const saved=localStorage.getItem('theme')||'dark';
applyTheme(saved);
const btn=document.getElementById('themeToggle');
if(btn){ btn.addEventListener('click',toggleTheme); }
}
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);
});
(async function(){
try{ initTheme(); await loadMe(); initPermissionUI(); await reload(); }
catch(e){ renderError('初始化失败:'+(e.message||e)); }
})();
</script>
</body>
</html>