Initial commit: Expo app with Better Auth integration

- Complete Expo React Native app setup with TypeScript
- Better Auth authentication system integration
- Secure storage implementation for session tokens
- Authentication flow with login/logout functionality
- API client configuration for backend communication
- Responsive UI components with themed styling
- Expo Router navigation setup
- Development configuration and scripts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
imeepos
2025-10-14 10:32:52 +08:00
commit 7e3f94bae3
71 changed files with 20800 additions and 0 deletions

162
utils/cookie-encoding.ts Normal file
View File

@@ -0,0 +1,162 @@
/**
* Cookie编码处理工具
* 解决特殊字符在Cookie中的编码问题
*/
/**
* 安全编码Cookie值
* 使用encodeURIComponent确保特殊字符正确编码
*
* @param value 原始值
* @returns 编码后的值
*/
export function encodeCookieValue(value: string): string {
if (!value) return '';
try {
// 使用encodeURIComponent编码所有特殊字符
return encodeURIComponent(value);
} catch (error) {
console.error('Cookie值编码失败:', error);
return value; // 编码失败时返回原值
}
}
/**
* 安全解码Cookie值
* 使用decodeURIComponent解码编码的值
*
* @param encodedValue 编码后的值
* @returns 解码后的原始值
*/
export function decodeCookieValue(encodedValue: string): string {
if (!encodedValue) return '';
try {
// 使用decodeURIComponent解码
return decodeURIComponent(encodedValue);
} catch (error) {
console.error('Cookie值解码失败:', error);
// 解码失败时,可能值已经未编码,返回原值
return encodedValue;
}
}
/**
* 批量编码Cookie对象中的所有值
*
* @param cookieObj Cookie对象 {key: value}
* @returns 编码后的Cookie对象
*/
export function encodeCookieObject(cookieObj: Record<string, string>): Record<string, string> {
const encoded: Record<string, string> = {};
for (const [key, value] of Object.entries(cookieObj)) {
encoded[key] = encodeCookieValue(value);
}
return encoded;
}
/**
* 批量解码Cookie对象中的所有值
*
* @param encodedCookieObj 编码的Cookie对象
* @returns 解码后的Cookie对象
*/
export function decodeCookieObject(encodedCookieObj: Record<string, string>): Record<string, string> {
const decoded: Record<string, string> = {};
for (const [key, value] of Object.entries(encodedCookieObj)) {
decoded[key] = decodeCookieValue(value);
}
return decoded;
}
/**
* 安全解析包含编码值的Cookie字符串
*
* @param cookieString Cookie字符串
* @returns 解析后的Cookie对象
*/
export function parseEncodedCookieString(cookieString: string): Record<string, string> {
if (!cookieString) return {};
const cookies: Record<string, string> = {};
// 分割多个Cookie对
const cookiePairs = cookieString.split(';').map(pair => pair.trim());
for (const pair of cookiePairs) {
if (!pair) continue;
const [key, ...valueParts] = pair.split('=');
if (!key) continue;
const value = valueParts.join('=');
cookies[key] = decodeCookieValue(value);
}
return cookies;
}
/**
* 创建包含编码值的Cookie字符串
*
* @param cookieObj Cookie对象
* @returns 编码后的Cookie字符串
*/
export function createEncodedCookieString(cookieObj: Record<string, string>): string {
const encodedPairs: string[] = [];
for (const [key, value] of Object.entries(cookieObj)) {
const encodedValue = encodeCookieValue(value);
encodedPairs.push(`${key}=${encodedValue}`);
}
return encodedPairs.join('; ');
}
/**
* 检查字符串是否包含特殊字符
*
* @param value 要检查的字符串
* @returns 是否包含需要编码的字符
*/
export function hasSpecialChars(value: string): boolean {
// 检查是否包含需要编码的特殊字符
const specialChars = /[^\w\-._~!$&'()*+,;=:@\/]/;
return specialChars.test(value);
}
/**
* 智能编码:仅对包含特殊字符的值进行编码
*
* @param value 要编码的值
* @returns 编码后的值
*/
export function smartEncodeCookieValue(value: string): string {
if (!value) return '';
// 如果包含特殊字符则编码,否则返回原值
return hasSpecialChars(value) ? encodeCookieValue(value) : value;
}
/**
* 智能解码:尝试解码,失败则返回原值
*
* @param encodedValue 可能编码的值
* @returns 解码后的值
*/
export function smartDecodeCookieValue(encodedValue: string): string {
if (!encodedValue) return '';
// 尝试解码,如果解码后的值与原值相同,可能说明没有编码过
try {
const decoded = decodeCookieValue(encodedValue);
return decoded;
} catch (error) {
return encodedValue;
}
}

212
utils/video-utils.ts Normal file
View File

@@ -0,0 +1,212 @@
import { AVPlaybackStatus } from 'expo-av';
export interface VideoMetadata {
width: number;
height: number;
duration: number;
aspectRatio: number;
orientation: 'portrait' | 'landscape' | 'square';
}
/**
* 从AVPlaybackStatus中提取视频元数据
*/
export function extractVideoMetadata(status: AVPlaybackStatus): VideoMetadata | null {
if (!status.isLoaded) {
return null;
}
// 尝试从不同的属性获取视频尺寸
let width = 0, height = 0;
// 检查是否有naturalSize属性
if ('naturalSize' in status && status.naturalSize) {
const naturalSize = status.naturalSize as any;
width = naturalSize.width || 0;
height = naturalSize.height || 0;
}
// 如果没有获取到尺寸,使用默认值
if (width === 0 || height === 0) {
width = 1920; // 默认宽度
height = 1080; // 默认高度
}
const duration = status.durationMillis ? status.durationMillis / 1000 : 0;
const aspectRatio = width > 0 ? width / height : 1;
// 判断视频方向
let orientation: 'portrait' | 'landscape' | 'square';
if (Math.abs(width - height) < 10) {
orientation = 'square';
} else if (width > height) {
orientation = 'landscape';
} else {
orientation = 'portrait';
}
return {
width,
height,
duration,
aspectRatio,
orientation,
};
}
/**
* 根据容器尺寸和视频元数据计算最佳显示尺寸
*/
export function calculateOptimalVideoSize(
containerWidth: number,
maxHeight: number,
metadata: VideoMetadata,
resizeMode: 'contain' | 'cover' | 'stretch' = 'contain'
): { width: number; height: number } {
const { aspectRatio } = metadata;
switch (resizeMode) {
case 'contain':
// 保持宽高比,完全显示在容器内
if (containerWidth / aspectRatio <= maxHeight) {
return {
width: containerWidth,
height: containerWidth / aspectRatio,
};
} else {
return {
width: maxHeight * aspectRatio,
height: maxHeight,
};
}
case 'cover':
// 保持宽高比,填满容器(可能裁剪)
if (containerWidth / aspectRatio >= maxHeight) {
return {
width: containerWidth,
height: containerWidth / aspectRatio,
};
} else {
return {
width: maxHeight * aspectRatio,
height: maxHeight,
};
}
case 'stretch':
// 拉伸填满容器
return {
width: containerWidth,
height: maxHeight,
};
default:
return {
width: containerWidth,
height: containerWidth / aspectRatio,
};
}
}
/**
* 格式化视频时长
*/
export function formatVideoDuration(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
if (minutes === 0) {
return `${remainingSeconds}`;
} else if (remainingSeconds === 0) {
return `${minutes}分钟`;
} else {
return `${minutes}${remainingSeconds}`;
}
}
/**
* 格式化视频尺寸信息
*/
export function formatVideoSize(metadata: VideoMetadata): string {
const { width, height } = metadata;
if (width >= 1000 || height >= 1000) {
return `${(width / 1000).toFixed(1)}k × ${(height / 1000).toFixed(1)}k`;
} else {
return `${width} × ${height}`;
}
}
/**
* 检测视频文件类型
*/
export function getVideoFileType(url: string): string | null {
const extension = url.split('.').pop()?.toLowerCase();
const videoExtensions = [
'mp4', 'webm', 'ogg', 'mov', 'avi',
'mkv', 'flv', 'wmv', 'm4v', '3gp'
];
return extension && videoExtensions.includes(extension) ? extension : null;
}
/**
* 生成视频封面图的URI如果需要
*/
export function generateVideoPosterUrl(videoUrl: string): string {
// 某些CDN支持通过参数生成封面图
// 这里可以根据实际使用的视频服务进行调整
if (videoUrl.includes('youtube.com') || videoUrl.includes('youtu.be')) {
// YouTube视频封面图逻辑
const videoId = extractYouTubeVideoId(videoUrl);
return videoId ? `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg` : '';
}
// 其他视频服务的封面图逻辑可以在这里添加
return '';
}
/**
* 从YouTube URL提取视频ID
*/
function extractYouTubeVideoId(url: string): string | null {
const regex = /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/;
const match = url.match(regex);
return match ? match[1] : null;
}
/**
* 验证视频URL是否有效
*/
export function isValidVideoUrl(url: string): boolean {
if (!url || typeof url !== 'string') {
return false;
}
try {
const urlObj = new URL(url);
const isValidProtocol = ['http:', 'https:', 'ftp:'].includes(urlObj.protocol);
const hasVideoExtension = getVideoFileType(url) !== null;
return isValidProtocol && (hasVideoExtension || url.includes('stream'));
} catch {
return false;
}
}
/**
* 获取推荐的缩略图时间点视频的10%、30%、60%位置)
*/
export function getThumbnailTimepoints(duration: number): number[] {
if (duration <= 0) return [1];
const points = [
duration * 0.1,
duration * 0.3,
duration * 0.6,
];
return points.map(t => Math.max(1, Math.floor(t)));
}