feat: 版本号管理机制(VERSION文件+git哈希,部署/启动/UI/health均展示) + 平台账号管理(accounts表CRUD,资产所属账号候选联动,重命名同步引用)
This commit is contained in:
+6
-1
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user