From acfb0cfdd29cf86a3322ff1d5c479692135d3887 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 16 Oct 2026 12:00:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=99=BB=E5=BD=95=E5=8D=B3=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E7=BB=91=E5=AE=9A=E4=BA=91=E7=AB=AF=E5=B9=B6=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=EF=BC=8C=E5=85=8D=E6=89=8B=E5=8A=A8=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- intern-log/src/App.vue | 6 ++- intern-log/src/db/auth.ts | 22 +++++++++ intern-log/src/sync/engine.ts | 65 +++++++++++++++++++-------- intern-log/src/views/LoginView.vue | 2 +- intern-log/src/views/SettingsView.vue | 47 +++++++------------ 5 files changed, 91 insertions(+), 51 deletions(-) diff --git a/intern-log/src/App.vue b/intern-log/src/App.vue index 7c473ba..76e05aa 100644 --- a/intern-log/src/App.vue +++ b/intern-log/src/App.vue @@ -11,7 +11,7 @@ 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 { autoSyncNow, enableRealtimeSync, stopRealtime } from './sync/engine' +import { autoSyncNow, enableRealtimeSync, ensureCloudBinding, stopRealtime } 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' @@ -29,6 +29,10 @@ async function onLogin(s: Session) { await refreshClock() await refreshTip() startSyncTimer() + try { + await ensureCloudBinding() + await autoSyncNow() + } catch { /* 云端不可达时不阻塞登录,稍后自动重试 */ } showToast('欢迎,' + s.username) const mt = sessionStorage.getItem('dsh_migrate_toast') if (mt) { diff --git a/intern-log/src/db/auth.ts b/intern-log/src/db/auth.ts index a4f0845..3e1e1c9 100644 --- a/intern-log/src/db/auth.ts +++ b/intern-log/src/db/auth.ts @@ -49,6 +49,24 @@ function clearSession(): void { localStorage.removeItem(SESSION_KEY) } +// 云端凭据:与本地登录同账号同密码,用于登录后自动云同步(保存在本机浏览器) +const CREDS_KEY = 'dsh_cloud_creds' +export function setCloudCreds(username: string, password: string): void { + localStorage.setItem(CREDS_KEY, JSON.stringify({ username, password })) +} +export function getCloudCreds(): { username: string; password: string } | null { + try { + const raw = localStorage.getItem(CREDS_KEY) + if (!raw) return null + const c = JSON.parse(raw) + if (c && c.username && c.password) return c as { username: string; password: string } + } catch { /* 忽略损坏数据 */ } + return null +} +export function clearCloudCreds(): void { + localStorage.removeItem(CREDS_KEY) +} + export function openUserDatabase(userId: string): void { bindDatabase(new WorklogDB(LEGACY_DB_NAME + '_' + userId)) } @@ -71,6 +89,7 @@ export async function createUser(username: string, password: string, migrateLega } const session: Session = { userId: id, username: name } setSession(session) + setCloudCreds(session.username, password) if (copied) showToastCopied(copied) return session } @@ -84,6 +103,7 @@ export async function login(username: string, password: string): Promise { lockVault() unbindDatabase() clearSession() + clearCloudCreds() } export async function changeLoginPassword(oldPwd: string, newPwd: string): Promise { @@ -114,6 +135,7 @@ export async function deleteCurrentUser(): Promise { await Dexie.delete(LEGACY_DB_NAME + '_' + s.userId) await registry.user.delete(s.userId) clearSession() + clearCloudCreds() } // 旧单机数据检测与迁移(迁移只复制,不删除原库,作为安全兜底) diff --git a/intern-log/src/sync/engine.ts b/intern-log/src/sync/engine.ts index 992e753..e23469f 100644 --- a/intern-log/src/sync/engine.ts +++ b/intern-log/src/sync/engine.ts @@ -2,8 +2,11 @@ // 实时性:本地变更 3 秒内推送;服务端变更通过 SSE 秒级通知其他端;60 秒轮询兜底 import { db } from '../db/schema' import { getSetting, setSetting } from '../db/repo' +import { getCloudCreds } from '../db/auth' import { nowLocalISO } from '../lib/date' +export const DEFAULT_SYNC_BASE = 'https://daywk.linyux.cn' + const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'transaction'] // 本地敏感或临时性的设置不参与同步 @@ -63,13 +66,45 @@ export interface SyncConfig { password: string } +// 同步基址:设置页可覆盖,默认使用独立子域名 +export async function getSyncBase(): Promise { + return (await getSetting('sync_base_url', '')) || DEFAULT_SYNC_BASE +} + +async function loadSyncConfig(): Promise { + const creds = getCloudCreds() + if (!creds) throw new Error('未登录,无法同步') + return { baseUrl: await getSyncBase(), username: creds.username, password: creds.password } +} + +// 使用当前登录凭据执行同步(供设置页与自动同步调用) +export async function syncWithStoredConfig(): Promise { + return runSync(await loadSyncConfig()) +} + +// 登录后自动绑定云端账号:云端不存在则注册,同名则校验密码一致 +export async function ensureCloudBinding(): Promise { + const creds = getCloudCreds() + if (!creds) throw new Error('未登录') + const base = await getSyncBase() + const headers = { 'Content-Type': 'application/json' } + const body = JSON.stringify({ username: creds.username, password: creds.password }) + let resp = await fetch(base + '/api/auth/register', { method: 'POST', headers, body }) + if (resp.status === 409) { + resp = await fetch(base + '/api/auth/login', { method: 'POST', headers, body }) + if (!resp.ok) throw new Error('云端已存在同名账号且密码不一致,请检查') + } else if (!resp.ok) { + throw new Error('云端绑定失败:' + resp.status) + } +} + +export function getCloudUser(): string | null { + const c = getCloudCreds() + return c ? c.username : null +} + export async function isSyncConfigured(): Promise { - 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()) + return getCloudCreds() != null } export async function getSyncLastTime(): Promise { @@ -135,12 +170,7 @@ export async function autoSyncNow(): Promise { } try { if (!(await isSyncConfigured())) return - const values = await Promise.all([ - getSetting('sync_base_url', ''), - getSetting('sync_username', ''), - getSetting('sync_password', '') - ]) - await runSync({ baseUrl: values[0], username: values[1], password: values[2] }) + await runSync(await loadSyncConfig()) } catch { /* 自动同步静默失败,等待下次触发 */ } } @@ -234,20 +264,17 @@ async function doRunSync(config: SyncConfig): Promise { // 2. 认证并与服务端交换(token 失效自动重登一次) const token = await ensureToken(base, config) - let resp = await fetch(base + '/api/sync', { + const doPush = (tk: string) => fetch(base + '/api/sync', { method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token }, + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + tk }, body: JSON.stringify({ device: 'client', tables: pushTables, tombstones }) }) + let resp = await doPush(token) if (resp.status === 401) { await setSetting('sync_token', '') const token2 = await loginCloud(base, config) await setSetting('sync_token', token2) - resp = await fetch(base + '/api/sync', { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token2 }, - body: JSON.stringify({ device: 'client', tables: pushTables, tombstones }) - }) + resp = await doPush(token2) } if (!resp.ok) throw new Error('同步服务返回 ' + resp.status) const data = await resp.json() diff --git a/intern-log/src/views/LoginView.vue b/intern-log/src/views/LoginView.vue index 42ea942..1d2abd4 100644 --- a/intern-log/src/views/LoginView.vue +++ b/intern-log/src/views/LoginView.vue @@ -78,7 +78,7 @@ async function submit() { {{ mode === 'login' ? '登录' : '创建并进入' }} diff --git a/intern-log/src/views/SettingsView.vue b/intern-log/src/views/SettingsView.vue index 39cb068..0bf073a 100644 --- a/intern-log/src/views/SettingsView.vue +++ b/intern-log/src/views/SettingsView.vue @@ -13,7 +13,8 @@ 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 { ensureCloudBinding, getSyncBase, getSyncLastTime, syncWithStoredConfig, type SyncResult } from '../sync/engine' +import { getCloudCreds } from '../db/auth' import { showToast } from '../lib/toast' const rule = ref({ work_start: '09:00', work_end: '18:00', grace_minutes: 10, lunch_minutes: 60, remind_weekly: 'friday' }) @@ -36,35 +37,30 @@ const newLoginPwd2 = ref('') const accError = ref('') const deleteAccConfirm = ref(false) const syncBase = ref('') -const syncUser = ref('') -const syncPwd = ref('') +const cloudUser = 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', '') + syncBase.value = await getSyncBase() + cloudUser.value = getCloudCreds()?.username || '' 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('同步配置已保存') + showToast('同步地址已保存') } -async function registerCloud() { +async function rebindCloud() { syncError.value = '' try { - await registerCloudAccount(syncBase.value, syncUser.value, syncPwd.value) - await saveSyncConfig() - showToast('云端账号已注册,可直接同步') + await ensureCloudBinding() + showToast('云端绑定已重建') } catch (e) { - syncError.value = e instanceof Error ? e.message : '注册失败' + syncError.value = e instanceof Error ? e.message : '绑定失败' } } @@ -74,7 +70,7 @@ async function doSyncNow() { syncing.value = true try { await saveSyncConfig() - const result: SyncResult = await runSync({ baseUrl: syncBase.value, username: syncUser.value, password: syncPwd.value }) + const result: SyncResult = await syncWithStoredConfig() lastSync.value = result.at syncStatus.value = '推送 ' + result.pushed + ' 条 · 拉取 ' + result.pulled + ' 条 · 删除 ' + result.removed + ' 条' showToast('同步完成') @@ -343,24 +339,15 @@ async function doImport() {
多端数据同步
-
在自建同步服务端注册云端账号后,手机(PWA)、桌面与网页端登录同一账号即可互通数据;密码库以密文同步,主密码永不出本机
-
-
- - -
-
- - -
-
- - -
+
云端账号:{{ cloudUser || '未绑定' }} · 登录即自动绑定并同步(变更实时推送 + 每 5 分钟兜底);密码库以密文同步,主密码永不出本机
+
+ +
- + +
上次同步:{{ lastSync || '--' }} · {{ syncStatus }}
{{ syncError }}