feat(account): 账号登录密码支持查看明文——新增GET /accounts/{id}/password解密接口+前端🔑查看弹窗(8s自动关闭/一键复制)
This commit is contained in:
@@ -55,6 +55,15 @@ def delete_account(account_id: int, session: Session = Depends(get_session)) ->
|
|||||||
return account_service.delete_account(session, account_id)
|
return account_service.delete_account(session, account_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{account_id}/password",
|
||||||
|
summary="查看账号登录密码明文(解密返回)",
|
||||||
|
dependencies=[Depends(require_api_key)],
|
||||||
|
)
|
||||||
|
def reveal_password(account_id: int, session: Session = Depends(get_session)) -> dict:
|
||||||
|
return account_service.reveal_password(session, account_id)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{account_id}/test",
|
"/{account_id}/test",
|
||||||
summary="测试账号凭证有效性(按账号所属平台的 SDK)",
|
summary="测试账号凭证有效性(按账号所属平台的 SDK)",
|
||||||
|
|||||||
@@ -109,3 +109,17 @@ def delete_account(session: Session, account_id: int) -> Dict[str, int]:
|
|||||||
session.commit()
|
session.commit()
|
||||||
# 资产端保留原账号名文本(不级联清空),由用户自行处理
|
# 资产端保留原账号名文本(不级联清空),由用户自行处理
|
||||||
return {"affected_assets": affected}
|
return {"affected_assets": affected}
|
||||||
|
|
||||||
|
|
||||||
|
def reveal_password(session: Session, account_id: int) -> Dict[str, str]:
|
||||||
|
"""解密返回账号登录密码明文(供前端「查看密码」功能)。
|
||||||
|
|
||||||
|
安全说明:接口受 API Key 保护;密码本可逆加密存储,此处合法解密还原。
|
||||||
|
"""
|
||||||
|
account = _get_account(session, account_id)
|
||||||
|
plain = crypto.decrypt(account.login_password_encrypted)
|
||||||
|
if not plain:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="该账号未配置登录密码"
|
||||||
|
)
|
||||||
|
return {"login_user": account.login_user or "", "password": plain}
|
||||||
|
|||||||
+56
-2
@@ -291,6 +291,7 @@ const AccountsViewModal = {
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-slate-400 truncate"><span v-if="a.remark">{{ a.name }} · </span>关联 {{ assetsOf(a).length }} 个资产</div>
|
<div class="text-xs text-slate-400 truncate"><span v-if="a.remark">{{ a.name }} · </span>关联 {{ assetsOf(a).length }} 个资产</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button v-if="a.has_login_password" @click="viewPwd(a)" class="text-xs text-amber-600 dark:text-amber-400" title="查看登录密码">🔑</button>
|
||||||
<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="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 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="edit(a)" class="text-xs text-blue-600 dark:text-blue-400">编辑</button>
|
||||||
@@ -310,9 +311,31 @@ const AccountsViewModal = {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 密码查看弹窗:点击账号的 🔑 后展示明文,几秒后自动隐藏 -->
|
||||||
|
<div v-if="pwd.show" class="absolute inset-0 bg-black/50 flex items-center justify-center p-4" @click.self="pwd.show=false">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl w-full max-w-xs p-5 shadow-xl">
|
||||||
|
<h4 class="font-semibold text-sm mb-3">🔑 {{ pwd.name }} 的登录凭证</h4>
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div v-if="pwd.login_user" class="flex justify-between items-center gap-2">
|
||||||
|
<span class="text-slate-400 text-xs shrink-0">用户名</span>
|
||||||
|
<span class="font-mono truncate">{{ pwd.login_user }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center gap-2">
|
||||||
|
<span class="text-slate-400 text-xs shrink-0">密码</span>
|
||||||
|
<span class="font-mono break-all">{{ pwd.password }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 mt-4">
|
||||||
|
<button @click="copyPwd" class="flex-1 py-1.5 rounded-lg bg-blue-600 text-white text-xs">{{ pwd.copied ? '✓ 已复制' : '复制密码' }}</button>
|
||||||
|
<button @click="pwd.show=false" class="px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-700 text-xs">关闭</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-[10px] text-slate-400 mt-2 text-center">{{ pwd.countdown }}s 后自动关闭</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>`,
|
</div>`,
|
||||||
setup() {
|
setup() {
|
||||||
const expanded = Vue.reactive({});
|
const expanded = Vue.reactive({});
|
||||||
|
const pwd = Vue.reactive({ show: false, name: '', login_user: '', password: '', copied: false, countdown: 0, _timer: null });
|
||||||
const singleProvider = Vue.computed(() => !!store.accountsModal.providerSlug);
|
const singleProvider = Vue.computed(() => !!store.accountsModal.providerSlug);
|
||||||
const title = Vue.computed(() => {
|
const title = Vue.computed(() => {
|
||||||
const slug = store.accountsModal.providerSlug;
|
const slug = store.accountsModal.providerSlug;
|
||||||
@@ -372,9 +395,40 @@ const AccountsViewModal = {
|
|||||||
Vue.watch(() => store.accountsModal.show, (show) => {
|
Vue.watch(() => store.accountsModal.show, (show) => {
|
||||||
if (show) for (const k of Object.keys(expanded)) delete expanded[k];
|
if (show) for (const k of Object.keys(expanded)) delete expanded[k];
|
||||||
});
|
});
|
||||||
|
// 查看密码:拉取明文并展示,8 秒后自动关闭;关闭时清除定时器
|
||||||
|
async function viewPwd(a) {
|
||||||
|
try {
|
||||||
|
const r = await Api.get('/accounts/' + a.id + '/password');
|
||||||
|
pwd.name = a.remark || a.name;
|
||||||
|
pwd.login_user = r.login_user || '';
|
||||||
|
pwd.password = r.password;
|
||||||
|
pwd.copied = false;
|
||||||
|
pwd.show = true;
|
||||||
|
if (pwd._timer) clearInterval(pwd._timer);
|
||||||
|
pwd.countdown = 8;
|
||||||
|
pwd._timer = setInterval(() => {
|
||||||
|
pwd.countdown--;
|
||||||
|
if (pwd.countdown <= 0) { pwd.show = false; clearInterval(pwd._timer); pwd._timer = null; }
|
||||||
|
}, 1000);
|
||||||
|
} catch (e) { alert('获取密码失败:' + e.message); }
|
||||||
|
}
|
||||||
|
function copyPwd() {
|
||||||
|
const done = () => { pwd.copied = true; setTimeout(() => { pwd.copied = false; }, 1500); };
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(pwd.password).then(done).catch(() => fallbackCopy());
|
||||||
|
} else { fallbackCopy(); }
|
||||||
|
function fallbackCopy() {
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = pwd.password; document.body.appendChild(ta); ta.select();
|
||||||
|
try { document.execCommand('copy'); done(); } catch (e) {}
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 关闭弹窗时清定时器,避免泄漏
|
||||||
|
Vue.watch(() => pwd.show, (v) => { if (!v && pwd._timer) { clearInterval(pwd._timer); pwd._timer = null; } });
|
||||||
return {
|
return {
|
||||||
store, Fmt, expanded, singleProvider, title, accountList, groups,
|
store, Fmt, expanded, pwd, singleProvider, title, accountList, groups,
|
||||||
platformName, assetsOf, toggle, canSync, testAcct, syncAcct,
|
platformName, assetsOf, toggle, canSync, testAcct, syncAcct, viewPwd, copyPwd,
|
||||||
add: () => openAccountCreate(store.accountsModal.providerSlug || ''),
|
add: () => openAccountCreate(store.accountsModal.providerSlug || ''),
|
||||||
edit: openAccountEdit, del: deleteAccount,
|
edit: openAccountEdit, del: deleteAccount,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user