feat: 登录即自动绑定云端并同步,免手动配置

This commit is contained in:
root
2026-10-16 12:00:50 +08:00
parent 8108f60825
commit acfb0cfdd2
5 changed files with 91 additions and 51 deletions
+5 -1
View File
@@ -11,7 +11,7 @@ import VaultView from './views/VaultView.vue'
import SettingsView from './views/SettingsView.vue' import SettingsView from './views/SettingsView.vue'
import LoginView from './views/LoginView.vue' import LoginView from './views/LoginView.vue'
import { getSession, logoutUser, openUserDatabase, type Session } from './db/auth' 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 { clockIn, getAttendance, getWorkRule, getWeeklyNote, searchLogItems, searchMemos, smartClockOut, type WorkRule } from './db/repo'
import { addDays, hhmm, todayStr, weekStart } from './lib/date' import { addDays, hhmm, todayStr, weekStart } from './lib/date'
import { useToasts } from './lib/toast' import { useToasts } from './lib/toast'
@@ -29,6 +29,10 @@ async function onLogin(s: Session) {
await refreshClock() await refreshClock()
await refreshTip() await refreshTip()
startSyncTimer() startSyncTimer()
try {
await ensureCloudBinding()
await autoSyncNow()
} catch { /* 云端不可达时不阻塞登录,稍后自动重试 */ }
showToast('欢迎,' + s.username) showToast('欢迎,' + s.username)
const mt = sessionStorage.getItem('dsh_migrate_toast') const mt = sessionStorage.getItem('dsh_migrate_toast')
if (mt) { if (mt) {
+22
View File
@@ -49,6 +49,24 @@ function clearSession(): void {
localStorage.removeItem(SESSION_KEY) 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 { export function openUserDatabase(userId: string): void {
bindDatabase(new WorklogDB(LEGACY_DB_NAME + '_' + userId)) 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 } const session: Session = { userId: id, username: name }
setSession(session) setSession(session)
setCloudCreds(session.username, password)
if (copied) showToastCopied(copied) if (copied) showToastCopied(copied)
return session return session
} }
@@ -84,6 +103,7 @@ export async function login(username: string, password: string): Promise<Session
openUserDatabase(rec.id) openUserDatabase(rec.id)
const session: Session = { userId: rec.id, username: rec.username } const session: Session = { userId: rec.id, username: rec.username }
setSession(session) setSession(session)
setCloudCreds(session.username, password)
return session return session
} }
@@ -91,6 +111,7 @@ export async function logoutUser(): Promise<void> {
lockVault() lockVault()
unbindDatabase() unbindDatabase()
clearSession() clearSession()
clearCloudCreds()
} }
export async function changeLoginPassword(oldPwd: string, newPwd: string): Promise<void> { export async function changeLoginPassword(oldPwd: string, newPwd: string): Promise<void> {
@@ -114,6 +135,7 @@ export async function deleteCurrentUser(): Promise<void> {
await Dexie.delete(LEGACY_DB_NAME + '_' + s.userId) await Dexie.delete(LEGACY_DB_NAME + '_' + s.userId)
await registry.user.delete(s.userId) await registry.user.delete(s.userId)
clearSession() clearSession()
clearCloudCreds()
} }
// 旧单机数据检测与迁移(迁移只复制,不删除原库,作为安全兜底) // 旧单机数据检测与迁移(迁移只复制,不删除原库,作为安全兜底)
+46 -19
View File
@@ -2,8 +2,11 @@
// 实时性:本地变更 3 秒内推送;服务端变更通过 SSE 秒级通知其他端;60 秒轮询兜底 // 实时性:本地变更 3 秒内推送;服务端变更通过 SSE 秒级通知其他端;60 秒轮询兜底
import { db } from '../db/schema' import { db } from '../db/schema'
import { getSetting, setSetting } from '../db/repo' import { getSetting, setSetting } from '../db/repo'
import { getCloudCreds } from '../db/auth'
import { nowLocalISO } from '../lib/date' 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'] const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'transaction']
// 本地敏感或临时性的设置不参与同步 // 本地敏感或临时性的设置不参与同步
@@ -63,13 +66,45 @@ export interface SyncConfig {
password: string password: string
} }
// 同步基址:设置页可覆盖,默认使用独立子域名
export async function getSyncBase(): Promise<string> {
return (await getSetting('sync_base_url', '')) || DEFAULT_SYNC_BASE
}
async function loadSyncConfig(): Promise<SyncConfig> {
const creds = getCloudCreds()
if (!creds) throw new Error('未登录,无法同步')
return { baseUrl: await getSyncBase(), username: creds.username, password: creds.password }
}
// 使用当前登录凭据执行同步(供设置页与自动同步调用)
export async function syncWithStoredConfig(): Promise<SyncResult> {
return runSync(await loadSyncConfig())
}
// 登录后自动绑定云端账号:云端不存在则注册,同名则校验密码一致
export async function ensureCloudBinding(): Promise<void> {
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<boolean> { export async function isSyncConfigured(): Promise<boolean> {
const values = await Promise.all([ return getCloudCreds() != null
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> { export async function getSyncLastTime(): Promise<string> {
@@ -135,12 +170,7 @@ export async function autoSyncNow(): Promise<void> {
} }
try { try {
if (!(await isSyncConfigured())) return if (!(await isSyncConfigured())) return
const values = await Promise.all([ await runSync(await loadSyncConfig())
getSetting('sync_base_url', ''),
getSetting('sync_username', ''),
getSetting('sync_password', '')
])
await runSync({ baseUrl: values[0], username: values[1], password: values[2] })
} catch { /* 自动同步静默失败,等待下次触发 */ } } catch { /* 自动同步静默失败,等待下次触发 */ }
} }
@@ -234,20 +264,17 @@ async function doRunSync(config: SyncConfig): Promise<SyncResult> {
// 2. 认证并与服务端交换(token 失效自动重登一次) // 2. 认证并与服务端交换(token 失效自动重登一次)
const token = await ensureToken(base, config) const token = await ensureToken(base, config)
let resp = await fetch(base + '/api/sync', { const doPush = (tk: string) => fetch(base + '/api/sync', {
method: 'POST', 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 }) body: JSON.stringify({ device: 'client', tables: pushTables, tombstones })
}) })
let resp = await doPush(token)
if (resp.status === 401) { if (resp.status === 401) {
await setSetting('sync_token', '') await setSetting('sync_token', '')
const token2 = await loginCloud(base, config) const token2 = await loginCloud(base, config)
await setSetting('sync_token', token2) await setSetting('sync_token', token2)
resp = await fetch(base + '/api/sync', { resp = await doPush(token2)
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token2 },
body: JSON.stringify({ device: 'client', tables: pushTables, tombstones })
})
} }
if (!resp.ok) throw new Error('同步服务返回 ' + resp.status) if (!resp.ok) throw new Error('同步服务返回 ' + resp.status)
const data = await resp.json() const data = await resp.json()
+1 -1
View File
@@ -78,7 +78,7 @@ async function submit() {
{{ mode === 'login' ? '登录' : '创建并进入' }} {{ mode === 'login' ? '登录' : '创建并进入' }}
</button> </button>
<div class="muted login-note"> <div class="muted login-note">
登录密码仅在本机用于身份校验,系统只保存密码的 PBKDF2 哈希;忘记密码将无法访问该用户数据 登录后自动开启云端同步(密码会保存在本机用于云同步);系统只保存密码哈希,忘记密码将无法访问该用户数据
</div> </div>
</div> </div>
</div> </div>
+17 -30
View File
@@ -13,7 +13,8 @@ import { downloadCsv } from '../lib/csv'
import { hhmm, parseDateStr, todayStr } from '../lib/date' import { hhmm, parseDateStr, todayStr } from '../lib/date'
import { computeDay } from '../domain/attendance' import { computeDay } from '../domain/attendance'
import { changeLoginPassword, deleteCurrentUser, getSession } from '../db/auth' 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' 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' }) const rule = ref<WorkRule>({ 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 accError = ref('')
const deleteAccConfirm = ref(false) const deleteAccConfirm = ref(false)
const syncBase = ref('') const syncBase = ref('')
const syncUser = ref('') const cloudUser = ref('')
const syncPwd = ref('')
const syncStatus = ref('') const syncStatus = ref('')
const syncError = ref('') const syncError = ref('')
const syncing = ref(false) const syncing = ref(false)
const lastSync = ref('') const lastSync = ref('')
async function loadSyncInfo() { async function loadSyncInfo() {
syncBase.value = await getSetting('sync_base_url', '') syncBase.value = await getSyncBase()
syncUser.value = await getSetting('sync_username', '') cloudUser.value = getCloudCreds()?.username || ''
syncPwd.value = await getSetting('sync_password', '')
lastSync.value = await getSyncLastTime() lastSync.value = await getSyncLastTime()
} }
async function saveSyncConfig() { async function saveSyncConfig() {
await setSetting('sync_base_url', syncBase.value.trim()) await setSetting('sync_base_url', syncBase.value.trim())
await setSetting('sync_username', syncUser.value.trim()) showToast('同步地址已保存')
await setSetting('sync_password', syncPwd.value)
showToast('同步配置已保存')
} }
async function registerCloud() { async function rebindCloud() {
syncError.value = '' syncError.value = ''
try { try {
await registerCloudAccount(syncBase.value, syncUser.value, syncPwd.value) await ensureCloudBinding()
await saveSyncConfig() showToast('云端绑定已重建')
showToast('云端账号已注册,可直接同步')
} catch (e) { } 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 syncing.value = true
try { try {
await saveSyncConfig() 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 lastSync.value = result.at
syncStatus.value = '推送 ' + result.pushed + ' 条 · 拉取 ' + result.pulled + ' 条 · 删除 ' + result.removed + ' 条' syncStatus.value = '推送 ' + result.pushed + ' 条 · 拉取 ' + result.pulled + ' 条 · 删除 ' + result.removed + ' 条'
showToast('同步完成') showToast('同步完成')
@@ -343,24 +339,15 @@ async function doImport() {
<div class="card section"> <div class="card section">
<div class="section-title">多端数据同步</div> <div class="section-title">多端数据同步</div>
<div class="muted acc-line">在自建同步服务端注册云端账号后,手机(PWA)、桌面与网页端登录同一账号即可互通数据;密码库以密文同步,主密码永不出本机</div> <div class="muted acc-line">云端账号:{{ cloudUser || '未绑定' }} · 登录即自动绑定并同步(变更实时推送 + 每 5 分钟兜底);密码库以密文同步,主密码永不出本机</div>
<div class="ai-grid"> <div class="rule-item">
<div class="rule-item"> <label>同步服务地址(默认即可)</label>
<label>同步服务地址</label> <input class="input" v-model="syncBase" placeholder="https://daywk.linyux.cn" />
<input class="input" v-model="syncBase" placeholder="如 https://daywk.linyux.cn" />
</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>
<div class="rule-save"> <div class="rule-save">
<button class="btn btn-sm" @click="registerCloud">注册云端账号</button> <button class="btn btn-sm" @click="saveSyncConfig">保存地址</button>
<button class="btn btn-primary btn-sm" :disabled="syncing" @click="doSyncNow">{{ syncing ? '同步中' : '立即同步' }}</button> <button class="btn btn-primary btn-sm" :disabled="syncing" @click="doSyncNow">{{ syncing ? '同步中' : '立即同步' }}</button>
<button class="btn btn-sm" @click="rebindCloud">重建云端绑定</button>
</div> </div>
<div v-if="syncStatus" class="muted acc-line">上次同步:{{ lastSync || '--' }} · {{ syncStatus }}</div> <div v-if="syncStatus" class="muted acc-line">上次同步:{{ lastSync || '--' }} · {{ syncStatus }}</div>
<div v-if="syncError" class="form-error">{{ syncError }}</div> <div v-if="syncError" class="form-error">{{ syncError }}</div>