From ba0b6c258a0a8bbbcbc92040e6cc1d4207be1009 Mon Sep 17 00:00:00 2001 From: JustMe Date: Sat, 17 Oct 2026 13:44:16 +0800 Subject: [PATCH] feat(web): space avatars with GIF support and live space list sync [CI] --- web/src/core-bridge/types.ts | 4 ++ .../core-bridge/worker/channelResources.ts | 1 + web/src/core-bridge/worker/wire.ts | 1 + .../channel/ChannelSettingsSheet.module.css | 34 +++++++++++++++ .../features/channel/ChannelSettingsSheet.tsx | 36 +++++++++++++--- web/src/features/channel/SpaceRail.module.css | 17 ++++++++ web/src/features/channel/SpaceRail.tsx | 3 +- web/src/features/home/AvatarEditor.module.css | 11 +++++ web/src/features/home/AvatarEditor.tsx | 43 +++++++++++++------ web/src/features/home/ProfileSettings.tsx | 12 +----- web/src/features/home/avatarGif.ts | 4 +- web/src/features/home/avatarSource.ts | 10 +++++ web/src/store/channels.ts | 3 ++ web/src/store/workspace.ts | 7 ++- 14 files changed, 153 insertions(+), 33 deletions(-) create mode 100644 web/src/features/home/avatarSource.ts diff --git a/web/src/core-bridge/types.ts b/web/src/core-bridge/types.ts index b77ec93..c7513ac 100644 --- a/web/src/core-bridge/types.ts +++ b/web/src/core-bridge/types.ts @@ -12,6 +12,8 @@ export interface ChannelInfo { id: string; /** 服务端 channel settings 中的共享名称,加入者可见。 */ name?: string; + /** 空间头像(data URL,可以是 GIF 动图) */ + avatar?: string; mode: ChannelMode; role: 'owner' | 'member'; maxMembers: number; @@ -51,6 +53,8 @@ export interface ChannelSettingsPatch { joinPolicy?: 'open' | 'invite'; joinPassword?: string; name?: string; + /** 空字符串表示移除头像 */ + avatar?: string; maxMembers?: number; membersCanUpload?: boolean; ttlSeconds?: number; diff --git a/web/src/core-bridge/worker/channelResources.ts b/web/src/core-bridge/worker/channelResources.ts index 33135c2..9fed6fe 100644 --- a/web/src/core-bridge/worker/channelResources.ts +++ b/web/src/core-bridge/worker/channelResources.ts @@ -152,6 +152,7 @@ export async function updateChannelSettings(resourceId: string, patch: ChannelSe if (!session.can('owner')) throw new Error('只有所有者可以修改空间设置'); const body: SettingsRequest = {}; if (patch.name !== undefined) body.name = patch.name.trim(); + if (patch.avatar !== undefined) body.avatar = patch.avatar; if (patch.maxMembers !== undefined) body.max_members = patch.maxMembers; if (patch.membersCanUpload !== undefined) body.members_can_upload = patch.membersCanUpload; if (patch.ttlSeconds !== undefined) body.ttl_seconds = patch.ttlSeconds; diff --git a/web/src/core-bridge/worker/wire.ts b/web/src/core-bridge/worker/wire.ts index 8ab8293..70a319e 100644 --- a/web/src/core-bridge/worker/wire.ts +++ b/web/src/core-bridge/worker/wire.ts @@ -10,6 +10,7 @@ export function toChannelInfo(raw: ChannelWire, saved: SavedResource): ChannelIn creatorAccount: raw.creator_account ?? undefined, joinPolicy, passwordRequired: raw.password_required, name: raw.settings.name ?? undefined, + avatar: raw.avatar ?? undefined, mode: raw.mode, role: raw.can_manage_structure ? 'owner' : 'member', canUpload: raw.can_upload, diff --git a/web/src/features/channel/ChannelSettingsSheet.module.css b/web/src/features/channel/ChannelSettingsSheet.module.css index fd618c6..e7e1c16 100644 --- a/web/src/features/channel/ChannelSettingsSheet.module.css +++ b/web/src/features/channel/ChannelSettingsSheet.module.css @@ -146,3 +146,37 @@ color: var(--danger); font-size: 13px; } + +.avatarRow { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.avatar { + display: grid; + flex: 0 0 64px; + place-items: center; + width: 64px; + height: 64px; + border-radius: var(--radius-lg); + background: var(--surface-2); + object-fit: cover; +} + +.avatarFallback { + color: var(--text); + font-size: 24px; + font-weight: 600; +} + +.avatarSide { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.avatarButtons { + display: flex; + gap: var(--space-2); +} diff --git a/web/src/features/channel/ChannelSettingsSheet.tsx b/web/src/features/channel/ChannelSettingsSheet.tsx index 6ffb662..c109607 100644 --- a/web/src/features/channel/ChannelSettingsSheet.tsx +++ b/web/src/features/channel/ChannelSettingsSheet.tsx @@ -1,8 +1,12 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; import type { ChannelInfo, ChannelSettingsPatch } from '@/core-bridge/types'; import { Button } from '@/ui/Button'; import { Icon, type IconName } from '@/ui/Icon'; import { Sheet } from '@/ui/Sheet'; +import { AvatarEditor } from '../home/AvatarEditor'; +import { checkAvatarSource } from '../home/avatarSource'; +import { errorMessage } from '@/core-bridge/errors'; +import { useToast } from '@/store/toast'; import { RolesEditor } from './RolesEditor'; import styles from './ChannelSettingsSheet.module.css'; @@ -42,6 +46,10 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server const [joinPassword, setJoinPassword] = useState(''); const [clearPassword, setClearPassword] = useState(false); const [tab, setTab] = useState('basic'); + const [avatar, setAvatar] = useState(channel.avatar); + const [editing, setEditing] = useState(); + const fileInput = useRef(null); + const spaceName = name.trim() || channel.name || '空间'; const [saving, setSaving] = useState(false); const [failed, setFailed] = useState(false); const ttlOptions = withCurrent(info.choices.ttl_seconds, ttlSeconds); @@ -52,7 +60,7 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server setSaving(true); setFailed(false); try { - await onSave({ name: name.trim(), maxMembers, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy, + await onSave({ name: name.trim(), ...(avatar !== channel.avatar ? { avatar: avatar ?? '' } : {}), maxMembers, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy, ...(clearPassword ? { joinPassword: '' } : joinPassword ? { joinPassword } : {}) }); onClose(); } catch { @@ -62,7 +70,13 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server } } - const dirty = name.trim() !== (channel.name ?? '') || maxMembers !== channel.maxMembers || membersCanUpload !== channel.membersCanUpload + async function chooseAvatar(file?: File) { + if (!file) return; + try { await checkAvatarSource(file); setEditing(file); } + catch (cause) { useToast.getState().push({ kind: 'error', text: errorMessage(cause) }); } + } + + const dirty = avatar !== channel.avatar || name.trim() !== (channel.name ?? '') || maxMembers !== channel.maxMembers || membersCanUpload !== channel.membersCanUpload || ttlSeconds !== channel.ttlSeconds || fileMaxDownloads !== channel.maxDownloads || joinPolicy !== channel.joinPolicy || joinPassword !== '' || clearPassword; const invalid = maxMembers < 1 || maxMembers > info.limits.max_members || ttlSeconds < info.limits.min_ttl_seconds || ttlSeconds > info.limits.max_ttl_seconds || Boolean(joinPassword && joinPassword.length < 8); @@ -74,8 +88,20 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server {item.label})}
-
item.id === tab)?.label}> + {editing ? setEditing(undefined)} onDone={(url) => { setAvatar(url); setEditing(undefined); }} /> + :
item.id === tab)?.label}> {tab === 'basic' ? <> +
+ {avatar ? 空间头像 : } +
+ { void chooseAvatar(event.target.files?.[0]); event.target.value = ''; }} /> +
+ + {avatar && owner ? : null} +
+ PNG、JPEG、WebP 或 GIF 动图,所有成员可见 +
+

空间名称和设置对加入者及服务端可见。

@@ -108,7 +134,7 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server

空间主及受邀用户无需加入密码。修改规则不会移除已加入成员。

: null} {tab === 'roles' ? : null} -
+
} {tab === 'roles' ? null : owner ? (
{failed ?

保存失败,请检查名称和设置后重试。

: null} diff --git a/web/src/features/channel/SpaceRail.module.css b/web/src/features/channel/SpaceRail.module.css index c9117ea..f174bb6 100644 --- a/web/src/features/channel/SpaceRail.module.css +++ b/web/src/features/channel/SpaceRail.module.css @@ -12,6 +12,8 @@ place-items: center; width: 44px; height: 44px; + padding: 0; + overflow: hidden; border: 0; border-radius: var(--radius-lg); background: var(--surface-2); @@ -92,3 +94,18 @@ height: 40px; } } + +.avatar { + display: block; + width: 100%; + height: 100%; + border-radius: inherit; + object-fit: cover; +} + +.headerAvatar { + width: 24px; + height: 24px; + border-radius: var(--radius-md); + object-fit: cover; +} diff --git a/web/src/features/channel/SpaceRail.tsx b/web/src/features/channel/SpaceRail.tsx index 1e425ca..64489aa 100644 --- a/web/src/features/channel/SpaceRail.tsx +++ b/web/src/features/channel/SpaceRail.tsx @@ -16,7 +16,7 @@ export function SpaceRail({ id, onCreate, onJoin }: RailProps) { const name = channel.name || channel.id.slice(0, 8); return ; })} @@ -30,6 +30,7 @@ export function SpaceHeader() { const name = channel?.name || `${channel?.id.slice(0, 8) ?? ''}…`; return
{channel?.mode === 'raw' ? 明文 : } + {channel?.avatar ? : null} {name}
; } diff --git a/web/src/features/home/AvatarEditor.module.css b/web/src/features/home/AvatarEditor.module.css index fadf1e5..8dee7cb 100644 --- a/web/src/features/home/AvatarEditor.module.css +++ b/web/src/features/home/AvatarEditor.module.css @@ -141,3 +141,14 @@ color: var(--text-dim); font-size: 12px; } + +/* 空间头像:方形取景框,四角用圆角压暗 */ +.maskSquare { + border-radius: var(--radius-lg); + background: none; + box-shadow: 0 0 0 999px var(--scrim); +} + +.rounded { + border-radius: var(--radius-lg); +} diff --git a/web/src/features/home/AvatarEditor.tsx b/web/src/features/home/AvatarEditor.tsx index 177fee9..2b81a77 100644 --- a/web/src/features/home/AvatarEditor.tsx +++ b/web/src/features/home/AvatarEditor.tsx @@ -10,7 +10,7 @@ interface View { zoom: number; x: number; y: number; quarter: number; fine: numb const FRESH: View = { zoom: 1, x: 0, y: 0, quarter: 0, fine: 0 }; /** 头像预览:同一份裁剪结果按实际使用的尺寸画出来 */ -function Preview({ image, crop, size }: { image: ImageBitmap; crop: Crop; size: number }) { +function Preview({ image, crop, size, rounded }: { image: ImageBitmap; crop: Crop; size: number; rounded?: boolean }) { const ref = useRef(null); useEffect(() => { const canvas = ref.current; @@ -19,10 +19,15 @@ function Preview({ image, crop, size }: { image: ImageBitmap; crop: Crop; size: canvas.width = canvas.height = Math.round(size * (window.devicePixelRatio || 1)); paintCrop(ctx, image, crop, VIEW, canvas.width); }, [image, crop, size]); - return

预览

-
- {image ? : } -

{name}刚刚

大家好~

-
-
- {image ? : } -
{name}在线
-
+ {space ? <> +
+ {image ? : } +
空间列表
+
+
+ {image ? : } +
{name}
+
+ : <> +
+ {image ? : } +

{name}刚刚

大家好~

+
+
+ {image ? : } +
{name}在线
+
+ }
diff --git a/web/src/features/home/ProfileSettings.tsx b/web/src/features/home/ProfileSettings.tsx index debb431..f03b26e 100644 --- a/web/src/features/home/ProfileSettings.tsx +++ b/web/src/features/home/ProfileSettings.tsx @@ -4,19 +4,9 @@ import { useAccount } from '@/store/account'; import { useToast } from '@/store/toast'; import { Button } from '@/ui/Button'; import { AvatarEditor } from './AvatarEditor'; +import { checkAvatarSource } from './avatarSource'; import styles from './AccountSettings.module.css'; -// 原图只做初步检查;裁剪后会重新编码为 256×256,体积由编辑器控制 -const MAX_SOURCE_BYTES = 20_000_000; - -async function checkAvatarSource(file: File): Promise { - if (!['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(file.type) || file.size > MAX_SOURCE_BYTES) throw new Error('请选择 20 MB 以内的 PNG、JPEG、WebP 或 GIF 图片'); - const bitmap = await createImageBitmap(file); - const valid = bitmap.width > 0 && bitmap.height > 0 && bitmap.width <= 8192 && bitmap.height <= 8192; - bitmap.close(); - if (!valid) throw new Error('图片宽高不能超过 8192 像素'); -} - export function ProfileSettings() { const personal = useAccount((s) => s.personal); const username = useAccount((s) => s.username) ?? ''; diff --git a/web/src/features/home/avatarGif.ts b/web/src/features/home/avatarGif.ts index b1eaf1b..9d4d9f7 100644 --- a/web/src/features/home/avatarGif.ts +++ b/web/src/features/home/avatarGif.ts @@ -77,12 +77,12 @@ function encode(frames: readonly GifFrame[], crop: Crop, from: number, size: num } /** 把每一帧按同样的裁剪导出成动图;默认 256 像素、保留每一帧;只有超过上限(约 4.5 MB)才逐步缩小尺寸、再隔帧 */ -export async function exportGifAvatar(frames: readonly GifFrame[], crop: Crop, from: number): Promise { +export async function exportGifAvatar(frames: readonly GifFrame[], crop: Crop, from: number, maxChars = MAX_GIF_DATA_URL): Promise { for (const [size, step] of [[256, 1], [192, 1], [128, 1], [192, 2], [128, 2], [96, 2]] as const) { // 让出线程,编辑器的「处理中」能先画出来 await new Promise((resolve) => setTimeout(resolve, 0)); const url = encode(frames, crop, from, size, step); - if (url.length <= MAX_GIF_DATA_URL) return url; + if (url.length <= maxChars) return url; } throw new Error('动图压缩后仍然过大,请换一张更短或更小的 GIF'); } diff --git a/web/src/features/home/avatarSource.ts b/web/src/features/home/avatarSource.ts new file mode 100644 index 0000000..acbdde0 --- /dev/null +++ b/web/src/features/home/avatarSource.ts @@ -0,0 +1,10 @@ +// 原图只做初步检查;裁剪后会重新编码,体积由编辑器控制 +const MAX_SOURCE_BYTES = 20_000_000; + +export async function checkAvatarSource(file: File): Promise { + if (!['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(file.type) || file.size > MAX_SOURCE_BYTES) throw new Error('请选择 20 MB 以内的 PNG、JPEG、WebP 或 GIF 图片'); + const bitmap = await createImageBitmap(file); + const valid = bitmap.width > 0 && bitmap.height > 0 && bitmap.width <= 8192 && bitmap.height <= 8192; + bitmap.close(); + if (!valid) throw new Error('图片宽高不能超过 8192 像素'); +} diff --git a/web/src/store/channels.ts b/web/src/store/channels.ts index 50d0951..b6dc2e2 100644 --- a/web/src/store/channels.ts +++ b/web/src/store/channels.ts @@ -16,6 +16,8 @@ interface ChannelsState { join: (link: ParsedChannelLink, password?: string) => Promise; hydrate: () => Promise; refresh: () => Promise; + /** 打开着的空间收到最新信息(名称、头像、人数等)时,同步到列表里的那一项 */ + patch: (info: ChannelInfo) => void; forget: (id: string) => Promise; setPendingLink: (link: ParsedChannelLink | null) => void; clearJustCreated: () => void; @@ -47,6 +49,7 @@ export const useChannels = create()((set, get) => ({ return hydration; }, refresh: async () => set({ list: await api.listSaved(), hydrated: true }), + patch: (info) => set((s) => (s.list.some((c) => c.id === info.id) ? { list: s.list.map((c) => (c.id === info.id ? { ...c, ...info, avatar: info.avatar } : c)) } : s)), forget: async (id) => { await api.forget(id); set((s) => ({ list: s.list.filter((c) => c.id !== id) })); diff --git a/web/src/store/workspace.ts b/web/src/store/workspace.ts index 02a032d..129f148 100644 --- a/web/src/store/workspace.ts +++ b/web/src/store/workspace.ts @@ -5,6 +5,7 @@ import type { FileItem, Item } from '@/core-bridge/types'; import { previewKindOf } from '@/lib/fileKind'; import { errorMessage } from '@/core-bridge/errors'; import { useAccount } from './account'; +import { useChannels } from './channels'; import { initialData, reduce, withoutItem, type WorkspaceData } from './reduce'; /** 乐观发送:点发送即在本地画出占位项,后台再加密上传;worker 发布真实条目时带回 localId 替换占位项 */ @@ -96,7 +97,11 @@ export const useWorkspace = create()(() => ({ revokeInvitation: (username) => api.revokeInvitation(username), open: (channelId) => { useWorkspace.setState(initialData); - return api.connect(channelId, (e) => useWorkspace.setState((s) => reduce(s, e))); + return api.connect(channelId, (e) => { + useWorkspace.setState((s) => reduce(s, e)); + // 空间名称、头像等改了:左侧空间列表是另一份投影,一起更新 + if (e.t === 'channel') useChannels.getState().patch(e.info); + }); }, sendText: (text, threadId) => { const id = newLocalId();