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:
1 parent
330df53195
commit
beb898b1ab
12 files changed
+291
-53
No files matched your search
+2
-4
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 === []
|
||||
|
||||
@@ -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')];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user