Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a08aa7568f | ||
|
|
3b062687cd | ||
|
|
bef8898cd8 |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"x-backend-contract-sha256": "2a28bf52c136caa7c0b86fc2630153cb71529e3c5d85c3b7196e9d9a04d63b75",
|
||||
"x-backend-contract-sha256": "8e42178b1777925d0d9226bf2627fb82e637e0c89deb079d3a4d4eecd3a5fa4e",
|
||||
"$defs": {
|
||||
"AccountChannelBody": {
|
||||
"type": "object",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using WebDrop.Core;
|
||||
using WebDrop.Storage;
|
||||
|
||||
@@ -15,6 +16,8 @@ public static partial class Endpoints
|
||||
api.MapPatch("/accounts/profile", async (HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
// 动图头像比其他请求大得多,单独放宽(全局上限 2 MB)
|
||||
if (ctx.Features.Get<IHttpMaxRequestBodySizeFeature>() is { IsReadOnly: false } size) size.MaxRequestBodySize = 8 * 1024 * 1024;
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountProfileRequest, ct);
|
||||
var profile = accounts.UpdateProfile(username, req.DisplayName, req.Avatar);
|
||||
svc.AccountProfileChanged(username);
|
||||
|
||||
@@ -22,10 +22,14 @@ public sealed partial class ChannelService
|
||||
/// <summary>补上默认身份组(只补一次)。读取时只在内存里补,下一次写入才落库</summary>
|
||||
private static void EnsureDefaultRole(WorkspaceState state, long now)
|
||||
{
|
||||
if (state.RolesSeeded) return;
|
||||
if (!state.RolesSeeded)
|
||||
{
|
||||
if (!state.Roles.Any(r => r.Id == WorkspaceRole.DefaultId)) state.Roles.Insert(0, WorkspaceRole.CreateDefault(now));
|
||||
state.RolesSeeded = true;
|
||||
}
|
||||
// Owner 组永远排在最前面
|
||||
if (!state.Roles.Any(r => r.Id == WorkspaceRole.OwnerId)) state.Roles.Insert(0, WorkspaceRole.CreateOwner(now));
|
||||
}
|
||||
public WorkspaceState GetWorkspace(Id16 id, Credentials creds) => store.Read(tx =>
|
||||
{
|
||||
var (space, principal) = Authenticate(tx, id, creds, Membership.Required);
|
||||
@@ -112,12 +116,12 @@ public sealed partial class ChannelService
|
||||
{
|
||||
value = value?.Trim();
|
||||
if (string.IsNullOrEmpty(value)) return null;
|
||||
if (value.Length > 12 || value.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (value.Length > WorkspacePermission.MaxLabelLength || value.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
return value;
|
||||
}
|
||||
private static void ApplyRole(WorkspaceRole role, string name, string? prefix, string? suffix, string? color, IReadOnlyList<string> permissions)
|
||||
{
|
||||
if (color is not null && !WorkspacePermission.Colors.Contains(color)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (color is not null && !WorkspacePermission.IsValidColor(color)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (permissions.Any(p => !WorkspacePermission.All.Contains(p))) throw new WebDropException(ErrorCode.BadRequest);
|
||||
role.Name = WorkspaceName(name); role.Prefix = RoleLabel(prefix); role.Suffix = RoleLabel(suffix); role.Color = color;
|
||||
role.Permissions = permissions.Distinct().ToList();
|
||||
@@ -134,13 +138,25 @@ public sealed partial class ChannelService
|
||||
return (state, created!);
|
||||
}
|
||||
public WorkspaceState UpdateWorkspaceRole(Id16 id, Credentials creds, string roleId, string name, string? prefix, string? suffix, string? color, IReadOnlyList<string> permissions) =>
|
||||
ChangeWorkspace(id, creds, (_, state, _) => ApplyRole(FindRole(state, roleId), name, prefix, suffix, color, permissions));
|
||||
ChangeWorkspace(id, creds, (_, state, _) =>
|
||||
{
|
||||
var role = FindRole(state, roleId);
|
||||
// Owner 组的名称和权限固定,只有前后缀和颜色可改
|
||||
if (role.Id == WorkspaceRole.OwnerId) ApplyRole(role, role.Name, prefix, suffix, color, WorkspacePermission.All);
|
||||
else ApplyRole(role, name, prefix, suffix, color, permissions);
|
||||
});
|
||||
public WorkspaceState DeleteWorkspaceRole(Id16 id, Credentials creds, string roleId) =>
|
||||
ChangeWorkspace(id, creds, (_, state, _) => state.Roles.Remove(FindRole(state, roleId)));
|
||||
ChangeWorkspace(id, creds, (_, state, _) =>
|
||||
{
|
||||
var role = FindRole(state, roleId);
|
||||
if (role.Id == WorkspaceRole.OwnerId) throw new WebDropException(ErrorCode.BadRequest);
|
||||
state.Roles.Remove(role);
|
||||
});
|
||||
public WorkspaceState SetWorkspaceRoleMembers(Id16 id, Credentials creds, string roleId, IReadOnlyList<string> members) =>
|
||||
ChangeWorkspace(id, creds, (tx, state, _) =>
|
||||
{
|
||||
var role = FindRole(state, roleId);
|
||||
if (role.Id == WorkspaceRole.OwnerId) throw new WebDropException(ErrorCode.BadRequest);
|
||||
var distinct = members.Select(m => m.Trim().ToLowerInvariant()).Distinct().ToList();
|
||||
if (distinct.Count > 256) throw new WebDropException(ErrorCode.BadRequest);
|
||||
// 只能把已加入空间的账号放进身份组;空间主本来就有全部权限,不必加入
|
||||
|
||||
@@ -36,22 +36,37 @@ public sealed class WorkspaceLocation
|
||||
[JsonPropertyName("authorAccount")] public string? AuthorAccount { get; init; }
|
||||
}
|
||||
/// <summary>身份组可授予的权限。空间主拥有全部权限,不属于任何身份组</summary>
|
||||
public static class WorkspacePermission
|
||||
public static partial class WorkspacePermission
|
||||
{
|
||||
/// <summary>移除成员、撤销邀请</summary>
|
||||
public const string RemoveMembers = "members.remove";
|
||||
/// <summary>重命名频道、调整频道所属分组、管理分组(新建频道涉及密钥,仍只有空间主)</summary>
|
||||
public const string ManageStructure = "structure.manage";
|
||||
public static readonly IReadOnlyList<string> All = [RemoveMembers, ManageStructure];
|
||||
public static readonly IReadOnlyList<string> Colors = ["gray", "blue", "green", "amber", "red", "purple"];
|
||||
/// <summary>前后缀(支持行内 Markdown)的长度上限:界面不限制,这里只防止空间数据被撑大</summary>
|
||||
public const int MaxLabelLength = 2000;
|
||||
/// <summary>预设名,或自定义:纯色 "#rrggbb"、渐变 "g:#rrggbb,#rrggbb[,#rrggbb]"(2~5 个色标,从左到右);后面可以接动画效果 "|breathe"(呼吸) "|blink"(闪烁) "|marquee"(跑马灯,仅渐变) "|cycle"(色相循环)</summary>
|
||||
public static bool IsValidColor(string color) => Colors.Contains(color) || CustomColor().IsMatch(color);
|
||||
[System.Text.RegularExpressions.GeneratedRegex("^(#[0-9a-fA-F]{6}|g:#[0-9a-fA-F]{6}(,#[0-9a-fA-F]{6}){1,4})(\\|(breathe|blink|marquee|cycle))?$")]
|
||||
private static partial System.Text.RegularExpressions.Regex CustomColor();
|
||||
/// <summary>早期版本的预设色名,老数据里可能还有;新的界面只写自定义颜色</summary>
|
||||
public static readonly IReadOnlyList<string> Colors = ["gray", "blue", "green", "amber", "red", "purple", "sunset", "ocean", "aurora", "candy", "gold", "rainbow"];
|
||||
}
|
||||
public sealed class WorkspaceRole
|
||||
{
|
||||
/// <summary>空间主所在的「Owner 组」,每个空间都有,id 固定。成员就是空间主本人,权限固定为全部,只能改前后缀和颜色</summary>
|
||||
public const string OwnerId = "00000000-0000-4000-8000-000000000000";
|
||||
public static WorkspaceRole CreateOwner(long now) => new()
|
||||
{
|
||||
Id = OwnerId, Name = "Owner 组", Suffix = "Owner", Color = "g:#d99a1c,#f2c94c,#c27a0a", CreatedAt = now,
|
||||
Permissions = [.. WorkspacePermission.All],
|
||||
};
|
||||
|
||||
/// <summary>每个空间都有的默认「管理组」,id 固定,老空间第一次读写时补上</summary>
|
||||
public const string DefaultId = "00000000-0000-4000-8000-000000000001";
|
||||
public static WorkspaceRole CreateDefault(long now) => new()
|
||||
{
|
||||
Id = DefaultId, Name = "管理组", Suffix = "管理员", Color = "blue", CreatedAt = now,
|
||||
Id = DefaultId, Name = "管理组", Suffix = "管理员", Color = "#3b6ea8", CreatedAt = now,
|
||||
Permissions = [.. WorkspacePermission.All],
|
||||
};
|
||||
|
||||
|
||||
@@ -16,14 +16,20 @@ public sealed partial class SqliteAccountStore
|
||||
: new AccountProfile { Username = username, DisplayName = username, ModifiedAt = 0 };
|
||||
}
|
||||
}
|
||||
/// <summary>动图头像保留全部帧,允许比静态头像大得多(约 4.5 MB)</summary>
|
||||
private const int MaxGifAvatarChars = 6_000_000;
|
||||
public AccountProfile UpdateProfile(string username, string displayName, string? avatar)
|
||||
{
|
||||
if (displayName is null || displayName.Trim().Length > 48 || displayName.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (avatar is not null)
|
||||
{
|
||||
string? prefix = new[] { "data:image/png;base64,", "data:image/jpeg;base64,", "data:image/webp;base64," }.FirstOrDefault(p => avatar.StartsWith(p, StringComparison.Ordinal));
|
||||
if (prefix is null || avatar.Length > 350_000 || avatar.Length <= prefix.Length) throw new WebDropException(ErrorCode.BadRequest);
|
||||
try { _ = Convert.FromBase64String(avatar[prefix.Length..]); }
|
||||
string? prefix = new[] { "data:image/png;base64,", "data:image/jpeg;base64,", "data:image/webp;base64,", "data:image/gif;base64," }.FirstOrDefault(p => avatar.StartsWith(p, StringComparison.Ordinal));
|
||||
if (prefix is null || avatar.Length > (prefix.Contains("gif") ? MaxGifAvatarChars : 350_000) || avatar.Length <= prefix.Length) throw new WebDropException(ErrorCode.BadRequest);
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(avatar[prefix.Length..]);
|
||||
if (prefix.Contains("gif") && !(bytes.Length > 6 && bytes[0] == (byte)'G' && bytes[1] == (byte)'I' && bytes[2] == (byte)'F')) throw new WebDropException(ErrorCode.BadRequest);
|
||||
}
|
||||
catch (FormatException) { throw new WebDropException(ErrorCode.BadRequest); }
|
||||
}
|
||||
lock (_gate)
|
||||
|
||||
Binary file not shown.
@@ -90,9 +90,17 @@ public sealed class WorkspaceStructureTests
|
||||
Assert.Equal("管理员", role.Suffix); Assert.Equal(["alice"], role.Members);
|
||||
// 默认的「管理组」一直在;删掉自己建的不影响它,删掉默认组后也不会再自动出现
|
||||
var remaining = env.Service.DeleteWorkspaceRole(ch.Id, owner, created.Role.Id);
|
||||
Assert.Equal(WorkspaceRole.DefaultId, Assert.Single(remaining.Roles).Id);
|
||||
Assert.Empty(env.Service.DeleteWorkspaceRole(ch.Id, owner, WorkspaceRole.DefaultId).Roles);
|
||||
Assert.Empty(env.Service.CreateWorkspaceChannel(ch.Id, owner, "再写一次", null).Roles);
|
||||
Assert.Equal([WorkspaceRole.OwnerId, WorkspaceRole.DefaultId], remaining.Roles.Select(r => r.Id));
|
||||
Assert.Equal(WorkspaceRole.OwnerId, Assert.Single(env.Service.DeleteWorkspaceRole(ch.Id, owner, WorkspaceRole.DefaultId).Roles).Id);
|
||||
// Owner 组删不掉、不能加成员,但前后缀和颜色可以改,名称和权限保持不变
|
||||
Assert.Throws<WebDropException>(() => env.Service.DeleteWorkspaceRole(ch.Id, owner, WorkspaceRole.OwnerId));
|
||||
Assert.Throws<WebDropException>(() => env.Service.SetWorkspaceRoleMembers(ch.Id, owner, WorkspaceRole.OwnerId, ["alice"]));
|
||||
var edited = env.Service.UpdateWorkspaceRole(ch.Id, owner, WorkspaceRole.OwnerId, "改名", "**主**", "*Owner*", "g:#ff0000,#0000ff", []).Roles.Single();
|
||||
Assert.Equal("Owner 组", edited.Name); Assert.Equal("**主**", edited.Prefix); Assert.Equal("g:#ff0000,#0000ff", edited.Color); Assert.Equal(WorkspacePermission.All, edited.Permissions);
|
||||
Assert.Throws<WebDropException>(() => env.Service.UpdateWorkspaceRole(ch.Id, owner, WorkspaceRole.OwnerId, "x", null, null, "#12345", []));
|
||||
Assert.Equal("g:#ff0000,#0000ff|marquee", env.Service.UpdateWorkspaceRole(ch.Id, owner, WorkspaceRole.OwnerId, "x", null, null, "g:#ff0000,#0000ff|marquee", []).Roles.Single().Color); // 动画效果跟在颜色后面
|
||||
Assert.Throws<WebDropException>(() => env.Service.UpdateWorkspaceRole(ch.Id, owner, WorkspaceRole.OwnerId, "x", null, null, "#ff0000|spin", []));
|
||||
Assert.Single(env.Service.CreateWorkspaceChannel(ch.Id, owner, "再写一次", null).Roles);
|
||||
}
|
||||
[Fact]
|
||||
public void StructureStoredBeforeRolesExistedStillLoadsWithAnEmptyRoleList()
|
||||
@@ -104,7 +112,7 @@ public sealed class WorkspaceStructureTests
|
||||
public void EverySpaceStartsWithADefaultAdminGroupThatCanHoldMembers()
|
||||
{
|
||||
var (env, ch, owner, alice, bob, _) = RoleSpace(); using var _env = env;
|
||||
var admin = Assert.Single(env.Service.GetWorkspace(ch.Id, owner).Roles);
|
||||
var admin = Assert.Single(env.Service.GetWorkspace(ch.Id, owner).Roles, r => r.Id == WorkspaceRole.DefaultId);
|
||||
Assert.Equal(WorkspaceRole.DefaultId, admin.Id); Assert.Equal("管理组", admin.Name); Assert.Empty(admin.Members);
|
||||
Assert.Contains(WorkspacePermission.RemoveMembers, admin.Permissions); Assert.Contains(WorkspacePermission.ManageStructure, admin.Permissions);
|
||||
env.Service.SetWorkspaceRoleMembers(ch.Id, owner, admin.Id, ["alice"]);
|
||||
|
||||
Generated
+14
@@ -8,8 +8,10 @@
|
||||
"name": "@webdrop/web",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@material/material-color-utilities": "^0.3.0",
|
||||
"@webdrop/core": "file:../packages/core",
|
||||
"cap-widget": "^0.1.58",
|
||||
"gifenc": "^1.0.3",
|
||||
"idb": "^8.0.3",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
@@ -303,6 +305,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@material/material-color-utilities": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@material/material-color-utilities/-/material-color-utilities-0.3.0.tgz",
|
||||
"integrity": "sha512-ztmtTd6xwnuh2/xu+Vb01btgV8SQWYCaK56CkRK8gEkWe5TuDyBcYJ0wgkMRn+2VcE9KUmhvkz+N9GHrqw/C0g==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -1530,6 +1538,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/gifenc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/gifenc/-/gifenc-1.0.3.tgz",
|
||||
"integrity": "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@material/material-color-utilities": "^0.3.0",
|
||||
"@webdrop/core": "file:../packages/core",
|
||||
"cap-widget": "^0.1.58",
|
||||
"gifenc": "^1.0.3",
|
||||
"idb": "^8.0.3",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Generated from backend DTOs; do not edit.
|
||||
// backend-contract-sha256: 2a28bf52c136caa7c0b86fc2630153cb71529e3c5d85c3b7196e9d9a04d63b75
|
||||
// backend-contract-sha256: 8e42178b1777925d0d9226bf2627fb82e637e0c89deb079d3a4d4eecd3a5fa4e
|
||||
|
||||
export interface AccountChannelBody {
|
||||
channel_id: string;
|
||||
|
||||
@@ -15,6 +15,7 @@ import { serial } from './worker/serial';
|
||||
import { errorMessage } from './errors';
|
||||
import { accountStatus, loginAccount, logoutAccount, registerAccount, syncAccount, listAccountInvitations, acceptInvitation, inviteChannelUser, listChannelInvitations, revokeChannelInvitation } from './worker/account';
|
||||
import { createFolder, createThread, createGroup, renameGroup, deleteGroup, createRole, updateRole, deleteRole, setRoleMembers, groupThread, moveItems, renameFolder, renameThread, setChannelHistory } from './worker/channelThreads';
|
||||
import { refreshLoadedWorkspaces } from './worker/workspaceStructure';
|
||||
import { restoreAccount, updatePersonal, listAccountDevices, renameAccountDevice, revokeAccountDevice, getSavedLogin, clearSavedLogin } from './worker/account';
|
||||
|
||||
interface WorkerRequest { requestId: number; operation: string; payload: unknown }
|
||||
@@ -44,7 +45,7 @@ async function dispatch(operation: string, input: unknown): Promise<unknown> {
|
||||
case 'beginPasskeyLogin': return beginPasskeyLogin(String(payload.username ?? ''));
|
||||
case 'finishPasskeyLogin': return finishPasskeyLogin(String(payload.username ?? ''), id, String(payload.credential ?? ''), payload.prf as ArrayBuffer | undefined, payload.remember !== false);
|
||||
case 'unlockPasskeyLogin': return unlockPasskeyLogin(String(payload.password ?? ''), payload.remember !== false);
|
||||
case 'updatePersonal': await updatePersonal(String(payload.displayName ?? ''), typeof payload.avatar === 'string' ? payload.avatar : undefined); return accountStatus();
|
||||
case 'updatePersonal': await updatePersonal(String(payload.displayName ?? ''), typeof payload.avatar === 'string' ? payload.avatar : undefined); void refreshLoadedWorkspaces(); return accountStatus();
|
||||
case 'accountDevices': return listAccountDevices();
|
||||
case 'renameAccountDevice': await renameAccountDevice(id, String(payload.name ?? '')); return true;
|
||||
case 'revokeAccountDevice': await revokeAccountDevice(id); return accountStatus();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { canDecryptRecord, objectAuthorAccount, objectEventProjection, readDataK
|
||||
import { getResource, cacheResource, type SavedResource } from '../database';
|
||||
import { channelCache } from './resourceMetadata';
|
||||
import { cachedPerson, loadWorkspace } from './workspaceStructure';
|
||||
import { avatarThumbnail } from './senderSnapshot';
|
||||
import { displayAvatar } from './senderSnapshot';
|
||||
import type { FileItem, Item, SenderInfo, Snapshot } from '../types';
|
||||
import { previewKindOf } from '@/lib/fileKind';
|
||||
import { blobs, readers } from './sessions';
|
||||
@@ -37,7 +37,7 @@ async function authorOf(resourceId: string, itemId: string): Promise<SenderInfo
|
||||
const username = objectAuthorAccount(resourceId, itemId);
|
||||
if (!username) return undefined;
|
||||
const person = cachedPerson(resourceId, username);
|
||||
const avatar = person?.avatar ? await avatarThumbnail(person.avatar) : undefined;
|
||||
const avatar = person?.avatar ? await displayAvatar(person.avatar) : undefined;
|
||||
return { username, displayName: person?.displayName || username, ...(avatar ? { avatar } : {}) };
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@ export async function senderSnapshot(): Promise<SenderInfo | undefined> {
|
||||
return { username: sender.username, displayName: sender.displayName, ...(avatar ? { avatar } : {}) };
|
||||
}
|
||||
|
||||
/** 本机显示用:动图头像原样保留(会动);其他压成小缩略图。发进消息里的快照仍用 avatarThumbnail,不能把几 MB 的动图塞进每条消息 */
|
||||
export function displayAvatar(value: string): Promise<string | undefined> {
|
||||
return value.startsWith('data:image/gif') ? Promise.resolve(value) : avatarThumbnail(value);
|
||||
}
|
||||
|
||||
export function avatarThumbnail(value: string): Promise<string | undefined> {
|
||||
let thumbnail = thumbnails.get(value);
|
||||
if (!thumbnail) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getResource, cacheResource } from '../database';
|
||||
import { getSession } from './sessions';
|
||||
import { apiFetch } from './http';
|
||||
import { publishWorkspace } from './presentation';
|
||||
import { avatarThumbnail } from './senderSnapshot';
|
||||
import { displayAvatar } from './senderSnapshot';
|
||||
|
||||
const current = new Map<string, WorkspaceBody>();
|
||||
export function clearWorkspaceStructures(): void { current.clear(); }
|
||||
@@ -19,7 +19,7 @@ async function publish(id: string, body: WorkspaceBody): Promise<WorkspaceBody>
|
||||
current.set(id, body);
|
||||
const resource = await getResource(id);
|
||||
if (resource) await cacheResource(resource, { workspaceMetadata: body });
|
||||
const people = await Promise.all(body.people.map(async person => ({ ...person, avatar: person.avatar ? await avatarThumbnail(person.avatar) : undefined })));
|
||||
const people = await Promise.all(body.people.map(async person => ({ ...person, avatar: person.avatar ? await displayAvatar(person.avatar) : undefined })));
|
||||
if (current.get(id) !== body) return current.get(id) ?? body;
|
||||
publishWorkspace(id, { t: 'structure', state: body.structure, people });
|
||||
return body;
|
||||
@@ -37,6 +37,10 @@ export async function loadWorkspace(id: string): Promise<WorkspaceBody> {
|
||||
const body = await apiFetch(`/spaces/${id}/structure`, { headers: session.authHeaders('read') }).then(r => r.json() as Promise<WorkspaceBody>);
|
||||
return publish(id, body);
|
||||
}
|
||||
/** 自己改了头像或名字后立刻重新拉取所有已打开空间的成员资料(其他人靠服务端的设置变更通知) */
|
||||
export async function refreshLoadedWorkspaces(): Promise<void> {
|
||||
await Promise.all([...current.keys()].map((id) => loadWorkspace(id).catch(() => undefined)));
|
||||
}
|
||||
export async function changeWorkspace(id: string, path: string, method: 'POST' | 'PATCH' | 'PUT' | 'DELETE', value?: unknown): Promise<WorkspaceBody> {
|
||||
const { session } = await getSession(id);
|
||||
const body = await apiFetch(`/spaces/${id}/${path}`, {
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
/* 身份组页内容多:加高,列表在滚动时固定在左边 */
|
||||
.tall {
|
||||
height: min(560px, 80dvh);
|
||||
height: min(760px, 84dvh);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
|
||||
@@ -67,7 +67,7 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
||||
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);
|
||||
|
||||
return (
|
||||
<Sheet title="空间设置" onClose={onClose} wide>
|
||||
<Sheet title="空间设置" onClose={onClose} wide={tab === 'roles' ? 'xl' : true}>
|
||||
<div className={`${styles.layout} ${tab === 'roles' ? styles.tall : ''}`}>
|
||||
<div className={styles.tabs} role="tablist" aria-orientation="vertical" aria-label="空间设置分类">
|
||||
{TABS.filter((item) => owner || item.id !== 'roles').map((item) => <button key={item.id} type="button" role="tab" aria-selected={tab === item.id} onClick={() => setTab(item.id)}>
|
||||
|
||||
@@ -13,10 +13,6 @@
|
||||
vertical-align: 1px;
|
||||
}
|
||||
|
||||
.owner {
|
||||
--c: var(--text);
|
||||
}
|
||||
|
||||
.gray {
|
||||
--c: var(--text-dim);
|
||||
}
|
||||
@@ -40,3 +36,99 @@
|
||||
.purple {
|
||||
--c: var(--role-purple);
|
||||
}
|
||||
|
||||
.gradient {
|
||||
background: var(--g) text;
|
||||
box-shadow: inset 0 0 0 1px var(--line-strong);
|
||||
color: transparent;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sunset {
|
||||
--g: var(--grad-sunset);
|
||||
}
|
||||
|
||||
.ocean {
|
||||
--g: var(--grad-ocean);
|
||||
}
|
||||
|
||||
.aurora {
|
||||
--g: var(--grad-aurora);
|
||||
}
|
||||
|
||||
.candy {
|
||||
--g: var(--grad-candy);
|
||||
}
|
||||
|
||||
.gold {
|
||||
--g: var(--grad-gold);
|
||||
}
|
||||
|
||||
.rainbow {
|
||||
--g: var(--grad-rainbow);
|
||||
}
|
||||
|
||||
.badge code {
|
||||
font-family: monospace;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.badge a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 动画效果。系统设置了「减少动态效果」时一律不播放 */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.breathe {
|
||||
animation: breathe 2.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.blink {
|
||||
animation: blink 1.2s steps(1, end) infinite;
|
||||
}
|
||||
|
||||
.cycle {
|
||||
animation: cycle 6s linear infinite;
|
||||
}
|
||||
|
||||
.marquee.gradient {
|
||||
background-repeat: repeat-x;
|
||||
background-size: 200% 100%;
|
||||
animation: marquee 3s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cycle {
|
||||
to {
|
||||
filter: hue-rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
to {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useMemo } from 'react';
|
||||
import { badgesOf, type Badge } from '@/lib/roles';
|
||||
import { InlineMarkdown } from '@/lib/inlineMarkdown';
|
||||
import { badgesOf, customColorStyle, effectOf, isGradient, type Badge } from '@/lib/roles';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import styles from './RoleBadges.module.css';
|
||||
|
||||
function List({ items }: { items: Badge[] }) {
|
||||
return <>{items.map((badge) => <span key={badge.key} className={`${styles.badge} ${styles[badge.color]}`}>{badge.text}</span>)}</>;
|
||||
return <>{items.map((badge) => <span key={badge.key} style={customColorStyle(badge.color)} className={`${styles.badge} ${styles[badge.color.split('|')[0]!] ?? ''} ${isGradient(badge.color) ? styles.gradient : ''} ${styles[effectOf(badge.color) ?? 'still'] ?? ''}`}><InlineMarkdown text={badge.text} /></span>)}</>;
|
||||
}
|
||||
|
||||
/** 名字前后的身份标签:空间主固定一个,其余是身份组里自定义的前缀、后缀 */
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.head,
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 300px) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
@media (width < 720px) {
|
||||
.body {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.modes,
|
||||
.stops {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.button {
|
||||
min-height: 28px;
|
||||
padding: 0 var(--space-2);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button[aria-checked='true'] {
|
||||
border-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 20px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.stop {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stop[aria-checked='true'] {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.sv,
|
||||
.hue {
|
||||
position: relative;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.sv {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.hue {
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 2px solid var(--surface);
|
||||
border-radius: var(--radius-avatar);
|
||||
box-shadow: 0 0 0 1px var(--line-strong);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hexRow,
|
||||
.import {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hexRow input {
|
||||
width: 96px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.draw {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.drawHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--space-1);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent-soft);
|
||||
color: var(--text-dim);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chip {
|
||||
height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { extractColors, hexToHsv, hsvToHex, parseHex, type Hsv } from '@/lib/colorMath';
|
||||
import { drawColors, type DrawnColor } from '@/lib/colorDraw';
|
||||
import { EFFECTS, gradientOf, isGradient, joinColor, splitColor, stopsOf } from '@/lib/roles';
|
||||
import styles from './RoleColorPicker.module.css';
|
||||
|
||||
const MAX_STOPS = 5;
|
||||
const HUES = 'linear-gradient(90deg, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)';
|
||||
|
||||
/** 在一个元素上按住拖动,回调给出 0~1 的相对位置 */
|
||||
function drag(onMove: (x: number, y: number) => void) {
|
||||
return (event: React.PointerEvent<HTMLElement>) => {
|
||||
const box = event.currentTarget.getBoundingClientRect();
|
||||
const move = (e: { clientX: number; clientY: number }) => onMove(Math.min(1, Math.max(0, (e.clientX - box.left) / box.width)), Math.min(1, Math.max(0, (e.clientY - box.top) / box.height)));
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
move(event);
|
||||
const target = event.currentTarget;
|
||||
const onMoveEvent = (e: PointerEvent) => move(e);
|
||||
const stop = () => { target.removeEventListener('pointermove', onMoveEvent); target.removeEventListener('pointerup', stop); target.removeEventListener('pointercancel', stop); };
|
||||
target.addEventListener('pointermove', onMoveEvent); target.addEventListener('pointerup', stop); target.addEventListener('pointercancel', stop);
|
||||
};
|
||||
}
|
||||
|
||||
/** 一个颜色的选择器:饱和度/明度方块 + 色相条 + 色值输入 */
|
||||
function ColorField({ value, onChange }: { value: string; onChange: (hex: string) => void }) {
|
||||
// 拖动时在 HSV 里保存色相,否则拖到灰色/黑色时色相会丢
|
||||
const [hsv, setHsv] = useState<Hsv>(() => hexToHsv(value));
|
||||
const last = useRef(value);
|
||||
const [text, setText] = useState(value);
|
||||
if (last.current !== value) { last.current = value; const next = hexToHsv(value); setHsv((old) => (hsvToHex(old) === value ? old : next)); setText(value); }
|
||||
const apply = (next: Hsv) => { setHsv(next); const hex = hsvToHex(next); last.current = hex; setText(hex); onChange(hex); };
|
||||
|
||||
return <div className={styles.field}>
|
||||
<div className={styles.sv} style={{ background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, ${hsvToHex({ h: hsv.h, s: 1, v: 1 })})` }}
|
||||
onPointerDown={drag((x, y) => apply({ ...hsv, s: x, v: 1 - y }))} role="presentation">
|
||||
<span className={styles.thumb} style={{ left: `${hsv.s * 100}%`, top: `${(1 - hsv.v) * 100}%`, background: value }} />
|
||||
</div>
|
||||
<div className={styles.hue} style={{ background: HUES }} onPointerDown={drag((x) => apply({ ...hsv, h: x * 360 }))} role="presentation">
|
||||
<span className={styles.thumb} style={{ left: `${(hsv.h / 360) * 100}%`, top: '50%', background: hsvToHex({ h: hsv.h, s: 1, v: 1 }) }} />
|
||||
</div>
|
||||
<label className={styles.hexRow}>色值
|
||||
<input value={text} maxLength={7} spellCheck={false} aria-label="色值"
|
||||
onChange={(event) => { setText(event.target.value); const hex = parseHex(event.target.value); if (hex) { last.current = hex; setHsv(hexToHsv(hex)); onChange(hex); } }} />
|
||||
</label>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 身份组前后缀的颜色:自定义纯色,或 2~5 个色标的渐变(均匀分布,从左到右)。
|
||||
* 也可以把在线渐变生成器(如 cssgradient.io)复制出来的 CSS 粘贴进来,自动取出其中的颜色。
|
||||
*/
|
||||
export function RoleColorPicker({ color: full, onPick: pickFull }: { color: string; onPick: (color: string) => void }) {
|
||||
const { base: color, effect } = splitColor(full);
|
||||
// 选颜色时保留已经选好的动画效果;纯色没有跑马灯
|
||||
const onPick = (next: string) => pickFull(joinColor(next, effect === 'marquee' && !isGradient(next) ? undefined : effect));
|
||||
const [seed, setSeed] = useState(Math.random);
|
||||
const stops = stopsOf(color);
|
||||
const gradient = isGradient(color) && stops.length > 0;
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [paste, setPaste] = useState('');
|
||||
const at = Math.min(selected, Math.max(0, stops.length - 1));
|
||||
const drawn: DrawnColor[] = useMemo(() => drawColors(() => seed, 6, gradient ? 'gradient' : 'solid'), [seed, gradient]);
|
||||
const solid = parseHex(color) ?? '#3b6ea8';
|
||||
const setStop = (index: number, hex: string) => onPick(gradientOf(stops.map((stop, i) => (i === index ? hex : stop))));
|
||||
|
||||
function importText(text: string) {
|
||||
setPaste(text);
|
||||
const colors = extractColors(text, MAX_STOPS);
|
||||
if (colors.length >= 2) { onPick(gradientOf(colors)); setSelected(0); }
|
||||
else if (colors.length === 1) onPick(colors[0]!);
|
||||
}
|
||||
|
||||
return <div className={styles.picker}>
|
||||
<div className={styles.head}>
|
||||
<div className={styles.modes} role="radiogroup" aria-label="颜色类型">
|
||||
<button type="button" role="radio" aria-checked={!gradient} className={styles.button} onClick={() => onPick(solid)}>纯色</button>
|
||||
<button type="button" role="radio" aria-checked={gradient} className={styles.button}
|
||||
onClick={() => onPick(gradientOf(gradient ? stops : [solid, hsvToHex({ ...hexToHsv(solid), h: (hexToHsv(solid).h + 60) % 360 })]))}>渐变</button>
|
||||
</div>
|
||||
{gradient ? <>
|
||||
<div className={styles.bar} style={{ background: `linear-gradient(90deg, ${stops.join(', ')})` }} aria-hidden />
|
||||
<div className={styles.stops} role="radiogroup" aria-label="色标">
|
||||
{stops.map((stop, index) => <button key={index} type="button" role="radio" aria-checked={index === at} aria-label={`色标 ${index + 1}`}
|
||||
className={styles.stop} style={{ background: stop }} onClick={() => setSelected(index)} />)}
|
||||
<button type="button" className={styles.button} disabled={stops.length >= MAX_STOPS}
|
||||
onClick={() => { onPick(gradientOf([...stops, stops[stops.length - 1]!])); setSelected(stops.length); }}>加色标</button>
|
||||
<button type="button" className={styles.button} disabled={stops.length <= 2}
|
||||
onClick={() => { onPick(gradientOf(stops.filter((_, i) => i !== at))); setSelected(0); }}>删除</button>
|
||||
<button type="button" className={styles.button} onClick={() => onPick(gradientOf([...stops].reverse()))}>反转</button>
|
||||
</div>
|
||||
</> : null}
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
{gradient ? <ColorField key={at} value={stops[at]!} onChange={(hex) => setStop(at, hex)} /> : <ColorField value={solid} onChange={onPick} />}
|
||||
<div className={styles.side}>
|
||||
<div className={styles.draw}>
|
||||
<div className={styles.drawHead}>随机{gradient ? '渐变' : '纯色'} · Material 3<button type="button" className={styles.button} onClick={() => setSeed(Math.random())}>换一批</button></div>
|
||||
<div className={styles.cards}>
|
||||
{drawn.map((item, index) => <button key={index} type="button" className={styles.card} title={item.label} onClick={() => { onPick(item.color); setSelected(0); }}>
|
||||
<span className={styles.chip} style={{ background: isGradient(item.color) ? `linear-gradient(90deg, ${stopsOf(item.color).join(', ')})` : item.color }} />
|
||||
<small>{item.label}</small></button>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.modes} role="radiogroup" aria-label="动画效果">
|
||||
<button type="button" role="radio" aria-checked={!effect} className={styles.button} onClick={() => pickFull(color)}>无动画</button>
|
||||
{EFFECTS.map((item) => <button key={item.id} type="button" role="radio" aria-checked={effect === item.id} className={styles.button}
|
||||
disabled={item.id === 'marquee' && !gradient} title={item.id === 'marquee' && !gradient ? '跑马灯需要渐变色' : undefined}
|
||||
onClick={() => pickFull(joinColor(color, item.id))}>{item.label}</button>)}
|
||||
</div>
|
||||
<label className={styles.import}>从在线生成器导入
|
||||
<input value={paste} placeholder="粘贴 CSS 渐变或色值,如 linear-gradient(90deg, #ff8a3d, #f0366b)" onChange={(event) => importText(event.target.value)} />
|
||||
</label>
|
||||
<small className={styles.hint}><a href="https://cssgradient.io" target="_blank" rel="noreferrer noopener">cssgradient.io</a></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -90,55 +90,6 @@
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.colors {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.swatch {
|
||||
--c: var(--text-dim);
|
||||
|
||||
min-width: 36px;
|
||||
min-height: 28px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent-soft);
|
||||
color: var(--c);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.swatch[aria-checked='true'] {
|
||||
border-color: var(--c);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gray {
|
||||
--c: var(--text-dim);
|
||||
}
|
||||
|
||||
.blue {
|
||||
--c: var(--busy);
|
||||
}
|
||||
|
||||
.green {
|
||||
--c: var(--ok);
|
||||
}
|
||||
|
||||
.amber {
|
||||
--c: var(--warn);
|
||||
}
|
||||
|
||||
.red {
|
||||
--c: var(--danger);
|
||||
}
|
||||
|
||||
.purple {
|
||||
--c: var(--role-purple);
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -205,3 +156,19 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.fixed {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
@@ -2,25 +2,27 @@ import { useEffect, useState } from 'react';
|
||||
import { channelAccessApi, type ChannelMember } from '@/core-bridge/channelAccess';
|
||||
import { errorMessage } from '@/core-bridge/errors';
|
||||
import type { RoleInfo } from '@/core-bridge/types';
|
||||
import { PERMISSIONS, ROLE_COLORS } from '@/lib/roles';
|
||||
import { customColorStyle, effectOf, isGradient, normalizeColor, OWNER_ROLE_ID, PERMISSIONS, toCustomColor } from '@/lib/roles';
|
||||
import { InlineMarkdown } from '@/lib/inlineMarkdown';
|
||||
import { useToast } from '@/store/toast';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import badgeStyles from './RoleBadges.module.css';
|
||||
import { RoleColorPicker } from './RoleColorPicker';
|
||||
import styles from './RolesEditor.module.css';
|
||||
|
||||
const COLOR_LABEL = { gray: '灰', blue: '蓝', green: '绿', amber: '黄', red: '红', purple: '紫' } as const;
|
||||
|
||||
function RoleForm({ role, members, onDone }: { role?: RoleInfo; members: ChannelMember[]; onDone: (id?: string) => void }) {
|
||||
const people = useWorkspace((s) => s.people);
|
||||
const createRole = useWorkspace((s) => s.createRole);
|
||||
const updateRole = useWorkspace((s) => s.updateRole);
|
||||
const deleteRole = useWorkspace((s) => s.deleteRole);
|
||||
const setRoleMembers = useWorkspace((s) => s.setRoleMembers);
|
||||
const fixed = role?.id === OWNER_ROLE_ID;
|
||||
const [name, setName] = useState(role?.name ?? '');
|
||||
const [prefix, setPrefix] = useState(role?.prefix ?? '');
|
||||
const [suffix, setSuffix] = useState(role?.suffix ?? '');
|
||||
const [color, setColor] = useState<string>(role?.color ?? 'gray');
|
||||
const [color, setColor] = useState<string>(toCustomColor(normalizeColor(role?.color)));
|
||||
const [permissions, setPermissions] = useState<ReadonlySet<string>>(new Set(role?.permissions));
|
||||
const [chosen, setChosen] = useState<ReadonlySet<string>>(new Set(role?.members));
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -37,41 +39,39 @@ function RoleForm({ role, members, onDone }: { role?: RoleInfo; members: Channel
|
||||
const request = { name: name.trim(), prefix: prefix.trim() || null, suffix: suffix.trim() || null, color, permissions: [...permissions] };
|
||||
let id = role?.id;
|
||||
if (id) await updateRole(id, request); else id = await createRole(request);
|
||||
await setRoleMembers(id, [...chosen]);
|
||||
if (!fixed) await setRoleMembers(id, [...chosen]);
|
||||
useToast.getState().push({ kind: 'ok', text: '身份组已保存' });
|
||||
return id;
|
||||
});
|
||||
const remove = () => run(async () => { if (role) await deleteRole(role.id); return undefined; });
|
||||
const sample = `${prefix.trim()}${prefix.trim() ? ' ' : ''}你的名字${suffix.trim() ? ' ' : ''}${suffix.trim()}`;
|
||||
const tag = (text: string, key: string) => text.trim() ? <span key={key} style={customColorStyle(color)} className={`${badgeStyles.badge} ${badgeStyles[color.split('|')[0]!] ?? ''} ${isGradient(color) ? badgeStyles.gradient : ''} ${badgeStyles[effectOf(color) ?? 'still'] ?? ''}`}><InlineMarkdown text={text.trim()} /></span> : null;
|
||||
|
||||
return <form className={styles.form} onSubmit={(event) => { event.preventDefault(); if (name.trim()) void save(); }}>
|
||||
<label className={styles.field}>名称
|
||||
<input maxLength={48} value={name} placeholder="例如:管理组" onChange={(event) => setName(event.target.value)} /></label>
|
||||
<input maxLength={48} disabled={fixed} value={name} placeholder="例如:管理组" onChange={(event) => setName(event.target.value)} /></label>
|
||||
<div className={styles.pair}>
|
||||
<label className={styles.field}>前缀(可选)
|
||||
<input maxLength={12} value={prefix} placeholder="例如:【组长】" onChange={(event) => setPrefix(event.target.value)} /></label>
|
||||
<input value={prefix} placeholder="例如:【组长】 或 **组长**" onChange={(event) => setPrefix(event.target.value)} /></label>
|
||||
<label className={styles.field}>后缀(可选)
|
||||
<input maxLength={12} value={suffix} placeholder="例如:管理员" onChange={(event) => setSuffix(event.target.value)} /></label>
|
||||
<input value={suffix} placeholder="例如:管理员 或 *管理员*" onChange={(event) => setSuffix(event.target.value)} /></label>
|
||||
</div>
|
||||
<div className={styles.field}>颜色 · 聊天里显示为 <b>{sample}</b>
|
||||
<div className={styles.colors} role="radiogroup" aria-label="标签颜色">
|
||||
{ROLE_COLORS.map((item) => <button key={item} type="button" role="radio" aria-checked={color === item} className={`${styles.swatch} ${styles[item]}`}
|
||||
onClick={() => setColor(item)}>{COLOR_LABEL[item]}</button>)}
|
||||
<div className={styles.field}>颜色 · 前后缀可用纯色或渐变
|
||||
<RoleColorPicker color={color} onPick={setColor} />
|
||||
<div className={styles.preview} aria-label="预览">{tag(prefix, 'p')}<span>你的名字</span>{tag(suffix, 's')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<fieldset className={styles.group}><legend>权限</legend>
|
||||
<fieldset className={styles.group} disabled={fixed}><legend>权限{fixed ? ' · 空间主拥有全部权限' : ''}</legend>
|
||||
{PERMISSIONS.map((item) => <label key={item.id} className={styles.check}>
|
||||
<input type="checkbox" checked={permissions.has(item.id)} onChange={() => setPermissions(toggle(permissions, item.id))} />
|
||||
<input type="checkbox" checked={fixed || permissions.has(item.id)} onChange={() => setPermissions(toggle(permissions, item.id))} />
|
||||
<span>{item.label}<small>{item.hint}</small></span></label>)}
|
||||
</fieldset>
|
||||
<fieldset className={styles.group}><legend>成员 · {chosen.size}</legend>
|
||||
{fixed ? <p className={styles.fixed}>这个组固定属于空间主,只能修改前后缀和颜色,不能删除,也不能添加其他成员。</p> : <fieldset className={styles.group}><legend>成员 · {chosen.size}</legend>
|
||||
{members.length ? members.map((member) => <label key={member.username} className={styles.check}>
|
||||
<input type="checkbox" checked={chosen.has(member.username)} onChange={() => setChosen(toggle(chosen, member.username))} />
|
||||
<span>{people[member.username]?.displayName || member.username}<small>{member.username}</small></span></label>)
|
||||
: <p className={styles.empty}>还没有其他成员。成员接受邀请后可以加入身份组。</p>}
|
||||
</fieldset>
|
||||
</fieldset>}
|
||||
<div className={styles.actions}>
|
||||
{role ? (confirmDelete
|
||||
{role && !fixed ? (confirmDelete
|
||||
? <><Button variant="ghost" disabled={busy} onClick={() => setConfirmDelete(false)}>先不删</Button><Button variant="danger" disabled={busy} onClick={() => { void remove(); }}>确认删除</Button></>
|
||||
: <Button variant="ghost" icon="trash" disabled={busy} onClick={() => setConfirmDelete(true)}>删除身份组</Button>) : null}
|
||||
<Button type="submit" variant="primary" disabled={busy || !name.trim()}>{busy ? '保存中…' : role ? '保存' : '创建身份组'}</Button>
|
||||
|
||||
@@ -135,3 +135,9 @@
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { clampCrop, exportAvatar, paintCrop, type Crop } from './avatarCrop';
|
||||
import { canDecodeGif, decodeGif, exportGifAvatar, type GifFrame } from './avatarGif';
|
||||
import styles from './AvatarEditor.module.css';
|
||||
|
||||
const VIEW = 260;
|
||||
@@ -31,7 +32,11 @@ interface Props {
|
||||
|
||||
/** 在浏览器里裁剪头像:拖动、缩放、旋转,右侧按聊天消息和成员列表的样子实时预览 */
|
||||
export function AvatarEditor({ file, name, onCancel, onDone }: Props) {
|
||||
const [image, setImage] = useState<ImageBitmap>();
|
||||
const [still, setStill] = useState<ImageBitmap>();
|
||||
// 动图:各帧(已取样)和当前播放到哪一帧;所有帧尺寸相同,裁剪几何只看尺寸
|
||||
const [frames, setFrames] = useState<GifFrame[]>([]);
|
||||
const [tick, setTick] = useState(0);
|
||||
const image = frames.length ? frames[tick % frames.length]!.bitmap : still;
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [view, setView] = useState<View>(FRESH);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -42,13 +47,29 @@ export function AvatarEditor({ file, name, onCancel, onDone }: Props) {
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let bitmap: ImageBitmap | undefined;
|
||||
createImageBitmap(file).then((value) => {
|
||||
let decoded: GifFrame[] | undefined;
|
||||
(async () => {
|
||||
try {
|
||||
decoded = await decodeGif(file);
|
||||
if (decoded) {
|
||||
if (!active) { for (const frame of decoded) frame.bitmap.close(); return; }
|
||||
setFrames(decoded); setStill(decoded[0]!.bitmap); setTick(0); setView(FRESH);
|
||||
return;
|
||||
}
|
||||
const value = await createImageBitmap(file);
|
||||
if (!active) { value.close(); return; }
|
||||
bitmap = value; setImage(value); setView(FRESH);
|
||||
}).catch(() => { if (active) setFailed(true); });
|
||||
return () => { active = false; bitmap?.close(); };
|
||||
bitmap = value; setFrames([]); setStill(value); setView(FRESH);
|
||||
} catch { if (active) setFailed(true); }
|
||||
})();
|
||||
return () => { active = false; bitmap?.close(); for (const frame of decoded ?? []) frame.bitmap.close(); };
|
||||
}, [file]);
|
||||
|
||||
useEffect(() => {
|
||||
if (frames.length < 2) return;
|
||||
const timer = setTimeout(() => setTick((value) => value + 1), frames[tick % frames.length]!.delayMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [frames, tick]);
|
||||
|
||||
const update = useCallback((next: (value: View) => View) => {
|
||||
if (!image) return;
|
||||
setView((value) => {
|
||||
@@ -94,12 +115,12 @@ export function AvatarEditor({ file, name, onCancel, onDone }: Props) {
|
||||
async function confirm() {
|
||||
if (!image || busy) return;
|
||||
setBusy(true);
|
||||
try { onDone(await exportAvatar(image, crop, VIEW)); }
|
||||
try { onDone(frames.length ? await exportGifAvatar(frames, crop, VIEW) : await exportAvatar(image, crop, VIEW)); }
|
||||
catch { setFailed(true); setBusy(false); }
|
||||
}
|
||||
|
||||
if (failed) return <div className={styles.editor}>
|
||||
<p className={styles.error} role="alert">这张图片无法处理,请换一张 PNG、JPEG 或 WebP 图片。</p>
|
||||
<p className={styles.error} role="alert">这张图片无法处理,请换一张 PNG、JPEG、WebP 或 GIF 图片。</p>
|
||||
<div className={styles.actions}><Button onClick={onCancel}>返回</Button></div>
|
||||
</div>;
|
||||
|
||||
@@ -112,6 +133,7 @@ export function AvatarEditor({ file, name, onCancel, onDone }: Props) {
|
||||
onPointerDown={down} onPointerMove={move} onPointerUp={() => { drag.current = null; }} onPointerCancel={() => { drag.current = null; }} />
|
||||
<span className={styles.mask} aria-hidden="true" />
|
||||
</div>
|
||||
{file.type === 'image/gif' && !canDecodeGif() ? <p className={styles.note}>当前浏览器不支持动图头像,只会使用第一帧。</p> : null}
|
||||
<label className={styles.slider}><span>缩放</span>
|
||||
<input type="range" min={1} max={4} step={0.01} value={view.zoom} disabled={!ready}
|
||||
onChange={(event) => update((value) => ({ ...value, zoom: Number(event.target.value) }))} /></label>
|
||||
|
||||
@@ -10,7 +10,7 @@ import styles from './AccountSettings.module.css';
|
||||
const MAX_SOURCE_BYTES = 20_000_000;
|
||||
|
||||
async function checkAvatarSource(file: File): Promise<void> {
|
||||
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type) || file.size > MAX_SOURCE_BYTES) throw new Error('请选择 20 MB 以内的 PNG、JPEG 或 WebP 图片');
|
||||
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();
|
||||
@@ -54,7 +54,7 @@ export function ProfileSettings() {
|
||||
{avatar ? <img className={styles.avatarLarge} src={avatar} alt="个人头像" />
|
||||
: <span className={`${styles.avatarLarge} ${styles.avatarFallback}`} aria-hidden="true">{Array.from(shown)[0]?.toLocaleUpperCase() ?? '?'}</span>}
|
||||
<div className={styles.avatarSide}>
|
||||
<input ref={fileInput} type="file" accept="image/png,image/jpeg,image/webp" aria-label="选择头像文件" hidden onChange={(event) => { void choose(event.target.files?.[0]); event.target.value = ''; }} />
|
||||
<input ref={fileInput} type="file" accept="image/png,image/jpeg,image/webp,image/gif" aria-label="选择头像文件" hidden onChange={(event) => { void choose(event.target.files?.[0]); event.target.value = ''; }} />
|
||||
<div className={styles.row}>
|
||||
<Button icon="image" onClick={() => fileInput.current?.click()}>更换头像</Button>
|
||||
{avatar ? <Button variant="ghost" onClick={() => setAvatar(undefined)}>移除</Button> : null}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { applyPalette, GIFEncoder, quantize } from 'gifenc';
|
||||
import { paintCrop, type Crop } from './avatarCrop';
|
||||
|
||||
export interface GifFrame { bitmap: ImageBitmap; delayMs: number }
|
||||
|
||||
/** 编辑器里保留的帧:尽量保留全部。太长的动图才隔帧取样(时长不变),避免把几百张大图都放进内存 */
|
||||
const MAX_FRAMES = 400;
|
||||
const MAX_PIXELS = 120_000_000;
|
||||
const MIN_DELAY_MS = 20;
|
||||
/** 服务端对头像 data URL 的上限(字符数),留一点余量 */
|
||||
export const MAX_GIF_DATA_URL = 5_900_000;
|
||||
|
||||
/** 浏览器是否能把 GIF 拆成帧(WebCodecs ImageDecoder)。不能就退回成静态头像 */
|
||||
export const canDecodeGif = (): boolean => typeof ImageDecoder !== 'undefined';
|
||||
|
||||
/** 解出 GIF 的各帧;不是动图、不支持或解码失败时返回 undefined(调用方按静态图处理) */
|
||||
export async function decodeGif(file: File): Promise<GifFrame[] | undefined> {
|
||||
if (file.type !== 'image/gif' || !canDecodeGif()) return undefined;
|
||||
const frames: GifFrame[] = [];
|
||||
const decoder = new ImageDecoder({ data: await file.arrayBuffer(), type: 'image/gif' });
|
||||
try {
|
||||
await decoder.tracks.ready;
|
||||
const total = decoder.tracks.selectedTrack?.frameCount ?? 1;
|
||||
const { displayWidth, displayHeight } = decoder.tracks.selectedTrack?.frameCount ? await sizeOf(decoder) : { displayWidth: 0, displayHeight: 0 };
|
||||
const stride = Math.max(1, Math.ceil(total / MAX_FRAMES), Math.ceil((total * displayWidth * displayHeight) / MAX_PIXELS));
|
||||
let pending = 0;
|
||||
for (let index = 0; index < total; index += 1) {
|
||||
const { image } = await decoder.decode({ frameIndex: index });
|
||||
pending += Math.max(MIN_DELAY_MS, (image.duration ?? 100_000) / 1000);
|
||||
if (index % stride === 0 || index === total - 1) {
|
||||
frames.push({ bitmap: await createImageBitmap(image), delayMs: pending }); pending = 0;
|
||||
}
|
||||
image.close();
|
||||
}
|
||||
} catch {
|
||||
for (const frame of frames) frame.bitmap.close();
|
||||
return undefined;
|
||||
} finally { decoder.close(); }
|
||||
if (frames.length > 1) return frames;
|
||||
for (const frame of frames) frame.bitmap.close();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function sizeOf(decoder: ImageDecoder): Promise<{ displayWidth: number; displayHeight: number }> {
|
||||
const { image } = await decoder.decode({ frameIndex: 0 });
|
||||
const size = { displayWidth: image.displayWidth, displayHeight: image.displayHeight };
|
||||
image.close();
|
||||
return size;
|
||||
}
|
||||
|
||||
function toDataUrl(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (let at = 0; at < bytes.length; at += 0x8000) binary += String.fromCharCode(...bytes.subarray(at, at + 0x8000));
|
||||
return `data:image/gif;base64,${btoa(binary)}`;
|
||||
}
|
||||
|
||||
function encode(frames: readonly GifFrame[], crop: Crop, from: number, size: number, step: number): string {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = canvas.height = size;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) throw new Error('当前浏览器不支持图片处理');
|
||||
const encoder = GIFEncoder();
|
||||
let delay = 0;
|
||||
frames.forEach((frame, at) => {
|
||||
delay += frame.delayMs;
|
||||
if (at % step !== 0 && at !== frames.length - 1) return;
|
||||
paintCrop(ctx, frame.bitmap, crop, from, size);
|
||||
const { data } = ctx.getImageData(0, 0, size, size);
|
||||
const palette = quantize(data, 256, { format: 'rgba4444', oneBitAlpha: true });
|
||||
const transparentIndex = palette.findIndex((color) => color.length > 3 && color[3] === 0);
|
||||
encoder.writeFrame(applyPalette(data, palette, 'rgba4444'), size, size,
|
||||
{ palette, delay: Math.round(delay), transparent: transparentIndex >= 0, transparentIndex: Math.max(0, transparentIndex) });
|
||||
delay = 0;
|
||||
});
|
||||
encoder.finish();
|
||||
return toDataUrl(encoder.bytes());
|
||||
}
|
||||
|
||||
/** 把每一帧按同样的裁剪导出成动图;默认 256 像素、保留每一帧;只有超过上限(约 4.5 MB)才逐步缩小尺寸、再隔帧 */
|
||||
export async function exportGifAvatar(frames: readonly GifFrame[], crop: Crop, from: number): Promise<string> {
|
||||
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;
|
||||
}
|
||||
throw new Error('动图压缩后仍然过大,请换一张更短或更小的 GIF');
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
declare module 'gifenc' {
|
||||
export type Palette = number[][];
|
||||
export interface QuantizeOptions { format?: 'rgb565' | 'rgb444' | 'rgba4444'; oneBitAlpha?: boolean | number; clearAlpha?: boolean }
|
||||
export interface FrameOptions { palette?: Palette; delay?: number; repeat?: number; transparent?: boolean; transparentIndex?: number }
|
||||
export interface Encoder {
|
||||
writeFrame(index: Uint8Array, width: number, height: number, options?: FrameOptions): void;
|
||||
finish(): void;
|
||||
bytes(): Uint8Array;
|
||||
}
|
||||
export function GIFEncoder(): Encoder;
|
||||
export function quantize(rgba: Uint8Array | Uint8ClampedArray, maxColors: number, options?: QuantizeOptions): Palette;
|
||||
export function applyPalette(rgba: Uint8Array | Uint8ClampedArray, palette: Palette, format?: 'rgb565' | 'rgb444' | 'rgba4444'): Uint8Array;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { drawColors } from './colorDraw';
|
||||
import { isCustomColor } from './roles';
|
||||
|
||||
describe('drawColors', () => {
|
||||
it('每个候选都是合法的自定义颜色,渐变 2~5 个色标', () => {
|
||||
for (const seed of [0, 0.31, 0.77, 0.999]) {
|
||||
const drawn = drawColors(() => seed);
|
||||
expect(drawn).toHaveLength(6);
|
||||
for (const { color } of drawn) expect(isCustomColor(color)).toBe(true);
|
||||
}
|
||||
});
|
||||
it('同一个种子给出同样的结果,不同种子给出不同的颜色', () => {
|
||||
expect(drawColors(() => 0.2)).toEqual(drawColors(() => 0.2));
|
||||
expect(drawColors(() => 0.2)[0]!.color).not.toBe(drawColors(() => 0.6)[0]!.color);
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawColors 类型', () => {
|
||||
it('纯色模式只抽纯色,渐变模式只抽渐变', () => {
|
||||
expect(drawColors(() => 0.4, 6, 'solid').every(({ color }) => color.startsWith('#'))).toBe(true);
|
||||
expect(drawColors(() => 0.4, 6, 'gradient').every(({ color }) => color.startsWith('g:'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { hexFromArgb, TonalPalette } from '@material/material-color-utilities';
|
||||
import { gradientOf } from './roles';
|
||||
|
||||
export interface DrawnColor { label: string; color: string }
|
||||
|
||||
/**
|
||||
* 按 Material 3 的配色规则抽颜色:随机一个种子色相,取 HCT 色调板(TonalPalette)里的颜色。
|
||||
* M3 的配色是 主色(primary)、次色(secondary,色相相同、彩度低)、第三色(tertiary,色相 +60°),
|
||||
* 这里沿用这种关系,并把彩度提高一些、色调固定在 50~70 的中间段:标签很小,亮色和深色主题下都要看得清。
|
||||
*/
|
||||
export function drawColors(random: () => number = Math.random, count = 6, kind: 'solid' | 'gradient' = 'gradient'): DrawnColor[] {
|
||||
const tone = (hue: number, chroma: number, level: number) => hexFromArgb(TonalPalette.fromHueAndChroma(((hue % 360) + 360) % 360, chroma).tone(level));
|
||||
const solids: ((hue: number) => DrawnColor)[] = [
|
||||
(h) => ({ label: '主色', color: tone(h, 48, 50) }),
|
||||
(h) => ({ label: '主色 · 深', color: tone(h, 48, 38) }),
|
||||
(h) => ({ label: '主色 · 浅', color: tone(h, 48, 66) }),
|
||||
(h) => ({ label: '次色', color: tone(h, 24, 50) }),
|
||||
(h) => ({ label: '第三色', color: tone(h + 60, 48, 50) }),
|
||||
(h) => ({ label: '互补色', color: tone(h + 180, 48, 50) }),
|
||||
];
|
||||
const gradients: ((hue: number) => DrawnColor)[] = [
|
||||
(h) => ({ label: '主色 → 第三色', color: gradientOf([tone(h, 48, 50), tone(h + 60, 48, 60)]) }),
|
||||
(h) => ({ label: '主色 → 次色 → 第三色', color: gradientOf([tone(h, 48, 45), tone(h + 30, 36, 60), tone(h + 60, 48, 70)]) }),
|
||||
(h) => ({ label: '邻近色', color: gradientOf([tone(h, 56, 55), tone(h + 40, 56, 55)]) }),
|
||||
(h) => ({ label: '互补色', color: gradientOf([tone(h, 48, 55), tone(h + 180, 48, 55)]) }),
|
||||
(h) => ({ label: '明暗', color: gradientOf([tone(h, 56, 35), tone(h, 48, 70)]) }),
|
||||
(h) => ({ label: '多彩', color: gradientOf([0, 1, 2, 3, 4].map((step) => tone(h + step * 72, 56, 60))) }),
|
||||
];
|
||||
const recipes = kind === 'solid' ? solids : gradients;
|
||||
// 每次抽的候选共用一个种子色相,同一批颜色彼此协调
|
||||
const hue = random() * 360;
|
||||
return Array.from({ length: count }, (_, at) => recipes[at % recipes.length]!(hue + (at >= recipes.length ? 30 : 0)));
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extractColors, hexToHsv, hsvToHex, parseHex } from './colorMath';
|
||||
|
||||
describe('colorMath', () => {
|
||||
it('hex 和 hsv 互转', () => {
|
||||
for (const hex of ['#ff0000', '#00ff00', '#3b6ea8', '#ffffff', '#000000', '#7b4fb3']) expect(hsvToHex(hexToHsv(hex))).toBe(hex);
|
||||
});
|
||||
it('parseHex 接受三位、六位、带或不带 #', () => {
|
||||
expect(parseHex('#ABC')).toBe('#aabbcc'); expect(parseHex('ff8a3d')).toBe('#ff8a3d'); expect(parseHex('#12345')).toBeUndefined();
|
||||
});
|
||||
it('从 CSS 渐变里按顺序取颜色,忽略透明度和位置', () => {
|
||||
expect(extractColors('background: linear-gradient(90deg, #ff8a3d 0%, rgb(240, 54, 107) 50%, #ffffff80 100%);')).toEqual(['#ff8a3d', '#f0366b', '#ffffff']);
|
||||
expect(extractColors('没有颜色')).toEqual([]);
|
||||
expect(extractColors('#111 #222 #333 #444 #555 #666')).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
export interface Hsv { h: number; s: number; v: number }
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
const byte = (value: number) => Math.round(clamp(value, 0, 255)).toString(16).padStart(2, '0');
|
||||
|
||||
/** "#rgb" / "#rrggbb"(大小写均可)转成小写 "#rrggbb",其他返回 undefined */
|
||||
export function parseHex(text: string): string | undefined {
|
||||
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(text.trim());
|
||||
if (!m) return undefined;
|
||||
const hex = m[1]!.toLowerCase();
|
||||
return `#${hex.length === 3 ? [...hex].map((c) => c + c).join('') : hex}`;
|
||||
}
|
||||
|
||||
export function hexToHsv(hex: string): Hsv {
|
||||
const value = parseHex(hex) ?? '#000000';
|
||||
const r = parseInt(value.slice(1, 3), 16) / 255; const g = parseInt(value.slice(3, 5), 16) / 255; const b = parseInt(value.slice(5, 7), 16) / 255;
|
||||
const max = Math.max(r, g, b); const delta = max - Math.min(r, g, b);
|
||||
let h = 0;
|
||||
if (delta) {
|
||||
if (max === r) h = ((g - b) / delta) % 6;
|
||||
else if (max === g) h = (b - r) / delta + 2;
|
||||
else h = (r - g) / delta + 4;
|
||||
h = (h * 60 + 360) % 360;
|
||||
}
|
||||
return { h, s: max ? delta / max : 0, v: max };
|
||||
}
|
||||
|
||||
export function hsvToHex({ h, s, v }: Hsv): string {
|
||||
const f = (n: number) => { const k = (n + h / 60) % 6; return v - v * s * Math.max(0, Math.min(k, 4 - k, 1)); };
|
||||
return `#${byte(f(5) * 255)}${byte(f(3) * 255)}${byte(f(1) * 255)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从一段文字里取出颜色:兼容在线渐变生成器复制出来的 CSS(linear-gradient(90deg, #ff8a3d 0%, rgb(240,54,107) 100%) 之类)。
|
||||
* 按出现顺序最多取 max 个,透明度忽略。
|
||||
*/
|
||||
export function extractColors(text: string, max = 5): string[] {
|
||||
const found: string[] = [];
|
||||
const re = /#([0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3})\b|rgba?\(\s*(\d{1,3})[\s,]+(\d{1,3})[\s,]+(\d{1,3})/gi;
|
||||
for (let m = re.exec(text); m && found.length < max; m = re.exec(text)) {
|
||||
if (m[1]) found.push(parseHex(m[1].length === 8 ? m[1].slice(0, 6) : m[1])!);
|
||||
else found.push(`#${byte(Number(m[2]))}${byte(Number(m[3]))}${byte(Number(m[4]))}`);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { InlineMarkdown } from './inlineMarkdown';
|
||||
|
||||
const html = (text: string) => renderToStaticMarkup(<InlineMarkdown text={text} />);
|
||||
|
||||
describe('InlineMarkdown', () => {
|
||||
it('支持粗体、斜体、删除线、代码', () => {
|
||||
expect(html('**a** *b* ~~c~~ `d`')).toBe('<strong>a</strong> <em>b</em> <s>c</s> <code>d</code>');
|
||||
});
|
||||
it('嵌套样式', () => { expect(html('**a *b***')).toBe('<strong>a <em>b</em></strong>'); });
|
||||
it('只允许 http(s) 链接', () => {
|
||||
expect(html('[x](https://a.b/c)')).toContain('href="https://a.b/c"');
|
||||
expect(html('[x](javascript:alert(1))')).toBe('[x](javascript:alert(1))');
|
||||
});
|
||||
it('HTML 一律当文字转义', () => { expect(html('<img src=x onerror=alert(1)>')).toBe('<img src=x onerror=alert(1)>'); });
|
||||
it('没闭合的符号按原样显示', () => { expect(html('**a')).toBe('**a'); });
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* 身份组前后缀用的行内 Markdown:`code`、**粗体**、~~删除线~~、*斜体* / _斜体_、[文字](http(s) 链接)。
|
||||
* 只生成 React 元素,不接受任何 HTML,链接只允许 http(s),所以别人写的前后缀不会注入脚本或样式。
|
||||
* 没有闭合的符号按普通文字显示。
|
||||
*/
|
||||
const PATTERNS: { re: RegExp; render: (match: RegExpExecArray, key: string) => ReactNode }[] = [
|
||||
{ re: /`([^`]+)`/y, render: (m, key) => <code key={key}>{m[1]}</code> },
|
||||
{ re: /\*\*(.+?)\*\*(?!\*)/y, render: (m, key) => <strong key={key}>{parse(m[1]!, key)}</strong> },
|
||||
{ re: /~~(.+?)~~/y, render: (m, key) => <s key={key}>{parse(m[1]!, key)}</s> },
|
||||
{ re: /\*(?!\s)(.+?)\*/y, render: (m, key) => <em key={key}>{parse(m[1]!, key)}</em> },
|
||||
{ re: /\b_(?!\s)(.+?)_\b/y, render: (m, key) => <em key={key}>{parse(m[1]!, key)}</em> },
|
||||
{ re: /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/y, render: (m, key) => <a key={key} href={m[2]} target="_blank" rel="noreferrer noopener">{parse(m[1]!, key)}</a> },
|
||||
];
|
||||
|
||||
function parse(source: string, prefix: string): ReactNode[] {
|
||||
const out: ReactNode[] = [];
|
||||
let plain = ''; let at = 0; let n = 0;
|
||||
const flush = () => { if (plain) { out.push(plain); plain = ''; } };
|
||||
while (at < source.length) {
|
||||
let hit = false;
|
||||
for (const { re, render } of PATTERNS) {
|
||||
re.lastIndex = at;
|
||||
const match = re.exec(source);
|
||||
if (!match) continue;
|
||||
flush(); out.push(render(match, `${prefix}.${n++}`)); at += match[0].length; hit = true;
|
||||
break;
|
||||
}
|
||||
if (!hit) { plain += source[at]; at += 1; }
|
||||
}
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
export function InlineMarkdown({ text }: { text: string }) {
|
||||
return <>{parse(text, 'm')}</>;
|
||||
}
|
||||
+63
-5
@@ -1,6 +1,9 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { RoleInfo } from '@/core-bridge/types';
|
||||
|
||||
export const ROLE_COLORS = ['gray', 'blue', 'green', 'amber', 'red', 'purple'] as const;
|
||||
/** 早期的预设色名(老数据里可能还有,渐变的色值在 tokens.css 的 --grad-*);新的颜色都是自定义的 */
|
||||
export const ROLE_COLORS = ['gray', 'blue', 'green', 'amber', 'red', 'purple', 'sunset', 'ocean', 'aurora', 'candy', 'gold', 'rainbow'] as const;
|
||||
const GRADIENT_COLORS = ['sunset', 'ocean', 'aurora', 'candy', 'gold', 'rainbow'] as const;
|
||||
export type RoleColor = (typeof ROLE_COLORS)[number];
|
||||
|
||||
/** 与后端 WorkspacePermission 一致 */
|
||||
@@ -9,7 +12,61 @@ export const PERMISSIONS = [
|
||||
{ id: 'structure.manage', label: '管理频道和分组', hint: '重命名频道、调整频道所属分组、新建和删除分组。新建频道涉及密钥,仍只有空间主' },
|
||||
] as const;
|
||||
|
||||
export interface Badge { key: string; text: string; color: RoleColor | 'owner' }
|
||||
/** 与后端 WorkspaceRole.OwnerId 一致:空间主所在的固定身份组 */
|
||||
export const OWNER_ROLE_ID = '00000000-0000-4000-8000-000000000000';
|
||||
/** 还没拿到身份组数据时(老数据、离线)空间主的默认标签 */
|
||||
const OWNER_FALLBACK = { suffix: 'Owner', color: 'gold' };
|
||||
|
||||
const HEX = /^#[0-9a-fA-F]{6}$/;
|
||||
const CUSTOM_GRADIENT = /^g:(#[0-9a-fA-F]{6})(,#[0-9a-fA-F]{6}){1,4}$/;
|
||||
|
||||
/** 颜色后面可以接 "|效果":呼吸、闪烁、跑马灯(只对渐变有意义)、色相循环 */
|
||||
export const EFFECTS = [
|
||||
{ id: 'breathe', label: '呼吸' }, { id: 'blink', label: '闪烁' }, { id: 'marquee', label: '跑马灯' }, { id: 'cycle', label: '色相循环' },
|
||||
] as const;
|
||||
export type RoleEffect = (typeof EFFECTS)[number]['id'];
|
||||
const EFFECT_SUFFIX = /\|(breathe|blink|marquee|cycle)$/;
|
||||
|
||||
export function splitColor(color: string): { base: string; effect: RoleEffect | undefined } {
|
||||
const m = EFFECT_SUFFIX.exec(color);
|
||||
return m ? { base: color.slice(0, m.index), effect: m[1] as RoleEffect } : { base: color, effect: undefined };
|
||||
}
|
||||
export const joinColor = (base: string, effect: RoleEffect | undefined): string => (effect ? `${base}|${effect}` : base);
|
||||
export const effectOf = (color: string): RoleEffect | undefined => splitColor(color).effect;
|
||||
|
||||
/** 预设名、自定义纯色 "#rrggbb"、自定义渐变 "g:#a,#b[,#c]"(可带 "|效果") 之一;其余一律当作灰色 */
|
||||
export function normalizeColor(color: string | null | undefined): string {
|
||||
if (!color) return 'gray';
|
||||
const { base } = splitColor(color);
|
||||
return (ROLE_COLORS as readonly string[]).includes(base) || HEX.test(base) || CUSTOM_GRADIENT.test(base) ? color : 'gray';
|
||||
}
|
||||
|
||||
export const isGradient = (color: string): boolean => { const { base } = splitColor(color); return (GRADIENT_COLORS as readonly string[]).includes(base) || CUSTOM_GRADIENT.test(base); };
|
||||
|
||||
/** 自定义颜色没有对应的 CSS 类,色值通过变量传给样式;预设返回 undefined。跑马灯的渐变首尾同色,循环滚动才没有接缝 */
|
||||
export function customColorStyle(color: string): CSSProperties | undefined {
|
||||
const { base, effect } = splitColor(color);
|
||||
if (HEX.test(base)) return { '--c': base } as CSSProperties;
|
||||
if (CUSTOM_GRADIENT.test(base)) {
|
||||
const stops = base.slice(2).split(',');
|
||||
return { '--g': `linear-gradient(90deg, ${(effect === 'marquee' ? [...stops, stops[0]] : stops).join(', ')})` } as CSSProperties;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 早期的预设色名对应的自定义颜色:编辑器只处理自定义颜色,打开老的身份组时先换算过来 */
|
||||
const LEGACY: Record<string, string> = {
|
||||
gray: '#8a8a85', blue: '#3b6ea8', green: '#1f7a4d', amber: '#b25e00', red: '#b3261e', purple: '#7b4fb3',
|
||||
sunset: 'g:#ff8a3d,#f0366b', ocean: 'g:#1ea7d8,#5b5be0', aurora: 'g:#19b97a,#2c8fe6,#9a5bf0', candy: 'g:#f06bb3,#8b6bf0',
|
||||
gold: 'g:#d99a1c,#f2c94c,#c27a0a', rainbow: 'g:#e5484d,#f5a524,#3fb950,#2c8fe6,#9a5bf0',
|
||||
};
|
||||
export const toCustomColor = (color: string): string => LEGACY[color] ?? color;
|
||||
|
||||
export const gradientOf = (stops: readonly string[]): string => `g:${stops.join(',')}`;
|
||||
export const stopsOf = (color: string): string[] => { const { base } = splitColor(color); return CUSTOM_GRADIENT.test(base) ? base.slice(2).split(',') : []; };
|
||||
export const isCustomColor = (color: string): boolean => { const { base } = splitColor(color); return HEX.test(base) || CUSTOM_GRADIENT.test(base); };
|
||||
|
||||
export interface Badge { key: string; text: string; color: string }
|
||||
|
||||
const hasText = (value: string | null | undefined): value is string => Boolean(value && value.trim());
|
||||
|
||||
@@ -17,10 +74,11 @@ const hasText = (value: string | null | undefined): value is string => Boolean(v
|
||||
export function badgesOf(roles: readonly RoleInfo[], username: string | undefined, creator: string | undefined): { prefix: Badge[]; suffix: Badge[] } {
|
||||
const prefix: Badge[] = []; const suffix: Badge[] = [];
|
||||
if (!username) return { prefix, suffix };
|
||||
if (username === creator) suffix.push({ key: 'owner', text: '空间主', color: 'owner' });
|
||||
const isOwner = username === creator;
|
||||
if (isOwner && !roles.some((role) => role.id === OWNER_ROLE_ID)) suffix.push({ key: 'owner', text: OWNER_FALLBACK.suffix, color: OWNER_FALLBACK.color });
|
||||
for (const role of roles) {
|
||||
if (!role.members.includes(username)) continue;
|
||||
const color = (ROLE_COLORS as readonly string[]).includes(role.color ?? '') ? (role.color as RoleColor) : 'gray';
|
||||
if (role.id === OWNER_ROLE_ID ? !isOwner : !role.members.includes(username)) continue;
|
||||
const color = normalizeColor(role.color);
|
||||
if (hasText(role.prefix)) prefix.push({ key: role.id + ':p', text: role.prefix, color });
|
||||
if (hasText(role.suffix)) suffix.push({ key: role.id + ':s', text: role.suffix, color });
|
||||
}
|
||||
|
||||
@@ -70,10 +70,14 @@ export function reduce(s: WorkspaceData, e: WorkspaceEvent): WorkspaceData {
|
||||
const person = location.authorAccount ? people.get(location.authorAccount) : undefined;
|
||||
if (person) authors[location.itemId] = { username: person.username, displayName: person.displayName || person.username, avatar: person.avatar ?? undefined };
|
||||
}
|
||||
const byId = Object.fromEntries(Object.entries(s.byId).map(([id, item]) => [id, {
|
||||
...item, ...(locations[id] ?? {}), ...(authors[id] ? { author: authors[id] } : {}),
|
||||
}]));
|
||||
const profiles = Object.fromEntries(e.people.map(person => [person.username, { username: person.username, displayName: person.displayName || person.username, ...(person.avatar ? { avatar: person.avatar } : {}) }]));
|
||||
// 资料改了(头像、名字),已经显示的消息也要跟着换:作者按账号取最新的资料
|
||||
const fresh = (author: SenderInfo | undefined) => (author && profiles[author.username]) || author;
|
||||
for (const id of Object.keys(authors)) authors[id] = fresh(authors[id])!;
|
||||
const byId = Object.fromEntries(Object.entries(s.byId).map(([id, item]) => {
|
||||
const author = fresh(authors[id] ?? item.author);
|
||||
return [id, { ...item, ...(locations[id] ?? {}), ...(author ? { author } : {}) }];
|
||||
}));
|
||||
return { ...s, people: profiles, threads: e.state.channels.map(c => ({ ...c, groupId: c.groupId ?? undefined })), groups: e.state.groups, roles: e.state.roles ?? [],
|
||||
folders: e.state.folders.map(f => ({ ...f, parentId: f.parentId ?? undefined })), locations, authors, byId };
|
||||
}
|
||||
@@ -96,7 +100,8 @@ export function reduce(s: WorkspaceData, e: WorkspaceEvent): WorkspaceData {
|
||||
const known = e.item.id in s.byId;
|
||||
let item = e.item;
|
||||
const current = s.byId[item.id];
|
||||
const author = s.authors[item.id] ?? item.author ?? current?.author;
|
||||
const known0 = s.authors[item.id] ?? item.author ?? current?.author;
|
||||
const author = (known0 && s.people[known0.username]) || known0;
|
||||
if (author) item = { ...item, author };
|
||||
if (e.t === 'item' && item.kind === 'file' && item.metadataHidden && current?.kind === 'file' && !current.metadataHidden) return s;
|
||||
const location = s.locations[item.id];
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
--danger: #b3261e;
|
||||
--busy: #3b6ea8;
|
||||
--role-purple: #7b4fb3;
|
||||
--grad-sunset: linear-gradient(90deg, #ff8a3d, #f0366b);
|
||||
--grad-ocean: linear-gradient(90deg, #1ea7d8, #5b5be0);
|
||||
--grad-aurora: linear-gradient(90deg, #19b97a, #2c8fe6, #9a5bf0);
|
||||
--grad-candy: linear-gradient(90deg, #f06bb3, #8b6bf0);
|
||||
--grad-gold: linear-gradient(90deg, #d99a1c, #f2c94c, #c27a0a);
|
||||
--grad-rainbow: linear-gradient(90deg, #e5484d, #f5a524, #3fb950, #2c8fe6, #9a5bf0);
|
||||
|
||||
--surface-2: #efefed;
|
||||
--line-strong: rgb(0 0 0 / 14%);
|
||||
|
||||
@@ -86,6 +86,10 @@
|
||||
.wide {
|
||||
width: min(880px, 92vw);
|
||||
}
|
||||
|
||||
.wide.xl {
|
||||
width: min(1200px, 94vw);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scrim-in {
|
||||
|
||||
@@ -9,7 +9,7 @@ interface Props {
|
||||
onClose: () => void;
|
||||
/** 标题栏右侧的操作区 */
|
||||
actions?: ReactNode;
|
||||
wide?: boolean;
|
||||
wide?: boolean | 'xl';
|
||||
/** 手机上从哪边出来。需要键盘输入的用 top,避免被软键盘遮住(iOS 不会缩小布局视口) */
|
||||
placement?: 'bottom' | 'top';
|
||||
children: ReactNode;
|
||||
@@ -49,7 +49,7 @@ export function Sheet({ title, onClose, actions, wide, placement = 'bottom', chi
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
className={`${styles.sheet} ${wide ? styles.wide : ''}`}
|
||||
className={`${styles.sheet} ${wide ? styles.wide : ''} ${wide === 'xl' ? styles.xl : ''}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className={styles.head}>
|
||||
|
||||
Reference in new issue
Block a user