[ '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 */ 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)), ]; } }