Files
lgp-admin-plus-api/app/Service/business/WxTemplatePresetService.php
LQ bcf54c2727 初始化
缺陷:主题配色需要优化整体的同风格
2026-08-14 23:21:21 +08:00

200 lines
9.5 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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)),
];
}
}