388 lines
13 KiB
Vue
388 lines
13 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||
import Icon from '../components/Icon.vue'
|
||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||
import CredentialDialog from '../components/CredentialDialog.vue'
|
||
import {
|
||
changeMasterPassword, deleteCredential, isVaultInitialized, isVaultUnlocked,
|
||
listCredentials, lockVault, revealCredentialPassword, saveCredential, setupVault, unlockVault
|
||
} from '../db/vault'
|
||
import type { Credential } from '../db/schema'
|
||
import { copyText } from '../lib/clipboard'
|
||
import { showToast } from '../lib/toast'
|
||
|
||
const initialized = ref<boolean | null>(null)
|
||
const unlocked = ref(isVaultUnlocked())
|
||
const busy = ref(false)
|
||
|
||
const setup1 = ref('')
|
||
const setup2 = ref('')
|
||
const setupError = ref('')
|
||
const unlockPwd = ref('')
|
||
const unlockError = ref('')
|
||
|
||
const rows = ref<Credential[]>([])
|
||
const search = ref('')
|
||
const revealed = ref<Record<number, boolean>>({})
|
||
|
||
const dialogOpen = ref(false)
|
||
const editingId = ref<number | null>(null)
|
||
const dialogInitial = ref({ system: '', account: '', password: '', remark: '' })
|
||
const deleteTarget = ref<Credential | null>(null)
|
||
|
||
const pwdDialogOpen = ref(false)
|
||
const oldPwd = ref('')
|
||
const newPwd1 = ref('')
|
||
const newPwd2 = ref('')
|
||
const pwdError = ref('')
|
||
|
||
async function loadRows() {
|
||
rows.value = await listCredentials()
|
||
revealed.value = {}
|
||
}
|
||
|
||
// ---- 自动锁定:解锁状态下 5 分钟无操作自动锁定 ----
|
||
const IDLE_MS = 5 * 60 * 1000
|
||
let idleTimer: number | null = null
|
||
function armIdleTimer() {
|
||
if (idleTimer !== null) window.clearTimeout(idleTimer)
|
||
idleTimer = window.setTimeout(() => {
|
||
if (unlocked.value) {
|
||
lockVault()
|
||
unlocked.value = false
|
||
rows.value = []
|
||
revealed.value = {}
|
||
showToast('长时间未操作,密码库已自动锁定')
|
||
}
|
||
}, IDLE_MS)
|
||
}
|
||
function onActivity() {
|
||
if (unlocked.value) armIdleTimer()
|
||
}
|
||
|
||
onMounted(async () => {
|
||
initialized.value = await isVaultInitialized()
|
||
window.addEventListener('click', onActivity)
|
||
window.addEventListener('keydown', onActivity)
|
||
if (unlocked.value) armIdleTimer()
|
||
})
|
||
onUnmounted(() => {
|
||
window.removeEventListener('click', onActivity)
|
||
window.removeEventListener('keydown', onActivity)
|
||
if (idleTimer !== null) window.clearTimeout(idleTimer)
|
||
})
|
||
|
||
const filtered = computed(() => {
|
||
const q = search.value.trim().toLowerCase()
|
||
if (!q) return rows.value
|
||
return rows.value.filter(r => (r.system + ' ' + r.account + ' ' + (r.remark || '')).toLowerCase().includes(q))
|
||
})
|
||
|
||
async function doSetup() {
|
||
if (busy.value) return
|
||
setupError.value = ''
|
||
if (!setup1.value) { setupError.value = '请输入主密码'; return }
|
||
if (setup1.value.length < 6) { setupError.value = '主密码至少 6 位'; return }
|
||
if (setup1.value !== setup2.value) { setupError.value = '两次输入的主密码不一致'; return }
|
||
busy.value = true
|
||
try {
|
||
await setupVault(setup1.value)
|
||
setup1.value = ''
|
||
setup2.value = ''
|
||
unlocked.value = true
|
||
armIdleTimer()
|
||
await loadRows()
|
||
showToast('主密码已设置,密码库已解锁')
|
||
} catch (e) {
|
||
setupError.value = e instanceof Error ? e.message : '设置失败'
|
||
}
|
||
busy.value = false
|
||
}
|
||
|
||
async function doUnlock() {
|
||
if (busy.value) return
|
||
unlockError.value = ''
|
||
if (!unlockPwd.value) { unlockError.value = '请输入主密码'; return }
|
||
busy.value = true
|
||
try {
|
||
await unlockVault(unlockPwd.value)
|
||
unlockPwd.value = ''
|
||
unlocked.value = true
|
||
armIdleTimer()
|
||
await loadRows()
|
||
} catch (e) {
|
||
unlockError.value = e instanceof Error ? e.message : '解锁失败'
|
||
}
|
||
busy.value = false
|
||
}
|
||
|
||
function doLock() {
|
||
lockVault()
|
||
unlocked.value = false
|
||
rows.value = []
|
||
revealed.value = {}
|
||
if (idleTimer !== null) window.clearTimeout(idleTimer)
|
||
showToast('密码库已锁定')
|
||
}
|
||
|
||
async function toggleReveal(row: Credential) {
|
||
const id = row.id as number
|
||
if (revealed.value[id]) {
|
||
revealed.value[id] = false
|
||
return
|
||
}
|
||
try {
|
||
;(row as Credential & { passwordPlain?: string }).passwordPlain = await revealCredentialPassword(id)
|
||
revealed.value[id] = true
|
||
} catch (e) {
|
||
showToast(e instanceof Error ? e.message : '解密失败')
|
||
}
|
||
}
|
||
|
||
async function copyPassword(row: Credential) {
|
||
try {
|
||
const plain = await revealCredentialPassword(row.id as number)
|
||
await copyText(plain)
|
||
showToast('密码已复制')
|
||
} catch (e) {
|
||
showToast(e instanceof Error ? e.message : '复制失败')
|
||
}
|
||
}
|
||
|
||
async function copyAccount(row: Credential) {
|
||
await copyText(row.account)
|
||
showToast('账号已复制')
|
||
}
|
||
|
||
function openNew() {
|
||
editingId.value = null
|
||
dialogInitial.value = { system: '', account: '', password: '', remark: '' }
|
||
dialogOpen.value = true
|
||
}
|
||
|
||
async function openEdit(row: Credential) {
|
||
try {
|
||
const pwd = await revealCredentialPassword(row.id as number)
|
||
editingId.value = row.id as number
|
||
dialogInitial.value = { system: row.system, account: row.account, password: pwd, remark: row.remark || '' }
|
||
dialogOpen.value = true
|
||
} catch (e) {
|
||
showToast(e instanceof Error ? e.message : '解密失败')
|
||
}
|
||
}
|
||
|
||
async function onDialogSave(payload: { system: string; account: string; password: string; remark: string }) {
|
||
const id = editingId.value ?? undefined
|
||
await saveCredential(payload, id)
|
||
dialogOpen.value = false
|
||
await loadRows()
|
||
showToast(id ? '账号已更新' : '账号已添加')
|
||
}
|
||
|
||
async function confirmDelete() {
|
||
if (deleteTarget.value && deleteTarget.value.id) {
|
||
await deleteCredential(deleteTarget.value.id)
|
||
showToast('账号已删除')
|
||
}
|
||
deleteTarget.value = null
|
||
await loadRows()
|
||
}
|
||
|
||
function openPwdDialog() {
|
||
oldPwd.value = ''
|
||
newPwd1.value = ''
|
||
newPwd2.value = ''
|
||
pwdError.value = ''
|
||
pwdDialogOpen.value = true
|
||
}
|
||
|
||
async function doChangePwd() {
|
||
pwdError.value = ''
|
||
try {
|
||
const n = await changeMasterPassword(oldPwd.value, newPwd1.value)
|
||
pwdDialogOpen.value = false
|
||
oldPwd.value = ''
|
||
newPwd1.value = ''
|
||
newPwd2.value = ''
|
||
showToast('主密码已修改,' + n + ' 条账号密码已重新加密')
|
||
} catch (e) {
|
||
pwdError.value = e instanceof Error ? e.message : '修改失败'
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="vault-page">
|
||
<div v-if="initialized === null" class="empty muted">加载中</div>
|
||
|
||
<div v-else-if="!unlocked" class="card lock-card">
|
||
<div class="lock-badge"><Icon name="lock" :size="26" /></div>
|
||
<template v-if="initialized === false">
|
||
<div class="lock-title">设置主密码</div>
|
||
<div class="muted lock-tip">主密码用于加密保存在本机的账号密码,遗忘后无法找回,请务必牢记</div>
|
||
<input type="password" class="input" v-model="setup1" placeholder="设置主密码(至少 6 位)" @keydown.enter="doSetup" />
|
||
<input type="password" class="input" v-model="setup2" placeholder="再次输入主密码" @keydown.enter="doSetup" />
|
||
<div v-if="setupError" class="form-error">{{ setupError }}</div>
|
||
<button class="btn btn-primary lock-btn" :disabled="busy" @click="doSetup">完成设置并解锁</button>
|
||
</template>
|
||
<template v-else>
|
||
<div class="lock-title">密码库已锁定</div>
|
||
<div class="muted lock-tip">输入主密码解锁后查看与复制密码</div>
|
||
<input type="password" class="input" v-model="unlockPwd" placeholder="主密码" @keydown.enter="doUnlock" />
|
||
<div v-if="unlockError" class="form-error">{{ unlockError }}</div>
|
||
<button class="btn btn-primary lock-btn" :disabled="busy" @click="doUnlock">解锁</button>
|
||
</template>
|
||
</div>
|
||
|
||
<template v-else>
|
||
<div class="vault-toolbar">
|
||
<div class="search-wrap">
|
||
<Icon name="search" :size="14" class="search-icon" />
|
||
<input class="input" v-model="search" placeholder="搜索系统名称、账号或备注" />
|
||
</div>
|
||
<span class="flex1"></span>
|
||
<button class="btn btn-sm" @click="openPwdDialog">修改主密码</button>
|
||
<button class="btn btn-sm" @click="doLock">锁定</button>
|
||
<button class="btn btn-primary btn-sm" @click="openNew">新增账号</button>
|
||
</div>
|
||
|
||
<div class="card table-card">
|
||
<table class="cred-table">
|
||
<thead>
|
||
<tr>
|
||
<th>系统名称</th>
|
||
<th>账号</th>
|
||
<th>密码</th>
|
||
<th>备注</th>
|
||
<th class="th-ops">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="row in filtered" :key="row.id">
|
||
<td class="td-system">{{ row.system }}</td>
|
||
<td>
|
||
<span class="copyable" title="点击复制账号" @click="copyAccount(row)">{{ row.account }}</span>
|
||
</td>
|
||
<td>
|
||
<div class="pwd-cell">
|
||
<span class="num pwd-text">{{ revealed[row.id as number] ? row.passwordPlain : '........' }}</span>
|
||
<button class="btn btn-xs" @click="toggleReveal(row)">{{ revealed[row.id as number] ? '隐藏' : '显示' }}</button>
|
||
<button class="btn btn-xs" @click="copyPassword(row)">复制</button>
|
||
</div>
|
||
</td>
|
||
<td class="td-remark">{{ row.remark || '--' }}</td>
|
||
<td class="td-ops">
|
||
<div class="ops">
|
||
<button class="btn btn-xs" @click="openEdit(row)">编辑</button>
|
||
<button class="btn btn-xs" @click="deleteTarget = row">删除</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<tr v-if="!filtered.length">
|
||
<td colspan="5" class="empty muted">暂无账号,点击右上角新增</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="muted table-note num">共 {{ rows.length }} 条账号记录 · 密码以 AES-256-GCM 加密,服务器仅存密文</div>
|
||
</template>
|
||
|
||
<CredentialDialog
|
||
:open="dialogOpen"
|
||
:title="editingId ? '编辑账号' : '新增账号'"
|
||
:initial="dialogInitial"
|
||
@cancel="dialogOpen = false"
|
||
@save="onDialogSave"
|
||
/>
|
||
|
||
<ConfirmDialog
|
||
:open="!!deleteTarget"
|
||
title="删除确认"
|
||
:message="deleteTarget ? '将删除系统「' + deleteTarget.system + '」的账号记录。删除后不可恢复。' : ''"
|
||
confirm-text="删除" danger
|
||
@cancel="deleteTarget = null"
|
||
@confirm="confirmDelete"
|
||
/>
|
||
|
||
<teleport to="body">
|
||
<transition name="modal">
|
||
<div v-if="pwdDialogOpen" class="mask" @click.self="pwdDialogOpen = false">
|
||
<div class="dialog pwd-dialog">
|
||
<div class="dialog-head">
|
||
<span class="dialog-title">修改主密码</span>
|
||
<button class="icon-btn" title="关闭" @click="pwdDialogOpen = false">
|
||
<Icon name="close" :size="16" />
|
||
</button>
|
||
</div>
|
||
<div class="dialog-body">
|
||
<div class="vp-field">
|
||
<label>旧主密码</label>
|
||
<input type="password" class="input" v-model="oldPwd" />
|
||
</div>
|
||
<div class="vp-field">
|
||
<label>新主密码(至少 6 位)</label>
|
||
<input type="password" class="input" v-model="newPwd1" />
|
||
</div>
|
||
<div class="vp-field">
|
||
<label>确认新主密码</label>
|
||
<input type="password" class="input" v-model="newPwd2" />
|
||
</div>
|
||
<div v-if="pwdError" class="form-error">{{ pwdError }}</div>
|
||
<div class="muted vp-tip">确认后所有账号密码将使用新主密码重新加密</div>
|
||
</div>
|
||
<div class="dialog-btns">
|
||
<button class="btn btn-sm" @click="pwdDialogOpen = false">取消</button>
|
||
<button class="btn btn-primary btn-sm" @click="doChangePwd">确认修改</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
</teleport>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.lock-card {
|
||
max-width: 380px;
|
||
margin: 60px auto 0;
|
||
padding: 28px 24px;
|
||
text-align: center;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
.lock-badge {
|
||
width: 52px;
|
||
height: 52px;
|
||
margin: 0 auto;
|
||
border-radius: 50%;
|
||
background: var(--accent-bg);
|
||
color: var(--accent);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.lock-title { font-size: 15px; font-weight: 600; }
|
||
.lock-tip { font-size: 12px; line-height: 1.7; }
|
||
.lock-btn { margin-top: 4px; }
|
||
|
||
.vault-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||
.search-wrap { position: relative; width: 280px; max-width: 100%; }
|
||
.search-wrap .input { padding-left: 30px; }
|
||
.search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--text-3); }
|
||
|
||
.table-card { overflow-x: auto; }
|
||
.td-system { font-weight: 500; }
|
||
.th-ops { width: 150px; }
|
||
.pwd-cell { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
|
||
.pwd-text { letter-spacing: 1px; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||
.copyable { cursor: pointer; }
|
||
.copyable:hover { color: var(--accent); }
|
||
.ops { display: flex; gap: 4px; flex-wrap: wrap; }
|
||
.table-note { margin-top: 10px; font-size: 12px; text-align: right; }
|
||
|
||
.dialog { width: 440px; }
|
||
.vp-field { margin-bottom: 10px; }
|
||
.vp-tip { font-size: 12px; margin-top: 8px; }
|
||
</style>
|