964 lines
51 KiB
TypeScript
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;
|