Initial commit: expo-popcore project

This commit is contained in:
imeepos
2025-12-25 16:31:17 +08:00
commit 0cbb74e03a
188 changed files with 20796 additions and 0 deletions

29
lib/api/activities.ts Normal file
View File

@@ -0,0 +1,29 @@
import { getApiActivities } from '@repo/loomart-sdk';
import { loomartClient } from './loomart-client';
export interface GetActivitiesParams {
isActive?: string;
}
export interface Activity {
id: string;
title: string;
titleEn: string;
desc: string;
descEn: string;
coverUrl: string;
videoUrl: string;
link: string;
isActive: boolean;
sortOrder: number;
createdAt: string;
updatedAt: string;
}
export const getActivities = async (params: GetActivitiesParams = {}) => {
const { data } = await getApiActivities({
client: loomartClient,
query: params,
});
return data?.data || [];
};

214
lib/api/balance.ts Normal file
View File

@@ -0,0 +1,214 @@
import { router } from 'expo-router';
import { Alert } from 'react-native';
import { authClient } from '../auth/client';
export interface UserBalance {
remainingTokenBalance: number;
totalTokenBalance: number;
usedTokenBalance: number;
}
export interface BalanceResponse {
success: boolean;
data: UserBalance;
message?: string;
}
export interface TokenUsageRequest {
price: number;
name: string;
metadata?: Record<string, any>;
}
export interface TokenUsageResponse {
success: boolean;
data?: {
identifier: string;
remainingBalance: number;
};
message?: string;
}
export interface BalanceCheckResult {
hasEnough: boolean;
currentBalance: number;
isLoading: boolean;
message?: string;
}
/**
* 获取用户余额
* 从 metered 类型的订阅中获取 creditBalance
*/
export async function getUserBalance(): Promise<BalanceResponse> {
try {
const { data, error } = await authClient.subscription.list({});
if (error) {
return {
success: false,
data: {
remainingTokenBalance: 0,
totalTokenBalance: 0,
usedTokenBalance: 0,
},
message: error.message || '获取余额失败',
};
}
// 找到 metered 类型的订阅
const meteredSubscriptions = data?.filter((sub: any) => sub.type === 'metered') || [];
if (meteredSubscriptions.length === 0) {
return {
success: false,
data: {
remainingTokenBalance: 0,
totalTokenBalance: 0,
usedTokenBalance: 0,
},
message: '未找到计费订阅',
};
}
const creditBalance = (meteredSubscriptions[0] as any)?.creditBalance || {};
return {
success: true,
data: {
remainingTokenBalance: creditBalance.remainingTokenBalance || 0,
totalTokenBalance: creditBalance.totalTokenBalance || 0,
usedTokenBalance: creditBalance.usedTokenBalance || 0,
},
};
} catch (error) {
console.error('Failed to get user balance:', error);
return {
success: false,
data: {
remainingTokenBalance: 0,
totalTokenBalance: 0,
usedTokenBalance: 0,
},
message: '网络错误,请稍后重试',
};
}
}
/**
* 检查用户余额是否足够
*/
export async function checkTokenBalance(tokens: number): Promise<BalanceCheckResult> {
const balanceResponse = await getUserBalance();
if (!balanceResponse.success) {
return {
hasEnough: false,
currentBalance: 0,
isLoading: false,
message: balanceResponse.message || '无法获取余额',
};
}
const currentBalance = balanceResponse.data.remainingTokenBalance;
const hasEnough = currentBalance >= tokens;
return {
hasEnough,
currentBalance,
isLoading: false,
message: hasEnough
? undefined
: `余额不足,当前: ${currentBalance.toLocaleString()}, 需要: ${tokens.toLocaleString()}`,
};
}
/**
* 扣除 Token 使用量
* 参考 usePricing 中的 recordTokenUsage 方法
*/
export async function recordTokenUsage(
request: TokenUsageRequest
): Promise<TokenUsageResponse> {
const { price, name, metadata } = request;
// price 的单位就是 token
const tokens = Math.ceil(price);
try {
// 1. 检查余额是否足够
const balanceCheck = await checkTokenBalance(tokens);
if (balanceCheck.isLoading) {
return {
success: false,
message: '正在检查余额...',
};
}
if (!balanceCheck.hasEnough) {
// 余额不足,提示用户并引导充值
Alert.alert(
'余额不足',
`当前余额: ${balanceCheck.currentBalance}\n需要费用: ${tokens}\n请先充值`,
[
{ text: '取消', style: 'cancel' },
{
text: '去充值',
onPress: () => {
router.push('/exchange');
},
},
]
);
return {
success: false,
message: balanceCheck.message || '余额不足',
};
}
// 2. 余额充足,执行消费操作
const { data, error } = await authClient.subscription.meterEvent({
event_name: 'token_usage',
payload: {
value: tokens.toString(),
...(metadata && { metadata }),
} as any,
});
if (error) {
Alert.alert('扣费失败', error.message || '请稍后重试');
return {
success: false,
message: error.message || '扣费失败',
};
}
// 3. 扣费成功,返回结果
const balanceAfter = await getUserBalance();
return {
success: true,
data: {
identifier: data?.identifier || '',
remainingBalance: balanceAfter.data.remainingTokenBalance,
},
message: '扣费成功',
};
} catch (error) {
console.error('Failed to record token usage:', error);
const errorMessage = error instanceof Error ? error.message : '扣费失败,请稍后重试';
Alert.alert('错误', errorMessage);
return {
success: false,
message: errorMessage,
};
}
}
/**
* 跳转到充值页面
*/
export function redirectToPricePage() {
router.push('/exchange');
}

View File

@@ -0,0 +1,9 @@
import { getApiCategoriesWithChildrenV2 } from '@repo/loomart-sdk';
import { loomartClient } from './loomart-client';
export const categoriesWithChildren = async () => {
const { data } = await getApiCategoriesWithChildrenV2({
client: loomartClient,
});
return data;
};

15
lib/api/client.ts Normal file
View File

@@ -0,0 +1,15 @@
type UnauthorizedListener = () => void;
const unauthorizedListeners: UnauthorizedListener[] = [];
export const authEvents = {
onUnauthorized(listener: UnauthorizedListener) {
unauthorizedListeners.push(listener);
return () => {
const index = unauthorizedListeners.indexOf(listener);
if (index > -1) unauthorizedListeners.splice(index, 1);
};
},
emitUnauthorized() {
unauthorizedListeners.forEach(listener => listener());
}
};

34
lib/api/loomart-client.ts Normal file
View File

@@ -0,0 +1,34 @@
import { createClient, createConfig } from '@repo/loomart-sdk';
import { storage } from '../storage';
import { authEvents } from './client';
export const getAuthToken = async () =>
(await storage.getItem('bestaibest.better-auth.session_token')) || '';
export const loomartClient = createClient(
createConfig({
baseUrl: 'https://api.mixvideo.bowong.cc'
})
);
loomartClient.interceptors.request.use(async (request) => {
const Authorization = `Bearer ${await getAuthToken()}`
request.headers.append(`Authorization`, Authorization)
if (__DEV__) {
console.log('[API]', request.method, request.url);
}
return request;
})
loomartClient.interceptors.response.use(async (response) => {
if (__DEV__) {
console.log('[API]', response.status, response.url);
}
if (response.status === 401) {
authEvents.emitUnauthorized();
}
return response;
});

53
lib/api/pricing.ts Normal file
View File

@@ -0,0 +1,53 @@
import { getApiStripePlans } from '@repo/loomart-sdk';
import { loomartClient } from './loomart-client';
export interface CreditPlan {
name: string;
recurring?: {
interval: string;
};
credits?: number;
price_id?: string;
product_id?: string;
metered_price_id?: string;
}
export interface StripePricingTableResponse {
pricing_table_items: CreditPlan[];
}
export const getStripePlans = async () => {
try {
const { data } = await getApiStripePlans({
client: loomartClient,
});
// 后端返回 { success: true, data: pricingTable }
// SDK 包装为 { data: { success: true, data: pricingTable } }
// 需要解包嵌套的 data
if (data && typeof data === 'object' && 'success' in data && data.success && 'data' in data) {
return {
success: true,
data: data.data,
};
}
return {
success: false,
message: 'Invalid response format',
};
} catch (error) {
console.error('Failed to fetch Stripe plans:', error);
return {
success: false,
message: error instanceof Error ? error.message : 'Unknown error',
};
}
};
export const getPlanNames = (pricingData?: StripePricingTableResponse): string[] => {
if (!pricingData?.pricing_table_items) return [];
return pricingData.pricing_table_items
.filter((item) => item.recurring?.interval === 'month')
.map((item) => item.name || 'basic');
};

10
lib/api/tags.ts Normal file
View File

@@ -0,0 +1,10 @@
import { getApiTagsByNameByName } from '@repo/loomart-sdk';
import { loomartClient } from './loomart-client';
export const getTagByName = async (name: string) => {
const { data } = await getApiTagsByNameByName({
client: loomartClient,
path: { name },
});
return data;
};

View File

@@ -0,0 +1,33 @@
import { getApiTemplateGenerations } from '@repo/loomart-sdk';
import { loomartClient } from './loomart-client';
export interface GetTemplateGenerationsParams {
page?: string;
limit?: string;
type?: 'VIDEO' | 'IMAGE' | 'TEXT';
}
export interface TemplateGeneration {
id: string;
userId: string;
templateId: string;
type: 'TEXT' | 'IMAGE' | 'VIDEO';
status: string;
resultUrl: string[];
originalUrl?: string;
createdAt: string;
updatedAt: string;
template?: {
id: string;
title: string;
titleEn: string;
};
}
export const getTemplateGenerations = async (params: GetTemplateGenerationsParams = {}) => {
const { data } = await getApiTemplateGenerations({
client: loomartClient,
query: params,
});
return data;
};

93
lib/api/template-runs.ts Normal file
View File

@@ -0,0 +1,93 @@
import { postApiTemplatesByIdRun, getApiTemplateGenerationsById } from '@repo/loomart-sdk';
import { loomartClient } from './loomart-client';
import { RunTemplateData } from '../types/template-run';
export const runTemplate = async (
templateId: string,
data: RunTemplateData,
identifier: string
) => {
if (!identifier) {
throw new Error('支付凭证缺失,无法创建生成任务');
}
const result = await postApiTemplatesByIdRun({
client: loomartClient,
path: { id: templateId },
body: { data, identifier },
});
return result.data;
};
export const getTemplateGeneration = async (generationId: string) => {
const { data } = await getApiTemplateGenerationsById({
client: loomartClient,
path: { id: generationId },
});
return data;
};
export function pollTemplateGeneration(
generationId: string,
onComplete: (result: any) => void,
onError: (error: Error) => void,
maxAttempts: number = 100,
intervalMs: number = 3000
): () => void {
let attempts = 0;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let isCancelled = false;
const poll = async () => {
if (isCancelled) return;
try {
attempts++;
if (attempts > maxAttempts) {
throw new Error('轮询超时,请稍后重试');
}
const response = await getTemplateGeneration(generationId);
if (!response) {
throw new Error('请稍后重试');
}
const generation = response.data;
if (isCancelled) return;
// 检查是否完成
if (generation.status === 'completed') {
onComplete(generation);
return;
}
// 检查是否失败
if (generation.status === 'failed') {
throw new Error('任务执行失败');
}
// 继续轮询
if (!isCancelled) {
timeoutId = setTimeout(poll, intervalMs);
}
} catch (error) {
if (!isCancelled) {
onError(error as Error);
}
}
};
// 开始轮询
timeoutId = setTimeout(poll, intervalMs);
// 返回取消函数
return () => {
isCancelled = true;
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
};
}

32
lib/api/templates.ts Normal file
View File

@@ -0,0 +1,32 @@
import { getApiTemplates, getApiTemplatesById } from '@repo/loomart-sdk';
import { loomartClient } from './loomart-client';
export interface GetTemplatesParams {
page?: number;
size?: number;
search?: string;
categoryId?: string;
status?: 'AUDITING' | 'RELEASE' | 'EDITING';
}
export const getTemplates = async (params: GetTemplatesParams = {}) => {
const { data } = await getApiTemplates({
client: loomartClient,
query: {
page: params.page || 1,
size: params.size || 10,
search: params.search,
categoryId: params.categoryId,
status: params.status,
},
});
return data;
};
export const getTemplateById = async (id: string) => {
const { data } = await getApiTemplatesById({
client: loomartClient,
path: { id },
});
return data;
};

86
lib/api/transactions.ts Normal file
View File

@@ -0,0 +1,86 @@
import { authClient } from '../auth/client';
export type TransactionKind = 'obtained' | 'consumed';
export interface Transaction {
id: string;
title: string;
happenedAt: string;
amount: number;
kind: TransactionKind;
}
export interface TransactionsResponse {
success: boolean;
data: Transaction[];
message?: string;
}
/**
* 获取用户积分交易记录
* 通过 subscription.meterEvent 获取消费记录,通过订阅历史获取充值记录
*/
export async function getUserTransactions(): Promise<TransactionsResponse> {
try {
const { data: subscriptions, error } = await authClient.subscription.list({});
if (error) {
return {
success: false,
data: [],
message: error.message || '获取交易记录失败',
};
}
const transactions: Transaction[] = [];
// 从订阅中提取 meter events (消费记录)
subscriptions?.forEach((sub: any) => {
if (sub.type === 'metered' && sub.meterEvents) {
sub.meterEvents.forEach((event: any) => {
const value = parseInt(event.payload?.value || '0');
if (value > 0) {
transactions.push({
id: event.id || `event-${Date.now()}-${Math.random()}`,
title: event.event_name === 'token_usage' ? 'Token消费' : '积分消费',
happenedAt: event.createdAt || new Date().toISOString(),
amount: -value,
kind: 'consumed',
});
}
});
}
// 提取充值记录 (从订阅创建或续费事件)
if (sub.createdAt) {
const grantToken = parseInt(sub.plan?.metadata?.grant_token || '0');
if (grantToken > 0) {
transactions.push({
id: `sub-${sub.id}`,
title: `订阅充值 - ${sub.plan?.name || '未知套餐'}`,
happenedAt: sub.createdAt,
amount: grantToken,
kind: 'obtained',
});
}
}
});
// 按时间倒序排序
transactions.sort((a, b) =>
new Date(b.happenedAt).getTime() - new Date(a.happenedAt).getTime()
);
return {
success: true,
data: transactions,
};
} catch (error) {
console.error('Failed to get user transactions:', error);
return {
success: false,
data: [],
message: '网络错误,请稍后重试',
};
}
}

77
lib/api/upload.ts Normal file
View File

@@ -0,0 +1,77 @@
import { postApiFileUploadS3 } from '@repo/loomart-sdk';
import { loomartClient } from './loomart-client';
export interface UploadResponse {
success: boolean;
data?: {
url: string;
filename: string;
size: number;
mimeType: string;
};
message?: string;
}
export async function uploadFile(uri: string, type: 'image' | 'video'): Promise<UploadResponse> {
try {
if (uri.startsWith('https://') || uri.startsWith('http://')) {
return {
success: true,
data: {
url: uri,
filename: uri.split('/').pop() || 'file',
size: 0,
mimeType: type === 'image' ? 'image/jpeg' : 'video/mp4',
},
};
}
const response = await fetch(uri);
const blob = await response.blob();
let mimeType = blob.type || (type === 'image' ? 'image/jpeg' : 'video/mp4');
const extensions: Record<string, string> = {
'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp',
'video/mp4': 'mp4', 'video/webm': 'webm', 'video/avi': 'avi', 'video/quicktime': 'mov',
};
const extension = extensions[mimeType] || (type === 'image' ? 'jpg' : 'mp4');
const filename = `${type}_${Date.now()}.${extension}`;
const file = new File([blob], filename, { type: mimeType });
const result = await postApiFileUploadS3({
client: loomartClient,
body: { file },
});
const data = result.data as any;
if (data?.status === true && data?.data) {
return {
success: true,
data: {
url: data.data,
filename,
size: file.size,
mimeType,
},
};
}
throw new Error(data?.error?.message || data?.msg || 'Upload failed');
} catch (error) {
console.error('Failed to upload file:', error);
return {
success: false,
message: error instanceof Error ? error.message : '上传失败,请稍后重试',
};
}
}
/**
* 批量上传文件
*/
export async function uploadFiles(
files: Array<{ uri: string; type: 'image' | 'video' }>
): Promise<UploadResponse[]> {
const uploadPromises = files.map(file => uploadFile(file.uri, file.type));
return Promise.all(uploadPromises);
}