mirror of
https://gitee.com/wanwujie/sub2api-mobile
synced 2026-04-12 11:04:46 +08:00
feat: refine expo admin mobile flows
This commit is contained in:
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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() })}`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user