init: ops-assistant codebase
This commit is contained in:
413
templates/channels.html
Normal file
413
templates/channels.html
Normal file
@@ -0,0 +1,413 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user