feat: 分组图标统一(iconfont 名字优先,字符兜底)+ 后台选图标
- 新增 components/common/GroupMark:值是图标名就渲染图标,否则按字符显示, 兼容旧数据里的 emoji;后台的 GroupMark 改为复用同一判断 - 分组详情页 hero 之前把图标名当文本渲染(会直接显示 folder_2_line),已修 - 分组编辑弹窗加「选择图标」:搜索 + 网格,446 个图标按关键字过滤(默认展示前 120) - 输入框仍可手填字符,旧数据不用迁移
This commit is contained in:
@@ -1053,6 +1053,46 @@
|
|||||||
cursor: default;
|
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 {
|
.group-mark {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex: none;
|
flex: none;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { ItemEditor } from './components/content/ItemEditor'
|
|||||||
import { PosterGrid } from './components/content/PosterGrid'
|
import { PosterGrid } from './components/content/PosterGrid'
|
||||||
import { SectionCard } from './components/content/SectionCard'
|
import { SectionCard } from './components/content/SectionCard'
|
||||||
import { SiteHeader } from './components/header/SiteHeader'
|
import { SiteHeader } from './components/header/SiteHeader'
|
||||||
|
import { GroupMark } from './components/common/GroupMark'
|
||||||
import { LoginDialog } from './components/auth/LoginDialog'
|
import { LoginDialog } from './components/auth/LoginDialog'
|
||||||
import { ProfileCard } from './components/sidebar/ProfileCard'
|
import { ProfileCard } from './components/sidebar/ProfileCard'
|
||||||
import { ServicePanel } from './components/sidebar/ServicePanel'
|
import { ServicePanel } from './components/sidebar/ServicePanel'
|
||||||
@@ -486,7 +487,7 @@ export default function AppNext() {
|
|||||||
|
|
||||||
<div className="detail-hero">
|
<div className="detail-hero">
|
||||||
<span className="detail-hero-icon" aria-hidden="true">
|
<span className="detail-hero-icon" aria-hidden="true">
|
||||||
{group.icon || '•'}
|
<GroupMark icon={group.icon} fallback="•" />
|
||||||
</span>
|
</span>
|
||||||
<h2 className="detail-hero-title">{group.title}</h2>
|
<h2 className="detail-hero-title">{group.title}</h2>
|
||||||
<span className="detail-hero-count">[{groupEntryCount(group)}]</span>
|
<span className="detail-hero-count">[{groupEntryCount(group)}]</span>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { changePassword, fetchSession, logout, type SessionState } from '../../auth'
|
import { changePassword, fetchSession, logout, type SessionState } from '../../auth'
|
||||||
import { LoginDialog } from '../auth/LoginDialog'
|
import { LoginDialog } from '../auth/LoginDialog'
|
||||||
|
import { GroupMark as GroupGlyph } from '../common/GroupMark'
|
||||||
import {
|
import {
|
||||||
fetchSettings,
|
fetchSettings,
|
||||||
saveSettings,
|
saveSettings,
|
||||||
@@ -430,19 +431,18 @@ const FORMAT_LABEL: Record<GroupFormat, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function GroupMark({ icon }: { icon: string }) {
|
function GroupMark({ icon }: { icon: string }) {
|
||||||
if (icon.trim() === '') {
|
const value = icon.trim()
|
||||||
return <span className="group-mark group-mark-empty" aria-hidden="true" />
|
const isIcon = ICON_NAMES.includes(value as IconName)
|
||||||
}
|
const classes = [
|
||||||
if (ICON_NAMES.includes(icon as IconName)) {
|
'group-mark',
|
||||||
return (
|
value === '' ? 'group-mark-empty' : isIcon ? '' : 'group-mark-text',
|
||||||
<span className="group-mark" aria-hidden="true">
|
]
|
||||||
<Icon name={icon as IconName} />
|
.filter(Boolean)
|
||||||
</span>
|
.join(' ')
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<span className="group-mark group-mark-text" aria-hidden="true">
|
<span className={classes} aria-hidden="true">
|
||||||
{icon}
|
<GroupGlyph icon={icon} />
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -632,6 +632,16 @@ function GroupDialog({
|
|||||||
const [enabled, setEnabled] = useState(true)
|
const [enabled, setEnabled] = useState(true)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState('')
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
@@ -787,7 +797,11 @@ function GroupDialog({
|
|||||||
</FormField>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FormField label="图标样式" htmlFor="group-icon" hint="留空则不显示图标;可填 iconfont 图标名或一个字符">
|
<FormField
|
||||||
|
label="图标"
|
||||||
|
htmlFor="group-icon"
|
||||||
|
hint="从图标库选一个,或直接填一个字符(旧数据里的 emoji 照旧显示)"
|
||||||
|
>
|
||||||
<div className="group-icon-field">
|
<div className="group-icon-field">
|
||||||
<span className="group-icon-preview">
|
<span className="group-icon-preview">
|
||||||
<GroupMark icon={icon} />
|
<GroupMark icon={icon} />
|
||||||
@@ -795,10 +809,47 @@ function GroupDialog({
|
|||||||
<Input
|
<Input
|
||||||
id="group-icon"
|
id="group-icon"
|
||||||
value={icon}
|
value={icon}
|
||||||
placeholder="如 folder_line / 📁,留空不显示"
|
placeholder="如 folder_2_line / 📁,留空不显示"
|
||||||
onChange={(event) => setIcon(event.target.value)}
|
onChange={(event) => setIcon(event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
<Button variant="secondary" onClick={() => setIconPickerOpen((open) => !open)}>
|
||||||
|
选择图标
|
||||||
|
</Button>
|
||||||
</div>
|
</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>
|
</FormField>
|
||||||
|
|
||||||
<div className="group-dialog-row">
|
<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