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,
};