989 lines
66 KiB
JavaScript
989 lines
66 KiB
JavaScript
/* vps-manager 前端应用(Vue 3 组件化) */
|
||
const { createApp, reactive, ref, computed, onMounted, watch, nextTick } = 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', renew_url: '', cancel_url: '',
|
||
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,
|
||
renew_url: f.renew_url || null, cancel_url: f.cancel_url || 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', 'renew_url', 'cancel_url', '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?limit=200'),
|
||
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 };
|
||
},
|
||
};
|
||
|
||
function normalizedMonthly(cost, cycle) {
|
||
if (!cost) return 0;
|
||
if (cycle === 'yearly') return cost / 12;
|
||
if (cycle === 'quarterly') return cost / 3;
|
||
return cost;
|
||
}
|
||
|
||
const SubscriptionsView = {
|
||
template: `
|
||
<div class="space-y-4">
|
||
<div class="grid grid-cols-2 md: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">订阅数量</div>
|
||
<div class="text-2xl font-bold mt-1">{{ subs.length }}</div>
|
||
</div>
|
||
<div v-for="(v, cur) in monthlyByCurrency" :key="cur" 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">月度支出({{ cur }})</div>
|
||
<div class="text-2xl font-bold mt-1">{{ v.toFixed(2) }}</div>
|
||
<div class="text-xs text-slate-400 mt-1">年度约 {{ (v*12).toFixed(2) }}</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="subs.length" 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>
|
||
<canvas id="billingChart" height="80"></canvas>
|
||
</div>
|
||
<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">费用</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="!subs.length"><td colspan="5" class="px-4 py-10 text-center text-slate-400">暂无付费订阅(给资产设置费用和到期日即可出现)</td></tr>
|
||
<tr v-for="a in subs" :key="a.id" class="hover:bg-slate-50 dark:hover:bg-slate-800/30">
|
||
<td class="px-4 py-2.5 font-medium">{{ a.name }}</td>
|
||
<td class="px-4 py-2.5 text-slate-500">{{ a.provider_name || a.provider }}</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" :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-right whitespace-nowrap">
|
||
<a v-if="a.renew_url" :href="a.renew_url" target="_blank" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs">续费</a>
|
||
<a v-if="a.cancel_url" :href="a.cancel_url" target="_blank" class="text-red-600 dark:text-red-400 hover:underline text-xs">取消</a>
|
||
<span v-if="!a.renew_url && !a.cancel_url" class="text-xs text-slate-400">—</span>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>`,
|
||
setup() {
|
||
const subs = computed(() => store.assets
|
||
.filter(a => (a.cost > 0 || a.expiry_date) && a.status !== 'cancelled')
|
||
.sort((x, y) => (x.days_to_expiry ?? 9999) - (y.days_to_expiry ?? 9999)));
|
||
const monthlyByCurrency = computed(() => {
|
||
const m = {};
|
||
for (const a of subs.value) {
|
||
const cur = a.currency || 'USD';
|
||
m[cur] = (m[cur] || 0) + normalizedMonthly(a.cost, a.renewal_cycle);
|
||
}
|
||
return m;
|
||
});
|
||
const byProviderMonthly = computed(() => {
|
||
const m = {};
|
||
for (const a of subs.value) {
|
||
const prov = a.provider_name || a.provider;
|
||
m[prov] = (m[prov] || 0) + normalizedMonthly(a.cost, a.renewal_cycle);
|
||
}
|
||
return m;
|
||
});
|
||
let billingChart = null;
|
||
function renderBillingChart() {
|
||
const canvas = document.getElementById('billingChart');
|
||
if (!canvas || !window.Chart) return;
|
||
const entries = Object.entries(byProviderMonthly.value).sort((x, y) => y[1] - x[1]);
|
||
if (!entries.length) return;
|
||
if (billingChart) { billingChart.destroy(); billingChart = null; }
|
||
billingChart = new Chart(canvas, {
|
||
type: 'bar',
|
||
data: {
|
||
labels: entries.map(e => e[0]),
|
||
datasets: [{ label: '月度支出', data: entries.map(e => e[1].toFixed(2)), backgroundColor: '#6366f1', borderRadius: 6 }],
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
scales: { y: { beginAtZero: true } },
|
||
plugins: { legend: { display: false } },
|
||
},
|
||
});
|
||
}
|
||
watch([subs, byProviderMonthly], () => { nextTick(renderBillingChart); }, { deep: true });
|
||
onMounted(() => { nextTick(renderBillingChart); });
|
||
return { store, Fmt, subs, monthlyByCurrency, byProviderMonthly };
|
||
},
|
||
};
|
||
|
||
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 v-if="m.metrics && m.metrics.length > 1" 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">资源趋势(最近 {{ m.metrics.length }} 次采样)</h3>
|
||
<canvas id="metricsChart" height="90"></canvas>
|
||
</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);
|
||
let chartInstance = null;
|
||
function renderChart() {
|
||
const canvas = document.getElementById('metricsChart');
|
||
if (!canvas || !window.Chart) return;
|
||
if (!m.value.metrics || m.value.metrics.length < 2) return;
|
||
if (chartInstance) { chartInstance.destroy(); chartInstance = null; }
|
||
const data = [...m.value.metrics].reverse();
|
||
const labels = data.map(p => new Date(p.ts).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }));
|
||
chartInstance = new Chart(canvas, {
|
||
type: 'line',
|
||
data: {
|
||
labels,
|
||
datasets: [
|
||
{ label: 'CPU %', data: data.map(p => p.cpu_pct), borderColor: '#3b82f6', backgroundColor: 'rgba(59,130,246,0.1)', tension: 0.3, pointRadius: 0 },
|
||
{ label: '内存 %', data: data.map(p => p.mem_pct), borderColor: '#10b981', backgroundColor: 'rgba(16,185,129,0.1)', tension: 0.3, pointRadius: 0 },
|
||
{ label: '磁盘 %', data: data.map(p => p.disk_pct), borderColor: '#f59e0b', backgroundColor: 'rgba(245,158,11,0.1)', tension: 0.3, pointRadius: 0 },
|
||
],
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
interaction: { mode: 'index', intersect: false },
|
||
scales: { y: { min: 0, max: 100, ticks: { callback: v => v + '%' } } },
|
||
plugins: { legend: { position: 'top' } },
|
||
},
|
||
});
|
||
}
|
||
watch(m, () => { nextTick(renderChart); }, { deep: true });
|
||
onMounted(() => { nextTick(renderChart); });
|
||
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>
|
||
<div class="text-xs text-slate-500 mb-3">已配置渠道:
|
||
<span v-if="notifyChannels.length" class="text-emerald-600 dark:text-emerald-400">{{ notifyChannels.join('、') }}</span>
|
||
<span v-else class="text-slate-400">未配置(在 .env 设置 TELEGRAM_*/SMTP_*)</span>
|
||
</div>
|
||
<div class="flex gap-2 flex-wrap">
|
||
<button @click="testNotify" 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="checkRenewals" class="text-sm px-3 py-1.5 rounded-lg bg-blue-600 text-white hover:bg-blue-700">检查续费并提醒</button>
|
||
</div>
|
||
<div v-if="notifyResult" class="mt-3 text-xs px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/50 whitespace-pre-wrap break-words">{{ notifyResult }}</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() {
|
||
const notifyChannels = ref([]);
|
||
const notifyResult = ref('');
|
||
onMounted(async () => {
|
||
try { const r = await Api.get('/notify/channels'); notifyChannels.value = r.channels || []; } catch (e) { /* ignore */ }
|
||
});
|
||
async function testNotify() {
|
||
notifyResult.value = '发送中…';
|
||
try {
|
||
const r = await Api.post('/notify/test', {});
|
||
notifyResult.value = r.notifications.map(n => '[' + n.channel + '] ' + (n.ok ? '成功' : '失败:' + n.message)).join('\n');
|
||
} catch (e) { notifyResult.value = '失败:' + e.message; }
|
||
}
|
||
async function checkRenewals() {
|
||
notifyResult.value = '检查中…';
|
||
try {
|
||
const r = await Api.post('/notify/renewals?send=true', {});
|
||
let text = '即将到期 ' + r.expiring_count + ' 项(阈值 ' + r.threshold_days + ' 天)';
|
||
if (r.expiring.length) text += '\n' + r.expiring.map(i => '• ' + i.name + ' [' + i.provider + '] ' + i.days + ' 天后').join('\n');
|
||
if (r.notifications && r.notifications.length) text += '\n\n通知:' + r.notifications.map(n => '[' + n.channel + '] ' + (n.ok ? '成功' : '失败:' + n.message)).join(',');
|
||
notifyResult.value = text;
|
||
} catch (e) { notifyResult.value = '失败:' + e.message; }
|
||
}
|
||
return { store, settings, save: saveSettings, applyDark, notifyChannels, notifyResult, testNotify, checkRenewals };
|
||
},
|
||
};
|
||
|
||
/* ================= 资产编辑模态框 ================= */
|
||
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="col-span-2 block"><span class="text-xs text-slate-500">续费网址</span>
|
||
<input v-model="f.renew_url" placeholder="https://…(去哪续费)" 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">取消订阅网址</span>
|
||
<input v-model="f.cancel_url" placeholder="https://…(去哪取消)" 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.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: 'subscriptions', 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', subscriptions: 'subscriptions-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('subscriptions-view', SubscriptionsView);
|
||
app.component('ai-view', AIAccountsView);
|
||
app.component('settings-view', SettingsView);
|
||
applyDark();
|
||
loadAll();
|
||
app.mount('#app');
|