feat: 版本号管理机制(VERSION文件+git哈希,部署/启动/UI/health均展示) + 平台账号管理(accounts表CRUD,资产所属账号候选联动,重命名同步引用)

This commit is contained in:
gouki
2026-08-08 20:07:03 +00:00
parent 4883837dda
commit a33a2b71de
15 changed files with 373 additions and 16 deletions
+1
View File
@@ -0,0 +1 @@
0.3.0
+3 -3
View File
@@ -1,6 +1,6 @@
"""SQLite 数据库连接与初始化(分库)
- assets.db Provider / Asset / VPSDetail / DomainDetail / AIAccount
- assets.db Provider / Asset / VPSDetail / DomainDetail / AIAccount / Account
- metrics.dbMetricPoint / ServerInfo / SecurityCheck / EventLog
"""
@@ -47,7 +47,7 @@ engine = assets_engine
def init_db() -> None:
"""分库建表(幂等,可重复调用)"""
from app.models.asset import AIAccount, Asset, CloudflareDetail, DomainDetail, VPSDetail # noqa: F401
from app.models.asset import Account, AIAccount, Asset, CloudflareDetail, DomainDetail, VPSDetail # noqa: F401
from app.models.monitor import ( # noqa: F401
EventLog,
MetricPoint,
@@ -57,7 +57,7 @@ def init_db() -> None:
from app.models.provider import Provider # noqa: F401
from app.models.ssl import SiteCert, Subdomain # noqa: F401
asset_models = [Provider, Asset, VPSDetail, DomainDetail, AIAccount, CloudflareDetail, Subdomain, SiteCert]
asset_models = [Provider, Asset, VPSDetail, DomainDetail, AIAccount, CloudflareDetail, Subdomain, SiteCert, Account]
metric_models = [MetricPoint, ServerInfo, SecurityCheck, EventLog]
SQLModel.metadata.create_all(
+40 -5
View File
@@ -10,6 +10,7 @@ from contextlib import asynccontextmanager
from pathlib import Path
import asyncio
import logging
import subprocess
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -21,7 +22,7 @@ from sqlmodel import Session
from app.core.config import settings
from app.core.seed import seed_providers
from app.database import assets_engine, init_db
from app.routers import agent, assets, monitor, notify, providers, ssl, stats, sync
from app.routers import accounts, agent, assets, monitor, notify, providers, ssl, stats, sync
from app.services import cleanup_service
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -50,6 +51,30 @@ def _asset_version() -> str:
_ASSET_VERSION_CACHE: str | None = None
def _read_app_version() -> str:
"""语义版本号:读取项目根目录 VERSION 文件(缺失时回退 dev"""
try:
return (BASE_DIR / "VERSION").read_text(encoding="utf-8").strip() or "dev"
except OSError:
return "dev"
def _read_git_commit() -> str:
"""当前 git 短哈希:用于比对本地与线上代码是否一致(非 git 环境回退 unknown"""
try:
out = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
cwd=BASE_DIR, capture_output=True, text=True, timeout=5,
)
return out.stdout.strip() or "unknown"
except Exception:
return "unknown"
APP_VERSION = _read_app_version()
GIT_COMMIT = _read_git_commit()
# 全局日志配置:统一格式,便于生产环境排查
logging.basicConfig(
level=logging.INFO,
@@ -71,7 +96,10 @@ async def lifespan(_: FastAPI):
cleanup_task.cancel()
app = FastAPI(title=settings.APP_NAME, version="0.2.0", lifespan=lifespan)
app = FastAPI(title=settings.APP_NAME, version=f"{APP_VERSION} ({GIT_COMMIT})", lifespan=lifespan)
# 启动即打印版本号,部署后可通过 journalctl 快速确认线上运行版本
logging.getLogger(__name__).info("vps-manager 启动:版本 %scommit %s", APP_VERSION, GIT_COMMIT)
app.add_middleware(
CORSMiddleware,
@@ -83,6 +111,7 @@ app.add_middleware(
app.include_router(assets.router)
app.include_router(providers.router)
app.include_router(accounts.router)
app.include_router(stats.router)
app.include_router(agent.router)
app.include_router(monitor.router)
@@ -105,7 +134,8 @@ app.mount("/static", NoCacheStaticFiles(directory=STATIC_DIR), name="static")
def index() -> HTMLResponse:
"""前端 SPA 入口"""
html = jinja_env.get_template("index.html").render(
app_name=settings.APP_NAME, asset_version=_asset_version()
app_name=settings.APP_NAME, asset_version=_asset_version(),
app_version=APP_VERSION, git_commit=GIT_COMMIT,
)
return HTMLResponse(html)
@@ -129,5 +159,10 @@ def service_worker() -> FileResponse:
@app.get("/health")
def health_check() -> dict:
"""健康检查(version 动态读取,便于验证自动更新)"""
return {"status": "ok", "app": "vps-manager", "version": app.version}
"""健康检查(version/commit 动态读取,便于验证自动更新与比对本地版本"""
return {
"status": "ok",
"app": "vps-manager",
"version": APP_VERSION,
"commit": GIT_COMMIT,
}
+19 -1
View File
@@ -1,10 +1,11 @@
"""资产数据库模型
包含种核心资产模型:
包含种核心模型:
- Asset: 资产主表(统一登记 VPS/域名/AI 账号/Cloudflare 等一切资产)
- VPSDetail: VPS 资产详情(与 Asset 一对一关联)
- DomainDetail: 域名资产详情(与 Asset 一对一关联)
- AIAccount: AI Agent 账号详情(与 Asset 一对一关联)
- Account: 平台账号字典(Asset.account 按名称引用)
"""
from datetime import date, datetime
@@ -195,3 +196,20 @@ class CloudflareDetail(SQLModel, table=True):
default=None, index=True, description="关联的 zone/域名"
)
status: Optional[str] = Field(default=None, description="子资产状态")
class Account(SQLModel, table=True):
"""平台账号字典:统一管理各平台下的账号(多账号场景区分归属)
资产的 Asset.account 字段按名称引用本表,重命名时由服务层同步更新引用。
"""
__tablename__ = "accounts"
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(unique=True, index=True, description="账号标识(如邮箱/用户名)")
platform: Optional[str] = Field(
default=None, index=True, description="所属平台(slug 或名称)"
)
remark: Optional[str] = Field(default=None, description="备注")
created_at: datetime = Field(default_factory=utcnow, description="创建时间")
+54
View File
@@ -0,0 +1,54 @@
"""平台账号 CRUD 路由
统一入口 /api/accounts,写操作(POST/PUT/DELETE)受 API Key 保护。
资产的 Asset.account 按名称引用账号;重命名账号时服务层会同步更新引用资产。
"""
from typing import List
from fastapi import APIRouter, Depends
from sqlmodel import Session
from app.core.security import require_api_key
from app.database import get_session
from app.schemas.account import AccountCreate, AccountRead, AccountUpdate
from app.services import account_service
router = APIRouter(prefix="/api/accounts", tags=["accounts"])
@router.get("", response_model=List[AccountRead], summary="账号列表(含关联资产数)")
def list_accounts(session: Session = Depends(get_session)):
return account_service.list_accounts(session)
@router.post(
"",
response_model=AccountRead,
status_code=201,
summary="创建账号",
dependencies=[Depends(require_api_key)],
)
def create_account(data: AccountCreate, session: Session = Depends(get_session)):
return account_service.create_account(session, data)
@router.put(
"/{account_id}",
response_model=AccountRead,
summary="更新账号(重命名会同步更新引用该账号的资产)",
dependencies=[Depends(require_api_key)],
)
def update_account(
account_id: int, data: AccountUpdate, session: Session = Depends(get_session)
):
return account_service.update_account(session, account_id, data)
@router.delete(
"/{account_id}",
summary="删除账号(资产中已填的账号名不受影响)",
dependencies=[Depends(require_api_key)],
)
def delete_account(account_id: int, session: Session = Depends(get_session)) -> dict:
return account_service.delete_account(session, account_id)
+28
View File
@@ -0,0 +1,28 @@
"""平台账号 Schema"""
from datetime import datetime
from typing import Optional
from sqlmodel import SQLModel
class AccountBase(SQLModel):
name: str
platform: Optional[str] = None
remark: Optional[str] = None
class AccountCreate(AccountBase):
pass
class AccountUpdate(SQLModel):
name: Optional[str] = None
platform: Optional[str] = None
remark: Optional[str] = None
class AccountRead(AccountBase):
id: int
created_at: datetime
asset_count: int = 0 # 引用该账号的资产数(列表接口填充)
+98
View File
@@ -0,0 +1,98 @@
"""平台账号业务逻辑
账号与资产的关系:Asset.account 按名称引用账号(字符串,兼容历史自由文本)。
重命名账号时同步更新所有引用资产,保证两边一致。
"""
from typing import Dict, List
from fastapi import HTTPException, status
from sqlalchemy import func
from sqlmodel import Session, select
from app.models.asset import Account, Asset
from app.schemas.account import AccountCreate, AccountRead, AccountUpdate
def _asset_counts(session: Session) -> Dict[str, int]:
"""按 account 名称统计引用资产数"""
rows = session.exec(
select(Asset.account, func.count(Asset.id))
.where(Asset.account.is_not(None)) # type: ignore[union-attr]
.group_by(Asset.account)
).all()
return {name: cnt for name, cnt in rows}
def _to_read(account: Account, counts: Dict[str, int]) -> AccountRead:
read = AccountRead.model_validate(account)
read.asset_count = counts.get(account.name, 0)
return read
def list_accounts(session: Session) -> List[AccountRead]:
counts = _asset_counts(session)
accounts = session.exec(select(Account).order_by(Account.name.asc())).all()
return [_to_read(a, counts) for a in accounts]
def _get_account(session: Session, account_id: int) -> Account:
account = session.get(Account, account_id)
if not account:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="账号不存在")
return account
def _check_name_taken(session: Session, name: str, exclude_id: int | None = None) -> None:
stmt = select(Account).where(Account.name == name)
if exclude_id is not None:
stmt = stmt.where(Account.id != exclude_id)
if session.exec(stmt).first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=f"账号已存在:{name}"
)
def create_account(session: Session, data: AccountCreate) -> AccountRead:
name = data.name.strip()
if not name:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="账号名称不能为空")
_check_name_taken(session, name)
account = Account(name=name, platform=data.platform, remark=data.remark)
session.add(account)
session.commit()
session.refresh(account)
return _to_read(account, _asset_counts(session))
def update_account(session: Session, account_id: int, data: AccountUpdate) -> AccountRead:
account = _get_account(session, account_id)
if data.name is not None:
new_name = data.name.strip()
if not new_name:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="账号名称不能为空")
if new_name != account.name:
_check_name_taken(session, new_name, exclude_id=account.id)
# 同步更新引用该账号的资产,避免重命名后资产端失联
assets = session.exec(select(Asset).where(Asset.account == account.name)).all()
for a in assets:
a.account = new_name
session.add(a)
account.name = new_name
if data.platform is not None:
account.platform = data.platform or None
if data.remark is not None:
account.remark = data.remark or None
session.add(account)
session.commit()
session.refresh(account)
return _to_read(account, _asset_counts(session))
def delete_account(session: Session, account_id: int) -> Dict[str, int]:
account = _get_account(session, account_id)
affected = len(session.exec(select(Asset.id).where(Asset.account == account.name)).all())
session.delete(account)
session.commit()
# 资产端保留原账号名文本(不级联清空),由用户自行处理
return {"affected_assets": affected}
+1 -1
View File
@@ -14,7 +14,7 @@
<script>tailwind.config = { darkMode: 'class' };</script>
<script src="/static/js/vue.global.prod.js?v={{ asset_version }}"></script>
<script src="/static/js/chart.umd.js?v={{ asset_version }}"></script>
<script>window.APP_CONFIG = { appName: "{{ app_name }}" };</script>
<script>window.APP_CONFIG = { appName: "{{ app_name }}", version: "{{ app_version }}", commit: "{{ git_commit }}" };</script>
<style>
html { -webkit-tap-highlight-color: transparent; }
::-webkit-scrollbar { width: 8px; height: 8px; }
+1
View File
@@ -91,6 +91,7 @@ fi
echo ""
echo "=========================================="
echo "部署完成!"
echo " 当前版本: v$(cat VERSION 2>/dev/null | tr -d '[:space:]' || echo dev) ($(git rev-parse --short HEAD 2>/dev/null || echo unknown))"
echo " 本地访问:http://127.0.0.1:8000"
echo " Tailscale 内网:http://<本机tailscale_ip>:8000"
echo " 如需 PWA/HTTPStailscale serve --bg --https=443 http://127.0.0.1:8000"
+11
View File
@@ -19,6 +19,15 @@ BRANCH="${GIT_BRANCH:-main}"
cd "$APP_DIR"
# 输出当前运行版本号(语义版本 + git 短哈希),便于比对本地与线上是否一致
print_version() {
local semver commit
semver=$(cat VERSION 2>/dev/null | tr -d '[:space:]')
[ -z "$semver" ] && semver="dev"
commit=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
echo "[$(date '+%F %T')] 当前版本: v${semver} (${commit})"
}
git fetch -q origin "$BRANCH"
LOCAL=$(git rev-parse HEAD)
@@ -26,6 +35,7 @@ REMOTE=$(git rev-parse "origin/$BRANCH")
if [ "$LOCAL" = "$REMOTE" ]; then
echo "[$(date '+%F %T')] 代码已是最新,无需更新"
print_version
exit 0
fi
@@ -40,3 +50,4 @@ fi
# 重启服务加载新代码
systemctl restart "$SERVICE_NAME"
echo "[$(date '+%F %T')] 已重启 $SERVICE_NAME 服务"
print_version
+6 -1
View File
@@ -53,6 +53,7 @@ const AppRoot = {
</nav>
<div class="mt-auto pt-3 border-t border-slate-100 dark:border-slate-800">
<button @click="toggleDark" class="w-full text-left px-3 py-2 rounded-lg text-sm text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800">{{ store.dark ? '☀️ 浅色' : '🌙 深色' }}</button>
<div class="px-3 pt-1 text-[11px] text-slate-400 dark:text-slate-500" :title="'commit ' + appCommit">v{{ appVersion }} · {{ appCommit }}</div>
</div>
</aside>
@@ -82,9 +83,12 @@ const AppRoot = {
<asset-modal></asset-modal>
<provider-modal></provider-modal>
<account-modal></account-modal>
</div>`,
setup() {
const appName = Api.CFG.appName;
const appVersion = Api.CFG.version || 'dev';
const appCommit = Api.CFG.commit || 'unknown';
const navs = NAVS;
const currentNav = Vue.computed(() => navs.find(n => n.key === store.view) || navs[0]);
const viewComponent = Vue.computed(() => VIEW_MAP[store.view] || 'dashboard-view');
@@ -107,7 +111,7 @@ const AppRoot = {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => { loadAssets(); }, 300);
}
return { store, appName, navs, currentNav, viewComponent, go, quickAdd, toggleDark, reload: debouncedReload };
return { store, appName, appVersion, appCommit, navs, currentNav, viewComponent, go, quickAdd, toggleDark, reload: debouncedReload };
},
};
@@ -121,6 +125,7 @@ app.component('servers-view', ServersView);
app.component('settings-view', SettingsView);
app.component('asset-modal', AssetModal);
app.component('provider-modal', ProviderModal);
app.component('account-modal', AccountModal);
applyDark();
initRouter();
loadAll();
+43 -2
View File
@@ -41,7 +41,10 @@ const AssetModal = {
<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>
<input v-model="f.account" list="account-options" placeholder="在「平台」页管理账号" 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">
<datalist id="account-options">
<option v-for="a in store.accounts" :key="a.id" :value="a.name">{{ a.platform ? a.name + '' + platformName(a.platform) + '' : a.name }}</option>
</datalist></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>
@@ -128,7 +131,12 @@ const AssetModal = {
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 };
// 账号候选显示平台名:platform 存的是 slug,回退显示原值
function platformName(slug) {
const p = store.providers.find(x => x.slug === slug);
return p ? p.name : slug;
}
return { store, Fmt, f, providersForType, onProviderChange, platformName, save: saveAsset };
},
};
@@ -205,3 +213,36 @@ const ProviderModal = {
return { store, Fmt, f, sdkOptions, currentFields, servicesSet, toggleService, apiFields, save };
},
};
/* 账号编辑模态框 */
const AccountModal = {
template: `
<div v-if="store.accountModal.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.accountModal.editing ? '编辑账号' : '新增账号' }}</h3>
<button @click="store.accountModal.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">
<label class="block"><span class="text-xs text-slate-500">账号标识 *(邮箱/用户名)</span>
<input v-model="f.name" required placeholder="如 you@example.com" 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.platform" 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="p in store.providers" :key="p.id" :value="p.slug">{{ p.name }}</option>
</select></label>
<label class="block"><span class="text-xs text-slate-500">备注</span>
<textarea v-model="f.remark" rows="2" placeholder="如:主账号/公司账号" 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>
<p v-if="store.accountModal.editing" class="text-xs text-slate-400">修改账号标识后,引用该账号的资产会自动同步更新。</p>
<div class="flex justify-end gap-2">
<button type="button" @click="store.accountModal.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.accountModal.form);
return { store, f, save: saveAccount };
},
};
+32 -1
View File
@@ -8,6 +8,7 @@ const store = reactive({
view: 'dashboard',
assets: [],
providers: [],
accounts: [],
subdomains: [],
siteCerts: [],
overview: {},
@@ -23,6 +24,7 @@ const store = reactive({
error: '',
assetModal: { show: false, editing: null, form: null },
providerModal: { show: false, editing: null, form: null },
accountModal: { show: false, editing: null, form: null },
});
function applyDark() {
@@ -78,13 +80,14 @@ async function loadAssets() {
store.assets = await Api.get('/assets?' + params.toString());
}
async function loadProviders() { store.providers = await Api.get('/providers'); }
async function loadAccounts() { store.accounts = await Api.get('/accounts'); }
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()]); }
try { await Promise.all([loadAssets(), loadProviders(), loadAccounts(), loadSubdomains(), loadSiteCerts(), loadOverview(), loadExpiring()]); }
catch (e) { store.error = e.message; } finally { store.loading = false; }
}
@@ -162,6 +165,34 @@ async function deleteProvider(p) {
catch (e) { store.error = e.message; }
}
/* ---------------- 账号操作 ---------------- */
function openAccountCreate(platform) {
store.accountModal = { show: true, editing: null, form: { name: '', platform: platform || '', remark: '' } };
}
function openAccountEdit(a) {
store.accountModal = { show: true, editing: a.id, form: { name: a.name, platform: a.platform || '', remark: a.remark || '' } };
}
async function saveAccount() {
store.error = '';
const f = store.accountModal.form;
const payload = { name: f.name, platform: f.platform || null, remark: f.remark || null };
try {
if (store.accountModal.editing) await Api.put('/accounts/' + store.accountModal.editing, payload);
else await Api.post('/accounts', payload);
store.accountModal.show = false;
// 重命名会同步更新资产端引用,需一并刷新资产
await Promise.all([loadAccounts(), loadAssets()]);
} catch (e) { store.error = e.message; }
}
async function deleteAccount(a) {
let msg = '确认删除账号「' + a.name + '」?';
if (a.asset_count) msg += '\n有 ' + a.asset_count + ' 个资产引用该账号(资产中已填的账号名不受影响)。';
if (!confirm(msg)) return;
store.error = '';
try { await Api.del('/accounts/' + a.id); await loadAccounts(); }
catch (e) { store.error = e.message; }
}
/* ---------------- 服务器监控 ---------------- */
async function viewServer(a) {
try {
+28 -1
View File
@@ -9,6 +9,28 @@ const ProvidersView = {
<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>
<button @click="addAccount" class="text-sm px-3 py-1.5 rounded-lg border border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20">+ 账号</button>
</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">👤 我的账号({{ store.accounts.length }}</h3>
<button @click="addAccount" class="text-xs text-blue-600 dark:text-blue-400">+ 新增账号</button>
</div>
<p v-if="!store.accounts.length" class="text-xs text-slate-400">暂无账号。添加后,可在资产表单「所属账号」中快速选择,区分同一平台下的多个账号。</p>
<div v-else class="divide-y divide-slate-100 dark:divide-slate-800">
<div v-for="a in store.accounts" :key="a.id" class="py-2 flex items-center gap-2">
<div class="flex-1 min-w-0">
<div class="text-sm truncate">{{ a.name }}
<span v-if="a.platform" class="text-xs text-slate-400">@ {{ platformName(a.platform) }}</span>
</div>
<div class="text-xs text-slate-400">关联 {{ a.asset_count }} 个资产<span v-if="a.remark"> · {{ a.remark }}</span></div>
</div>
<button @click="editAccount(a)" class="text-xs text-blue-600 dark:text-blue-400">编辑</button>
<button @click="delAccount(a)" class="text-xs text-red-600 dark:text-red-400">删除</button>
</div>
</div>
</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">
@@ -64,6 +86,11 @@ const ProvidersView = {
function serviceList(p) {
return (p.services || '').split(',').map(s => s.trim()).filter(Boolean);
}
// 账号所属平台显示名:platform 存的是 slug,回退显示原值
function platformName(slug) {
const p = store.providers.find(x => x.slug === slug);
return p ? p.name : slug;
}
Vue.onMounted(async () => {
try { const r = await Api.get('/providers/adapters'); supported.value = r.supported_types || []; capabilities.value = r.capabilities || {}; } catch (e) { /* ignore */ }
});
@@ -96,6 +123,6 @@ const ProvidersView = {
} 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 };
return { store, Fmt, testing, syncing, results, countOf, serviceList, platformName, isSupported, canSync, fmtTime, test, sync, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider, addAccount: () => openAccountCreate(''), editAccount: openAccountEdit, delAccount: deleteAccount };
},
};
+8 -1
View File
@@ -37,11 +37,18 @@ const SettingsView = {
</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 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">当前运行版本:<span class="text-slate-700 dark:text-slate-300 font-mono">v{{ version }} ({{ commit }})</span></div>
<p class="text-xs text-slate-400 mt-1">与本地 git rev-parse --short HEAD 比对,可确认线上是否为最新代码。</p>
</div>
</div>`,
setup() {
const notifyChannels = Vue.ref([]);
const notifyResult = Vue.ref('');
const dataResult = Vue.ref('');
const version = Api.CFG.version || 'dev';
const commit = Api.CFG.commit || 'unknown';
Vue.onMounted(async () => {
try { const r = await Api.get('/notify/channels'); notifyChannels.value = r.channels || []; } catch (e) { /* ignore */ }
});
@@ -91,6 +98,6 @@ const SettingsView = {
};
reader.readAsText(file);
}
return { store, settings, save: saveSettings, applyDark, notifyChannels, notifyResult, testNotify, checkRenewals, dataResult, exportData, onImportFile };
return { store, settings, save: saveSettings, applyDark, notifyChannels, notifyResult, testNotify, checkRenewals, dataResult, exportData, onImportFile, version, commit };
},
};