feat: 综合平台services多服务支持+SW网络优先缓存修复+适配层/定时任务完善
This commit is contained in:
@@ -32,6 +32,8 @@ window.VpsFmt = {
|
||||
TYPE_LABELS: { vps: 'VPS', domain: '域名', ai_agent: 'AI账号', cloudflare: 'Cloudflare', other: '其他' },
|
||||
STATUS_LABELS: { active: '使用中', expired: '已过期', stopped: '已停止', cancelled: '已注销', unknown: '未知' },
|
||||
CATEGORY_LABELS: { vps: 'VPS', domain: '域名', ai_agent: 'AI账号', cloudflare: 'Cloudflare', other: '其他' },
|
||||
// 平台可提供的服务(综合平台多标签):字段对应 Provider.services 逗号分隔列表
|
||||
SERVICES_LABELS: { vps: '云服务器', domain: '域名', ai_agent: 'AI服务', cloudflare: 'Cloudflare', ssl_cert: 'SSL证书', cdn: 'CDN', dns: 'DNS', other: '其他' },
|
||||
CYCLE_LABELS: { monthly: '月付', quarterly: '季付', yearly: '年付' },
|
||||
typeBadge(t) {
|
||||
return { vps: 'bg-blue-500/10 text-blue-600 dark:text-blue-400', domain: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', ai_agent: 'bg-violet-500/10 text-violet-600 dark:text-violet-400', cloudflare: 'bg-orange-500/10 text-orange-600 dark:text-orange-400', other: 'bg-slate-500/10 text-slate-600 dark:text-slate-400' }[t] || 'bg-slate-500/10 text-slate-500';
|
||||
|
||||
+54
-1028
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,207 @@
|
||||
/* 资产编辑模态框 */
|
||||
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>
|
||||
|
||||
<fieldset v-if="f.asset_type==='cloudflare'" 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">Cloudflare 子资产详情</legend>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="block"><span class="text-xs text-slate-400">账号邮箱</span><input v-model="f.cloudflare_detail.account_email" placeholder="账号邮箱(区分多账号)" 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.cloudflare_detail.sub_type" 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="zone">zone(解析)</option><option value="worker">Worker</option><option value="r2">R2</option><option value="tunnel">Tunnel</option><option value="mail">Mail</option><option value="dns_record">DNS记录</option><option value="other">其他</option></select></label>
|
||||
<label class="block"><span class="text-xs text-slate-400">子资产名称</span><input v-model="f.cloudflare_detail.sub_name" placeholder="如 worker 名 / bucket 名" 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">关联 zone/域名</span><input v-model="f.cloudflare_detail.zone_name" placeholder="example.com" 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.cloudflare_detail.status" placeholder="active / paused 等" 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 = Vue.computed(() => store.assetModal.form);
|
||||
const providersForType = Vue.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];
|
||||
if (!cat) return store.providers;
|
||||
// 综合平台按 services 过滤(如阿里云可同时选 vps/domain);services 为空时回退分类匹配
|
||||
return store.providers.filter(p => {
|
||||
if (p.services) return p.services.split(',').map(s => s.trim()).includes(cat);
|
||||
return p.category === cat || p.category === 'other';
|
||||
});
|
||||
});
|
||||
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="col-span-2 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>
|
||||
<div class="col-span-2 block"><span class="text-xs text-slate-500">提供服务(可多选,综合平台全选)</span>
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-1 mt-1">
|
||||
<label v-for="(l,k) in Fmt.SERVICES_LABELS" :key="k" class="flex items-center gap-1 text-xs text-slate-600 dark:text-slate-300"><input type="checkbox" :checked="servicesSet.has(k)" @change="toggleService(k)"> {{ l }}</label>
|
||||
</div>
|
||||
</div>
|
||||
<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 = Vue.computed(() => store.providerModal.form);
|
||||
const adapterMeta = Vue.ref([]);
|
||||
const apiFields = Vue.reactive({});
|
||||
Vue.onMounted(async () => {
|
||||
try { const r = await Api.get('/providers/adapters'); adapterMeta.value = r.adapters || []; } catch (e) { /* ignore */ }
|
||||
});
|
||||
const sdkOptions = Vue.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 = Vue.computed(() => {
|
||||
const opt = sdkOptions.value.find(o => o.type === (f.value && f.value.sdk_type));
|
||||
return opt ? opt.fields : [];
|
||||
});
|
||||
// 提供服务多选:services 逗号分隔字符串 <-> checkbox 集合
|
||||
const servicesSet = Vue.computed(() => new Set((f.value && f.value.services || '').split(',').map(s => s.trim()).filter(Boolean)));
|
||||
function toggleService(k) {
|
||||
const arr = (f.value.services || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const idx = arr.indexOf(k);
|
||||
if (idx >= 0) arr.splice(idx, 1); else arr.push(k);
|
||||
f.value.services = arr.join(',');
|
||||
}
|
||||
// 模态框每次打开时清空上次残留的凭证输入,避免串号
|
||||
Vue.watch(() => store.providerModal.show, (show) => {
|
||||
if (show) { for (const k of Object.keys(apiFields)) delete apiFields[k]; }
|
||||
});
|
||||
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, servicesSet, toggleService, apiFields, save };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
/* 全局响应式状态与工具函数 */
|
||||
const { reactive, computed } = Vue;
|
||||
const Api = window.VpsApi;
|
||||
const Fmt = window.VpsFmt;
|
||||
|
||||
/* ---------------- 全局状态 ---------------- */
|
||||
const store = reactive({
|
||||
view: 'dashboard',
|
||||
assets: [],
|
||||
providers: [],
|
||||
subdomains: [],
|
||||
siteCerts: [],
|
||||
overview: {},
|
||||
expiring: [],
|
||||
serverMetrics: {},
|
||||
search: '',
|
||||
filterType: '',
|
||||
filterStatus: '',
|
||||
assetTab: '', // 资产页 TAB:'' 全部 / vps / domain / cloudflare / subscription(付费资产) / ai_agent
|
||||
monitorTab: '', // 监控中心 TAB:同上
|
||||
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 },
|
||||
cloudflare_detail: { account_email: '', sub_type: 'zone', sub_name: '', zone_name: '', status: '' },
|
||||
};
|
||||
}
|
||||
|
||||
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 };
|
||||
} else if (f.asset_type === 'cloudflare') {
|
||||
const c = f.cloudflare_detail;
|
||||
p.cloudflare_detail = { account_email: c.account_email || null, sub_type: c.sub_type || 'zone', sub_name: c.sub_name || null, zone_name: c.zone_name || null, status: c.status || 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 loadSubdomains() { store.subdomains = await Api.get('/subdomains'); }
|
||||
async function loadSiteCerts() { store.siteCerts = await Api.get('/site-certs'); }
|
||||
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(), loadSubdomains(), loadSiteCerts(), 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);
|
||||
if (a.cloudflare_detail) Object.assign(form.cloudflare_detail, a.cloudflare_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', services: '', 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, services: p.services || '', 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, services: f.services || null, 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) {
|
||||
try {
|
||||
const [metrics, info, checks, security] = await Promise.all([
|
||||
Api.get('/monitor/' + a.id + '/metrics?limit=200'),
|
||||
Api.get('/monitor/' + a.id + '/info'),
|
||||
Api.get('/monitor/' + a.id + '/security'),
|
||||
Api.get('/monitor/' + a.id + '/security-score'),
|
||||
]);
|
||||
store.serverMetrics = { asset: a, metrics, info, checks, security };
|
||||
} catch (e) { store.serverMetrics = { asset: a, metrics: [], info: null, checks: [], security: null }; }
|
||||
// 通过 hash 路由切换视图,保证刷新/分享链接后能回到 servers 视图
|
||||
if (window.location.hash !== '#servers') {
|
||||
window.location.hash = '#servers';
|
||||
} else {
|
||||
store.view = 'servers';
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 设置 ---------------- */
|
||||
const settings = reactive({ apiKey: Api.getApiKey(), saved: false });
|
||||
function saveSettings() { Api.setApiKey(settings.apiKey); settings.saved = true; setTimeout(() => settings.saved = false, 2000); }
|
||||
|
||||
function normalizedMonthly(cost, cycle) {
|
||||
if (!cost) return 0;
|
||||
if (cycle === 'yearly') return cost / 12;
|
||||
if (cycle === 'quarterly') return cost / 3;
|
||||
return cost;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/* AI 账号视图 */
|
||||
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 = Vue.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 = Vue.computed(() => aiList().length);
|
||||
const totalBalance = Vue.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 };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
/* 资产视图:TAB 容器(全部/VPS/域名/Cloudflare/订阅/AI账号)
|
||||
* 注意:专有视图(DomainsView 等)在后续脚本才定义,必须在 setup 运行时求值,
|
||||
* 不能在组件定义处直接引用(会因加载顺序抛 ReferenceError)。
|
||||
*/
|
||||
const AssetsView = {
|
||||
template: `
|
||||
<div class="space-y-3">
|
||||
<!-- 类型 TAB -->
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button v-for="t in tabs" :key="t.key" @click="switchTab(t.key)" :title="t.tip"
|
||||
class="text-sm px-3 py-1.5 rounded-full transition"
|
||||
:class="store.assetTab===t.key ? 'bg-blue-600 text-white' : 'bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800'">
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 通用列表(全部 / VPS) -->
|
||||
<template v-if="store.assetTab==='' || store.assetTab==='vps'">
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<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 v-if="store.assetTab===''" @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">共 {{ list.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="!list.length"><td colspan="7" class="px-4 py-10 text-center text-slate-400">暂无资产</td></tr>
|
||||
<tr v-for="a in list" :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="!list.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 list" :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>
|
||||
</template>
|
||||
|
||||
<!-- 专有视图(域名/Cloudflare/订阅/AI账号) -->
|
||||
<component v-else :is="tabView"></component>
|
||||
</div>`,
|
||||
setup() {
|
||||
// tip 为 TAB 悬浮说明:让用户一眼明白每个分类装的是什么
|
||||
const tabs = [
|
||||
{ key: '', label: '全部', tip: '所有类型的资产' },
|
||||
{ key: 'vps', label: 'VPS', tip: '云服务器(可查看资源监控)' },
|
||||
{ key: 'domain', label: '域名', tip: '域名与解析' },
|
||||
{ key: 'cloudflare', label: 'Cloudflare', tip: 'Cloudflare 账号下的资源' },
|
||||
{ key: 'subscription', label: '付费资产', tip: '有费用或到期日、需要续费的项目(跨类型汇总)' },
|
||||
{ key: 'ai_agent', label: 'AI账号', tip: 'ChatGPT 等 AI 服务账号与余额' },
|
||||
];
|
||||
const tabView = Vue.computed(() => {
|
||||
const t = store.assetTab;
|
||||
if (t === 'domain') return DomainsView;
|
||||
if (t === 'cloudflare') return CloudflareView;
|
||||
if (t === 'subscription') return SubscriptionsView;
|
||||
if (t === 'ai_agent') return AIAccountsView;
|
||||
return null;
|
||||
});
|
||||
// 通用列表:全部显示所有,VPS 前端过滤类型
|
||||
const list = Vue.computed(() => {
|
||||
if (store.assetTab === 'vps') return store.assets.filter(a => a.asset_type === 'vps');
|
||||
return store.assets;
|
||||
});
|
||||
function switchTab(k) {
|
||||
store.assetTab = k;
|
||||
// 专有视图需要全量数据;若此前按类型过滤过则清空重载
|
||||
if (k !== '' && k !== 'vps' && store.filterType) { store.filterType = ''; loadAssets(); }
|
||||
}
|
||||
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, tabs, tabView, list, switchTab, reload: loadAssets, edit: openAssetEdit, del: deleteAsset, viewServer, refreshBalance, syncAllAI };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
/* Cloudflare 子资产视图 */
|
||||
const SUB_TYPE_LABELS = { zone: '解析', worker: 'Worker', r2: 'R2', tunnel: 'Tunnel', mail: 'Mail', dns_record: 'DNS记录', other: '其他' };
|
||||
|
||||
const CloudflareView = {
|
||||
template: `
|
||||
<div class="space-y-4">
|
||||
<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">
|
||||
暂无 Cloudflare 子资产(新增资产时选 Cloudflare 类型即可)
|
||||
</div>
|
||||
<div v-for="g in groups" :key="g.account" 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">
|
||||
<div class="font-semibold text-sm">☁️ {{ g.account || '未指定账号' }}</div>
|
||||
<span class="text-xs text-slate-400">{{ g.items.length }} 个子资产</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div v-for="a in g.items" :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">
|
||||
<div class="min-w-0 flex items-center gap-2">
|
||||
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="subTypeBadge(a.cloudflare_detail.sub_type)">{{ SUB_TYPE_LABELS[a.cloudflare_detail.sub_type] || a.cloudflare_detail.sub_type }}</span>
|
||||
<span class="font-medium truncate">{{ a.cloudflare_detail.sub_name || a.cloudflare_detail.zone_name || a.name }}</span>
|
||||
<span v-if="a.cloudflare_detail.zone_name && a.cloudflare_detail.sub_type !== 'zone'" class="text-xs text-slate-400">@{{ a.cloudflare_detail.zone_name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs shrink-0">
|
||||
<span v-if="a.cloudflare_detail.status" class="text-slate-500">{{ a.cloudflare_detail.status }}</span>
|
||||
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline">编辑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`,
|
||||
setup() {
|
||||
const groups = Vue.computed(() => {
|
||||
const cfAssets = store.assets.filter(a => a.asset_type === 'cloudflare' && a.cloudflare_detail);
|
||||
const map = {};
|
||||
for (const a of cfAssets) {
|
||||
const acc = a.cloudflare_detail.account_email || '';
|
||||
if (!map[acc]) map[acc] = { account: a.cloudflare_detail.account_email, items: [] };
|
||||
map[acc].items.push(a);
|
||||
}
|
||||
return Object.values(map);
|
||||
});
|
||||
function subTypeBadge(t) {
|
||||
return { zone: 'bg-blue-500/10 text-blue-600 dark:text-blue-400', worker: 'bg-orange-500/10 text-orange-600 dark:text-orange-400', r2: 'bg-purple-500/10 text-purple-600 dark:text-purple-400', tunnel: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', mail: 'bg-pink-500/10 text-pink-600 dark:text-pink-400', dns_record: 'bg-slate-500/10 text-slate-600', other: 'bg-slate-500/10 text-slate-500' }[t] || 'bg-slate-500/10 text-slate-500';
|
||||
}
|
||||
return { store, groups, subTypeBadge, SUB_TYPE_LABELS, edit: openAssetEdit };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
/* 总览视图 */
|
||||
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 }; },
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
/* 域名视图:域名列表 + 子域名管理 + 站点 SSL 状态
|
||||
* 交互:每行可展开「子域名」面板;子域名可添加/编辑/删除、一键探测 SSL 证书;
|
||||
* 域名行显示该域名下最近的证书到期徽章。
|
||||
*/
|
||||
const DomainsView = {
|
||||
template: `
|
||||
<div class="space-y-3">
|
||||
<!-- 说明条 -->
|
||||
<div class="text-xs text-slate-500 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2">
|
||||
🌐 域名资产下管理子域名与站点 SSL 证书:展开任意域名添加子域名(如 www/api),点「探测SSL」即可监控该站点证书到期情况。
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-x-auto">
|
||||
<table class="w-full text-sm min-w-[820px]">
|
||||
<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">站点SSL</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="7" class="px-4 py-10 text-center text-slate-400">暂无域名资产</td></tr>
|
||||
<template v-for="a in domains" :key="a.id">
|
||||
<tr 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 v-if="sslByAsset[a.id]" class="text-xs px-2 py-0.5 rounded-full whitespace-nowrap" :class="certBadge(sslByAsset[a.id].days_to_expiry)" :title="sslByAsset[a.id].issuer || ''">
|
||||
{{ sslByAsset[a.id].hostname }} · {{ certText(sslByAsset[a.id]) }}
|
||||
</span>
|
||||
<span v-else class="text-xs text-slate-400">—</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 whitespace-nowrap">
|
||||
<button @click="toggle(a)" class="text-indigo-600 dark:text-indigo-400 hover:underline mr-3 text-xs">{{ expanded[a.id] ? '收起' : '子域名' }}</button>
|
||||
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline text-xs">编辑/绑定</button>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- 子域名展开区 -->
|
||||
<tr v-if="expanded[a.id]">
|
||||
<td colspan="7" class="px-4 py-3 bg-slate-50/60 dark:bg-slate-800/20">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-2">
|
||||
<span class="text-xs font-semibold text-slate-600 dark:text-slate-300">子域名({{ domainName(a) }})</span>
|
||||
<button @click="resetForm(a)" class="text-xs px-2 py-1 rounded-lg bg-blue-600 text-white">+ 新增子域名</button>
|
||||
<button @click="probeSsl(domainName(a), a)" class="text-xs px-2 py-1 rounded-lg border border-violet-300 dark:border-violet-700 text-violet-600 dark:text-violet-400">🔒 探测根域名SSL</button>
|
||||
<span class="ml-auto text-xs text-slate-400">共 {{ (subsByAsset[a.id]||[]).length }} 条</span>
|
||||
</div>
|
||||
|
||||
<div v-if="(subsByAsset[a.id]||[]).length" class="space-y-1 mb-2">
|
||||
<div v-for="s in subsByAsset[a.id]||[]" :key="s.id" class="flex flex-wrap items-center gap-2 bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 px-3 py-2 text-xs">
|
||||
<span class="font-mono font-medium text-slate-700 dark:text-slate-200">{{ fullHost(s, a) }}</span>
|
||||
<span v-if="s.record_type" class="px-1.5 py-0.5 rounded bg-slate-500/10 text-slate-500">{{ s.record_type }}</span>
|
||||
<span v-if="s.record_value" class="text-slate-400 truncate max-w-[180px]" :title="s.record_value">{{ s.record_value }}</span>
|
||||
<span :class="s.is_active ? 'text-emerald-600 dark:text-emerald-400' : 'text-slate-400'">{{ s.is_active ? '启用' : '停用' }}</span>
|
||||
<span v-if="s.note" class="text-slate-400 truncate max-w-[120px]" :title="s.note">{{ s.note }}</span>
|
||||
<span class="ml-auto flex gap-3">
|
||||
<button @click="probeSsl(fullHost(s, a), a)" class="text-violet-600 dark:text-violet-400 hover:underline">🔒 探测SSL</button>
|
||||
<button @click="startEdit(a, s)" class="text-blue-600 dark:text-blue-400 hover:underline">编辑</button>
|
||||
<button @click="delSub(s)" class="text-red-600 dark:text-red-400 hover:underline">删除</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-slate-400 mb-2">暂无子域名,点「+ 新增子域名」开始管理</div>
|
||||
|
||||
<!-- 新增/编辑表单 -->
|
||||
<form v-if="forms[a.id]" @submit.prevent="saveSub(a)" class="flex flex-wrap gap-2 items-end bg-white dark:bg-slate-900 rounded-lg border border-blue-200 dark:border-blue-900/50 px-3 py-2">
|
||||
<label class="text-xs text-slate-500">主机名
|
||||
<input v-model="forms[a.id].host" placeholder="www / api(@=根域名)" class="block text-sm px-2 py-1 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 w-28 mt-0.5">
|
||||
</label>
|
||||
<label class="text-xs text-slate-500">类型
|
||||
<select v-model="forms[a.id].record_type" class="block text-sm px-2 py-1 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 mt-0.5">
|
||||
<option v-for="t in ['A','CNAME','AAAA','MX','TXT']" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-xs text-slate-500 flex-1 min-w-[130px]">记录值
|
||||
<input v-model="forms[a.id].record_value" placeholder="IP 或目标域名" class="block text-sm px-2 py-1 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 w-full mt-0.5">
|
||||
</label>
|
||||
<label class="text-xs text-slate-500">备注
|
||||
<input v-model="forms[a.id].note" class="block text-sm px-2 py-1 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 w-24 mt-0.5">
|
||||
</label>
|
||||
<label class="text-xs text-slate-500 flex items-center gap-1 pb-1.5"><input type="checkbox" v-model="forms[a.id].is_active" class="accent-blue-600">启用</label>
|
||||
<button type="submit" class="text-sm px-3 py-1.5 rounded-lg bg-blue-600 text-white">{{ forms[a.id].editing ? '保存' : '添加' }}</button>
|
||||
<button type="button" @click="resetForm(a)" class="text-sm px-2 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 text-slate-500">取消</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>`,
|
||||
setup() {
|
||||
const domains = Vue.computed(() => store.assets.filter(a => a.asset_type === 'domain' && a.domain_detail));
|
||||
const expanded = Vue.reactive({}); // asset_id -> 是否展开子域名面板
|
||||
const forms = Vue.reactive({}); // asset_id -> 新增/编辑表单
|
||||
|
||||
const subsByAsset = Vue.computed(() => {
|
||||
const m = {};
|
||||
for (const s of store.subdomains) { (m[s.asset_id] = m[s.asset_id] || []).push(s); }
|
||||
return m;
|
||||
});
|
||||
// 每个域名资产下最近到期的一条证书(用于主行徽章)
|
||||
const sslByAsset = Vue.computed(() => {
|
||||
const m = {};
|
||||
for (const c of store.siteCerts) {
|
||||
if (!c.asset_id) continue;
|
||||
if (!m[c.asset_id] || (c.days_to_expiry ?? 9999) < (m[c.asset_id].days_to_expiry ?? 9999)) m[c.asset_id] = c;
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
function domainName(a) { return a.domain_detail.domain_name; }
|
||||
function fullHost(s, a) { return s.host === '@' ? domainName(a) : s.host + '.' + domainName(a); }
|
||||
function toggle(a) {
|
||||
expanded[a.id] = !expanded[a.id];
|
||||
if (expanded[a.id] && !forms[a.id]) resetForm(a);
|
||||
}
|
||||
function resetForm(a) {
|
||||
forms[a.id] = { host: '', record_type: 'A', record_value: '', note: '', is_active: true, editing: null };
|
||||
}
|
||||
function startEdit(a, s) {
|
||||
expanded[a.id] = true;
|
||||
forms[a.id] = { host: s.host, record_type: s.record_type || 'A', record_value: s.record_value || '', note: s.note || '', is_active: !!s.is_active, editing: s.id };
|
||||
}
|
||||
async function saveSub(a) {
|
||||
const f = forms[a.id];
|
||||
if (!f.host.trim()) { alert('请填写主机名'); return; }
|
||||
const payload = { host: f.host.trim(), record_type: f.record_type, record_value: f.record_value, note: f.note, is_active: !!f.is_active };
|
||||
try {
|
||||
if (f.editing) await Api.put('/subdomains/' + f.editing, payload);
|
||||
else await Api.post('/subdomains', { ...payload, asset_id: a.id });
|
||||
await loadSubdomains();
|
||||
resetForm(a);
|
||||
} catch (e) { alert('保存失败:' + e.message); }
|
||||
}
|
||||
async function delSub(s) {
|
||||
if (!confirm('删除子域名「' + s.host + '」?')) return;
|
||||
try { await Api.del('/subdomains/' + s.id); await loadSubdomains(); }
|
||||
catch (e) { alert('删除失败:' + e.message); }
|
||||
}
|
||||
async function probeSsl(defaultHost, a) {
|
||||
const target = prompt('探测 SSL 证书的站点(留空取消):', defaultHost);
|
||||
if (!target) return;
|
||||
try {
|
||||
await Api.post('/site-certs', { hostname: target, asset_id: a.id });
|
||||
await loadSiteCerts();
|
||||
alert('已探测:' + target);
|
||||
} catch (e) { alert('探测失败:' + e.message); }
|
||||
}
|
||||
function certText(c) {
|
||||
const d = c.days_to_expiry;
|
||||
if (d === null || d === undefined) return '未知';
|
||||
if (d < 0) return '已过期' + Math.abs(d) + '天';
|
||||
if (d === 0) return '今天到期';
|
||||
return d + '天';
|
||||
}
|
||||
function certBadge(d) {
|
||||
if (d === null || d === undefined) return 'bg-slate-500/10 text-slate-500';
|
||||
if (d < 0 || d === 0 || d <= 7) return 'bg-red-500/10 text-red-600 dark:text-red-400';
|
||||
if (d <= 30) return 'bg-amber-500/10 text-amber-600 dark:text-amber-400';
|
||||
if (d <= 90) return 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400';
|
||||
return 'bg-slate-500/10 text-slate-500';
|
||||
}
|
||||
return { store, Fmt, domains, subsByAsset, sslByAsset, expanded, forms, domainName, fullHost, toggle, resetForm, startEdit, saveSub, delSub, probeSsl, certText, certBadge, edit: openAssetEdit };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,246 @@
|
||||
/* 监控中心视图:默认到期倒计时,可按类别筛选 */
|
||||
const MonitorView = {
|
||||
template: `
|
||||
<div class="space-y-4">
|
||||
<!-- 说明条:让用户明白这个页面是干嘛的 -->
|
||||
<div class="text-xs text-slate-500 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span>⏳ 所有设置了到期日的资产按剩余天数倒计时排序,越靠前越紧急</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-red-500/10 text-red-600 dark:text-red-400">红=7天内</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400">橙=30天内</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">绿=90天内</span>
|
||||
</div>
|
||||
|
||||
<!-- 顶部统计(资产) -->
|
||||
<div v-if="store.monitorTab!=='ssl'" 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">30 天内到期</div>
|
||||
<div class="text-2xl font-bold mt-1 text-red-600 dark:text-red-400">{{ stats.soon30 }}</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">7 天内到期</div>
|
||||
<div class="text-2xl font-bold mt-1 text-amber-600 dark:text-amber-400">{{ stats.soon7 }}</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-2xl font-bold mt-1 text-slate-700 dark:text-slate-200">{{ stats.expired }}</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-2xl font-bold mt-1">{{ total }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 顶部统计(SSL 证书) -->
|
||||
<div v-if="store.monitorTab==='ssl'" 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">{{ sslStats.total }}</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-2xl font-bold mt-1 text-emerald-600 dark:text-emerald-400">{{ sslStats.valid }}</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-2xl font-bold mt-1 text-amber-600 dark:text-amber-400">{{ sslStats.expiring }}</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-2xl font-bold mt-1 text-red-600 dark:text-red-400">{{ sslStats.expired + sslStats.error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 类别 TAB -->
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button v-for="t in tabs" :key="t.key" @click="switchTab(t.key)" :title="t.tip"
|
||||
class="text-sm px-3 py-1.5 rounded-full transition"
|
||||
:class="store.monitorTab===t.key ? 'bg-blue-600 text-white' : 'bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800'">
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 资产表格(非 SSL TAB) -->
|
||||
<template v-if="store.monitorTab!=='ssl'">
|
||||
<!-- 桌面表格 -->
|
||||
<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="!items.length"><td colspan="7" class="px-4 py-10 text-center text-slate-400">{{ emptyText }}</td></tr>
|
||||
<tr v-for="a in items" :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"><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">{{ a.expiry_date }}</td>
|
||||
<td class="px-4 py-2.5"><span class="text-xs font-semibold px-2 py-0.5 rounded-full" :class="countdownBadge(a.days_to_expiry)">{{ countdownText(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 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>
|
||||
<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="!items.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">{{ emptyText }}</div>
|
||||
<div v-for="a in items" :key="a.id" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-3">
|
||||
<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="countdownBadge(a.days_to_expiry)">{{ countdownText(a.days_to_expiry) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-2 text-xs">
|
||||
<span class="text-slate-500">{{ a.expiry_date }}</span>
|
||||
<span class="text-slate-500">{{ a.cost }}{{ a.currency }}</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.typeBadge(a.asset_type)">{{ Fmt.TYPE_LABELS[a.asset_type] }}</span>
|
||||
</div>
|
||||
<div class="flex gap-4 mt-2 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs">
|
||||
<a v-if="a.renew_url" :href="a.renew_url" target="_blank" class="text-emerald-600 dark:text-emerald-400">续费</a>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<!-- SSL 证书表格 -->
|
||||
<template v-if="store.monitorTab==='ssl'">
|
||||
<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="!sslList.length"><td colspan="7" class="px-4 py-10 text-center text-slate-400">暂无证书监控(在「资产 → 域名」的子域名面板点「探测SSL」,或稍后支持直接添加)</td></tr>
|
||||
<tr v-for="c in sslList" :key="c.id" class="hover:bg-slate-50 dark:hover:bg-slate-800/30">
|
||||
<td class="px-4 py-2.5 font-mono font-medium">{{ c.hostname }}<span v-if="c.port!==443" class="text-xs text-slate-400">:{{ c.port }}</span></td>
|
||||
<td class="px-4 py-2.5 text-slate-500">{{ c.asset_name || '—' }}</td>
|
||||
<td class="px-4 py-2.5 text-slate-500 max-w-[220px] truncate" :title="c.issuer || ''">{{ c.issuer || '—' }}</td>
|
||||
<td class="px-4 py-2.5">{{ c.valid_to || '—' }}</td>
|
||||
<td class="px-4 py-2.5"><span class="text-xs font-semibold px-2 py-0.5 rounded-full" :class="countdownBadge(c.days_to_expiry)">{{ countdownText(c.days_to_expiry) }}</span></td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span v-if="c.status==='error'" class="text-xs text-red-600 dark:text-red-400" :title="c.error || ''">探测失败</span>
|
||||
<span v-else class="text-xs text-slate-500" :title="c.error || ''">{{ c.last_checked_at ? '已检查 ' + c.last_checked_at.slice(5,16).replace('T',' ') : '未检查' }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-right whitespace-nowrap">
|
||||
<button @click="recheck(c)" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs">重测</button>
|
||||
<button @click="delCert(c)" class="text-red-600 dark:text-red-400 hover:underline text-xs">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- SSL 移动卡片 -->
|
||||
<div class="md:hidden space-y-2">
|
||||
<div v-if="!sslList.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="c in sslList" :key="c.id" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-3">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="min-w-0">
|
||||
<div class="font-mono font-medium text-sm truncate">{{ c.hostname }}</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">{{ c.asset_name || '独立站点' }}</div>
|
||||
</div>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="countdownBadge(c.days_to_expiry)">{{ countdownText(c.days_to_expiry) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-2 text-xs text-slate-500">
|
||||
<span>{{ c.valid_to || '—' }}</span>
|
||||
<span v-if="c.status==='error'" class="text-red-600 dark:text-red-400" :title="c.error || ''">探测失败</span>
|
||||
</div>
|
||||
<div class="flex gap-4 mt-2 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs">
|
||||
<button @click="recheck(c)" class="text-emerald-600 dark:text-emerald-400">重测</button>
|
||||
<button @click="delCert(c)" class="text-red-600 dark:text-red-400">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>`,
|
||||
setup() {
|
||||
const tabs = [
|
||||
{ key: '', label: '全部', tip: '所有有到期日的资产' },
|
||||
{ key: 'vps', label: 'VPS', tip: '仅看云服务器' },
|
||||
{ key: 'domain', label: '域名', tip: '仅看域名' },
|
||||
{ key: 'cloudflare', label: 'Cloudflare', tip: '仅看 Cloudflare 资源' },
|
||||
{ key: 'subscription', label: '付费资产', tip: '仅看有费用、需要续费的项目' },
|
||||
{ key: 'ai_agent', label: 'AI账号', tip: '仅看 AI 服务账号' },
|
||||
{ key: 'ssl', label: 'SSL证书', tip: '站点 HTTPS 证书到期倒计时(独立于资产)' },
|
||||
];
|
||||
const hasExpiry = a => a.expiry_date && a.status !== 'cancelled';
|
||||
const items = Vue.computed(() => {
|
||||
if (store.monitorTab === 'ssl') return [];
|
||||
let list = store.assets.filter(hasExpiry);
|
||||
const t = store.monitorTab;
|
||||
if (t === 'subscription') list = list.filter(a => a.cost > 0);
|
||||
else if (t) list = list.filter(a => a.asset_type === t);
|
||||
return list.sort((x, y) => (x.days_to_expiry ?? 9999) - (y.days_to_expiry ?? 9999));
|
||||
});
|
||||
// SSL 证书:按剩余天数倒计时排序
|
||||
const sslList = Vue.computed(() =>
|
||||
[...store.siteCerts].sort((x, y) => (x.days_to_expiry ?? 9999) - (y.days_to_expiry ?? 9999))
|
||||
);
|
||||
const sslStats = Vue.computed(() => {
|
||||
const s = { total: store.siteCerts.length, valid: 0, expiring: 0, expired: 0, error: 0 };
|
||||
for (const c of store.siteCerts) if (s[c.status] !== undefined) s[c.status]++;
|
||||
return s;
|
||||
});
|
||||
async function recheck(c) {
|
||||
try { await Api.post('/site-certs/' + c.id + '/check', {}); await loadSiteCerts(); }
|
||||
catch (e) { alert('重测失败:' + e.message); }
|
||||
}
|
||||
async function delCert(c) {
|
||||
if (!confirm('删除证书监控「' + c.hostname + '」?')) return;
|
||||
try { await Api.del('/site-certs/' + c.id); await loadSiteCerts(); }
|
||||
catch (e) { alert('删除失败:' + e.message); }
|
||||
}
|
||||
const total = Vue.computed(() => items.value.length);
|
||||
const stats = Vue.computed(() => {
|
||||
const s = { soon30: 0, soon7: 0, expired: 0 };
|
||||
for (const a of store.assets.filter(hasExpiry)) {
|
||||
const d = a.days_to_expiry;
|
||||
if (d === null || d === undefined) continue;
|
||||
if (d < 0) s.expired++;
|
||||
else if (d <= 7) { s.soon7++; s.soon30++; }
|
||||
else if (d <= 30) s.soon30++;
|
||||
}
|
||||
return s;
|
||||
});
|
||||
const emptyText = Vue.computed(() => store.monitorTab ? '该类别暂无到期资产' : '暂无到期资产(资产设置到期日即可出现在这里)');
|
||||
function switchTab(k) { store.monitorTab = k; }
|
||||
function countdownText(d) {
|
||||
if (d === null || d === undefined) return '—';
|
||||
if (d < 0) return '已过期 ' + Math.abs(d) + ' 天';
|
||||
if (d === 0) return '今天到期';
|
||||
return d + ' 天';
|
||||
}
|
||||
function countdownBadge(d) {
|
||||
if (d === null || d === undefined) return 'bg-slate-500/10 text-slate-500';
|
||||
if (d < 0 || d === 0 || d <= 7) return 'bg-red-500/10 text-red-600 dark:text-red-400';
|
||||
if (d <= 30) return 'bg-amber-500/10 text-amber-600 dark:text-amber-400';
|
||||
if (d <= 90) return 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400';
|
||||
return 'bg-slate-500/10 text-slate-500';
|
||||
}
|
||||
return { store, Fmt, tabs, items, total, stats, sslList, sslStats, emptyText, switchTab, countdownText, countdownBadge, recheck, delCert, edit: openAssetEdit, del: deleteAsset };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
/* 平台管理视图 */
|
||||
const ProvidersView = {
|
||||
template: `
|
||||
<div class="space-y-3">
|
||||
<!-- 说明条:让用户明白平台是什么 -->
|
||||
<div class="text-xs text-slate-500 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2">
|
||||
🏢 平台 = 资产的服务商(Vultr / 新网 / OpenAI…)。在「资产」中添加项目时选择归属平台;支持 API 对接的平台配置好密钥后,可「测试连接」或「同步资产」拉取真实数据。
|
||||
</div>
|
||||
<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>
|
||||
<div v-if="serviceList(p).length" class="flex flex-wrap gap-1 justify-end max-w-[60%]">
|
||||
<span v-for="s in serviceList(p)" :key="s" class="text-[10px] px-1.5 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400">{{ Fmt.SERVICES_LABELS[s] || s }}</span>
|
||||
</div>
|
||||
<span v-else 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">API 对接: {{ 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 class="text-slate-500">📎 关联 {{ countOf(p) }} 个资产</span>
|
||||
<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 = Vue.reactive({});
|
||||
const syncing = Vue.reactive({});
|
||||
const results = Vue.reactive({});
|
||||
const supported = Vue.ref([]);
|
||||
const capabilities = Vue.ref({});
|
||||
// 平台关联资产数:优先按 provider_id 归并,其次按资产里填的平台名/slug
|
||||
const assetCounts = Vue.computed(() => {
|
||||
const m = {};
|
||||
for (const a of store.assets) {
|
||||
if (a.provider_id) m['id:' + a.provider_id] = (m['id:' + a.provider_id] || 0) + 1;
|
||||
else if (a.provider) m['p:' + a.provider] = (m['p:' + a.provider] || 0) + 1;
|
||||
}
|
||||
return m;
|
||||
});
|
||||
function countOf(p) {
|
||||
return (assetCounts.value['id:' + p.id] || 0) + (assetCounts.value['p:' + p.slug] || 0) + (assetCounts.value['p:' + p.name] || 0);
|
||||
}
|
||||
function serviceList(p) {
|
||||
return (p.services || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
Vue.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, countOf, serviceList, isSupported, canSync, fmtTime, test, sync, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
/* 服务器监控视图 */
|
||||
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="goBack" 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">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<h3 class="font-semibold text-sm">安全检查</h3>
|
||||
<div v-if="m.security && m.security.score !== null && m.security.score !== undefined" class="flex items-center gap-2">
|
||||
<span class="text-xs text-slate-500">安全评分</span>
|
||||
<span class="text-lg font-bold" :class="scoreClass(m.security.level)">{{ m.security.score }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<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 = Vue.computed(() => store.serverMetrics);
|
||||
const latest = Vue.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' } },
|
||||
},
|
||||
});
|
||||
}
|
||||
Vue.watch(m, () => { Vue.nextTick(renderChart); }, { deep: true });
|
||||
Vue.onMounted(() => { Vue.nextTick(renderChart); });
|
||||
Vue.onUnmounted(() => { if (chartInstance) { chartInstance.destroy(); chartInstance = null; } });
|
||||
function scoreClass(level) {
|
||||
return { good: 'text-emerald-600 dark:text-emerald-400', warning: 'text-amber-600 dark:text-amber-400', risk: 'text-red-600 dark:text-red-400', unknown: 'text-slate-400' }[level] || 'text-slate-400';
|
||||
}
|
||||
function goBack() { window.location.hash = '#assets'; }
|
||||
return { store, Fmt, m, latest, scoreClass, goBack };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
/* 设置视图 */
|
||||
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 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>
|
||||
<p class="text-xs text-slate-500 mb-2">导出所有资产为 JSON,可导入到新部署的实例,实现数据迁移。</p>
|
||||
<div class="flex gap-2 flex-wrap items-center">
|
||||
<button @click="exportData" 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>
|
||||
<label 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 cursor-pointer">导入数据<input type="file" accept=".json" @change="onImportFile" class="hidden"></label>
|
||||
</div>
|
||||
<div v-if="dataResult" class="mt-2 text-xs px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/50">{{ dataResult }}</div>
|
||||
</div>
|
||||
</div>`,
|
||||
setup() {
|
||||
const notifyChannels = Vue.ref([]);
|
||||
const notifyResult = Vue.ref('');
|
||||
const dataResult = Vue.ref('');
|
||||
Vue.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; }
|
||||
}
|
||||
async function exportData() {
|
||||
dataResult.value = '导出中…';
|
||||
try {
|
||||
const r = await Api.get('/assets/export');
|
||||
const blob = new Blob([JSON.stringify(r, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'vps-manager-export-' + new Date().toISOString().slice(0, 10) + '.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
dataResult.value = '导出成功:' + r.count + ' 个资产';
|
||||
} catch (e) { dataResult.value = '导出失败:' + e.message; }
|
||||
}
|
||||
function onImportFile(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (ev) => {
|
||||
dataResult.value = '导入中…';
|
||||
try {
|
||||
const data = JSON.parse(ev.target.result);
|
||||
const r = await Api.post('/assets/import', data);
|
||||
dataResult.value = '导入完成:新增 ' + r.created + ',更新 ' + r.updated + (r.errors && r.errors.length ? ',错误 ' + r.errors.length : '');
|
||||
await loadAssets();
|
||||
} catch (err) { dataResult.value = '导入失败:' + err.message; }
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
return { store, settings, save: saveSettings, applyDark, notifyChannels, notifyResult, testNotify, checkRenewals, dataResult, exportData, onImportFile };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
/* 订阅视图 */
|
||||
const SubscriptionsView = {
|
||||
template: `
|
||||
<div class="space-y-4">
|
||||
<!-- 说明条:让用户明白这个视图是什么 -->
|
||||
<div class="text-xs text-slate-500 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2">
|
||||
💸 这里是「付费资产」汇总:自动收集所有设置了费用或到期日的资产(跨 VPS / 域名 / AI 账号),统一看每个月花多少钱、哪些该续费。
|
||||
</div>
|
||||
<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 = Vue.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 = Vue.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 = Vue.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 } },
|
||||
},
|
||||
});
|
||||
}
|
||||
Vue.watch([subs, byProviderMonthly], () => { Vue.nextTick(renderBillingChart); }, { deep: true });
|
||||
Vue.onMounted(() => { Vue.nextTick(renderBillingChart); });
|
||||
Vue.onUnmounted(() => { if (billingChart) { billingChart.destroy(); billingChart = null; } });
|
||||
return { store, Fmt, subs, monthlyByCurrency, byProviderMonthly };
|
||||
},
|
||||
};
|
||||
+23
-6
@@ -1,12 +1,13 @@
|
||||
/* vps-manager Service Worker
|
||||
* 缓存策略:
|
||||
* - 预缓存核心静态资源,安装即可离线打开
|
||||
* - 静态资源与页面:stale-while-revalidate(先返回缓存秒开,后台自动拉取最新版本)
|
||||
* - 页面导航(HTML):network-first,回源失败才用缓存。保证刷新即拿到最新版本号
|
||||
* - 静态资源(?v= 版本号):stale-while-revalidate(先返回缓存秒开,后台自动拉取最新)
|
||||
* —— 版本号变了 URL 就变,缓存必 miss,天然回源新文件
|
||||
* - /api/* 数据请求:始终走网络,保证数据实时
|
||||
* 更新机制:业务代码更新后,后台自动同步到缓存,下次访问生效;
|
||||
* 若需强制刷新缓存,递增 CACHE_NAME 即可清理旧缓存。
|
||||
* 更新机制:sw.js 由服务端 no-cache 提供,改动后导航时立即被浏览器发现;
|
||||
* install 后 skipWaiting + clients.claim 立即接管,无需手动清缓存。
|
||||
*/
|
||||
const CACHE_NAME = 'vps-manager-v1';
|
||||
const CACHE_NAME = 'vps-manager-v3';
|
||||
|
||||
const PRECACHE_URLS = [
|
||||
'/',
|
||||
@@ -48,10 +49,26 @@ self.addEventListener('fetch', (event) => {
|
||||
// 数据接口走网络,不缓存
|
||||
if (url.pathname.startsWith('/api/')) return;
|
||||
|
||||
// 页面导航:network-first,保证每次刷新都拿到最新 HTML(含最新版本号)
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(request, { cache: 'no-store' })
|
||||
.then((response) => {
|
||||
const copy = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match(request))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 静态资源:stale-while-revalidate(版本号控制缓存失效)
|
||||
event.respondWith(
|
||||
caches.open(CACHE_NAME).then(async (cache) => {
|
||||
const cached = await cache.match(request);
|
||||
const network = fetch(request)
|
||||
// no-store:绕开浏览器 HTTP 磁盘缓存,保证回源拿到最新版本
|
||||
const network = fetch(request, { cache: 'no-store' })
|
||||
.then((response) => {
|
||||
if (response && response.ok) cache.put(request, response.clone());
|
||||
return response;
|
||||
|
||||
Reference in New Issue
Block a user