200 lines
9.5 KiB
PHP
200 lines
9.5 KiB
PHP
<?php
|
||
|
||
namespace App\Service\business;
|
||
|
||
/**
|
||
* 把预设的紧凑规格展开成完整设计令牌
|
||
*
|
||
* 20 套模板真正的差异在四条轴上:配色、字体与字号梯度、圆角与阴影、动效曲线与时长。
|
||
* 这里把后三条做成可复用的档位,预设只需要挑档位,避免 20 份 JSON 手抄跑偏,
|
||
* 也让「再加一套模板」变成加十几行配置而不是重写一份主题。
|
||
*/
|
||
class WxTemplatePresetService
|
||
{
|
||
private static mixed $_instance;
|
||
|
||
/**
|
||
* 字体与字号梯度档位(单位 rpx,小程序习惯用 rpx)
|
||
*/
|
||
private const SCALES = [
|
||
'serif-elegant' => [
|
||
'family' => 'Songti SC, Noto Serif SC, serif',
|
||
'family_title' => 'Songti SC, Noto Serif SC, serif',
|
||
'size_xs' => '20rpx', 'size_sm' => '24rpx', 'size_md' => '28rpx',
|
||
'size_lg' => '34rpx', 'size_xl' => '42rpx', 'size_title' => '52rpx',
|
||
'weight_normal' => '400', 'weight_bold' => '600',
|
||
'line_height' => '1.7', 'letter_spacing' => '1rpx',
|
||
],
|
||
'serif-book' => [
|
||
'family' => 'Noto Serif SC, Georgia, serif',
|
||
'family_title' => 'Noto Serif SC, Georgia, serif',
|
||
'size_xs' => '22rpx', 'size_sm' => '26rpx', 'size_md' => '30rpx',
|
||
'size_lg' => '34rpx', 'size_xl' => '40rpx', 'size_title' => '48rpx',
|
||
'weight_normal' => '400', 'weight_bold' => '700',
|
||
'line_height' => '1.8', 'letter_spacing' => '0',
|
||
],
|
||
'sans-refined' => [
|
||
'family' => 'PingFang SC, HarmonyOS Sans, sans-serif',
|
||
'family_title' => 'PingFang SC, HarmonyOS Sans, sans-serif',
|
||
'size_xs' => '20rpx', 'size_sm' => '24rpx', 'size_md' => '28rpx',
|
||
'size_lg' => '32rpx', 'size_xl' => '40rpx', 'size_title' => '48rpx',
|
||
'weight_normal' => '400', 'weight_bold' => '600',
|
||
'line_height' => '1.6', 'letter_spacing' => '0',
|
||
],
|
||
'sans-compact' => [
|
||
'family' => 'PingFang SC, Roboto, sans-serif',
|
||
'family_title' => 'PingFang SC, Roboto, sans-serif',
|
||
'size_xs' => '18rpx', 'size_sm' => '22rpx', 'size_md' => '26rpx',
|
||
'size_lg' => '30rpx', 'size_xl' => '36rpx', 'size_title' => '42rpx',
|
||
'weight_normal' => '400', 'weight_bold' => '700',
|
||
'line_height' => '1.5', 'letter_spacing' => '0',
|
||
],
|
||
'sans-wide' => [
|
||
'family' => 'PingFang SC, Inter, sans-serif',
|
||
'family_title' => 'PingFang SC, Inter, sans-serif',
|
||
'size_xs' => '22rpx', 'size_sm' => '26rpx', 'size_md' => '30rpx',
|
||
'size_lg' => '36rpx', 'size_xl' => '44rpx', 'size_title' => '56rpx',
|
||
'weight_normal' => '400', 'weight_bold' => '700',
|
||
'line_height' => '1.6', 'letter_spacing' => '2rpx',
|
||
],
|
||
];
|
||
|
||
private const RADIUS = [
|
||
'none' => ['none' => '0', 'sm' => '0', 'md' => '0', 'lg' => '0', 'xl' => '0', 'pill' => '0'],
|
||
'sharp' => ['none' => '0', 'sm' => '2rpx', 'md' => '4rpx', 'lg' => '8rpx', 'xl' => '12rpx', 'pill' => '999rpx'],
|
||
'soft' => ['none' => '0', 'sm' => '8rpx', 'md' => '12rpx', 'lg' => '20rpx', 'xl' => '28rpx', 'pill' => '999rpx'],
|
||
'round' => ['none' => '0', 'sm' => '12rpx', 'md' => '20rpx', 'lg' => '32rpx', 'xl' => '44rpx', 'pill' => '999rpx'],
|
||
'pill' => ['none' => '0', 'sm' => '20rpx', 'md' => '32rpx', 'lg' => '48rpx', 'xl' => '64rpx', 'pill' => '999rpx'],
|
||
];
|
||
|
||
private const SHADOW = [
|
||
'flat' => ['none' => 'none', 'sm' => 'none', 'md' => 'none', 'lg' => 'none'],
|
||
'airy' => ['none' => 'none', 'sm' => '0 2rpx 8rpx rgba(0,0,0,0.04)', 'md' => '0 8rpx 24rpx rgba(0,0,0,0.06)', 'lg' => '0 16rpx 48rpx rgba(0,0,0,0.08)'],
|
||
'soft' => ['none' => 'none', 'sm' => '0 2rpx 8rpx rgba(0,0,0,0.06)', 'md' => '0 8rpx 20rpx rgba(0,0,0,0.10)', 'lg' => '0 16rpx 40rpx rgba(0,0,0,0.14)'],
|
||
'deep' => ['none' => 'none', 'sm' => '0 4rpx 12rpx rgba(0,0,0,0.16)', 'md' => '0 12rpx 32rpx rgba(0,0,0,0.24)', 'lg' => '0 24rpx 64rpx rgba(0,0,0,0.32)'],
|
||
];
|
||
|
||
private const MOTION = [
|
||
'snappy' => ['fast' => '120ms', 'base' => '180ms', 'slow' => '260ms', 'easing' => 'cubic-bezier(0.4,0,0.2,1)', 'easing_in' => 'cubic-bezier(0.4,0,1,1)', 'easing_out' => 'cubic-bezier(0,0,0.2,1)'],
|
||
'gentle' => ['fast' => '180ms', 'base' => '260ms', 'slow' => '400ms', 'easing' => 'cubic-bezier(0.25,0.1,0.25,1)', 'easing_in' => 'cubic-bezier(0.42,0,1,1)', 'easing_out' => 'cubic-bezier(0,0,0.58,1)'],
|
||
'silk' => ['fast' => '220ms', 'base' => '320ms', 'slow' => '520ms', 'easing' => 'cubic-bezier(0.22,1,0.36,1)', 'easing_in' => 'cubic-bezier(0.55,0,1,0.45)', 'easing_out' => 'cubic-bezier(0.16,1,0.3,1)'],
|
||
'bouncy' => ['fast' => '160ms', 'base' => '280ms', 'slow' => '460ms', 'easing' => 'cubic-bezier(0.34,1.56,0.64,1)', 'easing_in' => 'cubic-bezier(0.36,0,0.66,-0.56)', 'easing_out' => 'cubic-bezier(0.34,1.56,0.64,1)'],
|
||
];
|
||
|
||
private const SPACE = [
|
||
'xxs' => '4rpx', 'xs' => '8rpx', 'sm' => '16rpx',
|
||
'md' => '24rpx', 'lg' => '32rpx', 'xl' => '48rpx', 'page' => '32rpx',
|
||
];
|
||
|
||
public static function getInstance(): null|static
|
||
{
|
||
$name = get_called_class();
|
||
if (!isset(self::$_instance[$name])) {
|
||
self::$_instance[$name] = new static();
|
||
}
|
||
return self::$_instance[$name];
|
||
}
|
||
|
||
/**
|
||
* 全部预设,已展开成入库可用的行
|
||
*
|
||
* @return array<int, array{code:string,name:string,style_tag:string,tokens:array,layout:array}>
|
||
*/
|
||
public function all(): array
|
||
{
|
||
$rows = [];
|
||
foreach ((array) config('wx_templates', []) as $index => $preset) {
|
||
$rows[] = [
|
||
'code' => (string) $preset['code'],
|
||
'name' => (string) $preset['name'],
|
||
'style_tag' => (string) ($preset['style_tag'] ?? ''),
|
||
'sort' => $index,
|
||
'tokens' => $this->expandTokens($preset),
|
||
'layout' => (array) ($preset['layout'] ?? []),
|
||
];
|
||
}
|
||
return $rows;
|
||
}
|
||
|
||
/**
|
||
* 展开单个预设的令牌
|
||
*/
|
||
public function expandTokens(array $preset): array
|
||
{
|
||
$palette = (array) ($preset['palette'] ?? []);
|
||
$primary = (string) ($palette['primary'] ?? '#B08D57');
|
||
$isDark = $this->isDark((string) ($palette['bg'] ?? '#FFFFFF'));
|
||
return [
|
||
'color' => [
|
||
'primary' => $primary,
|
||
'primary_soft' => $this->mix($primary, $isDark ? '#000000' : '#FFFFFF', 0.7),
|
||
'primary_strong' => $this->mix($primary, '#000000', 0.2),
|
||
'accent' => (string) ($palette['accent'] ?? $primary),
|
||
'bg' => (string) ($palette['bg'] ?? '#FFFFFF'),
|
||
'bg_soft' => $this->mix((string) ($palette['bg'] ?? '#FFFFFF'), $isDark ? '#FFFFFF' : '#000000', 0.04),
|
||
'surface' => (string) ($palette['surface'] ?? '#FFFFFF'),
|
||
'surface_soft' => $this->mix((string) ($palette['surface'] ?? '#FFFFFF'), $isDark ? '#FFFFFF' : '#000000', 0.03),
|
||
'text' => (string) ($palette['text'] ?? '#18181B'),
|
||
'text_soft' => $this->mix((string) ($palette['text'] ?? '#18181B'), (string) ($palette['bg'] ?? '#FFFFFF'), 0.25),
|
||
'text_muted' => $this->mix((string) ($palette['text'] ?? '#18181B'), (string) ($palette['bg'] ?? '#FFFFFF'), 0.5),
|
||
'border' => (string) ($palette['border'] ?? '#E4E4E7'),
|
||
// 价格用主色的强化版,保证在浅色与深色底上都够醒目
|
||
'price' => $this->mix($primary, '#000000', $isDark ? 0 : 0.12),
|
||
'success' => '#16A34A',
|
||
'warning' => '#D97706',
|
||
'danger' => '#DC2626',
|
||
'mask' => $isDark ? 'rgba(0,0,0,0.72)' : 'rgba(0,0,0,0.45)',
|
||
],
|
||
'font' => self::SCALES[$preset['scale'] ?? 'sans-refined'] ?? self::SCALES['sans-refined'],
|
||
'radius' => self::RADIUS[$preset['radius'] ?? 'soft'] ?? self::RADIUS['soft'],
|
||
'shadow' => self::SHADOW[$preset['shadow'] ?? 'soft'] ?? self::SHADOW['soft'],
|
||
'space' => self::SPACE,
|
||
'motion' => self::MOTION[$preset['motion'] ?? 'gentle'] ?? self::MOTION['gentle'],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 两色按比例混合,用来派生 soft / strong 变体
|
||
*
|
||
* @param float $ratio target 的占比
|
||
*/
|
||
private function mix(string $color, string $target, float $ratio): string
|
||
{
|
||
[$r1, $g1, $b1] = $this->toRgb($color);
|
||
[$r2, $g2, $b2] = $this->toRgb($target);
|
||
$ratio = max(0, min(1, $ratio));
|
||
return sprintf(
|
||
'#%02X%02X%02X',
|
||
(int) round($r1 + ($r2 - $r1) * $ratio),
|
||
(int) round($g1 + ($g2 - $g1) * $ratio),
|
||
(int) round($b1 + ($b2 - $b1) * $ratio)
|
||
);
|
||
}
|
||
|
||
private function isDark(string $color): bool
|
||
{
|
||
[$r, $g, $b] = $this->toRgb($color);
|
||
// 感知亮度,低于 128 视作深色底
|
||
return (0.299 * $r + 0.587 * $g + 0.114 * $b) < 128;
|
||
}
|
||
|
||
/**
|
||
* @return array{0:int,1:int,2:int}
|
||
*/
|
||
private function toRgb(string $color): array
|
||
{
|
||
$hex = ltrim(trim($color), '#');
|
||
if (strlen($hex) === 3) {
|
||
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
|
||
}
|
||
if (strlen($hex) !== 6 || !ctype_xdigit($hex)) {
|
||
return [255, 255, 255];
|
||
}
|
||
return [
|
||
(int) hexdec(substr($hex, 0, 2)),
|
||
(int) hexdec(substr($hex, 2, 2)),
|
||
(int) hexdec(substr($hex, 4, 2)),
|
||
];
|
||
}
|
||
}
|