Files
vps-manager/static/js/app.js
T

815 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* vps-manager 前端应用(Vue 3 组件化) */
const { createApp, reactive, ref, computed, onMounted, watch } = Vue;
const Api = window.VpsApi;
const Fmt = window.VpsFmt;
/* ---------------- 全局状态 ---------------- */
const store = reactive({
view: 'dashboard',
assets: [],
providers: [],
overview: {},
expiring: [],
serverMetrics: {},
search: '',
filterType: '',
filterStatus: '',
dark: localStorage.getItem('vps_dark') === '1',
loading: false,
error: '',
assetModal: { show: false, editing: null, form: null },
providerModal: { show: false, editing: null, form: null },
});
function applyDark() {
document.documentElement.classList.toggle('dark', store.dark);
localStorage.setItem('vps_dark', store.dark ? '1' : '0');
}
/* ---------------- 表单工厂 ---------------- */
function emptyAssetForm() {
return {
name: '', asset_type: 'vps', provider: '', provider_id: null, renewal_cycle: 'monthly',
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: '', ssh_user: '', login_method: 'key', ssh_key: '', password: '', purpose: '' },
domain_detail: { domain_name: '', registrar: '', dns_provider: '', cloudflare_account: '', is_using: true, redirect_target: '', bind_asset_id: null },
ai_detail: { provider: '', api_key: '', plan: '', balance: null, currency: 'USD', monthly_usage: null, monthly_limit: null },
};
}
function buildAssetPayload(f) {
const p = {
name: f.name, asset_type: f.asset_type, provider: f.provider,
provider_id: f.provider_id || null, renewal_cycle: f.renewal_cycle || null,
account: f.account || null, expiry_date: f.expiry_date || null,
auto_renew: !!f.auto_renew, cost: Number(f.cost) || 0, currency: f.currency,
status: f.status, is_archived: !!f.is_archived, remark: f.remark || null,
};
if (f.asset_type === 'vps') {
const v = f.vps_detail;
p.vps_detail = { ip_address: v.ip_address, tailscale_ip: v.tailscale_ip || null, region: v.region || null, os: v.os || null, cpu_cores: Number(v.cpu_cores) || 1, memory_gb: Number(v.memory_gb) || 1, disk_gb: Number(v.disk_gb) || 20, bandwidth_gb: v.bandwidth_gb ? Number(v.bandwidth_gb) : null, ssh_port: Number(v.ssh_port) || 22, panel_url: v.panel_url || null, ssh_user: v.ssh_user || null, login_method: v.login_method || 'key', ssh_key: v.ssh_key || null, password: v.password || null, purpose: v.purpose || null };
} else if (f.asset_type === 'domain') {
const d = f.domain_detail;
p.domain_detail = { domain_name: d.domain_name, registrar: d.registrar || null, dns_provider: d.dns_provider || null, cloudflare_account: d.cloudflare_account || null, is_using: !!d.is_using, redirect_target: d.redirect_target || null, bind_asset_id: d.bind_asset_id || null };
} else if (f.asset_type === 'ai_agent') {
const a = f.ai_detail;
p.ai_detail = { provider: a.provider, api_key: a.api_key || null, plan: a.plan || null, balance: a.balance ? Number(a.balance) : null, currency: a.currency || 'USD', monthly_usage: a.monthly_usage ? Number(a.monthly_usage) : null, monthly_limit: a.monthly_limit ? Number(a.monthly_limit) : null };
}
return p;
}
/* ---------------- 数据加载 ---------------- */
async function loadAssets() {
const params = new URLSearchParams();
if (store.filterType) params.set('asset_type', store.filterType);
if (store.filterStatus) params.set('status', store.filterStatus);
if (store.search) params.set('q', store.search);
params.set('sort', 'expiry_date'); params.set('order', 'asc');
store.assets = await Api.get('/assets?' + params.toString());
}
async function loadProviders() { store.providers = await Api.get('/providers'); }
async function loadOverview() { store.overview = await Api.get('/stats/overview'); }
async function loadExpiring() { store.expiring = await Api.get('/stats/expiring?days=30'); }
async function loadAll() {
store.loading = true; store.error = '';
try { await Promise.all([loadAssets(), loadProviders(), loadOverview(), loadExpiring()]); }
catch (e) { store.error = e.message; } finally { store.loading = false; }
}
/* ---------------- 资产操作 ---------------- */
function openAssetCreate(presetType) {
const form = emptyAssetForm();
if (presetType) form.asset_type = presetType;
store.assetModal = { show: true, editing: null, form };
}
function openAICreate(provider) {
const form = emptyAssetForm();
form.asset_type = 'ai_agent';
if (provider) {
form.provider = provider;
form.ai_detail.provider = provider;
const p = store.providers.find(x => x.slug === provider);
if (p) form.provider_id = p.id;
}
store.assetModal = { show: true, editing: null, form };
}
function openAssetEdit(a) {
const form = emptyAssetForm();
['name', 'asset_type', 'provider', 'provider_id', 'renewal_cycle', '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);
form.vps_detail.ssh_key = ''; form.vps_detail.password = '';
store.assetModal = { show: true, editing: a.id, form };
}
async function saveAsset() {
store.error = '';
const f = store.assetModal.form;
try {
const payload = buildAssetPayload(f);
if (store.assetModal.editing) await Api.put('/assets/' + store.assetModal.editing, payload);
else await Api.post('/assets', payload);
store.assetModal.show = false;
await loadAll();
} catch (e) { store.error = e.message; }
}
async function deleteAsset(a) {
if (!confirm('确认删除资产「' + a.name + '」?此操作不可恢复。')) return;
store.error = '';
try { await Api.del('/assets/' + a.id); await loadAll(); }
catch (e) { store.error = e.message; }
}
/* ---------------- 平台操作 ---------------- */
function openProviderCreate() {
store.providerModal = { show: true, editing: null, form: { slug: '', name: '', name_en: '', category: 'vps', website: '', console_url: '', sdk_type: '', enabled: true, remark: '', api_config: '' } };
}
function openProviderEdit(p) {
store.providerModal = { show: true, editing: p.id, form: { slug: p.slug, name: p.name, name_en: p.name_en || '', category: p.category, website: p.website || '', console_url: p.console_url || '', sdk_type: p.sdk_type || '', enabled: p.enabled, remark: p.remark || '', api_config: '' } };
}
async function saveProvider() {
store.error = '';
const f = store.providerModal.form;
const payload = { slug: f.slug, name: f.name, name_en: f.name_en || null, category: f.category, website: f.website || null, console_url: f.console_url || null, sdk_type: f.sdk_type || null, enabled: !!f.enabled, remark: f.remark || null, api_config: f.api_config || null };
try {
if (store.providerModal.editing) await Api.put('/providers/' + store.providerModal.editing, payload);
else await Api.post('/providers', payload);
store.providerModal.show = false;
await loadProviders();
} catch (e) { store.error = e.message; }
}
async function seedProviders() {
store.error = '';
try { const r = await Api.post('/providers/seed', {}); await loadProviders(); alert('已初始化预设平台,新增 ' + r.added + ' 个'); }
catch (e) { store.error = e.message; }
}
async function deleteProvider(p) {
if (!confirm('确认删除平台「' + p.name + '」?')) return;
try { await Api.del('/providers/' + p.id); await loadProviders(); }
catch (e) { store.error = e.message; }
}
/* ---------------- 服务器监控 ---------------- */
async function viewServer(a) {
store.view = 'servers';
try {
const [metrics, info, checks] = await Promise.all([
Api.get('/monitor/' + a.id + '/metrics'),
Api.get('/monitor/' + a.id + '/info'),
Api.get('/monitor/' + a.id + '/security'),
]);
store.serverMetrics = { asset: a, metrics, info, checks };
} catch (e) { store.serverMetrics = { asset: a, metrics: [], info: null, checks: [] }; }
}
/* ---------------- 设置 ---------------- */
const settings = reactive({ apiKey: Api.getApiKey(), saved: false });
function saveSettings() { Api.setApiKey(settings.apiKey); settings.saved = true; setTimeout(() => settings.saved = false, 2000); }
/* ================= 视图组件 ================= */
const DashboardView = {
template: `
<div class="space-y-5">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div class="bg-white dark:bg-slate-900 rounded-xl p-4 border border-slate-200 dark:border-slate-800">
<div class="text-xs text-slate-500">总资产</div>
<div class="text-2xl font-bold mt-1">{{ store.overview.total || 0 }}</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl p-4 border border-red-200 dark:border-red-900/50">
<div class="text-xs text-slate-500">30天内到期</div>
<div class="text-2xl font-bold mt-1 text-red-600 dark:text-red-400">{{ store.overview.expiring_30 || 0 }}</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl p-4 border border-slate-200 dark:border-slate-800">
<div class="text-xs text-slate-500">本年支出</div>
<div class="text-2xl font-bold mt-1">{{ store.overview.year_cost || 0 }}</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl p-4 border border-amber-200 dark:border-amber-900/50">
<div class="text-xs text-slate-500">异常状态</div>
<div class="text-2xl font-bold mt-1 text-amber-600 dark:text-amber-400">{{ store.overview.abnormal_count || 0 }}</div>
</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4" v-if="store.expiring.length">
<h2 class="text-sm font-semibold mb-3">续费倒计时(30天内)</h2>
<div class="space-y-2">
<div v-for="a in store.expiring" :key="a.id" class="flex justify-between items-center px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/50">
<div class="min-w-0">
<span class="font-medium text-sm truncate">{{ a.name }}</span>
<span class="text-xs text-slate-400 ml-2">{{ a.provider_name || a.provider }}</span>
</div>
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="Fmt.expiryBadge(a.days_to_expiry)">{{ a.days_to_expiry }}天 · {{ a.cost }}{{ a.currency }}</span>
</div>
</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<h2 class="text-sm font-semibold mb-3">按类型分布</h2>
<div class="flex flex-wrap gap-2">
<span v-for="(n, t) in store.overview.by_type" :key="t" class="text-xs px-3 py-1.5 rounded-lg" :class="Fmt.typeBadge(t)">{{ Fmt.TYPE_LABELS[t] }} {{ n }}</span>
<span v-if="!store.overview.by_type || !Object.keys(store.overview.by_type).length" class="text-xs text-slate-400">暂无数据</span>
</div>
</div>
</div>`,
setup() { return { store, Fmt }; },
};
const AssetsView = {
template: `
<div class="space-y-3">
<div class="flex flex-wrap gap-2 items-center">
<select v-model="store.filterType" @change="reload" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900">
<option value="">全部类型</option>
<option v-for="(l,k) in Fmt.TYPE_LABELS" :key="k" :value="k">{{ l }}</option>
</select>
<select v-model="store.filterStatus" @change="reload" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900">
<option value="">全部状态</option>
<option v-for="(l,k) in Fmt.STATUS_LABELS" :key="k" :value="k">{{ l }}</option>
</select>
<button @click="syncAllAI" class="text-sm px-3 py-1.5 rounded-lg border border-violet-300 dark:border-violet-700 text-violet-600 dark:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/20">同步AI余额</button>
<span class="ml-auto text-xs text-slate-400">共 {{ store.assets.length }} 条</span>
</div>
<!-- 桌面表格 -->
<div class="hidden md:block bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-slate-50 dark:bg-slate-800/50 text-slate-500 text-left text-xs">
<tr>
<th class="px-4 py-2.5 font-medium">名称</th>
<th class="px-4 py-2.5 font-medium">类型</th>
<th class="px-4 py-2.5 font-medium">平台</th>
<th class="px-4 py-2.5 font-medium">到期</th>
<th class="px-4 py-2.5 font-medium">费用</th>
<th class="px-4 py-2.5 font-medium">状态</th>
<th class="px-4 py-2.5 font-medium text-right">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
<tr v-if="!store.assets.length"><td colspan="7" class="px-4 py-10 text-center text-slate-400">暂无资产</td></tr>
<tr v-for="a in store.assets" :key="a.id" class="hover:bg-slate-50 dark:hover:bg-slate-800/30" :class="{ 'opacity-50': a.is_archived }">
<td class="px-4 py-2.5 font-medium">{{ a.name }}</td>
<td class="px-4 py-2.5"><span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.typeBadge(a.asset_type)">{{ Fmt.TYPE_LABELS[a.asset_type] }}</span></td>
<td class="px-4 py-2.5 text-slate-500">{{ a.provider_name || a.provider }}</td>
<td class="px-4 py-2.5" :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date || '—' }}<span v-if="a.days_to_expiry!==null && a.days_to_expiry!==undefined" class="text-xs ml-1">({{ a.days_to_expiry }}天)</span></td>
<td class="px-4 py-2.5 text-slate-600 dark:text-slate-300">{{ a.cost }} {{ a.currency }}<span v-if="a.renewal_cycle" class="text-xs text-slate-400 ml-1">/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }}</span></td>
<td class="px-4 py-2.5"><span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.statusBadge(a.status)">{{ Fmt.STATUS_LABELS[a.status] }}</span></td>
<td class="px-4 py-2.5 text-right whitespace-nowrap">
<button v-if="a.asset_type==='vps'" @click="viewServer(a)" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs">监控</button>
<button v-if="a.asset_type==='ai_agent'" @click="refreshBalance(a)" class="text-violet-600 dark:text-violet-400 hover:underline mr-3 text-xs">刷新余额</button>
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline mr-3 text-xs">编辑</button>
<button @click="del(a)" class="text-red-600 dark:text-red-400 hover:underline text-xs">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 移动卡片 -->
<div class="md:hidden space-y-2">
<div v-if="!store.assets.length" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-8 text-center text-slate-400 text-sm">暂无资产</div>
<div v-for="a in store.assets" :key="a.id" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-3" :class="{ 'opacity-50': a.is_archived }">
<div class="flex justify-between items-start">
<div class="min-w-0">
<div class="font-medium text-sm truncate">{{ a.name }}</div>
<div class="text-xs text-slate-400 mt-0.5">{{ a.provider_name || a.provider }}</div>
</div>
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="Fmt.typeBadge(a.asset_type)">{{ Fmt.TYPE_LABELS[a.asset_type] }}</span>
</div>
<div class="flex items-center gap-3 mt-2 text-xs">
<span :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date || '无到期' }}<span v-if="a.days_to_expiry!==null&&a.days_to_expiry!==undefined"> ({{ a.days_to_expiry }}天)</span></span>
<span class="text-slate-500">{{ a.cost }}{{ a.currency }}</span>
<span class="px-2 py-0.5 rounded-full" :class="Fmt.statusBadge(a.status)">{{ Fmt.STATUS_LABELS[a.status] }}</span>
</div>
<div class="flex gap-4 mt-2 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs">
<button v-if="a.asset_type==='vps'" @click="viewServer(a)" class="text-emerald-600 dark:text-emerald-400">监控</button>
<button v-if="a.asset_type==='ai_agent'" @click="refreshBalance(a)" class="text-violet-600 dark:text-violet-400">刷新余额</button>
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400">编辑</button>
<button @click="del(a)" class="text-red-600 dark:text-red-400">删除</button>
</div>
</div>
</div>
</div>`,
setup() {
async function refreshBalance(a) {
if (!confirm('刷新「' + a.name + '」的余额?')) return;
try {
const r = await Api.post('/assets/' + a.id + '/refresh-balance', {});
alert('余额已更新:' + r.balance + ' ' + r.currency);
await loadAssets();
} catch (e) { alert('刷新失败:' + e.message); }
}
async function syncAllAI() {
if (!confirm('同步所有 AI 账号的余额?')) return;
try {
const r = await Api.post('/sync/ai-balances', {});
alert('同步完成:成功 ' + r.success + ',失败 ' + r.failed + (r.errors && r.errors.length ? '\n' + r.errors.join('\n') : ''));
await loadAssets();
} catch (e) { alert('同步失败:' + e.message); }
}
return { store, Fmt, reload: loadAssets, edit: openAssetEdit, del: deleteAsset, viewServer, refreshBalance, syncAllAI };
},
};
const ProvidersView = {
template: `
<div class="space-y-3">
<div class="flex gap-2">
<button @click="seed" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800">初始化预设平台</button>
<button @click="create" class="text-sm px-3 py-1.5 rounded-lg bg-blue-600 text-white hover:bg-blue-700">+ 自定义平台</button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<div v-for="p in store.providers" :key="p.id" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="flex justify-between items-start">
<div>
<div class="font-medium text-sm">{{ p.name }}</div>
<div class="text-xs text-slate-400 mt-0.5">{{ p.slug }}</div>
</div>
<span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.typeBadge(p.category)">{{ Fmt.CATEGORY_LABELS[p.category] }}</span>
</div>
<div class="text-xs text-slate-500 mt-2 space-y-1">
<div v-if="p.sdk_type">SDK: {{ p.sdk_type }}</div>
<div v-if="p.console_url"><a :href="p.console_url" target="_blank" class="text-blue-600 dark:text-blue-400 hover:underline">管理面板 ↗</a></div>
<div class="flex items-center gap-2 mt-1">
<span v-if="p.has_api_config" class="text-emerald-600 dark:text-emerald-400">已配置 API</span>
<span v-else class="text-slate-400">未配置 API</span>
<span :class="p.enabled ? 'text-emerald-600 dark:text-emerald-400' : 'text-slate-400'">{{ p.enabled ? '启用' : '停用' }}</span>
</div>
<div v-if="p.last_synced_at" class="text-slate-400">最后同步:{{ fmtTime(p.last_synced_at) }}</div>
</div>
<div class="flex gap-4 mt-3 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs flex-wrap">
<button @click="edit(p)" class="text-blue-600 dark:text-blue-400">编辑</button>
<button v-if="isSupported(p.sdk_type)" @click="test(p)" :disabled="testing[p.id]" class="text-emerald-600 dark:text-emerald-400 disabled:opacity-50">{{ testing[p.id] ? '测试中…' : '测试连接' }}</button>
<button v-if="canSync(p.sdk_type)" @click="sync(p)" :disabled="syncing[p.id]" class="text-violet-600 dark:text-violet-400 disabled:opacity-50">{{ syncing[p.id] ? '同步中…' : '同步资产' }}</button>
<button @click="del(p)" class="text-red-600 dark:text-red-400">删除</button>
</div>
<div v-if="results[p.id]" class="mt-2 text-xs px-2 py-1.5 rounded-lg break-words" :class="results[p.id].ok ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' : 'bg-amber-500/10 text-amber-600 dark:text-amber-400'">{{ results[p.id].text }}</div>
</div>
</div>
</div>`,
setup() {
const testing = reactive({});
const syncing = reactive({});
const results = reactive({});
const supported = ref([]);
const capabilities = ref({});
onMounted(async () => {
try { const r = await Api.get('/providers/adapters'); supported.value = r.supported_types || []; capabilities.value = r.capabilities || {}; } catch (e) { /* ignore */ }
});
function isSupported(t) { return !!t && supported.value.includes(t); }
function canSync(t) { const c = capabilities.value[t]; return !!(c && (c.list_vps || c.list_domains)); }
function fmtTime(iso) {
if (!iso) return '';
return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
async function test(p) {
testing[p.id] = true; delete results[p.id];
try {
const r = await Api.post('/providers/' + p.id + '/test', {});
results[p.id] = { ok: r.ok, text: (r.ok ? '✓ ' : '✗ ') + r.message };
} catch (e) { results[p.id] = { ok: false, text: '✗ ' + e.message }; }
finally { testing[p.id] = false; }
}
async function sync(p) {
syncing[p.id] = true; delete results[p.id];
try {
const r = await Api.post('/providers/' + p.id + '/sync', {});
const parts = [];
if (r.vps) parts.push('VPS 新增' + r.vps.created + '/更新' + r.vps.updated);
if (r.domains) parts.push('域名 新增' + r.domains.created + '/更新' + r.domains.updated);
if (r.vps_error) parts.push('VPS错误:' + r.vps_error);
if (r.domains_error) parts.push('域名错误:' + r.domains_error);
if (r.account && r.account.balance !== null && r.account.balance !== undefined) parts.push('余额 ' + r.account.balance);
results[p.id] = { ok: true, text: '✓ 同步完成:' + (parts.join('') || '无变化') };
await loadAll();
} catch (e) { results[p.id] = { ok: false, text: '✗ ' + e.message }; }
finally { syncing[p.id] = false; }
}
return { store, Fmt, testing, syncing, results, isSupported, canSync, fmtTime, test, sync, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider };
},
};
const ServersView = {
template: `
<div class="space-y-4">
<div v-if="!m.asset" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-8 text-center text-slate-400 text-sm">
请在「资产」页点击 VPS 的「监控」查看服务器资源与安全状态
</div>
<template v-else>
<div class="flex items-center gap-2">
<button @click="store.view='assets'" class="text-sm text-slate-500 hover:text-slate-700">← 返回</button>
<h2 class="font-semibold">{{ m.asset.name }}</h2>
</div>
<div v-if="m.info" class="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="text-xs text-slate-500">CPU</div>
<div class="text-xl font-bold mt-1">{{ latest ? latest.cpu_pct : '—' }}%</div>
<div class="text-xs text-slate-400 mt-1">{{ m.info.cpu_cores }} 核</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="text-xs text-slate-500">内存</div>
<div class="text-xl font-bold mt-1">{{ latest ? latest.mem_pct : '—' }}%</div>
<div class="text-xs text-slate-400 mt-1">{{ m.info.mem_total_gb }} GB</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="text-xs text-slate-500">磁盘</div>
<div class="text-xl font-bold mt-1">{{ latest ? latest.disk_pct : '—' }}%</div>
<div class="text-xs text-slate-400 mt-1">{{ m.info.disk_total_gb }} GB</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="text-xs text-slate-500">负载/在线</div>
<div class="text-xl font-bold mt-1">{{ latest && latest.load_1m !== null ? latest.load_1m : '—' }}</div>
<div class="text-xs text-slate-400 mt-1">{{ m.info.hostname }}</div>
</div>
</div>
<div v-if="m.info" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4 text-sm">
<h3 class="font-semibold mb-2">服务器信息</h3>
<div class="grid grid-cols-2 gap-y-1 text-xs text-slate-500">
<div>系统:{{ m.info.os }}</div><div>内核:{{ m.info.kernel }}</div>
<div>公网 IP{{ m.info.public_ip || '—' }}</div><div>状态:{{ m.info.status }}</div>
</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<h3 class="font-semibold mb-2 text-sm">安全检查</h3>
<div class="space-y-2">
<div v-for="c in m.checks" :key="c.id" class="flex items-start gap-2 text-sm">
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="Fmt.checkBadge(c.status)">{{ c.status }}</span>
<div class="min-w-0">
<div class="font-medium text-xs">{{ c.check_item }}</div>
<div class="text-xs text-slate-500">{{ c.detail }}</div>
<div v-if="c.suggestion" class="text-xs text-amber-600 dark:text-amber-400">建议:{{ c.suggestion }}</div>
</div>
</div>
<div v-if="!m.checks.length" class="text-xs text-slate-400">暂无安全检查数据(需 Agent 上报)</div>
</div>
</div>
</template>
</div>`,
setup() {
const m = computed(() => store.serverMetrics);
const latest = computed(() => (m.value.metrics && m.value.metrics.length) ? m.value.metrics[0] : null);
return { store, Fmt, m, latest };
},
};
const DomainsView = {
template: `
<div class="space-y-3">
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-slate-50 dark:bg-slate-800/50 text-slate-500 text-left text-xs">
<tr><th class="px-4 py-2.5 font-medium">域名</th><th class="px-4 py-2.5 font-medium">注册商</th><th class="px-4 py-2.5 font-medium">DNS</th><th class="px-4 py-2.5 font-medium">到期</th><th class="px-4 py-2.5 font-medium">使用中</th><th class="px-4 py-2.5 font-medium text-right">操作</th></tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
<tr v-if="!domains.length"><td colspan="6" class="px-4 py-10 text-center text-slate-400">暂无域名资产</td></tr>
<tr v-for="a in domains" :key="a.id" class="hover:bg-slate-50 dark:hover:bg-slate-800/30">
<td class="px-4 py-2.5 font-medium">{{ a.domain_detail.domain_name }}</td>
<td class="px-4 py-2.5 text-slate-500">{{ a.domain_detail.registrar || '—' }}</td>
<td class="px-4 py-2.5 text-slate-500">{{ a.domain_detail.dns_provider || '—' }}</td>
<td class="px-4 py-2.5" :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date || '—' }}<span v-if="a.days_to_expiry!==null" class="text-xs ml-1">({{ a.days_to_expiry }}天)</span></td>
<td class="px-4 py-2.5"><span class="text-xs px-2 py-0.5 rounded-full" :class="a.domain_detail.is_using ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' : 'bg-slate-500/10 text-slate-400'">{{ a.domain_detail.is_using ? '使用' : '闲置' }}</span></td>
<td class="px-4 py-2.5 text-right"><button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline text-xs">编辑/绑定</button></td>
</tr>
</tbody>
</table>
</div>
</div>`,
setup() {
const domains = computed(() => store.assets.filter(a => a.asset_type === 'domain'));
return { store, Fmt, domains, edit: openAssetEdit };
},
};
const AIAccountsView = {
template: `
<div class="space-y-4">
<div class="flex justify-between items-center flex-wrap gap-2">
<div class="text-sm text-slate-500">共 {{ totalAccounts }} 个账号 · 总余额 {{ totalBalance }}</div>
<button @click="addAny" class="text-sm px-3 py-1.5 rounded-lg bg-blue-600 text-white hover:bg-blue-700">+ 新增账号</button>
</div>
<div v-for="g in groups" :key="g.provider" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="flex justify-between items-center mb-3 flex-wrap gap-2">
<div class="flex items-center gap-2">
<span class="font-semibold text-sm">{{ g.provider }}</span>
<span class="text-xs px-2 py-0.5 rounded-full bg-violet-500/10 text-violet-600 dark:text-violet-400">{{ g.accounts.length }} 个账号</span>
</div>
<div class="flex items-center gap-3 text-xs">
<span class="text-slate-500">余额合计 <b class="text-slate-800 dark:text-slate-200">{{ g.totalBalance }}</b></span>
<button @click="add(g.provider)" class="text-blue-600 dark:text-blue-400 hover:underline">+ 添加账号</button>
</div>
</div>
<div class="space-y-2">
<div v-for="a in g.accounts" :key="a.id" class="flex items-center justify-between px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/50 text-sm gap-2">
<div class="min-w-0">
<span class="font-medium">{{ a.name }}</span>
<span v-if="a.ai_detail && a.ai_detail.plan" class="text-xs text-slate-400 ml-2">{{ a.ai_detail.plan }}</span>
</div>
<div class="flex items-center gap-3 text-xs shrink-0">
<span :class="balanceClass(a.ai_detail && a.ai_detail.balance)">余额 {{ fmtBal(a.ai_detail && a.ai_detail.balance) }}{{ a.ai_detail && a.ai_detail.balance !== null && a.ai_detail.balance !== undefined ? ' ' + a.ai_detail.currency : '' }}</span>
<span v-if="a.ai_detail && a.ai_detail.monthly_usage !== null && a.ai_detail.monthly_usage !== undefined" class="text-slate-500">用量 {{ a.ai_detail.monthly_usage }}/{{ a.ai_detail.monthly_limit ?? '∞' }}</span>
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline">编辑</button>
</div>
</div>
</div>
</div>
<div v-if="!groups.length" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-8 text-center text-slate-400 text-sm">暂无 AI 账号,点右上角「新增账号」添加(同一平台可添加多个)</div>
</div>`,
setup() {
const aiList = () => store.assets.filter(a => a.asset_type === 'ai_agent');
const groups = computed(() => {
const map = {};
for (const a of aiList()) {
const prov = (a.ai_detail && a.ai_detail.provider) || a.provider || '其他';
if (!map[prov]) map[prov] = { provider: prov, accounts: [], totalBalance: 0 };
map[prov].accounts.push(a);
const bal = a.ai_detail && a.ai_detail.balance;
if (bal) map[prov].totalBalance += Number(bal);
}
return Object.values(map).map(g => ({ ...g, totalBalance: Math.round(g.totalBalance * 100) / 100 })).sort((x, y) => y.totalBalance - x.totalBalance);
});
const totalAccounts = computed(() => aiList().length);
const totalBalance = computed(() => Math.round(aiList().reduce((s, a) => s + (a.ai_detail && a.ai_detail.balance ? Number(a.ai_detail.balance) : 0), 0) * 100) / 100);
function balanceClass(b) {
if (b === null || b === undefined) return 'text-slate-400';
if (Number(b) < 10) return 'text-red-600 dark:text-red-400 font-semibold';
if (Number(b) < 50) return 'text-amber-600 dark:text-amber-400';
return 'text-emerald-600 dark:text-emerald-400';
}
function fmtBal(b) { return (b === null || b === undefined) ? '—' : b; }
return { store, groups, totalAccounts, totalBalance, balanceClass, fmtBal, add: openAICreate, addAny: () => openAICreate(''), edit: openAssetEdit };
},
};
const SettingsView = {
template: `
<div class="max-w-lg space-y-4">
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<h3 class="font-semibold text-sm mb-3">API Key(写操作鉴权)</h3>
<p class="text-xs text-slate-500 mb-2">若服务端 .env 配置了 API_KEY,请在此填写相同的值,前端写操作会自动携带。</p>
<div class="flex gap-2">
<input v-model="settings.apiKey" type="password" placeholder="留空表示服务端未启用鉴权" class="flex-1 text-sm px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900">
<button @click="save" class="text-sm px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700">{{ settings.saved ? '已保存' : '保存' }}</button>
</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<h3 class="font-semibold text-sm mb-2">外观</h3>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" v-model="store.dark" @change="applyDark"> 深色模式
</label>
</div>
</div>`,
setup() { return { store, settings, save: saveSettings, applyDark }; },
};
/* ================= 资产编辑模态框 ================= */
const AssetModal = {
template: `
<div v-if="store.assetModal.show" class="fixed inset-0 bg-black/40 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
<div class="bg-white dark:bg-slate-900 w-full md:max-w-lg md:rounded-xl rounded-t-xl max-h-[92vh] overflow-y-auto">
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center sticky top-0 bg-white dark:bg-slate-900">
<h3 class="font-semibold text-sm">{{ store.assetModal.editing ? '编辑资产' : '新增资产' }}</h3>
<button @click="store.assetModal.show=false" class="text-slate-400 hover:text-slate-600">✕</button>
</div>
<form @submit.prevent="save" class="px-5 py-4 space-y-3" v-if="f">
<div class="grid grid-cols-2 gap-3">
<label class="col-span-2 block"><span class="text-xs text-slate-500">名称 *</span>
<input v-model="f.name" required class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">类型 *</span>
<select v-model="f.asset_type" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm">
<option v-for="(l,k) in Fmt.TYPE_LABELS" :key="k" :value="k">{{ l }}</option>
</select></label>
<label class="block"><span class="text-xs text-slate-500">所属平台</span>
<select v-model="f.provider_id" @change="onProviderChange" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm">
<option :value="null">未指定</option>
<option v-for="p in providersForType" :key="p.id" :value="p.id">{{ p.name }}</option>
</select></label>
<label class="block"><span class="text-xs text-slate-500">到期时间</span>
<input type="date" v-model="f.expiry_date" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">续费周期</span>
<select v-model="f.renewal_cycle" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm">
<option value="">未设定</option><option v-for="(l,k) in Fmt.CYCLE_LABELS" :key="k" :value="k">{{ l }}</option>
</select></label>
<label class="block"><span class="text-xs text-slate-500">费用</span>
<input type="number" step="0.01" v-model.number="f.cost" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">币种</span>
<select v-model="f.currency" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm">
<option>USD</option><option>CNY</option><option>JPY</option><option>EUR</option>
</select></label>
<label class="block"><span class="text-xs text-slate-500">状态</span>
<select v-model="f.status" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm">
<option v-for="(l,k) in Fmt.STATUS_LABELS" :key="k" :value="k">{{ l }}</option>
</select></label>
<label class="block"><span class="text-xs text-slate-500">所属账号</span>
<input v-model="f.account" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<div class="col-span-2 flex gap-5 text-sm text-slate-600 dark:text-slate-300">
<label class="flex items-center gap-2"><input type="checkbox" v-model="f.auto_renew"> 自动续费</label>
<label class="flex items-center gap-2"><input type="checkbox" v-model="f.is_archived"> 已归档</label>
</div>
</div>
<fieldset v-if="f.asset_type==='vps'" class="border border-slate-200 dark:border-slate-700 rounded-lg p-3">
<legend class="text-xs font-medium text-slate-600 dark:text-slate-300 px-1">VPS 详情</legend>
<div class="grid grid-cols-2 gap-2">
<label class="block"><span class="text-xs text-slate-400">公网 IP *</span><input v-model="f.vps_detail.ip_address" required class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">Tailscale IP</span><input v-model="f.vps_detail.tailscale_ip" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">区域</span><input v-model="f.vps_detail.region" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">系统</span><input v-model="f.vps_detail.os" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">CPU核</span><input type="number" v-model.number="f.vps_detail.cpu_cores" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">内存GB</span><input type="number" step="0.5" v-model.number="f.vps_detail.memory_gb" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">磁盘GB</span><input type="number" v-model.number="f.vps_detail.disk_gb" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">SSH端口</span><input type="number" v-model.number="f.vps_detail.ssh_port" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">SSH用户</span><input v-model="f.vps_detail.ssh_user" placeholder="root" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">登录方式</span><select v-model="f.vps_detail.login_method" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"><option value="key">密钥</option><option value="password">密码</option><option value="other">其他</option></select></label>
<label class="block"><span class="text-xs text-slate-400">SSH密钥</span><input v-model="f.vps_detail.ssh_key" type="password" :placeholder="store.assetModal.editing ? '留空不修改' : ''" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">密码</span><input v-model="f.vps_detail.password" type="password" :placeholder="store.assetModal.editing ? '留空不修改' : ''" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="col-span-2 block"><span class="text-xs text-slate-400">用途/项目</span><input v-model="f.vps_detail.purpose" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
</div>
</fieldset>
<fieldset v-if="f.asset_type==='domain'" class="border border-slate-200 dark:border-slate-700 rounded-lg p-3">
<legend class="text-xs font-medium text-slate-600 dark:text-slate-300 px-1">域名详情</legend>
<div class="grid grid-cols-2 gap-2">
<label class="col-span-2 block"><span class="text-xs text-slate-400">域名 *</span><input v-model="f.domain_detail.domain_name" required class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">注册商</span><input v-model="f.domain_detail.registrar" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">DNS托管</span><input v-model="f.domain_detail.dns_provider" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">CF账号</span><input v-model="f.domain_detail.cloudflare_account" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">绑定资产ID</span><input type="number" v-model.number="f.domain_detail.bind_asset_id" placeholder="关联VPS的ID" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="col-span-2 flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300"><input type="checkbox" v-model="f.domain_detail.is_using"> 仍在实际使用</label>
</div>
</fieldset>
<fieldset v-if="f.asset_type==='ai_agent'" class="border border-slate-200 dark:border-slate-700 rounded-lg p-3">
<legend class="text-xs font-medium text-slate-600 dark:text-slate-300 px-1">AI 账号详情</legend>
<div class="grid grid-cols-2 gap-2">
<label class="block"><span class="text-xs text-slate-400">服务商 *</span><input v-model="f.ai_detail.provider" required class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">计划</span><input v-model="f.ai_detail.plan" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="col-span-2 block"><span class="text-xs text-slate-400">API Key</span><input v-model="f.ai_detail.api_key" type="password" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">余额</span><input type="number" step="0.01" v-model.number="f.ai_detail.balance" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">月限额</span><input type="number" step="0.01" v-model.number="f.ai_detail.monthly_limit" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
</div>
</fieldset>
<label class="block"><span class="text-xs text-slate-500">备注</span>
<textarea v-model="f.remark" rows="2" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></textarea></label>
<div class="flex justify-end gap-2 pt-1">
<button type="button" @click="store.assetModal.show=false" class="px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300">取消</button>
<button type="submit" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700">{{ store.assetModal.editing ? '保存' : '创建' }}</button>
</div>
</form>
</div>
</div>`,
setup() {
const f = computed(() => store.assetModal.form);
const providersForType = computed(() => {
const t = f.value ? f.value.asset_type : null;
const map = { vps: 'vps', domain: 'domain', ai_agent: 'ai_agent', cloudflare: 'cloudflare' };
const cat = map[t];
return cat ? store.providers.filter(p => p.category === cat || p.category === 'other') : store.providers;
});
function onProviderChange() {
const p = store.providers.find(x => x.id === f.value.provider_id);
if (p) f.value.provider = p.slug;
}
return { store, Fmt, f, providersForType, onProviderChange, save: saveAsset };
},
};
const ProviderModal = {
template: `
<div v-if="store.providerModal.show" class="fixed inset-0 bg-black/40 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
<div class="bg-white dark:bg-slate-900 w-full md:max-w-md md:rounded-xl rounded-t-xl max-h-[92vh] overflow-y-auto">
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center">
<h3 class="font-semibold text-sm">{{ store.providerModal.editing ? '编辑平台' : '新增平台' }}</h3>
<button @click="store.providerModal.show=false" class="text-slate-400 hover:text-slate-600">✕</button>
</div>
<form @submit.prevent="save" class="px-5 py-4 space-y-3" v-if="f">
<div class="grid grid-cols-2 gap-3">
<label class="block"><span class="text-xs text-slate-500">标识 slug *</span><input v-model="f.slug" required placeholder="aliyun" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">名称 *</span><input v-model="f.name" required class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">分类</span><select v-model="f.category" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"><option v-for="(l,k) in Fmt.CATEGORY_LABELS" :key="k" :value="k">{{ l }}</option></select></label>
<label class="block"><span class="text-xs text-slate-500">SDK类型</span><select v-model="f.sdk_type" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"><option value="">未指定</option><option v-for="o in sdkOptions" :key="o.type" :value="o.type">{{ o.type }}</option></select></label>
<label class="col-span-2 block"><span class="text-xs text-slate-500">官网</span><input v-model="f.website" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="col-span-2 block"><span class="text-xs text-slate-500">管理面板URL</span><input v-model="f.console_url" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<div v-if="currentFields.length" class="col-span-2 border border-slate-200 dark:border-slate-700 rounded-lg p-3 space-y-2">
<div class="text-xs font-medium text-slate-600 dark:text-slate-300">API 凭证(加密存储)</div>
<label v-for="field in currentFields" :key="field" class="block"><span class="text-xs text-slate-400">{{ field }}</span><input v-model="apiFields[field]" type="password" :placeholder="store.providerModal.editing ? '留空不修改' : '填写 ' + field" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
</div>
<div v-else-if="f.sdk_type" class="col-span-2 text-xs text-amber-600 dark:text-amber-400">该 SDK 类型暂无预设凭证字段(可后续扩展)</div>
<label class="col-span-2 flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300"><input type="checkbox" v-model="f.enabled"> 启用</label>
</div>
<div class="flex justify-end gap-2">
<button type="button" @click="store.providerModal.show=false" class="px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300">取消</button>
<button type="submit" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700">保存</button>
</div>
</form>
</div>
</div>`,
setup() {
const f = computed(() => store.providerModal.form);
const adapterMeta = ref([]);
const apiFields = reactive({});
onMounted(async () => {
try { const r = await Api.get('/providers/adapters'); adapterMeta.value = r.adapters || []; } catch (e) { /* ignore */ }
});
const sdkOptions = computed(() => {
const opts = [];
for (const m of adapterMeta.value) for (const t of (m.sdk_types || [])) opts.push({ type: t, fields: m.required_config || [] });
return opts;
});
const currentFields = computed(() => {
const opt = sdkOptions.value.find(o => o.type === (f.value && f.value.sdk_type));
return opt ? opt.fields : [];
});
function save() {
const cfg = {};
for (const field of currentFields.value) if (apiFields[field]) cfg[field] = apiFields[field];
if (Object.keys(cfg).length) f.value.api_config = JSON.stringify(cfg);
saveProvider();
}
return { store, Fmt, f, sdkOptions, currentFields, apiFields, save };
},
};
/* ================= 根组件 ================= */
const AppRoot = {
components: { DashboardView, AssetsView, ProvidersView, ServersView, DomainsView, SettingsView, AssetModal, ProviderModal },
template: `
<div class="min-h-screen md:flex">
<aside class="hidden md:flex flex-col w-52 shrink-0 border-r border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-3 sticky top-0 h-screen">
<div class="px-2 py-3 font-bold text-slate-900 dark:text-white">📦 {{ appName }}</div>
<nav class="space-y-1 mt-2">
<button v-for="n in navs" :key="n.key" @click="go(n.key)" class="w-full text-left px-3 py-2 rounded-lg text-sm flex items-center gap-2 transition" :class="store.view===n.key ? 'bg-blue-500/10 text-blue-600 dark:text-blue-400 font-medium' : 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800'">
<span>{{ n.icon }}</span>{{ n.label }}
</button>
</nav>
<div class="mt-auto pt-3 border-t border-slate-100 dark:border-slate-800">
<button @click="toggleDark" class="w-full text-left px-3 py-2 rounded-lg text-sm text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800">{{ store.dark ? '☀️ 浅色' : '🌙 深色' }}</button>
</div>
</aside>
<div class="flex-1 flex flex-col min-w-0">
<header class="sticky top-0 z-30 bg-white/80 dark:bg-slate-900/80 backdrop-blur border-b border-slate-200 dark:border-slate-800 px-4 py-3 flex items-center gap-3">
<h1 class="font-semibold text-slate-900 dark:text-white md:text-lg">{{ currentNav.label }}</h1>
<div class="ml-auto flex items-center gap-2">
<input v-if="store.view==='assets'" v-model="store.search" @input="reload" placeholder="搜索…" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 w-32 md:w-48">
<button @click="quickAdd" class="text-sm px-3 py-1.5 rounded-lg bg-blue-600 text-white hover:bg-blue-700 whitespace-nowrap">+ 新增</button>
</div>
</header>
<div v-if="store.error" class="mx-4 mt-3 px-4 py-2.5 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-900/50 text-red-700 dark:text-red-400 text-sm flex justify-between">
<span>⚠️ {{ store.error }}</span><button @click="store.error=''" class="font-bold">✕</button>
</div>
<main class="flex-1 p-4 pb-20 md:pb-6 overflow-y-auto">
<component :is="viewComponent"></component>
</main>
</div>
<nav class="md:hidden fixed bottom-0 inset-x-0 z-30 bg-white dark:bg-slate-900 border-t border-slate-200 dark:border-slate-800 flex">
<button v-for="n in navs" :key="n.key" @click="go(n.key)" class="flex-1 py-2 flex flex-col items-center gap-0.5 text-xs" :class="store.view===n.key ? 'text-blue-600 dark:text-blue-400' : 'text-slate-400'">
<span class="text-base">{{ n.icon }}</span>{{ n.label }}
</button>
</nav>
<asset-modal></asset-modal>
<provider-modal></provider-modal>
</div>`,
setup() {
const appName = Api.CFG.appName;
const navs = [
{ key: 'dashboard', label: '总览', icon: '📊' },
{ key: 'assets', label: '资产', icon: '📦' },
{ key: 'servers', label: '服务器', icon: '🖥️' },
{ key: 'domains', label: '域名', icon: '🌐' },
{ key: 'ai', label: 'AI账号', icon: '🤖' },
{ key: 'providers', label: '平台', icon: '🏢' },
{ key: 'settings', label: '设置', icon: '⚙️' },
];
const viewMap = { dashboard: 'dashboard-view', assets: 'assets-view', servers: 'servers-view', domains: 'domains-view', ai: 'ai-view', providers: 'providers-view', settings: 'settings-view' };
const currentNav = computed(() => navs.find(n => n.key === store.view) || navs[0]);
const viewComponent = computed(() => viewMap[store.view] || 'dashboard-view');
function go(key) { store.view = key; if (key !== 'servers') store.serverMetrics = {}; if (key === 'ai') { store.filterType = ''; store.filterStatus = ''; store.search = ''; loadAssets(); } }
function quickAdd() {
if (store.view === 'providers') return openProviderCreate();
if (store.view === 'ai') return openAICreate('');
const preset = store.view === 'domains' ? 'domain' : 'vps';
openAssetCreate(preset);
}
function toggleDark() { store.dark = !store.dark; applyDark(); }
return { store, appName, navs, currentNav, viewComponent, go, quickAdd, toggleDark, reload: loadAssets };
},
};
/* ================= 挂载 ================= */
const app = createApp(AppRoot);
app.component('dashboard-view', DashboardView);
app.component('assets-view', AssetsView);
app.component('providers-view', ProvidersView);
app.component('servers-view', ServersView);
app.component('domains-view', DomainsView);
app.component('ai-view', AIAccountsView);
app.component('settings-view', SettingsView);
applyDark();
loadAll();
app.mount('#app');