feat: 分组图标统一(iconfont 名字优先,字符兜底)+ 后台选图标

- 新增 components/common/GroupMark:值是图标名就渲染图标,否则按字符显示,
  兼容旧数据里的 emoji;后台的 GroupMark 改为复用同一判断
- 分组详情页 hero 之前把图标名当文本渲染(会直接显示 folder_2_line),已修
- 分组编辑弹窗加「选择图标」:搜索 + 网格,446 个图标按关键字过滤(默认展示前 120)
- 输入框仍可手填字符,旧数据不用迁移
This commit is contained in:
2026-11-01 15:43:53 +08:00
parent 2f2ceb9ea0
commit 18e9543607
4 changed files with 121 additions and 15 deletions
+65 -14
View File
@@ -25,6 +25,7 @@ import {
import { useEffect, useMemo, useState } from 'react'
import { changePassword, fetchSession, logout, type SessionState } from '../../auth'
import { LoginDialog } from '../auth/LoginDialog'
import { GroupMark as GroupGlyph } from '../common/GroupMark'
import {
fetchSettings,
saveSettings,
@@ -430,19 +431,18 @@ const FORMAT_LABEL: Record<GroupFormat, string> = {
}
function GroupMark({ icon }: { icon: string }) {
if (icon.trim() === '') {
return <span className="group-mark group-mark-empty" aria-hidden="true" />
}
if (ICON_NAMES.includes(icon as IconName)) {
return (
<span className="group-mark" aria-hidden="true">
<Icon name={icon as IconName} />
</span>
)
}
const value = icon.trim()
const isIcon = ICON_NAMES.includes(value as IconName)
const classes = [
'group-mark',
value === '' ? 'group-mark-empty' : isIcon ? '' : 'group-mark-text',
]
.filter(Boolean)
.join(' ')
return (
<span className="group-mark group-mark-text" aria-hidden="true">
{icon}
<span className={classes} aria-hidden="true">
<GroupGlyph icon={icon} />
</span>
)
}
@@ -632,6 +632,16 @@ function GroupDialog({
const [enabled, setEnabled] = useState(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [iconPickerOpen, setIconPickerOpen] = useState(false)
const [iconQuery, setIconQuery] = useState('')
// 图标库里 446 个名字,先按关键字过滤,没输入时只给前 120 个,避免一次渲染太多
const iconList = useMemo(() => {
const query = iconQuery.trim().toLowerCase()
const names =
query === '' ? ICON_NAMES : ICON_NAMES.filter((name) => name.includes(query))
return names.slice(0, 120)
}, [iconQuery])
useEffect(() => {
if (!open) return
@@ -787,7 +797,11 @@ function GroupDialog({
</FormField>
)}
<FormField label="图标样式" htmlFor="group-icon" hint="留空则不显示图标;可填 iconfont 图标名或一个字符">
<FormField
label="图标"
htmlFor="group-icon"
hint="从图标库选一个,或直接填一个字符(旧数据里的 emoji 照旧显示)"
>
<div className="group-icon-field">
<span className="group-icon-preview">
<GroupMark icon={icon} />
@@ -795,10 +809,47 @@ function GroupDialog({
<Input
id="group-icon"
value={icon}
placeholder="如 folder_line / 📁,留空不显示"
placeholder="如 folder_2_line / 📁,留空不显示"
onChange={(event) => setIcon(event.target.value)}
/>
<Button variant="secondary" onClick={() => setIconPickerOpen((open) => !open)}>
选择图标
</Button>
</div>
{iconPickerOpen && (
<div className="icon-picker">
<Input
value={iconQuery}
placeholder="搜索图标名,如 folder / user / play"
icon="search_3_line"
clearable
onClear={() => setIconQuery('')}
onChange={(event) => setIconQuery(event.target.value)}
/>
<div className="icon-picker-grid">
{iconList.map((name) => (
<button
key={name}
type="button"
className="icon-picker-item"
title={name}
aria-label={name}
onClick={() => {
setIcon(name)
setIconPickerOpen(false)
}}
>
<Icon name={name} />
</button>
))}
</div>
{iconList.length === 0 && (
<Text size="sm" tone="muted">
没有匹配的图标,换个关键字试试。
</Text>
)}
</div>
)}
</FormField>
<div className="group-dialog-row">