chore: track version files, docs, web app split, scripts (missed commits from March work)

This commit is contained in:
2026-09-13 08:41:35 +08:00
parent 84c709bbcc
commit edf76db56b
33 changed files with 3082 additions and 504 deletions
+634
View File
@@ -0,0 +1,634 @@
window.INP2P = window.INP2P || {};
window.INP2P.createActions = function createActions(state, deps) {
const { api, loginByPassword, Vue } = deps;
const refreshAll = async () => {
if (!state.loggedIn.value) return;
state.globalBusy.value = true;
try {
const [h, s] = await Promise.all([api('/api/v1/health'), api('/api/v1/stats')]);
state.health.value = (h && typeof h === 'object') ? h : {};
state.stats.value = (s && typeof s === 'object') ? s : {};
const nd = await api('/api/v1/nodes');
state.nodes.value = Array.isArray(nd?.nodes) ? nd.nodes : [];
try {
const sdRes = await api('/api/v1/sdwans');
state.sd.value = (sdRes && typeof sdRes === 'object') ? sdRes : { nodes: [], routes: [], subnetProxies: [] };
if (!Array.isArray(state.sd.value.nodes)) state.sd.value.nodes = [];
if (!Array.isArray(state.sd.value.routes)) state.sd.value.routes = [];
if (!Array.isArray(state.sd.value.subnetProxies)) state.sd.value.subnetProxies = [];
// tenant subnet locked by backend: keep UI aligned
if (!state.isAdmin.value && state.subnet.value) {
state.sd.value.gatewayCIDR = state.subnet.value;
if (!state.sd.value.routes.length) {
state.sd.value.routes = [state.subnet.value];
}
}
} catch (e) {
state.toast('SDWAN 加载失败:' + (state.humanizeError ? state.humanizeError(e.message || '') : (e.message || '')), 'error');
}
if (state.isAdmin.value) {
try {
const settingsRes = await api('/api/v1/admin/settings');
state.settings.value = (settingsRes && typeof settingsRes === 'object') ? settingsRes : state.settings.value;
} catch(_) {}
try {
const a = await api('/api/v1/admin/audit?limit=50');
state.audit.value = Array.isArray(a?.logs) ? a.logs : [];
} catch(_) {}
}
} catch (e) {
state.toast(state.humanizeError ? state.humanizeError(e.message || '刷新失败') : (e.message || '刷新失败'), 'error');
} finally {
state.globalBusy.value = false;
}
};
const stopTimer = () => {
if (state.timer.value) {
clearInterval(state.timer.value);
state.timer.value = null;
}
};
const startTimer = () => {
stopTimer();
if (!state.loggedIn.value) return;
const sec = Math.max(5, Number(state.refreshSec.value || 15));
state.timer.value = setInterval(refreshAll, sec * 1000);
};
const login = async () => {
state.loginErr.value = '';
state.loginBusy.value = true;
try {
const uname = (state.loginUser.value || '').trim();
const uerr = state.validateUsername ? state.validateUsername(uname) : '';
if (uerr) throw new Error(uerr);
const d = await loginByPassword(uname, state.loginPass.value);
if (d.error) throw new Error(d.message || '用户名密码登录失败');
localStorage.setItem('t', d.token || '');
state.role.value = d.role || '';
state.status.value = d.status ?? 1;
state.tokenType.value = d.token_type || 'session';
state.tenantId.value = d.tenant_id || 0;
state.subnet.value = d.subnet || '';
if (state.status.value !== 1) throw new Error('账号已停用');
state.loggedIn.value = true;
await refreshAll();
state.toast('登录成功');
} catch (e) {
state.loginErr.value = e.message || '登录失败';
} finally {
state.loginBusy.value = false;
}
};
const logout = () => {
localStorage.removeItem('t');
localStorage.removeItem('master_t');
state.loggedIn.value = false;
state.role.value = '';
state.tokenType.value = '';
state.tenantId.value = 0;
state.subnet.value = '';
stopTimer();
};
const addSDWANNode = () => state.sd.value.nodes = [...(state.sd.value.nodes || []), { node:'', ip:'' }];
const removeSDWANNode = i => state.sd.value.nodes.splice(i, 1);
const addSubnetProxy = () => state.sd.value.subnetProxies = [...(state.sd.value.subnetProxies || []), { node:'', localCIDR:'', virtualCIDR:'' }];
const removeSubnetProxy = i => state.sd.value.subnetProxies.splice(i, 1);
const autoAssignIPs = () => {
const used = new Set();
(state.sd.value.nodes || []).forEach(n => { const p = (n.ip||'').split('.'); if (p.length===4) used.add(Number(p[3])); });
let k = 2;
for (const n of state.sd.value.nodes || []) {
if (!n.ip) {
while (used.has(k) && k < 254) k++;
n.ip = `10.10.0.${k}`;
used.add(k);
k++;
}
}
state.toast('已自动分配缺失 IP');
};
const saveSDWAN = async () => {
const verr = state.validateSDWAN ? state.validateSDWAN(state.sd.value) : '';
if (verr) {
state.toast(verr, 'error');
state.logEvent && state.logEvent('error', { tab:'sdwan', action:'save', message:verr });
return;
}
state.beginTab('sdwan');
try {
await api('/api/v1/sdwan/edit', { method:'POST', body: JSON.stringify(state.sd.value) });
state.toast('SDWAN 保存成功');
await refreshAll();
state.endTabOk('sdwan', !(state.sd.value?.nodes||[]).length);
} catch (e) { state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('sdwan'); state.logEvent && state.logEvent('error', { tab:'sdwan', action:'save', message:e.message||'' }); }
};
const kickNode = async (node) => {
if (!confirm(`高风险操作:踢下线节点 ${node}\n这会中断该节点现有连接。确认继续?`)) return;
try { await api('/api/v1/nodes/kick', { method:'POST', body: JSON.stringify({ node }) }); state.toast('已发送踢下线'); refreshAll(); }
catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); }
};
const openNodeEdit = (node, mode='alias') => {
if (!node || !node.nodeUUID) return state.toast('该节点尚无UUID,稍后重连后再试', 'error');
state.nodeEditNode.value = node;
state.nodeEditMode.value = mode;
state.nodeEditTitle.value = mode === 'alias' ? '编辑设备昵称' : '编辑设备虚拟IP';
state.nodeEditValue.value = mode === 'alias' ? (node.alias || '') : (node.virtualIP || '');
state.nodeEditVisible.value = true;
};
const closeNodeEdit = () => {
state.nodeEditVisible.value = false;
state.nodeEditNode.value = null;
state.nodeEditValue.value = '';
};
const submitNodeEdit = async () => {
const node = state.nodeEditNode.value;
if (!node || !node.nodeUUID) return;
const val = String(state.nodeEditValue.value || '').trim();
try {
if (state.nodeEditMode.value === 'alias') {
await api('/api/v1/nodes/alias', { method:'POST', body: JSON.stringify({ node_uuid: node.nodeUUID, alias: val }) });
state.toast('昵称已更新');
} else {
if (!val) return state.toast('虚拟IP不能为空', 'error');
await api('/api/v1/nodes/ip', { method:'POST', body: JSON.stringify({ node_uuid: node.nodeUUID, virtual_ip: val }) });
state.toast('IP已更新,节点将按新IP重连');
}
closeNodeEdit();
refreshAll();
} catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); }
};
const renameNode = async (node) => openNodeEdit(node, 'alias');
const changeNodeIP = async (node) => openNodeEdit(node, 'ip');
const appPushNode = Vue.ref('');
const appPushRaw = Vue.ref('[{"appName":"demo","protocol":"tcp","srcPort":8080,"peerNode":"","dstHost":"127.0.0.1","dstPort":80,"enabled":1}]');
let switchTabRef = null;
const setSwitchTab = fn => { switchTabRef = fn; };
const openAppManager = (node) => { appPushNode.value = node; state.toast(`已选中 ${node},请在控制台执行推配置`); if (switchTabRef) switchTabRef('p2p'); };
const openConnect = (from) => { state.connectForm.value.from = from; if (switchTabRef) switchTabRef('p2p'); };
const pushAppConfigs = async () => {
if (!appPushNode.value) return state.toast('请先选择节点', 'error');
let apps;
try { apps = JSON.parse(appPushRaw.value); } catch(_) { return state.toast('配置 JSON 格式错误', 'error'); }
state.setTabBusy('p2p', true);
try { await api('/api/v1/nodes/apps', { method:'POST', body: JSON.stringify({ node: appPushNode.value, apps }) }); state.toast('配置已推送'); }
catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); }
finally { state.setTabBusy('p2p', false); }
};
const doConnect = async () => {
const req = { ...state.connectForm.value };
if (!req.from || !req.to) return state.toast('请选择 from/to 节点', 'error');
state.setTabBusy('p2p', true);
try {
await api('/api/v1/connect', { method:'POST', body: JSON.stringify(req) });
state.toast('连接请求已发送');
} catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); }
finally { state.setTabBusy('p2p', false); }
};
const loadTenants = async () => {
if (!state.isAdmin.value) { state.tenants.value = []; state.tabState.value = { ...state.tabState.value, tenants:'empty' }; return; }
state.beginTab('tenants');
try {
const d = await api('/api/v1/admin/tenants');
state.tenants.value = d.tenants || [];
state.endTabOk('tenants', !state.tenants.value.length);
} catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('tenants'); }
};
const createTenant = async () => {
const terr = state.validateRequired ? state.validateRequired(state.tenantForm.value.name, '租户名') : (!state.tenantForm.value.name ? '租户名不能为空' : '');
if (terr) return state.toast(terr, 'error');
state.beginTab('tenants');
try {
const tenantName = state.tenantForm.value.name;
await api('/api/v1/admin/tenants', { method:'POST', body: JSON.stringify(state.tenantForm.value) });
state.tenantForm.value = { name:'', admin_password:'', operator_password:'' };
state.toast('租户创建成功'); state.opSummary.value = `已创建租户:${tenantName}`;
await loadTenants();
} catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('tenants'); }
};
const setTenantStatus = async (id, st) => {
if (!confirm(`确认将租户 ${id} ${st===1?'启用':'停用'}?`)) return;
try { await api(`/api/v1/admin/tenants/${id}?status=${st}`, { method:'POST', body:'{}' }); state.toast('租户状态已更新'); state.opSummary.value = `租户 ${id} 已${st===1?'启用':'停用'}`; loadTenants(); }
catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); }
};
const reassignTenantSubnet = async (id, currentSubnet='') => {
if (!state.isAdmin.value) return;
const next = prompt('输入新的租户子网(仅 10.200.x.0/24)', currentSubnet || '');
if (next === null) return;
const subnet = String(next || '').trim();
if (!subnet) return state.toast('子网不能为空', 'error');
try {
await api(`/api/v1/admin/tenants/${id}/subnet`, { method:'POST', body: JSON.stringify({ subnet }) });
state.toast('租户子网已重分配');
await loadTenants();
try { await refreshAll(); } catch(_) {}
} catch(e){ state.toast(e.message || '重分配失败', 'error'); }
};
const loadKeys = async () => {
if (!state.activeTenant.value) return;
state.beginTab('tenants');
try {
const d = await api(`/api/v1/admin/tenants/${state.activeTenant.value}/keys`);
state.keys.value = d.keys || [];
state.selectedKeyIds.value = [];
if (!state.keepFailedPool.value) state.bulkFailedKeyIds.value = [];
state.endTabOk('tenants', !state.keys.value.length);
} catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('tenants'); }
};
const createKey = async () => {
if (!state.activeTenant.value) return state.toast('请先填写 Tenant ID', 'error');
const scopeErr = state.validateRequired ? state.validateRequired(state.keyForm.value.scope, 'scope') : '';
if (scopeErr) return state.toast(scopeErr, 'error');
state.beginTab('tenants');
try {
const d = await api(`/api/v1/admin/tenants/${state.activeTenant.value}/keys`, { method:'POST', body: JSON.stringify(state.keyForm.value) });
state.toast(`API Key 创建成功: ${d.api_key || ''}`);
await loadKeys();
} catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('tenants'); }
};
const setKeyStatus = async (id, st) => {
if (!confirm(`确认将 Key ${id} ${st===1?'启用':'停用'}?`)) return;
try { await api(`/api/v1/admin/tenants/${state.activeTenant.value}/keys/${id}?status=${st}`, { method:'POST', body:'{}' }); state.toast('Key 状态已更新'); state.opSummary.value = `Key ${id} 已${st===1?'启用':'停用'}`; loadKeys(); }
catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); }
};
const loadUsers = async () => {
if (!state.activeTenant.value) return;
state.beginTab('tenants');
try {
const d = await api(`/api/v1/admin/users?tenant=${state.activeTenant.value}`);
state.users.value = d.users || [];
state.selectedUserIds.value = [];
if (!state.keepFailedPool.value) state.bulkFailedUserIds.value = [];
state.endTabOk('tenants', !state.users.value.length);
} catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('tenants'); }
};
const createUser = async () => {
if (!state.activeTenant.value) return state.toast('请先填写 Tenant ID', 'error');
const e1 = state.validateRequired ? state.validateRequired(state.userForm.value.email, 'email/username') : '';
const e2 = state.validateRequired ? state.validateRequired(state.userForm.value.password, 'password') : '';
if (e1 || e2) return state.toast(e1 || e2, 'error');
state.beginTab('tenants');
try {
const createdUser = state.userForm.value.email;
await api('/api/v1/admin/users', { method:'POST', body: JSON.stringify({ tenant: Number(state.activeTenant.value), ...state.userForm.value }) });
state.userForm.value = { role:'operator', email:'', password:'' };
state.toast('用户创建成功'); state.opSummary.value = `已创建用户:${createdUser}`;
loadUsers();
} catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('tenants'); }
};
const setUserStatus = async (id, st) => {
if (!confirm(`确认将用户 ${id} ${st===1?'启用':'停用'}?`)) return;
try { await api(`/api/v1/admin/users/${id}?status=${st}`, { method:'POST', body:'{}' }); state.toast('用户状态已更新'); state.opSummary.value = `用户 ${id} 已${st===1?'启用':'停用'}`; loadUsers(); }
catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); }
};
const resetUserPassword = async (id) => {
const p = prompt('请输入新密码(至少6位)');
if (!p) return;
try { await api(`/api/v1/admin/users/${id}/password`, { method:'POST', body: JSON.stringify({ password: p }) }); state.toast('密码已重置'); }
catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); }
};
const deleteUser = async (id) => {
if (!confirm('高风险操作:删除用户后不可恢复。\n确认继续?')) return;
try { await api(`/api/v1/admin/users/${id}/delete`, { method:'POST', body:'{}' }); state.toast('用户已删除'); state.opSummary.value = `用户 ${id} 已删除` ; loadUsers(); }
catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); }
};
const loadEnrolls = async () => {
state.beginTab('tenants');
try {
const d = await api('/api/v1/tenants/enroll');
state.enrolls.value = d.enrolls || [];
state.selectedEnrollIds.value = [];
if (!state.keepFailedPool.value) state.bulkFailedEnrollIds.value = [];
state.endTabOk('tenants', !state.enrolls.value.length);
} catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('tenants'); }
};
const createEnroll = async () => {
state.beginTab('tenants');
try {
const d = await api('/api/v1/tenants/enroll', { method:'POST', body:'{}' });
state.toast(`enroll_code: ${d.enroll_code || ''}`); state.opSummary.value = `已生成 enroll code(租户 ${state.activeTenant.value})`;
loadEnrolls();
} catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('tenants'); }
};
const setEnrollStatus = async (id, st) => {
try { await api(`/api/v1/enroll/consume/${id}?status=${st}`, { method:'POST', body:'{}' }); state.toast('enroll 状态已更新'); state.opSummary.value = `enroll ${id} 状态已更新为 ${st}`; loadEnrolls(); }
catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); }
};
const consumeEnroll = async () => {
state.toast('consumeEnroll 为客户端配网流程,控制台当前不直接调用');
};
const toggleAllKeys = () => {
const ids = (state.filteredKeys.value || []).map(k => Number(k.id)).filter(Boolean);
if (!ids.length) return;
if ((state.selectedKeyIds.value || []).length === ids.length) state.selectedKeyIds.value = [];
else state.selectedKeyIds.value = ids;
};
const toggleAllUsers = () => {
const ids = (state.filteredUsers.value || []).map(u => Number(u.id)).filter(Boolean);
if (!ids.length) return;
if ((state.selectedUserIds.value || []).length === ids.length) state.selectedUserIds.value = [];
else state.selectedUserIds.value = ids;
};
const toggleAllEnrolls = () => {
const ids = (state.filteredEnrolls.value || []).map(e => Number(e.id)).filter(Boolean);
if (!ids.length) return;
if ((state.selectedEnrollIds.value || []).length === ids.length) state.selectedEnrollIds.value = [];
else state.selectedEnrollIds.value = ids;
};
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const runWithRetry = async (fn, retries = 1) => {
let lastErr = null;
for (let i = 0; i <= retries; i++) {
try { return await fn(); }
catch (e) { lastErr = e; if (i < retries) await sleep(120); }
}
throw lastErr || new Error('unknown error');
};
const runBulk = async ({ ids, confirmText, onEach, afterReload, summaryLabel, setFailedIds }) => {
const list = (ids || []).slice();
if (!list.length) return false;
if (!confirm(confirmText)) return false;
state.setTabBusy('tenants', true);
let ok = 0, fail = 0;
const failedIds = [];
let cursor = 0;
const workers = Array.from({ length: Math.min(6, list.length) }, async () => {
while (cursor < list.length) {
const idx = cursor++;
const id = list[idx];
try {
await runWithRetry(() => onEach(id), 1);
ok++;
} catch (_) {
fail++;
failedIds.push(id);
}
}
});
await Promise.all(workers);
state.toast(`${summaryLabel}完成:成功 ${ok},失败 ${fail}${fail ? '(失败ID可重试)' : ''}`);
if (setFailedIds) setFailedIds(failedIds);
const failedShow = failedIds.slice(0, 8).join(',');
state.opSummary.value = `${summaryLabel}:成功 ${ok} / 失败 ${fail}${failedShow ? `|失败ID: ${failedShow}${failedIds.length>8?'...':''}` : ''}`;
try { await afterReload(); } finally { state.setTabBusy('tenants', false); }
return true;
};
const bulkSetKeyStatus = async (st) => {
const ids = (state.selectedKeyIds.value || []).slice();
if (!ids.length) return state.toast('请先选择至少 1 个 Key', 'error');
if (!state.activeTenant.value) return state.toast('请先填写 Tenant ID', 'error');
await runBulk({
ids,
confirmText: `批量操作确认:将 ${ids.length} 个 Key ${st===1?'启用':'停用'},是否继续?`,
onEach: (id) => api(`/api/v1/admin/tenants/${state.activeTenant.value}/keys/${id}?status=${st}`, { method:'POST', body:'{}' }),
afterReload: loadKeys,
summaryLabel: `Key 批量${st===1?'启用':'停用'}`,
setFailedIds: (arr) => { state.bulkFailedKeyIds.value = arr; }
});
};
const bulkSetUserStatus = async (st) => {
const ids = (state.selectedUserIds.value || []).slice();
if (!ids.length) return state.toast('请先选择至少 1 个用户', 'error');
await runBulk({
ids,
confirmText: `批量操作确认:将 ${ids.length} 个用户 ${st===1?'启用':'停用'},是否继续?`,
onEach: (id) => api(`/api/v1/admin/users/${id}?status=${st}`, { method:'POST', body:'{}' }),
afterReload: loadUsers,
summaryLabel: `用户批量${st===1?'启用':'停用'}`,
setFailedIds: (arr) => { state.bulkFailedUserIds.value = arr; }
});
};
const bulkSetEnrollStatus = async (st) => {
const ids = (state.selectedEnrollIds.value || []).slice();
if (!ids.length) return state.toast('请先选择至少 1 个 enroll', 'error');
await runBulk({
ids,
confirmText: `批量操作确认:将 ${ids.length} 个 enroll 置为状态 ${st},是否继续?`,
onEach: (id) => api(`/api/v1/enroll/consume/${id}?status=${st}`, { method:'POST', body:'{}' }),
afterReload: loadEnrolls,
summaryLabel: `enroll 批量状态变更`,
setFailedIds: (arr) => { state.bulkFailedEnrollIds.value = arr; }
});
};
const retryFailedKeyOps = async (st=0) => {
const ids = (state.bulkFailedKeyIds.value || []).slice();
if (!ids.length) return state.toast('没有可重试的 Key 失败项', 'error');
await runBulk({
ids,
confirmText: `重试确认:重试 ${ids.length} 个失败 Key 操作,是否继续?`,
onEach: (id) => api(`/api/v1/admin/tenants/${state.activeTenant.value}/keys/${id}?status=${st}`, { method:'POST', body:'{}' }),
afterReload: loadKeys,
summaryLabel: 'Key 失败项重试',
setFailedIds: (arr) => { state.bulkFailedKeyIds.value = arr; }
});
};
const retryFailedUserOps = async (st=0) => {
const ids = (state.bulkFailedUserIds.value || []).slice();
if (!ids.length) return state.toast('没有可重试的用户失败项', 'error');
await runBulk({
ids,
confirmText: `重试确认:重试 ${ids.length} 个失败用户操作,是否继续?`,
onEach: (id) => api(`/api/v1/admin/users/${id}?status=${st}`, { method:'POST', body:'{}' }),
afterReload: loadUsers,
summaryLabel: '用户失败项重试',
setFailedIds: (arr) => { state.bulkFailedUserIds.value = arr; }
});
};
const retryFailedEnrollOps = async (st=0) => {
const ids = (state.bulkFailedEnrollIds.value || []).slice();
if (!ids.length) return state.toast('没有可重试的 enroll 失败项', 'error');
await runBulk({
ids,
confirmText: `重试确认:重试 ${ids.length} 个失败 enroll 操作,是否继续?`,
onEach: (id) => api(`/api/v1/enroll/consume/${id}?status=${st}`, { method:'POST', body:'{}' }),
afterReload: loadEnrolls,
summaryLabel: 'enroll 失败项重试',
setFailedIds: (arr) => { state.bulkFailedEnrollIds.value = arr; }
});
};
const clearFailedPool = (scope='all') => {
if (scope === 'all' || scope === 'key') state.bulkFailedKeyIds.value = [];
if (scope === 'all' || scope === 'user') state.bulkFailedUserIds.value = [];
if (scope === 'all' || scope === 'enroll') state.bulkFailedEnrollIds.value = [];
state.toast(scope==='all' ? '全部失败池已清空' : '失败池已清空');
};
const saveSettings = async () => {
if (!state.isAdmin.value) return;
state.beginTab('settings');
try {
for (const k of Object.keys(state.settings.value || {})) {
await api('/api/v1/admin/settings', { method:'POST', body: JSON.stringify({ key: k, value: String(state.settings.value[k]) }) });
}
state.toast('高级设置已保存'); state.opSummary.value = '高级设置已保存';
state.settings.value = await api('/api/v1/admin/settings');
state.endTabOk('settings');
} catch (e) { state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('settings'); }
};
const loadAudit = async () => {
if (!state.isAdmin.value) return;
state.beginTab('audit');
try {
const p = new URLSearchParams();
const f = state.auditFilters.value || {};
if (state.auditTenant.value) p.set('tenant', String(state.auditTenant.value));
if (f.action) p.set('action', String(f.action));
if (f.actor_type) p.set('actor_type', String(f.actor_type));
if (f.actor_id) p.set('actor_id', String(f.actor_id));
if (f.target_type) p.set('target_type', String(f.target_type));
if (f.target_id) p.set('target_id', String(f.target_id));
if (f.from) p.set('from', String(f.from));
if (f.to) p.set('to', String(f.to));
p.set('limit', String(Number(f.limit || 100)));
p.set('offset', String(Number(f.offset || 0)));
const d = await api('/api/v1/admin/audit?' + p.toString());
state.audit.value = d.logs || [];
state.endTabOk('audit', !state.audit.value.length);
state.toast('审计日志已刷新');
} catch (e) { state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('audit'); }
};
const exportRowsAsCsv = (rows, tag='audit') => {
const esc = (v) => '"' + String(v ?? '').replace(/"/g, '""') + '"';
const head = ['id','actor_type','actor_id','action','target_type','target_id','detail','ip','created_at'];
const lines = [head.map(esc).join(',')];
for (const a of (rows || [])) {
lines.push([
a.id, a.actor_type, a.actor_id, a.action, a.target_type, a.target_id, a.detail, a.ip, a.created_at
].map(esc).join(','));
}
const csv = '' + lines.join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const t = new Date();
const pad = (n) => String(n).padStart(2, '0');
const fn = `${tag}-${t.getFullYear()}${pad(t.getMonth()+1)}${pad(t.getDate())}-${pad(t.getHours())}${pad(t.getMinutes())}${pad(t.getSeconds())}.csv`;
const a = document.createElement('a');
a.href = url;
a.download = fn;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
};
const exportAuditCsv = () => {
if (!state.isAdmin.value) return;
const rows = state.audit.value || [];
if (!rows.length) return state.toast('当前无审计数据可导出', 'error');
exportRowsAsCsv(rows, 'audit');
state.toast('审计 CSV 已导出');
state.opSummary.value = `已导出审计 CSV(${rows.length} 条)`;
};
const exportAuditFailuresCsv = () => {
if (!state.isAdmin.value) return;
const rows = (state.audit.value || []).filter(a => /denied|failed|error|kick|delete/i.test(String(a.action||'')));
if (!rows.length) return state.toast('当前无失败/高风险审计可导出', 'error');
exportRowsAsCsv(rows, 'audit-failures');
state.toast('失败事件 CSV 已导出');
state.opSummary.value = `已导出失败/高风险审计 CSV(${rows.length} 条)`;
};
const applyAuditPreset = async (preset) => {
const f = { action:'', actor_type:'', actor_id:'', target_type:'', target_id:'', from:'', to:'', limit:100, offset:0 };
state.auditTenant.value = '';
if (preset === 'login_failed') f.action = 'auth_login_failed';
else if (preset === 'denied') f.action = 'enroll_consume_denied';
else if (preset === 'connect_denied') f.action = 'connect_request_denied';
else if (preset === 'all') {
// keep default
}
state.auditPreset.value = preset || 'all';
localStorage.setItem('inp2p_audit_preset', state.auditPreset.value);
state.auditFilters.value = f;
await loadAudit();
};
const clearAuditFilters = async () => {
state.auditFilters.value = { action:'', actor_type:'', actor_id:'', target_type:'', target_id:'', from:'', to:'', limit:100, offset:0 };
state.auditTenant.value = '';
await loadAudit();
};
const updateCharts = () => {};
const actions = {
refreshAll, startTimer, stopTimer,
login, logout,
saveSDWAN, addSDWANNode, removeSDWANNode, addSubnetProxy, removeSubnetProxy, autoAssignIPs,
kickNode, renameNode, changeNodeIP, openNodeEdit, closeNodeEdit, submitNodeEdit,
openAppManager, pushAppConfigs, openConnect, doConnect,
createTenant, loadTenants, setTenantStatus, reassignTenantSubnet,
createKey, loadKeys, setKeyStatus,
createUser, loadUsers, setUserStatus, resetUserPassword, deleteUser,
createEnroll, loadEnrolls, setEnrollStatus, consumeEnroll,
saveSettings, loadAudit, clearAuditFilters, applyAuditPreset, exportAuditCsv, exportAuditFailuresCsv,
toggleAllKeys, toggleAllUsers, toggleAllEnrolls,
bulkSetKeyStatus, bulkSetUserStatus, bulkSetEnrollStatus,
retryFailedKeyOps, retryFailedUserOps, retryFailedEnrollOps, clearFailedPool,
updateCharts
};
return {
actions,
refs: { appPushNode, appPushRaw },
setSwitchTab
};
};
+87
View File
@@ -0,0 +1,87 @@
window.INP2P = window.INP2P || {};
window.INP2P.createApi = function createApi(state) {
const bearer = () => ({ Authorization: 'Bearer ' + (localStorage.getItem('t') || '') });
const inFlight = new Map();
const withTimeout = async (fetcher, timeoutMs=8000) => {
let timer = null;
try {
return await Promise.race([
fetcher(),
new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('请求超时')), timeoutMs); })
]);
} finally {
if (timer) clearTimeout(timer);
}
};
const shouldRetry = (method, err) => {
const m = String(method || 'GET').toUpperCase();
if (m !== 'GET') return false;
const msg = (err && err.message) ? String(err.message) : '';
return msg.includes('请求超时') || msg.includes('Failed to fetch') || msg.includes('NetworkError');
};
const doRequest = async (path, opt={}) => {
const headers = { 'Content-Type': 'application/json', ...(opt.headers||{}), ...bearer() };
const method = String(opt.method || 'GET').toUpperCase();
const timeoutMs = Number(opt.timeoutMs || (method === 'GET' ? 8000 : 12000));
const runOnce = async () => {
const r = await withTimeout(() => fetch(path, { ...opt, method, headers }), timeoutMs);
let d = {};
try { d = await r.json(); } catch(_) {}
if (!r.ok) {
if (r.status === 401) {
state.loggedIn.value = false;
state.logEvent && state.logEvent('api_error', { path, status:r.status, message:'401 登录已过期' });
throw new Error('401 登录已过期');
}
const em = d.message || ('HTTP ' + r.status);
state.logEvent && state.logEvent('api_error', { path, status:r.status, message: em });
throw new Error(em);
}
state.logEvent && state.logEvent('api_ok', { path, status:r.status, method });
return d;
};
try {
return await runOnce();
} catch (e) {
if (shouldRetry(method, e)) {
state.logEvent && state.logEvent('api_retry', { path, method, reason: e.message || 'retry' });
return await runOnce();
}
throw e;
}
};
const api = async (path, opt={}) => {
const method = String(opt.method || 'GET').toUpperCase();
const dedupe = opt.dedupe !== false && method === 'GET';
const key = dedupe ? `${method}:${path}` : '';
if (dedupe && inFlight.has(key)) {
state.logEvent && state.logEvent('api_dedupe_hit', { path, method });
return inFlight.get(key);
}
const p = doRequest(path, opt).finally(() => {
if (dedupe) inFlight.delete(key);
});
if (dedupe) inFlight.set(key, p);
return p;
};
const loginByPassword = async (username, password) => {
return withTimeout(() => fetch('/api/v1/auth/login', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ username, password })
}), 10000).then(r=>r.json());
};
return { api, loginByPassword };
};
+211
View File
@@ -0,0 +1,211 @@
console.log("[INP2P] boot", typeof Vue, Vue && Vue.version);
const { createApp, onMounted, onBeforeUnmount, watch } = Vue;
createApp({
setup(){
const state = window.INP2P.createState(Vue);
const { api, loginByPassword } = window.INP2P.createApi(state);
const { actions, refs, setSwitchTab } = window.INP2P.createActions(state, { api, loginByPassword, Vue });
const switchTab = async (next) => {
state.tab.value = next;
if (!state.loggedIn.value) return;
try {
if (next === 'tenants') await actions.loadTenants();
else if (next === 'apikeys') await actions.loadKeys();
else if (next === 'users') await actions.loadUsers();
else if (next === 'enroll') await actions.loadEnrolls();
else if (next === 'settings' && state.isAdmin.value) {
state.beginTab('settings');
try { state.settings.value = await api('/api/v1/admin/settings'); state.endTabOk('settings'); }
catch(_) { state.endTabErr('settings'); }
} else if (next === 'audit' && state.isAdmin.value) {
await actions.loadAudit();
} else if (next === 'sdwan') {
state.beginTab('sdwan');
try {
state.sd.value = await api('/api/v1/sdwans');
state.endTabOk('sdwan', !(state.sd.value?.nodes||[]).length);
} catch (e) { state.toast('SDWAN 加载失败: ' + (e.message || ''), 'error'); state.endTabErr('sdwan'); }
}
} catch (e) {
state.toast(e.message || '切换页面失败', 'error');
}
};
setSwitchTab(switchTab);
watch(state.refreshSec, actions.startTimer);
watch(state.loggedIn, (v) => {
if (v) actions.startTimer();
else actions.stopTimer();
});
watch(state.tenantKeyword, (v) => localStorage.setItem('inp2p_kw_tenant', String(v || '')));
watch(state.keyKeyword, (v) => localStorage.setItem('inp2p_kw_key', String(v || '')));
watch(state.userKeyword, (v) => localStorage.setItem('inp2p_kw_user', String(v || '')));
watch(state.enrollKeyword, (v) => localStorage.setItem('inp2p_kw_enroll', String(v || '')));
watch(state.keepFailedPool, (v) => localStorage.setItem('inp2p_keep_failed_pool', v ? '1' : '0'));
onMounted(() => {
state.applyTheme && state.applyTheme();
// 全局兜底:前端异常时保留可见提示,避免“瞬间空白”
window.addEventListener('error', (ev) => {
const m = (ev && ev.message) ? ev.message : 'unknown';
state.toast('前端异常:' + m, 'error');
state.logEvent && state.logEvent('error', { tab:'global', action:'window_error', message:m });
});
window.addEventListener('unhandledrejection', (ev) => {
const m = ev && ev.reason && ev.reason.message ? ev.reason.message : String(ev && ev.reason ? ev.reason : 'unknown');
state.toast('请求异常:' + m, 'error');
state.logEvent && state.logEvent('error', { tab:'global', action:'promise_rejection', message:m });
});
if (localStorage.getItem('t')) {
state.loggedIn.value = true;
actions.refreshAll();
switchTab(state.tab.value);
}
});
onBeforeUnmount(() => {
});
return {
buildVersion: state.buildVersion,
tab: state.tab,
filteredTabs: state.filteredTabs,
loggedIn: state.loggedIn,
loginBusy: state.loginBusy,
globalBusy: state.globalBusy,
tabBusy: state.tabBusy,
tabState: state.tabState,
debugMode: state.debugMode,
debugEvents: state.debugEvents,
clearDebugEvents: state.clearDebugEvents,
role: state.role,
roleLabel: state.roleLabel,
status: state.status,
tokenType: state.tokenType,
tenantId: state.tenantId,
loginUser: state.loginUser,
loginPass: state.loginPass,
loginErr: state.loginErr,
opSummary: state.opSummary,
refreshSec: state.refreshSec,
theme: state.theme,
health: state.health,
stats: state.stats,
nodes: state.nodes,
nodeKeyword: state.nodeKeyword,
filteredNodes: state.filteredNodes,
sd: state.sd,
connectForm: state.connectForm,
nodeEditVisible: state.nodeEditVisible,
nodeEditMode: state.nodeEditMode,
nodeEditTitle: state.nodeEditTitle,
nodeEditNode: state.nodeEditNode,
nodeEditValue: state.nodeEditValue,
tenants: state.tenants,
activeTenant: state.activeTenant,
keys: state.keys,
users: state.users,
enrolls: state.enrolls,
selectedKeyIds: state.selectedKeyIds,
selectedUserIds: state.selectedUserIds,
selectedEnrollIds: state.selectedEnrollIds,
bulkFailedKeyIds: state.bulkFailedKeyIds,
bulkFailedUserIds: state.bulkFailedUserIds,
bulkFailedEnrollIds: state.bulkFailedEnrollIds,
keepFailedPool: state.keepFailedPool,
auditPreset: state.auditPreset,
tenantKeyword: state.tenantKeyword,
keyKeyword: state.keyKeyword,
userKeyword: state.userKeyword,
enrollKeyword: state.enrollKeyword,
filteredTenants: state.filteredTenants,
filteredKeys: state.filteredKeys,
filteredUsers: state.filteredUsers,
filteredEnrolls: state.filteredEnrolls,
tenantForm: state.tenantForm,
keyForm: state.keyForm,
userForm: state.userForm,
settings: state.settings,
tenantFormErr: state.tenantFormErr,
keyFormErr: state.keyFormErr,
userFormErr: state.userFormErr,
sdwanFormErr: state.sdwanFormErr,
audit: state.audit,
auditTenant: state.auditTenant,
auditFilters: state.auditFilters,
auditActionOptions: state.auditActionOptions,
onlineMap: state.onlineMap,
nodeStatus: state.nodeStatus,
isOnline: state.isOnline,
nodeOnline: state.nodeOnline,
nodeStatusTextOf: state.nodeStatusTextOf,
nodeStatusClassOf: state.nodeStatusClassOf,
natText: state.natText,
uptime: state.uptime,
fmtTime: state.fmtTime,
appPushNode: refs.appPushNode,
appPushRaw: refs.appPushRaw,
login: actions.login,
logout: actions.logout,
refreshAll: actions.refreshAll,
switchTab,
toggleTheme: state.toggleTheme,
saveSDWAN: actions.saveSDWAN,
addSDWANNode: actions.addSDWANNode,
removeSDWANNode: actions.removeSDWANNode,
addSubnetProxy: actions.addSubnetProxy,
removeSubnetProxy: actions.removeSubnetProxy,
autoAssignIPs: actions.autoAssignIPs,
kickNode: actions.kickNode,
renameNode: actions.renameNode,
changeNodeIP: actions.changeNodeIP,
closeNodeEdit: actions.closeNodeEdit,
submitNodeEdit: actions.submitNodeEdit,
openAppManager: actions.openAppManager,
pushAppConfigs: actions.pushAppConfigs,
openConnect: actions.openConnect,
doConnect: actions.doConnect,
createTenant: actions.createTenant,
loadTenants: actions.loadTenants,
setTenantStatus: actions.setTenantStatus,
reassignTenantSubnet: actions.reassignTenantSubnet,
createKey: actions.createKey,
loadKeys: actions.loadKeys,
setKeyStatus: actions.setKeyStatus,
createUser: actions.createUser,
loadUsers: actions.loadUsers,
setUserStatus: actions.setUserStatus,
resetUserPassword: actions.resetUserPassword,
deleteUser: actions.deleteUser,
createEnroll: actions.createEnroll,
loadEnrolls: actions.loadEnrolls,
setEnrollStatus: actions.setEnrollStatus,
consumeEnroll: actions.consumeEnroll,
saveSettings: actions.saveSettings,
loadAudit: actions.loadAudit,
clearAuditFilters: actions.clearAuditFilters,
applyAuditPreset: actions.applyAuditPreset,
exportAuditCsv: actions.exportAuditCsv,
exportAuditFailuresCsv: actions.exportAuditFailuresCsv,
toggleAllKeys: actions.toggleAllKeys,
toggleAllUsers: actions.toggleAllUsers,
toggleAllEnrolls: actions.toggleAllEnrolls,
bulkSetKeyStatus: actions.bulkSetKeyStatus,
bulkSetUserStatus: actions.bulkSetUserStatus,
bulkSetEnrollStatus: actions.bulkSetEnrollStatus,
retryFailedKeyOps: actions.retryFailedKeyOps,
retryFailedUserOps: actions.retryFailedUserOps,
retryFailedEnrollOps: actions.retryFailedEnrollOps,
clearFailedPool: actions.clearFailedPool,
updateCharts: actions.updateCharts
};
}
}).mount('#app');
+244
View File
@@ -0,0 +1,244 @@
window.INP2P = window.INP2P || {};
window.INP2P.createState = function createState(Vue) {
const { ref, computed } = Vue;
const buildVersion = ref('20260307-2246');
const tab = ref('dashboard');
const tabs = [
{id:'dashboard',name:'仪表盘'},{id:'nodes',name:'设备管理'},{id:'sdwan',name:'组网'},{id:'p2p',name:'连接调试'},
{id:'apikeys',name:'API 密钥'},{id:'users',name:'用户'},{id:'enroll',name:'注册码'},
{id:'settings',name:'高级设置'},{id:'audit',name:'审计日志'}
];
const loggedIn = ref(false), msg = ref(''), msgType = ref('ok');
const opSummary = ref('');
const debugMode = ref(new URLSearchParams(location.search).get('debug') === '1');
const debugEvents = ref([]);
const logEvent = (type, payload={}) => {
const item = { id: Date.now() + Math.random(), ts: new Date().toISOString(), type, ...payload };
debugEvents.value = [item, ...debugEvents.value].slice(0, 20);
if (debugMode.value) {
if (type === 'error' || type === 'api_error') console.error('[INP2P]', item);
else console.log('[INP2P]', item);
}
};
const clearDebugEvents = () => { debugEvents.value = []; };
const loginBusy = ref(false), globalBusy = ref(false);
const tabBusy = ref({ sdwan:false, p2p:false, tenants:false, settings:false, audit:false });
const tabState = ref({});
const setTabBusy = (k, v) => { tabBusy.value = { ...tabBusy.value, [k]: !!v }; };
const beginTab = (k) => { setTabBusy(k, true); tabState.value = { ...tabState.value, [k]: 'loading' }; };
const endTabOk = (k, isEmpty=false) => { setTabBusy(k, false); tabState.value = { ...tabState.value, [k]: (isEmpty ? 'empty' : 'success') }; };
const endTabErr = (k) => { setTabBusy(k, false); tabState.value = { ...tabState.value, [k]: 'error' }; };
const role = ref(''), status = ref(1);
const loginUser = ref(''), loginPass = ref(''), loginErr = ref('');
const theme = ref(localStorage.getItem('inp2p_theme') || 'b');
const applyTheme = () => {
document.documentElement.setAttribute('data-theme', theme.value === 'a' ? 'a' : 'b');
};
const toggleTheme = () => {
theme.value = theme.value === 'a' ? 'b' : 'a';
localStorage.setItem('inp2p_theme', theme.value);
applyTheme();
};
const refreshSec = ref(15), timer = ref(null);
const health = ref({}), stats = ref({}), nodes = ref([]), nodeKeyword = ref('');
const sd = ref({ enabled:false, name:'sdwan-main', gatewayCIDR:'10.10.0.0/24', mode:'mesh', hubNode:'', mtu:1420, nodes:[], routes:['10.10.0.0/24'], subnetProxies:[] });
const connectForm = ref({ from:'', to:'', srcPort:80, dstPort:80, appName:'manual-connect' });
const nodeEditVisible = ref(false);
const nodeEditMode = ref('alias');
const nodeEditTitle = ref('编辑设备');
const nodeEditNode = ref(null);
const nodeEditValue = ref('');
const tenants = ref([]), activeTenant = ref(1), keys = ref([]), users = ref([]), enrolls = ref([]);
const tenantKeyword = ref(localStorage.getItem('inp2p_kw_tenant') || '');
const keyKeyword = ref(localStorage.getItem('inp2p_kw_key') || '');
const userKeyword = ref(localStorage.getItem('inp2p_kw_user') || '');
const enrollKeyword = ref(localStorage.getItem('inp2p_kw_enroll') || '');
const tenantForm = ref({ name:'', admin_password:'', operator_password:'' });
const keyForm = ref({ scope:'all', ttl:0 });
const userForm = ref({ role:'operator', email:'', password:'' });
const selectedKeyIds = ref([]);
const selectedUserIds = ref([]);
const selectedEnrollIds = ref([]);
const bulkFailedKeyIds = ref([]);
const bulkFailedUserIds = ref([]);
const bulkFailedEnrollIds = ref([]);
const keepFailedPool = ref(localStorage.getItem('inp2p_keep_failed_pool') !== '0');
const auditPreset = ref(localStorage.getItem('inp2p_audit_preset') || 'all');
const tokenType = ref('');
const tenantId = ref(0);
const subnet = ref('');
const settings = ref({
advanced_impersonate:'0', advanced_force_network:'0', advanced_cross_tenant:'0',
advanced_override_enabled:'0', allow_apikey_mutate_sdwan:'0', allow_operator_kick_cross_node:'0', allow_connect_cross_tenant:'0'
});
const audit = ref([]);
const auditTenant = ref('');
const auditFilters = ref({
action:'', actor_type:'', actor_id:'', target_type:'', target_id:'', from:'', to:'',
limit: 100, offset: 0
});
const auditActionOptions = [
'auth_login_success','auth_login_failed','tenant_create','apikey_create',
'user_create','enroll_create','enroll_status','enroll_consume','enroll_consume_denied',
'node_alias_update','node_ip_update','node_kick','connect_request_denied'
];
const isAdmin = computed(() => role.value === 'admin' && tenantId.value === 1);
const roleLabel = computed(() => isAdmin.value ? '系统管理' : '子网管理');
const filteredTabs = computed(() => isAdmin.value ? tabs : tabs.filter(t => !['apikeys','users','enroll','settings','audit'].includes(t.id)));
const filteredNodes = computed(() => {
const k = (nodeKeyword.value || '').trim().toLowerCase();
if (!k) return nodes.value;
return nodes.value.filter(n => (n.name||'').toLowerCase().includes(k) || (n.publicIP||'').toLowerCase().includes(k));
});
const filteredTenants = computed(() => {
const k = (tenantKeyword.value || '').trim().toLowerCase();
if (!k) return tenants.value;
return (tenants.value || []).filter(t =>
String(t.id||'').includes(k) || String(t.name||'').toLowerCase().includes(k) || String(t.subnet||'').toLowerCase().includes(k)
);
});
const filteredKeys = computed(() => {
const k = (keyKeyword.value || '').trim().toLowerCase();
if (!k) return keys.value;
return (keys.value || []).filter(x =>
String(x.id||'').includes(k) || String(x.scope||'').toLowerCase().includes(k)
);
});
const filteredUsers = computed(() => {
const k = (userKeyword.value || '').trim().toLowerCase();
if (!k) return users.value;
return (users.value || []).filter(u =>
String(u.id||'').includes(k) || String(u.role||'').toLowerCase().includes(k) || String(u.email||'').toLowerCase().includes(k)
);
});
const filteredEnrolls = computed(() => {
const k = (enrollKeyword.value || '').trim().toLowerCase();
if (!k) return enrolls.value;
return (enrolls.value || []).filter(e =>
String(e.id||'').includes(k) || String(e.code||'').toLowerCase().includes(k)
);
});
const onlineMap = computed(() => new Set((nodes.value || []).map(n => n.name)));
const nodeStatus = (name) => (name && onlineMap.value.has(name)) ? '在线' : '离线';
const isOnline = (name) => (name && onlineMap.value.has(name));
const nodeOnline = (n) => {
if (!n) return false;
if (typeof n.online === 'boolean') return n.online;
if (n.status === 1 || String(n.status).toLowerCase() === 'online') return true;
if (n.status === 0 || String(n.status).toLowerCase() === 'offline') return false;
if (n.lastSeen && (Date.now() - new Date(n.lastSeen).getTime()) > 120000) return false;
return !!(n.name && onlineMap.value.has(n.name));
};
const nodeStatusTextOf = (n) => nodeOnline(n) ? '在线' : '离线';
const nodeStatusClassOf = (n) => nodeOnline(n) ? 'status-online' : 'status-offline';
const humanizeError = (msg) => {
const t = String(msg || '').trim();
if (!t) return '请求失败,请稍后重试';
if (/unauthorized|invalid credentials|token/i.test(t)) return '认证已失效,请重新登录';
if (/forbidden|permission|denied/i.test(t)) return '权限不足,当前角色不可执行此操作';
if (/timeout|timed out/i.test(t)) return '请求超时,请检查网络后重试';
if (/network|failed to fetch|load failed/i.test(t)) return '网络异常,请稍后再试';
if (/bad request|invalid/i.test(t)) return '参数不正确,请检查输入项';
return t;
};
const toast = (text, t='ok') => {
msg.value = text;
msgType.value = t;
setTimeout(() => { if (msg.value === text) msg.value = ''; }, 2500);
};
const validateUsername = (v) => {
const x = (v || '').trim();
if (!/^[A-Za-z]{6,}$/.test(x)) return '用户名需仅字母且≥6位';
return '';
};
const validateRequired = (v, label='字段') => {
if (v === null || v === undefined || String(v).trim() === '') return `${label}不能为空`;
return '';
};
const validateSDWAN = (sdwan) => {
const x = sdwan || {};
if (!x.gatewayCIDR || !String(x.gatewayCIDR).includes('/')) return '网段格式不正确(如 10.10.0.0/24)';
const dup = new Set();
for (const n of (x.nodes || [])) {
if (!n || !n.node) continue;
if (dup.has(n.node)) return `SDWAN 节点重复:${n.node}`;
dup.add(n.node);
if (n.ip && !/^\d+\.\d+\.\d+\.\d+$/.test(String(n.ip))) return `节点IP格式错误:${n.ip}`;
}
return '';
};
const tenantFormErr = computed(() => {
const n = (tenantForm.value.name || '').trim();
return n ? '' : '租户名不能为空';
});
const keyFormErr = computed(() => {
const sc = (keyForm.value.scope || '').trim();
return sc ? '' : 'scope 不能为空';
});
const userFormErr = computed(() => {
if (!(userForm.value.email || '').trim()) return 'email/username 不能为空';
if (!(userForm.value.password || '').trim()) return 'password 不能为空';
if (String(userForm.value.password || '').length < 6) return 'password 至少 6 位';
return '';
});
const sdwanFormErr = computed(() => validateSDWAN(sd.value));
const natText = (v) => {
const x = String(v || '').toLowerCase();
if (!x) return 'Unknown';
if (x.includes('cone')) return 'Cone';
if (x.includes('symmetric')) return 'Symmetric';
if (x.includes('easy')) return 'Easy';
return x;
};
const uptime = ts => {
if(!ts) return '-';
const sec = Math.max(0, Math.floor((Date.now() - new Date(ts).getTime()) / 1000));
if(sec < 60) return sec + 's';
if(sec < 3600) return Math.floor(sec/60) + 'm';
if(sec < 86400) return Math.floor(sec/3600) + 'h';
return Math.floor(sec/86400) + 'd';
};
const fmtTime = t => t ? new Date(t).toLocaleString() : '-';
return {
buildVersion, tab, tabs,
loggedIn, msg, msgType, opSummary, loginBusy, globalBusy, tabBusy, tabState,
setTabBusy, beginTab, endTabOk, endTabErr,
role, status, loginUser, loginPass, loginErr, refreshSec, timer,
theme, applyTheme, toggleTheme,
health, stats, nodes, nodeKeyword, sd, connectForm,
nodeEditVisible, nodeEditMode, nodeEditTitle, nodeEditNode, nodeEditValue,
tenants, activeTenant, keys, users, enrolls, tenantForm, keyForm, userForm,
selectedKeyIds, selectedUserIds, selectedEnrollIds,
bulkFailedKeyIds, bulkFailedUserIds, bulkFailedEnrollIds, keepFailedPool, auditPreset,
tenantKeyword, keyKeyword, userKeyword, enrollKeyword,
tokenType, tenantId, subnet, settings, audit, auditTenant,
auditFilters, auditActionOptions,
isAdmin, roleLabel, filteredTabs, filteredNodes, filteredTenants, filteredKeys, filteredUsers, filteredEnrolls,
onlineMap, nodeStatus, isOnline, nodeOnline, nodeStatusTextOf, nodeStatusClassOf,
debugMode, debugEvents, logEvent, clearDebugEvents,
validateUsername, validateRequired, validateSDWAN,
tenantFormErr, keyFormErr, userFormErr, sdwanFormErr,
humanizeError, toast, natText, uptime, fmtTime
};
};
+628 -481
View File
File diff suppressed because one or more lines are too long