feat: 多端同步服务端与引擎 + Capacitor 安卓工程(v1.13)
This commit is contained in:
@@ -11,6 +11,8 @@ import VaultView from './views/VaultView.vue'
|
||||
import SettingsView from './views/SettingsView.vue'
|
||||
import LoginView from './views/LoginView.vue'
|
||||
import { getSession, logoutUser, openUserDatabase, type Session } from './db/auth'
|
||||
import { getSetting, } from './db/repo'
|
||||
import { isSyncConfigured, runSync } from './sync/engine'
|
||||
import { clockIn, getAttendance, getWorkRule, getWeeklyNote, searchLogItems, searchMemos, smartClockOut, type WorkRule } from './db/repo'
|
||||
import { addDays, hhmm, todayStr, weekStart } from './lib/date'
|
||||
import { useToasts } from './lib/toast'
|
||||
@@ -39,6 +41,7 @@ async function onLogin(s: Session) {
|
||||
armSessionTimer()
|
||||
await refreshClock()
|
||||
await refreshTip()
|
||||
startSyncTimer()
|
||||
showToast('欢迎,' + s.username)
|
||||
const mt = sessionStorage.getItem('dsh_migrate_toast')
|
||||
if (mt) {
|
||||
@@ -54,6 +57,33 @@ async function doLogout(auto: boolean) {
|
||||
showToast(auto ? '长时间未操作已自动退出' : '已退出登录')
|
||||
}
|
||||
|
||||
// ---- 自动同步:登录后与每 5 分钟,静默执行 ----
|
||||
let syncTimer: number | null = null
|
||||
let lastSyncToast = 0
|
||||
async function maybeAutoSync() {
|
||||
if (!session.value) return
|
||||
try {
|
||||
if (!(await isSyncConfigured())) return
|
||||
const result = await runSync({
|
||||
baseUrl: await getSetting('sync_base_url', ''),
|
||||
username: await getSetting('sync_username', ''),
|
||||
password: await getSetting('sync_password', '')
|
||||
})
|
||||
if (result.pushed || result.pulled || result.removed) showToast('云同步完成:推送 ' + result.pushed + ' · 拉取 ' + result.pulled)
|
||||
} catch (e) {
|
||||
const now = Date.now()
|
||||
if (now - lastSyncToast > 10 * 60 * 1000) {
|
||||
lastSyncToast = now
|
||||
showToast(e instanceof Error ? e.message : '自动同步失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
function startSyncTimer() {
|
||||
if (syncTimer !== null) window.clearInterval(syncTimer)
|
||||
syncTimer = window.setInterval(() => { maybeAutoSync() }, 5 * 60 * 1000)
|
||||
maybeAutoSync()
|
||||
}
|
||||
|
||||
// ---- 全局搜索 ----
|
||||
const gsInput = ref<HTMLInputElement | null>(null)
|
||||
const searchQ = ref('')
|
||||
@@ -188,6 +218,7 @@ onMounted(async () => {
|
||||
armSessionTimer()
|
||||
await refreshClock()
|
||||
await refreshTip()
|
||||
startSyncTimer()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -78,6 +78,22 @@ export interface Transaction {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// 同步镜像:记录上次同步时每条数据的哈希,用于增量差异检测
|
||||
export interface SyncMirror {
|
||||
id?: number
|
||||
table: string
|
||||
uuid: string
|
||||
hash: string
|
||||
}
|
||||
|
||||
// 删除墓碑:同步时告知其他端该记录已删除
|
||||
export interface SyncTombstone {
|
||||
id?: number
|
||||
table: string
|
||||
uuid: string
|
||||
deleted_at: string
|
||||
}
|
||||
|
||||
export class WorklogDB extends Dexie {
|
||||
project!: Table<Project, number>
|
||||
work_log!: Table<WorkLog, number>
|
||||
@@ -88,6 +104,8 @@ export class WorklogDB extends Dexie {
|
||||
memo!: Table<Memo, number>
|
||||
credential!: Table<Credential, number>
|
||||
transaction!: Table<Transaction, number>
|
||||
sync_mirror!: Table<SyncMirror, number>
|
||||
tombstone!: Table<SyncTombstone, number>
|
||||
|
||||
constructor(dbName = 'intern_worklog') {
|
||||
super(dbName)
|
||||
@@ -106,6 +124,10 @@ export class WorklogDB extends Dexie {
|
||||
this.version(3).stores({
|
||||
transaction: '++id, date, type, category'
|
||||
})
|
||||
this.version(4).stores({
|
||||
sync_mirror: '++id, table, uuid',
|
||||
tombstone: '++id, table, uuid, deleted_at'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +143,7 @@ export function unbindDatabase(): void {
|
||||
db = undefined
|
||||
}
|
||||
|
||||
export const SCHEMA_VERSION = 3
|
||||
export const SCHEMA_VERSION = 4
|
||||
|
||||
export const EXPENSE_CATEGORIES: string[] = ['餐饮', '交通', '住房', '通讯', '学习', '娱乐', '其他']
|
||||
export const INCOME_CATEGORIES: string[] = ['工资', '报销', '其他']
|
||||
|
||||
@@ -110,7 +110,13 @@ button, input, select, textarea { font: inherit; color: inherit; }
|
||||
.dot.normal { background: var(--ok); }
|
||||
.dot.late, .dot.early, .dot.incomplete { background: var(--warn); }
|
||||
.dot.absent { background: var(--danger); }
|
||||
.dot.none { background: var(--dot-empty); }
|
||||
.dot.none, .dot.leave { background: var(--dot-empty); }
|
||||
|
||||
/* 移动端:避免 iOS 聚焦缩放,输入字号提到 16px */
|
||||
@media (max-width: 720px) {
|
||||
input, select, textarea { font-size: 16px !important; }
|
||||
input[type="checkbox"] { font-size: initial; }
|
||||
}
|
||||
.dot.rest { background: var(--border); }
|
||||
|
||||
.icon { display: inline-flex; align-items: center; justify-content: center; flex: none; }
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
// 多端同步引擎:全量差异对比 + 记录级新者胜 + 软删除墓碑
|
||||
import { db } from '../db/schema'
|
||||
import { getSetting, setSetting } from '../db/repo'
|
||||
import { nowLocalISO } from '../lib/date'
|
||||
|
||||
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'transaction']
|
||||
|
||||
// 本地敏感或临时性的设置不参与同步
|
||||
const EXCLUDE_SETTING_KEYS = new Set(['sync_cursor', 'sync_last_time', 'last_project', 'last_category'])
|
||||
|
||||
export const SYNC_TABLES = TABLES
|
||||
|
||||
interface AnyRow { [key: string]: any }
|
||||
|
||||
function newUuid(): string {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||
const r = (Math.random() * 16) | 0
|
||||
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
|
||||
})
|
||||
}
|
||||
|
||||
function stripId(row: AnyRow): AnyRow {
|
||||
const copy: AnyRow = { ...row }
|
||||
delete copy.id
|
||||
return copy
|
||||
}
|
||||
|
||||
function rowHash(row: AnyRow): string {
|
||||
return JSON.stringify(row)
|
||||
}
|
||||
|
||||
async function getTable(name: string): Promise<AnyRow[]> {
|
||||
return (db as unknown as AnyRow)[name].toArray()
|
||||
}
|
||||
|
||||
// 一次性补齐 uuid 与 updated_at,缺失时回填
|
||||
async function ensureSyncFields(): Promise<void> {
|
||||
for (const t of TABLES) {
|
||||
const table = (db as unknown as AnyRow)[t]
|
||||
const rows: AnyRow[] = await table.toArray()
|
||||
for (const row of rows) {
|
||||
if (t === 'setting' && EXCLUDE_SETTING_KEYS.has(row.key)) continue
|
||||
const patch: AnyRow = {}
|
||||
if (!row.uuid) patch.uuid = newUuid()
|
||||
if (!row.updated_at) patch.updated_at = row.created_at || nowLocalISO()
|
||||
if (Object.keys(patch).length) await table.update(row.id, patch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
pushed: number
|
||||
pulled: number
|
||||
removed: number
|
||||
at: string
|
||||
}
|
||||
|
||||
export interface SyncConfig {
|
||||
baseUrl: string
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export async function isSyncConfigured(): Promise<boolean> {
|
||||
const values = await Promise.all([
|
||||
getSetting('sync_base_url', ''),
|
||||
getSetting('sync_username', ''),
|
||||
getSetting('sync_password', '')
|
||||
])
|
||||
return !!(values[0].trim() && values[1].trim() && values[2].trim())
|
||||
}
|
||||
|
||||
export async function getSyncLastTime(): Promise<string> {
|
||||
return getSetting('sync_last_time', '')
|
||||
}
|
||||
|
||||
// 云端账号注册(服务端存 PBKDF2 哈希)
|
||||
export async function registerCloudAccount(baseUrl: string, username: string, password: string): Promise<void> {
|
||||
const base = baseUrl.trim().replace(/\/+$/, '')
|
||||
if (!base || !username.trim() || password.length < 6) {
|
||||
throw new Error('请完整填写同步地址、用户名与至少 6 位的密码')
|
||||
}
|
||||
let resp: Response
|
||||
try {
|
||||
resp = await fetch(base + '/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: username.trim(), password })
|
||||
})
|
||||
} catch {
|
||||
throw new Error('无法访问同步服务:请检查地址、端口与网络')
|
||||
}
|
||||
if (resp.status === 409) throw new Error('云端用户名已存在,可直接同步')
|
||||
if (!resp.ok) throw new Error('云端注册失败:' + resp.status)
|
||||
}
|
||||
|
||||
// 执行一次完整同步
|
||||
export async function runSync(config: SyncConfig): Promise<SyncResult> {
|
||||
const base = config.baseUrl.trim().replace(/\/+$/, '')
|
||||
if (!base) throw new Error('请先配置同步服务地址')
|
||||
await ensureSyncFields()
|
||||
|
||||
// 1. 载入镜像,计算本地差异
|
||||
const mirrorRows = await db.sync_mirror.toArray()
|
||||
const mirror = new Map<string, { table: string; uuid: string; hash: string }>()
|
||||
for (const m of mirrorRows) mirror.set(m.table + '|' + m.uuid, m)
|
||||
|
||||
const pushTables: Record<string, AnyRow[]> = {}
|
||||
const tombstones: { table: string; uuid: string; deleted_at: string }[] = []
|
||||
let pushed = 0
|
||||
|
||||
for (const t of TABLES) {
|
||||
let rows: AnyRow[] = await getTable(t)
|
||||
if (t === 'setting') rows = rows.filter(r => !EXCLUDE_SETTING_KEYS.has(r.key))
|
||||
const current = new Map<string, AnyRow>()
|
||||
for (const row of rows) current.set(row.uuid, row)
|
||||
|
||||
for (const [u, row] of current) {
|
||||
const clean = stripId(row)
|
||||
const h = rowHash(clean)
|
||||
const m = mirror.get(t + '|' + u)
|
||||
if (!m || m.hash !== h) {
|
||||
pushTables[t] = pushTables[t] || []
|
||||
pushTables[t].push(clean)
|
||||
pushed++
|
||||
}
|
||||
}
|
||||
for (const [key, m] of mirror) {
|
||||
if (key.startsWith(t + '|') && !current.has(m.uuid)) {
|
||||
tombstones.push({ table: t, uuid: m.uuid, deleted_at: nowLocalISO() })
|
||||
}
|
||||
}
|
||||
pushTables[t] = pushTables[t] || []
|
||||
}
|
||||
|
||||
// 2. 与服务端交换
|
||||
let resp: Response
|
||||
try {
|
||||
resp = await fetch(base + '/api/sync', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer ' + config.username.trim() + ':' + config.password
|
||||
},
|
||||
body: JSON.stringify({ device: 'client', tables: pushTables, tombstones })
|
||||
})
|
||||
} catch {
|
||||
throw new Error('无法访问同步服务:请检查地址、端口与网络')
|
||||
}
|
||||
if (resp.status === 401) throw new Error('云端账号或密码不正确')
|
||||
if (!resp.ok) throw new Error('同步服务返回 ' + resp.status)
|
||||
const data = await resp.json()
|
||||
const serverTables: Record<string, AnyRow[]> = data.tables || {}
|
||||
const serverTombstones: { table: string; uuid: string; deleted_at: string }[] = data.tombstones || []
|
||||
|
||||
// 3. 应用服务端权威数据
|
||||
let pulled = 0
|
||||
let removed = 0
|
||||
const finalMirror: { table: string; uuid: string; hash: string }[] = []
|
||||
|
||||
for (const t of TABLES) {
|
||||
const table = (db as unknown as AnyRow)[t]
|
||||
const localRows: AnyRow[] = await table.toArray()
|
||||
const byUuid = new Map<string, AnyRow>()
|
||||
for (const row of localRows) byUuid.set(row.uuid, row)
|
||||
|
||||
const serverRows: AnyRow[] = serverTables[t] || []
|
||||
const dead = new Set(serverTombstones.filter(x => x.table === t).map(x => x.uuid))
|
||||
|
||||
for (const [u, row] of byUuid) {
|
||||
if (dead.has(u)) {
|
||||
await table.delete(row.id)
|
||||
removed++
|
||||
}
|
||||
}
|
||||
for (const [u, srow] of serverRows) {
|
||||
if (dead.has(u)) continue
|
||||
const local = byUuid.get(u)
|
||||
if (!local) {
|
||||
await table.add({ ...srow })
|
||||
pulled++
|
||||
} else {
|
||||
const localUa = String(local.updated_at || '')
|
||||
const serverUa = String(srow.updated_at || '')
|
||||
if (serverUa > localUa) {
|
||||
await table.update(local.id, { ...srow })
|
||||
pulled++
|
||||
}
|
||||
}
|
||||
}
|
||||
const finalRows: AnyRow[] = await table.toArray()
|
||||
for (const row of finalRows) {
|
||||
if (t === 'setting' && EXCLUDE_SETTING_KEYS.has(row.key)) continue
|
||||
finalMirror.push({ table: t, uuid: row.uuid, hash: rowHash(stripId(row)) })
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 重建镜像
|
||||
await db.sync_mirror.clear()
|
||||
if (finalMirror.length) await db.sync_mirror.bulkAdd(finalMirror.map(m => ({ ...m })))
|
||||
const at = nowLocalISO()
|
||||
await setSetting('sync_last_time', at)
|
||||
return { pushed, pulled, removed, at }
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { downloadCsv } from '../lib/csv'
|
||||
import { hhmm, parseDateStr, todayStr } from '../lib/date'
|
||||
import { computeDay } from '../domain/attendance'
|
||||
import { changeLoginPassword, deleteCurrentUser, getSession } from '../db/auth'
|
||||
import { getSyncLastTime, isSyncConfigured, registerCloudAccount, runSync, type SyncResult } from '../sync/engine'
|
||||
import { showToast } from '../lib/toast'
|
||||
|
||||
const rule = ref<WorkRule>({ work_start: '09:00', work_end: '18:00', grace_minutes: 10, lunch_minutes: 60, remind_weekly: 'friday' })
|
||||
@@ -34,6 +35,54 @@ const newLoginPwd1 = ref('')
|
||||
const newLoginPwd2 = ref('')
|
||||
const accError = ref('')
|
||||
const deleteAccConfirm = ref(false)
|
||||
const syncBase = ref('')
|
||||
const syncUser = ref('')
|
||||
const syncPwd = ref('')
|
||||
const syncStatus = ref('')
|
||||
const syncError = ref('')
|
||||
const syncing = ref(false)
|
||||
const lastSync = ref('')
|
||||
|
||||
async function loadSyncInfo() {
|
||||
syncBase.value = await getSetting('sync_base_url', '')
|
||||
syncUser.value = await getSetting('sync_username', '')
|
||||
syncPwd.value = await getSetting('sync_password', '')
|
||||
lastSync.value = await getSyncLastTime()
|
||||
}
|
||||
|
||||
async function saveSyncConfig() {
|
||||
await setSetting('sync_base_url', syncBase.value.trim())
|
||||
await setSetting('sync_username', syncUser.value.trim())
|
||||
await setSetting('sync_password', syncPwd.value)
|
||||
showToast('同步配置已保存')
|
||||
}
|
||||
|
||||
async function registerCloud() {
|
||||
syncError.value = ''
|
||||
try {
|
||||
await registerCloudAccount(syncBase.value, syncUser.value, syncPwd.value)
|
||||
await saveSyncConfig()
|
||||
showToast('云端账号已注册,可直接同步')
|
||||
} catch (e) {
|
||||
syncError.value = e instanceof Error ? e.message : '注册失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function doSyncNow() {
|
||||
if (syncing.value) return
|
||||
syncError.value = ''
|
||||
syncing.value = true
|
||||
try {
|
||||
await saveSyncConfig()
|
||||
const result: SyncResult = await runSync({ baseUrl: syncBase.value, username: syncUser.value, password: syncPwd.value })
|
||||
lastSync.value = result.at
|
||||
syncStatus.value = '推送 ' + result.pushed + ' 条 · 拉取 ' + result.pulled + ' 条 · 删除 ' + result.removed + ' 条'
|
||||
showToast('同步完成')
|
||||
} catch (e) {
|
||||
syncError.value = e instanceof Error ? e.message : '同步失败'
|
||||
}
|
||||
syncing.value = false
|
||||
}
|
||||
|
||||
async function doChangeLoginPwd() {
|
||||
accError.value = ''
|
||||
@@ -64,6 +113,7 @@ async function load() {
|
||||
aiBase.value = await getSetting('ai_base_url', '')
|
||||
aiKey.value = await getSetting('ai_api_key', '')
|
||||
aiModel.value = await getSetting('ai_model', '')
|
||||
await loadSyncInfo()
|
||||
}
|
||||
|
||||
async function saveAi() {
|
||||
@@ -291,6 +341,31 @@ async function doImport() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card section">
|
||||
<div class="section-title">多端数据同步</div>
|
||||
<div class="muted acc-line">在自建同步服务端注册云端账号后,手机(PWA)、桌面与网页端登录同一账号即可互通数据;密码库以密文同步,主密码永不出本机</div>
|
||||
<div class="ai-grid">
|
||||
<div class="rule-item">
|
||||
<label>同步服务地址</label>
|
||||
<input class="input" v-model="syncBase" placeholder="如 http://124.221.192.176:3780" />
|
||||
</div>
|
||||
<div class="rule-item">
|
||||
<label>云端用户名</label>
|
||||
<input class="input" v-model="syncUser" placeholder="云端账号用户名" />
|
||||
</div>
|
||||
<div class="rule-item">
|
||||
<label>云端密码(至少 6 位)</label>
|
||||
<input type="password" class="input" v-model="syncPwd" placeholder="云端账号密码" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="rule-save">
|
||||
<button class="btn btn-sm" @click="registerCloud">注册云端账号</button>
|
||||
<button class="btn btn-primary btn-sm" :disabled="syncing" @click="doSyncNow">{{ syncing ? '同步中' : '立即同步' }}</button>
|
||||
</div>
|
||||
<div v-if="syncStatus" class="muted acc-line">上次同步:{{ lastSync || '--' }} · {{ syncStatus }}</div>
|
||||
<div v-if="syncError" class="form-error">{{ syncError }}</div>
|
||||
</div>
|
||||
|
||||
<div class="card section">
|
||||
<div class="section-title">AI 润色配置(可选)</div>
|
||||
<div class="ai-grid">
|
||||
|
||||
Reference in New Issue
Block a user