初始提交:实习工作日志系统(设计方案 + Vue3 应用 + Electron 打包)
This commit is contained in:
@@ -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++
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user