feat(creator-studio): multi-select category picker and creatable tag input

分类从平铺复选标签改为带搜索的多选下拉(触发器内是已选令牌),
标签改为令牌输入框:回车 / 逗号 / 顿号 / 分号 / 换行切分,输入时列出
站点已有标签作为建议,面板内可一键新建。

- 抽出 useAnchoredPanel,工具条下拉与新的选择器共用同一套 portal 定位
- bootstrap 的 tags 增加 suggestions(按使用量取前 200 个已有标签)
- 清理 .jio-creator-inline 与 chip 内的死样式

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Opus 5 committed 2026-11-09 17:35:52 +08:00
1 parent 8bb4b98fc9
commit 330df53195
10 files changed
+888 -170

No files matched your search

+1 -1
View File
@@ -32,7 +32,7 @@ const mockBootstrap = {
],
selected: [1],
},
tags: { items: ['写作', '产品'], enabled: true },
tags: { items: ['写作', '产品'], enabled: true, suggestions: ['写作', '产品', '前端', 'WordPress', '设计'] },
custom: {},
customDefs: [],
featured: { type: '', data: {}, canImage: true, canSlide: false, canVideo: false },
@@ -0,0 +1,159 @@
/**
* 分类多选下拉。
*
* 触发器本身就是「已选令牌 + 占位文字」的输入框外观,点开后是带搜索的复选列表:
* 作者不用在一屏几十个分类里横向找,输入关键词就能定位。面板 portal 到 body,
* 避免被发布设置卡片或页面容器裁剪。
*
* @package Jio
*/
import { createPortal, useEffect, useMemo, useState } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import { useAnchoredPanel } from '../hooks/use-anchored-panel';
export type CategoryOption = { id: number; name: string };
type Props = {
options: CategoryOption[];
value: number[];
onChange: (ids: number[]) => void;
};
export function CategorySelect({ options, value, onChange }: Props) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const { triggerRef, panelRef, position } = useAnchoredPanel<HTMLDivElement>({
open,
width: 320,
align: 'start',
followScroll: true,
onDismiss: () => setOpen(false),
});
const selected = useMemo(() => new Set(value), [value]);
const chosen = useMemo(
() => options.filter((option) => selected.has(option.id)),
[options, selected]
);
const filtered = useMemo(() => {
const keyword = query.trim().toLowerCase();
if (keyword === '') {
return options;
}
return options.filter((option) => option.name.toLowerCase().includes(keyword));
}, [options, query]);
// 每次打开都从干净的搜索词开始,避免上次的过滤条件把列表藏起来。
useEffect(() => {
if (!open) {
setQuery('');
}
}, [open]);
const toggle = (id: number) => {
onChange(
selected.has(id) ? value.filter((item) => item !== id) : [...value, id]
);
};
const panel = open
? createPortal(
<div
ref={panelRef}
className={`jio-creator-picker is-${position.placement}`}
style={{ left: `${position.left}px`, top: `${position.top}px`, width: `${position.width}px` }}
>
<div className="jio-creator-picker__search">
<i className="fa fas fa-search" aria-hidden="true" />
<input
type="search"
value={query}
placeholder={__('搜索分类', 'jio')}
aria-label={__('搜索分类', 'jio')}
// 打开即可输入:分类列表通常很长,键盘操作比翻找更快。
autoFocus
onChange={(event) => setQuery(event.target.value)}
/>
</div>
<div className="jio-creator-picker__list" aria-label={__('分类', 'jio')}>
{filtered.length === 0 && (
<p className="jio-creator-picker__empty">{__('没有匹配的分类', 'jio')}</p>
)}
{filtered.map((option) => {
const active = selected.has(option.id);
return (
<label
key={option.id}
className={`jio-creator-picker__item${active ? ' is-active' : ''}`}
>
<input type="checkbox" checked={active} onChange={() => toggle(option.id)} />
<span className="jio-creator-picker__name">{option.name}</span>
<i className="fa fas fa-check jio-creator-picker__check" aria-hidden="true" />
</label>
);
})}
</div>
<div className="jio-creator-picker__foot">
<span className="jio-creator-picker__count">
{sprintf(
/* translators: %d: number of selected categories */
__('已选 %d 个', 'jio'),
value.length
)}
</span>
<button type="button" className="jio-creator-picker__done" onClick={() => setOpen(false)}>
{__('完成', 'jio')}
</button>
</div>
</div>,
document.body
)
: null;
return (
<div className="jio-creator-select" ref={triggerRef}>
<div
className={`jio-creator-select__control${open ? ' is-open' : ''}`}
onClick={() => setOpen((value) => !value)}
role="button"
tabIndex={0}
aria-haspopup="dialog"
aria-expanded={open ? 'true' : 'false'}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setOpen((value) => !value);
}
}}
>
<span className="jio-creator-select__tokens">
{chosen.length === 0 ? (
<span className="jio-creator-select__placeholder">{__('选择分类(可多选)', 'jio')}</span>
) : (
chosen.map((option) => (
<span key={option.id} className="jio-creator-select__token">
<span className="jio-creator-select__token-label">{option.name}</span>
<button
type="button"
className="jio-creator-select__token-x"
aria-label={sprintf(__('移除分类 %s', 'jio'), option.name)}
onClick={(event) => {
// 令牌上的叉号只移除这一项,不要顺带展开面板。
event.stopPropagation();
toggle(option.id);
}}
>
<i className="fa fas fa-times" aria-hidden="true" />
</button>
</span>
))
)}
</span>
<i className="fa fas fa-caret-down jio-creator-select__caret" aria-hidden="true" />
</div>
{panel}
</div>
);
}
@@ -11,13 +11,16 @@
import { useMemo, useState } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import { CategorySelect, type CategoryOption } from './category-select';
import { TagInput } from './tag-input';
import type { PayState } from '../draft';
type Featured = { type: string; data: Record<string, any>; mediaId?: number };
export type TaxonomyDef = {
categories: Array<{ id: number; name: string }>;
tags: boolean;
categories: CategoryOption[];
/** 标签是否开放;开放时附带站点已有标签,用于输入建议。 */
tags: { enabled: boolean; suggestions: string[] };
customDefs: Array<{ key: string; name: string; values: Array<{ key: string; name: string }> }>;
};
@@ -44,7 +47,6 @@ type Props = {
};
const bootstrap: Record<string, any> = window['jioCreatorStudio'] || {};
const nonce: string = bootstrap.nonce || '';
const pad = (value: number): string => String(value).padStart(2, '0');
@@ -77,30 +79,10 @@ export function PublishSettings({
onCustomChange,
onOpenPaySettings,
}: Props) {
const [tagInput, setTagInput] = useState('');
const [error, setError] = useState('');
const minSchedule = useMemo(() => toLocalInput(new Date(Date.now() + 5 * 60 * 1000)), []);
const coverUrl = typeof featured?.data?.url === 'string' ? featured.data.url : '';
const toggleCategory = (id: number) => {
onCategoriesChange(
categories.includes(id) ? categories.filter((value) => value !== id) : [...categories, id]
);
};
const addTag = () => {
const value = tagInput.trim();
if (!value) {
return;
}
const next = value
.split(/[,,\n]+/u)
.map((item) => item.trim())
.filter(Boolean);
onTagsChange(Array.from(new Set([...tags, ...next])));
setTagInput('');
};
const toggleCustom = (key: string, value: string) => {
const current = custom[key] || [];
onCustomChange({
@@ -150,67 +132,24 @@ export function PublishSettings({
<div className="jio-creator__setting-row jio-creator__setting-row--stack">
<div className="jio-creator__setting-copy">
<span className="jio-creator__setting-label">{__('分类', 'jio')}</span>
<p className="jio-creator__setting-help">{__('必选,决定文章出现在哪些栏目。', 'jio')}</p>
</div>
<div className="jio-creator-chips">
{taxonomy.categories.length === 0 && (
<span className="jio-creator-muted">{__('暂无可选分类', 'jio')}</span>
)}
{taxonomy.categories.map((category) => (
<label
key={category.id}
className={`jio-creator-chip${categories.includes(category.id) ? ' is-active' : ''}`}
>
<input
type="checkbox"
checked={categories.includes(category.id)}
onChange={() => toggleCategory(category.id)}
/>
<span>{category.name}</span>
</label>
))}
<p className="jio-creator__setting-help">{__('必选,可多选,决定文章出现在哪些栏目。', 'jio')}</p>
</div>
{taxonomy.categories.length === 0 ? (
<p className="jio-creator-muted">{__('暂无可选分类', 'jio')}</p>
) : (
<CategorySelect options={taxonomy.categories} value={categories} onChange={onCategoriesChange} />
)}
</div>
{taxonomy.tags && (
{taxonomy.tags.enabled && (
<div className="jio-creator__setting-row jio-creator__setting-row--stack">
<div className="jio-creator__setting-copy">
<span className="jio-creator__setting-label">{__('标签', 'jio')}</span>
<p className="jio-creator__setting-help">{__('多个标签用逗号分隔,回车即可添加。', 'jio')}</p>
</div>
<div>
{tags.length > 0 && (
<div className="jio-creator-chips">
{tags.map((tag) => (
<span key={tag} className="jio-creator-chip is-active">
<span>{tag}</span>
<button
type="button"
onClick={() => onTagsChange(tags.filter((value) => value !== tag))}
aria-label={sprintf(__('移除标签 %s', 'jio'), tag)}
>
<i className="fa fas fa-times" aria-hidden="true" />
</button>
</span>
))}
</div>
)}
<div className="jio-creator-inline">
<input
type="text"
value={tagInput}
placeholder={__('输入标签,逗号分隔', 'jio')}
onChange={(event) => setTagInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addTag();
}
}}
/>
<button type="button" onClick={addTag}>{__('添加', 'jio')}</button>
</div>
<p className="jio-creator__setting-help">
{__('可直接输入新标签,回车或逗号分隔;也可从站点已有标签中选择。', 'jio')}
</p>
</div>
<TagInput value={tags} suggestions={taxonomy.tags.suggestions} onChange={onTagsChange} />
</div>
)}
@@ -0,0 +1,194 @@
/**
* 标签输入框(可创建 + 站点标签建议)。
*
* 标签在 WordPress 里本来就是"没有就建"的自由词条,所以这里的主交互是输入框:
* - 回车 / 逗号 / 顿号 / 分号 / 换行都算一个标签的结束;
* - 输入时在下方列出站点已有标签作为建议,点选即可复用,避免同义标签散落;
* - 面板里没有匹配项时,回车直接把输入内容建成新标签。
*
* @package Jio
*/
import { createPortal, useEffect, useMemo, useState } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import { useAnchoredPanel } from '../hooks/use-anchored-panel';
type Props = {
value: string[];
suggestions: string[];
onChange: (tags: string[]) => void;
};
const MAX_SUGGESTIONS = 8;
/** 一个标签的结束符:中英文逗号、顿号、分号、换行。 */
const SEPARATORS = /[,,、;;\n]+/u;
const normalize = (value: string): string => value.trim().replace(/\s+/g, ' ');
export function TagInput({ value, suggestions, onChange }: Props) {
const [open, setOpen] = useState(false);
const [text, setText] = useState('');
const { triggerRef, panelRef, position } = useAnchoredPanel<HTMLDivElement>({
open,
width: 320,
align: 'start',
followScroll: true,
onDismiss: () => setOpen(false),
});
const lower = useMemo(() => new Set(value.map((tag) => tag.toLowerCase())), [value]);
const keyword = text.trim().toLowerCase();
const matches = useMemo(() => {
const pool = suggestions.filter(
(tag) => !lower.has(tag.toLowerCase()) && (keyword === '' || tag.toLowerCase().includes(keyword))
);
return pool.slice(0, MAX_SUGGESTIONS);
}, [suggestions, lower, keyword]);
// 已经有完全相同的标签时不再提示「创建」,避免产生重复词条。
const canCreate =
keyword !== '' && !suggestions.some((tag) => tag.toLowerCase() === keyword) && !lower.has(keyword);
/** 输入框里的文本按分隔符切成标签,与前台投稿的 `tags` 文本框同一口径。 */
const commit = (raw: string) => {
// 只和已有标签比对,避免同一批输入里的重复项漏网。
const taken = new Set(value.map((tag) => tag.toLowerCase()));
const parts: string[] = [];
raw.split(SEPARATORS).forEach((piece) => {
const tag = normalize(piece);
const key = tag.toLowerCase();
if (tag === '' || taken.has(key)) {
return;
}
taken.add(key);
parts.push(tag);
});
setText('');
if (parts.length > 0) {
onChange([...value, ...parts]);
}
};
const remove = (tag: string) => {
onChange(value.filter((item) => item !== tag));
};
// 输入框获得焦点即展开建议面板;面板里的按钮用 mousedown 阻止默认行为,
// 不会让输入框先失焦,作者可以连续点选多个标签。
const focusInput = () => setOpen(true);
const panel = open
? createPortal(
<div
ref={panelRef}
className={`jio-creator-picker is-${position.placement}`}
style={{ left: `${position.left}px`, top: `${position.top}px`, width: `${position.width}px` }}
>
{matches.length > 0 && (
<>
<p className="jio-creator-picker__title">{__('站点已有标签', 'jio')}</p>
<div className="jio-creator-picker__list" role="group" aria-label={__('标签建议', 'jio')}>
{matches.map((tag) => (
<button
key={tag}
type="button"
className="jio-creator-picker__item"
// 按钮获得焦点会抢走输入框光标,用 mousedown 阻止默认行为。
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
onChange([...value, tag]);
setText('');
}}
>
<i className="fa fas fa-hashtag jio-creator-picker__icon" aria-hidden="true" />
<span className="jio-creator-picker__name">{tag}</span>
<i className="fa fas fa-plus jio-creator-picker__check" aria-hidden="true" />
</button>
))}
</div>
</>
)}
{canCreate && (
<button
type="button"
className="jio-creator-picker__item jio-creator-picker__item--create"
onMouseDown={(event) => event.preventDefault()}
onClick={() => commit(text)}
>
<i className="fa fas fa-plus jio-creator-picker__icon" aria-hidden="true" />
<span className="jio-creator-picker__name">
{sprintf(
/* translators: %s: tag name typed by the author */
__('新建标签「%s」', 'jio'),
text.trim()
)}
</span>
</button>
)}
{matches.length === 0 && !canCreate && (
<p className="jio-creator-picker__empty">
{keyword === '' ? __('还没有可用的站点标签', 'jio') : __('没有匹配的标签,回车即可新建', 'jio')}
</p>
)}
<p className="jio-creator-picker__foot-hint">
{__('回车或逗号分隔多个标签', 'jio')}
</p>
</div>,
document.body
)
: null;
return (
<div ref={triggerRef} className="jio-creator-tagbox">
<div className={`jio-creator-tagbox__control${open ? ' is-open' : ''}`}>
{value.map((tag) => (
<span key={tag} className="jio-creator-select__token">
<span className="jio-creator-select__token-label">{tag}</span>
<button
type="button"
className="jio-creator-select__token-x"
aria-label={sprintf(__('移除标签 %s', 'jio'), tag)}
// 保持输入框焦点,删完一个标签可以继续输入。
onMouseDown={(event) => event.preventDefault()}
onClick={() => remove(tag)}
>
<i className="fa fas fa-times" aria-hidden="true" />
</button>
</span>
))}
<input
type="text"
className="jio-creator-tagbox__input"
value={text}
placeholder={value.length === 0 ? __('输入标签,回车添加', 'jio') : __('继续添加', 'jio')}
onFocus={focusInput}
onChange={(event) => {
const next = event.target.value;
// 粘贴 / 输入了分隔符:立即切分成多个标签。
if (SEPARATORS.test(next)) {
setOpen(true);
commit(next);
return;
}
setText(next);
setOpen(true);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
commit(text);
return;
}
// 输入框为空时退格删掉最后一个标签,符合令牌输入框的惯例。
if (event.key === 'Backspace' && text === '' && value.length > 0) {
event.preventDefault();
remove(value[value.length - 1]);
}
}}
/>
</div>
{panel}
</div>
);
}
@@ -10,7 +10,9 @@
* @package Jio
*/
import { createPortal, useEffect, useLayoutEffect, useRef, useState } from '@wordpress/element';
import { createPortal, useEffect, useState } from '@wordpress/element';
import { useAnchoredPanel } from '../hooks/use-anchored-panel';
export type ToolMenuItem = {
key: string;
@@ -38,8 +40,6 @@ type Props = {
onRememberSelection?: () => void;
};
type Position = { left: number; top: number; placement: 'bottom' | 'top' };
const MENU_GAP = 8;
export function ToolMenu({
@@ -53,72 +53,29 @@ export function ToolMenu({
onRememberSelection,
}: Props) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const [position, setPosition] = useState<Position>({ left: 0, top: 0, placement: 'bottom' });
// 打开时按触发器矩形定位;优先向下展开,下方放不下时翻到上方。
useLayoutEffect(() => {
if (!open) {
return;
}
const update = () => {
const trigger = triggerRef.current;
if (!trigger) {
return;
}
const rect = trigger.getBoundingClientRect();
const menuHeight = menuRef.current?.offsetHeight || 240;
const placement: Position['placement'] =
rect.bottom + MENU_GAP + menuHeight > window.innerHeight && rect.top > menuHeight
? 'top'
: 'bottom';
const left = Math.min(
Math.max(MENU_GAP, rect.left + rect.width / 2 - width / 2),
Math.max(MENU_GAP, window.innerWidth - width - MENU_GAP)
);
const top = placement === 'bottom' ? rect.bottom + MENU_GAP : rect.top - menuHeight - MENU_GAP;
setPosition({ left, top, placement });
};
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, [open, width]);
const { triggerRef, panelRef, position } = useAnchoredPanel<HTMLButtonElement>({
open,
width,
gap: MENU_GAP,
onDismiss: () => setOpen(false),
});
// 工具条下拉在页面滚动时直接收起:菜单是 fixed 定位,滚动后与触发器脱节。
useEffect(() => {
if (!open) {
return undefined;
}
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node;
if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) {
return;
}
setOpen(false);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setOpen(false);
triggerRef.current?.focus();
}
};
const onScroll = () => setOpen(false);
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
window.addEventListener('scroll', onScroll, true);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
window.removeEventListener('scroll', onScroll, true);
};
return () => window.removeEventListener('scroll', onScroll, true);
}, [open]);
const menu = open
? createPortal(
<div
ref={menuRef}
ref={panelRef}
className={`jio-creator__menu is-${position.placement}`}
style={{ left: `${position.left}px`, top: `${position.top}px`, width: `${width}px` }}
style={{ left: `${position.left}px`, top: `${position.top}px`, width: `${position.width}px` }}
role="menu"
aria-label={title || label}
>
@@ -0,0 +1,133 @@
/**
* 浮层定位:把下拉菜单 / 选择面板固定在触发器的旁边。
*
* 工具条是横向滚动容器,发布设置卡片又可能出现在长页面的任意位置,浮层如果
* 直接绝对定位在触发器的 DOM 里,就会被祖先的 `overflow` 裁掉。这里统一把
* 浮层 portal 到 body,再用触发器的 `getBoundingClientRect()` 计算 fixed 坐标:
* 优先向下展开,下方空间不足时翻到上方,左右不越出视口。
*
* @package Jio
*/
import { useEffect, useLayoutEffect, useRef, useState } from '@wordpress/element';
export type AnchoredPosition = {
left: number;
top: number;
width: number;
placement: 'bottom' | 'top';
};
type Options = {
open: boolean;
/** 面板固定宽度(px);不传时与触发器同宽。 */
width?: number;
/** 面板最小宽度(px)。 */
minWidth?: number;
/** 面板与触发器的间距(px)。 */
gap?: number;
/** 面板相对触发器的水平对齐方式。 */
align?: 'center' | 'start';
/** 滚动时跟随重新定位。工具条下拉是滚动即收起,保持默认关闭。 */
followScroll?: boolean;
/** 点击浮层外部或按 Esc 时调用。 */
onDismiss?: () => void;
};
const VIEWPORT_GAP = 8;
export function useAnchoredPanel<T extends HTMLElement = HTMLElement>({
open,
width,
minWidth = 0,
gap = 8,
align = 'center',
followScroll = false,
onDismiss,
}: Options) {
const triggerRef = useRef<T | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const [position, setPosition] = useState<AnchoredPosition>({
left: 0,
top: 0,
width: width || minWidth,
placement: 'bottom',
});
useLayoutEffect(() => {
if (!open) {
return undefined;
}
const update = () => {
const trigger = triggerRef.current;
if (!trigger) {
return;
}
const rect = trigger.getBoundingClientRect();
const panelWidth = Math.max(minWidth, width || rect.width);
// 先按目标宽度写回面板再量高度:否则首帧宽度为 0,文字全部换行,
// 量出来的高度会把「向下展开」误判成「向上翻」。
const panel = panelRef.current;
if (panel) {
panel.style.width = `${panelWidth}px`;
}
const panelHeight = panel?.offsetHeight || 240;
const placement: AnchoredPosition['placement'] =
rect.bottom + gap + panelHeight > window.innerHeight && rect.top > panelHeight
? 'top'
: 'bottom';
const preferred = align === 'start' ? rect.left : rect.left + rect.width / 2 - panelWidth / 2;
const left = Math.min(
Math.max(VIEWPORT_GAP, preferred),
Math.max(VIEWPORT_GAP, window.innerWidth - panelWidth - VIEWPORT_GAP)
);
const top = placement === 'bottom' ? rect.bottom + gap : rect.top - panelHeight - gap;
setPosition({ left, top, width: panelWidth, placement });
};
update();
window.addEventListener('resize', update);
if (followScroll) {
window.addEventListener('scroll', update, true);
}
return () => {
window.removeEventListener('resize', update);
if (followScroll) {
window.removeEventListener('scroll', update, true);
}
};
}, [open, width, minWidth, gap, align, followScroll]);
// 点击外部 / Esc 收起。与定位逻辑分开:定位只在打开时算一次,
// 关闭时机则依赖文档级事件,两者互不干扰。
// 回调存进 ref:调用方通常传内联箭头函数,直接进依赖数组会让监听器
// 每次渲染都重新挂载。
const dismissRef = useRef(onDismiss);
dismissRef.current = onDismiss;
useEffect(() => {
if (!open) {
return undefined;
}
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node;
if (triggerRef.current?.contains(target) || panelRef.current?.contains(target)) {
return;
}
dismissRef.current?.();
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
dismissRef.current?.();
triggerRef.current?.focus();
}
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [open]);
return { triggerRef, panelRef, position };
}
+4 -1
View File
@@ -772,7 +772,10 @@ function Studio(): JSX.Element {
canPay={!!capabilities.canPay}
taxonomy={{
categories: (bootstrap.categories && bootstrap.categories.items) || [],
tags: !!bootstrap.tags?.enabled,
tags: {
enabled: !!bootstrap.tags?.enabled,
suggestions: (bootstrap.tags && bootstrap.tags.suggestions) || [],
},
customDefs: bootstrap.customDefs || [],
}}
categories={draft.categories}
@@ -162,6 +162,7 @@
gap: 8px;
}
/* 自定义分类法仍是少量固定选项的复选标签;分类 / 标签改用下面的选择器。 */
.jio-creator-chip {
display: inline-flex;
align-items: center;
@@ -186,40 +187,6 @@
background: #eef5f2;
color: $accent-strong;
}
button {
border: 0;
background: transparent;
cursor: pointer;
color: inherit;
}
}
.jio-creator-inline {
display: flex;
gap: 8px;
margin-top: 10px;
input[type="text"],
input:not([type]) {
flex: 1;
min-width: 0;
}
input[type="text"],
input:not([type]) {
height: 36px;
padding: 0 12px;
border: 1px solid $line;
border-radius: 8px;
}
label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
}
.jio-creator-switch {
@@ -507,3 +474,322 @@
text-decoration: underline;
cursor: pointer;
}
/* ---------- 发布设置:分类多选下拉 / 标签令牌输入 ---------- */
/* 触发器外观与卡片里的输入框一致:白底、细边、单行最小 40px 高。 */
.jio-creator-select,
.jio-creator-tagbox {
position: relative;
width: 100%;
}
.jio-creator-select__control,
.jio-creator-tagbox__control {
display: flex;
width: 100%;
min-height: 40px;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid $line;
border-radius: 8px;
background: #fff;
color: $ink;
cursor: pointer;
text-align: left;
transition: border-color 0.16s ease, box-shadow 0.16s ease;
&:hover {
border-color: #d3dbd7;
}
&.is-open,
&:focus-within {
border-color: $accent;
box-shadow: 0 0 0 3px rgba(38, 118, 101, 0.1);
outline: 0;
}
}
/* 触发器是容器(内含令牌叉号按钮),键盘聚焦时给出与输入框一致的焦点环。 */
.jio-creator-select__control:focus-visible {
border-color: $accent;
box-shadow: 0 0 0 3px rgba(38, 118, 101, 0.1);
outline: 0;
}
.jio-creator-select__tokens,
.jio-creator-tagbox__control {
flex-wrap: wrap;
}
.jio-creator-select__tokens {
display: flex;
flex: 1;
flex-wrap: wrap;
align-items: center;
gap: 6px;
min-width: 0;
}
.jio-creator-select__placeholder {
color: #b0b8b5;
font-size: 13px;
}
/* 已选令牌:分类与标签共用,保持同一视觉重量。 */
.jio-creator-select__token {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: 5px;
padding: 3px 6px 3px 10px;
border-radius: 6px;
background: #eef5f2;
color: $accent-strong;
font-size: 12px;
line-height: 1.5;
}
.jio-creator-select__token-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.jio-creator-select__token-x {
display: inline-flex;
width: 16px;
height: 16px;
flex: 0 0 16px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: inherit;
cursor: pointer;
font-size: 9px;
opacity: 0.7;
&:hover,
&:focus-visible {
background: rgba(29, 95, 82, 0.14);
outline: 0;
opacity: 1;
}
}
.jio-creator-select__caret {
flex: 0 0 auto;
color: #a9b2ae;
font-size: 11px;
}
.jio-creator-tagbox__input {
flex: 1 1 120px;
min-width: 120px;
height: 26px;
padding: 0 2px;
border: 0;
background: transparent;
color: inherit;
font: inherit;
font-size: 13px;
&:focus {
outline: 0;
}
&::placeholder {
color: #b0b8b5;
}
}
/* ---------- 浮层面板(portal 到 body,与工具条菜单同一套语言) ---------- */
.jio-creator-picker {
position: fixed;
z-index: 620;
display: flex;
max-height: min(320px, 60vh);
flex-direction: column;
overflow: hidden;
border: 1px solid rgba(24, 39, 34, 0.06);
border-radius: 12px;
background: #fff;
box-shadow: 0 14px 40px rgba(20, 32, 28, 0.16), 0 2px 6px rgba(20, 32, 28, 0.06);
animation: jio-menu-in 0.12s ease-out;
}
.jio-creator-picker.is-top {
animation-name: jio-menu-in-up;
}
.jio-creator-picker__search {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid #f0f2f1;
color: #9aa2a0;
font-size: 12px;
input {
flex: 1;
min-width: 0;
height: 26px;
padding: 0;
border: 0;
background: transparent;
color: $ink;
font: inherit;
font-size: 13px;
&:focus {
outline: 0;
}
}
}
.jio-creator-picker__title {
margin: 0;
padding: 10px 12px 4px;
color: #9aa2a0;
font-size: 11px;
letter-spacing: 0.02em;
}
.jio-creator-picker__list {
flex: 1;
overflow-y: auto;
padding: 6px;
}
.jio-creator-picker__item {
display: flex;
width: 100%;
align-items: center;
gap: 8px;
padding: 7px 10px;
border: 0;
border-radius: 7px;
background: transparent;
color: #4f5957;
cursor: pointer;
font: inherit;
font-size: 13px;
line-height: 1.5;
text-align: left;
&:hover,
&:focus-visible {
background: #f2f5f3;
color: $accent-strong;
outline: 0;
}
&.is-active {
color: $accent-strong;
}
input[type="checkbox"] {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
}
.jio-creator-picker__icon {
flex: 0 0 12px;
color: #a9b2ae;
font-size: 11px;
text-align: center;
}
.jio-creator-picker__name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.jio-creator-picker__check {
flex: 0 0 auto;
color: $accent;
font-size: 10px;
opacity: 0;
}
.jio-creator-picker__item.is-active .jio-creator-picker__check,
.jio-creator-picker__item--create .jio-creator-picker__check {
opacity: 1;
}
.jio-creator-picker__item--create {
color: $accent-strong;
.jio-creator-picker__name {
white-space: normal;
}
}
.jio-creator-picker__empty {
margin: 0;
padding: 14px 12px;
color: #9aa2a0;
font-size: 12px;
text-align: center;
}
.jio-creator-picker__foot {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 12px;
border-top: 1px solid #f0f2f1;
}
.jio-creator-picker__count {
color: #9aa2a0;
font-size: 12px;
}
.jio-creator-picker__foot-hint {
margin: 0;
padding: 0 12px 10px;
color: #9aa2a0;
font-size: 12px;
text-align: center;
}
.jio-creator-picker__done {
padding: 4px 12px;
border: 0;
border-radius: 6px;
background: #eef5f2;
color: $accent-strong;
cursor: pointer;
font: inherit;
font-size: 12px;
&:hover,
&:focus-visible {
background: #e2efea;
outline: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.jio-creator-picker {
animation: none;
}
}
+18 -1
View File
@@ -574,6 +574,19 @@ final class CreatorRest {
$tags = $source instanceof \WP_Post && $snapshot === []
? wp_get_post_tags($source->ID, ['fields' => 'names'])
: array_map('strval', (array) ($snapshot['tags'] ?? []));
$tag_enabled = !empty($settings['allow_tags']);
// 标签是"没有就建"的自由词条,但仍然把站点已有标签交给前端做输入建议,
// 避免同一概念散成多个近义标签。
$tag_suggestions = $tag_enabled
? get_terms([
'taxonomy' => 'post_tag',
'hide_empty' => false,
'number' => 200,
'orderby' => 'count',
'order' => 'DESC',
'fields' => 'names',
])
: [];
$custom_defs = [];
$custom = [];
@@ -601,7 +614,11 @@ final class CreatorRest {
return [
'categories' => ['items' => $categories, 'selected' => $current],
'tags' => ['items' => is_array($tags) ? array_values($tags) : [], 'enabled' => !empty($settings['allow_tags'])],
'tags' => [
'items' => is_array($tags) ? array_values($tags) : [],
'enabled' => $tag_enabled,
'suggestions' => is_array($tag_suggestions) ? array_values($tag_suggestions) : [],
],
'custom' => $custom,
'customDefs' => $custom_defs,
];
@@ -477,6 +477,36 @@ test('writing surface is a centered paper card with a publish settings card belo
assert.doesNotMatch(drawer, /jio-creator-captcha/);
});
test('publish settings use a multi-select category picker and a creatable tag input', () => {
const settings = read('assets/creator-studio/src/components/publish-settings.tsx');
const category = read('assets/creator-studio/src/components/category-select.tsx');
const tag = read('assets/creator-studio/src/components/tag-input.tsx');
const styles = read('assets/creator-studio/src/styles/_publish-drawer.scss');
// 分类:带搜索的多选下拉,触发器里是已选令牌。
assert.match(settings, /<CategorySelect/);
assert.match(category, /createPortal/);
assert.match(category, /type="checkbox"/);
assert.match(category, /onChange\(/);
assert.match(category, /选择分类(可多选)/);
// 旧的分类平铺复选列表已移除(自定义分类法仍是复选标签,不受影响)。
assert.doesNotMatch(settings, /toggleCategory/);
assert.doesNotMatch(settings, /categories\.includes\(/);
// 标签:可直接新建,并展示站点已有标签作为建议。
assert.match(settings, /<TagInput/);
assert.match(tag, /新建标签/);
assert.match(tag, /站点已有标签/);
assert.match(tag, /SEPARATORS/);
assert.match(tag, /Backspace/);
assert.doesNotMatch(styles, /\.jio-creator-inline\s*\{/);
// 服务端把已有标签作为建议下发,前端只读。
assert.match(creatorRest, /'suggestions'\s*=>/);
assert.match(creatorRest, /'taxonomy'\s*=>\s*'post_tag'/);
assert.match(appSource, /suggestions: \(bootstrap\.tags && bootstrap\.tags\.suggestions\)/);
});
test('scheduled publishing is validated and applied server side', () => {
const save = read('src/Core/FrontSubmitSave.php');
const rest = read('src/Core/CreatorRest.php');