feat: 登录即自动绑定云端并同步,免手动配置
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 旧单机数据检测与迁移(迁移只复制,不删除原库,作为安全兜底)
|
// 旧单机数据检测与迁移(迁移只复制,不删除原库,作为安全兜底)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user