feat(web): avatar thumbnails with original preview, registration avatar, pure-JS crypto fallback for http [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful

This commit is contained in:
justme committed 2026-10-17 14:35:42 +08:00
1 parent 283196938e
commit d66c93fe98
36 files changed
+735 -60

No files matched your search

+23 -5
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand';
import { accountApi } from '@/core-bridge/account';
import type { AccountStatus } from '@/core-bridge/account';
import type { AvatarChange } from '@/core-bridge/protocol/account';
import { useChannels } from './channels';
import { useShares } from './shares';
import { useWorkspace } from './workspace';
@@ -12,7 +13,7 @@ import { loginWithPasskey, passkeyApi } from '@/core-bridge/passkeys';
interface AccountState extends AccountStatus {
busy: boolean;
hydrated: boolean;
login: (username: string, password: string, create: boolean, remember?: boolean, displayName?: string) => Promise<void>;
login: (username: string, password: string, create: boolean, remember?: boolean, displayName?: string, avatar?: { avatar: string; thumb: string }) => Promise<void>;
passkeyLogin: (username: string, remember: boolean) => Promise<void>;
unlockPasskey: (password: string, remember: boolean) => Promise<void>;
cancelPasskey: () => Promise<void>;
@@ -20,7 +21,7 @@ interface AccountState extends AccountStatus {
logout: () => Promise<void>;
clearSavedLogin: () => Promise<void>;
sync: () => Promise<void>;
updatePersonal: (displayName: string, avatar?: string) => Promise<void>;
updatePersonal: (displayName: string, avatar?: AvatarChange) => Promise<void>;
}
let lastResourceRefresh = 0;
@@ -46,12 +47,12 @@ export const useAccount = create<AccountState>()((set, get) => ({
catch (error) { set({ busy: false, unlockUsername: undefined, error: error instanceof Error ? error.message : '账号解锁失败' }); throw error; }
},
cancelPasskey: async () => { await passkeyApi.cancel(); set({ unlockUsername: undefined, error: undefined }); },
login: async (username, password, create, remember = true, displayName = '') => {
login: async (username, password, create, remember = true, displayName = '', avatar) => {
set({ busy: true, error: undefined });
try {
let status = create ? await accountApi.register(username, password, remember) : await accountApi.login(username, password, remember);
// 注册时填的个人名称:账号已经建好,这一步失败不算注册失败,之后可在个人资料里补
if (create && displayName.trim()) status = await accountApi.updatePersonal(displayName.trim()).catch(() => status);
// 注册时填的个人名称和头像:账号已经建好,这一步失败不算注册失败,之后可在个人资料里补
if (create && (displayName.trim() || avatar)) status = await accountApi.updatePersonal(displayName.trim() || username, avatar).catch(() => status);
clearViews(); await refreshResources();
set({ ...status, busy: false, hydrated: true });
} catch (error) {
@@ -88,3 +89,20 @@ export const useAccount = create<AccountState>()((set, get) => ({
catch (error) { set({ error: error instanceof Error ? error.message : '资料保存失败', busy: false }); throw error; }
},
}));
// 头像不跟着账号状态一起传(动图有几 MB,状态每隔几秒就刷新一次):状态里只有 hasAvatar,头像在这里另取,
// 并且状态刷新后(personal 被换成没有头像的)按修改时间沿用已经取到的那张
let loadingAvatar = '';
useAccount.subscribe((state, previous) => {
const personal = state.personal;
if (!personal?.hasAvatar || personal.avatar || !state.username) return;
const known = previous.personal;
if (known?.avatar && known.modifiedAt === personal.modifiedAt) { useAccount.setState({ personal: { ...personal, avatar: known.avatar } }); return; }
const key = `${state.username}:${personal.modifiedAt}`;
if (loadingAvatar === key) return;
loadingAvatar = key;
void accountApi.avatar(state.username, personal.modifiedAt).then((avatar) => {
const now = useAccount.getState().personal;
if (avatar && now && now.modifiedAt === personal.modifiedAt && !now.avatar) useAccount.setState({ personal: { ...now, avatar } });
}).catch(() => undefined).finally(() => { if (loadingAvatar === key) loadingAvatar = ''; });
});
+1 -1
View File
@@ -13,7 +13,7 @@ describe('reduce', () => {
groups: [{ id: 'group-a', name: '项目', createdAt: 1 }],
folders: [{ id: 'folder-a', name: '目录', threadId: 'channel-a' }],
locations: [{ itemId: 'a', threadId: 'channel-a', folderId: 'folder-a', authorAccount: 'alice' }] },
people: [{ username: 'alice', displayName: '服务端名称', modifiedAt: 1 }] };
people: [{ username: 'alice', displayName: '服务端名称', hasAvatar: false, modifiedAt: 1 }] };
let state = reduce(initialData, stateEvent);
state = reduce(state, { t: 'item', item: { ...text('a', 1), threadId: 'old', author: { username: 'mallory', displayName: '伪造名称' } } });
expect(state.byId.a?.threadId).toBe('channel-a'); expect(state.byId.a?.folderId).toBe('folder-a');