Files
vps-manager/static/js/views/assets.js
T
gouki eeb05372c4 feat(ui): 全局弹窗替代原生 alert/confirm,添加防重复提交与搜索功能
- 新增 ConfirmModal / ResultModal 全局组件,替代原生 alert/confirm/prompt
- 资产/平台/账号保存添加防重复提交锁(assetSaving / providerSaving)
- 添加 Esc 键盘快捷键关闭模态框
- 平台/监控视图添加搜索过滤功能
- 修复监控中心 SSL TAB 下新增按钮的显示逻辑
- 调整 toast 位置避免遮挡底部导航
- 统一各弹窗 z-index 层级
2026-10-17 21:17:18 +00:00

182 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 资产视图: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>
<!-- 平台筛选标签(从平台页跳转而来,点 ✕ 取消) -->
<div v-if="store.filterProvider" class="flex items-center">
<span class="inline-flex items-center gap-1 text-xs px-2.5 py-1 rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400">
🏢 平台:{{ providerName(store.filterProvider) }}
<button @click="clearProvider" class="font-bold hover:opacity-70" title="取消平台筛选">✕</button>
</span>
</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 value="abnormal">异常(已停止/已过期)</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>
<button v-if="store.assetTab==='' || store.assetTab==='vps'" @click="syncAllAssets" class="text-sm px-3 py-1.5 rounded-lg border border-emerald-300 dark:border-emerald-700 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20" title="拉取所有已配 API 凭证账号下的云端 VPS 与域名">同步云端资产</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">({{ Fmt.countdownText(a.days_to_expiry, { unit: '' }) }})</span></td>
<td class="px-4 py-2.5" :class="a.cost ? 'text-slate-600 dark:text-slate-300' : 'text-slate-400'">{{ Fmt.costText(a.cost, a.currency) }}<span v-if="a.cost && 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="canControl(a)" @click="openAct(a)" class="text-amber-600 dark:text-amber-400 hover:underline mr-3 text-xs" title="远程开机/关机/重启/续费">云操作</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"> ({{ Fmt.countdownText(a.days_to_expiry, { unit: '' }) }})</span></span>
<span :class="a.cost ? 'text-slate-500' : 'text-slate-400'">{{ Fmt.costText(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="canControl(a)" @click="openAct(a)" class="text-amber-600 dark:text-amber-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(() => {
let arr = store.assets;
if (store.assetTab === 'vps') arr = arr.filter(a => a.asset_type === 'vps');
if (store.filterStatus === 'abnormal') arr = arr.filter(a => a.status === 'stopped' || a.status === 'expired');
return arr;
});
function switchTab(k) {
store.assetTab = k;
// 专有视图(域名/Cloudflare/订阅/AI)有自己的筛选逻辑,通用的类型/状态筛选切过去后会失效,
// 直接清空避免用户以为筛过了但实际看不出来
if (k !== '' && k !== 'vps' && (store.filterType || store.filterStatus)) {
store.filterType = '';
store.filterStatus = '';
loadAssets();
}
}
// 平台筛选标签显示名:filterProvider 存 slug,回退显示原值
function providerName(slug) {
const p = store.providers.find(x => x.slug === slug);
return p ? p.name : slug;
}
async function refreshBalance(a) {
if (!await showConfirm('刷新余额', '刷新「' + a.name + '」的余额?')) return;
try {
const r = await Api.post('/assets/' + a.id + '/refresh-balance', {});
showResult('刷新成功', '余额已更新:' + r.balance + ' ' + r.currency);
await loadAssets();
} catch (e) { showResult('刷新失败', e.message, true); }
}
async function syncAllAI() {
if (!await showConfirm('同步 AI 余额', '同步所有 AI 账号的余额?')) return;
try {
const r = await Api.post('/sync/ai-balances', {});
// skipped:平台本身没余额接口(如 OpenAI 已下线 billing),不属于失败
showResult('同步完成', '成功 ' + r.success + ',失败 ' + r.failed +
(r.skipped ? ',跳过 ' + r.skipped + '(平台无余额接口)' : '') +
(r.errors && r.errors.length ? '\n' + r.errors.join('\n') : ''));
await loadAssets();
} catch (e) { showResult('同步失败', e.message, true); }
}
async function syncAllAssets() {
if (!await showConfirm('同步云端资产', '从云端拉取所有已配 API 凭证账号下的 VPS 与域名?\n\n会按 (平台, 云端实例ID) 去重:已存在则更新状态/到期日,不存在则新建。')) return;
try {
const r = await Api.post('/sync/assets', {});
const lines = [
'同步完成:成功 ' + r.success + ',失败 ' + r.failed,
'新增 ' + r.created + ',更新 ' + r.updated,
];
// 未配凭证 / 平台无适配器属于「跳过」而非失败,但必须如实报出来,
// 否则用户以为同步过了、实际一条都没拉
if (r.skipped_no_config) lines.push('跳过(账号未配 API 凭证):' + r.skipped_no_config);
if (r.skipped_no_adapter) lines.push('跳过(平台无可用适配器):' + r.skipped_no_adapter);
if (r.errors && r.errors.length) lines.push('\n' + r.errors.join('\n'));
showResult('同步完成', lines.join('\n'));
await Promise.all([loadAssets(), loadOverview()]);
} catch (e) { showResult('同步失败', e.message, true); }
}
return { store, Fmt, tabs, tabView, list, switchTab, providerName, reload: loadAssets, clearProvider: clearProviderFilter, edit: openAssetEdit, del: deleteAsset, viewServer, refreshBalance, syncAllAI, syncAllAssets, canControl: assetCanControl, openAct: openActions };
},
};