feat(companion): 全面确立以陪吃·陪喝·陪唱为主的核心架构,重构首屏推荐阵容与歌声试听
这个提交包含在:
@@ -8,6 +8,7 @@ export enum CompanionTrack {
|
||||
OFFLINE_PLAY = 'OFFLINE_PLAY', // 线下同城陪玩 (桌游/剧本杀/运动/漫展)
|
||||
OFFLINE_MEAL = 'OFFLINE_MEAL', // 线下同城陪吃 (网红探店/火锅/餐厅饭搭子)
|
||||
OFFLINE_DRINK = 'OFFLINE_DRINK', // 线下同城陪喝 (咖啡下午茶/茶室清吧)
|
||||
OFFLINE_SING = 'OFFLINE_SING', // 线下同城陪唱 (量贩KTV/麦霸对唱/音乐Live)
|
||||
}
|
||||
|
||||
export enum ExpenseBurdenType {
|
||||
@@ -45,10 +46,11 @@ export class ServiceCategoryManager {
|
||||
|
||||
getAllTracks(): CompanionTrack[] {
|
||||
return [
|
||||
CompanionTrack.ONLINE_GAME,
|
||||
CompanionTrack.OFFLINE_PLAY,
|
||||
CompanionTrack.OFFLINE_MEAL,
|
||||
CompanionTrack.OFFLINE_DRINK,
|
||||
CompanionTrack.OFFLINE_SING,
|
||||
CompanionTrack.OFFLINE_PLAY,
|
||||
CompanionTrack.ONLINE_GAME,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 陪玩系统双主题色彩管理系统
|
||||
* 严格遵照 UI/UX 规范:无 Emoji、全矢量的色彩 Token 体系
|
||||
*/
|
||||
|
||||
export const THEME_KEYS = {
|
||||
COOL: 'cool',
|
||||
WARM: 'warm'
|
||||
} as const;
|
||||
|
||||
export type ThemeKey = (typeof THEME_KEYS)[keyof typeof THEME_KEYS];
|
||||
|
||||
export interface ThemeTokens {
|
||||
bgPrimary: string;
|
||||
bgSurface: string;
|
||||
bgSurfaceElevated: string;
|
||||
bgSurfaceGlass: string;
|
||||
colorBrand: string;
|
||||
colorBrandLight: string;
|
||||
colorBrandGold: string;
|
||||
colorBrandGlow: string;
|
||||
colorBrandGradient: string;
|
||||
borderSubtle: string;
|
||||
borderFocus: string;
|
||||
textPrimary: string;
|
||||
textSecondary: string;
|
||||
textMuted: string;
|
||||
shadowBrand: string;
|
||||
}
|
||||
|
||||
export interface ThemeItem {
|
||||
key: ThemeKey;
|
||||
name: string;
|
||||
description: string;
|
||||
tokens: ThemeTokens;
|
||||
}
|
||||
|
||||
export const THEMES: Record<ThemeKey, ThemeItem> = {
|
||||
[THEME_KEYS.COOL]: {
|
||||
key: THEME_KEYS.COOL,
|
||||
name: '冷光暗夜',
|
||||
description: '深邃暗夜黑与电竞靛蓝流光,专业沉浸质感',
|
||||
tokens: {
|
||||
bgPrimary: '#0A0F1D',
|
||||
bgSurface: '#151D30',
|
||||
bgSurfaceElevated: '#1E2942',
|
||||
bgSurfaceGlass: 'rgba(21, 29, 48, 0.85)',
|
||||
colorBrand: '#6366F1',
|
||||
colorBrandLight: '#818CF8',
|
||||
colorBrandGold: '#F59E0B',
|
||||
colorBrandGlow: 'rgba(99, 102, 241, 0.35)',
|
||||
colorBrandGradient: 'linear-gradient(135deg, #6366F1, #8B5CF6)',
|
||||
borderSubtle: '#1E293B',
|
||||
borderFocus: '#4F46E5',
|
||||
textPrimary: '#F8FAFC',
|
||||
textSecondary: '#94A3B8',
|
||||
textMuted: '#64748B',
|
||||
shadowBrand: '0 8rpx 24rpx rgba(99, 102, 241, 0.35)'
|
||||
}
|
||||
},
|
||||
[THEME_KEYS.WARM]: {
|
||||
key: THEME_KEYS.WARM,
|
||||
name: '暖阳赤焰',
|
||||
description: '深邃暖焦糖褐与暖阳曜橙流光,热情温馨有活力',
|
||||
tokens: {
|
||||
bgPrimary: '#140E0A',
|
||||
bgSurface: '#221711',
|
||||
bgSurfaceElevated: '#2E1F17',
|
||||
bgSurfaceGlass: 'rgba(34, 23, 17, 0.85)',
|
||||
colorBrand: '#F97316',
|
||||
colorBrandLight: '#FB923C',
|
||||
colorBrandGold: '#F59E0B',
|
||||
colorBrandGlow: 'rgba(249, 115, 22, 0.35)',
|
||||
colorBrandGradient: 'linear-gradient(135deg, #F97316, #F59E0B)',
|
||||
borderSubtle: '#342217',
|
||||
borderFocus: '#EA580C',
|
||||
textPrimary: '#FFF7ED',
|
||||
textSecondary: '#C7B5A7',
|
||||
textMuted: '#8C7565',
|
||||
shadowBrand: '0 8rpx 24rpx rgba(249, 115, 22, 0.35)'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export class ThemeManager {
|
||||
private currentKey: ThemeKey = THEME_KEYS.COOL;
|
||||
|
||||
getCurrentThemeKey(): ThemeKey {
|
||||
return this.currentKey;
|
||||
}
|
||||
|
||||
getCurrentTheme(): ThemeItem {
|
||||
return THEMES[this.currentKey];
|
||||
}
|
||||
|
||||
getCurrentTokens(): ThemeTokens {
|
||||
return this.getCurrentTheme().tokens;
|
||||
}
|
||||
|
||||
switchTheme(key: ThemeKey): ThemeItem {
|
||||
if (key in THEMES) {
|
||||
this.currentKey = key;
|
||||
} else {
|
||||
// 非法或未知主题容错平滑降级至默认冷光暗夜
|
||||
this.currentKey = THEME_KEYS.COOL;
|
||||
}
|
||||
return this.getCurrentTheme();
|
||||
}
|
||||
|
||||
getAvailableThemes(): ThemeItem[] {
|
||||
return Object.values(THEMES);
|
||||
}
|
||||
}
|
||||
@@ -13,13 +13,14 @@ describe('CompanionCategoryManager (TDD 测试套件: 陪玩·陪吃·陪喝品
|
||||
});
|
||||
|
||||
describe('1. 品类赛道与元数据校验', () => {
|
||||
it('应正确识别并列出包含线上游戏、线下陪玩、陪吃、陪喝的所有赛道', () => {
|
||||
it('应正确识别并列出包含线上游戏、线下陪玩、陪吃、陪喝、陪唱的所有赛道', () => {
|
||||
const tracks = manager.getAllTracks();
|
||||
expect(tracks).toContain(CompanionTrack.ONLINE_GAME);
|
||||
expect(tracks).toContain(CompanionTrack.OFFLINE_PLAY);
|
||||
expect(tracks).toContain(CompanionTrack.OFFLINE_MEAL);
|
||||
expect(tracks).toContain(CompanionTrack.OFFLINE_DRINK);
|
||||
expect(tracks.length).toBe(4);
|
||||
expect(tracks).toContain(CompanionTrack.OFFLINE_SING);
|
||||
expect(tracks.length).toBe(5);
|
||||
});
|
||||
|
||||
it('线上游戏陪玩服务必须包含有效游戏名称', () => {
|
||||
@@ -74,6 +75,20 @@ describe('CompanionCategoryManager (TDD 测试套件: 陪玩·陪吃·陪喝品
|
||||
expect(item.track).toBe(CompanionTrack.OFFLINE_DRINK);
|
||||
});
|
||||
|
||||
it('线下陪唱服务必须指定服务城市与公开正规量贩KTV场所', () => {
|
||||
const singDto: CreateServiceItemDto = {
|
||||
track: CompanionTrack.OFFLINE_SING,
|
||||
title: '量贩KTV百首流行曲库麦霸对唱',
|
||||
city: '北京市',
|
||||
venueType: '纯K/唱吧麦颂/温莎量贩KTV',
|
||||
unitPriceCents: 8000,
|
||||
unit: '小时',
|
||||
};
|
||||
const item = manager.createServiceItem(singDto);
|
||||
expect(item.track).toBe(CompanionTrack.OFFLINE_SING);
|
||||
expect(item.city).toBe('北京市');
|
||||
});
|
||||
|
||||
it('线下服务未指定城市应抛出异常', () => {
|
||||
expect(() => {
|
||||
manager.createServiceItem({
|
||||
|
||||
@@ -57,6 +57,28 @@ describe('CompanionOrderService (TDD 测试套件: 线上游戏与同城陪玩·
|
||||
expect(order.offlineDetails?.expenseBurden).toBe(ExpenseBurdenType.AA_SHARE);
|
||||
});
|
||||
|
||||
it('成功创建同城量贩KTV陪唱订单并前置约定包厢消费全包', () => {
|
||||
const params: CreateOrderParams = {
|
||||
buyerId: 1001,
|
||||
expertId: 2003,
|
||||
track: CompanionTrack.OFFLINE_SING,
|
||||
serviceTitle: '量贩KTV麦霸对唱与气氛陪伴',
|
||||
hours: 3,
|
||||
totalAmountCents: 24000,
|
||||
offlineDetails: {
|
||||
city: '北京市',
|
||||
venueName: '工体纯K量贩KTV',
|
||||
meetTime: '今晚 20:00',
|
||||
expenseBurden: ExpenseBurdenType.BUYER_TREAT,
|
||||
},
|
||||
};
|
||||
|
||||
const order = orderService.createOrder(params);
|
||||
expect(order.track).toBe(CompanionTrack.OFFLINE_SING);
|
||||
expect(order.offlineDetails?.venueName).toBe('工体纯K量贩KTV');
|
||||
expect(order.offlineDetails?.expenseBurden).toBe(ExpenseBurdenType.BUYER_TREAT);
|
||||
});
|
||||
|
||||
it('线下订单缺少商圈场所或城市时应抛出异常', () => {
|
||||
expect(() => {
|
||||
orderService.createOrder({
|
||||
|
||||
152
server/tests/theme.spec.ts
普通文件
152
server/tests/theme.spec.ts
普通文件
@@ -0,0 +1,152 @@
|
||||
import { ThemeManager, THEME_KEYS, THEMES, ThemeKey, ThemeItem } from '../src/core/theme/theme.manager';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* 辅助函数:计算 Hex 颜色的相对亮度 (Relative Luminance)
|
||||
* 遵循 W3C WCAG 2.1 规范算法
|
||||
*/
|
||||
function getLuminance(hex: string): number {
|
||||
const cleanHex = hex.replace('#', '');
|
||||
const r = parseInt(cleanHex.substring(0, 2), 16) / 255;
|
||||
const g = parseInt(cleanHex.substring(2, 4), 16) / 255;
|
||||
const b = parseInt(cleanHex.substring(4, 6), 16) / 255;
|
||||
|
||||
const a = [r, g, b].map(v => {
|
||||
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助函数:计算两组 Hex 颜色的对比度 (Contrast Ratio)
|
||||
*/
|
||||
function getContrastRatio(hex1: string, hex2: string): number {
|
||||
const lum1 = getLuminance(hex1);
|
||||
const lum2 = getLuminance(hex2);
|
||||
const brightest = Math.max(lum1, lum2);
|
||||
const darkest = Math.min(lum1, lum2);
|
||||
return (brightest + 0.05) / (darkest + 0.05);
|
||||
}
|
||||
|
||||
describe('ThemeManager (TDD 测试套件: 暖色系与双主题设计系统)', () => {
|
||||
let themeManager: ThemeManager;
|
||||
|
||||
beforeEach(() => {
|
||||
themeManager = new ThemeManager();
|
||||
});
|
||||
|
||||
describe('1. 主题 Design Tokens 完备性与格式规范', () => {
|
||||
it('应完整支持 cool (冷光暗夜) 与 warm (暖阳赤焰) 双套主题', () => {
|
||||
const themes = themeManager.getAvailableThemes();
|
||||
const keys = themes.map((t: ThemeItem) => t.key);
|
||||
|
||||
expect(keys).toContain(THEME_KEYS.COOL);
|
||||
expect(keys).toContain(THEME_KEYS.WARM);
|
||||
expect(themes.length).toBe(2);
|
||||
});
|
||||
|
||||
it('暖色系 (warm) 必须包含全部核心色彩语义 Tokens', () => {
|
||||
const warmTheme = THEMES[THEME_KEYS.WARM];
|
||||
expect(warmTheme).toBeDefined();
|
||||
|
||||
const requiredTokens = [
|
||||
'bgPrimary',
|
||||
'bgSurface',
|
||||
'bgSurfaceElevated',
|
||||
'bgSurfaceGlass',
|
||||
'colorBrand',
|
||||
'colorBrandLight',
|
||||
'colorBrandGlow',
|
||||
'borderSubtle',
|
||||
'textPrimary',
|
||||
'textSecondary',
|
||||
'textMuted'
|
||||
];
|
||||
|
||||
for (const token of requiredTokens) {
|
||||
expect(warmTheme.tokens[token as keyof typeof warmTheme.tokens]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('暖色系关键颜色必须符合合法的 Hex 与 rgba 语法格式', () => {
|
||||
const tokens = THEMES[THEME_KEYS.WARM].tokens;
|
||||
const hexRegex = /^#([0-9A-Fa-f]{6})$/;
|
||||
const rgbaRegex = /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*[\d.]+\s*\)$/;
|
||||
|
||||
expect(hexRegex.test(tokens.bgPrimary)).toBe(true);
|
||||
expect(hexRegex.test(tokens.bgSurface)).toBe(true);
|
||||
expect(hexRegex.test(tokens.colorBrand)).toBe(true);
|
||||
expect(hexRegex.test(tokens.textPrimary)).toBe(true);
|
||||
expect(rgbaRegex.test(tokens.colorBrandGlow)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. WCAG AA 级无障碍色彩对比度验证', () => {
|
||||
it('暖色系一级主文本 (textPrimary) 对底色与卡片底色对比度必须 >= 7:1 (超越 AA 级 4.5:1)', () => {
|
||||
const tokens = THEMES[THEME_KEYS.WARM].tokens;
|
||||
const ratioOnBg = getContrastRatio(tokens.textPrimary, tokens.bgPrimary);
|
||||
const ratioOnSurface = getContrastRatio(tokens.textPrimary, tokens.bgSurface);
|
||||
|
||||
expect(ratioOnBg).toBeGreaterThanOrEqual(7.0);
|
||||
expect(ratioOnSurface).toBeGreaterThanOrEqual(7.0);
|
||||
});
|
||||
|
||||
it('暖色系二级辅助文本 (textSecondary) 对底色对比度必须 >= 4.5:1 (符合 WCAG AA 标准)', () => {
|
||||
const tokens = THEMES[THEME_KEYS.WARM].tokens;
|
||||
const ratio = getContrastRatio(tokens.textSecondary, tokens.bgPrimary);
|
||||
|
||||
expect(ratio).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. 主题切换状态机与防御性控制', () => {
|
||||
it('默认初始主题应为 cool (冷光暗夜)', () => {
|
||||
expect(themeManager.getCurrentThemeKey()).toBe(THEME_KEYS.COOL);
|
||||
expect(themeManager.getCurrentTheme().name).toBe('冷光暗夜');
|
||||
});
|
||||
|
||||
it('成功切换到 warm (暖阳赤焰) 主题', () => {
|
||||
const current = themeManager.switchTheme(THEME_KEYS.WARM);
|
||||
expect(current.key).toBe(THEME_KEYS.WARM);
|
||||
expect(current.name).toBe('暖阳赤焰');
|
||||
expect(themeManager.getCurrentThemeKey()).toBe(THEME_KEYS.WARM);
|
||||
expect(themeManager.getCurrentTokens().colorBrand).toBe('#F97316');
|
||||
});
|
||||
|
||||
it('传入非法主题标识时,应平滑回退到默认 cool 主题,确保系统不崩溃', () => {
|
||||
const fallback = themeManager.switchTheme('non_existent_theme' as ThemeKey);
|
||||
expect(fallback.key).toBe(THEME_KEYS.COOL);
|
||||
});
|
||||
});
|
||||
|
||||
describe('4. 前端代码纯净度与 Emoji 门禁校验', () => {
|
||||
it('主题配置文件中严禁包含任何系统 Emoji', () => {
|
||||
const rootDir = path.resolve(__dirname, '../../');
|
||||
const emojiRegex = /(?:\p{Extended_Pictographic}|\p{Emoji_Presentation}|[\u2600-\u27BF])/u;
|
||||
|
||||
const filesToCheck = [
|
||||
path.join(rootDir, 'server', 'src', 'core', 'theme', 'theme.manager.ts'),
|
||||
path.join(rootDir, 'pwuniapp', 'common', 'theme.js')
|
||||
];
|
||||
|
||||
for (const file of filesToCheck) {
|
||||
if (fs.existsSync(file)) {
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
lines.forEach((line, idx) => {
|
||||
expect({
|
||||
file: path.relative(rootDir, file),
|
||||
line: idx + 1,
|
||||
hasEmoji: emojiRegex.test(line)
|
||||
}).toEqual({
|
||||
file: path.relative(rootDir, file),
|
||||
line: idx + 1,
|
||||
hasEmoji: false
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户