// 通用条目列表页逻辑(三类条目共用) let GT = { groups: [], tags: [], nodes: [] }; async function loadGT() { try { GT.groups = await api('GET', '/api/groups'); } catch (e) {} try { GT.tags = await api('GET', '/api/tags'); } catch (e) {} try { const n = await api('GET', '/api/nodes'); GT.nodes = n.items || n; } catch (e) { GT.nodes = [{ id: 1, name: '本地节点' }]; } } function groupChip(g) { if (!g) return '未分组'; return `${esc(g.name)}`; } function tagChips(tags) { return (tags || []).map(t => `${esc(t.name)}`).join(''); } function expireCell(row) { if (!row.expires_at) return '永久'; const exp = new Date(row.expires_at.replace(' ', 'T') + 'Z').getTime(); const days = (exp - Date.now()) / 86400000; const cls = days < 0 ? 'danger' : days <= 7 ? 'warn' : 'ok'; return `${fmtTime(row.expires_at)}`; } function quotaCell(row) { const q = row.quota; if (!q || q.quota_bytes === 0) return '-'; const pct = Math.min(100, (q.used_bytes / q.quota_bytes) * 100); const cls = q.status === 'exceeded' ? 'danger' : pct >= 80 ? 'warn' : ''; return `
${pct.toFixed(0)}%
`; } function initEntryPage(o) { const L = { api: o.api, etype: o.etype, page: 1, pageSize: 20, selected: new Set(), async reload() { const f = this.readFilters(); const d = await api('GET', this.api + '?' + qs({ page: this.page, page_size: this.pageSize, ...f })); this.render(d); if (o.runtimeBadge) refreshRuntimeColumn().catch(() => {}); }, readFilters() { const g = id => document.getElementById(id)?.value; const f = { q: g('f-q') || undefined, node_id: g('f-node') || undefined, enabled: g('f-enabled') || undefined, group_id: g('f-group') || undefined, tag_id: g('f-tag') || undefined, disabled_reason: g('f-reason') || undefined, expiring_days: g('f-expiring') || undefined, }; if (o.readExtraFilters) Object.assign(f, o.readExtraFilters()); return f; }, render(d) { this.rows = (d && d.data && d.data.items) || d.items || []; const thead = document.getElementById('thead'); thead.innerHTML = '' + o.columns.map(c => `${esc(c.label)}`).join('') + '操作'; document.getElementById('sel-all').onchange = e => { document.querySelectorAll('.row-sel').forEach(cb => { cb.checked = e.target.checked; }); this.syncSel(); }; const tb = document.getElementById('tbody'); tb.innerHTML = d.items.map(r => { const tds = o.columns.map(c => `${c.cell(r)}`).join(''); return `${tds} `; }).join('') || `暂无数据`; tb.querySelectorAll('.row-sel').forEach(cb => cb.onchange = () => this.syncSel()); document.getElementById('pager').innerHTML = pagerHtml(d.total, d.page, d.page_size, 'L.goto'); this.selected.clear(); this.syncBar(); }, goto(p) { this.page = p; this.reload().catch(e => toast(e.message, true)); }, syncSel() { this.selected = new Set([...document.querySelectorAll('.row-sel:checked')].map(cb => +cb.dataset.id)); this.syncBar(); }, syncBar() { const bar = document.getElementById('batchbar'); bar.style.display = this.selected.size ? 'flex' : 'none'; document.getElementById('sel-count').textContent = this.selected.size; }, items() { return [...this.selected].map(id => ({ entry_type: this.etype, entry_id: id })); }, async toggleOne(id, enabled) { if (enabled) { // P11:手动恢复因配额停用的条目时提示确认(配额未重置前流量会计入,立刻超限) const row = (this.rows || []).find(r => r.id === id); if (row && row.disabled_reason === 'quota') { const q = row.quota || {}; const pct = q.quota_bytes ? Math.round(q.used_bytes * 100 / q.quota_bytes) : 100; confirmDo( `该条目因流量配额用尽被自动停用(已用 ${pct}%)。手动启用后若配额未重置或调大,会立即再次超限停用。建议先在"流量配额"页重置用量或调大配额。仍要手动启用吗?`, async () => { await api('POST', `${this.api}/${id}/toggle`, { enabled: true }); toast('已启用'); this.reload(); }, '仍要启用'); return; } } await api('POST', `${this.api}/${id}/toggle`, { enabled: !!enabled }); toast(enabled ? '已启用' : '已停用'); this.reload(); }, // P11:配额设置弹窗 async quotaForm(id) { let q = null; try { q = (await api('GET', `/api/entries/${this.etype}/${id}/quota`)).quota; } catch (e) { toast('加载配额失败:' + e.message, true); return; } const cur = q && q.quota_bytes ? `${fmtBytes(q.used_bytes)} / ${fmtBytes(q.quota_bytes)}(${Math.round(q.used_bytes * 100 / q.quota_bytes)}%)` : '未设置'; const row = (this.rows || []).find(r => r.id === id); const name = row ? row.name : ('#' + id); openModal(`配额 - ${esc(name)}`, `
${cur}
常用:1GB=${1073741824},10GB=${10737418240};仅落地代理(有监听地址)/端口转发/中转隧道的流量可计量。
${q && q.quota_bytes ? '' : ''}`, async () => { const body = { quota_bytes: parseInt(document.getElementById('qf-quota').value || '0'), period: document.getElementById('qf-period').value, reset_day: parseInt(document.getElementById('qf-day').value || '1'), }; try { const r = await api('PUT', `/api/entries/${this.etype}/${id}/quota`, body); toast(r.recovered ? '配额已更新,条目已恢复' : '配额已更新'); this.reload(); } catch (e) { toast('更新失败:' + e.message, true); } }, '保存'); const rb = document.getElementById('qf-reset'); if (rb) rb.onclick = async () => { try { const r = await api('POST', `/api/entries/${this.etype}/${id}/quota/reset`); toast(r.recovered ? '用量已重置,条目已恢复' : '用量已重置'); closeModal(); this.reload(); } catch (e) { toast('重置失败:' + e.message, true); } }; }, async delOne(id) { const d = await api('GET', `${this.api}/${id}`); const refs = d.sub_refs || 0; confirmDo(`确定删除「${d.name}」?${refs ? `该条目被 ${refs} 个订阅引用,删除后将从这些订阅中移除。` : ''}`, async () => { await api('DELETE', `${this.api}/${id}`); toast('已删除'); this.reload(); }, '删除'); }, openForm(id) { const isNew = !id; const load = isNew ? Promise.resolve(null) : api('GET', `${this.api}/${id}`); load.then(item => { openModal(isNew ? '新建' : '编辑', o.formHtml(item, isNew), async () => { const payload = o.formData(isNew); if (isNew) await api('POST', this.api, payload); else await api('PUT', `${this.api}/${id}`, payload); toast('已保存'); this.reload(); }, '保存'); if (o.afterFormOpen) o.afterFormOpen(item, isNew); }).catch(e => toast(e.message, true)); }, async quickSub(id) { openModal('一键生成订阅', `
`, async () => { const d = await api('POST', `/api/entries/${this.etype}/${id}/quick_subscription`, { name: document.getElementById('qs-name').value, expire_preset: document.getElementById('qs-preset').value, }); openModal('订阅已生成', `
${esc(d.url)}
`, () => {}, '关闭'); }, '生成'); }, // ---- 批量 ---- async bToggle(enabled) { await api('POST', `${this.api}/batch_toggle`, { ids: [...this.selected], enabled }); toast('已更新'); this.reload(); }, async bDelete() { confirmDo(`确定删除选中的 ${this.selected.size} 个条目?关联的标签、配额、订阅引用将一并清理。`, async () => { await api('POST', `${this.api}/batch_delete`, { ids: [...this.selected] }); toast('已删除'); this.reload(); }, '删除'); }, async bMove() { const nid = document.getElementById('b-node').value; if (!nid) return toast('请选择节点', true); await api('POST', `${this.api}/batch_move`, { ids: [...this.selected], node_id: +nid }); toast('已迁移'); this.reload(); }, async bSetGroup() { const gid = document.getElementById('b-group').value; await api('POST', '/api/entries/batch_set_group', { items: this.items(), group_id: gid ? +gid : null }); toast('已设置'); this.reload(); }, async bAddTag() { const tid = document.getElementById('b-tag').value; if (!tid) return toast('请选择标签', true); await api('POST', '/api/entries/batch_add_tags', { items: this.items(), tag_ids: [+tid] }); toast('已添加'); this.reload(); }, async bRemoveTag() { const tid = document.getElementById('b-tag').value; if (!tid) return toast('请选择标签', true); await api('POST', '/api/entries/batch_remove_tags', { items: this.items(), tag_ids: [+tid] }); toast('已移除'); this.reload(); }, async bSetExpiry() { const preset = document.getElementById('b-expiry').value; await api('POST', '/api/entries/batch_set_expiry', { items: this.items(), expire_preset: preset }); toast('已设置'); this.reload(); }, async bSetQuota() { openModal('批量设置配额', `

不经过本系统的条目(如无监听地址的代理)会被跳过并提示。

`, async () => { const gb = parseFloat(document.getElementById('bq-gb').value) || 0; const d = await api('POST', '/api/entries/batch_set_quota', { items: this.items(), quota_bytes: Math.round(gb * 1073741824), period: document.getElementById('bq-period').value, }); toast(`已设置 ${d.ok} 个${d.skipped.length ? `,跳过 ${d.skipped.length} 个` : ''}`); this.reload(); }, '设置'); }, }; window.L = L; window.__entryPage = L; return L; } function commonFiltersHtml() { const gopts = GT.groups.map(g => ``).join(''); const topts = GT.tags.map(t => ``).join(''); const nopts = GT.nodes.map(n => ``).join(''); return ` `; } function batchBarHtml() { const nopts = GT.nodes.map(n => ``).join(''); const gopts = GT.groups.map(g => ``).join(''); const topts = GT.tags.map(t => ``).join(''); return `已选 0 项 `; } function expirePresetHtml(sel) { const opts = [['permanent', '永久'], ['7d', '7 天'], ['30d', '30 天'], ['90d', '90 天'], ['1y', '1 年']]; return ``; } function nodeSelectHtml(selId, sel) { return ``; } function groupSelectHtml(selId, sel) { return ``; } function tagCheckHtml(selIds) { const sel = new Set(selIds || []); return GT.tags.map(t => ``).join('') || '暂无标签'; } function readTagIds() { return [...document.querySelectorAll('.fm-tag:checked')].map(c => +c.value); }