414 lines
16 KiB
HTML
414 lines
16 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>渠道配置 - 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|