fix(admin): replicate reference front-submit settings page 1:1

- Rewrite post_list/submit schema (PHP + JS) with the reference theme's
  field set, order, defaults and dependencies; topics fields excluded
- Map PostDisplay::get_submit_settings() and the FrontSubmit runtime to
  the new keys without legacy fallback
- Guest submission follows the capability matrix; category required on
  submit; pay limits read from the cash/points fieldset
- Drop dead front_submit_* settings, template branches and JS config
- Add submit settings reference contract test
This commit is contained in:
2026-09-24 12:29:19 +08:00
parent ed71314986
commit 264f2c51b2
14 changed files with 354 additions and 292 deletions
@@ -0,0 +1,183 @@
'use strict';
/**
* 「前台投稿」后台设置页契约(对齐参考主题 1:1,不含专题):
* 1) PHP schema(SchemaPostList.php submit section)字段键、顺序、默认值与
* JS schema(assets/admin/src/schema/sections/post-list/submit.js)一致;
* 2) 参考主题字段全集存在:post_article_s / post_article_btn_txte / post_article_cat /
* post_article_custom_filter_s / post_article_tag / post_article_user /
* post_article_title_strlen_limit / post_article_pay_free_s /
* post_article_pay_price_limit / post_article_pay_vip_price_s /
* post_article_pay_vip_1_discount / post_article_pay_vip_2_discount /
* post_t_placeholder / post_c_placeholder;
* 3) 专题(topics)相关字段不存在;
* 4) 旧 AKSRT 私有字段(front_submit_*、post_article_title_strlen_limit_min/max、
* post_article_pay_price_min/max)已彻底移除,无双路兼容;
* 5) 运行时(PostDisplay::get_submit_settings)只读取新键。
*/
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const php = fs.readFileSync(path.join(root, 'src/Admin/Schema/SchemaPostList.php'), 'utf8');
const js = fs.readFileSync(path.join(root, 'assets/admin/src/schema/sections/post-list/submit.js'), 'utf8');
const runtime = fs.readFileSync(path.join(root, 'src/Settings/PostDisplay.php'), 'utf8');
const REFERENCE_KEYS = [
'post_article_s',
'post_article_btn_txte',
'post_article_cat',
'post_article_custom_filter_s',
'post_article_tag',
'post_article_user',
'post_article_title_strlen_limit',
'post_article_pay_free_s',
'post_article_pay_price_limit',
'post_article_pay_vip_price_s',
'post_article_pay_vip_1_discount',
'post_article_pay_vip_2_discount',
'post_t_placeholder',
'post_c_placeholder',
];
// PHP 源码中字段定义的完整出现顺序(含 post_article_pay_price_limit 的
// fieldset 嵌套子字段 price / points)。
const PHP_FIELD_ORDER = [
...REFERENCE_KEYS.slice(0, REFERENCE_KEYS.indexOf('post_article_pay_vip_price_s')),
'price',
'points',
...REFERENCE_KEYS.slice(REFERENCE_KEYS.indexOf('post_article_pay_vip_price_s')),
];
const LEGACY_KEYS = [
'front_submit_enabled',
'front_submit_roles',
'front_submit_title',
'front_submit_content',
'front_submit_excerpt',
'front_submit_cover',
'front_submit_category',
'front_submit_guest_enabled',
'front_submit_tags',
'front_submit_custom_fields',
'front_submit_review_mode',
'front_submit_cover_required',
'front_submit_max_images',
'front_submit_editor_mode',
'front_submit_draft_save',
'front_submit_leave_warning',
'post_article_title_strlen_limit_min',
'post_article_title_strlen_limit_max',
'post_article_pay_price_min',
'post_article_pay_price_max',
];
/** Extract the PHP 'submit' => [ ... ] section body (balanced brackets). */
function phpSubmitSection(src) {
const start = src.indexOf("'submit' => [");
assert.notEqual(start, -1, 'PHP schema has a submit section');
const open = src.indexOf('[', start);
let depth = 0;
for (let i = open; i < src.length; i++) {
const ch = src[i];
if (ch === "'" || ch === '"') {
const quote = ch;
i++;
while (i < src.length) {
if (src[i] === '\\') { i += 2; continue; }
if (src[i] === quote) break;
i++;
}
continue;
}
if (ch === '[') depth++;
else if (ch === ']') {
depth--;
if (depth === 0) return src.slice(open, i + 1);
}
}
throw new Error('unbalanced submit section');
}
const submitPhp = phpSubmitSection(php);
test('PHP submit section 字段键与参考主题一致且顺序相同', () => {
// 字段定义均为 ['key' => '...', 'label' => ...] 开头;dependsOn 内的
// ['key' => '...', 'value' => ...] 不是字段定义,须排除。
const found = [];
const re = /'key'\s*=>\s*'([a-z0-9_]+)',\s*'label'/g;
let m;
while ((m = re.exec(submitPhp)) !== null) found.push(m[1]);
assert.deepEqual(found, PHP_FIELD_ORDER);
});
test('PHP submit section 不含专题字段', () => {
assert.doesNotMatch(submitPhp, /post_article_topics/);
assert.doesNotMatch(submitPhp, /'topics'/);
});
test('PHP submit section 默认值对齐参考主题', () => {
assert.match(submitPhp, /'post_article_s',\s*'label'\s*=>\s*'启用前台投稿',\s*'type'\s*=>\s*'switcher',\s*'default'\s*=>\s*true/);
assert.match(submitPhp, /'post_article_btn_txte',\s*'label'\s*=>\s*'按钮文字',\s*'type'\s*=>\s*'input',\s*'default'\s*=>\s*'发布文章'/);
assert.match(submitPhp, /'post_article_user',\s*'label'\s*=>\s*'免登陆投稿发布用户',\s*'type'\s*=>\s*'user_select',\s*'default'\s*=>\s*1/);
assert.match(submitPhp, /'post_article_title_strlen_limit',\s*'label'\s*=>\s*'标题字数限制',\s*'type'\s*=>\s*'between_number',\s*'default'\s*=>\s*\['min'\s*=>\s*5,\s*'max'\s*=>\s*30\]/);
assert.match(submitPhp, /'post_article_pay_vip_1_discount',\s*'label'\s*=>\s*'一级会员折扣',\s*'type'\s*=>\s*'number',\s*'default'\s*=>\s*100/);
assert.match(submitPhp, /'post_article_pay_vip_2_discount',\s*'label'\s*=>\s*'二级会员折扣',\s*'type'\s*=>\s*'number',\s*'default'\s*=>\s*100/);
assert.match(submitPhp, /'post_t_placeholder',\s*'label'\s*=>\s*'投稿标题占位符',\s*'type'\s*=>\s*'input',\s*'default'\s*=>\s*'请输入标题'/);
assert.match(submitPhp, /'post_c_placeholder',\s*'label'\s*=>\s*'投稿内容占位符',\s*'type'\s*=>\s*'input',\s*'default'\s*=>\s*'请输入内容'/);
assert.match(submitPhp, /'price',\s*'label'\s*=>\s*'现金金额限制'/);
assert.match(submitPhp, /'points',\s*'label'\s*=>\s*'积分金额限制'/);
});
test('会员折扣仅在关闭手动会员价时显示(对齐参考主题 dependency)', () => {
assert.match(
submitPhp,
/'post_article_pay_vip_1_discount'[\s\S]*?'dependsOn'\s*=>\s*\['all'\s*=>\s*\[\['key'\s*=>\s*'post_article_s',\s*'value'\s*=>\s*true\],\s*\['key'\s*=>\s*'post_article_pay_vip_price_s',\s*'value'\s*=>\s*false\]\]\]/
);
assert.match(
submitPhp,
/'post_article_pay_vip_2_discount'[\s\S]*?'dependsOn'\s*=>\s*\['all'\s*=>\s*\[\['key'\s*=>\s*'post_article_s',\s*'value'\s*=>\s*true\],\s*\['key'\s*=>\s*'post_article_pay_vip_price_s',\s*'value'\s*=>\s*false\]\]\]/
);
});
test('JS schema 与 PHP 字段键一致(ui_only content 行除外)', () => {
// 字段定义为 { key: '...', label: ... };dependsOn 内的 { key, value } 须排除。
const keys = [];
const re = /key:\s*'([a-z0-9_]+)',\s*label:/g;
let m;
while ((m = re.exec(js)) !== null) keys.push(m[1]);
const persisted = keys.filter((k) => REFERENCE_KEYS.includes(k));
assert.deepEqual(persisted, REFERENCE_KEYS);
for (const legacy of LEGACY_KEYS) {
assert.ok(!keys.includes(legacy), `JS schema 残留旧字段: ${legacy}`);
}
// 两个 ui_only 提示行承载参考主题 desc 中的【去设置】链接。
assert.match(js, /permissions\/capabilities/);
assert.match(js, /post-list\/filter/);
assert.match(js, /ui_only:\s*true/);
});
test('JS schema 默认值对齐参考主题', () => {
assert.match(js, /post_article_user:\s*1\b/);
assert.match(js, /post_article_pay_vip_1_discount:\s*100\b/);
assert.match(js, /post_article_pay_vip_2_discount:\s*100\b/);
assert.match(js, /post_t_placeholder:\s*'请输入标题'/);
assert.match(js, /post_c_placeholder:\s*'请输入内容'/);
assert.match(js, /price:\s*\{\s*min:\s*0,\s*max:\s*99999\s*\}/);
});
test('旧字段在 schema 与运行时中均已移除(无双路)', () => {
for (const legacy of LEGACY_KEYS) {
assert.ok(!php.includes(legacy), `PHP schema 残留旧字段: ${legacy}`);
assert.ok(!runtime.includes(legacy), `PostDisplay 残留旧字段: ${legacy}`);
}
// 运行时读取新键。
assert.match(runtime, /\$settings\['post_article_s'\]/);
assert.match(runtime, /\$settings\['post_article_title_strlen_limit'\]/);
assert.match(runtime, /\$settings\['post_article_pay_price_limit'\]/);
assert.match(runtime, /\$settings\['post_article_tag'\]/);
assert.match(runtime, /\$settings\['post_article_user'\]/);
});