feat(account): 账号登录密码支持查看明文——新增GET /accounts/{id}/password解密接口+前端🔑查看弹窗(8s自动关闭/一键复制)

This commit is contained in:
gouki
2026-08-10 14:51:52 +00:00
parent 09a7b7aa42
commit bc1b804931
3 changed files with 79 additions and 2 deletions
+9
View File
@@ -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)",
+14
View File
@@ -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}
+56 -2
View File
@@ -291,6 +291,7 @@ const AccountsViewModal = {
</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="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="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>
@@ -310,9 +311,31 @@ const AccountsViewModal = {
</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>`,
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,
};