feat: 订阅/付费管理(续费/取消网址+订阅视图+月度年度支出统计)

This commit is contained in:
gouki
2026-08-02 21:01:34 +00:00
parent 99349875ae
commit e53361d1e4
4 changed files with 89 additions and 3 deletions
+4
View File
@@ -60,6 +60,10 @@ def _migrate_assets_db() -> None:
cols = {c["name"] for c in insp.get_columns("assets")}
if "external_id" not in cols:
conn.execute(sa.text("ALTER TABLE assets ADD COLUMN external_id VARCHAR"))
if "renew_url" not in cols:
conn.execute(sa.text("ALTER TABLE assets ADD COLUMN renew_url VARCHAR"))
if "cancel_url" not in cols:
conn.execute(sa.text("ALTER TABLE assets ADD COLUMN cancel_url VARCHAR"))
if insp.has_table("providers"):
cols = {c["name"] for c in insp.get_columns("providers")}
if "last_synced_at" not in cols:
+6
View File
@@ -52,6 +52,12 @@ class Asset(SQLModel, table=True):
renewal_cycle: Optional[str] = Field(
default=None, description="续费周期:monthly/quarterly/yearly"
)
renew_url: Optional[str] = Field(
default=None, description="续费网址(去哪续费)"
)
cancel_url: Optional[str] = Field(
default=None, description="取消订阅网址(去哪取消)"
)
account: Optional[str] = Field(
default=None, description="所属账号标识(如 Cloudflare 账号邮箱)"
)
+4
View File
@@ -72,6 +72,8 @@ class AssetBase(SQLModel):
provider: str
provider_id: Optional[int] = None
renewal_cycle: Optional[str] = None
renew_url: Optional[str] = None
cancel_url: Optional[str] = None
account: Optional[str] = None
expiry_date: Optional[date] = None
auto_renew: bool = False
@@ -98,6 +100,8 @@ class AssetUpdate(SQLModel):
provider: Optional[str] = None
provider_id: Optional[int] = None
renewal_cycle: Optional[str] = None
renew_url: Optional[str] = None
cancel_url: Optional[str] = None
account: Optional[str] = None
expiry_date: Optional[date] = None
auto_renew: Optional[bool] = None
+75 -3
View File
@@ -29,7 +29,7 @@ function applyDark() {
/* ---------------- 表单工厂 ---------------- */
function emptyAssetForm() {
return {
name: '', asset_type: 'vps', provider: '', provider_id: null, renewal_cycle: 'monthly',
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: '' },
@@ -42,6 +42,7 @@ 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,
@@ -96,7 +97,7 @@ function openAICreate(provider) {
}
function openAssetEdit(a) {
const form = emptyAssetForm();
['name', 'asset_type', 'provider', 'provider_id', 'renewal_cycle', 'account', 'expiry_date', 'auto_renew', 'cost', 'currency', 'status', 'is_archived', 'remark'].forEach(k => { form[k] = a[k]; });
['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);
@@ -310,6 +311,71 @@ const AssetsView = {
},
};
function normalizedMonthly(cost, cycle) {
if (!cost) return 0;
if (cycle === 'yearly') return cost / 12;
if (cycle === 'quarterly') return cost / 3;
return cost;
}
const SubscriptionsView = {
template: `
<div class="space-y-4">
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="text-xs text-slate-500">订阅数量</div>
<div class="text-2xl font-bold mt-1">{{ subs.length }}</div>
</div>
<div v-for="(v, cur) in monthlyByCurrency" :key="cur" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="text-xs text-slate-500">月度支出({{ cur }}</div>
<div class="text-2xl font-bold mt-1">{{ v.toFixed(2) }}</div>
<div class="text-xs text-slate-400 mt-1">年度约 {{ (v*12).toFixed(2) }}</div>
</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-slate-50 dark:bg-slate-800/50 text-slate-500 text-left text-xs">
<tr>
<th class="px-4 py-2.5 font-medium">订阅</th>
<th class="px-4 py-2.5 font-medium">平台</th>
<th class="px-4 py-2.5 font-medium">费用</th>
<th class="px-4 py-2.5 font-medium">到期</th>
<th class="px-4 py-2.5 font-medium text-right">管理</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
<tr v-if="!subs.length"><td colspan="5" class="px-4 py-10 text-center text-slate-400">暂无付费订阅(给资产设置费用和到期日即可出现)</td></tr>
<tr v-for="a in subs" :key="a.id" class="hover:bg-slate-50 dark:hover:bg-slate-800/30">
<td class="px-4 py-2.5 font-medium">{{ a.name }}</td>
<td class="px-4 py-2.5 text-slate-500">{{ a.provider_name || a.provider }}</td>
<td class="px-4 py-2.5 text-slate-600 dark:text-slate-300">{{ a.cost }} {{ a.currency }}<span v-if="a.renewal_cycle" class="text-xs text-slate-400 ml-1">/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }}</span></td>
<td class="px-4 py-2.5" :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date || '—' }}<span v-if="a.days_to_expiry!==null&&a.days_to_expiry!==undefined" class="text-xs ml-1">({{ a.days_to_expiry }}天)</span></td>
<td class="px-4 py-2.5 text-right whitespace-nowrap">
<a v-if="a.renew_url" :href="a.renew_url" target="_blank" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs">续费</a>
<a v-if="a.cancel_url" :href="a.cancel_url" target="_blank" class="text-red-600 dark:text-red-400 hover:underline text-xs">取消</a>
<span v-if="!a.renew_url && !a.cancel_url" class="text-xs text-slate-400">—</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>`,
setup() {
const subs = computed(() => store.assets
.filter(a => (a.cost > 0 || a.expiry_date) && a.status !== 'cancelled')
.sort((x, y) => (x.days_to_expiry ?? 9999) - (y.days_to_expiry ?? 9999)));
const monthlyByCurrency = computed(() => {
const m = {};
for (const a of subs.value) {
const cur = a.currency || 'USD';
m[cur] = (m[cur] || 0) + normalizedMonthly(a.cost, a.renewal_cycle);
}
return m;
});
return { store, Fmt, subs, monthlyByCurrency };
},
};
const ProvidersView = {
template: `
<div class="space-y-3">
@@ -630,6 +696,10 @@ const AssetModal = {
<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>
@@ -817,11 +887,12 @@ const AppRoot = {
{ key: 'assets', label: '资产', icon: '📦' },
{ key: 'servers', label: '服务器', icon: '🖥️' },
{ key: 'domains', label: '域名', icon: '🌐' },
{ key: 'subscriptions', label: '订阅', icon: '💳' },
{ key: 'ai', label: 'AI账号', icon: '🤖' },
{ key: 'providers', label: '平台', icon: '🏢' },
{ key: 'settings', label: '设置', icon: '⚙️' },
];
const viewMap = { dashboard: 'dashboard-view', assets: 'assets-view', servers: 'servers-view', domains: 'domains-view', ai: 'ai-view', providers: 'providers-view', settings: 'settings-view' };
const viewMap = { dashboard: 'dashboard-view', assets: 'assets-view', servers: 'servers-view', domains: 'domains-view', subscriptions: 'subscriptions-view', ai: 'ai-view', providers: 'providers-view', settings: 'settings-view' };
const currentNav = computed(() => navs.find(n => n.key === store.view) || navs[0]);
const viewComponent = computed(() => viewMap[store.view] || 'dashboard-view');
function go(key) { store.view = key; if (key !== 'servers') store.serverMetrics = {}; if (key === 'ai') { store.filterType = ''; store.filterStatus = ''; store.search = ''; loadAssets(); } }
@@ -843,6 +914,7 @@ app.component('assets-view', AssetsView);
app.component('providers-view', ProvidersView);
app.component('servers-view', ServersView);
app.component('domains-view', DomainsView);
app.component('subscriptions-view', SubscriptionsView);
app.component('ai-view', AIAccountsView);
app.component('settings-view', SettingsView);
applyDark();