初始提交:实习工作日志系统(设计方案 + 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
+11
View File
@@ -0,0 +1,11 @@
import { ref } from 'vue'
// 跨视图定位信号:全局搜索命中后跳转到对应视图并聚焦
export const focusDate = ref<string | null>(null)
export const focusMemo = ref<number | null>(null)
// 考勤数据变更信号:打卡、修改时间、补卡、休假后自增,所有考勤展示视图监听并实时刷新
export const attendanceChanged = ref(0)
export function notifyAttendanceChanged(): void {
attendanceChanged.value++
}
+14
View File
@@ -0,0 +1,14 @@
export async function copyText(text: string): Promise<void> {
try {
await navigator.clipboard.writeText(text)
} catch {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
}
+62
View File
@@ -0,0 +1,62 @@
// 主密码加密:PBKDF2-SHA256 派生 AES-256-GCM 密钥(设计方案 3.8)
const PBKDF2_ITERATIONS = 150000
export function bufToB64(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf)
let bin = ''
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i])
return btoa(bin)
}
function b64ToBytes(s: string): Uint8Array {
const bin = atob(s)
const bytes = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
return bytes
}
export function randomSaltB64(): string {
return bufToB64(crypto.getRandomValues(new Uint8Array(16)).buffer)
}
// 登录密码哈希:仅存 PBKDF2 派生值,不存明文
export async function pbkdf2Hash(password: string, saltB64: string): Promise<string> {
const enc = new TextEncoder()
const base = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveBits'])
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', salt: b64ToBytes(saltB64), iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
base,
256
)
return bufToB64(bits)
}
export async function deriveKeyFromPassword(password: string, saltB64: string): Promise<CryptoKey> {
const enc = new TextEncoder()
const base = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveKey'])
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: b64ToBytes(saltB64), iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
base,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
)
}
// 密文打包格式:iv_base64:cipher_base64
export async function aesGcmEncrypt(key: CryptoKey, plaintext: string): Promise<string> {
const iv = crypto.getRandomValues(new Uint8Array(12))
const cipher = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(plaintext))
return bufToB64(iv.buffer as ArrayBuffer) + ':' + bufToB64(cipher)
}
export async function aesGcmDecrypt(key: CryptoKey, packed: string): Promise<string> {
const parts = packed.split(':')
if (parts.length !== 2) throw new Error('密文格式错误')
const plain = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: b64ToBytes(parts[0]) },
key,
b64ToBytes(parts[1])
)
return new TextDecoder().decode(plain)
}
+16
View File
@@ -0,0 +1,16 @@
function csvCell(v: string | number | null | undefined): string {
const s = v == null ? '' : String(v)
return '"' + s.replace(/"/g, '""') + '"'
}
// 导出 UTF-8 BOM 的 CSV,Excel 可直接打开中文不乱码
export function downloadCsv(filename: string, rows: (string | number | null | undefined)[][]): void {
const content = rows.map(r => r.map(csvCell).join(',')).join('\r\n')
const blob = new Blob(['\ufeff' + content], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
+65
View File
@@ -0,0 +1,65 @@
export function pad(n: number): string {
return n < 10 ? '0' + n : String(n)
}
export function toDateStr(d: Date): string {
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
}
export function todayStr(): string {
return toDateStr(new Date())
}
export function nowLocalISO(): string {
const d = new Date()
return toDateStr(d) + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds())
}
export function hhmm(iso: string | null | undefined): string {
if (!iso) return '--:--'
const i = iso.indexOf('T')
return i >= 0 ? iso.slice(i + 1, i + 6) : iso.slice(0, 5)
}
export function parseDateStr(s: string): Date {
const parts = s.split('-').map(Number)
return new Date(parts[0], parts[1] - 1, parts[2])
}
export function addDays(s: string, n: number): string {
const d = parseDateStr(s)
d.setDate(d.getDate() + n)
return toDateStr(d)
}
export function addMonths(s: string, n: number): string {
const d = parseDateStr(s)
const day = d.getDate()
d.setDate(1)
d.setMonth(d.getMonth() + n)
const last = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate()
d.setDate(Math.min(day, last))
return toDateStr(d)
}
export function weekStart(s: string): string {
const d = parseDateStr(s)
const wd = (d.getDay() + 6) % 7
d.setDate(d.getDate() - wd)
return toDateStr(d)
}
export function isWeekend(s: string): boolean {
const wd = parseDateStr(s).getDay()
return wd === 0 || wd === 6
}
export function tsMinutes(a: string, b: string): number {
return Math.round((new Date(b).getTime() - new Date(a).getTime()) / 60000)
}
export function formatDateCN(s: string): string {
const d = parseDateStr(s)
const names = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
return d.getFullYear() + ' 年 ' + (d.getMonth() + 1) + ' 月 ' + d.getDate() + ' 日 ' + names[d.getDay()]
}
+9
View File
@@ -0,0 +1,9 @@
export function downloadText(filename: string, text: string): void {
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
+22
View File
@@ -0,0 +1,22 @@
import { ref } from 'vue'
export interface ToastItem {
id: number
text: string
}
const toasts = ref<ToastItem[]>([])
let seq = 0
// 轻提示:操作结果统一在右上角反馈,2 秒自动消失
export function showToast(text: string): void {
const id = ++seq
toasts.value.push({ id, text })
setTimeout(() => {
toasts.value = toasts.value.filter(t => t.id !== id)
}, 2000)
}
export function useToasts() {
return toasts
}