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
+40
View File
@@ -1053,6 +1053,46 @@
cursor: default;
}
/* 分组图标选择:搜索 + 图标网格 */
.icon-picker {
display: flex;
flex-direction: column;
gap: var(--su-space-3);
margin-top: var(--su-space-3);
padding: var(--su-space-3);
border: 1px solid var(--su-border);
border-radius: var(--su-radius-md);
background-color: var(--su-bg-subtle);
}
.icon-picker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(40px, 1fr));
gap: var(--su-space-2);
max-height: 220px;
overflow-y: auto;
}
.icon-picker-item {
display: grid;
place-items: center;
height: 36px;
border: 1px solid transparent;
border-radius: var(--su-radius-sm);
background-color: var(--su-bg-card);
color: var(--su-text-sub);
font-size: var(--su-font-size-lg);
cursor: pointer;
transition:
border-color var(--su-duration-fast) var(--su-ease-in-out),
color var(--su-duration-fast) var(--su-ease-in-out);
}
.icon-picker-item:hover {
border-color: var(--su-color-primary);
color: var(--su-color-primary);
}
.group-mark {
display: inline-flex;
flex: none;
+2 -1
View File
@@ -18,6 +18,7 @@ import { ItemEditor } from './components/content/ItemEditor'
import { PosterGrid } from './components/content/PosterGrid'
import { SectionCard } from './components/content/SectionCard'
import { SiteHeader } from './components/header/SiteHeader'
import { GroupMark } from './components/common/GroupMark'
import { LoginDialog } from './components/auth/LoginDialog'
import { ProfileCard } from './components/sidebar/ProfileCard'
import { ServicePanel } from './components/sidebar/ServicePanel'
@@ -486,7 +487,7 @@ export default function AppNext() {
<div className="detail-hero">
<span className="detail-hero-icon" aria-hidden="true">
{group.icon || '•'}
<GroupMark icon={group.icon} fallback="•" />
</span>
<h2 className="detail-hero-title">{group.title}</h2>
<span className="detail-hero-count">[{groupEntryCount(group)}]</span>
+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">
@@ -0,0 +1,14 @@
import { Icon, ICON_NAMES, type IconName } from '@stash/ui'
/**
* 分组图标:能对上 iconfont 名字就渲染图标,否则按字符渲染。
*
* 旧数据里存的是 emoji / 任意字符,新数据存图标名(如 `folder_2_line`);
* 两种都要能显示,所以这里做一次判定,前后台共用同一套规则。
*/
export function GroupMark({ icon, fallback = '' }: { icon: string; fallback?: string }) {
const value = icon.trim()
if (value === '') return fallback === '' ? null : <>{fallback}</>
if (ICON_NAMES.includes(value as IconName)) return <Icon name={value as IconName} />
return <>{value}</>
}