diff --git a/app/routers/accounts.py b/app/routers/accounts.py index f8d32f6..c8b09bd 100644 --- a/app/routers/accounts.py +++ b/app/routers/accounts.py @@ -55,6 +55,15 @@ def delete_account(account_id: int, session: Session = Depends(get_session)) -> 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( "/{account_id}/test", summary="测试账号凭证有效性(按账号所属平台的 SDK)", diff --git a/app/services/account_service.py b/app/services/account_service.py index 966abdf..408e3c8 100644 --- a/app/services/account_service.py +++ b/app/services/account_service.py @@ -109,3 +109,17 @@ def delete_account(session: Session, account_id: int) -> Dict[str, int]: session.commit() # 资产端保留原账号名文本(不级联清空),由用户自行处理 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} diff --git a/static/js/modals.js b/static/js/modals.js index ff425e5..9a91b2f 100644 --- a/static/js/modals.js +++ b/static/js/modals.js @@ -291,6 +291,7 @@ const AccountsViewModal = {
{{ a.name }} · 关联 {{ assetsOf(a).length }} 个资产
+ @@ -310,9 +311,31 @@ const AccountsViewModal = { + +
+
+

🔑 {{ pwd.name }} 的登录凭证

+
+
+ 用户名 + {{ pwd.login_user }} +
+
+ 密码 + {{ pwd.password }} +
+
+
+ + +
+

{{ pwd.countdown }}s 后自动关闭

+
+
`, setup() { 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 title = Vue.computed(() => { const slug = store.accountsModal.providerSlug; @@ -372,9 +395,40 @@ const AccountsViewModal = { Vue.watch(() => store.accountsModal.show, (show) => { 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 { - store, Fmt, expanded, singleProvider, title, accountList, groups, - platformName, assetsOf, toggle, canSync, testAcct, syncAcct, + store, Fmt, expanded, pwd, singleProvider, title, accountList, groups, + platformName, assetsOf, toggle, canSync, testAcct, syncAcct, viewPwd, copyPwd, add: () => openAccountCreate(store.accountsModal.providerSlug || ''), edit: openAccountEdit, del: deleteAccount, };