From 3b062687cde2ed5efc01f0306a0921634076063b Mon Sep 17 00:00:00 2001 From: JustMe Date: Sat, 17 Oct 2026 13:25:14 +0800 Subject: [PATCH] feat(web): role colors with picker, Material 3 draw, effects and inline markdown --- web/package-lock.json | 14 ++ web/package.json | 2 + .../channel/ChannelSettingsSheet.module.css | 2 +- .../features/channel/ChannelSettingsSheet.tsx | 2 +- .../features/channel/RoleBadges.module.css | 100 ++++++++++- web/src/features/channel/RoleBadges.tsx | 5 +- .../channel/RoleColorPicker.module.css | 170 ++++++++++++++++++ web/src/features/channel/RoleColorPicker.tsx | 117 ++++++++++++ .../features/channel/RolesEditor.module.css | 65 ++----- web/src/features/channel/RolesEditor.tsx | 38 ++-- web/src/lib/colorDraw.test.ts | 24 +++ web/src/lib/colorDraw.ts | 33 ++++ web/src/lib/colorMath.test.ts | 16 ++ web/src/lib/colorMath.ts | 45 +++++ web/src/lib/inlineMarkdown.test.tsx | 18 ++ web/src/lib/inlineMarkdown.tsx | 38 ++++ web/src/lib/roles.ts | 68 ++++++- web/src/styles/tokens.css | 6 + web/src/ui/Sheet.module.css | 4 + web/src/ui/Sheet.tsx | 4 +- 20 files changed, 688 insertions(+), 83 deletions(-) create mode 100644 web/src/features/channel/RoleColorPicker.module.css create mode 100644 web/src/features/channel/RoleColorPicker.tsx create mode 100644 web/src/lib/colorDraw.test.ts create mode 100644 web/src/lib/colorDraw.ts create mode 100644 web/src/lib/colorMath.test.ts create mode 100644 web/src/lib/colorMath.ts create mode 100644 web/src/lib/inlineMarkdown.test.tsx create mode 100644 web/src/lib/inlineMarkdown.tsx diff --git a/web/package-lock.json b/web/package-lock.json index 8c6f9e6..879fdae 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index bb397ba..0dc10a2 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/features/channel/ChannelSettingsSheet.module.css b/web/src/features/channel/ChannelSettingsSheet.module.css index bea0c21..fd618c6 100644 --- a/web/src/features/channel/ChannelSettingsSheet.module.css +++ b/web/src/features/channel/ChannelSettingsSheet.module.css @@ -24,7 +24,7 @@ /* 身份组页内容多:加高,列表在滚动时固定在左边 */ .tall { - height: min(560px, 80dvh); + height: min(760px, 84dvh); } .tabs { diff --git a/web/src/features/channel/ChannelSettingsSheet.tsx b/web/src/features/channel/ChannelSettingsSheet.tsx index ac3e62f..6ffb662 100644 --- a/web/src/features/channel/ChannelSettingsSheet.tsx +++ b/web/src/features/channel/ChannelSettingsSheet.tsx @@ -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 ( - +
{TABS.filter((item) => owner || item.id !== 'roles').map((item) => + +
+ {gradient ? <> +
+
+ {stops.map((stop, index) => + + +
+ : null} +
+
+ {gradient ? setStop(at, hex)} /> : } +
+
+
随机{gradient ? '渐变' : '纯色'} · Material 3
+
+ {drawn.map((item, index) => )} +
+
+
+ + {EFFECTS.map((item) => )} +
+ + cssgradient.io +
+
+
; +} diff --git a/web/src/features/channel/RolesEditor.module.css b/web/src/features/channel/RolesEditor.module.css index 943b999..badaae3 100644 --- a/web/src/features/channel/RolesEditor.module.css +++ b/web/src/features/channel/RolesEditor.module.css @@ -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); +} diff --git a/web/src/features/channel/RolesEditor.tsx b/web/src/features/channel/RolesEditor.tsx index cb6e804..353d64b 100644 --- a/web/src/features/channel/RolesEditor.tsx +++ b/web/src/features/channel/RolesEditor.tsx @@ -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(role?.color ?? 'gray'); + const [color, setColor] = useState(toCustomColor(normalizeColor(role?.color))); const [permissions, setPermissions] = useState>(new Set(role?.permissions)); const [chosen, setChosen] = useState>(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() ? : null; return
{ event.preventDefault(); if (name.trim()) void save(); }}> + setName(event.target.value)} />
+ setPrefix(event.target.value)} /> + setSuffix(event.target.value)} />
-
颜色 · 聊天里显示为 {sample} -
- {ROLE_COLORS.map((item) => )} -
+
颜色 · 前后缀可用纯色或渐变 + +
{tag(prefix, 'p')}你的名字{tag(suffix, 's')}
-
权限 +
权限{fixed ? ' · 空间主拥有全部权限' : ''} {PERMISSIONS.map((item) => )}
-
成员 · {chosen.size} + {fixed ?

这个组固定属于空间主,只能修改前后缀和颜色,不能删除,也不能添加其他成员。

:
成员 · {chosen.size} {members.length ? members.map((member) => ) :

还没有其他成员。成员接受邀请后可以加入身份组。

} -
+
}
- {role ? (confirmDelete + {role && !fixed ? (confirmDelete ? <> : ) : null} diff --git a/web/src/lib/colorDraw.test.ts b/web/src/lib/colorDraw.test.ts new file mode 100644 index 0000000..082f9f6 --- /dev/null +++ b/web/src/lib/colorDraw.test.ts @@ -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); + }); +}); diff --git a/web/src/lib/colorDraw.ts b/web/src/lib/colorDraw.ts new file mode 100644 index 0000000..7f8cb04 --- /dev/null +++ b/web/src/lib/colorDraw.ts @@ -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))); +} diff --git a/web/src/lib/colorMath.test.ts b/web/src/lib/colorMath.test.ts new file mode 100644 index 0000000..99af346 --- /dev/null +++ b/web/src/lib/colorMath.test.ts @@ -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); + }); +}); diff --git a/web/src/lib/colorMath.ts b/web/src/lib/colorMath.ts new file mode 100644 index 0000000..b6af3c2 --- /dev/null +++ b/web/src/lib/colorMath.ts @@ -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; +} diff --git a/web/src/lib/inlineMarkdown.test.tsx b/web/src/lib/inlineMarkdown.test.tsx new file mode 100644 index 0000000..e584a72 --- /dev/null +++ b/web/src/lib/inlineMarkdown.test.tsx @@ -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(); + +describe('InlineMarkdown', () => { + it('支持粗体、斜体、删除线、代码', () => { + expect(html('**a** *b* ~~c~~ `d`')).toBe('a b c d'); + }); + it('嵌套样式', () => { expect(html('**a *b***')).toBe('a b'); }); + 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('')).toBe('<img src=x onerror=alert(1)>'); }); + it('没闭合的符号按原样显示', () => { expect(html('**a')).toBe('**a'); }); +}); diff --git a/web/src/lib/inlineMarkdown.tsx b/web/src/lib/inlineMarkdown.tsx new file mode 100644 index 0000000..113311b --- /dev/null +++ b/web/src/lib/inlineMarkdown.tsx @@ -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) => {m[1]} }, + { re: /\*\*(.+?)\*\*(?!\*)/y, render: (m, key) => {parse(m[1]!, key)} }, + { re: /~~(.+?)~~/y, render: (m, key) => {parse(m[1]!, key)} }, + { re: /\*(?!\s)(.+?)\*/y, render: (m, key) => {parse(m[1]!, key)} }, + { re: /\b_(?!\s)(.+?)_\b/y, render: (m, key) => {parse(m[1]!, key)} }, + { re: /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/y, render: (m, key) => {parse(m[1]!, key)} }, +]; + +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')}; +} diff --git a/web/src/lib/roles.ts b/web/src/lib/roles.ts index 79b1d48..4f6a987 100644 --- a/web/src/lib/roles.ts +++ b/web/src/lib/roles.ts @@ -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 = { + 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 }); } diff --git a/web/src/styles/tokens.css b/web/src/styles/tokens.css index 50c8f15..b041d8f 100644 --- a/web/src/styles/tokens.css +++ b/web/src/styles/tokens.css @@ -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%); diff --git a/web/src/ui/Sheet.module.css b/web/src/ui/Sheet.module.css index f76a3c2..44c1590 100644 --- a/web/src/ui/Sheet.module.css +++ b/web/src/ui/Sheet.module.css @@ -86,6 +86,10 @@ .wide { width: min(880px, 92vw); } + + .wide.xl { + width: min(1200px, 94vw); + } } @keyframes scrim-in { diff --git a/web/src/ui/Sheet.tsx b/web/src/ui/Sheet.tsx index 42d6dc2..1076bca 100644 --- a/web/src/ui/Sheet.tsx +++ b/web/src/ui/Sheet.tsx @@ -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()} >