更新若干功能
This commit is contained in:
@@ -39,28 +39,234 @@ class WxTemplateSchemaService
|
||||
*/
|
||||
public const LAYOUT_SCHEMA = [
|
||||
'home' => [
|
||||
'hero' => ['banner', 'carousel', 'split', 'fullscreen'],
|
||||
'category' => ['grid', 'scroll', 'card', 'sidebar'],
|
||||
'product' => ['waterfall', 'list', 'grid', 'magazine'],
|
||||
'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'],
|
||||
'gallery' => ['swiper', 'stack', 'fullbleed', 'peek', 'fade', 'coverflow', 'mosaic', 'filmstrip'],
|
||||
'price' => ['inline', 'card', 'sticky'],
|
||||
'action' => ['fixed', 'inline'],
|
||||
'action' => ['fixed', 'inline', 'split'],
|
||||
],
|
||||
'list' => [
|
||||
'style' => ['card', 'table', 'timeline'],
|
||||
'style' => ['card', 'table', 'timeline', 'compact', 'ticket', 'stacked'],
|
||||
],
|
||||
'mine' => [
|
||||
'header' => ['gradient', 'image', 'plain'],
|
||||
'menu' => ['grid', 'list'],
|
||||
'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',
|
||||
];
|
||||
|
||||
/**
|
||||
* 危险片段:出现即拒绝整个值
|
||||
*/
|
||||
@@ -108,7 +314,10 @@ class WxTemplateSchemaService
|
||||
}
|
||||
|
||||
/**
|
||||
* 清洗布局:值必须是枚举里的选项,非法值退回该项的第一个选项
|
||||
* 清洗布局:旧枚举 + pages.sections + card_scheme
|
||||
*
|
||||
* 非法枚举在宽松模式退回第一项;strict(导入)直接报错。
|
||||
* pages / card_scheme 是装修工作室写入的,必须保留,否则保存后拖拽结果会丢。
|
||||
*/
|
||||
public function sanitizeLayout(mixed $layout, bool $strict = false): array
|
||||
{
|
||||
@@ -133,9 +342,362 @@ class WxTemplateSchemaService
|
||||
$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 这种
|
||||
*/
|
||||
@@ -153,6 +715,58 @@ class WxTemplateSchemaService
|
||||
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)) {
|
||||
|
||||
Reference in New Issue
Block a user