fix(creator-studio): caret overlap in toolbar and hidden sub-categories

工具条下拉触发器的下三角原本绝对定位在按钮右下角,按钮宽度不够时会压到
居中的文字上。改为文字 + 三角同行排版(.jio-creator__tool-meta),宽度不足
时只压缩文字。

分类只查询 parent=0,导致二级 / 三级分类在投稿页完全不可选。新增
SubmitCategories 统一解析:
- 取全部分类并按父子关系深度优先展平,带上 depth / path;
- 白名单按"选了上级即包含其全部子级"展开,未配置白名单时不做额外过滤;
- 创作者工作台、经典投稿表单、保存校验与审核应用共用同一份结果。

选择器按 depth 缩进,搜索同时匹配完整路径。

同时把 assets/creator-studio/build 与 assets/admin/build 统一为整目录忽略
(不再跟踪 .gitkeep),避免构建产物反复被误提交。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Opus 5 committed 2026-11-09 18:10:19 +08:00
1 parent 330df53195
commit beb898b1ab
12 files changed
+291 -53

No files matched your search

+2 -4
View File
@@ -28,13 +28,11 @@ assets/css/routes/docs/*
# cd assets/admin && npm ci && npm run build
# cd assets/creator-studio && npm ci && npm run build
/assets/admin/node_modules/
/assets/admin/build/*
!/assets/admin/build/.gitkeep
/assets/admin/build/
assets/admin/.vite-temp/
/assets/creator-studio/node_modules/
/assets/creator-studio/build/*
!/assets/creator-studio/build/.gitkeep
/assets/creator-studio/build/
# PHP installed dependencies
/vendor/*
Whitespace-only changes.
@@ -13,7 +13,7 @@ import { __, sprintf } from '@wordpress/i18n';
import { useAnchoredPanel } from '../hooks/use-anchored-panel';
export type CategoryOption = { id: number; name: string };
export type CategoryOption = { id: number; name: string; depth?: number; path?: string };
type Props = {
options: CategoryOption[];
@@ -42,7 +42,8 @@ export function CategorySelect({ options, value, onChange }: Props) {
if (keyword === '') {
return options;
}
return options.filter((option) => option.name.toLowerCase().includes(keyword));
// 同时匹配完整路径:搜上级分类名也能把它的子分类带出来。
return options.filter((option) => (option.path || option.name).toLowerCase().includes(keyword));
}, [options, query]);
// 每次打开都从干净的搜索词开始,避免上次的过滤条件把列表藏起来。
@@ -87,6 +88,8 @@ export function CategorySelect({ options, value, onChange }: Props) {
<label
key={option.id}
className={`jio-creator-picker__item${active ? ' is-active' : ''}`}
style={{ paddingLeft: `${10 + (option.depth || 0) * 16}px` }}
title={option.path || option.name}
>
<input type="checkbox" checked={active} onChange={() => toggle(option.id)} />
<span className="jio-creator-picker__name">{option.name}</span>
@@ -134,7 +137,9 @@ export function CategorySelect({ options, value, onChange }: Props) {
) : (
chosen.map((option) => (
<span key={option.id} className="jio-creator-select__token">
<span className="jio-creator-select__token-label">{option.name}</span>
<span className="jio-creator-select__token-label" title={option.path || option.name}>
{option.name}
</span>
<button
type="button"
className="jio-creator-select__token-x"
@@ -130,8 +130,11 @@ export function ToolMenu({
aria-expanded={open ? 'true' : 'false'}
>
<i className={icon} aria-hidden="true" />
<span className="jio-creator__tool-label">{label}</span>
<i className="fa fas fa-caret-down jio-creator__tool-caret" aria-hidden="true" />
{/* 下三角与文字同一行参与排版:按钮再窄也只会压缩文字,不会压到三角上。 */}
<span className="jio-creator__tool-meta">
<span className="jio-creator__tool-label">{label}</span>
<i className="fa fas fa-caret-down jio-creator__tool-caret" aria-hidden="true" />
</span>
</button>
{menu}
</div>
+17 -5
View File
@@ -283,11 +283,19 @@ body.jio-page-submit.has-static-nav {
white-space: nowrap;
}
/* 下拉触发器右下角的小三角:告诉作者这里还有更多选项。 */
.jio-creator__tool-caret {
position: absolute;
right: 3px;
bottom: 4px;
/* 文字 + 下三角同处一行:三角参与排版,不会再被绝对定位盖到文字上。 */
.jio-creator__tool-meta {
display: inline-flex;
max-width: 100%;
align-items: center;
justify-content: center;
gap: 2px;
min-width: 0;
}
/* 三角要压过 `.jio-creator__tool-btn i` 的图标字号,所以带上按钮前缀。 */
.jio-creator__tool-btn .jio-creator__tool-caret {
flex: 0 0 auto;
color: #b0b9b6;
font-size: 8px;
line-height: 1;
@@ -857,6 +865,10 @@ body.jio-page-submit.has-static-nav {
font-size: 10px;
}
.jio-creator__tool-meta {
gap: 1px;
}
.jio-creator__canvas {
width: calc(100% - 20px);
margin: 14px auto 0;
+9 -15
View File
@@ -127,7 +127,7 @@ final class CreatorRest {
? (array) get_post_meta($source->ID, DomainModel::POST_META_KEY, true)
: [];
$terms = $this->term_payload($settings, $source, $snapshot);
$terms = $this->term_payload($settings, $source, $snapshot);
$viewer = $user_id > 0 ? get_userdata($user_id) : null;
$ident = $this->identity_payload($viewer instanceof \WP_User ? $viewer : null);
@@ -551,21 +551,15 @@ final class CreatorRest {
* @return array<string, mixed>
*/
private function term_payload(array $settings, ?\WP_Post $source, array $snapshot): array {
$cat_args = [
'taxonomy' => 'category',
'hide_empty' => false,
'orderby' => 'name',
'order' => 'ASC',
];
if (!empty($settings['allowed_cat_ids'])) {
$cat_args['include'] = $settings['allowed_cat_ids'];
} else {
$cat_args['parent'] = 0;
}
$terms = get_terms($cat_args);
// 分类树按层级深度优先展平,二级 / 三级分类不会因为 parent=0 过滤而消失。
$categories = [];
foreach (is_array($terms) ? $terms : [] as $term) {
$categories[] = ['id' => (int) $term->term_id, 'name' => (string) $term->name];
foreach (SubmitCategories::tree($settings) as $node) {
$categories[] = [
'id' => (int) $node['term']->term_id,
'name' => (string) $node['term']->name,
'depth' => (int) $node['depth'],
'path' => (string) $node['path'],
];
}
$current = $source instanceof \WP_Post && $snapshot === []
+3 -4
View File
@@ -103,10 +103,9 @@ trait FrontSubmitSave {
}
}
// Allowed category whitelist.
$allowed = $settings['allowed_cat_ids'] ?? [];
if (!empty($allowed) && !empty($categories)) {
$categories = array_values(array_intersect($categories, $allowed));
// Allowed category whitelist: 选中上级分类时,其全部子分类同样可选。
if (!empty($categories)) {
$categories = SubmitCategories::filter_ids($categories, $settings);
if (!$is_draft && empty($categories)) {
return ['success' => false, 'message' => __('所选分类不可用', 'jio')];
}
+2 -3
View File
@@ -427,9 +427,8 @@ final class SubmissionVersion {
if (isset($snapshot['categories']) && is_array($snapshot['categories'])) {
$cats = array_values(array_unique(array_filter(array_map('absint', $snapshot['categories']))));
$allowed = is_array($settings['allowed_cat_ids'] ?? null) ? array_map('intval', $settings['allowed_cat_ids']) : [];
if ($allowed !== [] && $cats !== []) {
$cats = array_values(array_intersect($cats, $allowed));
if ($cats !== []) {
$cats = SubmitCategories::filter_ids($cats, $settings);
}
wp_set_post_categories($post_id, $cats);
}
+197
View File
@@ -0,0 +1,197 @@
<?php
/**
* 投稿可选分类的解析。
*
* 前台投稿的分类白名单(「投稿允许选择的分类」)按"选了上级就包含其全部子级"
* 的语义生效:白名单里出现某个分类时,它的所有后代分类都可选;白名单为空时
* 全部分类可选。列表本身始终是完整的层级树,而不是只有顶级分类——否则二级、
* 三级分类会从投稿页里彻底消失。
*
* 创作者工作台(CreatorRest)与经典投稿表单(templates/parts/submit)共用这里
* 的结果,避免两处各自拼一套查询。
*
* @package Jio
*/
namespace Jio\Core;
defined('ABSPATH') || exit;
final class SubmitCategories {
/**
* 请求内缓存:一次投稿页渲染 / 一次 REST 请求只查一次分类表。
*
* @var array<string, array{terms: \WP_Term[], tree: array<int, array{term: \WP_Term, depth: int, path: string}>, ids: array<int, true>}>
*/
private static array $cache = [];
/**
* 可选的分类树,按层级深度优先展平。
*
* 每个元素都带 `depth`(顶级为 0)与 `path`(从顶级到自身的名称串),
* 前端可以按需缩进或显示完整路径。
*
* @param array<string, mixed> $settings 投稿设置(读取 allowed_cat_ids)。
* @return array<int, array{term: \WP_Term, depth: int, path: string}>
*/
public static function tree(array $settings): array {
return self::resolve($settings)['tree'];
}
/**
* 一次解析出「可选分类树」与「允许的分类 ID 集合」。
*
* @param array<string, mixed> $settings
* @return array{terms: \WP_Term[], tree: array<int, array{term: \WP_Term, depth: int, path: string}>, ids: array<int, true>}
*/
private static function resolve(array $settings): array {
$signature = self::signature($settings);
if (isset(self::$cache[$signature])) {
return self::$cache[$signature];
}
$whitelist = self::whitelist($settings);
// 白名单为空 = 全部分类可选,此时不做 ID 过滤,避免多一次全表查询,
// 也避免分类查询异常时把正常投稿误判成"所选分类不可用"。
$allowed = $whitelist === [] ? null : self::expand_whitelist($whitelist);
$args = [
'taxonomy' => 'category',
'hide_empty' => false,
'orderby' => 'name',
'order' => 'ASC',
'number' => 0,
];
if ($allowed !== null) {
// include 只收窄查询范围,父子关系仍由 flatten() 复原。
$args['include'] = array_keys($allowed);
}
$terms = get_terms($args);
$terms = is_array($terms) ? $terms : [];
if ($allowed !== null) {
$terms = array_values(array_filter(
$terms,
static fn($term): bool => isset($allowed[(int) $term->term_id])
));
}
$ids = [];
foreach ($terms as $term) {
$ids[(int) $term->term_id] = true;
}
self::$cache[$signature] = [
'terms' => $terms,
'tree' => self::flatten($terms),
'ids' => $ids,
];
return self::$cache[$signature];
}
/**
* 过滤提交上来的分类 ID,只保留允许的部分。
*
* 白名单未启用时原样返回(不查分类表),启用时按"含后代"的集合过滤。
*
* @param int[] $ids
* @param array<string, mixed> $settings
* @return int[]
*/
public static function filter_ids(array $ids, array $settings): array {
$whitelist = self::whitelist($settings);
if ($whitelist === []) {
return array_values(array_unique(array_filter(array_map('intval', $ids))));
}
$allowed = self::expand_whitelist($whitelist);
$kept = [];
foreach ($ids as $id) {
$id = (int) $id;
if ($id > 0 && isset($allowed[$id])) {
$kept[] = $id;
}
}
return array_values(array_unique($kept));
}
/**
* 展开白名单:选中分类 + 其全部后代。
*
* @param int[] $whitelist
* @return array<int, true>
*/
private static function expand_whitelist(array $whitelist): array {
$ids = [];
foreach ($whitelist as $id) {
$ids[$id] = true;
$children = get_term_children($id, 'category');
foreach (is_array($children) ? $children : [] as $child) {
$ids[(int) $child] = true;
}
}
return $ids;
}
/**
* 配置里显式选中的分类(未展开后代)。
*
* @param array<string, mixed> $settings
* @return int[]
*/
private static function whitelist(array $settings): array {
if (!is_array($settings['allowed_cat_ids'] ?? null)) {
return [];
}
return array_values(array_filter(array_map('intval', $settings['allowed_cat_ids'])));
}
/**
* 把分类按父子关系整理成深度优先序列。
*
* 父级不在集合里(例如只授权了某个子分类)的分类直接作为根节点,
* 不会被静默丢弃。
*
* @param \WP_Term[] $terms
* @return array<int, array{term: \WP_Term, depth: int, path: string}>
*/
private static function flatten(array $terms): array {
$by_parent = [];
$present = [];
foreach ($terms as $term) {
$present[(int) $term->term_id] = true;
}
foreach ($terms as $term) {
$parent = (int) $term->parent;
if (!isset($present[$parent])) {
$parent = 0;
}
$by_parent[$parent][] = $term;
}
$out = [];
$walk = static function (int $parent, int $depth, string $prefix) use (&$walk, $by_parent, &$out): void {
foreach ($by_parent[$parent] ?? [] as $term) {
$path = $prefix === '' ? (string) $term->name : $prefix . ' / ' . $term->name;
$out[] = ['term' => $term, 'depth' => $depth, 'path' => $path];
$walk((int) $term->term_id, $depth + 1, $path);
}
};
$walk(0, 0, '');
return $out;
}
/**
* @param array<string, mixed> $settings
*/
private static function signature(array $settings): string {
$whitelist = self::whitelist($settings);
sort($whitelist);
return implode(',', $whitelist);
}
private function __construct() {
}
}
+4 -15
View File
@@ -91,21 +91,10 @@ if ($edit_post) {
}
// Terms.
$cat_args = [
'taxonomy' => 'category',
'hide_empty' => false,
'orderby' => 'name',
'order' => 'ASC',
];
if (!empty($settings['allowed_cat_ids'])) {
$cat_args['include'] = $settings['allowed_cat_ids'];
} else {
$cat_args['parent'] = 0;
}
$categories = get_terms($cat_args);
if (is_wp_error($categories)) {
$categories = [];
}
$categories = array_map(
static fn(array $node): \WP_Term => $node['term'],
\Jio\Core\SubmitCategories::tree($settings)
);
$current_cats = $edit_post ? wp_get_post_categories($edit_post->ID) : [];
$custom_dimensions = [];
@@ -380,7 +380,6 @@ test('quote, hidden content, and block math render like the live article', () =>
test('creator studio build artifacts and contracts exist', () => {
assert.ok(exists('assets/creator-studio/package.json'));
assert.ok(exists('assets/creator-studio/package-lock.json'));
assert.ok(exists('assets/creator-studio/build/.gitkeep'));
// build/ is gitignored and deploy-only (same as assets/admin/build); when a
// local build is present, all three entry artifacts must be there together.
const built = ['index.asset.php', 'index.js', 'index.css'];
@@ -507,6 +506,30 @@ test('publish settings use a multi-select category picker and a creatable tag in
assert.match(appSource, /suggestions: \(bootstrap\.tags && bootstrap\.tags\.suggestions\)/);
});
// 二级 / 三级分类必须出现在投稿页:列表是完整层级树,白名单向下展开到子分类。
test('submit categories resolve the full hierarchy and expand the whitelist', () => {
const categories = read('src/Core/SubmitCategories.php');
const submitContext = read('templates/parts/submit/context.php');
const save = read('src/Core/FrontSubmitSave.php');
// 不再用 parent=0 过滤,改为取全部分类后按父子关系展平。
assert.doesNotMatch(categories, /'parent'\s*=>\s*0/);
assert.match(categories, /get_term_children\(\$id, 'category'\)/);
assert.match(categories, /'number'\s*=>\s*0/);
assert.match(categories, /'depth'\s*=>\s*\$depth/);
// 父级不在集合里时,子分类仍作为根节点出现,不会被静默丢弃。
assert.match(categories, /if \(!isset\(\$present\[\$parent\]\)\) \{/);
// 创作者工作台与经典投稿表单共用同一份分类数据。
assert.match(creatorRest, /SubmitCategories::tree\(\$settings\)/);
assert.match(submitContext, /SubmitCategories::tree\(\$settings\)/);
assert.doesNotMatch(submitContext, /\$cat_args\['parent'\]\s*=\s*0/);
// 白名单与审核应用都走同一套"含后代"的过滤。
assert.match(save, /SubmitCategories::filter_ids\(\$categories, \$settings\)/);
assert.match(versions, /SubmitCategories::filter_ids\(\$cats, \$settings\)/);
});
test('scheduled publishing is validated and applied server side', () => {
const save = read('src/Core/FrontSubmitSave.php');
const rest = read('src/Core/CreatorRest.php');
@@ -34,10 +34,21 @@ namespace {
}
}
class WP_Term {
public int $term_id;
public string $name;
public int $parent;
public function __construct(int $term_id, string $name, int $parent = 0) {
$this->term_id = $term_id;
$this->name = $name;
$this->parent = $parent;
}
}
class WP_Error {
private string $code;
private string $message;
public function __construct($code = '', $message = '', $data = null) {
$this->code = (string) $code;
$this->message = (string) $message;
@@ -185,6 +196,13 @@ namespace {
function do_action($tag, ...$args): void { $GLOBALS['front_pending_state']['actions'][] = $tag; }
function current_time($type): string { return '2026-10-30 12:00:00'; }
function wp_set_post_categories($post_id, $categories): void {}
function get_terms($args = []) {
// 投稿分类解析需要一张分类表;本 harness 只关心上传生命周期,
// 给一个单层分类即可让白名单校验通过。
$term = new WP_Term(1, 'uncategorized', 0);
return [$term];
}
function get_term_children($term_id, $taxonomy) { return []; }
function wp_set_post_tags($post_id, $tags): void {}
function get_permalink($post_id): string { return 'https://example.test/?p=' . (int) $post_id; }
function clean_post_cache($post_id): void {}
@@ -334,6 +352,7 @@ namespace {
}
fp_reset();
require_once dirname(__DIR__, 2) . '/src/Core/SubmitCategories.php';
require_once dirname(__DIR__, 2) . '/src/Attachment/PendingUploads.php';
require_once dirname(__DIR__, 2) . '/src/Core/FrontSubmitSave.php';
require_once dirname(__DIR__, 2) . '/src/Core/FrontSubmitPay.php';