Files
daywork/intern-log/src/views/VaultView.vue
T

388 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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; }
.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>