feat(v0.11.3): F421-F425 frontend admin platform dashboard and routes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ec2792118f
commit
c92e3e8799
8 changed files with 629 additions and 0 deletions
454
apps/web/src/components/admin/AdminPlatformView.tsx
Normal file
454
apps/web/src/components/admin/AdminPlatformView.tsx
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Card } from '../ui/card';
|
||||
import { Button } from '../ui/button';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { EmptyState } from '../ui/empty-state';
|
||||
import {
|
||||
BarChart3,
|
||||
Users,
|
||||
Music,
|
||||
CreditCard,
|
||||
FileText,
|
||||
Search,
|
||||
Ban,
|
||||
CheckCircle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Loader2,
|
||||
Shield,
|
||||
} from 'lucide-react';
|
||||
import { useToast } from '../../components/feedback/ToastProvider';
|
||||
import {
|
||||
adminService,
|
||||
PlatformMetrics,
|
||||
AdminUserInfo,
|
||||
AdminContentItem,
|
||||
PaymentOverview,
|
||||
} from '../../services/adminService';
|
||||
import { logger } from '@/utils/logger';
|
||||
|
||||
type PlatformTab = 'metrics' | 'users' | 'content' | 'payments';
|
||||
|
||||
export const AdminPlatformView: React.FC = () => {
|
||||
const { addToast } = useToast();
|
||||
const [activeTab, setActiveTab] = useState<PlatformTab>('metrics');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Metrics
|
||||
const [metrics, setMetrics] = useState<PlatformMetrics | null>(null);
|
||||
|
||||
// Users
|
||||
const [users, setUsers] = useState<AdminUserInfo[]>([]);
|
||||
const [usersTotal, setUsersTotal] = useState(0);
|
||||
const [userSearch, setUserSearch] = useState('');
|
||||
const [userRoleFilter, setUserRoleFilter] = useState('');
|
||||
|
||||
// Content
|
||||
const [content, setContent] = useState<AdminContentItem[]>([]);
|
||||
const [contentTotal, setContentTotal] = useState(0);
|
||||
const [contentSearch, setContentSearch] = useState('');
|
||||
const [contentType, setContentType] = useState('track');
|
||||
|
||||
// Payments
|
||||
const [payments, setPayments] = useState<PaymentOverview | null>(null);
|
||||
|
||||
const loadMetrics = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await adminService.getPlatformMetrics();
|
||||
setMetrics(data);
|
||||
} catch (e) {
|
||||
logger.error('Error loading platform metrics', { error: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await adminService.searchUsers({ q: userSearch || undefined, role: userRoleFilter || undefined });
|
||||
setUsers(data.users);
|
||||
setUsersTotal(data.total);
|
||||
} catch (e) {
|
||||
logger.error('Error loading users', { error: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userSearch, userRoleFilter]);
|
||||
|
||||
const loadContent = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await adminService.searchContent({ type: contentType, q: contentSearch || undefined });
|
||||
setContent(data.content);
|
||||
setContentTotal(data.total);
|
||||
} catch (e) {
|
||||
logger.error('Error loading content', { error: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [contentType, contentSearch]);
|
||||
|
||||
const loadPayments = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await adminService.getPaymentOverview();
|
||||
setPayments(data);
|
||||
} catch (e) {
|
||||
logger.error('Error loading payments', { error: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
switch (activeTab) {
|
||||
case 'metrics': loadMetrics(); break;
|
||||
case 'users': loadUsers(); break;
|
||||
case 'content': loadContent(); break;
|
||||
case 'payments': loadPayments(); break;
|
||||
}
|
||||
}, [activeTab, loadMetrics, loadUsers, loadContent, loadPayments]);
|
||||
|
||||
const handleSuspendUser = async (userId: string) => {
|
||||
const reason = prompt('Suspension reason:');
|
||||
if (!reason) return;
|
||||
try {
|
||||
await adminService.suspendUser(userId, reason);
|
||||
addToast('User suspended', 'success');
|
||||
loadUsers();
|
||||
} catch { addToast('Suspension failed', 'error'); }
|
||||
};
|
||||
|
||||
const handleUnsuspendUser = async (userId: string) => {
|
||||
try {
|
||||
await adminService.unsuspendUser(userId);
|
||||
addToast('User unsuspended', 'success');
|
||||
loadUsers();
|
||||
} catch { addToast('Unsuspension failed', 'error'); }
|
||||
};
|
||||
|
||||
const handleHideContent = async (id: string, type: string) => {
|
||||
try {
|
||||
await adminService.hideContent(id, type, 'Admin action');
|
||||
addToast('Content hidden', 'success');
|
||||
loadContent();
|
||||
} catch { addToast('Hide failed', 'error'); }
|
||||
};
|
||||
|
||||
const handleRestoreContent = async (id: string, type: string) => {
|
||||
try {
|
||||
await adminService.restoreContent(id, type);
|
||||
addToast('Content restored', 'success');
|
||||
loadContent();
|
||||
} catch { addToast('Restore failed', 'error'); }
|
||||
};
|
||||
|
||||
const tabs: { key: PlatformTab; label: string; icon: React.ReactNode }[] = [
|
||||
{ key: 'metrics', label: 'Dashboard', icon: <BarChart3 className="w-4 h-4" /> },
|
||||
{ key: 'users', label: 'Users', icon: <Users className="w-4 h-4" /> },
|
||||
{ key: 'content', label: 'Content', icon: <Music className="w-4 h-4" /> },
|
||||
{ key: 'payments', label: 'Payments', icon: <CreditCard className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn pb-20">
|
||||
<div className="flex items-center gap-3 border-b border-border/50 pb-6">
|
||||
<Shield className="w-6 h-6 text-primary" />
|
||||
<div>
|
||||
<h2 className="text-2xl font-heading font-bold text-foreground tracking-tight">
|
||||
PLATFORM ADMINISTRATION
|
||||
</h2>
|
||||
<p className="text-muted-foreground font-mono text-sm">
|
||||
Metrics, users, content, and payments management.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-border flex gap-6 mb-6">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`pb-3 text-sm font-bold uppercase tracking-wider border-b-2 transition-colors flex items-center gap-2 ${
|
||||
activeTab === tab.key
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
aria-label={`${tab.label} tab`}
|
||||
aria-selected={activeTab === tab.key}
|
||||
role="tab"
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center py-24">
|
||||
<Loader2 className="w-8 h-8 text-muted-foreground animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && activeTab === 'metrics' && metrics && <MetricsPanel metrics={metrics} />}
|
||||
{!loading && activeTab === 'users' && (
|
||||
<UsersPanel
|
||||
users={users}
|
||||
total={usersTotal}
|
||||
search={userSearch}
|
||||
roleFilter={userRoleFilter}
|
||||
onSearchChange={setUserSearch}
|
||||
onRoleFilterChange={setUserRoleFilter}
|
||||
onSearch={loadUsers}
|
||||
onSuspend={handleSuspendUser}
|
||||
onUnsuspend={handleUnsuspendUser}
|
||||
/>
|
||||
)}
|
||||
{!loading && activeTab === 'content' && (
|
||||
<ContentPanel
|
||||
content={content}
|
||||
total={contentTotal}
|
||||
search={contentSearch}
|
||||
contentType={contentType}
|
||||
onSearchChange={setContentSearch}
|
||||
onTypeChange={setContentType}
|
||||
onSearch={loadContent}
|
||||
onHide={handleHideContent}
|
||||
onRestore={handleRestoreContent}
|
||||
/>
|
||||
)}
|
||||
{!loading && activeTab === 'payments' && payments && <PaymentsPanel payments={payments} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function MetricsPanel({ metrics }: { metrics: PlatformMetrics }) {
|
||||
const cards = [
|
||||
{ label: 'Total Users', value: metrics.total_users.toLocaleString(), color: 'text-blue-400', icon: <Users className="w-5 h-5" /> },
|
||||
{ label: 'Active (30d)', value: metrics.active_users.toLocaleString(), color: 'text-green-400', icon: <CheckCircle className="w-5 h-5" /> },
|
||||
{ label: 'New Today', value: metrics.new_users_today.toLocaleString(), color: 'text-cyan-400', icon: <Users className="w-5 h-5" /> },
|
||||
{ label: 'New This Week', value: metrics.new_users_week.toLocaleString(), color: 'text-cyan-400', icon: <Users className="w-5 h-5" /> },
|
||||
{ label: 'Total Tracks', value: metrics.total_tracks.toLocaleString(), color: 'text-purple-400', icon: <Music className="w-5 h-5" /> },
|
||||
{ label: 'Tracks Today', value: metrics.tracks_today.toLocaleString(), color: 'text-purple-400', icon: <Music className="w-5 h-5" /> },
|
||||
{ label: 'Playlists', value: metrics.total_playlists.toLocaleString(), color: 'text-orange-400', icon: <FileText className="w-5 h-5" /> },
|
||||
{ label: 'Comments', value: metrics.total_comments.toLocaleString(), color: 'text-orange-400', icon: <FileText className="w-5 h-5" /> },
|
||||
{ label: 'Banned Users', value: metrics.banned_users.toLocaleString(), color: 'text-red-400', icon: <Ban className="w-5 h-5" /> },
|
||||
{ label: 'Pending Reports', value: metrics.pending_reports.toLocaleString(), color: 'text-red-400', icon: <FileText className="w-5 h-5" /> },
|
||||
{ label: 'Storage', value: `${(metrics.storage_used_mb / 1024).toFixed(1)} GB`, color: 'text-yellow-400', icon: <BarChart3 className="w-5 h-5" /> },
|
||||
{ label: 'Total Revenue', value: `$${metrics.total_revenue.toFixed(2)}`, color: 'text-green-400', icon: <CreditCard className="w-5 h-5" /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{cards.map((card) => (
|
||||
<Card key={card.label} variant="default" className="p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className={card.color}>{card.icon}</div>
|
||||
<span className="text-xs text-muted-foreground uppercase">{card.label}</span>
|
||||
</div>
|
||||
<div className={`text-2xl font-bold ${card.color}`}>{card.value}</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersPanel({
|
||||
users, total, search, roleFilter, onSearchChange, onRoleFilterChange, onSearch, onSuspend, onUnsuspend,
|
||||
}: {
|
||||
users: AdminUserInfo[];
|
||||
total: number;
|
||||
search: string;
|
||||
roleFilter: string;
|
||||
onSearchChange: (v: string) => void;
|
||||
onRoleFilterChange: (v: string) => void;
|
||||
onSearch: () => void;
|
||||
onSuspend: (userId: string) => void;
|
||||
onUnsuspend: (userId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<div className="relative flex-1 min-w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search username or email..."
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onSearch()}
|
||||
className="w-full bg-muted border border-border rounded pl-10 pr-3 py-2 text-sm text-foreground"
|
||||
aria-label="Search users"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={roleFilter}
|
||||
onChange={(e) => { onRoleFilterChange(e.target.value); }}
|
||||
className="bg-muted border border-border rounded px-2 py-2 text-sm text-foreground"
|
||||
aria-label="Filter by role"
|
||||
>
|
||||
<option value="">All Roles</option>
|
||||
<option value="user">User</option>
|
||||
<option value="creator">Creator</option>
|
||||
<option value="artist">Artist</option>
|
||||
<option value="premium">Premium</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<Button variant="secondary" size="sm" icon={<Search className="w-4 h-4" />} onClick={onSearch}>
|
||||
Search
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground ml-auto">{total} users</span>
|
||||
</div>
|
||||
|
||||
{users.length === 0 && (
|
||||
<EmptyState icon={<Users className="w-full h-full" />} title="No users found" description="Try adjusting your search." />
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{users.map((user) => (
|
||||
<Card key={user.id} variant="default" className="p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="font-bold text-foreground">{user.username}</span>
|
||||
<Badge label={user.role} variant="terminal" />
|
||||
{user.is_banned && <span className="text-xs bg-red-500/20 text-red-400 px-2 py-0.5 rounded">BANNED</span>}
|
||||
{user.is_suspended && <span className="text-xs bg-orange-500/20 text-orange-400 px-2 py-0.5 rounded">SUSPENDED</span>}
|
||||
{user.is_admin && <span className="text-xs bg-blue-500/20 text-blue-400 px-2 py-0.5 rounded">ADMIN</span>}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground flex gap-4">
|
||||
<span>{user.email}</span>
|
||||
<span>{user.track_count} tracks</span>
|
||||
<span>{user.active_strikes} strikes</span>
|
||||
<span>Joined: {new Date(user.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{!user.is_banned && !user.is_suspended ? (
|
||||
<Button variant="primary" size="sm" className="bg-destructive hover:bg-destructive/90 border-destructive text-destructive-foreground" icon={<Ban className="w-4 h-4" />} onClick={() => onSuspend(user.id)}>
|
||||
Suspend
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="secondary" size="sm" icon={<CheckCircle className="w-4 h-4" />} onClick={() => onUnsuspend(user.id)}>
|
||||
Unsuspend
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentPanel({
|
||||
content, total, search, contentType, onSearchChange, onTypeChange, onSearch, onHide, onRestore,
|
||||
}: {
|
||||
content: AdminContentItem[];
|
||||
total: number;
|
||||
search: string;
|
||||
contentType: string;
|
||||
onSearchChange: (v: string) => void;
|
||||
onTypeChange: (v: string) => void;
|
||||
onSearch: () => void;
|
||||
onHide: (id: string, type: string) => void;
|
||||
onRestore: (id: string, type: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<select
|
||||
value={contentType}
|
||||
onChange={(e) => onTypeChange(e.target.value)}
|
||||
className="bg-muted border border-border rounded px-2 py-2 text-sm text-foreground"
|
||||
aria-label="Content type"
|
||||
>
|
||||
<option value="track">Tracks</option>
|
||||
<option value="comment">Comments</option>
|
||||
</select>
|
||||
<div className="relative flex-1 min-w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search content..."
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onSearch()}
|
||||
className="w-full bg-muted border border-border rounded pl-10 pr-3 py-2 text-sm text-foreground"
|
||||
aria-label="Search content"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" icon={<Search className="w-4 h-4" />} onClick={onSearch}>
|
||||
Search
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground ml-auto">{total} items</span>
|
||||
</div>
|
||||
|
||||
{content.length === 0 && (
|
||||
<EmptyState icon={<Music className="w-full h-full" />} title="No content found" description="Try adjusting your search." />
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{content.map((item) => (
|
||||
<Card key={item.id} variant="default" className="p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<Badge label={item.type} variant="terminal" />
|
||||
<span className="font-bold text-foreground">{item.title}</span>
|
||||
{item.status === 'hidden' && <span className="text-xs bg-red-500/20 text-red-400 px-2 py-0.5 rounded">HIDDEN</span>}
|
||||
{item.report_count > 0 && (
|
||||
<span className="text-xs bg-orange-500/20 text-orange-400 px-2 py-0.5 rounded">{item.report_count} reports</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
By: {item.creator_name} | {new Date(item.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{item.status === 'active' ? (
|
||||
<Button variant="secondary" size="sm" icon={<EyeOff className="w-4 h-4" />} onClick={() => onHide(item.id, item.type)}>
|
||||
Hide
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="secondary" size="sm" icon={<Eye className="w-4 h-4" />} onClick={() => onRestore(item.id, item.type)}>
|
||||
Restore
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentsPanel({ payments }: { payments: PaymentOverview }) {
|
||||
const cards = [
|
||||
{ label: 'Total Orders', value: payments.total_orders.toLocaleString(), color: 'text-blue-400' },
|
||||
{ label: 'Completed', value: payments.completed_orders.toLocaleString(), color: 'text-green-400' },
|
||||
{ label: 'Pending', value: payments.pending_orders.toLocaleString(), color: 'text-orange-400' },
|
||||
{ label: 'Refunded', value: payments.refunded_orders.toLocaleString(), color: 'text-red-400' },
|
||||
{ label: 'Total Revenue', value: `$${payments.total_revenue.toFixed(2)}`, color: 'text-green-400' },
|
||||
{ label: 'Platform Fees', value: `$${payments.platform_fees.toFixed(2)}`, color: 'text-purple-400' },
|
||||
{ label: 'Total Refunded', value: `$${payments.total_refunded.toFixed(2)}`, color: 'text-red-400' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{cards.map((card) => (
|
||||
<div key={card.label} className="bg-muted/30 rounded-lg p-4 border border-border/50">
|
||||
<div className="text-xs text-muted-foreground uppercase mb-1">{card.label}</div>
|
||||
<div className={`text-2xl font-bold ${card.color}`}>{card.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -22,6 +22,8 @@ export {
|
|||
LazyPlaylistRoutes,
|
||||
LazySharedPlaylistPage,
|
||||
LazyAdminDashboard,
|
||||
LazyAdminModeration,
|
||||
LazyAdminPlatform,
|
||||
LazyAdminTransfers,
|
||||
LazyAnalytics,
|
||||
LazyWebhooks,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ export {
|
|||
LazyPlaylistRoutes,
|
||||
LazySharedPlaylistPage,
|
||||
LazyAdminDashboard,
|
||||
LazyAdminModeration,
|
||||
LazyAdminPlatform,
|
||||
LazyAdminTransfers,
|
||||
LazyAnalytics,
|
||||
LazyWebhooks,
|
||||
|
|
|
|||
|
|
@ -129,6 +129,22 @@ export const LazyAdminDashboard = createLazyComponent(
|
|||
undefined,
|
||||
'Admin Dashboard',
|
||||
);
|
||||
export const LazyAdminModeration = createLazyComponent(
|
||||
() =>
|
||||
import('@/features/admin/pages/AdminModerationPage').then((m) => ({
|
||||
default: m.AdminModerationPage,
|
||||
})),
|
||||
undefined,
|
||||
'Admin Moderation',
|
||||
);
|
||||
export const LazyAdminPlatform = createLazyComponent(
|
||||
() =>
|
||||
import('@/features/admin/pages/AdminPlatformPage').then((m) => ({
|
||||
default: m.AdminPlatformPage,
|
||||
})),
|
||||
undefined,
|
||||
'Admin Platform',
|
||||
);
|
||||
export const LazyAdminTransfers = createLazyComponent(
|
||||
() =>
|
||||
import('@/features/admin/pages/AdminTransfersPage').then((m) => ({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
import { AdminModerationView } from '@/components/admin/AdminModerationView';
|
||||
|
||||
export function AdminModerationPage() {
|
||||
return <AdminModerationView />;
|
||||
}
|
||||
|
||||
export default AdminModerationPage;
|
||||
7
apps/web/src/features/admin/pages/AdminPlatformPage.tsx
Normal file
7
apps/web/src/features/admin/pages/AdminPlatformPage.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { AdminPlatformView } from '@/components/admin/AdminPlatformView';
|
||||
|
||||
export function AdminPlatformPage() {
|
||||
return <AdminPlatformView />;
|
||||
}
|
||||
|
||||
export default AdminPlatformPage;
|
||||
|
|
@ -27,6 +27,8 @@ import {
|
|||
LazyAnalytics,
|
||||
LazyWebhooks,
|
||||
LazyAdminDashboard,
|
||||
LazyAdminModeration,
|
||||
LazyAdminPlatform,
|
||||
LazyAdminTransfers,
|
||||
LazyDesignSystemDemo,
|
||||
LazySocial,
|
||||
|
|
@ -118,6 +120,8 @@ export function getProtectedRoutes(): RouteEntry[] {
|
|||
{ path: '/analytics', element: wrapProtected(<LazyAnalytics />) },
|
||||
{ path: '/webhooks', element: wrapProtected(<LazyWebhooks />) },
|
||||
{ path: '/admin', element: wrapProtected(<LazyAdminDashboard />) },
|
||||
{ path: '/admin/moderation', element: wrapProtected(<LazyAdminModeration />) },
|
||||
{ path: '/admin/platform', element: wrapProtected(<LazyAdminPlatform />) },
|
||||
{ path: '/admin/transfers', element: wrapProtected(<LazyAdminTransfers />) },
|
||||
{ path: '/social', element: wrapProtected(<LazySocial />) },
|
||||
{ path: '/feed', element: wrapProtected(<LazyFeed />) },
|
||||
|
|
|
|||
|
|
@ -261,6 +261,87 @@ export const adminService = {
|
|||
const response = await apiClient.get<{ data: { stats: ModerationStats } }>('/admin/moderation/stats');
|
||||
return response.data?.data?.stats ?? { pending_reports: 0, resolved_reports: 0, pending_appeals: 0, pending_fingerprints: 0 };
|
||||
},
|
||||
|
||||
// --- v0.11.3: Admin Platform Management (F421-F435) ---
|
||||
|
||||
/** F421: Get platform-wide metrics */
|
||||
getPlatformMetrics: async (): Promise<PlatformMetrics> => {
|
||||
const response = await apiClient.get<{ data: { metrics: PlatformMetrics } }>('/admin/platform/metrics');
|
||||
return response.data?.data?.metrics ?? {
|
||||
total_users: 0, active_users: 0, new_users_today: 0, new_users_week: 0,
|
||||
total_tracks: 0, tracks_today: 0, total_playlists: 0, total_comments: 0,
|
||||
banned_users: 0, pending_reports: 0, storage_used_mb: 0, total_revenue: 0, revenue_this_month: 0,
|
||||
};
|
||||
},
|
||||
|
||||
/** F422: Search/list users */
|
||||
searchUsers: async (params: { q?: string; role?: string; is_banned?: string; limit?: number; offset?: number; sort_by?: string } = {}) => {
|
||||
const response = await apiClient.get<{ data: { users: AdminUserInfo[]; pagination: { total: number } } }>('/admin/platform/users', { params });
|
||||
return {
|
||||
users: response.data?.data?.users ?? [],
|
||||
total: response.data?.data?.pagination?.total ?? 0,
|
||||
};
|
||||
},
|
||||
|
||||
/** F422: Get user detail */
|
||||
getUserDetail: async (userId: string): Promise<AdminUserInfo | null> => {
|
||||
const response = await apiClient.get<{ data: { user: AdminUserInfo } }>(`/admin/platform/users/${userId}`);
|
||||
return response.data?.data?.user ?? null;
|
||||
},
|
||||
|
||||
/** F422: Update user role */
|
||||
updateUserRole: async (userId: string, role: string) => {
|
||||
await apiClient.put(`/admin/platform/users/${userId}/role`, { role });
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/** F422: Suspend user */
|
||||
suspendUser: async (userId: string, reason: string, durationDays?: number) => {
|
||||
await apiClient.post(`/admin/platform/users/${userId}/suspend`, { reason, duration_days: durationDays });
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/** F422: Unsuspend user */
|
||||
unsuspendUser: async (userId: string) => {
|
||||
await apiClient.post(`/admin/platform/users/${userId}/unsuspend`, {});
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/** F423: Search content */
|
||||
searchContent: async (params: { type?: string; q?: string; limit?: number; offset?: number } = {}) => {
|
||||
const response = await apiClient.get<{ data: { content: AdminContentItem[]; pagination: { total: number } } }>('/admin/platform/content', { params });
|
||||
return {
|
||||
content: response.data?.data?.content ?? [],
|
||||
total: response.data?.data?.pagination?.total ?? 0,
|
||||
};
|
||||
},
|
||||
|
||||
/** F423: Hide content */
|
||||
hideContent: async (contentId: string, contentType: string, reason: string) => {
|
||||
await apiClient.post(`/admin/platform/content/${contentId}/hide`, { content_type: contentType, reason });
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/** F423: Restore content */
|
||||
restoreContent: async (contentId: string, contentType: string) => {
|
||||
await apiClient.post(`/admin/platform/content/${contentId}/restore`, { content_type: contentType });
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/** F424: Get payment overview */
|
||||
getPaymentOverview: async (): Promise<PaymentOverview> => {
|
||||
const response = await apiClient.get<{ data: { payments: PaymentOverview } }>('/admin/platform/payments');
|
||||
return response.data?.data?.payments ?? {
|
||||
total_orders: 0, completed_orders: 0, pending_orders: 0, refunded_orders: 0,
|
||||
total_revenue: 0, total_refunded: 0, platform_fees: 0,
|
||||
};
|
||||
},
|
||||
|
||||
/** F424: Refund order */
|
||||
refundOrder: async (orderId: string, reason: string) => {
|
||||
await apiClient.post(`/admin/platform/orders/${orderId}/refund`, { reason });
|
||||
return { success: true };
|
||||
},
|
||||
};
|
||||
|
||||
// --- v0.11.2 Types ---
|
||||
|
|
@ -329,3 +410,59 @@ export interface ModerationStats {
|
|||
pending_appeals: number;
|
||||
pending_fingerprints: number;
|
||||
}
|
||||
|
||||
// --- v0.11.3 Types ---
|
||||
|
||||
export interface PlatformMetrics {
|
||||
total_users: number;
|
||||
active_users: number;
|
||||
new_users_today: number;
|
||||
new_users_week: number;
|
||||
total_tracks: number;
|
||||
tracks_today: number;
|
||||
total_playlists: number;
|
||||
total_comments: number;
|
||||
banned_users: number;
|
||||
pending_reports: number;
|
||||
storage_used_mb: number;
|
||||
total_revenue: number;
|
||||
revenue_this_month: number;
|
||||
}
|
||||
|
||||
export interface AdminUserInfo {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
is_banned: boolean;
|
||||
is_verified: boolean;
|
||||
is_admin: boolean;
|
||||
track_count: number;
|
||||
login_count: number;
|
||||
last_login_at?: string;
|
||||
created_at: string;
|
||||
active_strikes: number;
|
||||
is_suspended: boolean;
|
||||
}
|
||||
|
||||
export interface AdminContentItem {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
creator_id: string;
|
||||
creator_name: string;
|
||||
status: string;
|
||||
report_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PaymentOverview {
|
||||
total_orders: number;
|
||||
completed_orders: number;
|
||||
pending_orders: number;
|
||||
refunded_orders: number;
|
||||
total_revenue: number;
|
||||
total_refunded: number;
|
||||
platform_fees: number;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue