feat: bootstrap v2 admin app tabs

This commit is contained in:
xuhongbin
2026-03-07 18:12:39 +08:00
parent c0d1ab377a
commit 28348f76cf
33 changed files with 5536 additions and 151 deletions

View File

@@ -0,0 +1,15 @@
import { Text, View } from 'react-native';
type DetailRowProps = {
label: string;
value: string;
};
export function DetailRow({ label, value }: DetailRowProps) {
return (
<View className="flex-row items-start justify-between gap-4 border-b border-[#eee6d7] py-3 last:border-b-0">
<Text className="text-sm text-[#7d7468]">{label}</Text>
<Text className="max-w-[62%] text-right text-sm font-medium text-[#16181a]">{value}</Text>
</View>
);
}

View File

@@ -0,0 +1,70 @@
import { Text, View } from 'react-native';
import Svg, { Defs, LinearGradient, Path, Stop } from 'react-native-svg';
type Point = {
label: string;
value: number;
};
type LineTrendChartProps = {
points: Point[];
color?: string;
title: string;
subtitle: string;
formatValue?: (value: number) => string;
};
export function LineTrendChart({
points,
color = '#1d5f55',
title,
subtitle,
formatValue = (value) => `${value}`,
}: LineTrendChartProps) {
const width = 320;
const height = 160;
const maxValue = Math.max(...points.map((point) => point.value), 1);
const minValue = Math.min(...points.map((point) => point.value), 0);
const range = Math.max(maxValue - minValue, 1);
const line = points
.map((point, index) => {
const x = (index / Math.max(points.length - 1, 1)) * width;
const y = height - ((point.value - minValue) / range) * (height - 18) - 12;
return `${index === 0 ? 'M' : 'L'} ${x} ${y}`;
})
.join(' ');
const area = `${line} L ${width} ${height} L 0 ${height} Z`;
const latest = points[points.length - 1]?.value ?? 0;
return (
<View className="rounded-[28px] bg-[#fbf8f2] p-5">
<Text className="text-xs uppercase tracking-[1.6px] text-[#7d7468]">{title}</Text>
<Text className="mt-2 text-3xl font-bold text-[#16181a]">{formatValue(latest)}</Text>
<Text className="mt-1 text-sm text-[#7d7468]">{subtitle}</Text>
<View className="mt-5 overflow-hidden rounded-[20px] bg-[#f4efe4] p-3">
<Svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`}>
<Defs>
<LinearGradient id="trendFill" x1="0" x2="0" y1="0" y2="1">
<Stop offset="0%" stopColor={color} stopOpacity="0.28" />
<Stop offset="100%" stopColor={color} stopOpacity="0.02" />
</LinearGradient>
</Defs>
<Path d={area} fill="url(#trendFill)" />
<Path d={line} fill="none" stroke={color} strokeWidth="3" strokeLinejoin="round" strokeLinecap="round" />
</Svg>
<View className="mt-3 flex-row justify-between">
{points.map((point) => (
<Text key={point.label} className="text-xs text-[#7d7468]">
{point.label}
</Text>
))}
</View>
</View>
</View>
);
}

View File

@@ -0,0 +1,33 @@
import type { LucideIcon } from 'lucide-react-native';
import type { ReactNode } from 'react';
import { Text, View } from 'react-native';
type ListCardProps = {
title: string;
meta?: string;
badge?: string;
children?: ReactNode;
icon?: LucideIcon;
};
export function ListCard({ title, meta, badge, children, icon: Icon }: ListCardProps) {
return (
<View className="rounded-[24px] bg-[#fbf8f2] p-4">
<View className="flex-row items-start justify-between gap-3">
<View className="flex-1">
<View className="flex-row items-center gap-2">
{Icon ? <Icon color="#7d7468" size={16} /> : null}
<Text className="text-lg font-semibold text-[#16181a]">{title}</Text>
</View>
{meta ? <Text className="mt-1 text-sm text-[#7d7468]">{meta}</Text> : null}
</View>
{badge ? (
<View className="rounded-full bg-[#e7dfcf] px-3 py-1">
<Text className="text-xs font-semibold uppercase tracking-[1.2px] text-[#5d564d]">{badge}</Text>
</View>
) : null}
</View>
{children ? <View className="mt-4">{children}</View> : null}
</View>
);
}

View File

@@ -0,0 +1,28 @@
import type { PropsWithChildren, ReactNode } from 'react';
import { SafeAreaView } from 'react-native-safe-area-context';
import { ScrollView, Text, View } from 'react-native';
type ScreenShellProps = PropsWithChildren<{
title: string;
subtitle: string;
right?: ReactNode;
}>;
export function ScreenShell({ title, subtitle, right, children }: ScreenShellProps) {
return (
<SafeAreaView className="flex-1 bg-[#f4efe4]">
<ScrollView className="flex-1" contentContainerClassName="px-5 pb-24">
<View className="mt-4 rounded-[24px] border border-[#e6dece] bg-[#fbf8f2] px-4 py-4">
<View className="flex-row items-start justify-between gap-4">
<View className="flex-1">
<Text className="text-[24px] font-bold tracking-tight text-[#16181a]">{title}</Text>
<Text className="mt-1 text-sm leading-6 text-[#7d7468]">{subtitle}</Text>
</View>
{right}
</View>
</View>
<View className="mt-4 gap-4">{children}</View>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,33 @@
import type { LucideIcon } from 'lucide-react-native';
import { TrendingDown, TrendingUp } from 'lucide-react-native';
import { Text, View } from 'react-native';
type StatCardProps = {
label: string;
value: string;
tone?: 'light' | 'dark';
trend?: 'up' | 'down';
icon?: LucideIcon;
};
export function StatCard({ label, value, tone = 'light', trend, icon: Icon }: StatCardProps) {
const dark = tone === 'dark';
const TrendIcon = trend === 'up' ? TrendingUp : trend === 'down' ? TrendingDown : null;
return (
<View className={dark ? 'rounded-[24px] bg-[#1d5f55] p-4' : 'rounded-[24px] bg-[#fbf8f2] p-4'}>
<View className="flex-row items-center justify-between gap-3">
<Text className={dark ? 'text-xs uppercase tracking-[1.5px] text-[#d8efe7]' : 'text-xs uppercase tracking-[1.5px] text-[#7d7468]'}>
{label}
</Text>
<View className="flex-row items-center gap-2">
{TrendIcon ? <TrendIcon color={dark ? '#d8efe7' : '#7d7468'} size={14} /> : null}
{Icon ? <Icon color={dark ? '#d8efe7' : '#7d7468'} size={14} /> : null}
</View>
</View>
<Text className={dark ? 'mt-3 text-3xl font-bold text-white' : 'mt-3 text-3xl font-bold text-[#16181a]'}>
{value}
</Text>
</View>
);
}

2
src/global.css Normal file
View File

@@ -0,0 +1,2 @@
@import 'tailwindcss';
@import 'uniwind';

56
src/lib/admin-fetch.ts Normal file
View File

@@ -0,0 +1,56 @@
import { adminConfigState } from '@/src/store/admin-config';
import type { ApiEnvelope } from '@/src/types/admin';
function isProxyBaseUrl(baseUrl: string) {
return /localhost:8787$/.test(baseUrl) || /127\.0\.0\.1:8787$/.test(baseUrl);
}
export function isLocalProxyBaseUrl(baseUrl: string) {
return isProxyBaseUrl(baseUrl);
}
export async function adminFetch<T>(
path: string,
init: RequestInit = {},
options?: { idempotencyKey?: string }
): Promise<T> {
const baseUrl = adminConfigState.baseUrl.trim().replace(/\/$/, '');
const adminApiKey = adminConfigState.adminApiKey.trim();
if (!baseUrl) {
throw new Error('BASE_URL_REQUIRED');
}
if (!adminApiKey && !isProxyBaseUrl(baseUrl)) {
throw new Error('ADMIN_API_KEY_REQUIRED');
}
const headers = new Headers(init.headers);
headers.set('Content-Type', 'application/json');
if (adminApiKey) {
headers.set('x-api-key', adminApiKey);
}
if (options?.idempotencyKey) {
headers.set('Idempotency-Key', options.idempotencyKey);
}
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers,
});
let json: ApiEnvelope<T>;
try {
json = (await response.json()) as ApiEnvelope<T>;
} catch {
throw new Error('INVALID_SERVER_RESPONSE');
}
if (!response.ok || json.code !== 0) {
throw new Error(json.reason || json.message || 'REQUEST_FAILED');
}
return json.data as T;
}

11
src/lib/query-client.ts Normal file
View File

@@ -0,0 +1,11 @@
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30_000,
refetchOnWindowFocus: false,
},
},
});

129
src/services/admin.ts Normal file
View File

@@ -0,0 +1,129 @@
import { adminFetch } from '@/src/lib/admin-fetch';
import type {
AccountTodayStats,
AdminAccount,
AdminApiKey,
AdminGroup,
AdminSettings,
AdminUser,
BalanceOperation,
DashboardModelStats,
DashboardStats,
DashboardTrend,
PaginatedData,
UserUsageSummary,
} from '@/src/types/admin';
function buildQuery(params: Record<string, string | number | boolean | undefined>) {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
query.set(key, String(value));
}
});
const value = query.toString();
return value ? `?${value}` : '';
}
export function getDashboardStats() {
return adminFetch<DashboardStats>('/api/v1/admin/dashboard/stats');
}
export function getAdminSettings() {
return adminFetch<AdminSettings>('/api/v1/admin/settings');
}
export function getDashboardTrend(params: {
start_date: string;
end_date: string;
granularity?: 'day' | 'hour';
account_id?: number;
group_id?: number;
user_id?: number;
}) {
return adminFetch<DashboardTrend>(`/api/v1/admin/dashboard/trend${buildQuery(params)}`);
}
export function getDashboardModels(params: { start_date: string; end_date: string }) {
return adminFetch<DashboardModelStats>(`/api/v1/admin/dashboard/models${buildQuery(params)}`);
}
export function listUsers(search = '') {
return adminFetch<PaginatedData<AdminUser>>(
`/api/v1/admin/users${buildQuery({ page: 1, page_size: 20, search: search.trim() })}`
);
}
export function getUser(userId: number) {
return adminFetch<AdminUser>(`/api/v1/admin/users/${userId}`);
}
export function getUserUsage(userId: number, period: 'day' | 'week' | 'month' = 'month') {
return adminFetch<UserUsageSummary>(`/api/v1/admin/users/${userId}/usage${buildQuery({ period })}`);
}
export function listUserApiKeys(userId: number) {
return adminFetch<PaginatedData<AdminApiKey>>(`/api/v1/admin/users/${userId}/api-keys${buildQuery({ page: 1, page_size: 100 })}`);
}
export function updateUserBalance(
userId: number,
body: { balance: number; operation: BalanceOperation; notes?: string }
) {
return adminFetch<AdminUser>(
`/api/v1/admin/users/${userId}/balance`,
{
method: 'POST',
body: JSON.stringify(body),
},
{
idempotencyKey: `user-balance-${userId}-${Date.now()}`,
}
);
}
export function listGroups(search = '') {
return adminFetch<PaginatedData<AdminGroup>>(
`/api/v1/admin/groups${buildQuery({ page: 1, page_size: 20, search: search.trim() })}`
);
}
export function getGroup(groupId: number) {
return adminFetch<AdminGroup>(`/api/v1/admin/groups/${groupId}`);
}
export function listAccounts(search = '') {
return adminFetch<PaginatedData<AdminAccount>>(
`/api/v1/admin/accounts${buildQuery({ page: 1, page_size: 20, search: search.trim() })}`
);
}
export function getAccount(accountId: number) {
return adminFetch<AdminAccount>(`/api/v1/admin/accounts/${accountId}`);
}
export function getAccountTodayStats(accountId: number) {
return adminFetch<AccountTodayStats>(`/api/v1/admin/accounts/${accountId}/today-stats`);
}
export function testAccount(accountId: number) {
return adminFetch(`/api/v1/admin/accounts/${accountId}/test`, {
method: 'POST',
});
}
export function refreshAccount(accountId: number) {
return adminFetch(`/api/v1/admin/accounts/${accountId}/refresh`, {
method: 'POST',
});
}
export function setAccountSchedulable(accountId: number, schedulable: boolean) {
return adminFetch<AdminAccount>(`/api/v1/admin/accounts/${accountId}/schedulable`, {
method: 'POST',
body: JSON.stringify({ schedulable }),
});
}

71
src/store/admin-config.ts Normal file
View File

@@ -0,0 +1,71 @@
import * as SecureStore from 'expo-secure-store';
const { proxy } = require('valtio');
const BASE_URL_KEY = 'sub2api_base_url';
const ADMIN_KEY_KEY = 'sub2api_admin_api_key';
export function getDefaultAdminConfig() {
return {
baseUrl: '',
adminApiKey: '',
};
}
async function getItem(key: string) {
if (typeof window !== 'undefined') {
if (typeof localStorage === 'undefined') {
return null;
}
return localStorage.getItem(key);
}
return SecureStore.getItemAsync(key);
}
async function setItem(key: string, value: string) {
if (typeof window !== 'undefined') {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(key, value);
}
return;
}
await SecureStore.setItemAsync(key, value);
}
export const adminConfigState = proxy({
...getDefaultAdminConfig(),
hydrated: false,
saving: false,
});
export async function hydrateAdminConfig() {
const defaults = getDefaultAdminConfig();
const [baseUrl, adminApiKey] = await Promise.all([
getItem(BASE_URL_KEY),
getItem(ADMIN_KEY_KEY),
]);
adminConfigState.baseUrl = baseUrl ?? defaults.baseUrl;
adminConfigState.adminApiKey = adminApiKey ?? defaults.adminApiKey;
adminConfigState.hydrated = true;
}
export async function saveAdminConfig(input: { baseUrl: string; adminApiKey: string }) {
adminConfigState.saving = true;
const nextBaseUrl = input.baseUrl.trim().replace(/\/$/, '');
const nextAdminApiKey = input.adminApiKey.trim();
await Promise.all([
setItem(BASE_URL_KEY, nextBaseUrl),
setItem(ADMIN_KEY_KEY, nextAdminApiKey),
]);
adminConfigState.baseUrl = nextBaseUrl;
adminConfigState.adminApiKey = nextAdminApiKey;
adminConfigState.saving = false;
}

174
src/types/admin.ts Normal file
View File

@@ -0,0 +1,174 @@
export type ApiEnvelope<T> = {
code: number;
message: string;
reason?: string;
metadata?: Record<string, string>;
data?: T;
};
export type PaginatedData<T> = {
items: T[];
total: number;
page: number;
page_size: number;
pages: number;
};
export type DashboardStats = {
total_users: number;
today_new_users: number;
active_users: number;
total_api_keys: number;
active_api_keys: number;
total_accounts: number;
normal_accounts: number;
error_accounts: number;
total_requests: number;
total_cost: number;
total_tokens: number;
today_requests: number;
today_cost: number;
today_tokens: number;
today_input_tokens?: number;
today_output_tokens?: number;
today_cache_read_tokens?: number;
rpm: number;
tpm: number;
};
export type TrendPoint = {
date: string;
requests: number;
input_tokens: number;
output_tokens: number;
cache_creation_tokens: number;
cache_read_tokens: number;
total_tokens: number;
cost: number;
actual_cost: number;
};
export type DashboardTrend = {
start_date: string;
end_date: string;
granularity: 'day' | 'hour' | string;
trend: TrendPoint[];
};
export type ModelStat = {
model: string;
requests: number;
input_tokens: number;
output_tokens: number;
cache_creation_tokens: number;
cache_read_tokens: number;
total_tokens: number;
cost: number;
actual_cost: number;
};
export type DashboardModelStats = {
start_date: string;
end_date: string;
models: ModelStat[];
};
export type AdminSettings = {
site_name?: string;
[key: string]: string | number | boolean | null | string[] | undefined;
};
export type AdminUser = {
id: number;
email: string;
username?: string | null;
balance?: number;
concurrency?: number;
status?: string;
role?: string;
current_concurrency?: number;
notes?: string | null;
created_at?: string;
updated_at?: string;
};
export type UserUsageSummary = {
total_requests?: number;
total_tokens?: number;
total_cost?: number;
requests?: number;
tokens?: number;
cost?: number;
[key: string]: string | number | boolean | null | undefined;
};
export type AdminApiKey = {
id: number;
user_id: number;
key: string;
name: string;
group_id?: number | null;
status: string;
quota: number;
quota_used: number;
last_used_at?: string | null;
expires_at?: string | null;
created_at?: string;
updated_at?: string;
usage_5h?: number;
usage_1d?: number;
usage_7d?: number;
group?: AdminGroup;
user?: {
id: number;
email?: string;
username?: string | null;
};
};
export type BalanceOperation = 'set' | 'add' | 'subtract';
export type AdminGroup = {
id: number;
name: string;
description?: string | null;
platform: string;
rate_multiplier?: number;
is_exclusive?: boolean;
status?: string;
subscription_type?: string;
daily_limit_usd?: number | null;
weekly_limit_usd?: number | null;
monthly_limit_usd?: number | null;
account_count?: number;
sort_order?: number;
created_at?: string;
updated_at?: string;
};
export type AccountTodayStats = {
requests: number;
tokens: number;
cost: number;
standard_cost?: number;
user_cost?: number;
};
export type AdminAccount = {
id: number;
name: string;
platform: string;
type: string;
status?: string;
schedulable?: boolean;
priority?: number;
concurrency?: number;
current_concurrency?: number;
rate_multiplier?: number;
error_message?: string;
updated_at?: string;
last_used_at?: string | null;
group_ids?: number[];
groups?: AdminGroup[];
extra?: Record<string, string | number | boolean | null>;
};

9
src/uniwind-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import 'react-native';
import 'react-native-safe-area-context';
import 'uniwind/types';
declare module 'react-native-safe-area-context' {
interface NativeSafeAreaViewProps {
className?: string;
}
}

10
src/uniwind-types.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
// NOTE: This file is generated by uniwind and it should not be edited manually.
/// <reference types="uniwind/types" />
declare module 'uniwind' {
export interface UniwindConfig {
themes: readonly ['light', 'dark']
}
}
export {}