// 通用条目列表页逻辑(三类条目共用)
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 `
`;
}
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)}`, `
常用: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('订阅已生成', `
`, () => {}, '关闭');
}, '生成');
},
// ---- 批量 ----
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);
}