feat(web): discord-style chat, members pane, invitations and optimistic send [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful

- chat: left-aligned discord-style messages with grouped senders, hover
  actions, an auto-growing composer and file cards
- send: render messages and files locally first and replace the
  placeholder when the worker publishes the real item; failures keep the
  text with a retry action
- navigation: space rail plus a flatter channel list with the space name
  header and collapsible groups
- members: right pane tabs (files / members) grouped by online, offline
  and pending users, with a prominent invite action and a top bar shortcut
- notifications: toast host for invite results; invitees get a toast with
  an accept action, and the invitation payload now carries the space name
  and inviter inside the encrypted grant
- users over devices: drop device names, the device list and device
  rename/release; senders show the account (guests show as guest) and the
  share panel only keeps links
- follow contract 7.0.0 (max_members, members_used, members_full, /join)
- docs: ui-design and frontend updated for the new layout and concepts
This commit is contained in:
justme committed 2026-10-14 22:19:25 +08:00
1 parent 5fd4ddd411
commit 4b8d0b5a5f
83 files changed
+1291 -661

No files matched your search

@@ -1,49 +0,0 @@
import { useEffect, useState, type FormEvent } from 'react';
import { useWorkspace } from '@/store/workspace';
import { channelAccessApi, type ChannelMember } from '@/core-bridge/channelAccess';
import type { AccountInvitation } from '@/core-bridge/account';
import { Button } from '@/ui/Button';
import styles from './ChannelSettingsSheet.module.css';
export function ChannelInvitations({ owner }: { owner: boolean }) {
const channelId = useWorkspace((s) => s.channel?.id);
const invite = useWorkspace((s) => s.inviteUser);
const list = useWorkspace((s) => s.listInvitations);
const revoke = useWorkspace((s) => s.revokeInvitation);
const [members, setMembers] = useState<ChannelMember[]>([]);
const [invitations, setInvitations] = useState<AccountInvitation[]>([]);
const [username, setUsername] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
async function refresh() {
if (channelId) setMembers(await channelAccessApi.members(channelId));
if (owner) setInvitations(await list());
}
useEffect(() => { void refresh().catch(() => setError('成员清单读取失败')); }, [channelId, owner, list]);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); setBusy(true); setError('');
try { await invite(username); setUsername(''); await refresh(); }
catch (cause) { setError(cause instanceof Error ? cause.message : '邀请失败'); }
finally { setBusy(false); }
}
async function remove(username: string) {
setBusy(true); setError('');
try { await revoke(username); await refresh(); }
catch (cause) { setError(cause instanceof Error ? cause.message : '移除失败'); }
finally { setBusy(false); }
}
return <section className={styles.form} aria-label="空间成员与邀请"><strong>空间成员</strong>
{members.map((member) => <div className={styles.member} key={member.username}>
<span>{member.username} · {member.role === 'owner' ? '空间主' : '成员'}</span>
{owner && member.role !== 'owner' ? <Button variant="ghost" disabled={busy} onClick={() => { void remove(member.username); }}>移除成员</Button> : null}
</div>)}
<p className={styles.note}>未登录访客以独立设备身份加入,可在设备面板移除。</p>
{owner ? <><strong>邀请用户</strong><form className={styles.member} onSubmit={(event) => { void submit(event); }}>
<input aria-label="被邀请用户的用户名" maxLength={32} value={username} onChange={(event) => setUsername(event.target.value)} placeholder="已注册用户的用户名" required />
<Button type="submit" disabled={busy || !username.trim()}>发送邀请</Button>
</form>
{invitations.map((row) => <div className={styles.member} key={row.username}><span>{row.username} · 已邀请</span><Button variant="ghost" disabled={busy} onClick={() => { void remove(row.username); }}>撤销邀请</Button></div>)}
<p className={styles.note}>移除会取消加入授权并断开该账号的设备。已读取的内容无法收回。</p></> : null}
{error ? <p className={styles.error} role="alert">{error}</p> : null}
</section>;
}
@@ -3,7 +3,6 @@ import type { ChannelInfo, ChannelSettingsPatch } from '@/core-bridge/types';
import { Button } from '@/ui/Button';
import { Sheet } from '@/ui/Sheet';
import styles from './ChannelSettingsSheet.module.css';
import { ChannelInvitations } from './ChannelInvitations';
import { useServerSettings } from '@/store/server';
import type { ServerInfoBody } from '@/core-bridge/generated/api';
@@ -25,7 +24,7 @@ export function ChannelSettingsSheet(props: Props) {
function SettingsForm({ channel, onClose, onSave, info }: Props & { info: ServerInfoBody }) {
const owner = channel.role === 'owner';
const [name, setName] = useState(channel.name ?? '');
const [seats, setSeats] = useState(channel.seats);
const [maxMembers, setMaxMembers] = useState(channel.maxMembers);
const [membersCanUpload, setMembersCanUpload] = useState(channel.membersCanUpload!);
const [ttlSeconds, setTtlSeconds] = useState(channel.ttlSeconds!);
const [fileMaxDownloads, setFileMaxDownloads] = useState(channel.maxDownloads!);
@@ -43,7 +42,7 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
setSaving(true);
setFailed(false);
try {
await onSave({ name: name.trim(), seats, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy, allowGuests,
await onSave({ name: name.trim(), maxMembers, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy, allowGuests,
...(clearPassword ? { joinPassword: '' } : joinPassword ? { joinPassword } : {}) });
onClose();
} catch {
@@ -58,13 +57,13 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
<div className={styles.form}>
<label className={styles.field}>
<span>空间名称</span>
<input type="text" maxLength={48} value={name} disabled={!owner} placeholder="例如:工作设备" onChange={(event) => setName(event.target.value)} />
<input type="text" maxLength={48} value={name} disabled={!owner} placeholder="例如:项目组" onChange={(event) => setName(event.target.value)} />
<span className={styles.note}>空间名称和设置对加入者及服务端可见。</span>
</label>
<label className={styles.field}>
<span>成员人数上限</span>
<input type="number" min={1} max={info.limits.max_seats} step={1} value={seats} disabled={!owner} onChange={(event) => setSeats(Number(event.target.value))} />
<input type="number" min={1} max={info.limits.max_members} step={1} value={maxMembers} disabled={!owner} onChange={(event) => setMaxMembers(Number(event.target.value))} />
</label>
<label className={styles.field}>
@@ -97,11 +96,11 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
<span className={styles.note}>空间主及受邀用户无需加入密码。修改规则不移除已加入成员。</span>
</label>
{owner && channel.passwordRequired ? <label className={styles.check}><input type="checkbox" checked={clearPassword} onChange={(event) => setClearPassword(event.target.checked)} /><span>移除空间加入密码</span></label> : null}
<ChannelInvitations owner={owner} />
<p className={styles.note}>成员与邀请请在右侧栏的「成员」页管理。</p>
{owner ? (
<>
{failed ? <p className={styles.error} role="alert">保存失败,请检查名称和设置后重试。</p> : null}
<Button variant="primary" disabled={saving || seats < 1 || seats > info.limits.max_seats || ttlSeconds < info.limits.min_ttl_seconds || ttlSeconds > info.limits.max_ttl_seconds || Boolean(joinPassword && joinPassword.length < 8)} onClick={() => void save()}>{saving ? '保存中…' : '保存设置'}</Button>
<Button variant="primary" disabled={saving || maxMembers < 1 || maxMembers > info.limits.max_members || ttlSeconds < info.limits.min_ttl_seconds || ttlSeconds > info.limits.max_ttl_seconds || Boolean(joinPassword && joinPassword.length < 8)} onClick={() => void save()}>{saving ? '保存中…' : '保存设置'}</Button>
</>
) : <p className={styles.note}>只有所有者可以修改空间设置。</p>}
</div>
@@ -1,17 +0,0 @@
import { useChannels } from '@/store/channels';
import { useAccount } from '@/store/account';
import { channelPath, navigate } from '@/app/navigation';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import styles from './ThreadSidebar.module.css';
export function ChannelSwitcher({ id, onCreate }: { id?: string; onCreate: () => void }) {
const channels = useChannels((s) => s.list);
const username = useAccount((s) => s.username);
return <section className={styles.panel} aria-label="我的空间导航">
<div className={styles.heading}><strong>空间</strong><Button variant="ghost" icon="edit" label="新建空间" disabled={!username} onClick={onCreate} /></div>
<nav className={styles.channels} aria-label="空间列表">{channels.map((channel) => <button key={channel.id} type="button"
className={`${styles.thread} ${channel.id === id ? styles.selected : ''}`} aria-current={channel.id === id ? 'page' : undefined}
onClick={() => navigate(channelPath(channel.id))}><Icon name={channel.mode === 'enc' ? 'lock' : 'file'} size={14} /><span>{channel.name || channel.id.slice(0, 8)}</span></button>)}</nav>
</section>;
}
+27 -21
View File
@@ -1,50 +1,56 @@
import { useState } from 'react';
import { useWorkspace } from '@/store/workspace';
import { isLocalId, useWorkspace } from '@/store/workspace';
import { useAccount } from '@/store/account';
import { useDownload } from '@/features/item/download';
import { iconOf } from '@/features/item/summary';
import { senderLabel } from '@/lib/sender';
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
import { Icon } from '@/ui/Icon';
import { Button } from '@/ui/Button';
import styles from './ChatPane.module.css';
export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: { id: string; compact?: boolean; onOpenFile: (id: string) => void; onDelete: (id: string) => void }) {
interface Props { id: string; compact?: boolean; onOpenFile: (id: string) => void; onDelete: (id: string) => void }
export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props) {
const item = useWorkspace((s) => s.byId[id]);
const owner = useWorkspace((s) => s.channel?.role === 'owner');
const ownDevice = useWorkspace((s) => s.devices.find((device) => device.self)?.name);
const retry = useWorkspace((s) => s.retrySend);
const username = useAccount((s) => s.username);
const download = useDownload();
const [downloading, setDownloading] = useState(false);
if (!item) return null;
const sender = item.author;
const name = sender?.displayName || sender?.username || item.deviceId || '未知用户';
const self = sender?.username !== undefined ? sender.username === username : Boolean(ownDevice && item.deviceId === ownDevice);
const name = senderLabel(item);
const self = sender?.username !== undefined && sender.username === username;
const local = isLocalId(id);
const sending = local && item.state.kind === 'receiving';
const failed = local && item.state.kind === 'incomplete';
const ready = item.state.kind === 'complete';
const deleteLabel = item.kind === 'file' ? `删除文件消息 ${item.name}` : '删除消息';
async function save() {
if (!item || downloading) return;
setDownloading(true); try { await download(item); } finally { setDownloading(false); }
}
return <article className={`${styles.message} ${self ? styles.mine : ''} ${compact ? styles.compact : ''}`} title={compact ? `${name} · ${formatTime(item.ts)}` : undefined}
aria-label={item.kind === 'file' ? `${name} 上传了 ${item.name}` : `${name} 的消息`}>
{compact ? <span className={styles.avatarSpacer} aria-hidden="true" /> : sender?.avatar ? <img className={styles.avatar} src={sender.avatar} alt={`${name} 的头像`} />
: <span className={`${styles.avatar} ${styles.avatarFallback}`} role="img" aria-label={`${name} 的默认头像`}>{Array.from(name)[0] ?? '?'}</span>}
<div className={styles.bubble}>
{!compact ? <header><strong className={styles.senderName} title={sender ? `${sender.username} · ${item.deviceId}` : item.deviceId}>{name}</strong><time>{formatTime(item.ts)}</time>
{owner || self ? <Button className={styles.deleteAction} variant="ghost" icon="trash" label={item.kind === 'file' ? `删除文件消息 ${item.name}` : '删除消息'} onClick={() => onDelete(id)} /> : null}
</header> : null}
{compact && (owner || self) ? <Button className={`${styles.deleteAction} ${styles.compactDelete}`} variant="ghost" icon="trash" label={item.kind === 'file' ? `删除文件消息 ${item.name}` : '删除消息'} onClick={() => onDelete(id)} /> : null}
return <article className={`${styles.message} ${compact ? styles.compact : ''} ${sending ? styles.sending : ''}`}
aria-label={item.kind === 'file' ? `${name} 上传了 ${item.name}` : `${name} 的消息`} aria-busy={sending}>
{compact ? <time className={styles.gutterTime} dateTime={new Date(item.ts).toISOString()}>{formatTime(item.ts)}</time>
: sender?.avatar ? <img className={styles.avatar} src={sender.avatar} alt={`${name} 的头像`} />
: <span className={`${styles.avatar} ${styles.avatarFallback}`} role="img" aria-label={`${name} 的默认头像`}>{Array.from(name)[0] ?? '?'}</span>}
<div className={styles.body}>
{!compact ? <header><strong className={styles.senderName} title={sender?.username}>{name}</strong><time>{formatTime(item.ts)}</time></header> : null}
{item.state.kind === 'tampered' ? <p className={styles.integrity}>内容校验失败,已拒绝显示。</p> : item.kind === 'text' ? <p className={styles.messageText}>{item.text}</p>
: <div className={styles.fileMessage}>
<button type="button" className={styles.fileCard} aria-label={`预览文件 ${item.name}`} disabled={!ready} onClick={() => onOpenFile(id)}>
: <div className={styles.fileCard}>
<button type="button" className={styles.fileOpen} aria-label={`预览文件 ${item.name}`} disabled={!ready} onClick={() => onOpenFile(id)}>
<span className={styles.fileIcon}><Icon name={iconOf(item)} size={28} /></span>
<span className={styles.fileInfo}><strong>{item.name}</strong><span>{formatSize(item.size)} · {item.mime}</span></span>
</button>
<div className={styles.fileActions}><Button variant="ghost" icon="eye" disabled={!ready} onClick={() => onOpenFile(id)}>预览</Button>
<Button variant="ghost" icon="download" disabled={!ready || downloading} onClick={() => { void save(); }}>{downloading ? '下载中…' : '下载'}</Button>
</div>
<Button variant="ghost" icon="eye" label="预览" disabled={!ready} onClick={() => onOpenFile(id)} />
<Button variant="ghost" icon="download" label={downloading ? '下载中…' : '下载'} disabled={!ready || downloading} onClick={() => { void save(); }} />
</div>}
{item.state.kind === 'receiving' ? <div className={styles.delivery} role="status">{formatTransferProgress(item.state)}<progress max={1} value={item.state.progress} /></div>
: item.state.kind === 'incomplete' ? <span className={styles.delivery}>发送未完成,等待续传。</span> : null}
{item.state.kind === 'receiving' && item.kind === 'file' ? <div className={styles.delivery} role="status">{formatTransferProgress(item.state)}<progress max={1} value={item.state.progress} /></div>
: failed ? <span className={styles.failure} role="alert">发送失败。<button type="button" onClick={() => { void retry(id); }}>重试</button></span>
: item.state.kind === 'incomplete' ? <span className={styles.delivery}>发送未完成,等待续传。</span> : null}
</div>
{owner || self ? <Button className={styles.deleteAction} variant="ghost" icon="trash" label={deleteLabel} onClick={() => onDelete(id)} /> : null}
</article>;
}
+144 -88
View File
@@ -3,23 +3,28 @@
flex: 1;
flex-direction: column;
min-height: 0;
border-top: 1px solid var(--line);
background: var(--surface);
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
min-height: var(--bar-h);
padding: 0 var(--space-3);
padding: 0 var(--space-4);
border-bottom: 1px solid var(--line);
font-size: 12px;
font-size: 15px;
}
.hash {
color: var(--text-dim);
font-size: 20px;
font-weight: 400;
}
.head span,
.empty,
.message time {
.message time,
.delivery {
color: var(--text-dim);
font-size: 11px;
}
@@ -27,59 +32,72 @@
.messages {
flex: 1;
min-height: 0;
padding: var(--space-3) var(--space-4);
padding: var(--space-4) 0 var(--space-2);
overflow-y: auto;
overscroll-behavior: contain;
}
.empty {
margin: var(--space-2) 0;
margin: 0;
padding: 0 var(--space-4);
font-size: 13px;
}
/* Discord 式:所有消息同一条左边线,头像 + 名称/时间 + 正文占满宽度 */
.message {
display: flex;
align-items: flex-start;
gap: var(--space-3);
width: 100%;
margin-bottom: var(--space-3);
position: relative;
display: grid;
grid-template-columns: 40px minmax(0, 1fr);
column-gap: var(--space-3);
padding: var(--space-1) var(--space-4);
margin-top: var(--space-3);
}
.mine {
flex-direction: row-reverse;
.message:hover,
.message:focus-within {
background: var(--accent-soft);
}
.compact {
margin-top: calc(var(--space-2) * -1);
margin-bottom: var(--space-1);
margin-top: 0;
}
.sending {
opacity: 0.6;
}
.avatar {
width: 40px;
height: 40px;
flex-shrink: 0;
margin-top: 2px;
border-radius: var(--radius-avatar);
object-fit: cover;
}
.avatarSpacer {
width: 40px;
height: 1px;
flex: 0 0 40px;
}
.avatarFallback {
display: grid;
place-items: center;
background: var(--surface-2);
color: var(--text-dim);
font-size: 14px;
font-size: 15px;
font-weight: 600;
}
.bubble {
position: relative;
/* 连发合并行:头像位置平时留空,悬停才显示时间 */
.gutterTime {
align-self: start;
padding-top: 3px;
text-align: center;
opacity: 0;
}
.message:hover .gutterTime,
.message:focus-within .gutterTime {
opacity: 1;
}
.body {
min-width: 0;
max-width: min(82%, 760px);
padding: 0 0 var(--space-1);
}
.message header {
@@ -93,22 +111,28 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--accent);
font-size: 13px;
font-size: 14px;
}
.message header time {
margin-inline-end: auto;
.messageText {
margin: 0;
overflow-wrap: anywhere;
white-space: pre-wrap;
font-size: 14px;
line-height: 1.5;
}
.deleteAction {
width: 26px;
min-width: 26px;
height: 26px;
min-height: 26px;
position: absolute;
inset-block-start: 0;
inset-inline-end: var(--space-4);
width: 28px;
min-width: 28px;
height: 28px;
min-height: 28px;
padding: 0;
background: var(--surface);
opacity: 0;
transition: opacity 0.12s ease-out;
}
.message:hover .deleteAction,
@@ -116,24 +140,11 @@
opacity: 1;
}
.compactDelete {
position: absolute;
inset-block-start: 0;
inset-inline-end: calc(var(--space-2) * -1);
}
.messageText {
margin: var(--space-1) 0 0;
overflow-wrap: anywhere;
white-space: pre-wrap;
font-size: 14px;
}
.dateDivider {
display: flex;
align-items: center;
gap: var(--space-3);
margin: var(--space-4) 0 var(--space-3);
margin: var(--space-4) var(--space-4) var(--space-2);
color: var(--text-dim);
font-size: 11px;
font-weight: 600;
@@ -147,17 +158,26 @@
content: '';
}
.fileMessage {
width: 280px;
max-width: 100%;
}
.fileCard {
display: flex;
align-items: center;
gap: var(--space-1);
width: 360px;
max-width: 100%;
margin-top: var(--space-1);
padding: var(--space-2) var(--space-2) var(--space-2) var(--space-3);
border: 1px solid var(--line-strong);
border-radius: var(--radius-md);
background: var(--surface-2);
}
.fileOpen {
display: flex;
flex: 1;
align-items: center;
gap: var(--space-3);
width: 100%;
padding: var(--space-3) 0;
min-width: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--text);
@@ -165,6 +185,10 @@
cursor: pointer;
}
.fileOpen:disabled {
cursor: default;
}
.fileIcon {
flex-shrink: 0;
}
@@ -181,23 +205,19 @@
font-size: 13px;
}
.fileInfo > span,
.delivery {
.fileInfo > span {
color: var(--text-dim);
overflow-wrap: anywhere;
font-size: 11px;
}
.fileActions {
display: flex;
gap: var(--space-2);
border-top: 1px solid var(--line);
}
.delivery {
display: flex;
flex-direction: column;
gap: var(--space-1);
width: 360px;
max-width: 100%;
margin-top: var(--space-1);
}
.delivery progress {
@@ -206,7 +226,23 @@
accent-color: var(--busy);
}
.failure {
color: var(--danger);
font-size: 12px;
}
.failure button {
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-decoration: underline;
cursor: pointer;
}
.integrity {
margin: 0;
color: var(--danger);
font-size: 12px;
}
@@ -214,36 +250,56 @@
.composer {
display: flex;
align-items: flex-end;
gap: var(--space-2);
padding: var(--space-2);
border-top: 1px solid var(--line);
gap: var(--space-1);
margin: var(--space-2) var(--space-4) var(--space-4);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-lg);
background: var(--surface-2);
}
.composer:focus-within {
outline: 2px solid var(--line-strong);
}
.composer textarea {
flex: 1;
min-width: 0;
min-height: 42px;
max-height: 112px;
padding: var(--space-2);
resize: vertical;
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
background: var(--bg);
min-height: 36px;
max-height: 160px;
padding: var(--space-2) var(--space-1);
resize: none;
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font: inherit;
font-size: 13px;
}
.error {
margin: 0;
padding: 0 var(--space-3) var(--space-2);
color: var(--danger);
font-size: 12px;
font-size: 14px;
line-height: 20px;
}
@media (width <= 899px) {
.pane {
flex: 1;
min-height: 0;
.message {
grid-template-columns: 32px minmax(0, 1fr);
column-gap: var(--space-2);
padding-inline: var(--space-3);
}
.avatar {
width: 32px;
height: 32px;
}
.deleteAction {
inset-inline-end: var(--space-2);
}
.composer {
margin: var(--space-2);
}
}
@media (hover: none) {
.deleteAction {
opacity: 1;
}
}
+25 -18
View File
@@ -8,36 +8,43 @@ import styles from './ChatPane.module.css';
interface Props { threadName: string; ids: string[]; enabled: boolean; onSend: (text: string) => Promise<void>; onDelete: (id: string) => void; onOpenFile: (id: string) => void; onFiles?: (files: File[]) => void }
const MAX_INPUT_HEIGHT = 160;
export function ChatPane({ threadName, ids, enabled, onSend, onDelete, onOpenFile, onFiles }: Props) {
const [text, setText] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const messages = useRef<HTMLDivElement>(null);
const input = useRef<HTMLTextAreaElement>(null);
const fileInput = useRef<HTMLInputElement>(null);
const byId = useWorkspace((s) => s.byId);
const chronological = ids.slice().reverse();
useEffect(() => { messages.current?.scrollTo({ top: messages.current.scrollHeight }); }, [chronological.length]);
async function submit() {
function fit() {
const el = input.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, MAX_INPUT_HEIGHT)}px`;
}
// 乐观发送:不等后端,先清空输入框,消息立刻画出来;失败由消息行自己显示重试
function submit() {
const value = text.trim();
if (!value || busy || !enabled) return;
setBusy(true);
setError('');
try { await onSend(value); setText(''); }
catch (cause) { setError(cause instanceof Error ? cause.message : '发送失败'); }
finally { setBusy(false); }
if (!value || !enabled) return;
setText('');
if (input.current) input.current.style.height = 'auto';
void onSend(value);
}
function keyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); void submit(); }
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); submit(); }
}
return (
<section className={styles.pane} aria-label={`${threadName} 聊天`}>
<div className={styles.head}><strong>{threadName}</strong><span>频道聊天</span></div>
<div className={styles.head}><span className={styles.hash} aria-hidden="true">#</span><strong>{threadName}</strong></div>
<div className={styles.messages} ref={messages} aria-live="polite">
{chronological.length === 0 ? <p className={styles.empty}>在这里发送消息或文件,成员可以看到你的名称和头像。</p> : null}
{chronological.length === 0 ? <p className={styles.empty}>这里是 #{threadName} 的开头。发送消息或文件,成员可以看到你的名称和头像。</p> : null}
{(() => {
let previous: Item | undefined;
return chronological.map((id) => {
@@ -46,7 +53,7 @@ export function ChatPane({ threadName, ids, enabled, onSend, onDelete, onOpenFil
const dateKey = new Date(item.ts).toDateString();
const previousDate = previous ? new Date(previous.ts).toDateString() : undefined;
const showDate = dateKey !== previousDate;
const sameSender = previous && (previous.author?.username ?? previous.deviceId) === (item.author?.username ?? item.deviceId);
const sameSender = previous && previous.author?.username === item.author?.username;
const grouped = Boolean(sameSender && !showDate && item.ts - previous!.ts < 5 * 60_000);
previous = item;
return <Fragment key={id}>
@@ -56,13 +63,13 @@ export function ChatPane({ threadName, ids, enabled, onSend, onDelete, onOpenFil
});
})()}
</div>
<form className={styles.composer} onSubmit={(event) => { event.preventDefault(); void submit(); }}>
<form className={styles.composer} onSubmit={(event) => { event.preventDefault(); submit(); }}>
{onFiles ? <><input ref={fileInput} type="file" multiple hidden onChange={(event) => { if (event.target.files?.length) onFiles([...event.target.files]); event.target.value = ''; }} />
<Button variant="ghost" icon="clip" label="上传文件到当前频道" disabled={!enabled} onClick={() => fileInput.current?.click()} /></> : null}
<textarea value={text} disabled={!enabled || busy} rows={2} placeholder={enabled ? '输入消息,Enter 发送,Shift+Enter 换行' : '先创建或加入一个频道'} onChange={(event) => setText(event.target.value)} onKeyDown={keyDown} />
<Button variant="primary" icon="send" label="发送消息" disabled={!enabled || busy || !text.trim()} onClick={() => void submit()} />
<Button variant="ghost" icon="plus" label="上传文件到当前频道" disabled={!enabled} onClick={() => fileInput.current?.click()} /></> : null}
<textarea ref={input} value={text} disabled={!enabled} rows={1} aria-label="消息内容" placeholder={enabled ? `给 #${threadName} 发消息` : '先创建或加入一个频道'}
onChange={(event) => { setText(event.target.value); fit(); }} onKeyDown={keyDown} />
<Button type="submit" variant="ghost" icon="send" label="发送消息" disabled={!enabled || !text.trim()} />
</form>
{error ? <p className={styles.error} role="alert">{error}</p> : null}
</section>
);
}
-88
View File
@@ -1,88 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { formatAgo } from '@/lib/format';
import { useWorkspace } from '@/store/workspace';
import { useAccount } from '@/store/account';
import { channelAccessApi, type ChannelMember } from '@/core-bridge/channelAccess';
import { Button } from '@/ui/Button';
import { CopyButton } from '@/ui/CopyButton';
import { QrCode } from '@/ui/QrCode';
import styles from './DevicePanel.module.css';
export function DevicePanel({ qrOpen = false }: { qrOpen?: boolean }) {
const deviceCount = useWorkspace((s) => s.devices.length);
const channel = useWorkspace((s) => s.channel);
const username = useAccount((s) => s.username);
const [members, setMembers] = useState<ChannelMember[]>([]);
const [membersError, setMembersError] = useState('');
const [showOwner, setShowOwner] = useState(false);
const [showQr, setShowQr] = useState(qrOpen);
const membersUsed = channel?.membersUsed ?? '—';
const accountAddress = channel ? `${location.origin}/c/${channel.id}` : '';
const refreshMembers = useCallback(async () => {
if (!channel?.id) return;
setMembersError('');
try { setMembers(await channelAccessApi.members(channel.id)); }
catch { setMembersError('成员清单读取失败'); }
}, [channel?.id]);
useEffect(() => {
void refreshMembers();
}, [refreshMembers, deviceCount]);
return (
<aside className={styles.panel}>
<section>
<div className={styles.memberHeading}>
<h2 className={styles.h}>成员 <span className={styles.dim}>{membersUsed}/{channel?.seats} 人</span></h2>
<Button variant="ghost" disabled={!channel} onClick={() => { void refreshMembers(); }}>刷新</Button>
</div>
<ul className={styles.list}>
{members.map((member) => <li key={member.username} className={styles.member}>
<span className={styles.name}>{member.username}</span>
<span className={member.username === username ? styles.memberCurrent : styles.memberRole}>{member.username === username ? '你' : member.role === 'owner' ? '空间主' : '成员'}</span>
<time className={styles.memberTime}>{formatAgo(member.joined_at)}</time>
</li>)}
</ul>
{!members.length && !membersError ? <p className={styles.note}>暂无账号成员。</p> : null}
{membersError ? <p className={styles.note} role="alert">{membersError}</p> : null}
</section>
<section>
<h2 className={styles.h}>分享</h2>
<div className={styles.link}>
<div className={styles.label}>账号空间地址</div>
<code className={styles.code}>{accountAddress}</code>
<CopyButton text={accountAddress} />
<p className={styles.note}>此地址不包含密钥。在空间设置中邀请账号后,成员登录即可自动领取密钥包。</p>
</div>
{channel?.joinPolicy === 'open' ? <>
<div className={styles.link}>
<div className={styles.label}>成员链接</div>
<code className={styles.code}>{channel?.memberLink}</code>
<div className={styles.row}>
<CopyButton text={channel?.memberLink ?? ''} />
<Button variant="ghost" icon="scan" aria-expanded={showQr} onClick={() => setShowQr((v) => !v)}>
{showQr ? '收起二维码' : '二维码'}
</Button>
</div>
{showQr && channel ? <QrCode text={channel.memberLink} label="成员链接二维码" /> : null}
</div>
{channel?.ownerLink ? (
<div className={styles.link}>
<div className={styles.label}>所有者链接 <span className={styles.warn}>含管理权限</span></div>
<code className={styles.code}>{showOwner ? channel.ownerLink : '••••••••••••••••••••'}</code>
<div className={styles.row}>
<Button variant="ghost" icon="eye" onClick={() => setShowOwner((v) => !v)}>{showOwner ? '隐藏' : '显示'}</Button>
<CopyButton text={channel.ownerLink} />
</div>
</div>
) : null}
<p className={styles.note}>
持有链接并符合空间加入规则后,可读取空间历史内容。不隐藏 IP 与访问时间。
</p>
</> : null}
</section>
</aside>
);
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { senderLabel } from '@/lib/sender';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useWorkspace } from '@/store/workspace';
@@ -16,7 +17,7 @@ export function ExplorerHover({ id, point }: { id: string; point: { x: number; y
const preview = encrypted && !item.gated && item.state.kind === 'complete' && (item.preview === 'image' || item.preview === 'text');
return createPortal(<aside className={styles.hover} role="tooltip" aria-label="悬浮文件预览" style={{ left: position.x, top: position.y }}>
<div className={styles.hoverTitle}><Icon name={iconOf(item)} size={20} /><strong>{item.name}</strong></div>
<p>{formatSize(item.size)} · {item.mime}</p><p>{formatTime(item.ts)} · 上传者 {item.author?.displayName ?? item.author?.username ?? item.deviceId} · {item.deviceId}</p>
<p>{formatSize(item.size)} · {item.mime}</p><p>{formatTime(item.ts)} · 上传者 {senderLabel(item)}</p>
{preview ? <HoverContent id={id} image={item.preview === 'image'} /> : <p>{item.gated ? '受限文件:悬停不读取内容,点击预览后再解密。' : item.state.kind === 'tampered' ? '内容校验失败,已拒绝预览。' : '双击文件打开完整预览。'}</p>}
</aside>, document.body);
}
+44
View File
@@ -0,0 +1,44 @@
import { useState, type FormEvent } from 'react';
import { errorMessage } from '@/core-bridge/errors';
import { useToast } from '@/store/toast';
import { useWorkspace } from '@/store/workspace';
import { Button } from '@/ui/Button';
import { Sheet } from '@/ui/Sheet';
import styles from './MembersPane.module.css';
export function InviteSheet({ onClose, onInvited }: { onClose: () => void; onInvited: () => void }) {
const invite = useWorkspace((s) => s.inviteUser);
const [username, setUsername] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function submit(event: FormEvent) {
event.preventDefault();
const target = username.trim();
if (!target || busy) return;
setBusy(true); setError('');
try {
await invite(target);
useToast.getState().push({ kind: 'ok', text: `已向 ${target} 发送邀请` });
onInvited(); onClose();
} catch (cause) {
const message = errorMessage(cause);
setError(message);
useToast.getState().push({ kind: 'error', text: `邀请失败:${message}` });
} finally { setBusy(false); }
}
return <Sheet title="邀请成员" onClose={() => { if (!busy) onClose(); }} placement="top">
<form className={styles.invite} onSubmit={(event) => { void submit(event); }}>
<label>用户名
<input autoFocus maxLength={32} value={username} disabled={busy} placeholder="已注册用户的用户名" onChange={(event) => setUsername(event.target.value)} />
</label>
<p className={styles.note}>对方登录后会收到通知,接受邀请后加入空间。能否读取加入前的历史,取决于各频道的历史开关。</p>
{error ? <p className={styles.error} role="alert">{error}</p> : null}
<div className={styles.inviteActions}>
<Button variant="ghost" disabled={busy} onClick={onClose}>取消</Button>
<Button type="submit" variant="primary" disabled={busy || !username.trim()}>{busy ? '发送中…' : '发送邀请'}</Button>
</div>
</form>
</Sheet>;
}
+3 -3
View File
@@ -1,7 +1,8 @@
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
import { useDownload } from '@/features/item/download';
import { InlineContent, inlineKindOf } from '@/features/item/InlineContent';
import { shortDevice, titleOf } from '@/features/item/summary';
import { senderLabel } from '@/lib/sender';
import { titleOf } from '@/features/item/summary';
import { Tile } from '@/features/item/Tile';
import { useWorkspace } from '@/store/workspace';
import { Button } from '@/ui/Button';
@@ -16,7 +17,6 @@ interface Props {
/** 手机首屏:最新一项放大,拇指区一键取走 */
export function LatestCard({ id, onOpen }: Props) {
const item = useWorkspace((s) => s.byId[id]);
const device = useWorkspace((s) => s.devices.find((d) => d.id === s.byId[id]?.deviceId)?.name);
const download = useDownload();
if (!item) return null;
@@ -32,7 +32,7 @@ export function LatestCard({ id, onOpen }: Props) {
<span className={styles.text}>
{inline === 'text' ? null : <span className={styles.title}>{titleOf(item)}</span>}
<span className={styles.sub}>
{shortDevice(device)} · {formatTime(item.ts)} · {formatSize(size)}
{senderLabel(item)} · {formatTime(item.ts)} · {formatSize(size)}
{receiving !== null && item.state.kind === 'receiving' ? ` · ${formatTransferProgress(item.state)}` : ''}
</span>
</span>
@@ -0,0 +1,177 @@
.pane {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
min-height: var(--bar-h);
padding: 0 var(--space-4);
border-bottom: 1px solid var(--line);
font-size: 14px;
font-weight: 600;
}
.scroll {
flex: 1;
min-height: 0;
padding: var(--space-2) var(--space-2) var(--space-4);
overflow-y: auto;
}
.section h3 {
margin: var(--space-4) var(--space-2) var(--space-1);
color: var(--text-dim);
font-size: 12px;
font-weight: 600;
}
.section ul {
margin: 0;
padding: 0;
list-style: none;
}
.row {
display: flex;
align-items: center;
gap: var(--space-3);
min-height: 44px;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-md);
}
.row:hover,
.row:focus-within {
background: var(--accent-soft);
}
.avatar {
flex: 0 0 32px;
width: 32px;
height: 32px;
border-radius: var(--radius-avatar);
object-fit: cover;
}
.avatarFallback {
display: grid;
place-items: center;
background: var(--surface-2);
color: var(--text-dim);
font-size: 13px;
font-weight: 600;
}
.dim {
opacity: 0.6;
}
.who em {
margin-inline-start: var(--space-2);
padding: 0 var(--space-1);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--text-dim);
font-size: 11px;
font-style: normal;
font-weight: 400;
}
.who {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
line-height: 1.3;
}
.who strong,
.who small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.who strong {
font-size: 14px;
}
.who small {
color: var(--text-dim);
font-size: 11px;
}
.online {
flex: 0 0 8px;
width: 8px;
height: 8px;
border-radius: var(--radius-avatar);
background: var(--ok);
}
.remove {
opacity: 0;
}
.row:hover .remove,
.row:focus-within .remove {
opacity: 1;
}
@media (hover: none) {
.remove {
opacity: 1;
}
}
.note,
.error {
margin: var(--space-4) var(--space-2) 0;
color: var(--text-dim);
font-size: 12px;
line-height: 1.5;
}
.error {
color: var(--danger);
}
.invite {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.invite label {
display: flex;
flex-direction: column;
gap: var(--space-2);
color: var(--text-dim);
font-size: 13px;
}
.invite input {
min-height: 42px;
padding: 0 var(--space-3);
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--surface-2);
color: var(--text);
font: inherit;
}
.invite .note {
margin: 0;
}
.inviteActions {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
}
+92
View File
@@ -0,0 +1,92 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react';
import { channelAccessApi, type ChannelMember } from '@/core-bridge/channelAccess';
import type { AccountInvitation } from '@/core-bridge/account';
import { errorMessage } from '@/core-bridge/errors';
import type { SenderInfo } from '@/core-bridge/types';
import { useToast } from '@/store/toast';
import { useWorkspace } from '@/store/workspace';
import { Button } from '@/ui/Button';
import styles from './MembersPane.module.css';
interface Props { owner: boolean; refreshKey: number; onInvite: () => void }
function Avatar({ person, name }: { person?: SenderInfo; name: string }) {
return person?.avatar ? <img className={styles.avatar} src={person.avatar} alt="" />
: <span className={`${styles.avatar} ${styles.avatarFallback}`} aria-hidden="true">{Array.from(name)[0]?.toLocaleUpperCase() ?? '?'}</span>;
}
function Section({ title, count, children }: { title: string; count: number; children: ReactNode }) {
return count ? <section className={styles.section}><h3>{title} — {count}</h3><ul>{children}</ul></section> : null;
}
/** 右栏「成员」页:以用户为单位,按在线 / 离线 / 待接受分组。设备只是用户的属性(在线设备数由后端按账号统计) */
const PRESENCE_POLL_MS = 8000;
export function MembersPane({ owner, refreshKey, onInvite }: Props) {
const channelId = useWorkspace((s) => s.channel?.id);
const maxMembers = useWorkspace((s) => s.channel?.maxMembers);
const people = useWorkspace((s) => s.people);
const listInvitations = useWorkspace((s) => s.listInvitations);
const revoke = useWorkspace((s) => s.revokeInvitation);
const [members, setMembers] = useState<ChannelMember[]>([]);
const [pending, setPending] = useState<AccountInvitation[]>([]);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
if (!channelId) return;
try {
setMembers(await channelAccessApi.members(channelId));
setPending(owner ? await listInvitations() : []);
setError('');
} catch { setError('成员清单读取失败'); }
}, [channelId, owner, listInvitations]);
useEffect(() => {
void refresh();
// 在线状态不走订阅流,成员页可见期间定时读取
const timer = window.setInterval(() => { void refresh(); }, PRESENCE_POLL_MS);
return () => window.clearInterval(timer);
}, [refresh, refreshKey]);
async function remove(username: string, invited: boolean) {
if (!invited && !window.confirm(`移除成员 ${username}?对方的设备会被断开,已读取的内容无法收回。`)) return;
setBusy(true);
try {
await revoke(username);
useToast.getState().push({ kind: 'ok', text: invited ? `已撤销对 ${username} 的邀请` : `已移除 ${username}` });
await refresh();
} catch (cause) { useToast.getState().push({ kind: 'error', text: `操作失败:${errorMessage(cause)}` }); }
finally { setBusy(false); }
}
const online = members.filter((member) => member.online);
const offline = members.filter((member) => !member.online);
const row = (username: string, detail: string | undefined, invited: boolean, removable: boolean, isOwner = false, isOnline = false) => {
const person = people[username];
const name = person?.displayName || username;
return <li className={`${styles.row} ${invited || (!isOnline && !isOwner) ? styles.dim : ''}`} key={`${invited ? 'i' : 'm'}:${username}`}>
<Avatar person={person} name={name} />
<span className={styles.who}><strong>{name}{isOwner ? <em>空间主</em> : null}</strong>
{detail || name !== username ? <small>{[name !== username ? username : '', detail].filter(Boolean).join(' · ')}</small> : null}</span>
{isOnline ? <span className={styles.online} role="img" aria-label="在线" /> : null}
{removable ? <Button className={styles.remove} variant="ghost" disabled={busy} onClick={() => { void remove(username, invited); }}>{invited ? '撤销' : '移除'}</Button> : null}
</li>;
};
const member = (item: ChannelMember) => row(item.username,
item.devices > 1 || item.online_devices > 1 ? `${item.online_devices}/${item.devices} 台设备在线` : undefined,
false, owner && item.role !== 'owner', item.role === 'owner', item.online);
return <div className={styles.pane}>
<div className={styles.head}>
<span>成员 {members.length}{maxMembers ? `/${maxMembers}` : ''}</span>
{owner ? <Button variant="primary" icon="plus" onClick={onInvite}>邀请成员</Button> : null}
</div>
<div className={styles.scroll}>
<Section title="在线" count={online.length}>{online.map(member)}</Section>
<Section title="离线" count={offline.length}>{offline.map(member)}</Section>
<Section title="待接受" count={pending.length}>{pending.map((invitation) => row(invitation.username, undefined, true, owner))}</Section>
{error ? <p className={styles.error} role="alert">{error}</p> : null}
{!owner ? <p className={styles.note}>只有空间主可以邀请或移除成员。</p> : null}
</div>
</div>;
}
+3 -2
View File
@@ -1,6 +1,7 @@
.pad {
padding: var(--space-2) var(--space-4);
border-bottom: 1px solid var(--line);
margin-top: var(--space-2);
padding: var(--space-1) var(--space-2);
border-top: 1px solid var(--line);
}
.head {
-2
View File
@@ -9,7 +9,6 @@ const SAVE_DELAY_MS = 400;
export function Notepad({ defaultOpen = false }: { defaultOpen?: boolean }) {
const snapshot = useWorkspace((s) => s.snapshot);
const setSnapshot = useWorkspace((s) => s.setSnapshot);
const editor = useWorkspace((s) => s.devices.find((d) => d.editing && !d.self)?.name);
const [open, setOpen] = useState(defaultOpen);
const [draft, setDraft] = useState<string | null>(null);
const timer = useRef<number | undefined>(undefined);
@@ -31,7 +30,6 @@ export function Notepad({ defaultOpen = false }: { defaultOpen?: boolean }) {
<span className={`${styles.chev} ${open ? styles.open : ''}`}><Icon name="chevronRight" size={16} /></span>
<span className={styles.title}>便签</span>
{open ? null : <span className={styles.preview}>{firstLine}</span>}
{editor ? <span className={styles.editing}>{editor} 正在编辑</span> : null}
</button>
{open ? (
<textarea
@@ -0,0 +1,43 @@
.tabs {
display: flex;
flex-shrink: 0;
gap: var(--space-4);
min-height: var(--bar-h);
padding: 0 var(--space-4);
border-bottom: 1px solid var(--line);
}
.tabs button {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: -1px;
padding: 0;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--text-dim);
font: inherit;
font-size: 14px;
cursor: pointer;
}
.tabs button:hover {
color: var(--text);
}
.tabs button[aria-selected="true"] {
border-bottom-color: var(--accent);
color: var(--text);
font-weight: 600;
}
.tabs span {
padding: 0 var(--space-2);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--text-dim);
font-size: 11px;
font-weight: 400;
line-height: 18px;
}
+11
View File
@@ -0,0 +1,11 @@
import styles from './PaneTabs.module.css';
export type SideTab = 'files' | 'members';
/** 桌面右栏的「文件 / 成员」切换 */
export function PaneTabs({ tab, memberCount, onTab }: { tab: SideTab; memberCount?: number; onTab: (tab: SideTab) => void }) {
return <div className={styles.tabs} role="tablist" aria-label="右侧栏">
<button type="button" role="tab" aria-selected={tab === 'files'} onClick={() => onTab('files')}>文件</button>
<button type="button" role="tab" aria-selected={tab === 'members'} onClick={() => onTab('members')}>成员{memberCount ? <span>{memberCount}</span> : null}</button>
</div>;
}
+54
View File
@@ -0,0 +1,54 @@
import { useState } from 'react';
import { useWorkspace } from '@/store/workspace';
import { Button } from '@/ui/Button';
import { CopyButton } from '@/ui/CopyButton';
import { QrCode } from '@/ui/QrCode';
import styles from './SharePanel.module.css';
/** 分享入口:空间地址与(持链接加入时的)成员链接。成员与邀请在右侧「成员」页管理,这里不列人 */
export function SharePanel({ qrOpen = false }: { qrOpen?: boolean }) {
const channel = useWorkspace((s) => s.channel);
const [showOwner, setShowOwner] = useState(false);
const [showQr, setShowQr] = useState(qrOpen);
const accountAddress = channel ? `${location.origin}/c/${channel.id}` : '';
return (
<aside className={styles.panel}>
<section>
<h2 className={styles.h}>分享</h2>
<div className={styles.link}>
<div className={styles.label}>账号空间地址</div>
<code className={styles.code}>{accountAddress}</code>
<CopyButton text={accountAddress} />
<p className={styles.note}>此地址不包含密钥。在「成员」页邀请账号后,对方登录即可自动领取密钥包。</p>
</div>
{channel?.joinPolicy === 'open' ? <>
<div className={styles.link}>
<div className={styles.label}>成员链接</div>
<code className={styles.code}>{channel.memberLink}</code>
<div className={styles.row}>
<CopyButton text={channel.memberLink} />
<Button variant="ghost" icon="scan" aria-expanded={showQr} onClick={() => setShowQr((v) => !v)}>
{showQr ? '收起二维码' : '二维码'}
</Button>
</div>
{showQr ? <QrCode text={channel.memberLink} label="成员链接二维码" /> : null}
</div>
{channel.ownerLink ? (
<div className={styles.link}>
<div className={styles.label}>所有者链接 <span className={styles.warn}>含管理权限</span></div>
<code className={styles.code}>{showOwner ? channel.ownerLink : '••••••••••••••••••••'}</code>
<div className={styles.row}>
<Button variant="ghost" icon="eye" onClick={() => setShowOwner((v) => !v)}>{showOwner ? '隐藏' : '显示'}</Button>
<CopyButton text={channel.ownerLink} />
</div>
</div>
) : null}
<p className={styles.note}>
持有链接并符合空间加入规则后,可读取空间历史内容。不隐藏 IP 与访问时间。
</p>
</> : null}
</section>
</aside>
);
}
@@ -1,48 +0,0 @@
.rail,
.open {
grid-row: 1 / -1;
grid-column: 3;
border-left: 1px solid var(--line);
background: var(--surface);
}
.rail {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
width: 56px;
padding: var(--space-3) 0;
}
.count {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 12px;
}
.countLabel {
font-family: var(--font-sans);
font-size: 10px;
}
.open {
width: 320px;
padding: var(--space-3) var(--space-4) var(--space-4);
overflow-y: auto;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-3);
}
.heading {
font-weight: 600;
}
-38
View File
@@ -1,38 +0,0 @@
import { useWorkspace } from '@/store/workspace';
import { Button } from '@/ui/Button';
import { DevicePanel } from './DevicePanel';
import styles from './SideRail.module.css';
interface Props {
open: boolean;
qrOpen: boolean;
onToggle: () => void;
}
/** 电脑右侧的设备与分享:低频,默认收成窄栏,需要时展开 */
export function SideRail({ open, qrOpen, onToggle }: Props) {
const channel = useWorkspace((s) => s.channel);
if (open) {
return (
<aside className={styles.open}>
<div className={styles.head}>
<span className={styles.heading}>成员与分享</span>
<Button variant="ghost" icon="chevronRight" label="收起" onClick={onToggle} />
</div>
<DevicePanel qrOpen={qrOpen} />
</aside>
);
}
return (
<aside className={styles.rail}>
<Button variant="ghost" icon="chevronLeft" label="展开成员与分享" onClick={onToggle} />
<span className={styles.count} title="已加入成员 / 最大成员人数">
{channel?.membersUsed ?? '—'}/{channel?.seats ?? '—'}
<span className={styles.countLabel}>人</span>
</span>
<Button variant="ghost" icon="share" label="分享" onClick={onToggle} />
</aside>
);
}
@@ -0,0 +1,108 @@
.rail {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) 0;
}
.space {
display: grid;
flex: 0 0 44px;
place-items: center;
width: 44px;
height: 44px;
border: 0;
border-radius: var(--radius-lg);
background: var(--surface-2);
color: var(--text);
font: inherit;
font-size: 16px;
font-weight: 600;
cursor: pointer;
}
.space:hover {
background: var(--line-strong);
}
.space:disabled {
opacity: 0.4;
cursor: default;
}
.current {
background: var(--accent);
color: var(--on-accent);
}
.current:hover {
background: var(--accent);
}
.action {
background: transparent;
color: var(--text-dim);
outline: 1px dashed var(--line-strong);
outline-offset: -1px;
}
.header {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
min-height: var(--bar-h);
padding: 0 var(--space-4);
border: 0;
border-bottom: 1px solid var(--line);
background: transparent;
color: var(--text);
font: inherit;
text-align: left;
cursor: pointer;
}
.header:hover {
background: var(--accent-soft);
}
.header strong {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 15px;
}
.header svg {
flex-shrink: 0;
color: var(--text-dim);
}
.raw {
color: var(--warn);
font-size: 12px;
font-weight: 600;
}
@media (width <= 899px) {
/* 手机顶栏已有空间名与设置入口 */
.header {
display: none;
}
.rail {
flex-direction: row;
justify-content: flex-start;
padding: var(--space-2) var(--space-3);
overflow-x: auto;
}
.space {
flex-basis: 40px;
width: 40px;
height: 40px;
}
}
+36
View File
@@ -0,0 +1,36 @@
import { useChannels } from '@/store/channels';
import { useAccount } from '@/store/account';
import { useWorkspace } from '@/store/workspace';
import { channelPath, navigate } from '@/app/navigation';
import { Icon } from '@/ui/Icon';
import styles from './SpaceRail.module.css';
interface RailProps { id?: string; onCreate: () => void; onJoin: () => void }
/** 空间切换:桌面是最左一列窄栏,手机是横向一排方块 */
export function SpaceRail({ id, onCreate, onJoin }: RailProps) {
const channels = useChannels((s) => s.list);
const username = useAccount((s) => s.username);
return <nav className={styles.rail} aria-label="空间列表">
{channels.map((channel) => {
const name = channel.name || channel.id.slice(0, 8);
return <button key={channel.id} type="button" className={`${styles.space} ${channel.id === id ? styles.current : ''}`}
aria-current={channel.id === id ? 'page' : undefined} title={name} aria-label={name} onClick={() => navigate(channelPath(channel.id))}>
{Array.from(name)[0]?.toLocaleUpperCase()}
</button>;
})}
<button type="button" className={`${styles.space} ${styles.action}`} title="新建空间" aria-label="新建空间" disabled={!username} onClick={onCreate}><Icon name="plus" size={20} /></button>
<button type="button" className={`${styles.space} ${styles.action}`} title="加入空间" aria-label="加入空间" onClick={onJoin}><Icon name="scan" size={20} /></button>
</nav>;
}
/** 频道列表顶部:空间名与加密状态,点击打开空间设置 */
export function SpaceHeader({ onSettings }: { onSettings: () => void }) {
const channel = useWorkspace((s) => s.channel);
const name = channel?.name || `${channel?.id.slice(0, 8) ?? ''}…`;
return <button type="button" className={styles.header} aria-label={`${name} 空间设置`} onClick={onSettings}>
{channel?.mode === 'raw' ? <span className={styles.raw}>明文</span> : <Icon name="lock" size={14} />}
<strong>{name}</strong>
<Icon name="settings" size={16} />
</button>;
}
@@ -1,9 +1,8 @@
.panel {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4) var(--space-3);
border-bottom: 1px solid var(--line);
gap: var(--space-1);
padding: var(--space-3) var(--space-2);
}
.channels {
@@ -17,7 +16,6 @@
.group {
min-width: 0;
margin-inline-start: var(--space-2);
padding-top: var(--space-2);
}
@@ -32,15 +30,15 @@
.groupToggle {
flex: 1;
min-width: 0;
min-height: 36px;
padding: 0 var(--space-2);
border-radius: var(--radius-md);
min-height: 28px;
padding: 0 var(--space-1);
border-radius: var(--radius-sm);
border: 0;
background: transparent;
color: var(--text-dim);
text-align: left;
font: inherit;
font-size: 13px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
@@ -67,6 +65,12 @@
flex-shrink: 0;
align-items: center;
gap: var(--space-1);
opacity: 0;
transition: opacity 0.12s ease-out;
}
.groupHeading:hover .categoryActions,
.groupHeading:focus-within .categoryActions {
opacity: 1;
}
@@ -142,8 +146,10 @@
}
.heading {
min-height: 36px;
padding-inline: var(--space-2);
min-height: 32px;
padding-inline: var(--space-1);
color: var(--text-dim);
font-size: 12px;
}
.folderHeading {
@@ -161,14 +167,13 @@
list-style: none;
}
/* 频道行缩进到分组标题文字之下 */
.threads {
padding-inline-start: var(--space-4);
}
.nestedThreads {
margin-inline-start: 0;
padding-inline-start: var(--space-4);
border-inline-start: 1px solid var(--line);
}
.threadRow,
@@ -213,12 +218,12 @@
align-items: center;
gap: var(--space-2);
min-width: 0;
min-height: 40px;
padding: 0 var(--space-3);
min-height: 32px;
padding: 0 var(--space-2);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
color: var(--text-dim);
text-align: left;
cursor: pointer;
}
@@ -247,12 +252,21 @@
background: var(--accent-soft);
}
.thread:hover {
color: var(--text);
}
.thread.selected {
background: var(--busy-soft);
color: var(--busy);
background: var(--accent-soft);
color: var(--text);
font-weight: 600;
}
.thread > span:first-child {
color: var(--text-dim);
font-size: 16px;
}
.empty,
.hint,
.error {
@@ -297,7 +311,11 @@
@media (width <= 899px) {
.panel {
padding: var(--space-2) var(--space-3);
padding: var(--space-1) var(--space-3);
}
.categoryActions {
opacity: 1;
}
.threads {
+10 -5
View File
@@ -11,16 +11,20 @@ interface Props {
/** 手机上点开成员与分享面板;桌面端面板在右侧栏,不传 */
onOpenPanel?: () => void;
onOpenSettings: () => void;
/** 打开右侧「成员」页(手机为成员页签) */
onOpenMembers: () => void;
/** 仅空间主:直接打开邀请弹窗 */
onInvite?: () => void;
searchOpen: boolean;
/** 有筛选条件在生效 */
filtering: boolean;
onToggleSearch: () => void;
}
export function TopBar({ onBack, onOpenPanel, onOpenSettings, searchOpen, filtering, onToggleSearch }: Props) {
export function TopBar({ onBack, onOpenPanel, onOpenSettings, onOpenMembers, onInvite, searchOpen, filtering, onToggleSearch }: Props) {
const channel = useWorkspace((s) => s.channel);
const connection = useWorkspace((s) => s.connection);
const memberCount = `${channel?.membersUsed ?? '—'}/${channel?.seats ?? '—'}`;
const memberCount = `${channel?.membersUsed ?? '—'}/${channel?.maxMembers ?? '—'}`;
const raw = channel?.mode === 'raw';
return (
@@ -38,11 +42,12 @@ export function TopBar({ onBack, onOpenPanel, onOpenSettings, searchOpen, filter
</span>
)}
</div>
<button type="button" className={styles.presence} onClick={onOpenPanel} disabled={!onOpenPanel}>
<span className={styles.count} title="已加入成员 / 最大成员人数" aria-label={`已加入 ${channel?.membersUsed ?? '未知'} 人,最多 ${channel?.seats ?? '未知'} 人`}>
<button type="button" className={styles.presence} onClick={onOpenMembers} title="查看成员">
<span className={styles.count} title="已加入成员 / 最大成员人数" aria-label={`已加入 ${channel?.membersUsed ?? '未知'} 人,最多 ${channel?.maxMembers ?? '未知'} 人`}>
{memberCount} <span className={styles.countLabel}>人</span>
</span>
</button>
{onInvite ? <Button variant="ghost" icon="plus" label="邀请成员" onClick={onInvite} /> : null}
<span className={`${styles.conn} ${styles[connection]}`} role="status">
{CONNECTION_TEXT[connection]}
</span>
@@ -56,7 +61,7 @@ export function TopBar({ onBack, onOpenPanel, onOpenSettings, searchOpen, filter
/>
<ThemeMenu />
<Button variant="ghost" icon="settings" label="空间设置" onClick={onOpenSettings} />
{onOpenPanel ? <Button variant="ghost" icon="share" label="成员与分享" onClick={onOpenPanel} /> : null}
{onOpenPanel ? <Button variant="ghost" icon="share" label="分享" onClick={onOpenPanel} /> : null}
</header>
);
}
+10 -3
View File
@@ -1,8 +1,9 @@
.desktop {
--nav-width: 320px;
--rail-width: 64px;
--nav-width: 240px;
display: grid;
grid-template-columns: var(--nav-width) minmax(260px, 1fr) 6px clamp(320px, var(--files-width, 36%), calc(100% - var(--nav-width) - 266px));
grid-template-columns: var(--rail-width) var(--nav-width) minmax(260px, 1fr) 6px clamp(320px, var(--files-width, 36%), calc(100% - var(--rail-width) - var(--nav-width) - 266px));
grid-template-rows: auto minmax(0, 1fr);
height: 100vh;
height: 100dvh;
@@ -13,6 +14,7 @@
border-bottom: 1px solid var(--line);
}
.rail,
.navigation,
.center,
.files,
@@ -23,6 +25,11 @@
min-height: 0;
}
.rail {
overflow-y: auto;
background: var(--bg);
}
.navigation {
overflow-y: auto;
border-right: 1px solid var(--line);
@@ -125,6 +132,6 @@
@media (width >= 900px) and (width <= 1100px) {
.desktop {
--nav-width: 272px;
--nav-width: 220px;
}
}
+20 -9
View File
@@ -10,15 +10,18 @@ import type { FileUploadOptions } from '@/core-bridge/types';
import type { ClipboardPayload } from '@/lib/clipboard';
import { Sheet } from '@/ui/Sheet';
import { Button } from '@/ui/Button';
import { ChannelSwitcher } from './ChannelSwitcher';
import { SpaceHeader, SpaceRail } from './SpaceRail';
import { ThreadSidebar } from './ThreadSidebar';
import { ChatPane } from './ChatPane';
import { FileExplorer } from './FileExplorer';
import { FilePaneResize } from './FilePaneResize';
import { InviteSheet } from './InviteSheet';
import { MembersPane } from './MembersPane';
import { PaneTabs, type SideTab } from './PaneTabs';
import { TopBar } from './TopBar';
import { FilterBar } from './FilterBar';
import { Notepad } from './Notepad';
import { DevicePanel } from './DevicePanel';
import { SharePanel } from './SharePanel';
import { ChannelSettingsSheet } from './ChannelSettingsSheet';
import { UploadOptionsSheet } from './UploadOptionsSheet';
import styles from './Workspace.module.css';
@@ -60,7 +63,10 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
const [settingsOpen, setSettingsOpen] = useState(false);
const [scanOpen, setScanOpen] = useState(false);
const [dragging, setDragging] = useState(false);
const [mobileView, setMobileView] = useState<'chat' | 'files'>('chat');
const [mobileView, setMobileView] = useState<'chat' | 'files' | 'members'>('chat');
const [sideTab, setSideTab] = useState<SideTab>('files');
const [inviteOpen, setInviteOpen] = useState(false);
const [memberRevision, setMemberRevision] = useState(0);
const [fileWidth, setFileWidth] = useState<number>();
const [upload, setUpload] = useState<{ files: File[]; threadId: string; folderId?: string }>();
const activeThread = threads.find((thread) => thread.id === threadId) ?? threads.find((thread) => thread.name === '常规') ?? threads[threads.length - 1];
@@ -68,6 +74,7 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
const activeFolders = folders.filter((folder) => folder.threadId === activeThreadId);
const activeFolderId = activeFolders.some((folder) => folder.id === folderId) ? folderId : undefined;
const enabled = Boolean(activeThreadId && channel?.canUpload);
const owner = channel?.role === 'owner';
const files = useWorkspace(useShallow((s) => s.order.filter((id) => {
const item = s.byId[id]; return item?.kind === 'file' && !item.metadataHidden && item.threadId === activeThreadId
&& (item.folderId ?? undefined) === activeFolderId && matchesFilter(item, filter);
@@ -103,10 +110,11 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
}
function drop(event: DragEvent) { event.preventDefault(); setDragging(false); requestFiles([...event.dataTransfer.files]); }
function deleteItem(id: string) { void deleteFiles([id]).catch((cause: unknown) => useWorkspace.setState({ error: cause instanceof Error ? cause.message : '删除失败' })); }
const navigation = <><ChannelSwitcher id={channel?.id} onCreate={onCreateChannel} />
const rail = <SpaceRail id={channel?.id} onCreate={onCreateChannel} onJoin={() => setScanOpen(true)} />;
const navigation = <><SpaceHeader onSettings={() => setSettingsOpen(true)} />
<ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} enabled={Boolean(channel?.canManageStructure)} owner={channel?.role === 'owner'} onHistory={setChannelHistory} onSelectThread={selectThread} onCreateThread={createThread} onRenameThread={renameThread}
onCreateGroup={createGroup} onRenameGroup={renameGroup} onGroupThread={groupThread} />
{desktop ? <Notepad /> : null}<Button variant="ghost" icon="scan" onClick={() => setScanOpen(true)}>加入空间</Button></>;
{desktop ? <Notepad /> : null}</>;
const chat = <ChatPane threadName={activeThread?.name ?? '选择频道'} ids={messages} enabled={enabled} onSend={(text) => sendText(text, activeThreadId)} onDelete={deleteItem} onOpenFile={setPreviewId} onFiles={requestFiles} />;
const explorer = <>
<FilterBar filter={filter} searchOpen={searchOpen} onChange={setFilter} />
@@ -120,14 +128,17 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
onDownloadItems={downloadItems} onDeleteItems={deleteFiles} onMoveItems={async (ids, target) => { if (activeThreadId) { await moveItems(ids, activeThreadId, target); setChecked(new Set()); setSelectedId(undefined); } }} />
{restricted.length ? <div className={styles.pendingCatalog}><span>等待同步文件目录</span>{restricted.map((id) => <Button variant="ghost" key={id} onClick={() => setPreviewId(id)}>查看受限文件</Button>)}</div> : null}
</>;
const members = <MembersPane owner={owner} refreshKey={memberRevision} onInvite={() => setInviteOpen(true)} />;
function openMembers() { if (desktop) setSideTab('members'); else setMobileView('members'); }
return <div className={desktop ? styles.desktop : styles.mobile} style={fileWidth ? { '--files-width': `${fileWidth}px` } as CSSProperties : undefined}
onDropCapture={() => setDragging(false)} onDragEndCapture={() => setDragging(false)}
onDragOver={(event) => { if (event.dataTransfer.types.includes('Files')) { event.preventDefault(); setDragging(true); } }} onDragLeave={() => setDragging(false)} onDrop={drop}>
<TopBar onBack={() => navigate('/')} onOpenPanel={() => setPanelOpen(true)} onOpenSettings={() => setSettingsOpen(true)} searchOpen={searchOpen} filtering={isFiltering(filter)} onToggleSearch={() => { setSearchOpen((value) => !value); if (!desktop) setMobileView('files'); }} />
{desktop ? <><aside className={styles.navigation} data-space-nav>{navigation}</aside><main className={styles.center}>{chat}</main><FilePaneResize width={fileWidth} onWidth={setFileWidth} /><aside className={styles.files} data-file-pane>{explorer}</aside></>
: <><aside className={styles.navigation}>{navigation}</aside><nav className={styles.tabs} aria-label="频道视图"><button type="button" aria-pressed={mobileView === 'chat'} onClick={() => setMobileView('chat')}>聊天</button><button type="button" aria-pressed={mobileView === 'files'} onClick={() => setMobileView('files')}>文件</button></nav><main className={styles.mobileContent}>{mobileView === 'chat' ? chat : explorer}</main></>}
<TopBar onBack={() => navigate('/')} onOpenMembers={openMembers} onInvite={owner ? () => { openMembers(); setInviteOpen(true); } : undefined} onOpenPanel={() => setPanelOpen(true)} onOpenSettings={() => setSettingsOpen(true)} searchOpen={searchOpen} filtering={isFiltering(filter)} onToggleSearch={() => { setSearchOpen((value) => !value); if (!desktop) setMobileView('files'); }} />
{desktop ? <><aside className={styles.rail}>{rail}</aside><aside className={styles.navigation} data-space-nav>{navigation}</aside><main className={styles.center}>{chat}</main><FilePaneResize width={fileWidth} onWidth={setFileWidth} /><aside className={styles.files} data-file-pane><PaneTabs tab={sideTab} memberCount={channel?.membersUsed} onTab={setSideTab} />{sideTab === 'files' ? explorer : members}</aside></>
: <>{rail}<aside className={styles.navigation}>{navigation}</aside><nav className={styles.tabs} aria-label="频道视图"><button type="button" aria-pressed={mobileView === 'chat'} onClick={() => setMobileView('chat')}>聊天</button><button type="button" aria-pressed={mobileView === 'files'} onClick={() => setMobileView('files')}>文件</button><button type="button" aria-pressed={mobileView === 'members'} onClick={() => setMobileView('members')}>成员</button></nav><main className={styles.mobileContent}>{mobileView === 'chat' ? chat : mobileView === 'files' ? explorer : members}</main></>}
{error ? <div className={styles.error} role="alert">{error}</div> : null}
{panelOpen ? <Sheet title="设备与账号邀请" onClose={() => setPanelOpen(false)}><DevicePanel /></Sheet> : null}
{inviteOpen ? <InviteSheet onClose={() => setInviteOpen(false)} onInvited={() => setMemberRevision((value) => value + 1)} /> : null}
{panelOpen ? <Sheet title="分享" onClose={() => setPanelOpen(false)}><SharePanel /></Sheet> : null}
{settingsOpen && channel ? <ChannelSettingsSheet channel={channel} onClose={() => setSettingsOpen(false)} onSave={updateSettings} /> : null}
{upload && channel?.maxDownloads !== undefined ? <UploadOptionsSheet files={upload.files} encrypted={channel?.mode === 'enc'} defaultMaxDownloads={channel.maxDownloads} onClose={() => setUpload(undefined)} onUpload={confirmUpload} /> : null}
<Suspense fallback={null}>{previewId ? <PreviewSheet id={previewId} onClose={() => setPreviewId(undefined)} onDelete={deleteItem} /> : null}