初始提交:实习工作日志系统(设计方案 + Vue3 应用 + Electron 打包)
This commit is contained in:
@@ -0,0 +1,437 @@
|
||||
<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>
|
||||
<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 vault-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(--bg-subtle);
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.lock-title { font-size: 14px; font-weight: 600; }
|
||||
.lock-tip { font-size: 12px; line-height: 1.7; }
|
||||
.lock-btn { margin-top: 4px; }
|
||||
.form-error { color: var(--danger); font-size: 12px; }
|
||||
.empty { text-align: center; padding: 24px 0; }
|
||||
|
||||
.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; }
|
||||
.cred-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.cred-table th {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
color: var(--text-2);
|
||||
background: var(--bg-subtle);
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cred-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); vertical-align: top; word-break: break-all; }
|
||||
.cred-table tbody tr:hover { background: var(--bg-subtle); }
|
||||
.cred-table tbody tr:last-child td { border-bottom: none; }
|
||||
.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; }
|
||||
.vault-note { margin-top: 10px; font-size: 12px; text-align: right; }
|
||||
|
||||
.mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(29, 33, 41, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 90;
|
||||
}
|
||||
.dialog {
|
||||
width: 440px;
|
||||
max-width: calc(100vw - 32px);
|
||||
background: var(--bg-page);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
.dialog-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.dialog-title { font-size: 14px; font-weight: 600; }
|
||||
.dialog-body { padding: 16px 20px; }
|
||||
.dialog-btns {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 20px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.vp-field { margin-bottom: 10px; }
|
||||
.vp-field label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
|
||||
.vp-tip { font-size: 12px; margin-top: 8px; }
|
||||
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
|
||||
.modal-enter-from, .modal-leave-to { opacity: 0; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user