801 lines
30 KiB
PHP
801 lines
30 KiB
PHP
<?php
|
||
|
||
namespace App\Service\business;
|
||
|
||
use App\Service\common\UtilsService;
|
||
|
||
/**
|
||
* 装修模板 JSON 的白名单校验
|
||
*
|
||
* 导入的 JSON 会被小程序当成 CSS 变量注入根节点,一份坏 JSON 能把线上小程序打成白屏,
|
||
* 所以入库前必须过白名单:键名不在表里的丢弃,值必须是安全的短字符串。
|
||
* 这里只允许「值」,不允许任何 CSS 语句片段(分号、url()、表达式一律拒绝)。
|
||
*/
|
||
class WxTemplateSchemaService
|
||
{
|
||
private static mixed $_instance;
|
||
|
||
/**
|
||
* 允许的令牌键,分组 => 键名清单
|
||
*/
|
||
public const TOKEN_SCHEMA = [
|
||
'color' => [
|
||
'primary', 'primary_soft', 'primary_strong', 'accent', 'bg', 'bg_soft',
|
||
'surface', 'surface_soft', 'text', 'text_soft', 'text_muted', 'border',
|
||
'price', 'success', 'warning', 'danger', 'mask',
|
||
],
|
||
'font' => [
|
||
'family', 'family_title', 'size_xs', 'size_sm', 'size_md', 'size_lg',
|
||
'size_xl', 'size_title', 'weight_normal', 'weight_bold', 'line_height', 'letter_spacing',
|
||
],
|
||
'radius' => ['none', 'sm', 'md', 'lg', 'xl', 'pill'],
|
||
'shadow' => ['none', 'sm', 'md', 'lg'],
|
||
'space' => ['xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'page'],
|
||
'motion' => ['fast', 'base', 'slow', 'easing', 'easing_in', 'easing_out'],
|
||
];
|
||
|
||
/**
|
||
* 允许的布局键与可选值
|
||
*/
|
||
public const LAYOUT_SCHEMA = [
|
||
'home' => [
|
||
'hero' => ['banner', 'carousel', 'split', 'fullscreen', 'stack', 'coverflow', 'fade', 'peek', 'cube', 'caption'],
|
||
'category' => ['grid', 'scroll', 'card', 'sidebar', 'pills', 'mosaic', 'featured', 'tile', 'contents'],
|
||
'package' => ['card', 'scroll', 'featured', 'magazine'],
|
||
'product' => ['waterfall', 'list', 'grid'],
|
||
],
|
||
'product' => [
|
||
'gallery' => ['swiper', 'stack', 'fullbleed', 'peek', 'fade', 'coverflow', 'mosaic', 'filmstrip'],
|
||
'price' => ['inline', 'card', 'sticky'],
|
||
'action' => ['fixed', 'inline', 'split'],
|
||
],
|
||
'list' => [
|
||
'style' => ['card', 'table', 'timeline', 'compact', 'ticket', 'stacked'],
|
||
],
|
||
'mine' => [
|
||
'header' => ['gradient', 'image', 'plain', 'split', 'editorial'],
|
||
'menu' => ['grid', 'list', 'card', 'tile', 'compact'],
|
||
],
|
||
'effect' => [
|
||
'transition' => ['fade', 'slide', 'zoom', 'none'],
|
||
'skeleton' => ['shimmer', 'pulse', 'none'],
|
||
// 卡片装饰与图标风格:小程序 ThemeCardFrame / ThemeIcon 按枚举切换
|
||
'card' => ['plain', 'elevated', 'ornament', 'canvas-frame'],
|
||
'icon' => ['line', 'fill', 'duotone', 'block'],
|
||
],
|
||
];
|
||
|
||
/**
|
||
* 装修工作室可编排的页面与区块类型
|
||
*/
|
||
public const PAGE_SECTION_TYPES = [
|
||
'home' => ['search', 'hero', 'category', 'package', 'product'],
|
||
'package' => ['list'],
|
||
'catalog' => ['filter', 'list'],
|
||
'search' => ['search', 'list'],
|
||
'product' => ['gallery', 'info', 'spec', 'price', 'action'],
|
||
'cart' => ['list'],
|
||
'mine' => ['header', 'menu'],
|
||
];
|
||
|
||
/**
|
||
* 各区块允许的变体(与旧 LAYOUT_SCHEMA 对齐,方便小程序复用)
|
||
*/
|
||
public const SECTION_VARIANTS = [
|
||
'search' => ['bar', 'overlay', 'pill', 'float'],
|
||
'hero' => ['banner', 'carousel', 'split', 'fullscreen', 'stack', 'coverflow', 'fade', 'peek', 'cube', 'caption'],
|
||
'category' => ['grid', 'scroll', 'card', 'sidebar', 'pills', 'mosaic', 'featured', 'tile', 'contents'],
|
||
'package' => ['card', 'scroll', 'featured', 'magazine'],
|
||
'product' => ['waterfall', 'list', 'grid'],
|
||
'filter' => ['chip', 'bar', 'sidebar', 'hidden'],
|
||
'list' => [
|
||
'card', 'table', 'timeline', 'waterfall', 'grid', 'list',
|
||
'masonry', 'featured', 'shelf', 'compact', 'airy', 'mosaic', 'duo',
|
||
'ticket', 'stacked',
|
||
],
|
||
'gallery' => ['swiper', 'stack', 'fullbleed', 'peek', 'fade', 'coverflow', 'mosaic', 'filmstrip'],
|
||
'info' => ['plain', 'editorial', 'split', 'overlay'],
|
||
'spec' => ['plain', 'table', 'chips', 'cards'],
|
||
'price' => ['inline', 'card', 'sticky'],
|
||
'action' => ['fixed', 'inline', 'split'],
|
||
'header' => ['gradient', 'image', 'plain', 'split', 'editorial'],
|
||
'menu' => ['grid', 'list', 'card', 'tile', 'compact'],
|
||
];
|
||
|
||
/**
|
||
* 同一 type 在不同页面含义不同:列表是封面+型号,清单是收藏夹卡片
|
||
*/
|
||
public const PAGE_SECTION_VARIANTS = [
|
||
'home' => [
|
||
'product' => ['waterfall', 'grid', 'list'],
|
||
'package' => ['card', 'scroll', 'featured', 'magazine'],
|
||
],
|
||
'package' => [
|
||
'list' => ['card', 'featured', 'magazine', 'list'],
|
||
],
|
||
'catalog' => [
|
||
'list' => ['waterfall', 'grid', 'list', 'masonry', 'featured', 'shelf', 'compact', 'airy', 'mosaic', 'duo'],
|
||
],
|
||
'search' => [
|
||
'list' => ['waterfall', 'grid', 'list', 'masonry', 'featured', 'shelf', 'compact', 'airy', 'mosaic', 'duo'],
|
||
],
|
||
'cart' => [
|
||
'list' => ['card', 'table', 'timeline', 'compact', 'ticket', 'stacked'],
|
||
],
|
||
];
|
||
|
||
public const PAGE_SKINS = ['shop', 'magazine'];
|
||
public const PAGE_DENSITIES = ['compact', 'regular', 'airy'];
|
||
public const PAGE_FRAMES = ['none', 'inset', 'ornament'];
|
||
public const CHROME_TABBAR = ['plain', 'line', 'pill', 'dot'];
|
||
public const CHROME_TABBAR_ANIM = ['none', 'fade', 'slide', 'spring'];
|
||
public const CHROME_NAVBAR = ['solid', 'line'];
|
||
public const LAYER_FACES = ['sans', 'serif', 'kai', 'script'];
|
||
public const LAYER_TONES = [
|
||
'primary', 'primary_soft', 'primary_strong', 'accent',
|
||
'surface', 'surface_soft', 'bg_soft', 'text', 'text_soft', 'text_muted', 'border',
|
||
];
|
||
|
||
/**
|
||
* 工作室样式画廊用的中文名
|
||
*/
|
||
public const SECTION_VARIANT_LABELS = [
|
||
'search' => [
|
||
'bar' => '搜索条',
|
||
'overlay' => '压在封面',
|
||
'pill' => '胶囊',
|
||
'float' => '悬浮',
|
||
],
|
||
'hero' => [
|
||
'banner' => '横幅',
|
||
'carousel' => '轮播',
|
||
'split' => '分栏',
|
||
'fullscreen' => '全屏',
|
||
'stack' => '堆叠',
|
||
'coverflow' => '封面流',
|
||
'fade' => '叠化',
|
||
'peek' => '露边',
|
||
'cube' => '立方',
|
||
'caption' => '刊名条',
|
||
],
|
||
'category' => [
|
||
'grid' => '宫格',
|
||
'scroll' => '横滑',
|
||
'card' => '卡片',
|
||
'sidebar' => '侧栏',
|
||
'pills' => '胶囊',
|
||
'mosaic' => '马赛克',
|
||
'featured' => '首图',
|
||
'tile' => '瓷砖',
|
||
'contents' => '刊页目录',
|
||
],
|
||
'package' => [
|
||
'card' => '卡片',
|
||
'scroll' => '横滑',
|
||
'featured' => '首图',
|
||
'magazine' => '刊名条',
|
||
],
|
||
'product' => [
|
||
'waterfall' => '瀑布',
|
||
'grid' => '双列',
|
||
'list' => '单列',
|
||
],
|
||
'filter' => [
|
||
'chip' => '分类条',
|
||
'bar' => '仅搜索',
|
||
'sidebar' => '侧栏',
|
||
'hidden' => '隐藏',
|
||
],
|
||
'list' => [
|
||
'waterfall' => '瀑布',
|
||
'grid' => '双列',
|
||
'list' => '单列',
|
||
'masonry' => '砌石',
|
||
'featured' => '首图',
|
||
'shelf' => '货架',
|
||
'compact' => '紧凑',
|
||
'airy' => '疏朗',
|
||
'mosaic' => '马赛克',
|
||
'duo' => '对开',
|
||
'card' => '卡片',
|
||
'table' => '表格',
|
||
'timeline' => '时间线',
|
||
'ticket' => '票根',
|
||
'stacked' => '层叠',
|
||
],
|
||
'gallery' => [
|
||
'swiper' => '轮播',
|
||
'stack' => '叠图',
|
||
'fullbleed' => '全出血',
|
||
'peek' => '露边',
|
||
'fade' => '叠化',
|
||
'coverflow' => '封面流',
|
||
'mosaic' => '拼贴',
|
||
'filmstrip' => '胶卷',
|
||
],
|
||
'info' => [
|
||
'plain' => '常规',
|
||
'editorial' => '编辑',
|
||
'split' => '分栏',
|
||
'overlay' => '压字',
|
||
],
|
||
'spec' => [
|
||
'plain' => '规格表',
|
||
'table' => '表格',
|
||
'chips' => '芯片',
|
||
'cards' => '卡片',
|
||
],
|
||
'price' => [
|
||
'inline' => '跟规格走',
|
||
'card' => '独立报价卡',
|
||
'sticky' => '吸顶报价',
|
||
],
|
||
'action' => [
|
||
'fixed' => '钉在底部',
|
||
'inline' => '跟正文走',
|
||
'split' => '左右拆开',
|
||
],
|
||
'header' => [
|
||
'gradient' => '渐变',
|
||
'image' => '头图',
|
||
'plain' => '素底',
|
||
'split' => '分栏',
|
||
'editorial' => '编辑',
|
||
],
|
||
'menu' => [
|
||
'grid' => '宫格',
|
||
'list' => '列表',
|
||
'card' => '卡片',
|
||
'tile' => '瓷砖',
|
||
'compact' => '紧凑',
|
||
],
|
||
];
|
||
|
||
/**
|
||
* 卡片图层允许的 type / bind / font
|
||
*/
|
||
public const LAYER_TYPES = ['photo', 'text', 'deco', 'shape'];
|
||
public const LAYER_BINDS = ['cover', 'gallery', 'upload', 'name', 'price', 'custom'];
|
||
public const LAYER_FONTS = ['title', 'body', 'caption', 'price'];
|
||
public const DECO_ASSETS = [
|
||
'paper-edge', 'pearl-line', 'walnut-frame', 'gold-foil', 'washi-1',
|
||
'botanical', 'museum-mat', 'folio-line', 'wax-seal', 'ribbon',
|
||
'newsprint', 'gold-corner', 'handwriting', 'arch-mat', 'double-frame',
|
||
'inset-shadow', 'torn-edge', 'film-sprocket', 'corner-bracket',
|
||
'emboss-plate', 'circle-crop', 'vignette', 'folio-number', 'stamp-ring',
|
||
'lace-corner', 'thin-rule', 'shadow-deck', 'cap-rail', 'gutter-fold',
|
||
'pearl-bead', 'vert-caption',
|
||
];
|
||
|
||
/**
|
||
* 危险片段:出现即拒绝整个值
|
||
*/
|
||
private const FORBIDDEN = ['<', '>', ';', '{', '}', 'url(', 'expression', 'javascript:', 'import'];
|
||
|
||
public static function getInstance(): null|static
|
||
{
|
||
$name = get_called_class();
|
||
if (!isset(self::$_instance[$name])) {
|
||
self::$_instance[$name] = new static();
|
||
}
|
||
return self::$_instance[$name];
|
||
}
|
||
|
||
/**
|
||
* 清洗令牌:只保留白名单键,值必须安全
|
||
*
|
||
* @param mixed $tokens 数组或 JSON 字符串
|
||
* @param bool $strict true 时遇到非法值直接报错(导入场景),false 时静默丢弃
|
||
*/
|
||
public function sanitizeTokens(mixed $tokens, bool $strict = false): array
|
||
{
|
||
$tokens = $this->toArray($tokens);
|
||
$clean = [];
|
||
foreach (self::TOKEN_SCHEMA as $group => $keys) {
|
||
$source = $tokens[$group] ?? [];
|
||
if (!is_array($source)) {
|
||
continue;
|
||
}
|
||
foreach ($keys as $key) {
|
||
if (!array_key_exists($key, $source)) {
|
||
continue;
|
||
}
|
||
$value = $source[$key];
|
||
if (!$this->isSafeValue($value)) {
|
||
if ($strict) {
|
||
UtilsService::getInstance()->errorThrow("模板令牌 {$group}.{$key} 的值不合法");
|
||
}
|
||
continue;
|
||
}
|
||
$clean[$group][$key] = (string) $value;
|
||
}
|
||
}
|
||
return $clean;
|
||
}
|
||
|
||
/**
|
||
* 清洗布局:旧枚举 + pages.sections + card_scheme
|
||
*
|
||
* 非法枚举在宽松模式退回第一项;strict(导入)直接报错。
|
||
* pages / card_scheme 是装修工作室写入的,必须保留,否则保存后拖拽结果会丢。
|
||
*/
|
||
public function sanitizeLayout(mixed $layout, bool $strict = false): array
|
||
{
|
||
$layout = $this->toArray($layout);
|
||
$clean = [];
|
||
foreach (self::LAYOUT_SCHEMA as $page => $options) {
|
||
$source = $layout[$page] ?? [];
|
||
if (!is_array($source)) {
|
||
continue;
|
||
}
|
||
foreach ($options as $key => $allowed) {
|
||
if (!array_key_exists($key, $source)) {
|
||
continue;
|
||
}
|
||
$value = (string) $source[$key];
|
||
if (!in_array($value, $allowed, true)) {
|
||
if ($strict) {
|
||
UtilsService::getInstance()->errorThrow("模板布局 {$page}.{$key} 只能是:" . implode('/', $allowed));
|
||
}
|
||
$value = $allowed[0];
|
||
}
|
||
$clean[$page][$key] = $value;
|
||
}
|
||
}
|
||
$scheme = trim((string) ($layout['card_scheme'] ?? ''));
|
||
if ($scheme !== '' && $this->isSafeCode($scheme)) {
|
||
$clean['card_scheme'] = $scheme;
|
||
}
|
||
if (isset($layout['pages']) && is_array($layout['pages'])) {
|
||
$clean['pages'] = $this->sanitizePages($layout['pages'], $strict);
|
||
}
|
||
if (isset($layout['page_presets']) && is_array($layout['page_presets'])) {
|
||
$clean['page_presets'] = $this->sanitizePagePresets($layout['page_presets']);
|
||
}
|
||
if (isset($layout['chrome']) && is_array($layout['chrome'])) {
|
||
$chrome = [];
|
||
$tabbar = (string) ($layout['chrome']['tabbar'] ?? '');
|
||
$navbar = (string) ($layout['chrome']['navbar'] ?? '');
|
||
$anim = (string) ($layout['chrome']['tabbar_anim'] ?? '');
|
||
if (in_array($tabbar, self::CHROME_TABBAR, true)) {
|
||
$chrome['tabbar'] = $tabbar;
|
||
} elseif ($strict && $tabbar !== '') {
|
||
UtilsService::getInstance()->errorThrow('chrome.tabbar 只能是:' . implode('/', self::CHROME_TABBAR));
|
||
}
|
||
if (in_array($navbar, self::CHROME_NAVBAR, true)) {
|
||
$chrome['navbar'] = $navbar;
|
||
} elseif ($strict && $navbar !== '') {
|
||
UtilsService::getInstance()->errorThrow('chrome.navbar 只能是:' . implode('/', self::CHROME_NAVBAR));
|
||
}
|
||
if (in_array($anim, self::CHROME_TABBAR_ANIM, true)) {
|
||
$chrome['tabbar_anim'] = $anim;
|
||
} elseif ($strict && $anim !== '') {
|
||
UtilsService::getInstance()->errorThrow('chrome.tabbar_anim 只能是:' . implode('/', self::CHROME_TABBAR_ANIM));
|
||
}
|
||
foreach (['tabbar_color', 'tabbar_color_on'] as $colorKey) {
|
||
$raw = trim((string) ($layout['chrome'][$colorKey] ?? ''));
|
||
if ($raw === '') {
|
||
continue;
|
||
}
|
||
if ($this->isSafeHexColor($raw)) {
|
||
$chrome[$colorKey] = $raw;
|
||
} elseif ($strict) {
|
||
UtilsService::getInstance()->errorThrow('chrome.' . $colorKey . ' 必须是 #RGB 或 #RRGGBB');
|
||
}
|
||
}
|
||
if ($chrome !== []) {
|
||
$clean['chrome'] = $chrome;
|
||
}
|
||
}
|
||
return $clean;
|
||
}
|
||
|
||
/**
|
||
* 清洗页面区块列表:只留白名单 type/variant,props 只留安全短值
|
||
*/
|
||
public function sanitizePages(array $pages, bool $strict = false): array
|
||
{
|
||
$clean = [];
|
||
foreach (self::PAGE_SECTION_TYPES as $page => $types) {
|
||
$pageBlock = $pages[$page] ?? [];
|
||
if (!is_array($pageBlock)) {
|
||
continue;
|
||
}
|
||
$source = $pageBlock['sections'] ?? $pageBlock;
|
||
if (!is_array($source)) {
|
||
continue;
|
||
}
|
||
// 兼容 { sections: [] } 与直接数组
|
||
$list = isset($source['sections']) && is_array($source['sections']) ? $source['sections'] : $source;
|
||
$sections = [];
|
||
foreach ($list as $index => $item) {
|
||
if (!is_array($item)) {
|
||
continue;
|
||
}
|
||
$type = (string) ($item['type'] ?? '');
|
||
if (!in_array($type, $types, true)) {
|
||
if ($strict) {
|
||
UtilsService::getInstance()->errorThrow("页面 {$page} 不支持区块 {$type}");
|
||
}
|
||
continue;
|
||
}
|
||
$allowed = $this->variantsFor($page, $type);
|
||
$variant = (string) ($item['variant'] ?? $allowed[0]);
|
||
// 旧杂志卡带报价,商品流收成瀑布;套餐 magazine 是刊名条,不要改
|
||
if ($variant === 'magazine' && in_array($page, ['home', 'catalog', 'search'], true) && $type !== 'package') {
|
||
$variant = 'waterfall';
|
||
}
|
||
if (!in_array($variant, $allowed, true)) {
|
||
if ($strict) {
|
||
UtilsService::getInstance()->errorThrow("区块 {$type} 变体只能是:" . implode('/', $allowed));
|
||
}
|
||
$variant = $allowed[0];
|
||
}
|
||
$id = $this->isSafeCode((string) ($item['id'] ?? ''))
|
||
? (string) $item['id']
|
||
: $type . '-' . $index;
|
||
$sections[] = [
|
||
'id' => $id,
|
||
'type' => $type,
|
||
'variant' => $variant,
|
||
'visible' => !empty($item['visible']) || !array_key_exists('visible', $item),
|
||
'props' => $this->sanitizeSectionProps($item['props'] ?? []),
|
||
];
|
||
}
|
||
$row = ['sections' => $sections];
|
||
$skin = (string) ($pageBlock['skin'] ?? '');
|
||
$density = (string) ($pageBlock['density'] ?? '');
|
||
$frame = (string) ($pageBlock['frame'] ?? '');
|
||
if (in_array($skin, self::PAGE_SKINS, true)) {
|
||
$row['skin'] = $skin;
|
||
}
|
||
if (in_array($density, self::PAGE_DENSITIES, true)) {
|
||
$row['density'] = $density;
|
||
}
|
||
if (in_array($frame, self::PAGE_FRAMES, true)) {
|
||
$row['frame'] = $frame;
|
||
}
|
||
$pageScheme = trim((string) ($pageBlock['card_scheme'] ?? ''));
|
||
if ($pageScheme !== '' && $this->isSafeCode($pageScheme)) {
|
||
$row['card_scheme'] = $pageScheme;
|
||
}
|
||
$caption = $pageBlock['card_caption'] ?? '';
|
||
if (is_string($caption) && $caption !== '' && mb_strlen($caption) <= 80 && $this->isSafeValue($caption)) {
|
||
$row['card_caption'] = $caption;
|
||
}
|
||
$clean[$page] = $row;
|
||
}
|
||
return $clean;
|
||
}
|
||
|
||
/**
|
||
* 清洗卡片图层:坐标数值化,非法 type 丢弃
|
||
*/
|
||
public function sanitizeLayers(mixed $layers, bool $strict = false): array
|
||
{
|
||
$layers = $this->toArray($layers);
|
||
$clean = [];
|
||
foreach ($layers as $index => $item) {
|
||
if (!is_array($item)) {
|
||
continue;
|
||
}
|
||
$type = (string) ($item['type'] ?? '');
|
||
if (!in_array($type, self::LAYER_TYPES, true)) {
|
||
if ($strict) {
|
||
UtilsService::getInstance()->errorThrow('图层 type 只能是 photo/text/deco/shape');
|
||
}
|
||
continue;
|
||
}
|
||
$id = $this->isSafeCode((string) ($item['id'] ?? ''))
|
||
? (string) $item['id']
|
||
: 'layer-' . $index;
|
||
$row = [
|
||
'id' => $id,
|
||
'type' => $type,
|
||
'x' => $this->num($item['x'] ?? 0),
|
||
'y' => $this->num($item['y'] ?? 0),
|
||
'w' => $this->num($item['w'] ?? 40),
|
||
'h' => $this->num($item['h'] ?? 40),
|
||
'rotate' => $this->num($item['rotate'] ?? 0),
|
||
'z' => (int) ($item['z'] ?? 1),
|
||
'visible' => !array_key_exists('visible', $item) || !empty($item['visible']),
|
||
'locked' => !empty($item['locked']),
|
||
];
|
||
if (!empty($item['bind']) && in_array((string) $item['bind'], self::LAYER_BINDS, true)) {
|
||
$row['bind'] = (string) $item['bind'];
|
||
}
|
||
if (!empty($item['font']) && in_array((string) $item['font'], self::LAYER_FONTS, true)) {
|
||
$row['font'] = (string) $item['font'];
|
||
}
|
||
foreach (['font_zh', 'font_en'] as $faceKey) {
|
||
$face = (string) ($item[$faceKey] ?? '');
|
||
if ($face !== '' && in_array($face, self::LAYER_FACES, true)) {
|
||
$row[$faceKey] = $face;
|
||
}
|
||
}
|
||
if (!empty($item['asset']) && $this->isSafeCode((string) $item['asset'])) {
|
||
$row['asset'] = (string) $item['asset'];
|
||
}
|
||
$tone = (string) ($item['tone'] ?? '');
|
||
if ($tone !== '' && in_array($tone, self::LAYER_TONES, true)) {
|
||
$row['tone'] = $tone;
|
||
}
|
||
if (isset($item['fill']) && $this->isSafeValue($item['fill'])) {
|
||
$row['fill'] = (string) $item['fill'];
|
||
}
|
||
if (isset($item['text']) && is_string($item['text']) && mb_strlen($item['text']) <= 80) {
|
||
$row['text'] = $item['text'];
|
||
}
|
||
if (!empty($item['src']) && $this->isSafeUrl((string) $item['src'])) {
|
||
$row['src'] = (string) $item['src'];
|
||
}
|
||
$clean[] = $row;
|
||
}
|
||
return $clean;
|
||
}
|
||
|
||
/**
|
||
* 某页某区块允许的变体:先看页面覆盖,再回落到全局
|
||
*
|
||
* @return array<int, string>
|
||
*/
|
||
public function variantsFor(string $page, string $type): array
|
||
{
|
||
$override = self::PAGE_SECTION_VARIANTS[$page][$type] ?? null;
|
||
if (is_array($override) && $override !== []) {
|
||
return $override;
|
||
}
|
||
return self::SECTION_VARIANTS[$type] ?? ['plain'];
|
||
}
|
||
|
||
/**
|
||
* 工作室拉一次就能画样式画廊,不必再写死中文名
|
||
*/
|
||
public function describe(): array
|
||
{
|
||
$pages = [];
|
||
foreach (self::PAGE_SECTION_TYPES as $page => $types) {
|
||
$modules = [];
|
||
foreach ($types as $type) {
|
||
$variants = $this->variantsFor($page, $type);
|
||
$labels = self::SECTION_VARIANT_LABELS[$type] ?? [];
|
||
$modules[] = [
|
||
'type' => $type,
|
||
'variants' => array_map(static function (string $value) use ($labels) {
|
||
return [
|
||
'value' => $value,
|
||
'label' => $labels[$value] ?? $value,
|
||
];
|
||
}, $variants),
|
||
];
|
||
}
|
||
$pages[] = [
|
||
'key' => $page,
|
||
'modules' => $modules,
|
||
];
|
||
}
|
||
return [
|
||
'tokens' => self::TOKEN_SCHEMA,
|
||
'layout' => self::LAYOUT_SCHEMA,
|
||
'pages' => self::PAGE_SECTION_TYPES,
|
||
'variants' => self::SECTION_VARIANTS,
|
||
'page_variants' => self::PAGE_SECTION_VARIANTS,
|
||
'labels' => self::SECTION_VARIANT_LABELS,
|
||
'studio' => $pages,
|
||
'page_presets' => $this->describePagePresets(),
|
||
'card_families' => $this->describeCardFamilies(),
|
||
'chrome' => [
|
||
'tabbar' => self::CHROME_TABBAR,
|
||
'tabbar_anim' => self::CHROME_TABBAR_ANIM,
|
||
'navbar' => self::CHROME_NAVBAR,
|
||
],
|
||
'layer_faces' => self::LAYER_FACES,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 工作室整页预设画廊:只下发 code/name/家族,套用时再取完整 sections
|
||
*/
|
||
public function describePagePresets(): array
|
||
{
|
||
$out = [];
|
||
foreach ((array) config('wx_page_presets', []) as $page => $items) {
|
||
foreach ((array) $items as $item) {
|
||
if (!is_array($item) || empty($item['code'])) {
|
||
continue;
|
||
}
|
||
$out[$page][] = [
|
||
'code' => (string) $item['code'],
|
||
'name' => (string) ($item['name'] ?? $item['code']),
|
||
'family' => (string) ($item['family'] ?? ''),
|
||
'skin' => (string) ($item['skin'] ?? 'shop'),
|
||
'card_scheme' => (string) ($item['card_scheme'] ?? ''),
|
||
'density' => (string) ($item['density'] ?? 'regular'),
|
||
'frame' => (string) ($item['frame'] ?? 'none'),
|
||
'hero' => $this->presetHeroHint($item),
|
||
'gallery' => $this->presetTypeHint($item, 'gallery'),
|
||
'list' => $this->presetListHint($item),
|
||
'sections' => is_array($item['sections'] ?? null) ? $item['sections'] : [],
|
||
];
|
||
}
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* 按 code 取完整预设(工作室一键套用)
|
||
*/
|
||
public function pagePresetByCode(string $code): ?array
|
||
{
|
||
foreach ((array) config('wx_page_presets', []) as $items) {
|
||
foreach ((array) $items as $item) {
|
||
if (is_array($item) && (string) ($item['code'] ?? '') === $code) {
|
||
return $item;
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 卡片家族:工作室缩略旁标轻奢 / 欧美 / 手作
|
||
*/
|
||
public function describeCardFamilies(): array
|
||
{
|
||
$map = [];
|
||
foreach ((array) config('wx_card_schemes', []) as $item) {
|
||
$code = (string) ($item['code'] ?? '');
|
||
if ($code === '') {
|
||
continue;
|
||
}
|
||
$family = (string) ($item['family'] ?? 'lux');
|
||
$map[$code] = match ($family) {
|
||
'editorial' => '欧美',
|
||
'atelier' => '手作',
|
||
default => '轻奢',
|
||
};
|
||
}
|
||
return $map;
|
||
}
|
||
|
||
/**
|
||
* 只留各页合法的预设 code
|
||
*/
|
||
private function sanitizePagePresets(array $presets): array
|
||
{
|
||
$clean = [];
|
||
foreach (array_keys(self::PAGE_SECTION_TYPES) as $page) {
|
||
$code = trim((string) ($presets[$page] ?? ''));
|
||
if ($code !== '' && $this->isSafeCode($code)) {
|
||
$clean[$page] = $code;
|
||
}
|
||
}
|
||
return $clean;
|
||
}
|
||
|
||
private function presetHeroHint(array $item): string
|
||
{
|
||
foreach ((array) ($item['sections'] ?? []) as $section) {
|
||
if (($section['type'] ?? '') === 'hero') {
|
||
return (string) ($section['variant'] ?? '');
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
private function presetListHint(array $item): string
|
||
{
|
||
return $this->presetTypeHint($item, 'list');
|
||
}
|
||
|
||
private function presetTypeHint(array $item, string $type): string
|
||
{
|
||
foreach ((array) ($item['sections'] ?? []) as $section) {
|
||
if (($section['type'] ?? '') === $type) {
|
||
return (string) ($section['variant'] ?? '');
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* 小程序侧要的扁平 CSS 变量表:--color-primary 这种
|
||
*/
|
||
public function toCssVariables(array $tokens): array
|
||
{
|
||
$vars = [];
|
||
foreach ($tokens as $group => $items) {
|
||
if (!is_array($items)) {
|
||
continue;
|
||
}
|
||
foreach ($items as $key => $value) {
|
||
$vars['--' . str_replace('_', '-', $group . '-' . $key)] = $value;
|
||
}
|
||
}
|
||
return $vars;
|
||
}
|
||
|
||
/**
|
||
* 区块 props:只留高度/列数/间距/卡片方案,避免塞任意 CSS
|
||
*/
|
||
private function sanitizeSectionProps(mixed $props): array
|
||
{
|
||
if (!is_array($props)) {
|
||
return [];
|
||
}
|
||
$clean = [];
|
||
foreach (['height', 'cols', 'spacing'] as $key) {
|
||
if (isset($props[$key]) && is_numeric($props[$key])) {
|
||
$clean[$key] = (int) $props[$key];
|
||
}
|
||
}
|
||
if (!empty($props['card_scheme']) && $this->isSafeCode((string) $props['card_scheme'])) {
|
||
$clean['card_scheme'] = (string) $props['card_scheme'];
|
||
}
|
||
return $clean;
|
||
}
|
||
|
||
/** 底栏独立色只收 hex,拒绝 CSS 语句 */
|
||
private function isSafeHexColor(string $value): bool
|
||
{
|
||
return preg_match('/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $value) === 1;
|
||
}
|
||
|
||
private function isSafeCode(string $value): bool
|
||
{
|
||
return $value !== '' && preg_match('/^[a-zA-Z0-9_-]{1,50}$/', $value) === 1;
|
||
}
|
||
|
||
private function isSafeUrl(string $value): bool
|
||
{
|
||
if ($value === '' || mb_strlen($value) > 500) {
|
||
return false;
|
||
}
|
||
foreach (self::FORBIDDEN as $needle) {
|
||
if ($needle === 'url(') {
|
||
continue;
|
||
}
|
||
if (stripos($value, $needle) !== false) {
|
||
return false;
|
||
}
|
||
}
|
||
return (bool) preg_match('/^https?:\\/\\//i', $value);
|
||
}
|
||
|
||
private function num(mixed $value): float
|
||
{
|
||
return is_numeric($value) ? (float) $value : 0;
|
||
}
|
||
|
||
private function isSafeValue(mixed $value): bool
|
||
{
|
||
if (is_int($value) || is_float($value)) {
|
||
return true;
|
||
}
|
||
if (!is_string($value)) {
|
||
return false;
|
||
}
|
||
$value = trim($value);
|
||
if ($value === '' || mb_strlen($value) > 64) {
|
||
return false;
|
||
}
|
||
foreach (self::FORBIDDEN as $needle) {
|
||
if (stripos($value, $needle) !== false) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private function toArray(mixed $value): array
|
||
{
|
||
if (is_string($value)) {
|
||
$value = json_decode($value, true);
|
||
if (!is_array($value)) {
|
||
UtilsService::getInstance()->errorThrow('模板 JSON 解析失败');
|
||
}
|
||
}
|
||
return is_array($value) ? $value : [];
|
||
}
|
||
}
|