初始提交:实习工作日志系统(设计方案 + Vue3 应用 + Electron 打包)

This commit is contained in:
root
2026-10-15 23:55:27 +08:00
commit cfb984627d
50 changed files with 9223 additions and 0 deletions
+437
View File
@@ -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>