feat: 资产 CRUD API + 统计接口 + Vue3 管理前端(可选 API Key 鉴权)
This commit is contained in:
@@ -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
Reference in New Issue
Block a user