diff --git a/app/database.py b/app/database.py index 3591535..2cc765c 100644 --- a/app/database.py +++ b/app/database.py @@ -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: diff --git a/app/models/asset.py b/app/models/asset.py index a76b3e8..6103f9d 100644 --- a/app/models/asset.py +++ b/app/models/asset.py @@ -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 账号邮箱)" ) diff --git a/app/schemas/asset.py b/app/schemas/asset.py index d80aa11..0cc0234 100644 --- a/app/schemas/asset.py +++ b/app/schemas/asset.py @@ -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 diff --git a/static/js/app.js b/static/js/app.js index a42a05c..4b1e2c4 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -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: ` +