Files
qihuaiflow/frontend/src/pages/admin/Market.tsx
T

964 lines
51 KiB
TypeScript

import React, { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
import McpMarketTab from './McpMarketTab';
// ============================================================
// Types
// ============================================================
interface MarketAppItem {
id: string;
name: string;
type: string;
category: string;
tags: string | null;
source: string;
publisher_name: string | null;
description: string | null;
icon: string | null;
price: number;
is_member_free: boolean;
featured: boolean;
featured_order: number;
status: string;
rating_avg: number;
review_count: number;
install_count: number;
version: string;
created_at: string | null;
updated_at: string | null;
tutorial?: string;
skill_content?: string;
}
interface OverviewData {
total_apps: number;
published: number;
pending: number;
removed: number;
total_installs: number;
total_purchases: number;
total_revenue: number;
total_reviews: number;
overall_rating: number;
by_type: Record<string, number>;
by_category: Record<string, number>;
trend_7d: Array<{ date: string; new_apps: number; new_installs: number }>;
}
interface CouponItem {
id: string;
code: string;
type: string;
value: number;
min_amount: number;
valid_from: string | null;
valid_to: string | null;
total_count: number;
used_count: number;
status: string;
}
interface ReviewItem {
id: string;
rating: number;
content: string;
username: string;
avatar: string | null;
app_name: string;
app_icon: string | null;
created_at: string | null;
}
const CATEGORY_LABELS: Record<string, string> = {
coding: '编程开发', assistant: '智能助手', tools: '效率工具',
writing: '内容创作', education: '教育培训', marketing: '营销推广',
data: '数据分析', design: '设计创意', business: '企业管理', other: '其他',
};
const TYPE_LABELS: Record<string, string> = {
skill: 'Skills工具', agent: '智能Agent', mcp: 'MCP协议',
};
const STATUS_LABELS: Record<string, string> = {
pending: '待审核', published: '已发布', removed: '已下架',
};
const STATUS_COLORS: Record<string, string> = {
pending: 'var(--color-warning)', published: 'var(--color-success)', removed: 'var(--color-danger)',
};
// ============================================================
// Icon helpers
// ============================================================
const Icon = ({ name, size = 18, color = 'currentColor' }: { name: string; size?: number; color?: string }) => {
const icons: Record<string, string> = {
chart: 'M3 3v18h18M7 14l4-4 4 4 5-5', store: 'M3 9l1-5h16l1 5M4 9v11h16V9M9 14h6',
check: 'M20 6L9 17l-5-5', close: 'M18 6L6 18M6 6l12 12', edit: 'M11 4H4v16h16v-7M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4 9.5-9.5z',
star: 'M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z',
trash: 'M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2',
tag: 'M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z',
plus: 'M12 5v14M5 12h14', search: 'M11 4a7 7 0 100 14 7 7 0 000-14zM21 21l-4.35-4.35',
download: 'M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3',
refresh: 'M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15',
trending: 'M23 6l-9.5 9.5-5-5L1 18', eye: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z',
};
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'inline-block', verticalAlign: 'middle' }}>
<path d={icons[name] || ''} />
</svg>
);
};
// ============================================================
// Main Component
// ============================================================
const Market: React.FC = () => {
const [activeTab, setActiveTab] = useState<'overview' | 'apps' | 'pending' | 'coupons' | 'reviews' | 'mcp'>('overview');
const [loading, setLoading] = useState(false);
// Overview data
const [overview, setOverview] = useState<OverviewData | null>(null);
const [topApps, setTopApps] = useState<MarketAppItem[]>([]);
const [topMetric, setTopMetric] = useState<string>('installs');
// Apps list
const [apps, setApps] = useState<MarketAppItem[]>([]);
const [appsTotal, setAppsTotal] = useState(0);
const [appsPage, setAppsPage] = useState(1);
const [filterStatus, setFilterStatus] = useState('');
const [filterType, setFilterType] = useState('');
const [filterCategory, setFilterCategory] = useState('');
const [filterKeyword, setFilterKeyword] = useState('');
// Pending
const [pendingApps, setPendingApps] = useState<MarketAppItem[]>([]);
const [pendingTotal, setPendingTotal] = useState(0);
// Coupons
const [coupons, setCoupons] = useState<CouponItem[]>([]);
const [couponModal, setCouponModal] = useState(false);
const [editingCoupon, setEditingCoupon] = useState<CouponItem | null>(null);
// Reviews
const [reviews, setReviews] = useState<ReviewItem[]>([]);
// Edit modal
const [editApp, setEditApp] = useState<MarketAppItem | null>(null);
const loadOverview = useCallback(async () => {
setLoading(true);
try {
const res = await adminAPI.getMarketOverview();
if (res?.code === 0) setOverview(res.data);
const topRes = await adminAPI.getTopApps(topMetric, 10);
if (topRes?.code === 0) setTopApps(topRes.data || []);
} catch (e) { console.error(e); }
setLoading(false);
}, [topMetric]);
const loadApps = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (filterStatus) params.set('status', filterStatus);
if (filterType) params.set('app_type', filterType);
if (filterCategory) params.set('category', filterCategory);
if (filterKeyword) params.set('keyword', filterKeyword);
params.set('page', String(appsPage));
params.set('page_size', '20');
const res = await adminAPI.getAdminMarketApps(params.toString());
if (res?.code === 0) {
const d = res.data as any;
setApps(d?.items || []);
setAppsTotal(d?.total || 0);
}
} catch (e) { console.error(e); }
setLoading(false);
}, [filterStatus, filterType, filterCategory, filterKeyword, appsPage]);
const loadPending = useCallback(async () => {
setLoading(true);
try {
const res = await adminAPI.getPendingApps();
if (res?.code === 0) {
const d = res.data as any;
setPendingApps(d?.items || d || []);
setPendingTotal(d?.total || (Array.isArray(d) ? d.length : 0));
}
} catch (e) { console.error(e); }
setLoading(false);
}, []);
const loadCoupons = useCallback(async () => {
setLoading(true);
try {
const res = await adminAPI.getCoupons();
if (res?.code === 0) { const d = res.data as any; setCoupons(d?.items || d || []); }
} catch (e) { console.error(e); }
setLoading(false);
}, []);
const loadReviews = useCallback(async () => {
setLoading(true);
try {
const res = await adminAPI.getRecentReviews();
if (res?.code === 0) setReviews(res.data || []);
} catch (e) { console.error(e); }
setLoading(false);
}, []);
useEffect(() => {
if (activeTab === 'overview') loadOverview();
else if (activeTab === 'apps') loadApps();
else if (activeTab === 'pending') loadPending();
else if (activeTab === 'coupons') loadCoupons();
else if (activeTab === 'reviews') loadReviews();
}, [activeTab, loadOverview, loadApps, loadPending, loadCoupons, loadReviews]);
// Actions
const handleApprove = async (id: string) => {
if (!confirm('确认审核通过?')) return;
try {
await adminAPI.approveApp(id);
loadPending();
loadOverview();
} catch (e) { alert('操作失败'); }
};
const handleReject = async (id: string) => {
const reason = prompt('拒绝原因:', '');
if (reason === null) return;
try {
await adminAPI.rejectApp(id + '?reason=' + encodeURIComponent(reason));
loadPending();
} catch (e) { alert('操作失败'); }
};
const handleToggleFeatured = async (app: MarketAppItem) => {
try {
await adminAPI.setFeaturedApp(app.id, !app.featured, app.featured ? 0 : 1);
loadApps();
} catch (e) { alert('操作失败'); }
};
const handleDeleteApp = async (id: string, name: string) => {
if (!confirm(`确认删除应用「${name}」?此操作不可恢复!`)) return;
try {
await adminAPI.deleteMarketApp(id);
loadApps();
} catch (e) { alert('操作失败'); }
};
const handleDeleteCoupon = async (id: string) => {
if (!confirm('确认删除此优惠券?')) return;
try {
await adminAPI.deleteCoupon(id);
loadCoupons();
} catch (e) { alert('操作失败'); }
};
const tabs = [
{ key: 'overview', label: '数据总览', icon: 'chart' },
{ key: 'apps', label: '应用管理', icon: 'store' },
{ key: 'pending', label: `待审核${pendingTotal > 0 ? `(${pendingTotal})` : ''}`, icon: 'eye' },
{ key: 'coupons', label: '优惠券', icon: 'tag' },
{ key: 'reviews', label: '用户评价', icon: 'star' },
{ key: 'mcp', label: 'MCP工具', icon: 'tool' },
] as const;
return (
<div style={{ padding: '24px', maxWidth: '1400px', margin: '0 auto' }}>
{/* Header */}
<div style={{ marginBottom: '24px' }}>
<h1 style={{ fontSize: '24px', fontWeight: 700, margin: '0 0 8px', color: 'var(--color-text-primary)' }}>
市场管理
</h1>
<p style={{ fontSize: '14px', color: 'var(--color-text-tertiary)', margin: 0 }}>
管理应用市场、审核提交、分析数据、配置优惠券
</p>
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: '4px', marginBottom: '24px', borderBottom: '1px solid var(--color-border)', paddingBottom: '0' }}>
{tabs.map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
style={{
padding: '10px 20px',
border: 'none',
background: activeTab === tab.key ? 'var(--color-primary)' : 'transparent',
color: activeTab === tab.key ? '#fff' : 'var(--color-text-secondary)',
borderRadius: '8px 8px 0 0',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: '6px',
transition: 'all 0.2s',
}}
>
<Icon name={tab.icon} size={16} />
{tab.label}
</button>
))}
</div>
{/* Loading */}
{loading && (
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--color-text-tertiary)' }}>
<div className="audit-spinner" style={{ margin: '0 auto 12px' }} />
<span>加载中...</span>
</div>
)}
{/* Content */}
{!loading && activeTab === 'overview' && overview && (
<MarketOverview overview={overview} topApps={topApps} topMetric={topMetric} onMetricChange={setTopMetric} />
)}
{!loading && activeTab === 'apps' && (
<AppsManagement
apps={apps} total={appsTotal} page={appsPage} onPageChange={setAppsPage}
filterStatus={filterStatus} setFilterStatus={setFilterStatus}
filterType={filterType} setFilterType={setFilterType}
filterCategory={filterCategory} setFilterCategory={setFilterCategory}
filterKeyword={filterKeyword} setFilterKeyword={setFilterKeyword}
onEdit={setEditApp} onToggleFeatured={handleToggleFeatured} onDelete={handleDeleteApp}
/>
)}
{!loading && activeTab === 'pending' && (
<PendingApps apps={pendingApps} onApprove={handleApprove} onReject={handleReject} />
)}
{!loading && activeTab === 'coupons' && (
<CouponsManagement
coupons={coupons} onAdd={() => { setEditingCoupon(null); setCouponModal(true); }}
onEdit={(c) => { setEditingCoupon(c); setCouponModal(true); }}
onDelete={handleDeleteCoupon}
/>
)}
{!loading && activeTab === 'reviews' && (
<ReviewsList reviews={reviews} />
)}
{!loading && activeTab === 'mcp' && (
<McpMarketTab />
)}
{/* Edit Modal */}
{editApp && (
<EditAppModal app={editApp} onClose={() => setEditApp(null)} onSaved={() => { setEditApp(null); loadApps(); }} />
)}
{/* Coupon Modal */}
{couponModal && (
<CouponModal coupon={editingCoupon} onClose={() => setCouponModal(false)} onSaved={() => { setCouponModal(false); loadCoupons(); }} />
)}
</div>
);
};
// ============================================================
// Overview Component
// ============================================================
const MarketOverview: React.FC<{ overview: OverviewData; topApps: MarketAppItem[]; topMetric: string; onMetricChange: (v: string) => void }> = ({
overview, topApps, topMetric, onMetricChange
}) => {
const stats = [
{ label: '总应用数', value: overview.total_apps, sub: `${overview.published} 已发布`, color: 'var(--color-primary)' },
{ label: '总安装量', value: overview.total_installs, sub: `${overview.pending} 待审核`, color: 'var(--color-success)' },
{ label: '总收入', value: `¥${overview.total_revenue.toFixed(2)}`, sub: `${overview.total_purchases} 笔购买`, color: 'var(--color-warning)' },
{ label: '平均评分', value: overview.overall_rating.toFixed(1), sub: `${overview.total_reviews} 条评价`, color: 'var(--color-danger)' },
];
const maxTrend = Math.max(...overview.trend_7d.map(t => Math.max(t.new_apps, t.new_installs)), 1);
return (
<div>
{/* Stat cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginBottom: '24px' }}>
{stats.map((s, i) => (
<div key={i} style={{
background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '20px',
border: '1px solid var(--color-border)',
}}>
<div style={{ fontSize: '13px', color: 'var(--color-text-tertiary)', marginBottom: '8px' }}>{s.label}</div>
<div style={{ fontSize: '28px', fontWeight: 700, color: s.color }}>{s.value}</div>
<div style={{ fontSize: '12px', color: 'var(--color-text-tertiary)', marginTop: '4px' }}>{s.sub}</div>
</div>
))}
</div>
{/* 7-day trend */}
<div style={{
background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '24px',
border: '1px solid var(--color-border)', marginBottom: '24px',
}}>
<h3 style={{ fontSize: '16px', fontWeight: 600, margin: '0 0 20px', color: 'var(--color-text-primary)' }}>
7日趋势
</h3>
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', height: '180px' }}>
{overview.trend_7d.map((t, i) => (
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
<div style={{ display: 'flex', gap: '4px', alignItems: 'flex-end', height: '140px' }}>
<div style={{
width: '16px', height: `${(t.new_apps / maxTrend) * 130 + 4}px`,
background: 'var(--color-primary)', borderRadius: '4px 4px 0 0',
minHeight: '4px', transition: 'height 0.3s',
}} title={`新应用: ${t.new_apps}`} />
<div style={{
width: '16px', height: `${(t.new_installs / maxTrend) * 130 + 4}px`,
background: 'var(--color-success)', borderRadius: '4px 4px 0 0',
minHeight: '4px', transition: 'height 0.3s',
}} title={`新安装: ${t.new_installs}`} />
</div>
<span style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>
{t.date.slice(5)}
</span>
</div>
))}
</div>
<div style={{ display: 'flex', gap: '24px', marginTop: '16px', fontSize: '12px' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ width: '12px', height: '12px', background: 'var(--color-primary)', borderRadius: '3px' }} />
<span style={{ color: 'var(--color-text-secondary)' }}>新应用</span>
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ width: '12px', height: '12px', background: 'var(--color-success)', borderRadius: '3px' }} />
<span style={{ color: 'var(--color-text-secondary)' }}>新安装</span>
</span>
</div>
</div>
{/* Category distribution + Top apps */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '16px' }}>
{/* Categories */}
<div style={{
background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '20px',
border: '1px solid var(--color-border)',
}}>
<h3 style={{ fontSize: '14px', fontWeight: 600, margin: '0 0 16px' }}>分类分布</h3>
{Object.entries(overview.by_category).map(([cat, cnt]) => (
<div key={cat} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 0', borderBottom: '1px solid var(--color-border-light)' }}>
<span style={{ fontSize: '13px', color: 'var(--color-text-secondary)' }}>{CATEGORY_LABELS[cat] || cat}</span>
<span style={{ fontSize: '13px', fontWeight: 600 }}>{cnt}</span>
</div>
))}
{Object.keys(overview.by_category).length === 0 && (
<div style={{ fontSize: '13px', color: 'var(--color-text-tertiary)', textAlign: 'center', padding: '20px' }}>暂无数据</div>
)}
</div>
{/* Top apps */}
<div style={{
background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '20px',
border: '1px solid var(--color-border)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h3 style={{ fontSize: '14px', fontWeight: 600, margin: 0 }}>热门应用</h3>
<select
value={topMetric}
onChange={(e) => onMetricChange(e.target.value)}
style={{
padding: '4px 12px', borderRadius: '6px', border: '1px solid var(--color-border)',
background: 'var(--color-bg-primary)', color: 'var(--color-text-primary)', fontSize: '12px',
}}
>
<option value="installs">按安装量</option>
<option value="rating">按评分</option>
<option value="revenue">按收入</option>
<option value="reviews">按评价数</option>
</select>
</div>
{topApps.map((app, i) => (
<div key={app.id} style={{ display: 'flex', alignItems: 'center', gap: '12px', padding: '8px 0', borderBottom: '1px solid var(--color-border-light)' }}>
<span style={{ fontSize: '16px', fontWeight: 700, color: i < 3 ? 'var(--color-warning)' : 'var(--color-text-tertiary)', width: '24px' }}>
#{i + 1}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: '13px', fontWeight: 500, color: 'var(--color-text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{app.name}
</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>
{TYPE_LABELS[app.type] || app.type} · {CATEGORY_LABELS[app.category] || app.category}
</div>
</div>
<div style={{ textAlign: 'right', fontSize: '13px', fontWeight: 600 }}>
{topMetric === 'installs' && `${app.install_count}`}
{topMetric === 'rating' && `${app.rating_avg.toFixed(1)}`}
{topMetric === 'reviews' && `${app.review_count}`}
{topMetric === 'revenue' && `¥${(app as any).revenue?.toFixed(0) || 0}`}
</div>
</div>
))}
{topApps.length === 0 && (
<div style={{ fontSize: '13px', color: 'var(--color-text-tertiary)', textAlign: 'center', padding: '20px' }}>暂无数据</div>
)}
</div>
</div>
</div>
);
};
// ============================================================
// Apps Management Component
// ============================================================
const AppsManagement: React.FC<{
apps: MarketAppItem[]; total: number; page: number; onPageChange: (p: number) => void;
filterStatus: string; setFilterStatus: (v: string) => void;
filterType: string; setFilterType: (v: string) => void;
filterCategory: string; setFilterCategory: (v: string) => void;
filterKeyword: string; setFilterKeyword: (v: string) => void;
onEdit: (app: MarketAppItem) => void; onToggleFeatured: (app: MarketAppItem) => void; onDelete: (id: string, name: string) => void;
}> = ({ apps, total, page, onPageChange, filterStatus, setFilterStatus, filterType, setFilterType, filterCategory, setFilterCategory, filterKeyword, setFilterKeyword, onEdit, onToggleFeatured, onDelete }) => {
const totalPages = Math.ceil(total / 20);
return (
<div>
{/* Filters */}
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px', flexWrap: 'wrap' }}>
<input
placeholder="搜索应用..."
value={filterKeyword}
onChange={(e) => setFilterKeyword(e.target.value)}
style={{ flex: 1, minWidth: '200px', padding: '8px 12px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}
/>
<select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)} style={{ padding: '8px 12px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}>
<option value="">全部状态</option>
<option value="published">已发布</option>
<option value="pending">待审核</option>
<option value="removed">已下架</option>
</select>
<select value={filterType} onChange={(e) => setFilterType(e.target.value)} style={{ padding: '8px 12px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}>
<option value="">全部类型</option>
<option value="skill">Skills工具</option>
<option value="agent">智能Agent</option>
<option value="mcp">MCP协议</option>
</select>
<select value={filterCategory} onChange={(e) => setFilterCategory(e.target.value)} style={{ padding: '8px 12px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}>
<option value="">全部分类</option>
{Object.entries(CATEGORY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
{/* Table */}
<div style={{ background: 'var(--color-bg-secondary)', borderRadius: '12px', border: '1px solid var(--color-border)', overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--color-border)' }}>
{['应用', '类型', '分类', '价格', '安装', '评分', '状态', '推荐', '操作'].map(h => (
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: 600, color: 'var(--color-text-tertiary)' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{apps.map(app => (
<tr key={app.id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
<td style={{ padding: '12px 16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{app.icon && <img src={app.icon} alt="" style={{ width: '32px', height: '32px', borderRadius: '6px' }} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />}
<div>
<div style={{ fontSize: '13px', fontWeight: 500 }}>{app.name}</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>{app.publisher_name || '—'}</div>
</div>
</div>
</td>
<td style={{ padding: '12px 16px', fontSize: '13px' }}>{TYPE_LABELS[app.type] || app.type}</td>
<td style={{ padding: '12px 16px', fontSize: '13px' }}>{CATEGORY_LABELS[app.category] || app.category}</td>
<td style={{ padding: '12px 16px', fontSize: '13px' }}>
{app.price === 0 ? <span style={{ color: 'var(--color-success)' }}>免费</span> : `¥${app.price}`}
{app.is_member_free && <span style={{ fontSize: '10px', color: 'var(--color-warning)', marginLeft: '4px' }}>会员免费</span>}
</td>
<td style={{ padding: '12px 16px', fontSize: '13px' }}>{app.install_count}</td>
<td style={{ padding: '12px 16px', fontSize: '13px' }}>
{app.rating_avg > 0 ? `★ ${app.rating_avg.toFixed(1)} (${app.review_count})` : '—'}
</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ fontSize: '12px', padding: '2px 8px', borderRadius: '4px', background: STATUS_COLORS[app.status] || 'var(--color-text-tertiary)', color: '#fff' }}>
{STATUS_LABELS[app.status] || app.status}
</span>
</td>
<td style={{ padding: '12px 16px' }}>
<button
onClick={() => onToggleFeatured(app)}
style={{
padding: '4px 8px', borderRadius: '4px', border: 'none', cursor: 'pointer', fontSize: '12px',
background: app.featured ? 'var(--color-warning)' : 'var(--color-bg-tertiary)',
color: app.featured ? '#fff' : 'var(--color-text-tertiary)',
}}
>
{app.featured ? '已推荐' : '推荐'}
</button>
</td>
<td style={{ padding: '12px 16px' }}>
<div style={{ display: 'flex', gap: '4px' }}>
<button onClick={() => onEdit(app)} style={{ padding: '4px 8px', borderRadius: '4px', border: '1px solid var(--color-border)', background: 'transparent', color: 'var(--color-text-secondary)', cursor: 'pointer', fontSize: '12px' }}>编辑</button>
<button onClick={() => onDelete(app.id, app.name)} style={{ padding: '4px 8px', borderRadius: '4px', border: '1px solid var(--color-danger)', background: 'transparent', color: 'var(--color-danger)', cursor: 'pointer', fontSize: '12px' }}>删除</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{apps.length === 0 && (
<div style={{ textAlign: 'center', padding: '40px', color: 'var(--color-text-tertiary)', fontSize: '14px' }}>暂无应用</div>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '16px' }}>
<button disabled={page <= 1} onClick={() => onPageChange(page - 1)} style={{ padding: '6px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-secondary)', cursor: page <= 1 ? 'not-allowed' : 'pointer', opacity: page <= 1 ? 0.5 : 1 }}>上一页</button>
<span style={{ padding: '6px 12px', fontSize: '13px', color: 'var(--color-text-secondary)' }}>{page} / {totalPages}</span>
<button disabled={page >= totalPages} onClick={() => onPageChange(page + 1)} style={{ padding: '6px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-secondary)', cursor: page >= totalPages ? 'not-allowed' : 'pointer', opacity: page >= totalPages ? 0.5 : 1 }}>下一页</button>
</div>
)}
</div>
);
};
// ============================================================
// Pending Apps Component
// ============================================================
const PendingApps: React.FC<{ apps: MarketAppItem[]; onApprove: (id: string) => void; onReject: (id: string) => void }> = ({ apps, onApprove, onReject }) => (
<div>
{apps.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--color-text-tertiary)' }}>
<Icon name="check" size={48} color="var(--color-success)" />
<p style={{ marginTop: '16px', fontSize: '14px' }}>没有待审核的应用</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{apps.map(app => (
<div key={app.id} style={{
background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '20px',
border: '1px solid var(--color-border)', display: 'flex', gap: '16px',
}}>
{app.icon ? (
<img src={app.icon} alt="" style={{ width: '56px', height: '56px', borderRadius: '10px' }} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
) : (
<div style={{ width: '56px', height: '56px', borderRadius: '10px', background: 'var(--color-bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Icon name="store" size={24} color="var(--color-text-tertiary)" />
</div>
)}
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
<h4 style={{ margin: 0, fontSize: '15px', fontWeight: 600 }}>{app.name}</h4>
<span style={{ fontSize: '11px', padding: '2px 6px', borderRadius: '4px', background: 'var(--color-bg-tertiary)', color: 'var(--color-text-tertiary)' }}>
{TYPE_LABELS[app.type] || app.type}
</span>
<span style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>
by {app.publisher_name || 'Unknown'}
</span>
</div>
<p style={{ margin: '0 0 8px', fontSize: '13px', color: 'var(--color-text-secondary)', lineHeight: 1.5 }}>
{app.description || '无描述'}
</p>
{app.skill_content && (
<details style={{ marginTop: '8px' }}>
<summary style={{ cursor: 'pointer', fontSize: '12px', color: 'var(--color-text-tertiary)' }}>查看 Skill 内容</summary>
<pre style={{ marginTop: '8px', padding: '12px', background: 'var(--color-bg-tertiary)', borderRadius: '8px', fontSize: '12px', overflow: 'auto', maxHeight: '200px', color: 'var(--color-text-secondary)' }}>
{app.skill_content.slice(0, 500)}{app.skill_content.length > 500 ? '...' : ''}
</pre>
</details>
)}
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
<button onClick={() => onApprove(app.id)} style={{ padding: '6px 16px', borderRadius: '6px', border: 'none', background: 'var(--color-success)', color: '#fff', cursor: 'pointer', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<Icon name="check" size={14} /> 审核通过
</button>
<button onClick={() => onReject(app.id)} style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid var(--color-danger)', background: 'transparent', color: 'var(--color-danger)', cursor: 'pointer', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<Icon name="close" size={14} /> 拒绝
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
// ============================================================
// Coupons Component
// ============================================================
const CouponsManagement: React.FC<{
coupons: CouponItem[]; onAdd: () => void; onEdit: (c: CouponItem) => void; onDelete: (id: string) => void;
}> = ({ coupons, onAdd, onEdit, onDelete }) => (
<div>
<div style={{ marginBottom: '16px' }}>
<button onClick={onAdd} style={{ padding: '8px 16px', borderRadius: '8px', border: 'none', background: 'var(--color-primary)', color: '#fff', cursor: 'pointer', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '6px' }}>
<Icon name="plus" size={16} /> 创建优惠券
</button>
</div>
<div style={{ background: 'var(--color-bg-secondary)', borderRadius: '12px', border: '1px solid var(--color-border)', overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--color-border)' }}>
{['优惠券码', '类型', '面值', '最低消费', '有效期', '使用情况', '状态', '操作'].map(h => (
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: 600, color: 'var(--color-text-tertiary)' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{coupons.map(c => (
<tr key={c.id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
<td style={{ padding: '12px 16px', fontSize: '13px', fontWeight: 500, fontFamily: 'monospace' }}>{c.code}</td>
<td style={{ padding: '12px 16px', fontSize: '13px' }}>{c.type === 'fixed' ? '固定金额' : '百分比'}</td>
<td style={{ padding: '12px 16px', fontSize: '13px' }}>{c.type === 'fixed' ? `¥${c.value}` : `${c.value}%`}</td>
<td style={{ padding: '12px 16px', fontSize: '13px' }}>¥{c.min_amount}</td>
<td style={{ padding: '12px 16px', fontSize: '12px', color: 'var(--color-text-tertiary)' }}>
{c.valid_from?.slice(0, 10) || '—'} ~ {c.valid_to?.slice(0, 10) || '长期'}
</td>
<td style={{ padding: '12px 16px', fontSize: '13px' }}>{c.used_count} / {c.total_count}</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ fontSize: '12px', padding: '2px 8px', borderRadius: '4px', background: c.status === 'active' ? 'var(--color-success)' : 'var(--color-text-tertiary)', color: '#fff' }}>
{c.status === 'active' ? '有效' : '已停用'}
</span>
</td>
<td style={{ padding: '12px 16px' }}>
<div style={{ display: 'flex', gap: '4px' }}>
<button onClick={() => onEdit(c)} style={{ padding: '4px 8px', borderRadius: '4px', border: '1px solid var(--color-border)', background: 'transparent', color: 'var(--color-text-secondary)', cursor: 'pointer', fontSize: '12px' }}>编辑</button>
<button onClick={() => onDelete(c.id)} style={{ padding: '4px 8px', borderRadius: '4px', border: '1px solid var(--color-danger)', background: 'transparent', color: 'var(--color-danger)', cursor: 'pointer', fontSize: '12px' }}>删除</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{coupons.length === 0 && (
<div style={{ textAlign: 'center', padding: '40px', color: 'var(--color-text-tertiary)', fontSize: '14px' }}>暂无优惠券</div>
)}
</div>
</div>
);
// ============================================================
// Reviews List Component
// ============================================================
const ReviewsList: React.FC<{ reviews: ReviewItem[] }> = ({ reviews }) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{reviews.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--color-text-tertiary)' }}>
<Icon name="star" size={48} color="var(--color-text-tertiary)" />
<p style={{ marginTop: '16px', fontSize: '14px' }}>暂无用户评价</p>
</div>
) : (
reviews.map(r => (
<div key={r.id} style={{
background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '16px',
border: '1px solid var(--color-border)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
{r.avatar ? (
<img src={r.avatar} alt="" style={{ width: '28px', height: '28px', borderRadius: '50%' }} />
) : (
<div style={{ width: '28px', height: '28px', borderRadius: '50%', background: 'var(--color-bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '12px' }}>
{r.username?.charAt(0).toUpperCase() || 'U'}
</div>
)}
<span style={{ fontSize: '13px', fontWeight: 500 }}>{r.username}</span>
<span style={{ fontSize: '12px', color: 'var(--color-warning)' }}>
{'★'.repeat(r.rating)}{'☆'.repeat(5 - r.rating)}
</span>
<span style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>
on {r.app_name}
</span>
<span style={{ fontSize: '11px', color: 'var(--color-text-tertiary)', marginLeft: 'auto' }}>
{r.created_at?.slice(0, 16)}
</span>
</div>
<p style={{ margin: 0, fontSize: '13px', color: 'var(--color-text-secondary)', lineHeight: 1.5 }}>
{r.content}
</p>
</div>
))
)}
</div>
);
// ============================================================
// Edit App Modal
// ============================================================
const EditAppModal: React.FC<{ app: MarketAppItem; onClose: () => void; onSaved: () => void }> = ({ app, onClose, onSaved }) => {
const [form, setForm] = useState({
name: app.name,
description: app.description || '',
category: app.category || 'other',
tags: app.tags || '',
price: app.price,
is_member_free: app.is_member_free,
featured: app.featured,
featured_order: app.featured_order,
status: app.status,
});
const [saving, setSaving] = useState(false);
const handleSave = async () => {
setSaving(true);
try {
await adminAPI.updateMarketApp(app.id, form);
onSaved();
} catch (e) {
alert('保存失败');
}
setSaving(false);
};
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }} onClick={onClose}>
<div style={{ background: 'var(--color-bg-primary)', borderRadius: '12px', padding: '24px', width: '500px', maxWidth: '90vw', maxHeight: '85vh', overflow: 'auto' }} onClick={e => e.stopPropagation()}>
<h3 style={{ margin: '0 0 20px', fontSize: '18px', fontWeight: 600 }}>编辑应用</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>应用名称</label>
<input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>描述</label>
<textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} rows={3} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)', resize: 'vertical' }} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>分类</label>
<select value={form.category} onChange={e => setForm({ ...form, category: e.target.value })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}>
{Object.entries(CATEGORY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>价格 (¥)</label>
<input type="number" value={form.price} onChange={e => setForm({ ...form, price: parseFloat(e.target.value) || 0 })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>标签 (逗号分隔)</label>
<input value={form.tags} onChange={e => setForm({ ...form, tags: e.target.value })} placeholder="AI, 工具, 效率" style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>状态</label>
<select value={form.status} onChange={e => setForm({ ...form, status: e.target.value })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}>
<option value="published">已发布</option>
<option value="pending">待审核</option>
<option value="removed">已下架</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>推荐排序</label>
<input type="number" value={form.featured_order} onChange={e => setForm({ ...form, featured_order: parseInt(e.target.value) || 0 })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
</div>
</div>
<div style={{ display: 'flex', gap: '16px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '13px', cursor: 'pointer' }}>
<input type="checkbox" checked={form.is_member_free} onChange={e => setForm({ ...form, is_member_free: e.target.checked })} />
会员免费
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '13px', cursor: 'pointer' }}>
<input type="checkbox" checked={form.featured} onChange={e => setForm({ ...form, featured: e.target.checked })} />
推荐应用
</label>
</div>
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '24px', justifyContent: 'flex-end' }}>
<button onClick={onClose} style={{ padding: '8px 20px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'transparent', color: 'var(--color-text-secondary)', cursor: 'pointer', fontSize: '14px' }}>取消</button>
<button onClick={handleSave} disabled={saving} style={{ padding: '8px 20px', borderRadius: '8px', border: 'none', background: 'var(--color-primary)', color: '#fff', cursor: 'pointer', fontSize: '14px', opacity: saving ? 0.6 : 1 }}>
{saving ? '保存中...' : '保存'}
</button>
</div>
</div>
</div>
);
};
// ============================================================
// Coupon Modal
// ============================================================
const CouponModal: React.FC<{ coupon: CouponItem | null; onClose: () => void; onSaved: () => void }> = ({ coupon, onClose, onSaved }) => {
const [form, setForm] = useState({
code: coupon?.code || '',
type: coupon?.type || 'fixed',
value: coupon?.value || 10,
min_amount: coupon?.min_amount || 0,
valid_from: coupon?.valid_from?.slice(0, 10) || '',
valid_to: coupon?.valid_to?.slice(0, 10) || '',
total_count: coupon?.total_count || 100,
});
const [saving, setSaving] = useState(false);
const handleSave = async () => {
if (!form.code.trim()) { alert('请输入优惠券码'); return; }
setSaving(true);
try {
if (coupon) {
await adminAPI.updateCoupon(coupon.id, form);
} else {
await adminAPI.addCoupon(form);
}
onSaved();
} catch (e) {
alert('保存失败');
}
setSaving(false);
};
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }} onClick={onClose}>
<div style={{ background: 'var(--color-bg-primary)', borderRadius: '12px', padding: '24px', width: '440px', maxWidth: '90vw' }} onClick={e => e.stopPropagation()}>
<h3 style={{ margin: '0 0 20px', fontSize: '18px', fontWeight: 600 }}>{coupon ? '编辑优惠券' : '创建优惠券'}</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>优惠券码</label>
<input value={form.code} onChange={e => setForm({ ...form, code: e.target.value })} placeholder="SUMMER2026" style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>类型</label>
<select value={form.type} onChange={e => setForm({ ...form, type: e.target.value })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}>
<option value="fixed">固定金额</option>
<option value="percent">百分比</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>面值</label>
<input type="number" value={form.value} onChange={e => setForm({ ...form, value: parseFloat(e.target.value) || 0 })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>最低消费 (¥)</label>
<input type="number" value={form.min_amount} onChange={e => setForm({ ...form, min_amount: parseFloat(e.target.value) || 0 })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>发放数量</label>
<input type="number" value={form.total_count} onChange={e => setForm({ ...form, total_count: parseInt(e.target.value) || 0 })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>开始日期</label>
<input type="date" value={form.valid_from} onChange={e => setForm({ ...form, valid_from: e.target.value })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>结束日期</label>
<input type="date" value={form.valid_to} onChange={e => setForm({ ...form, valid_to: e.target.value })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
</div>
</div>
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '24px', justifyContent: 'flex-end' }}>
<button onClick={onClose} style={{ padding: '8px 20px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'transparent', color: 'var(--color-text-secondary)', cursor: 'pointer', fontSize: '14px' }}>取消</button>
<button onClick={handleSave} disabled={saving} style={{ padding: '8px 20px', borderRadius: '8px', border: 'none', background: 'var(--color-primary)', color: '#fff', cursor: 'pointer', fontSize: '14px', opacity: saving ? 0.6 : 1 }}>
{saving ? '保存中...' : '保存'}
</button>
</div>
</div>
</div>
);
};
export default Market;