feat: 完善 TDD 开发环境配置

- 配置 Jest 测试框架,支持 TypeScript 和 React 组件测试
- 添加 Testing Library 相关依赖用于 React 组件测试
- 配置 Babel 预设支持 Jest 和 React 测试环境
- 添加 TDD 开发工作流脚本 (test, test:watch, test:coverage)
- 创建完整的 TDD 编码规范文档 (CLAUDE.md)
- 添加自定义 hooks 和多平台支持目录结构
- 配置 TypeScript 严格模式和 ESLint 规范
- 添加全局类型定义文件支持
This commit is contained in:
imeepos
2025-09-01 13:19:17 +08:00
parent 23a0b502d3
commit 9b3bc7bf2d
18 changed files with 3625 additions and 47 deletions

368
CLAUDE.md Normal file
View File

@@ -0,0 +1,368 @@
本文档展示如何在 Claude 环境中严格遵循 **测试驱动开发 (TDD)** 规范。
## 必须使用简体中文(严格遵守)
## 界面(pages/components等)不写测试
## 依赖网络请求、文件请求等外部环境不写测试(mock替代)
## 🎯 TDD 核心原则 (严格遵守)
### 🔴 红阶段:编写失败测试
```typescript
// ❌ 必须先写失败的测试
describe('Calculator', () => {
it('should add two numbers', () => {
const calc = new Calculator(); // 这里会失败 - 正确的!
expect(calc.add(2, 3)).toBe(5);
});
});
```
### 🟢 绿阶段:最小实现
```typescript
// ✅ 只写刚好让测试通过的代码
export class Calculator {
add(a: number, b: number): number {
return a + b; // 最简实现
}
}
```
### 🔄 重构阶段:优化代码
```typescript
// ✅ 在绿灯状态下安全重构
export class Calculator {
/**
* 两数相加
* @param a 第一个数
* @param b 第二个数
* @returns 和
*/
add(a: number, b: number): number {
return a + b;
}
}
```
## 🚫 TDD 强制规则 (绝不违反)
### ❌ 禁止行为
- **绝对不允许**在没有测试的情况下编写生产代码
- **绝对不允许**跳过红阶段直接写实现
- **绝对不允许**在红灯状态下进行重构
- **绝对不允许**同时处理多个失败测试
- **绝对不允许**写example示例代码
- **绝对不允许**业务中写测试代码
- **绝对不允许**业务中写简化或mock代码
### ✅ 必须行为
- **必须切换新分支** 切换新的分支后开始新功能开发
- **必须确认**测试在功能实现前是失败的
- **必须编写**刚好让测试通过的最小代码
- **必须在**所有测试通过的绿灯状态下进行重构
- **必须保持**每个测试的独立性
- **必须遵守**单个文件代码行数不超过500行
- **必须遵守**环境依赖性测试(文件系统监控、网络连接、性能测试),使用模拟对象(Mock)替代真实环境依赖,确保测试的稳定性
## 🛠️ Claude TDD 工作流程
### Step 1: 需求分析 → 测试设计
```bash
# 在 Claude 中的对话流程
用户: "我需要一个计算器功能"
Claude: "让我们从测试开始。首先写一个失败的测试..."
```
### Step 2: 🔴 红阶段执行
```typescript
// 1. 先创建测试文件
// src/calculator.test.ts
import { Calculator } from './calculator'; // ❌ 这会失败
describe('Calculator', () => {
it('should add two positive numbers', () => {
const calculator = new Calculator();
expect(calculator.add(2, 3)).toBe(5);
});
});
```
```bash
# 2. 运行测试确认失败
npm run test
# ❌ Cannot find module './calculator' - 正确的失败!
```
### Step 3: 🟢 绿阶段执行
```typescript
// 3. 创建最小实现
// src/calculator.ts
export class Calculator {
add(a: number, b: number): number {
return a + b; // 最简实现
}
}
```
```bash
# 4. 运行测试确认通过
npm run test
# ✅ Tests: 1 passed, 1 total
```
### Step 4: 🔄 重构阶段执行
```typescript
// 5. 在绿灯状态下改进代码质量
export class Calculator {
/**
* 添加两个数字
* @param a 第一个数字
* @param b 第二个数字
* @returns 两数之和
*/
add(a: number, b: number): number {
// 可以添加输入验证等
if (typeof a !== 'number' || typeof b !== 'number') {
throw new Error('Both arguments must be numbers');
}
return a + b;
}
}
```
```bash
# 6. 确保重构后测试仍然通过
npm run test
# ✅ Tests: 1 passed, 1 total
```
## 📊 Claude TDD 质量检查清单
### 🔴 红阶段检查清单
- [ ] 测试用例清晰表达了期望的行为
- [ ] 测试失败的原因是功能未实现,而非测试错误
- [ ] 测试范围适中,只验证一个具体功能点
- [ ] 测试用例名称具有良好的可读性
### 🟢 绿阶段检查清单
- [ ] 所有测试都通过
- [ ] 实现代码尽可能简单
- [ ] 没有实现当前测试不需要的功能
- [ ] 代码能够正确处理测试用例的所有场景
### 🔄 重构阶段检查清单
- [ ] 消除了代码重复
- [ ] 提高了代码可读性
- [ ] 改善了代码结构
- [ ] 所有测试仍然通过
- [ ] 没有改变外部行为
## 🎓 Claude TDD 最佳实践
### 1. 对话驱动的 TDD
```
用户: "添加减法功能"
Claude: "让我们遵循 TDD 流程:
1. 🔴 先写减法的失败测试
2. 🟢 实现最小的减法功能
3. 🔄 重构优化代码"
```
### 2. 增量式开发
```typescript
// 第一个测试:基本功能
it('should subtract two positive numbers', () => {
expect(calc.subtract(5, 3)).toBe(2);
});
// 第二个测试:边界情况
it('should handle negative results', () => {
expect(calc.subtract(3, 5)).toBe(-2);
});
// 第三个测试:零值处理
it('should handle zero values', () => {
expect(calc.subtract(5, 0)).toBe(5);
});
```
### 3. 测试即文档
```typescript
describe('Calculator', () => {
describe('add method', () => {
it('should add two positive integers correctly', () => {
// 测试名称就是功能文档
});
it('should handle floating point numbers with precision', () => {
// 清晰表达预期行为
});
});
});
```
## 🚀 Claude TDD 命令速查
### 快速启动 TDD 循环
```bash
# 1. 创建测试文件
touch src/feature.test.ts
# 2. 运行测试 (红阶段)
npm run test:watch
# 3. 创建实现文件
touch src/feature.ts
# 4. 运行构建 (绿阶段后)
npm run build
# 5. 查看覆盖率
npm run test:coverage
```
### TDD 开发节奏
```bash
# 保持快速的红-绿-重构循环
🔴 写测试 → ❌ 运行测试 → 🟢 写代码 → ✅ 运行测试 → 🔄 重构 → ✅ 运行测试
```
## 📈 成功的 TDD 指标
### 代码质量指标
-**测试覆盖率**: 95%+ (强制要求)
-**测试执行时间**: < 2 秒 (快速反馈)
-**测试独立性**: 100% (无依赖测试)
-**代码重复度**: 最小化
### TDD 流程指标
-**红-绿-重构循环**: 严格遵循
-**测试先行率**: 100% (无例外)
-**最小实现原则**: 始终遵守
-**重构安全性**: 测试保护下进行
## 🎯 下一步 TDD 开发
继续使用 Claude 进行 TDD 开发时:
1. **🔴 红阶段**: 告诉 Claude 你需要什么功能,让它先写失败测试
2. **🟢 绿阶段**: 让 Claude 实现最小代码使测试通过
3. **🔄 重构阶段**: 与 Claude 一起改进代码质量
4. **重复循环**: 为每个新功能重复此过程
**记住:在 Claude 环境中TDD 不仅是开发方法,更是确保代码质量和设计优雅的严格纪律!** 🎉
## 💡 Claude TDD 实战示例
### 示例:添加乘法功能
#### 🔴 红阶段:用户请求
```
用户: "我需要添加乘法功能"
Claude: "好的,让我们严格遵循 TDD 流程。首先写一个失败的测试:"
```
#### 测试代码 (先写)
```typescript
// src/calculator.test.ts
describe('Calculator', () => {
// ... 现有测试 ...
describe('multiply', () => {
it('should multiply two positive numbers correctly', () => {
const calculator = new Calculator();
expect(calculator.multiply(3, 4)).toBe(12); // ❌ 这会失败
});
});
});
```
#### 🟢 绿阶段:最小实现
```typescript
// src/calculator.ts
export class Calculator {
add(a: number, b: number): number {
return a + b;
}
multiply(a: number, b: number): number {
return a * b; // 最简实现
}
}
```
#### 🔄 重构阶段:优化代码
```typescript
export class Calculator {
/**
* 两数相加
*/
add(a: number, b: number): number {
return a + b;
}
/**
* 两数相乘
* @param a 被乘数
* @param b 乘数
* @returns 乘积
*/
multiply(a: number, b: number): number {
return a * b;
}
}
```
## 🔧 Claude TDD 故障排除
### 常见问题与解决方案
#### 问题 1: 测试没有失败
```typescript
// ❌ 错误:测试一开始就通过了
it('should return true', () => {
expect(true).toBe(true); // 这不是有效的 TDD 测试
});
// ✅ 正确:测试应该验证具体功能
it('should validate email format', () => {
const validator = new EmailValidator();
expect(validator.isValid('test@example.com')).toBe(true); // 会失败,因为类不存在
});
```
#### 问题 2: 实现过度复杂
```typescript
// ❌ 错误:过度实现
multiply(a: number, b: number): number {
// 不需要的复杂逻辑
if (a === 0 || b === 0) return 0;
if (a === 1) return b;
if (b === 1) return a;
// ... 更多不必要的逻辑
return a * b;
}
// ✅ 正确:最小实现
multiply(a: number, b: number): number {
return a * b; // 简单直接
}
```
#### 问题 3: 在红灯状态下重构
```bash
# ❌ 错误流程
npm run test # ❌ 测试失败
# 开始重构代码 <- 错误!应该先让测试通过
# ✅ 正确流程
npm run test # ❌ 测试失败
# 写最小代码让测试通过
npm run test # ✅ 测试通过
# 现在可以安全重构
```
### 关键文件说明
- `jest.config.js` - 测试框架配置
- `tsconfig.json` - TypeScript 严格模式配置
- `package.json` - TDD 相关脚本命令
- `src/*.test.ts` - 测试文件 (TDD 的核心)

View File

@@ -4,7 +4,7 @@ import devConfig from './dev'
import prodConfig from './prod'
// https://taro-docs.jd.com/docs/next/config#defineconfig-辅助函数
export default defineConfig<'vite'>(async (merge, { command, mode }) => {
export default defineConfig<'vite'>(async (merge) => {
const baseConfig: UserConfigExport<'vite'> = {
projectName: 'bw-mini-app',
date: '2025-9-1',

18
global.d.ts vendored Normal file
View File

@@ -0,0 +1,18 @@
/// <reference path="node_modules/@tarojs/plugin-platform-tt/types/shims-tt.d.ts" />
declare module '*.png';
declare module '*.gif';
declare module '*.jpg';
declare module '*.jpeg';
declare module '*.svg';
declare module '*.css';
declare module '*.less';
declare module '*.scss';
declare module '*.sass';
declare module '*.styl';
declare namespace NodeJS {
interface ProcessEnv {
TARO_ENV: 'weapp' | 'swan' | 'alipay' | 'h5' | 'rn' | 'tt' | 'quickapp' | 'qq' | 'jd'
}
}

18
jest.config.js Normal file
View File

@@ -0,0 +1,18 @@
const defineJestConfig = require('@tarojs/test-utils-react/dist/jest.js').default
module.exports = defineJestConfig({
preset: 'ts-jest',
testEnvironment: 'jsdom',
roots: ['<rootDir>/src'],
testMatch: [
'**/__tests__/**/*.{ts,tsx}',
'**/*.(test|spec).{ts,tsx}'
],
setupFilesAfterEnv: ['<rootDir>/tests/setupTests.ts'],
transformIgnorePatterns: [
'node_modules/(?!(@tarojs|swiper|@babel|@testing-library)/)'
],
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy'
},
})

View File

@@ -13,6 +13,11 @@
"claude": "claude --dangerously-skip-permissions",
"prepare": "husky",
"new": "taro new",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"typecheck": "tsc --noEmit",
"lint": "eslint src --ext .ts,.tsx --fix",
"build:weapp": "taro build --type weapp",
"build:swan": "taro build --type swan",
"build:alipay": "taro build --type alipay",
@@ -48,47 +53,57 @@
"@babel/runtime": "^7.24.4",
"@tarojs/components": "4.1.6",
"@tarojs/helper": "4.1.6",
"@tarojs/plugin-platform-weapp": "4.1.6",
"@tarojs/plugin-framework-react": "4.1.6",
"@tarojs/plugin-platform-alipay": "4.1.6",
"@tarojs/plugin-platform-tt": "4.1.6",
"@tarojs/plugin-platform-swan": "4.1.6",
"@tarojs/plugin-platform-jd": "4.1.6",
"@tarojs/plugin-platform-qq": "4.1.6",
"@tarojs/plugin-platform-h5": "4.1.6",
"@tarojs/plugin-platform-harmony-hybrid": "4.1.6",
"@tarojs/plugin-platform-jd": "4.1.6",
"@tarojs/plugin-platform-qq": "4.1.6",
"@tarojs/plugin-platform-swan": "4.1.6",
"@tarojs/plugin-platform-tt": "4.1.6",
"@tarojs/plugin-platform-weapp": "4.1.6",
"@tarojs/react": "4.1.6",
"@tarojs/runtime": "4.1.6",
"@tarojs/shared": "4.1.6",
"@tarojs/taro": "4.1.6",
"@tarojs/plugin-framework-react": "4.1.6",
"@tarojs/react": "4.1.6",
"react-dom": "^18.0.0",
"react": "^18.0.0"
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"@tarojs/plugin-generator": "4.1.6",
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"lint-staged": "^16.1.2",
"husky": "^9.1.7",
"stylelint-config-standard": "^38.0.0",
"@babel/core": "^7.24.4",
"@babel/plugin-transform-class-properties": "7.25.9",
"@tarojs/cli": "4.1.6",
"@tarojs/vite-runner": "4.1.6",
"babel-preset-taro": "4.1.6",
"eslint-config-taro": "4.1.6",
"eslint": "^8.57.0",
"stylelint": "^16.4.0",
"terser": "^5.30.4",
"vite": "^4.2.0",
"@babel/preset-react": "^7.24.1",
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"@tarojs/cli": "4.1.6",
"@tarojs/plugin-generator": "4.1.6",
"@tarojs/test-utils-react": "^0.1.1",
"@tarojs/vite-runner": "4.1.6",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0",
"@types/minimatch": "^5",
"@types/react": "^18.0.0",
"@vitejs/plugin-react": "^4.3.0",
"babel-jest": "^30.1.2",
"babel-preset-taro": "4.1.6",
"eslint": "^8.57.0",
"eslint-config-taro": "4.1.6",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.4.0",
"react-refresh": "^0.14.0",
"typescript": "^5.4.5",
"husky": "^9.1.7",
"identity-obj-proxy": "^3.0.0",
"jest": "^30.1.2",
"jest-environment-jsdom": "^30.1.2",
"lint-staged": "^16.1.2",
"postcss": "^8.4.38",
"@types/minimatch": "^5"
"react-refresh": "^0.14.0",
"stylelint": "^16.4.0",
"stylelint-config-standard": "^38.0.0",
"terser": "^5.30.4",
"ts-jest": "^29.4.1",
"typescript": "^5.4.5",
"vite": "^4.2.0"
}
}

2600
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1 @@
{
"miniprogramRoot": "./dist",
"projectname": "bw-mini-app",
"description": "图生图 风格转换 ",
"appid": "touristappid",
"setting": {
"urlCheck": true,
"es6": false,
"enhance": false,
"compileHotReLoad": false,
"postcss": false,
"minified": false
},
"compileType": "miniprogram"
}
{"miniprogramRoot":"./dist","projectname":"bw-mini-app","description":"图生图 风格转换 ","appid":"ttbfd9c96420ec8f8201","setting":{"urlCheck":true,"es6":true,"enhance":false,"compileHotReLoad":false,"postcss":false,"minified":false},"compileType":"miniprogram"}

View File

@@ -0,0 +1,5 @@
#app{
width: 100vw;
height: 100vh;
}

View File

@@ -0,0 +1,33 @@
import { renderHook } from '@testing-library/react';
import { useAd } from '../useAd';
// Mock Taro
jest.mock('@tarojs/taro', () => ({
createRewardedVideoAd: jest.fn(() => ({
onLoad: jest.fn(),
onClose: jest.fn(),
onError: jest.fn(),
show: jest.fn(),
load: jest.fn(),
})),
}));
describe('useAd', () => {
it('should provide showAd function', () => {
const { result } = renderHook(() => useAd());
expect(result.current.showAd).toBeDefined();
expect(typeof result.current.showAd).toBe('function');
});
it('should provide loadAd function', () => {
const { result } = renderHook(() => useAd());
expect(result.current.loadAd).toBeDefined();
expect(typeof result.current.loadAd).toBe('function');
});
it('should provide loading state', () => {
const { result } = renderHook(() => useAd());
expect(result.current.loading).toBeDefined();
expect(typeof result.current.loading).toBe('boolean');
});
});

64
src/hooks/useAd.ts Normal file
View File

@@ -0,0 +1,64 @@
import { createPlatformFactory, RewardedVideoAd, RewardedVideoCloseCb, RewardedVideoErrorCb } from "../platforms";
import { useState, useCallback, useRef, useEffect } from 'react';
interface UseAdReturn {
showAd: () => void;
loadAd: () => void;
loading: boolean;
}
export function useAd(): UseAdReturn {
const [loading, setLoading] = useState(false);
const adRef = useRef<RewardedVideoAd | null>(null);
useEffect(() => {
const factory = createPlatformFactory()
adRef.current = factory.createRewardedVideoAd({
adUnitId: 'gncb4kr2b6kwp0uacr'
});
const ad = adRef.current!;
const onClose: RewardedVideoCloseCb = (res) => {
console.log('广告关闭:', res);
setLoading(false);
}
const onError: RewardedVideoErrorCb = (res) => {
console.error('广告错误:', res);
setLoading(false);
}
const onLoad = () => {
console.log(`广告加载`)
setLoading(false);
}
ad.onLoad(onLoad);
ad.onClose(onClose);
ad.onError(onError);
return () => {
if (adRef.current) {
adRef.current.offClose(onClose)
adRef.current.offError(onError)
adRef.current.offLoad(onLoad)
adRef.current.destroy();
}
};
}, []);
const showAd = useCallback(() => {
if (adRef.current) {
setLoading(true);
adRef.current.show();
}
}, []);
const loadAd = useCallback(() => {
if (adRef.current) {
setLoading(true);
adRef.current.load();
}
}, []);
return {
showAd,
loadAd,
loading
};
}

View File

@@ -0,0 +1,3 @@
.index{
font-size: 1rem;
}

View File

@@ -1,15 +1,23 @@
import { View, Text } from '@tarojs/components'
import { View, Text, Button } from '@tarojs/components'
import { useLoad } from '@tarojs/taro'
import './index.css'
import { useAd } from '../../hooks/useAd'
export default function Index () {
export default function Index() {
const { showAd, loadAd } = useAd()
useLoad(() => {
console.log('Page loaded.')
loadAd()
return () => {}
})
return (
<View className='index'>
<Text>Hello world!</Text>
<Text>Hello world Ymm !!!!</Text>
<Button onClick={() => {
showAd()
}}
>广</Button>
</View>
)
}

126
src/platforms/core.ts Normal file
View File

@@ -0,0 +1,126 @@
/**
* 激励视频广告关闭回调载荷数据接口
*/
export interface RewardedVideoCloseCbPayload {
/** 视频是否播放完成 */
isEnded: boolean;
/** 奖励数量 */
count: number;
}
/**
* 激励视频广告关闭回调函数接口
*/
export interface RewardedVideoCloseCb {
(data: RewardedVideoCloseCbPayload): void;
}
/**
* 激励视频广告错误回调载荷数据接口
*/
export interface RewardedVideoErrorCbPayload {
/** 错误消息 */
errMsg: string;
/** 错误代码 */
errCode: string;
}
/**
* 激励视频广告错误回调函数接口
*/
export interface RewardedVideoErrorCb {
(data: RewardedVideoErrorCbPayload): void;
}
/**
* 激励视频广告加载回调函数接口
*/
export interface RewardedVideoLoadCb {
(): void;
}
/**
* 激励视频广告配置选项接口
*/
export interface RewardedVideoAdOptions {
/** 广告位 ID */
adUnitId: string;
}
/**
* 激励视频广告抽象基类
* 定义了跨平台激励视频广告的统一接口
*/
export abstract class RewardedVideoAd {
/**
* 手动加载广告素材
* 当广告素材加载出现错误时,可以通过此方法手动重新加载
* @returns Promise<void> 加载完成的 Promise
*/
abstract load(): Promise<void>;
/**
* 展示激励视频广告
* @returns Promise<void> 展示完成的 Promise
*/
abstract show(): Promise<void>;
/**
* 销毁激励视频广告实例
* 释放相关资源,销毁后需要重新创建实例
* @returns Promise<void> 销毁完成的 Promise
*/
abstract destroy(): Promise<void>;
/**
* 绑定广告关闭事件监听器
* 当用户点击广告关闭按钮或广告播放完成时触发
* @param cb 关闭事件回调函数
*/
abstract onClose(cb: RewardedVideoCloseCb): void;
/**
* 解除绑定广告关闭事件监听器
* @param cb 需要移除的关闭事件回调函数
*/
abstract offClose(cb: RewardedVideoCloseCb): void;
/**
* 绑定广告错误事件监听器
* 当广告加载或播放出现错误时触发
* @param cb 错误事件回调函数
*/
abstract onError(cb: RewardedVideoErrorCb): void;
/**
* 解除绑定广告错误事件监听器
* @param cb 需要移除的错误事件回调函数
*/
abstract offError(cb: RewardedVideoErrorCb): void;
/**
* 绑定广告加载成功事件监听器
* 当广告素材加载完成时触发
* @param cb 加载成功事件回调函数
*/
abstract onLoad(cb: RewardedVideoLoadCb): void;
/**
* 解除绑定广告加载成功事件监听器
* @param cb 需要移除的加载成功事件回调函数
*/
abstract offLoad(cb: RewardedVideoLoadCb): void;
/**
* 获取广告是否准备就绪
* @returns boolean 广告是否可以播放
*/
abstract isReady(): boolean;
/**
* 预加载广告
* 在合适的时机预先加载广告,提高用户体验
* @returns Promise<void> 预加载完成的 Promise
*/
abstract preload(): Promise<void>;
}

106
src/platforms/factory.ts Normal file
View File

@@ -0,0 +1,106 @@
import { RewardedVideoAdTT } from "./tt";
import { RewardedVideoAd, RewardedVideoAdOptions } from "./core";
/**
* 小程序平台全局对象类型声明
*/
declare const tt: any;
declare const wx: any;
declare const my: any;
declare const swan: any;
declare const qq: any;
/**
* 支持的平台类型
* 目前支持:字节跳动小程序(tt)
*/
export type Platform = 'tt' | 'weapp' | 'alipay' | 'swan' | 'qq';
/**
* 平台适配器工厂类
* 根据不同平台创建对应的广告实例
*/
export class PlatformFactory {
private readonly platform: Platform;
/**
* 构造函数
* @param platform 目标平台类型
*/
constructor(platform: Platform) {
this.platform = platform;
}
/**
* 创建激励视频广告实例
* @param options 广告配置选项
* @returns RewardedVideoAd 广告实例
* @throws Error 当平台不支持时抛出错误
*/
createRewardedVideoAd(options?: RewardedVideoAdOptions): RewardedVideoAd {
switch (this.platform) {
case 'tt':
return new RewardedVideoAdTT(options);
case 'weapp':
throw new Error(`微信小程序平台暂未实现`);
case 'alipay':
throw new Error(`支付宝小程序平台暂未实现`);
case 'swan':
throw new Error(`百度智能小程序平台暂未实现`);
case 'qq':
throw new Error(`QQ 小程序平台暂未实现`);
default:
throw new Error(`不支持的平台类型: ${this.platform}`);
}
}
/**
* 获取当前平台类型
* @returns Platform 当前平台
*/
getPlatform(): Platform {
return this.platform;
}
/**
* 检查平台是否支持
* @param platform 平台类型
* @returns boolean 是否支持
*/
static isSupportedPlatform(platform: string): platform is Platform {
return ['tt', 'weapp', 'alipay', 'swan', 'qq'].includes(platform);
}
/**
* 自动检测当前运行环境平台
* @returns Platform 检测到的平台类型
* @throws Error 当无法检测到平台时抛出错误
*/
static detectPlatform(): Platform {
if (typeof tt !== 'undefined') {
return 'tt';
}
if (typeof wx !== 'undefined') {
return 'weapp';
}
if (typeof my !== 'undefined') {
return 'alipay';
}
if (typeof swan !== 'undefined') {
return 'swan';
}
if (typeof qq !== 'undefined') {
return 'qq';
}
throw new Error('无法检测到支持的小程序平台环境');
}
/**
* 创建自动检测平台的工厂实例
* @returns PlatformFactory 工厂实例
*/
static createAutoDetect(): PlatformFactory {
const platform = PlatformFactory.detectPlatform();
return new PlatformFactory(platform);
}
}

23
src/platforms/index.ts Normal file
View File

@@ -0,0 +1,23 @@
/**
* 平台适配器模块导出
* 提供跨小程序平台的统一广告接口
*/
import { Platform, PlatformFactory } from './factory';
export { PlatformFactory, type Platform } from './factory';
export { RewardedVideoAdTT } from './tt';
/**
* 便捷创建工厂实例的函数
* @param platform 平台类型,如果不指定则自动检测
* @returns PlatformFactory 工厂实例
*/
export function createPlatformFactory(platform?: Platform) {
if (platform) {
return new PlatformFactory(platform);
}
return PlatformFactory.createAutoDetect();
}
export * from './core'

197
src/platforms/tt.ts Normal file
View File

@@ -0,0 +1,197 @@
import {
RewardedVideoAd,
RewardedVideoCloseCb,
RewardedVideoErrorCb,
RewardedVideoLoadCb,
RewardedVideoAdOptions
} from "./core";
/**
* 字节跳动小程序全局对象类型声明
*/
declare const tt: {
createRewardedVideoAd: (options: any) => any;
[key: string]: any;
};
/**
* 字节跳动平台激励视频广告配置接口
*/
interface TTRewardedVideoAdOptions {
/** 广告位 ID */
adUnitId: string;
/** 是否启用多场景化 */
multiton?: boolean;
/** 用户 ID */
userId?: string;
}
/**
* 字节跳动平台激励视频广告实现类
* 封装字节跳动小程序的激励视频广告 API
*/
export class RewardedVideoAdTT extends RewardedVideoAd {
private readonly ad: any;
private readonly options: TTRewardedVideoAdOptions;
private _isReady: boolean = false;
/**
* 构造函数
* @param options 广告配置选项
*/
constructor(options?: RewardedVideoAdOptions) {
super();
// 设置默认配置
this.options = {
adUnitId: options?.adUnitId || '',
};
if (!this.options.adUnitId) {
throw new Error('广告位 ID (adUnitId) 不能为空');
}
// 检查字节跳动环境
if (typeof tt === 'undefined') {
throw new Error('当前环境不支持字节跳动小程序 API');
}
// 创建激励视频广告实例
this.ad = tt.createRewardedVideoAd({
adUnitId: this.options.adUnitId,
});
// 监听广告加载成功事件
this.ad.onLoad(() => {
this._isReady = true;
});
// 监听广告加载错误事件
this.ad.onError(() => {
this._isReady = false;
});
}
/**
* 手动加载广告素材
* @returns Promise<void> 加载完成的 Promise
*/
async load(): Promise<void> {
try {
await this.ad.load();
this._isReady = true;
} catch (error) {
this._isReady = false;
throw error;
}
}
/**
* 展示激励视频广告
* @returns Promise<void> 展示完成的 Promise
*/
async show(): Promise<void> {
if (!this._isReady) {
throw new Error('广告尚未加载完成,请先调用 load() 方法或等待广告加载完成');
}
return this.ad.show();
}
/**
* 销毁激励视频广告实例
* @returns Promise<void> 销毁完成的 Promise
*/
async destroy(): Promise<void> {
this._isReady = false;
if (this.ad.destroy) {
return this.ad.destroy();
}
// 兼容性处理:某些版本可能使用 destory (拼写错误)
if (this.ad.destory) {
return this.ad.destory();
}
return Promise.resolve();
}
/**
* 绑定广告关闭事件监听器
* @param cb 关闭事件回调函数
*/
onClose(cb: RewardedVideoCloseCb): void {
this.ad.onClose(cb);
}
/**
* 解除绑定广告关闭事件监听器
* @param cb 需要移除的关闭事件回调函数
*/
offClose(cb: RewardedVideoCloseCb): void {
this.ad.offClose(cb);
}
/**
* 绑定广告错误事件监听器
* @param cb 错误事件回调函数
*/
onError(cb: RewardedVideoErrorCb): void {
this.ad.onError(cb);
}
/**
* 解除绑定广告错误事件监听器
* @param cb 需要移除的错误事件回调函数
*/
offError(cb: RewardedVideoErrorCb): void {
this.ad.offError(cb);
}
/**
* 绑定广告加载成功事件监听器
* @param cb 加载成功事件回调函数
*/
onLoad(cb: RewardedVideoLoadCb): void {
this.ad.onLoad(cb);
}
/**
* 解除绑定广告加载成功事件监听器
* @param cb 需要移除的加载成功事件回调函数
*/
offLoad(cb: RewardedVideoLoadCb): void {
this.ad.offLoad(cb);
}
/**
* 获取广告是否准备就绪
* @returns boolean 广告是否可以播放
*/
isReady(): boolean {
return this._isReady;
}
/**
* 预加载广告
* 在合适的时机预先加载广告,提高用户体验
* @returns Promise<void> 预加载完成的 Promise
*/
async preload(): Promise<void> {
return this.load();
}
/**
* 获取广告配置选项
* @returns TTRewardedVideoAdOptions 当前配置
*/
getOptions(): TTRewardedVideoAdOptions {
return { ...this.options };
}
/**
* 获取原生广告实例
* 用于访问平台特有的方法和属性
* @returns any 原生广告实例
*/
getNativeAd(): any {
return this.ad;
}
}

1
tests/setupTests.ts Normal file
View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom';

View File

@@ -6,12 +6,19 @@
"preserveConstEnums": true,
"moduleResolution": "node",
"experimentalDecorators": true,
"noImplicitAny": false,
"strict": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noFallthroughCasesInSwitch": true,
"allowSyntheticDefaultImports": true,
"outDir": "lib",
"noUnusedLocals": true,
"noUnusedParameters": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"sourceMap": true,
"rootDir": ".",
"jsx": "react-jsx",