refactor: 个人自用,移除登录与云同步全链路
站点只给本人用,云乐坊 SSO 授权、收藏/历史云同步、获客归因和账号切换记账都没有收益,却被登录状态检查和同步失败提示持续打扰。 - 删除 useYunlefunAuth / yunlefunAuth / yunlefunSsoConfig / yunlefunAuth 适配器、useUserDataSync / userDataSync / userData 适配器、三个 client 插件与 /auth/callback 回调页 - 抽出 adapters/cloudbase.ts,让「反馈一下」和匿名筛选信号不再依赖登录态;cook-feedback 云函数保留 - 删除 cook-user-data 云函数与 cook_user_data 权限规则,移除 @yunlefun/sso 依赖 - 「我的」页删掉登录按钮、账号卡、四个统计格和退出登录;「关于」页删掉项目作者、致谢名单和上游宣传链接 - 本地收藏/历史与导出导入保留,删除同步专用的 lastUid 记账 取舍见 docs/decisions/0005-drop-login-and-sync.md
This commit is contained in:
@@ -4,7 +4,7 @@ import { namespace } from '~/constants'
|
||||
|
||||
export type { CustomRecipe }
|
||||
|
||||
// 和收藏、历史一样始终保存在本机;不参与云同步
|
||||
// 和收藏、历史一样始终保存在本机
|
||||
export const customRecipes = useStorage<CustomRecipe[]>(`${namespace}:custom-recipes`, [])
|
||||
|
||||
export function getCustomRecipe(customId: string) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { UserData, UserDataExportFile } from '~/utils/userData'
|
||||
import type { UserDataLocal } from '~/utils/userDataSync'
|
||||
import {
|
||||
createEmptyUserData,
|
||||
createUserDataExport,
|
||||
@@ -32,22 +31,12 @@ export function writeLocalUserData(data: UserData): void {
|
||||
}
|
||||
}
|
||||
|
||||
export const localUserData: UserDataLocal = {
|
||||
read: readLocalUserData,
|
||||
write: writeLocalUserData,
|
||||
readLastUid: () => userDataMeta.value.lastUid ?? '',
|
||||
writeLastUid: (uid) => {
|
||||
if (userDataMeta.value.lastUid !== uid)
|
||||
userDataMeta.value = { ...userDataMeta.value, lastUid: uid }
|
||||
},
|
||||
}
|
||||
|
||||
export function exportLocalUserData(now = Date.now()): UserDataExportFile {
|
||||
return createUserDataExport(mergeUserData(readLocalUserData(), createEmptyUserData(), now), now)
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an exported file into local data, using the same rules as cloud sync.
|
||||
* Merge an exported file into local data.
|
||||
* Returns null when the text is not a Cook export.
|
||||
*/
|
||||
export function importLocalUserData(text: string, now = Date.now()): { favorites: number, histories: number } | null {
|
||||
|
||||
@@ -3,21 +3,18 @@ import { useStorage } from '@vueuse/core'
|
||||
import { namespace } from '~/constants'
|
||||
|
||||
/**
|
||||
* Sync bookkeeping kept next to `cook:favorites` and `cook:history`, which stay
|
||||
* in their original format. Deletions are recorded even when logged out so they
|
||||
* can be applied to the cloud after the next login.
|
||||
* Deletion bookkeeping kept next to `cook:favorites` and `cook:history`, which
|
||||
* stay in their original format. Deletions are recorded so they survive an
|
||||
* export/import round trip instead of resurrecting removed records.
|
||||
*/
|
||||
export interface UserDataMeta {
|
||||
favoriteRemovals: FavoriteRemoval[]
|
||||
historyRemovals: HistoryRemoval[]
|
||||
historyClearedAt: number
|
||||
/** The account this device last synced with. */
|
||||
lastUid: string
|
||||
}
|
||||
|
||||
export const userDataMeta = useStorage<UserDataMeta>(`${namespace}:sync`, {
|
||||
favoriteRemovals: [],
|
||||
historyRemovals: [],
|
||||
historyClearedAt: 0,
|
||||
lastUid: '',
|
||||
}, undefined, { mergeDefaults: true })
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { CloudbaseFunctionCaller } from '~/adapters/cloudbase'
|
||||
import type { RecipeReport, SearchSignal } from '~/adapters/feedback'
|
||||
import { callCloudbaseFunction } from '~/adapters/cloudbase'
|
||||
import { createFeedbackRemote } from '~/adapters/feedback'
|
||||
import { getDeviceId, signalKey } from '~/utils/feedback'
|
||||
|
||||
@@ -9,8 +11,11 @@ const sentSignals = new Set<string>()
|
||||
* cloud function. Signals are best effort and never surface errors.
|
||||
*/
|
||||
export function useRecipeFeedback() {
|
||||
const auth = useYunlefunAuth()
|
||||
const remote = () => createFeedbackRemote(auth.callFunction, getDeviceId())
|
||||
const config = useRuntimeConfig()
|
||||
const env = config.public.yunlefunCloudbaseEnv
|
||||
const callFunction: CloudbaseFunctionCaller = (name, data) =>
|
||||
callCloudbaseFunction(env, name, data)
|
||||
const remote = () => createFeedbackRemote(callFunction, getDeviceId())
|
||||
|
||||
async function report(input: RecipeReport): Promise<void> {
|
||||
await remote().report(input)
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import type { UserDataLocal, UserDataSyncScheduler } from '~/utils/userDataSync'
|
||||
import { createCloudbaseUserDataRemote } from '~/adapters/userData'
|
||||
import { favoriteEntries } from '~/composables/store/favorite'
|
||||
import { recipeHistories } from '~/composables/store/history'
|
||||
import { localUserData } from '~/composables/store/userData'
|
||||
import { userDataMeta } from '~/composables/store/userDataMeta'
|
||||
import {
|
||||
createUserDataSyncScheduler,
|
||||
syncUserData,
|
||||
UserDataSyncError,
|
||||
} from '~/utils/userDataSync'
|
||||
|
||||
export type UserDataSyncStatus = 'idle' | 'syncing' | 'synced' | 'error'
|
||||
|
||||
interface UserDataSyncState {
|
||||
errorMessage: string
|
||||
lastSyncedAt: number
|
||||
status: UserDataSyncStatus
|
||||
}
|
||||
|
||||
/** Wait this long after a local change before pushing it. */
|
||||
const LOCAL_CHANGE_DELAY = 2000
|
||||
|
||||
let scheduler: UserDataSyncScheduler | undefined
|
||||
|
||||
/**
|
||||
* Keeps favorites and history in sync with the signed-in 云乐坊 account.
|
||||
* Logged out, nothing runs and local data behaves as before.
|
||||
*/
|
||||
export function useUserDataSync() {
|
||||
const state = useState<UserDataSyncState>('cook:user-data-sync', () => ({
|
||||
errorMessage: '',
|
||||
lastSyncedAt: 0,
|
||||
status: 'idle',
|
||||
}))
|
||||
|
||||
function install(): void {
|
||||
if (!import.meta.client || scheduler)
|
||||
return
|
||||
|
||||
const auth = useYunlefunAuth()
|
||||
const uid = computed(() => auth.isAuthenticated.value ? auth.account.value?.uid ?? '' : '')
|
||||
const remote = createCloudbaseUserDataRemote(auth.callFunction)
|
||||
|
||||
// Local writes made by a sync round must not schedule another round.
|
||||
let applyingRemote = false
|
||||
const local: UserDataLocal = {
|
||||
...localUserData,
|
||||
write: (data) => {
|
||||
applyingRemote = true
|
||||
try {
|
||||
localUserData.write(data)
|
||||
}
|
||||
finally {
|
||||
applyingRemote = false
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const activeScheduler = createUserDataSyncScheduler({
|
||||
run: async () => {
|
||||
const current = uid.value
|
||||
if (!current)
|
||||
return
|
||||
state.value = { ...state.value, errorMessage: '', status: 'syncing' }
|
||||
await syncUserData(current, local, remote)
|
||||
if (uid.value === current)
|
||||
state.value = { errorMessage: '', lastSyncedAt: Date.now(), status: 'synced' }
|
||||
},
|
||||
onError: (error) => {
|
||||
if (!uid.value)
|
||||
return
|
||||
state.value = {
|
||||
...state.value,
|
||||
errorMessage: error instanceof UserDataSyncError ? error.message : '云端同步失败',
|
||||
status: 'error',
|
||||
}
|
||||
},
|
||||
shouldRetry: error => !(error instanceof UserDataSyncError && error.code === 'invalid_remote'),
|
||||
})
|
||||
scheduler = activeScheduler
|
||||
|
||||
watch(uid, (current) => {
|
||||
if (current) {
|
||||
activeScheduler.request()
|
||||
return
|
||||
}
|
||||
activeScheduler.stop()
|
||||
state.value = { errorMessage: '', lastSyncedAt: 0, status: 'idle' }
|
||||
}, { immediate: true })
|
||||
|
||||
watch(
|
||||
() => [
|
||||
favoriteEntries.value,
|
||||
recipeHistories.value,
|
||||
userDataMeta.value.favoriteRemovals,
|
||||
userDataMeta.value.historyRemovals,
|
||||
userDataMeta.value.historyClearedAt,
|
||||
],
|
||||
() => {
|
||||
if (!applyingRemote && uid.value)
|
||||
activeScheduler.request(LOCAL_CHANGE_DELAY)
|
||||
},
|
||||
{ deep: true, flush: 'sync' },
|
||||
)
|
||||
|
||||
// Pull changes made on other devices when coming back to the app.
|
||||
const pull = () => {
|
||||
if (uid.value && document.visibilityState === 'visible')
|
||||
activeScheduler.request()
|
||||
}
|
||||
document.addEventListener('visibilitychange', pull)
|
||||
window.addEventListener('online', pull)
|
||||
}
|
||||
|
||||
function syncNow(): void {
|
||||
scheduler?.request()
|
||||
}
|
||||
|
||||
return {
|
||||
errorMessage: computed(() => state.value.errorMessage),
|
||||
install,
|
||||
lastSyncedAt: computed(() => state.value.lastSyncedAt),
|
||||
status: computed(() => state.value.status),
|
||||
syncNow,
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import type {
|
||||
YunlefunAuthController,
|
||||
YunlefunAuthSnapshot,
|
||||
} from '~/utils/yunlefunAuth'
|
||||
import {
|
||||
callCloudbaseFunction,
|
||||
createCloudbaseIdentityAdapter,
|
||||
createWebYunlefunAuthorizationAdapter,
|
||||
} from '~/adapters/yunlefunAuth'
|
||||
import { createYunlefunAuthController } from '~/utils/yunlefunAuth'
|
||||
import { resolveYunlefunSsoConfig } from '~/utils/yunlefunSsoConfig'
|
||||
|
||||
interface YunlefunRuntimeConfig {
|
||||
public: {
|
||||
yunlefunCloudbaseEnv?: string
|
||||
yunlefunSsoClientId?: string
|
||||
yunlefunSsoExchangeUrl?: string
|
||||
yunlefunSsoOrigin?: string
|
||||
yunlefunSsoRedirectUri?: string
|
||||
yunlefunSsoRedirectUris?: string
|
||||
yunlefunSsoScope?: string
|
||||
}
|
||||
}
|
||||
|
||||
let controller: YunlefunAuthController | undefined
|
||||
let cloudbaseEnv = ''
|
||||
let initialization: Promise<void> | undefined
|
||||
|
||||
export function useYunlefunAuth() {
|
||||
const runtimeConfig = useRuntimeConfig() as unknown as YunlefunRuntimeConfig
|
||||
const snapshot = useState<YunlefunAuthSnapshot>('yunlefun:auth:snapshot', () => ({
|
||||
account: null,
|
||||
errorMessage: '',
|
||||
status: 'idle',
|
||||
}))
|
||||
const configurationMessage = useState<string>('yunlefun:auth:configuration-message', () => '')
|
||||
const isConfigured = computed(() => !configurationMessage.value)
|
||||
const isAuthenticated = computed(() => snapshot.value.status === 'signed-in' && Boolean(snapshot.value.account))
|
||||
|
||||
async function initialize(): Promise<void> {
|
||||
if (!initialization)
|
||||
initialization = initializeOnce(runtimeConfig, snapshot, configurationMessage)
|
||||
await initialization
|
||||
publishSnapshot(snapshot)
|
||||
}
|
||||
|
||||
async function signIn(): Promise<void> {
|
||||
if (!controller || !isConfigured.value)
|
||||
return
|
||||
await controller.signIn()
|
||||
publishSnapshot(snapshot)
|
||||
}
|
||||
|
||||
async function signOut(): Promise<void> {
|
||||
if (!controller)
|
||||
return
|
||||
await controller.signOut()
|
||||
publishSnapshot(snapshot)
|
||||
}
|
||||
|
||||
/** Call a CloudBase cloud function as the signed-in account. */
|
||||
async function callFunction(name: string, data: Record<string, unknown>): Promise<unknown> {
|
||||
if (!cloudbaseEnv)
|
||||
throw new Error('CloudBase is not configured')
|
||||
return callCloudbaseFunction(cloudbaseEnv, name, data)
|
||||
}
|
||||
|
||||
return {
|
||||
account: computed(() => snapshot.value.account),
|
||||
configurationMessage: readonly(configurationMessage),
|
||||
errorMessage: computed(() => snapshot.value.errorMessage),
|
||||
callFunction,
|
||||
initialize,
|
||||
isAuthenticated,
|
||||
isConfigured,
|
||||
signIn,
|
||||
signOut,
|
||||
status: computed(() => snapshot.value.status),
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeOnce(
|
||||
runtimeConfig: YunlefunRuntimeConfig,
|
||||
snapshot: Ref<YunlefunAuthSnapshot>,
|
||||
configurationMessage: Ref<string>,
|
||||
): Promise<void> {
|
||||
if (!import.meta.client)
|
||||
return
|
||||
|
||||
const result = resolveYunlefunSsoConfig({
|
||||
clientId: runtimeConfig.public.yunlefunSsoClientId,
|
||||
cloudbaseEnv: runtimeConfig.public.yunlefunCloudbaseEnv,
|
||||
exchangeUrl: runtimeConfig.public.yunlefunSsoExchangeUrl,
|
||||
redirectUri: runtimeConfig.public.yunlefunSsoRedirectUri,
|
||||
redirectUris: runtimeConfig.public.yunlefunSsoRedirectUris,
|
||||
scope: runtimeConfig.public.yunlefunSsoScope,
|
||||
ssoOrigin: runtimeConfig.public.yunlefunSsoOrigin,
|
||||
}, {
|
||||
currentOrigin: window.location.origin,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
configurationMessage.value = result.message
|
||||
snapshot.value = {
|
||||
account: null,
|
||||
errorMessage: '',
|
||||
status: 'signed-out',
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
cloudbaseEnv = result.config.cloudbaseEnv
|
||||
controller = createYunlefunAuthController({
|
||||
identity: createCloudbaseIdentityAdapter(result.config),
|
||||
web: createWebYunlefunAuthorizationAdapter(result.config),
|
||||
})
|
||||
|
||||
await controller.initialize()
|
||||
publishSnapshot(snapshot)
|
||||
if (window.location.pathname === '/auth/callback'
|
||||
&& controller.snapshot().status === 'signed-in') {
|
||||
await navigateTo('/my', { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
function publishSnapshot(snapshot: Ref<YunlefunAuthSnapshot>): void {
|
||||
if (controller)
|
||||
snapshot.value = controller.snapshot()
|
||||
}
|
||||
Reference in New Issue
Block a user