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:
toor
2026-10-22 15:52:54 +08:00
committed by trae-solo-agent
parent 7408591fac
commit 8ff7b879b9
54 changed files with 214 additions and 3049 deletions
@@ -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 -12
View File
@@ -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)
-127
View File
@@ -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,
}
}
-129
View File
@@ -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()
}