feat: 分组图标统一(iconfont 名字优先,字符兜底)+ 后台选图标
- 新增 components/common/GroupMark:值是图标名就渲染图标,否则按字符显示, 兼容旧数据里的 emoji;后台的 GroupMark 改为复用同一判断 - 分组详情页 hero 之前把图标名当文本渲染(会直接显示 folder_2_line),已修 - 分组编辑弹窗加「选择图标」:搜索 + 网格,446 个图标按关键字过滤(默认展示前 120) - 输入框仍可手填字符,旧数据不用迁移
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}</>
|
||||
}
|
||||
Reference in New Issue
Block a user