refactor: 凭证下沉账号层——平台不再持有API凭证;账号支持登录凭证+API配置,SDK测试/同步改为账号维度,同步产出自动挂账号;含存量数据自动迁移

This commit is contained in:
gouki
2026-08-09 16:50:45 +00:00
parent cf18cadb6b
commit dc2134cf5a
9 changed files with 355 additions and 103 deletions
+78 -32
View File
@@ -160,14 +160,10 @@ const ProviderModal = {
<label v-for="(l,k) in Fmt.SERVICES_LABELS" :key="k" class="flex items-center gap-1 text-xs text-slate-600 dark:text-slate-300"><input type="checkbox" :checked="servicesSet.has(k)" @change="toggleService(k)"> {{ l }}</label>
</div>
</div>
<label class="block"><span class="text-xs text-slate-500">SDK类型</span><select v-model="f.sdk_type" 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="o in sdkOptions" :key="o.type" :value="o.type">{{ o.type }}</option></select></label>
<label class="block"><span class="text-xs text-slate-500">SDK类型</span><select v-model="f.sdk_type" 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="o in sdkOptions" :key="o" :value="o">{{ o }}</option></select></label>
<label class="col-span-2 block"><span class="text-xs text-slate-500">官网</span><input v-model="f.website" 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">管理面板URL</span><input v-model="f.console_url" 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>
<div v-if="currentFields.length" class="col-span-2 border border-slate-200 dark:border-slate-700 rounded-lg p-3 space-y-2">
<div class="text-xs font-medium text-slate-600 dark:text-slate-300">API 凭证(加密存储)</div>
<label v-for="field in currentFields" :key="field" class="block"><span class="text-xs text-slate-400">{{ field }}</span><input v-model="apiFields[field]" type="password" :placeholder="store.providerModal.editing ? '留空不修改' : '填写 ' + field" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
</div>
<div v-else-if="f.sdk_type" class="col-span-2 text-xs text-amber-600 dark:text-amber-400">该 SDK 类型暂无预设凭证字段(可后续扩展)</div>
<div v-if="f.sdk_type" class="col-span-2 text-xs text-slate-400 bg-slate-50 dark:bg-slate-800/50 rounded-lg p-2">API 凭证请在平台账号中配置:保存后在平台卡片点「账号」→ 新增账号并填写 API 配置,即可测试/同步。</div>
<label class="col-span-2 flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300"><input type="checkbox" v-model="f.enabled"> 启用</label>
</div>
<div class="flex justify-end gap-2">
@@ -179,19 +175,14 @@ const ProviderModal = {
</div>`,
setup() {
const f = Vue.computed(() => store.providerModal.form);
const adapterMeta = Vue.ref([]);
const apiFields = Vue.reactive({});
const sdkOptions = Vue.ref([]);
Vue.onMounted(async () => {
try { const r = await Api.get('/providers/adapters'); adapterMeta.value = r.adapters || []; } catch (e) { /* ignore */ }
});
const sdkOptions = Vue.computed(() => {
const opts = [];
for (const m of adapterMeta.value) for (const t of (m.sdk_types || [])) opts.push({ type: t, fields: m.required_config || [] });
return opts;
});
const currentFields = Vue.computed(() => {
const opt = sdkOptions.value.find(o => o.type === (f.value && f.value.sdk_type));
return opt ? opt.fields : [];
try {
const r = await Api.get('/providers/adapters');
const opts = [];
for (const m of (r.adapters || [])) for (const t of (m.sdk_types || [])) opts.push(t);
sdkOptions.value = opts;
} catch (e) { /* ignore */ }
});
// 提供服务多选:services 逗号分隔字符串 <-> checkbox 集合
const servicesSet = Vue.computed(() => new Set((f.value && f.value.services || '').split(',').map(s => s.trim()).filter(Boolean)));
@@ -201,17 +192,8 @@ const ProviderModal = {
if (idx >= 0) arr.splice(idx, 1); else arr.push(k);
f.value.services = arr.join(',');
}
// 模态框每次打开时清空上次残留的凭证输入,避免串号
Vue.watch(() => store.providerModal.show, (show) => {
if (show) { for (const k of Object.keys(apiFields)) delete apiFields[k]; }
});
function save() {
const cfg = {};
for (const field of currentFields.value) if (apiFields[field]) cfg[field] = apiFields[field];
if (Object.keys(cfg).length) f.value.api_config = JSON.stringify(cfg);
saveProvider();
}
return { store, Fmt, f, sdkOptions, currentFields, servicesSet, toggleService, apiFields, save };
// 凭证已下沉到账号层,平台表单不再收集 API 凭证
return { store, Fmt, f, sdkOptions, servicesSet, toggleService, save: saveProvider };
},
};
@@ -234,6 +216,17 @@ const AccountModal = {
<option value="">未指定</option>
<option v-for="p in store.providers" :key="p.id" :value="p.slug">{{ p.name }}</option>
</select></label>
<div class="grid grid-cols-2 gap-3">
<label class="block"><span class="text-xs text-slate-500">登录用户名/邮箱</span>
<input v-model="f.login_user" 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"></label>
<label class="block"><span class="text-xs text-slate-500">登录密码(加密存储)</span>
<input v-model="f.login_password" type="password" :placeholder="store.accountModal.editing ? '留空不修改' : '可选'" 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>
</div>
<div v-if="currentFields.length" class="border border-slate-200 dark:border-slate-700 rounded-lg p-3 space-y-2">
<div class="text-xs font-medium text-slate-600 dark:text-slate-300">API 配置(供 SDK 同步,加密存储)</div>
<label v-for="field in currentFields" :key="field" class="block"><span class="text-xs text-slate-400">{{ field }}</span>
<input v-model="apiFields[field]" type="password" :placeholder="store.accountModal.editing ? '留空不修改' : '填写 ' + field" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
</div>
<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>
@@ -244,7 +237,29 @@ const AccountModal = {
</div>`,
setup() {
const f = Vue.computed(() => store.accountModal.form);
return { store, f, save: saveAccount };
const adapterMeta = Vue.ref([]);
const apiFields = Vue.reactive({});
Vue.onMounted(async () => {
try { const r = await Api.get('/providers/adapters'); adapterMeta.value = r.adapters || []; } catch (e) { /* ignore */ }
});
// 按账号所属平台的 sdk_type 动态渲染所需凭证字段
const currentFields = Vue.computed(() => {
const p = store.providers.find(x => x.slug === (f.value && f.value.platform));
if (!p || !p.sdk_type) return [];
const m = adapterMeta.value.find(x => (x.sdk_types || []).includes(p.sdk_type));
return m ? (m.required_config || []) : [];
});
// 每次打开清空残留输入,避免串号
Vue.watch(() => store.accountModal.show, (show) => {
if (show) { for (const k of Object.keys(apiFields)) delete apiFields[k]; }
});
function save() {
const cfg = {};
for (const field of currentFields.value) if (apiFields[field]) cfg[field] = apiFields[field];
f.value.api_config = Object.keys(cfg).length ? JSON.stringify(cfg) : '';
saveAccount();
}
return { store, f, currentFields, apiFields, save };
},
};
@@ -270,9 +285,14 @@ const AccountsViewModal = {
<div class="px-3 py-2 flex items-center gap-2">
<button @click="toggle(a.id)" class="text-slate-400 text-xs w-4">{{ expanded[a.id] ? '▾' : '▸' }}</button>
<div class="flex-1 min-w-0">
<div class="text-sm truncate">{{ a.remark || a.name }}</div>
<div class="text-sm truncate">{{ a.remark || a.name }}
<span v-if="a.has_login_password" class="text-[10px] px-1 py-0.5 ml-1 rounded bg-slate-100 dark:bg-slate-800 text-slate-400" title="已配置登录凭证">🔑登录</span>
<span v-if="a.has_api_config" class="text-[10px] px-1 py-0.5 ml-1 rounded bg-blue-500/10 text-blue-500" title="已配置 API 凭证">⚙️API</span>
</div>
<div class="text-xs text-slate-400 truncate"><span v-if="a.remark">{{ a.name }} · </span>关联 {{ assetsOf(a).length }} 个资产</div>
</div>
<button v-if="canSync(a)" @click="testAcct(a)" class="text-xs text-emerald-600 dark:text-emerald-400">测试</button>
<button v-if="canSync(a)" @click="syncAcct(a)" class="text-xs text-violet-600 dark:text-violet-400">同步</button>
<button @click="edit(a)" class="text-xs text-blue-600 dark:text-blue-400">编辑</button>
<button @click="del(a)" class="text-xs text-red-600 dark:text-red-400">删除</button>
</div>
@@ -322,13 +342,39 @@ const AccountsViewModal = {
return store.assets.filter(x => x.account === a.name);
}
function toggle(id) { expanded[id] = !expanded[id]; }
// 账号所属平台配了 sdk_type 且账号有 API 配置时,提供测试/同步
function providerOf(a) {
return store.providers.find(p => p.slug === a.platform) || store.providers.find(p => p.name === a.platform);
}
function canSync(a) {
const p = providerOf(a);
return !!(a.has_api_config && p && p.sdk_type);
}
async function testAcct(a) {
try {
const r = await Api.post('/accounts/' + a.id + '/test', {});
alert(r.ok ? '✅ ' + r.message : '❌ ' + r.message);
} catch (e) { alert('测试失败:' + e.message); }
}
async function syncAcct(a) {
try {
const r = await Api.post('/accounts/' + a.id + '/sync', {});
const lines = ['同步完成'];
if (r.vps) lines.push('VPS:新增 ' + r.vps.created + ' / 更新 ' + r.vps.updated);
if (r.domains) lines.push('域名:新增 ' + r.domains.created + ' / 更新 ' + r.domains.updated);
if (r.vps_error) lines.push('VPS 错误:' + r.vps_error);
if (r.domains_error) lines.push('域名错误:' + r.domains_error);
alert(lines.join('\n'));
await Promise.all([loadAccounts(), loadAssets()]);
} catch (e) { alert('同步失败:' + e.message); }
}
// 每次打开重置展开状态
Vue.watch(() => store.accountsModal.show, (show) => {
if (show) for (const k of Object.keys(expanded)) delete expanded[k];
});
return {
store, Fmt, expanded, singleProvider, title, accountList, groups,
platformName, assetsOf, toggle,
platformName, assetsOf, toggle, canSync, testAcct, syncAcct,
add: () => openAccountCreate(store.accountsModal.providerSlug || ''),
edit: openAccountEdit, del: deleteAccount,
};
+13 -6
View File
@@ -141,15 +141,16 @@ async function deleteAsset(a) {
/* ---------------- 平台操作 ---------------- */
function openProviderCreate() {
store.providerModal = { show: true, editing: null, form: { slug: '', name: '', name_en: '', category: 'vps', services: '', website: '', console_url: '', sdk_type: '', enabled: true, remark: '', api_config: '' } };
store.providerModal = { show: true, editing: null, form: { slug: '', name: '', name_en: '', category: 'vps', services: '', website: '', console_url: '', sdk_type: '', enabled: true, remark: '' } };
}
function openProviderEdit(p) {
store.providerModal = { show: true, editing: p.id, form: { slug: p.slug, name: p.name, name_en: p.name_en || '', category: p.category, services: p.services || '', website: p.website || '', console_url: p.console_url || '', sdk_type: p.sdk_type || '', enabled: p.enabled, remark: p.remark || '', api_config: '' } };
store.providerModal = { show: true, editing: p.id, form: { slug: p.slug, name: p.name, name_en: p.name_en || '', category: p.category, services: p.services || '', website: p.website || '', console_url: p.console_url || '', sdk_type: p.sdk_type || '', enabled: p.enabled, remark: p.remark || '' } };
}
async function saveProvider() {
store.error = '';
const f = store.providerModal.form;
const payload = { slug: f.slug, name: f.name, name_en: f.name_en || null, category: f.category, services: f.services || null, website: f.website || null, console_url: f.console_url || null, sdk_type: f.sdk_type || null, enabled: !!f.enabled, remark: f.remark || null, api_config: f.api_config || null };
// 凭证已下沉到账号层,平台不再携带 api_config
const payload = { slug: f.slug, name: f.name, name_en: f.name_en || null, category: f.category, services: f.services || null, website: f.website || null, console_url: f.console_url || null, sdk_type: f.sdk_type || null, enabled: !!f.enabled, remark: f.remark || null };
try {
if (store.providerModal.editing) await Api.put('/providers/' + store.providerModal.editing, payload);
else await Api.post('/providers', payload);
@@ -173,15 +174,21 @@ function openAccountsView(providerSlug) {
store.accountsModal = { show: true, providerSlug: providerSlug || null };
}
function openAccountCreate(platform) {
store.accountModal = { show: true, editing: null, form: { name: '', platform: platform || '', remark: '' } };
store.accountModal = { show: true, editing: null, form: { name: '', platform: platform || '', remark: '', login_user: '', login_password: '', api_config: '' } };
}
function openAccountEdit(a) {
store.accountModal = { show: true, editing: a.id, form: { name: a.name, platform: a.platform || '', remark: a.remark || '' } };
// login_password / api_config 留空 = 不修改原凭证
store.accountModal = { show: true, editing: a.id, form: { name: a.name, platform: a.platform || '', remark: a.remark || '', login_user: a.login_user || '', login_password: '', api_config: '' } };
}
async function saveAccount() {
store.error = '';
const f = store.accountModal.form;
const payload = { name: f.name, platform: f.platform || null, remark: f.remark || null };
const payload = {
name: f.name, platform: f.platform || null, remark: f.remark || null,
login_user: f.login_user || null,
login_password: f.login_password || null,
api_config: f.api_config || null,
};
try {
if (store.accountModal.editing) await Api.put('/accounts/' + store.accountModal.editing, payload);
else await Api.post('/accounts', payload);
+3 -40
View File
@@ -4,7 +4,7 @@ const ProvidersView = {
<div class="space-y-3">
<!-- 说明条:让用户明白平台是什么 -->
<div class="text-xs text-slate-500 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2">
🏢 平台 = 资产的服务商(Vultr / 新网 / OpenAI…)。在「资产」中添加项目时选择归属平台;支持 API 对接的平台配置好密钥后,可「测试连接」或「同步资产」拉取真实数据。
🏢 平台 = 资产的服务商(Vultr / 新网 / OpenAI…)。凭证(登录信息/API Key)配在账号上:点平台卡片「👤 账号」新增账号并填写凭证,即可在账号上「测试/同步」拉取真实数据。
</div>
<div class="flex gap-2 flex-wrap">
<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>
@@ -30,19 +30,14 @@ const ProvidersView = {
<div class="flex items-center gap-2 mt-1">
<button @click="goAssets(p)" :disabled="!countOf(p)" class="text-slate-500 hover:text-blue-600 dark:hover:text-blue-400 disabled:hover:text-slate-500" :title="countOf(p) ? '查看该平台下的资产' : ''">📎 关联 {{ countOf(p) }} 个资产</button>
<button @click="openAccounts(p)" class="text-slate-500 hover:text-blue-600 dark:hover:text-blue-400">👤 账号 {{ accountsCountOf(p) }}</button>
<span v-if="p.has_api_config" class="text-emerald-600 dark:text-emerald-400">已配置 API</span>
<span v-else class="text-slate-400">未配置 API</span>
<span :class="p.enabled ? 'text-emerald-600 dark:text-emerald-400' : 'text-slate-400'">{{ p.enabled ? '启用' : '停用' }}</span>
</div>
<div v-if="p.last_synced_at" class="text-slate-400">最后同步:{{ fmtTime(p.last_synced_at) }}</div>
</div>
<div class="flex gap-4 mt-3 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs flex-wrap">
<button @click="edit(p)" class="text-blue-600 dark:text-blue-400">编辑</button>
<button v-if="isSupported(p.sdk_type)" @click="test(p)" :disabled="testing[p.id]" class="text-emerald-600 dark:text-emerald-400 disabled:opacity-50">{{ testing[p.id] ? '测试中…' : '测试连接' }}</button>
<button v-if="canSync(p.sdk_type)" @click="sync(p)" :disabled="syncing[p.id]" class="text-violet-600 dark:text-violet-400 disabled:opacity-50">{{ syncing[p.id] ? '同步中…' : '同步资产' }}</button>
<button @click="del(p)" class="text-red-600 dark:text-red-400">删除</button>
</div>
<div v-if="results[p.id]" class="mt-2 text-xs px-2 py-1.5 rounded-lg break-words" :class="results[p.id].ok ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' : 'bg-amber-500/10 text-amber-600 dark:text-amber-400'">{{ results[p.id].text }}</div>
</div>
</div>
<button v-if="!showAll && hiddenCount" @click="toggleShowAll" class="w-full text-center text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 py-2">
@@ -50,11 +45,7 @@ const ProvidersView = {
</button>
</div>`,
setup() {
const testing = Vue.reactive({});
const syncing = Vue.reactive({});
const results = Vue.reactive({});
const supported = Vue.ref([]);
const capabilities = Vue.ref({});
// 凭证已下沉到账号层:平台卡片不再提供测试/同步(见账号弹窗)
// 平台关联资产数:优先按 provider_id 归并,其次按资产里填的平台名/slug
const assetCounts = Vue.computed(() => {
const m = {};
@@ -86,38 +77,10 @@ const ProvidersView = {
function serviceList(p) {
return (p.services || '').split(',').map(s => s.trim()).filter(Boolean);
}
Vue.onMounted(async () => {
try { const r = await Api.get('/providers/adapters'); supported.value = r.supported_types || []; capabilities.value = r.capabilities || {}; } catch (e) { /* ignore */ }
});
function isSupported(t) { return !!t && supported.value.includes(t); }
function canSync(t) { const c = capabilities.value[t]; return !!(c && (c.list_vps || c.list_domains)); }
function fmtTime(iso) {
if (!iso) return '';
return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
async function test(p) {
testing[p.id] = true; delete results[p.id];
try {
const r = await Api.post('/providers/' + p.id + '/test', {});
results[p.id] = { ok: r.ok, text: (r.ok ? '✓ ' : '✗ ') + r.message };
} catch (e) { results[p.id] = { ok: false, text: '✗ ' + e.message }; }
finally { testing[p.id] = false; }
}
async function sync(p) {
syncing[p.id] = true; delete results[p.id];
try {
const r = await Api.post('/providers/' + p.id + '/sync', {});
const parts = [];
if (r.vps) parts.push('VPS 新增' + r.vps.created + '/更新' + r.vps.updated);
if (r.domains) parts.push('域名 新增' + r.domains.created + '/更新' + r.domains.updated);
if (r.vps_error) parts.push('VPS错误:' + r.vps_error);
if (r.domains_error) parts.push('域名错误:' + r.domains_error);
if (r.account && r.account.balance !== null && r.account.balance !== undefined) parts.push('余额 ' + r.account.balance);
results[p.id] = { ok: true, text: '✓ 同步完成:' + (parts.join('') || '无变化') };
await loadAll();
} catch (e) { results[p.id] = { ok: false, text: '✗ ' + e.message }; }
finally { syncing[p.id] = false; }
}
return { store, Fmt, testing, syncing, results, countOf, accountsCountOf, serviceList, showAll, toggleShowAll, visibleProviders, hiddenCount, isSupported, canSync, fmtTime, test, sync, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider, openAccounts: (p) => openAccountsView(p.slug), manageAccounts: () => openAccountsView(null), goAssets: openAssetsByProvider };
return { store, Fmt, countOf, accountsCountOf, serviceList, showAll, toggleShowAll, visibleProviders, hiddenCount, fmtTime, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider, openAccounts: (p) => openAccountsView(p.slug), manageAccounts: () => openAccountsView(null), goAssets: openAssetsByProvider };
},
};