feat: 资产 CRUD API + 统计接口 + Vue3 管理前端(可选 API Key 鉴权)

This commit is contained in:
gouki
2026-08-01 15:39:13 +00:00
parent e29154518b
commit 1dac5d8735
20 changed files with 1105 additions and 3 deletions
+157
View File
@@ -0,0 +1,157 @@
/* vps-manager 前端逻辑(Vue 3 全局构建) */
const { createApp, ref, reactive, onMounted } = Vue;
const CFG = window.APP_CONFIG || { appName: 'VPS 资产管理系统' };
// 若服务端 .env 配置了 API_KEY,请在此填写以通过写操作鉴权
const API_KEY = '';
const TYPE_LABELS = { vps: 'VPS', domain: '域名', ai_agent: 'AI账号', cloudflare: 'Cloudflare', other: '其他' };
const STATUS_LABELS = { active: '使用中', expired: '已过期', stopped: '已停止', cancelled: '已注销', unknown: '未知' };
async function api(path, options = {}) {
const headers = { 'Content-Type': 'application/json' };
if (API_KEY) headers['X-API-Key'] = API_KEY;
const res = await fetch('/api' + path, { ...options, headers: { ...headers, ...(options.headers || {}) } });
if (!res.ok) {
let msg = res.statusText;
try { const e = await res.json(); msg = (typeof e.detail === 'string' ? e.detail : JSON.stringify(e.detail)) || msg; } catch (_) {}
throw new Error(msg);
}
if (res.status === 204) return null;
return res.json();
}
function emptyForm() {
return {
name: '', asset_type: 'vps', provider: '', account: '', expiry_date: '',
auto_renew: false, cost: 0, currency: 'USD', status: 'active', is_archived: false, remark: '',
vps_detail: { ip_address: '', tailscale_ip: '', region: '', os: '', cpu_cores: 1, memory_gb: 1, disk_gb: 20, bandwidth_gb: null, ssh_port: 22, panel_url: '' },
domain_detail: { domain_name: '', registrar: '', dns_provider: '', cloudflare_account: '', is_using: true, redirect_target: '' },
ai_detail: { provider: '', api_key: '', plan: '', balance: null, currency: 'USD', monthly_usage: null, monthly_limit: null },
};
}
// 空字符串转 null(可选字段),保留数字与布尔
function cleanDetail(obj) {
const out = {};
for (const [k, v] of Object.entries(obj)) {
out[k] = (v === '' ? null : v);
}
return out;
}
createApp({
setup() {
const appName = CFG.appName;
const assets = ref([]);
const overview = ref({});
const expiring = ref([]);
const loading = ref(false);
const search = ref('');
const filterType = ref('');
const filterStatus = ref('');
const showModal = ref(false);
const editing = ref(null);
const form = reactive(emptyForm());
const error = ref('');
const typeLabels = TYPE_LABELS;
const statusLabels = STATUS_LABELS;
async function loadAssets() {
loading.value = true;
try {
const params = new URLSearchParams();
if (filterType.value) params.set('asset_type', filterType.value);
if (filterStatus.value) params.set('status', filterStatus.value);
if (search.value) params.set('q', search.value);
params.set('sort', 'expiry_date');
params.set('order', 'asc');
assets.value = await api('/assets?' + params.toString());
} catch (e) { error.value = e.message; } finally { loading.value = false; }
}
async function loadOverview() {
try { overview.value = await api('/stats/overview'); } catch (e) { error.value = e.message; }
}
async function loadExpiring() {
try { expiring.value = await api('/stats/expiring?days=30'); } catch (e) { error.value = e.message; }
}
async function loadAll() { await Promise.all([loadAssets(), loadOverview(), loadExpiring()]); }
function resetForm() { Object.assign(form, emptyForm()); }
function openCreate() { resetForm(); editing.value = null; showModal.value = true; }
function openEdit(a) {
resetForm();
editing.value = a.id;
['name', 'asset_type', 'provider', 'account', 'expiry_date', 'auto_renew', 'cost', 'currency', 'status', 'is_archived', 'remark']
.forEach(k => { form[k] = a[k]; });
if (a.vps_detail) Object.assign(form.vps_detail, a.vps_detail);
if (a.domain_detail) Object.assign(form.domain_detail, a.domain_detail);
if (a.ai_detail) Object.assign(form.ai_detail, a.ai_detail);
showModal.value = true;
}
function buildPayload() {
const p = {
name: form.name, asset_type: form.asset_type, provider: form.provider,
account: form.account || null, expiry_date: form.expiry_date || null,
auto_renew: !!form.auto_renew, cost: Number(form.cost) || 0, currency: form.currency,
status: form.status, is_archived: !!form.is_archived, remark: form.remark || null,
};
if (form.asset_type === 'vps') p.vps_detail = cleanDetail(form.vps_detail);
else if (form.asset_type === 'domain') p.domain_detail = cleanDetail(form.domain_detail);
else if (form.asset_type === 'ai_agent') p.ai_detail = cleanDetail(form.ai_detail);
return p;
}
async function saveAsset() {
error.value = '';
try {
const payload = buildPayload();
if (editing.value) {
await api('/assets/' + editing.value, { method: 'PUT', body: JSON.stringify(payload) });
} else {
await api('/assets', { method: 'POST', body: JSON.stringify(payload) });
}
showModal.value = false;
await loadAll();
} catch (e) { error.value = e.message; }
}
async function deleteAsset(a) {
if (!confirm('确认删除资产「' + a.name + '」?此操作不可恢复。')) return;
error.value = '';
try { await api('/assets/' + a.id, { method: 'DELETE' }); await loadAll(); }
catch (e) { error.value = e.message; }
}
// 样式辅助
function typeBadge(t) {
return { vps: 'bg-blue-100 text-blue-700', domain: 'bg-green-100 text-green-700', ai_agent: 'bg-purple-100 text-purple-700', cloudflare: 'bg-orange-100 text-orange-700', other: 'bg-slate-100 text-slate-600' }[t] || 'bg-slate-100 text-slate-600';
}
function statusBadge(s) {
return { active: 'bg-green-100 text-green-700', expired: 'bg-red-100 text-red-700', stopped: 'bg-slate-200 text-slate-600', cancelled: 'bg-slate-200 text-slate-500', unknown: 'bg-yellow-100 text-yellow-700' }[s] || 'bg-slate-100 text-slate-600';
}
function expiryText(d) {
if (d === null || d === undefined) return 'text-slate-400';
if (d < 0) return 'text-red-600 font-bold';
if (d <= 7) return 'text-red-600 font-semibold';
if (d <= 30) return 'text-amber-600';
return 'text-slate-600';
}
function expiryBadge(d) {
if (d < 0) return 'bg-red-100 text-red-700';
if (d <= 7) return 'bg-red-100 text-red-700';
if (d <= 30) return 'bg-amber-100 text-amber-700';
return 'bg-slate-100 text-slate-600';
}
onMounted(loadAll);
return {
appName, assets, overview, expiring, loading, search, filterType, filterStatus,
showModal, editing, form, error, typeLabels, statusLabels,
loadAssets, openCreate, openEdit, saveAsset, deleteAsset,
typeBadge, statusBadge, expiryText, expiryBadge,
};
},
}).mount('#app');
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long