feat: refine expo admin mobile flows

This commit is contained in:
xuhongbin
2026-03-08 20:53:15 +08:00
parent c70ca1641a
commit 434bbf258a
21 changed files with 3128 additions and 6381 deletions

View File

@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { Text, View } from 'react-native';
import Svg, { Defs, LinearGradient, Path, Stop } from 'react-native-svg';
@@ -28,6 +29,10 @@ export function LineTrendChart({
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 gradientId = useMemo(
() => `trendFill-${title.replace(/[^a-zA-Z0-9_-]/g, '')}-${compact ? 'compact' : 'full'}`,
[compact, title]
);
const line = points
.map((point, index) => {
@@ -53,18 +58,18 @@ export function LineTrendChart({
<View className={`overflow-hidden rounded-[14px] bg-[#f4efe4] p-3 ${compact ? 'mt-3' : 'mt-4'}`}>
<Svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`}>
<Defs>
<LinearGradient id="trendFill" x1="0" x2="0" y1="0" y2="1">
<LinearGradient id={gradientId} 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={area} fill={`url(#${gradientId})`} />
<Path d={line} fill="none" stroke={color} strokeWidth="3" strokeLinejoin="round" strokeLinecap="round" />
</Svg>
<View className="mt-2 flex-row justify-between">
{tickPoints.map((point) => (
<Text key={point.label} className={`text-[#7d7468] ${compact ? 'text-[10px]' : 'text-xs'}`}>
{tickPoints.map((point, index) => (
<Text key={`${point.label}-${index}`} className={`text-[#7d7468] ${compact ? 'text-[10px]' : 'text-xs'}`}>
{point.label}
</Text>
))}

View File

@@ -1,6 +1,6 @@
import type { PropsWithChildren, ReactNode } from 'react';
import { SafeAreaView } from 'react-native-safe-area-context';
import { ScrollView, Text, View } from 'react-native';
import { RefreshControl, ScrollView, Text, View } from 'react-native';
type ScreenShellProps = PropsWithChildren<{
title: string;
@@ -12,6 +12,8 @@ type ScreenShellProps = PropsWithChildren<{
bottomInsetClassName?: string;
horizontalInsetClassName?: string;
contentGapClassName?: string;
refreshing?: boolean;
onRefresh?: () => void | Promise<void>;
}>;
function ScreenHeader({
@@ -66,6 +68,8 @@ export function ScreenShell({
bottomInsetClassName = 'pb-24',
horizontalInsetClassName = 'px-5',
contentGapClassName = 'mt-4 gap-4',
refreshing = false,
onRefresh,
}: ScreenShellProps) {
if (!scroll) {
return (
@@ -80,9 +84,15 @@ export function ScreenShell({
return (
<SafeAreaView className="flex-1 bg-[#f4efe4]">
<ScrollView className="flex-1" contentContainerClassName={`${horizontalInsetClassName} ${bottomInsetClassName}`}>
<ScreenHeader title={title} subtitle={subtitle} titleAside={titleAside} right={right} variant={variant} />
<View className={contentGapClassName}>{children}</View>
<ScrollView
className="flex-1"
showsVerticalScrollIndicator={false}
refreshControl={onRefresh ? <RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor="#1d5f55" /> : undefined}
>
<View className={`${horizontalInsetClassName} ${bottomInsetClassName}`}>
<ScreenHeader title={title} subtitle={subtitle} titleAside={titleAside} right={right} variant={variant} />
<View className={contentGapClassName}>{children}</View>
</View>
</ScrollView>
</SafeAreaView>
);

View File

@@ -1,14 +1,6 @@
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 = {},
@@ -21,7 +13,7 @@ export async function adminFetch<T>(
throw new Error('BASE_URL_REQUIRED');
}
if (!adminApiKey && !isProxyBaseUrl(baseUrl)) {
if (!adminApiKey) {
throw new Error('ADMIN_API_KEY_REQUIRED');
}

View File

@@ -8,13 +8,15 @@ import type {
AdminUser,
BalanceOperation,
DashboardModelStats,
DashboardSnapshot,
DashboardStats,
DashboardTrend,
PaginatedData,
UsageStats,
UserUsageSummary,
} from '@/src/types/admin';
function buildQuery(params: Record<string, string | number | boolean | undefined>) {
function buildQuery(params: Record<string, string | number | boolean | null | undefined>) {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
@@ -51,6 +53,38 @@ export function getDashboardModels(params: { start_date: string; end_date: strin
return adminFetch<DashboardModelStats>(`/api/v1/admin/dashboard/models${buildQuery(params)}`);
}
export function getDashboardSnapshot(params: {
start_date: string;
end_date: string;
granularity?: 'day' | 'hour';
account_id?: number;
user_id?: number;
group_id?: number;
model?: string;
request_type?: string;
billing_type?: string | null;
include_stats?: boolean;
include_trend?: boolean;
include_model_stats?: boolean;
include_group_stats?: boolean;
include_users_trend?: boolean;
}) {
return adminFetch<DashboardSnapshot>(`/api/v1/admin/dashboard/snapshot-v2${buildQuery(params)}`);
}
export function getUsageStats(params: {
start_date: string;
end_date: string;
user_id?: number;
account_id?: number;
group_id?: number;
model?: string;
request_type?: string;
billing_type?: string | null;
}) {
return adminFetch<UsageStats>(`/api/v1/admin/usage/stats${buildQuery(params)}`);
}
export function listUsers(search = '') {
return adminFetch<PaginatedData<AdminUser>>(
`/api/v1/admin/users${buildQuery({ page: 1, page_size: 20, search: search.trim() })}`

View File

@@ -1,4 +1,5 @@
import * as SecureStore from 'expo-secure-store';
import { Platform } from 'react-native';
const { proxy } = require('valtio');
const BASE_URL_KEY = 'sub2api_base_url';
@@ -67,39 +68,51 @@ export function getDefaultAdminConfig() {
}
async function getItem(key: string) {
if (typeof window !== 'undefined') {
if (typeof localStorage === 'undefined') {
return null;
try {
if (Platform.OS === 'web') {
if (typeof localStorage === 'undefined') {
return null;
}
return localStorage.getItem(key);
}
return localStorage.getItem(key);
return await SecureStore.getItemAsync(key);
} catch {
return null;
}
return SecureStore.getItemAsync(key);
}
async function setItem(key: string, value: string) {
if (typeof window !== 'undefined') {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(key, value);
try {
if (Platform.OS === 'web') {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(key, value);
}
return;
}
await SecureStore.setItemAsync(key, value);
} catch {
return;
}
await SecureStore.setItemAsync(key, value);
}
async function deleteItem(key: string) {
if (typeof window !== 'undefined') {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(key);
try {
if (Platform.OS === 'web') {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(key);
}
return;
}
await SecureStore.deleteItemAsync(key);
} catch {
return;
}
await SecureStore.deleteItemAsync(key);
}
export const adminConfigState = proxy({
@@ -112,98 +125,104 @@ export const adminConfigState = proxy({
export async function hydrateAdminConfig() {
const defaults = getDefaultAdminConfig();
const [baseUrl, adminApiKey, rawAccounts, activeAccountId] = await Promise.all([
getItem(BASE_URL_KEY),
getItem(ADMIN_KEY_KEY),
getItem(ACCOUNTS_KEY),
getItem(ACTIVE_ACCOUNT_ID_KEY),
]);
let accounts: AdminAccountProfile[] = [];
try {
const [baseUrl, adminApiKey, rawAccounts, activeAccountId] = await Promise.all([
getItem(BASE_URL_KEY),
getItem(ADMIN_KEY_KEY),
getItem(ACCOUNTS_KEY),
getItem(ACTIVE_ACCOUNT_ID_KEY),
]);
if (rawAccounts) {
try {
const parsed = JSON.parse(rawAccounts) as AdminAccountProfile[];
accounts = Array.isArray(parsed) ? parsed.map((account) => normalizeAccount(account)) : [];
} catch {
accounts = [];
let accounts: AdminAccountProfile[] = [];
if (rawAccounts) {
try {
const parsed = JSON.parse(rawAccounts) as AdminAccountProfile[];
accounts = Array.isArray(parsed) ? parsed.map((account) => normalizeAccount(account)) : [];
} catch {
accounts = [];
}
}
if (accounts.length === 0 && baseUrl) {
const legacyConfig = normalizeConfig({
baseUrl,
adminApiKey: adminApiKey ?? defaults.adminApiKey,
});
accounts = [
{
id: createAccountId(),
label: getAccountLabel(legacyConfig.baseUrl),
...legacyConfig,
updatedAt: new Date().toISOString(),
enabled: true,
},
];
}
const sortedAccounts = sortAccounts(accounts);
const activeAccount = getNextActiveAccount(sortedAccounts, activeAccountId ?? undefined);
const nextActiveAccountId = activeAccount?.id || '';
adminConfigState.accounts = sortedAccounts;
adminConfigState.activeAccountId = nextActiveAccountId;
adminConfigState.baseUrl = activeAccount?.baseUrl ?? defaults.baseUrl;
adminConfigState.adminApiKey = activeAccount?.adminApiKey ?? defaults.adminApiKey;
await Promise.all([
setItem(ACCOUNTS_KEY, JSON.stringify(sortedAccounts)),
nextActiveAccountId ? setItem(ACTIVE_ACCOUNT_ID_KEY, nextActiveAccountId) : deleteItem(ACTIVE_ACCOUNT_ID_KEY),
setItem(BASE_URL_KEY, activeAccount?.baseUrl ?? defaults.baseUrl),
setItem(ADMIN_KEY_KEY, activeAccount?.adminApiKey ?? defaults.adminApiKey),
]);
} finally {
adminConfigState.hydrated = true;
}
if (accounts.length === 0 && baseUrl) {
const legacyConfig = normalizeConfig({
baseUrl,
adminApiKey: adminApiKey ?? defaults.adminApiKey,
});
accounts = [
{
id: createAccountId(),
label: getAccountLabel(legacyConfig.baseUrl),
...legacyConfig,
updatedAt: new Date().toISOString(),
enabled: true,
},
];
}
const sortedAccounts = sortAccounts(accounts);
const activeAccount = getNextActiveAccount(sortedAccounts, activeAccountId ?? undefined);
const nextActiveAccountId = activeAccount?.id || '';
adminConfigState.accounts = sortedAccounts;
adminConfigState.activeAccountId = nextActiveAccountId;
adminConfigState.baseUrl = activeAccount?.baseUrl ?? defaults.baseUrl;
adminConfigState.adminApiKey = activeAccount?.adminApiKey ?? defaults.adminApiKey;
adminConfigState.hydrated = true;
await Promise.all([
setItem(ACCOUNTS_KEY, JSON.stringify(sortedAccounts)),
nextActiveAccountId ? setItem(ACTIVE_ACCOUNT_ID_KEY, nextActiveAccountId) : deleteItem(ACTIVE_ACCOUNT_ID_KEY),
setItem(BASE_URL_KEY, activeAccount?.baseUrl ?? defaults.baseUrl),
setItem(ADMIN_KEY_KEY, activeAccount?.adminApiKey ?? defaults.adminApiKey),
]);
}
export async function saveAdminConfig(input: { baseUrl: string; adminApiKey: string }) {
adminConfigState.saving = true;
const normalized = normalizeConfig(input);
const nextUpdatedAt = new Date().toISOString();
const existingAccount = adminConfigState.accounts.find(
(account: AdminAccountProfile) => account.baseUrl === normalized.baseUrl && account.adminApiKey === normalized.adminApiKey
);
const nextAccount: AdminAccountProfile = existingAccount
? {
...existingAccount,
label: getAccountLabel(normalized.baseUrl),
updatedAt: nextUpdatedAt,
}
: {
id: createAccountId(),
label: getAccountLabel(normalized.baseUrl),
...normalized,
updatedAt: nextUpdatedAt,
enabled: true,
};
const nextAccounts = sortAccounts([
nextAccount,
...adminConfigState.accounts.filter((account: AdminAccountProfile) => account.id !== nextAccount.id),
]);
try {
const normalized = normalizeConfig(input);
const nextUpdatedAt = new Date().toISOString();
const existingAccount = adminConfigState.accounts.find(
(account: AdminAccountProfile) => account.baseUrl === normalized.baseUrl && account.adminApiKey === normalized.adminApiKey
);
const nextAccount: AdminAccountProfile = existingAccount
? {
...existingAccount,
label: getAccountLabel(normalized.baseUrl),
updatedAt: nextUpdatedAt,
}
: {
id: createAccountId(),
label: getAccountLabel(normalized.baseUrl),
...normalized,
updatedAt: nextUpdatedAt,
enabled: true,
};
const nextAccounts = sortAccounts([
nextAccount,
...adminConfigState.accounts.filter((account: AdminAccountProfile) => account.id !== nextAccount.id),
]);
await Promise.all([
setItem(BASE_URL_KEY, normalized.baseUrl),
setItem(ADMIN_KEY_KEY, normalized.adminApiKey),
setItem(ACCOUNTS_KEY, JSON.stringify(nextAccounts)),
setItem(ACTIVE_ACCOUNT_ID_KEY, nextAccount.id),
]);
await Promise.all([
setItem(BASE_URL_KEY, normalized.baseUrl),
setItem(ADMIN_KEY_KEY, normalized.adminApiKey),
setItem(ACCOUNTS_KEY, JSON.stringify(nextAccounts)),
setItem(ACTIVE_ACCOUNT_ID_KEY, nextAccount.id),
]);
adminConfigState.accounts = nextAccounts;
adminConfigState.activeAccountId = nextAccount.id;
adminConfigState.baseUrl = normalized.baseUrl;
adminConfigState.adminApiKey = normalized.adminApiKey;
adminConfigState.saving = false;
adminConfigState.accounts = nextAccounts;
adminConfigState.activeAccountId = nextAccount.id;
adminConfigState.baseUrl = normalized.baseUrl;
adminConfigState.adminApiKey = normalized.adminApiKey;
} finally {
adminConfigState.saving = false;
}
}
export async function switchAdminAccount(accountId: string) {

View File

@@ -73,6 +73,30 @@ export type DashboardModelStats = {
models: ModelStat[];
};
export type UsageStats = {
total_requests?: number;
total_tokens?: number;
total_input_tokens?: number;
total_output_tokens?: number;
total_cost?: number;
total_actual_cost?: number;
total_account_cost?: number;
average_duration_ms?: number;
};
export type DashboardSnapshot = {
trend?: TrendPoint[];
models?: ModelStat[];
groups?: Array<{
group_id?: number;
group_name?: string;
requests?: number;
total_tokens?: number;
total_cost?: number;
total_actual_cost?: number;
}>;
};
export type AdminSettings = {
site_name?: string;
[key: string]: string | number | boolean | null | string[] | undefined;
@@ -88,6 +112,7 @@ export type AdminUser = {
role?: string;
current_concurrency?: number;
notes?: string | null;
last_used_at?: string | null;
created_at?: string;
updated_at?: string;
};