stage3: 技能/应用市场解耦 - 前端拆分 (/marketplace/products + /admin/market-products)

This commit is contained in:
SeniorDev committed 2026-08-09 06:59:29 +08:00
1 parent c1f8b164bb
commit 048671870a
7 files changed
+1035

No files matched your search

+6
View File
@@ -59,6 +59,9 @@ const UserPortal = React.lazy(() => import('@/pages/UserPortal'));
const WechatBot = React.lazy(() => import('@/pages/front/WechatBot'));
const ImBotHub = React.lazy(() => import('@/pages/front/ImBotHub'));
const AppServices = React.lazy(() => import('@/pages/front/AppServices'));
const MarketProducts = React.lazy(() => import('@/pages/front/MarketProducts'));
const MarketProductDetail = React.lazy(() => import('@/pages/front/MarketProductDetail'));
const MarketProductsAdmin = React.lazy(() => import('@/pages/admin/MarketProductsAdmin'));
const pageFallback = <div className="loading-screen">加载中...</div>;
@@ -127,6 +130,8 @@ const App: React.FC = () => {
<Route path="wechat-bot" element={<WechatBot />} />
<Route path="im-bots" element={<ImBotHub />} />
<Route path="app-services" element={<AppServices />} />
<Route path="/marketplace/products" element={<MarketProducts />} />
<Route path="/marketplace/products/:id" element={<MarketProductDetail />} />
</Route>
{/* Admin Routes */}
@@ -141,6 +146,7 @@ const App: React.FC = () => {
<Route path="membership" element={<AdminMembership />} />
<Route path="ads" element={<AdminAds />} />
<Route path="market" element={<AdminMarket />} />
<Route path="market-products" element={<MarketProductsAdmin />} />
<Route path="logs" element={<AdminLogs />} />
<Route path="skills" element={<AdminSkills />} />
<Route path="agents" element={<AdminAgents />} />
+187
View File
@@ -0,0 +1,187 @@
// 平台应用市场 API 客户端 — 独立可部署产品(MuMuAINovel 类)
// 落库: market_products / market_product_access / market_product_health
const PRIMARY = '#5B5FED';
export interface MarketProduct {
id: string;
slug: string;
name: string;
icon_url?: string | null;
description?: string | null;
category: string; // 分类键:novel / coding / data / creative / education / finance / marketing / other
product_kind: 'saas' | 'desktop' | 'cli' | 'api' | 'mobile' | string;
status: 'pending' | 'active' | 'suspended' | 'deprecated' | string;
docker_image?: string | null;
endpoint_url?: string | null;
billing_plan: 'free' | 'metered' | 'subscription' | 'freemium' | string;
scopes?: string[];
requires_approval: boolean;
created_by_admin: boolean;
published_at?: string | null;
metadata?: Record<string, unknown> | null;
created_at: string;
updated_at: string;
}
export interface MarketProductAccess {
id: string;
user_id: string;
product_id: string;
status: 'pending' | 'approved' | 'rejected' | 'revoked' | string;
requested_at: string;
approved_at?: string | null;
revoked_at?: string | null;
reviewer_admin_id?: string | null;
note?: string | null;
}
export interface MarketProductHealth {
id: number;
product_id: string;
status: 'healthy' | 'degraded' | 'down' | string;
latency_ms?: number | null;
http_status?: number | null;
checked_at: string;
message?: string | null;
}
export interface MarketProductDetail extends MarketProduct {
health?: MarketProductHealth | null;
has_access: boolean;
my_access?: MarketProductAccess | null;
}
export interface PaginatedProducts {
items: MarketProduct[];
total: number;
page: number;
page_size: number;
}
const API_BASE = '/app/api';
function getToken(): string | null {
return localStorage.getItem('aiflow_token');
}
async function request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<{ code: number; data: T; message: string }> {
const token = getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...((options.headers as Record<string, string>) || {}),
};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers,
});
let payload: any = null;
try { payload = await res.json(); } catch { /* no body */ }
if (!res.ok) {
const msg = payload?.message || payload?.detail || `HTTP ${res.status}`;
throw new Error(msg);
}
return payload;
}
// 注意: 别名 PRIMARY 仅保留供页面级样式引用,无运行时依赖
export const PRIMARY_COLOR = PRIMARY;
export const marketProductAPI = {
// 列表(支持 category / product_kind / billing_plan / search 过滤 + 分页)
list: (params?: {
category?: string;
product_kind?: string;
billing_plan?: string;
search?: string;
page?: number;
page_size?: number;
}) => {
const sp = new URLSearchParams();
if (params?.category) sp.set('category', params.category);
if (params?.product_kind) sp.set('product_kind', params.product_kind);
if (params?.billing_plan) sp.set('billing_plan', params.billing_plan);
if (params?.search) sp.set('search', params.search);
if (params?.page) sp.set('page', String(params.page));
if (params?.page_size) sp.set('page_size', String(params.page_size));
const qs = sp.toString();
return request<PaginatedProducts>(`/market/products${qs ? `?${qs}` : ''}`);
},
// 详情(含 health 与当前用户 has_access)
detail: (id: string) => request<MarketProductDetail>(`/market/products/${id}`),
// 申请访问(仅 requires_approval=true 才需走此)
requestAccess: (id: string, payload?: { note?: string }) =>
request<MarketProductAccess>(`/market/products/${id}/request-access`, {
method: 'POST',
body: JSON.stringify(payload || {}),
}),
// 立即打开(OIDC 跳转 URL,已授权用户直接返回 launch_url)
launch: (id: string) =>
request<{ launch_url: string; token?: string; product_name: string }>(
`/market/products/${id}/launch`,
{ method: 'POST' }
),
// 我的已申请/已开通产品
myProducts: () => request<{ items: MarketProductDetail[] }>(`/market/my-products`),
// ===== 后台 API =====
// 列出待审核(后台 admin)
adminPending: () =>
request<{ items: MarketProduct[]; total: number }>(`/admin/market-products/pending`),
// 全部列表(后台)
adminList: (params?: {
category?: string; product_kind?: string; status?: string; search?: string;
page?: number; page_size?: number;
}) => {
const sp = new URLSearchParams();
if (params?.category) sp.set('category', params.category);
if (params?.product_kind) sp.set('product_kind', params.product_kind);
if (params?.status) sp.set('status', params.status);
if (params?.search) sp.set('search', params.search);
if (params?.page) sp.set('page', String(params.page));
if (params?.page_size) sp.set('page_size', String(params.page_size));
const qs = sp.toString();
return request<PaginatedProducts>(`/admin/market-products${qs ? `?${qs}` : ''}`);
},
// 审核/挂起/恢复/下线(后台)
review: (
id: string,
payload: { action: 'approve' | 'suspend' | 'reactivate' | 'deprecate'; reason?: string }
) =>
request<MarketProduct>(`/admin/market-products/${id}/review`, {
method: 'POST',
body: JSON.stringify(payload),
}),
// 健康探针历史(后台)
healthHistory: (id: string, limit = 50) =>
request<{ items: MarketProductHealth[] }>(
`/admin/market-products/${id}/health?limit=${limit}`
),
// 重新注册 OIDC(后台)
reRegisterOidc: (id: string) =>
request<{ success: boolean; oidc_client_id?: string }>(
`/admin/market-products/${id}/re-register-oidc`,
{ method: 'POST' }
),
// 创建新产品(后台)
create: (payload: Partial<MarketProduct>) =>
request<MarketProduct>(`/admin/market-products`, {
method: 'POST',
body: JSON.stringify(payload),
}),
};
export default marketProductAPI;
@@ -53,6 +53,7 @@ const adminNavGroups: NavGroup[] = [
{ path: '/admin/skills', icon: 'skills', label: 'Skills技能管理' },
{ path: '/admin/mcp', icon: 'mcp', label: 'MCP Client 管理' },
{ path: '/admin/market', icon: 'market', label: 'AI 技能审核' },
{ path: '/admin/market-products', icon: 'marketplace', label: '应用产品' },
],
},
@@ -28,6 +28,7 @@ const navGroups = [
title: '资源中心',
items: [
{ path: '/app-services', icon: 'apps', label: '应用服务' },
{ path: '/marketplace/products', icon: 'marketplace', label: '应用市场' },
{ path: '/token', icon: 'token', label: 'Token驾驶舱' },
{ path: '/pricing', icon: 'pricing', label: '会员订阅' },
{ path: '/assets', icon: 'assets', label: '资产文件' },
@@ -46,6 +47,7 @@ const iconPaths: Record<string, JSX.Element> = {
knowledge: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>,
market: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></svg>,
apps: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="2" width="9" height="9" rx="2"/><rect x="13" y="2" width="9" height="9" rx="2"/><rect x="2" y="13" width="9" height="9" rx="2"/><rect x="13" y="13" width="5" height="5" rx="1.5"/><path d="M18 18v3"/><path d="M19.5 16.5v6"/></svg>,
novel: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>,
token: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>,
pricing: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 18V6"/></svg>,
profile: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>,
@@ -0,0 +1,285 @@
// 应用市场 — 后台管理页 /admin/market-products
// 审核 / 挂起 / 恢复 / 下线 + 健康探针历史 + OIDC 重新注册
import React, { useEffect, useState, useCallback } from 'react';
import marketProductAPI, {
type MarketProduct,
type MarketProductHealth,
PRIMARY_COLOR,
} from '@/api/marketProductAPI';
type Action = 'approve' | 'suspend' | 'reactivate' | 'deprecate';
function fmtDate(s?: string | null) {
if (!s) return '—';
try { return new Date(s).toLocaleString('zh-CN', { hour12: false }); } catch { return s; }
}
const STATUS_COLORS: Record<string, { bg: string; fg: string; label: string }> = {
pending: { bg: '#FEF3C7', fg: '#D97706', label: '待审核' },
active: { bg: '#ECFDF5', fg: '#059669', label: '已上架' },
suspended: { bg: '#FEF3C7', fg: '#D97706', label: '已挂起' },
deprecated: { bg: '#F1F5F9', fg: '#64748B', label: '已下线' },
};
const MarketProductsAdmin: React.FC = () => {
const [loading, setLoading] = useState(true);
const [items, setItems] = useState<MarketProduct[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState('');
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<MarketProduct | null>(null);
const [healthHistory, setHealthHistory] = useState<MarketProductHealth[]>([]);
const [busy, setBusy] = useState(false);
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 2500);
};
const load = useCallback(async () => {
setLoading(true);
try {
const res = await marketProductAPI.adminList({
status: statusFilter || undefined,
search: search || undefined,
page,
page_size: 20,
});
const data: any = (res as any).data || res;
setItems(data.items || []);
setTotal(data.total || 0);
} catch (e: any) {
showToast(e?.message || '加载失败', 'error');
} finally {
setLoading(false);
}
}, [statusFilter, search, page]);
useEffect(() => { load(); }, [load]);
const onAction = async (product: MarketProduct, action: Action) => {
let reason = '';
if (action === 'suspend' || action === 'deprecate') {
const input = window.prompt(`请输入${action === 'suspend' ? '挂起' : '下线'}原因:`);
if (input === null) return;
reason = input;
}
setBusy(true);
try {
await marketProductAPI.review(product.id, { action, reason: reason || undefined });
showToast(`已${action === 'approve' ? '通过' : action === 'suspend' ? '挂起' : action === 'reactivate' ? '恢复' : '下线'}「${product.name}」`, 'success');
await load();
if (selected?.id === product.id) setSelected(null);
} catch (e: any) {
showToast(e?.message || '操作失败', 'error');
} finally {
setBusy(false);
}
};
const onSelect = async (p: MarketProduct) => {
setSelected(p);
setHealthHistory([]);
try {
const res = await marketProductAPI.healthHistory(p.id, 30);
const data: any = (res as any).data || res;
setHealthHistory(data.items || []);
} catch { /* ignore */ }
};
const onReRegisterOidc = async (p: MarketProduct) => {
setBusy(true);
try {
await marketProductAPI.reRegisterOidc(p.id);
showToast('OIDC 重新注册请求已提交', 'success');
} catch (e: any) {
showToast(e?.message || '操作失败', 'error');
} finally {
setBusy(false);
}
};
const totalPages = Math.max(1, Math.ceil(total / 20));
return (
<div style={{ padding: 24, background: '#F8F9FC', minHeight: '100%' }}>
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
<div style={{ marginBottom: 20 }}>
<h1 style={{ fontSize: 22, fontWeight: 700, margin: 0, color: '#1A1F36' }}>应用产品管理</h1>
<p style={{ fontSize: 13, color: '#6B7280', margin: '4px 0 0 0' }}>
审核 / 挂起 / 恢复 / 下线 独立可部署产品(MuMuAINovel 等)。共 {total} 条记录。
</p>
</div>
{toast && (
<div style={{ position: 'fixed', top: 20, right: 20, zIndex: 9999, padding: '12px 24px', borderRadius: 8, background: toast.type === 'success' ? '#10b981' : '#ef4444', color: '#fff', fontSize: 14, boxShadow: '0 4px 12px rgba(0,0,0,0.15)' }}>{toast.msg}</div>
)}
{/* 过滤栏 */}
<div style={{ background: '#fff', borderRadius: 10, border: '1px solid #E5E7EB', padding: 16, marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<input
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
placeholder="搜索产品名称 / slug…"
style={{ flex: '1 1 240px', minWidth: 240, padding: '8px 14px', border: '1px solid #E5E7EB', borderRadius: 6, fontSize: 13, outline: 'none' }}
/>
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
style={{ padding: '8px 14px', border: '1px solid #E5E7EB', borderRadius: 6, fontSize: 13, background: '#fff', cursor: 'pointer', minWidth: 120 }}
>
<option value="">全部状态</option>
<option value="pending">待审核</option>
<option value="active">已上架</option>
<option value="suspended">已挂起</option>
<option value="deprecated">已下线</option>
</select>
</div>
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}>
{/* 左: 表格 */}
<div style={{ flex: 1, minWidth: 0, background: '#fff', borderRadius: 10, border: '1px solid #E5E7EB', overflow: 'hidden' }}>
{loading ? (
<div style={{ padding: 60, textAlign: 'center', color: '#9CA3AF' }}>加载中…</div>
) : items.length === 0 ? (
<div style={{ padding: 80, textAlign: 'center', color: '#9CA3AF' }}>
<div style={{ fontSize: 32, marginBottom: 8 }}>📭</div>
<div style={{ fontSize: 14 }}>暂无产品记录</div>
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#F8F9FC', borderBottom: '1px solid #E5E7EB' }}>
<th style={th}>名称</th>
<th style={th}>Slug</th>
<th style={th}>形态</th>
<th style={th}>计费</th>
<th style={th}>状态</th>
<th style={th}>上架时间</th>
<th style={th}>操作</th>
</tr>
</thead>
<tbody>
{items.map((p) => {
const sc = STATUS_COLORS[p.status] || { bg: '#F1F5F9', fg: '#475569', label: p.status };
return (
<tr
key={p.id}
onClick={() => onSelect(p)}
style={{ borderBottom: '1px solid #F1F5F9', cursor: 'pointer', background: selected?.id === p.id ? '#FAFBFF' : 'transparent' }}
>
<td style={td}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 28, height: 28, borderRadius: 6, background: PRIMARY_COLOR + '15', color: PRIMARY_COLOR, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 14, flexShrink: 0 }}>🚀</div>
<span style={{ fontWeight: 500, color: '#1A1F36' }}>{p.name}</span>
</div>
</td>
<td style={{ ...td, fontFamily: 'Menlo, Consolas, monospace', color: '#64748B' }}>{p.slug}</td>
<td style={td}>{p.product_kind}</td>
<td style={td}>{p.billing_plan}</td>
<td style={td}>
<span style={{ display: 'inline-block', padding: '2px 10px', borderRadius: 10, fontSize: 11, background: sc.bg, color: sc.fg, fontWeight: 500 }}>{sc.label}</span>
</td>
<td style={{ ...td, color: '#6B7280', fontSize: 12 }}>{(p.published_at || '').slice(0, 10) || '—'}</td>
<td style={td} onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{p.status === 'pending' && (
<button disabled={busy} onClick={() => onAction(p, 'approve')} style={btnPrimary}>通过</button>
)}
{p.status === 'active' && (
<button disabled={busy} onClick={() => onAction(p, 'suspend')} style={btnWarn}>挂起</button>
)}
{p.status === 'suspended' && (
<button disabled={busy} onClick={() => onAction(p, 'reactivate')} style={btnPrimary}>恢复</button>
)}
{p.status !== 'deprecated' && (
<button disabled={busy} onClick={() => onAction(p, 'deprecate')} style={btnDanger}>下线</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
)}
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, padding: 12, borderTop: '1px solid #F1F5F9', alignItems: 'center' }}>
<button disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))} style={{ ...pageBtn(), opacity: page <= 1 ? 0.4 : 1 }}>上一页</button>
<span style={{ fontSize: 12, color: '#6B7280' }}>第 {page} / {totalPages} 页</span>
<button disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)} style={{ ...pageBtn(), opacity: page >= totalPages ? 0.4 : 1 }}>下一页</button>
</div>
)}
</div>
{/* 右: 详情侧栏 */}
{selected && (
<div style={{ width: 320, flexShrink: 0, background: '#fff', borderRadius: 10, border: '1px solid #E5E7EB', padding: 18, position: 'sticky', top: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: '#1A1F36' }}>{selected.name}</div>
<button onClick={() => setSelected(null)} style={{ background: 'transparent', border: 'none', cursor: 'pointer', color: '#9CA3AF', fontSize: 18, lineHeight: 1 }}>×</button>
</div>
<Row label="Slug" value={selected.slug} mono />
<Row label="形态" value={selected.product_kind} />
<Row label="计费" value={selected.billing_plan} />
<Row label="Endpoint" value={selected.endpoint_url || '—'} mono />
<Row label="Docker" value={selected.docker_image || '—'} mono />
<Row label="需审批" value={selected.requires_approval ? '是' : '否'} />
<Row label="创建时间" value={fmtDate(selected.created_at)} />
<div style={{ marginTop: 14, paddingTop: 14, borderTop: '1px solid #F1F5F9' }}>
<div style={{ fontSize: 12, color: '#9CA3AF', marginBottom: 8 }}>健康探针历史 ({healthHistory.length} 条)</div>
{healthHistory.length === 0 ? (
<div style={{ fontSize: 12, color: '#9CA3AF', padding: '12px 0', textAlign: 'center' }}>暂无记录</div>
) : (
<div style={{ maxHeight: 200, overflowY: 'auto', fontSize: 11 }}>
{healthHistory.slice(0, 15).map((h) => {
const c = h.status === 'healthy' ? '#10b981' : h.status === 'degraded' ? '#f59e0b' : '#ef4444';
return (
<div key={h.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0', borderBottom: '1px dashed #F1F5F9' }}>
<span style={{ color: c, fontWeight: 500 }}>● {h.status}</span>
<span style={{ color: '#94A3B8' }}>{h.latency_ms != null ? `${h.latency_ms}ms` : '—'} · {fmtDate(h.checked_at).slice(5, 16)}</span>
</div>
);
})}
</div>
)}
</div>
<button
onClick={() => onReRegisterOidc(selected)}
disabled={busy}
style={{ width: '100%', marginTop: 12, padding: '8px 14px', background: '#F0F2F8', border: '1px solid #E5E7EB', borderRadius: 6, color: '#1A1F36', fontSize: 12, cursor: busy ? 'not-allowed' : 'pointer' }}
>
重新注册 OIDC
</button>
</div>
)}
</div>
</div>
</div>
);
};
const th: React.CSSProperties = { padding: '12px 14px', textAlign: 'left', fontSize: 12, fontWeight: 600, color: '#475569', whiteSpace: 'nowrap' };
const td: React.CSSProperties = { padding: '12px 14px', color: '#1A1F36' };
const btnPrimary: React.CSSProperties = { padding: '4px 12px', background: PRIMARY_COLOR, color: '#fff', border: 'none', borderRadius: 6, fontSize: 12, cursor: 'pointer' };
const btnWarn: React.CSSProperties = { padding: '4px 12px', background: '#FEF3C7', color: '#D97706', border: 'none', borderRadius: 6, fontSize: 12, cursor: 'pointer' };
const btnDanger: React.CSSProperties = { padding: '4px 12px', background: '#FEE2E2', color: '#DC2626', border: 'none', borderRadius: 6, fontSize: 12, cursor: 'pointer' };
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div style={{ display: 'flex', gap: 8, marginBottom: 6, fontSize: 12 }}>
<span style={{ width: 70, color: '#9CA3AF', flexShrink: 0 }}>{label}</span>
<span style={{ flex: 1, color: '#1A1F36', wordBreak: 'break-all', fontFamily: mono ? 'Menlo, Consolas, monospace' : 'inherit' }}>{value}</span>
</div>
);
}
function pageBtn(): React.CSSProperties {
return { padding: '6px 12px', background: '#F0F2F8', border: '1px solid #E5E7EB', borderRadius: 6, color: '#1A1F36', fontSize: 12, cursor: 'pointer' };
}
export default MarketProductsAdmin;
@@ -0,0 +1,302 @@
// 应用市场 — 详情页 /marketplace/products/:id
// tabs: 概览(endpoint + 健康) / 接入指南(OIDC 跳转) / 评论(预留)
import React, { useEffect, useState, useCallback } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import marketProductAPI, {
type MarketProductDetail,
PRIMARY_COLOR,
} from '@/api/marketProductAPI';
type Tab = 'overview' | 'guide';
function fmtDate(s?: string | null) {
if (!s) return '—';
try {
return new Date(s).toLocaleString('zh-CN', { hour12: false });
} catch { return s; }
}
const MarketProductDetail: React.FC = () => {
const { id = '' } = useParams<{ id: string }>();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [product, setProduct] = useState<MarketProductDetail | null>(null);
const [tab, setTab] = useState<Tab>('overview');
const [accessNote, setAccessNote] = useState('');
const [submitting, setSubmitting] = useState(false);
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
const [launching, setLaunching] = useState(false);
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 2500);
};
const load = useCallback(async () => {
setLoading(true);
try {
const res = await marketProductAPI.detail(id);
const data: any = (res as any).data || res;
setProduct(data);
} catch (e: any) {
showToast(e?.message || '加载详情失败', 'error');
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => { if (id) load(); }, [id, load]);
const submitAccess = async () => {
if (!product) return;
if (product.requires_approval) {
setSubmitting(true);
try {
await marketProductAPI.requestAccess(product.id, { note: accessNote || undefined });
showToast('申请已提交,审核结果将通过站内信通知', 'success');
await load();
} catch (e: any) {
showToast(e?.message || '申请失败', 'error');
} finally {
setSubmitting(false);
}
} else {
// 不需要审核 → 直接 launch
await doLaunch();
}
};
const doLaunch = async () => {
if (!product) return;
setLaunching(true);
try {
const res = await marketProductAPI.launch(product.id);
const data: any = (res as any).data || res;
const url: string = data?.launch_url || data?.url || '';
if (url) {
showToast('正在打开产品…', 'success');
window.open(url, '_blank', 'noopener,noreferrer');
} else {
showToast('无法获取产品访问链接', 'error');
}
} catch (e: any) {
showToast(e?.message || '打开失败', 'error');
} finally {
setLaunching(false);
}
};
if (loading) {
return (
<div style={{ minHeight: '100%', background: '#FFFFFF', padding: 60, textAlign: 'center', color: '#9CA3AF' }}>
加载中…
</div>
);
}
if (!product) {
return (
<div style={{ minHeight: '100%', background: '#FFFFFF', padding: 60, textAlign: 'center', color: '#9CA3AF' }}>
<div style={{ fontSize: 16, marginBottom: 12 }}>未找到该产品</div>
<button onClick={() => navigate('/marketplace/products')} style={{ padding: '8px 20px', background: PRIMARY_COLOR, border: 'none', borderRadius: 8, color: '#fff', fontSize: 14, cursor: 'pointer' }}>返回列表</button>
</div>
);
}
const health = product.health;
const healthOk = health?.status === 'healthy';
const healthColor = healthOk ? '#10b981' : health?.status === 'degraded' ? '#f59e0b' : health?.status === 'down' ? '#ef4444' : '#9CA3AF';
return (
<div style={{ minHeight: '100%', background: '#F8F9FC', color: '#1A1F36', padding: '32px 0' }}>
<div style={{ maxWidth: 1100, margin: '0 auto', padding: '0 24px' }}>
{/* Toast */}
{toast && (
<div style={{ position: 'fixed', top: 20, right: 20, zIndex: 9999, padding: '12px 24px', borderRadius: 8, background: toast.type === 'success' ? '#10b981' : '#ef4444', color: '#fff', fontSize: 14, boxShadow: '0 4px 12px rgba(0,0,0,0.15)' }}>{toast.msg}</div>
)}
{/* Breadcrumb */}
<div style={{ fontSize: 13, color: '#6B7280', marginBottom: 16 }}>
<a onClick={() => navigate('/marketplace/products')} style={{ color: PRIMARY_COLOR, cursor: 'pointer' }}>应用市场</a>
<span style={{ margin: '0 8px' }}>/</span>
<span>{product.name}</span>
</div>
{/* Hero banner */}
<div style={{ background: '#fff', borderRadius: 16, padding: 32, border: '1px solid #E5E7EB', marginBottom: 24, display: 'flex', gap: 24, alignItems: 'flex-start' }}>
<div style={{ width: 80, height: 80, borderRadius: 16, background: PRIMARY_COLOR + '15', color: PRIMARY_COLOR, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 36, flexShrink: 0, overflow: 'hidden' }}>
{product.icon_url ? <img src={product.icon_url} alt="" style={{ width: 56, height: 56, borderRadius: 10, objectFit: 'cover' }} /> : '🚀'}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap', marginBottom: 8 }}>
<h1 style={{ fontSize: 28, fontWeight: 700, margin: 0 }}>{product.name}</h1>
<span style={{ fontSize: 12, padding: '2px 10px', borderRadius: 12, background: '#EEF0FF', color: PRIMARY_COLOR, fontWeight: 500 }}>{product.product_kind}</span>
<span style={{ fontSize: 12, padding: '2px 10px', borderRadius: 12, background: '#F1F5F9', color: '#475569' }}>{product.billing_plan}</span>
<span style={{ display: 'inline-flex', alignItems: 'center', fontSize: 12, color: healthColor, fontWeight: 500 }}>
<span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: '50%', background: healthColor, marginRight: 6 }} />
{health?.status || '未知'} · {health?.latency_ms != null ? `${health.latency_ms} ms` : '—'}
</span>
{product.status === 'suspended' && <span style={{ fontSize: 12, padding: '2px 10px', borderRadius: 12, background: '#FEF3C7', color: '#D97706', fontWeight: 500 }}>已挂起</span>}
</div>
<p style={{ color: '#6B7280', fontSize: 15, lineHeight: 1.7, margin: 0 }}>{product.description || '暂无描述'}</p>
<div style={{ display: 'flex', gap: 16, marginTop: 16, fontSize: 12, color: '#9CA3AF' }}>
<span>v{product.metadata?.version || '1.0.0'}</span>
<span>· 上架于 {fmtDate(product.published_at)}</span>
{product.requires_approval && <span>· 需申请访问</span>}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: 24, alignItems: 'flex-start' }}>
{/* 左: tabs 区 */}
<div style={{ flex: 1, minWidth: 0 }}>
{/* tabs header */}
<div style={{ background: '#fff', borderRadius: '12px 12px 0 0', border: '1px solid #E5E7EB', borderBottom: 'none', display: 'flex' }}>
{(['overview', 'guide'] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
style={{
flex: 0, padding: '14px 22px', fontSize: 14, fontWeight: tab === t ? 600 : 400,
color: tab === t ? PRIMARY_COLOR : '#6B7280',
background: 'transparent', border: 'none',
borderBottom: tab === t ? `2px solid ${PRIMARY_COLOR}` : '2px solid transparent',
cursor: 'pointer', marginBottom: -1,
}}
>
{t === 'overview' ? '概览' : '接入指南'}
</button>
))}
</div>
<div style={{ background: '#fff', borderRadius: '0 0 12px 12px', border: '1px solid #E5E7EB', padding: 28, minHeight: 320 }}>
{tab === 'overview' && (
<div>
<Section title="接入端点">
<FieldRow label="Endpoint URL" value={product.endpoint_url || '—'} mono />
<FieldRow label="Docker 镜像" value={product.docker_image || '—'} mono />
<FieldRow label="最新探针时间" value={fmtDate(health?.checked_at)} />
<FieldRow label="HTTP 状态" value={health?.http_status != null ? String(health.http_status) : '—'} />
</Section>
<Section title="授权范围">
{product.scopes && product.scopes.length > 0 ? (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{product.scopes.map((s) => (
<span key={s} style={{ fontSize: 12, padding: '4px 12px', borderRadius: 12, background: '#EEF0FF', color: PRIMARY_COLOR }}>{s}</span>
))}
</div>
) : <div style={{ fontSize: 13, color: '#9CA3AF' }}>无特殊授权范围</div>}
</Section>
<Section title="健康状态">
<div style={{ padding: 16, background: '#F8F9FC', borderRadius: 8, fontSize: 13, color: '#475569' }}>
最近一次探针: <strong style={{ color: healthColor }}>{health?.status || '未知'}</strong> · {fmtDate(health?.checked_at)}
{health?.message && <div style={{ marginTop: 6, color: '#6B7280' }}>备注: {health.message}</div>}
</div>
</Section>
</div>
)}
{tab === 'guide' && (
<div>
<Section title="接入方式">
<p style={{ fontSize: 14, color: '#475569', lineHeight: 1.8 }}>
本产品采用 OIDC 单点登录。点击右侧"<strong>{product.requires_approval ? '申请访问' : '立即打开'}</strong>"按钮后,
平台会生成一次性 OIDC 跳转地址,自动携带短期 Bearer Token。重定向到产品端后将获得你的身份与权限范围。
</p>
</Section>
<Section title="API 示例(伪代码)">
<pre style={{ background: '#0F172A', color: '#E2E8F0', padding: 16, borderRadius: 8, fontSize: 12, overflowX: 'auto', fontFamily: 'Menlo, Consolas, monospace', lineHeight: 1.6 }}>
{`# 1. 获取跳转地址(后端已封装为 launch_url,浏览器可直接跳转)
POST /app/api/market/products/${product.id}/launch
Authorization: Bearer <your_token>
# 2. 跳转后产品在 Authorization 头拿到短期 token,验证身份后进入主界面
GET ${product.endpoint_url || 'https://.../api/me'}
Authorization: Bearer <short_lived_token>`}
</pre>
</Section>
<Section title="注意事项">
<ul style={{ paddingLeft: 20, fontSize: 13, color: '#475569', lineHeight: 1.8 }}>
<li>OIDC token 有效期 15 分钟,过期需重新跳转获取。</li>
<li>产品运行独立容器,故障与平台主服务隔离。</li>
<li>如需取消授权,在产品内登出或联系客服。</li>
</ul>
</Section>
</div>
)}
</div>
</div>
{/* 右: 申请/打开 sticky */}
<div style={{ width: 260, flexShrink: 0 }}>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #E5E7EB', padding: 20, position: 'sticky', top: 80 }}>
{!product.has_access ? (
<>
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 12, color: '#1A1F36' }}>
{product.requires_approval ? '申请访问权限' : '立即使用'}
</div>
{product.requires_approval && (
<textarea
value={accessNote}
onChange={(e) => setAccessNote(e.target.value)}
placeholder="说明使用场景(可选)…"
style={{ width: '100%', minHeight: 80, padding: 10, border: '1px solid #E5E7EB', borderRadius: 8, fontSize: 13, outline: 'none', boxSizing: 'border-box', marginBottom: 12, resize: 'vertical', fontFamily: 'inherit' }}
/>
)}
<button
onClick={submitAccess}
disabled={submitting || launching}
style={{ width: '100%', padding: '10px 16px', background: PRIMARY_COLOR, border: 'none', borderRadius: 8, color: '#fff', fontSize: 14, fontWeight: 600, cursor: submitting || launching ? 'not-allowed' : 'pointer', opacity: submitting || launching ? 0.6 : 1 }}
>
{submitting ? '提交中…' : launching ? '打开中…' : (product.requires_approval ? '提交申请' : '立即打开')}
</button>
{product.requires_approval && (
<div style={{ fontSize: 12, color: '#9CA3AF', marginTop: 10, textAlign: 'center' }}>
审核结果通常 1-24 小时内通过站内信通知
</div>
)}
</>
) : (
<>
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 12, color: '#1A1F36' }}>已获得访问权限</div>
<div style={{ padding: '8px 12px', background: '#ECFDF5', borderRadius: 6, fontSize: 12, color: '#059669', marginBottom: 12 }}>
开通于 {fmtDate(product.my_access?.approved_at)}
</div>
<button
onClick={doLaunch}
disabled={launching}
style={{ width: '100%', padding: '10px 16px', background: PRIMARY_COLOR, border: 'none', borderRadius: 8, color: '#fff', fontSize: 14, fontWeight: 600, cursor: launching ? 'not-allowed' : 'pointer', opacity: launching ? 0.6 : 1 }}
>
{launching ? '打开中…' : '打开产品'}
</button>
</>
)}
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid #F1F5F9' }}>
<div style={{ fontSize: 12, color: '#9CA3AF', marginBottom: 4 }}>产品 slug</div>
<div style={{ fontFamily: 'Menlo, Consolas, monospace', fontSize: 12, color: '#475569' }}>{product.slug}</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#1A1F36', marginBottom: 12, textTransform: 'uppercase', letterSpacing: 0.5 }}>{title}</div>
{children}
</div>
);
}
function FieldRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div style={{ display: 'flex', gap: 12, marginBottom: 10, fontSize: 13 }}>
<span style={{ width: 110, color: '#9CA3AF' }}>{label}</span>
<span style={{ flex: 1, color: '#1A1F36', wordBreak: 'break-all', fontFamily: mono ? 'Menlo, Consolas, monospace' : 'inherit' }}>{value}</span>
</div>
);
}
export default MarketProductDetail;
+252
View File
@@ -0,0 +1,252 @@
// 应用市场 — 列表页 /marketplace/products
// 展示独立可部署产品(MuMuAINovel 等),支持 category / product_kind / billing 过滤
import React, { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import marketProductAPI, {
type MarketProduct,
PRIMARY_COLOR,
} from '@/api/marketProductAPI';
const CATEGORIES = [
{ key: '', name: '全部分类' },
{ key: 'novel', name: '文学创作' },
{ key: 'coding', name: '编程辅助' },
{ key: 'data', name: '数据分析' },
{ key: 'creative', name: '内容创作' },
{ key: 'education', name: '教育培训' },
{ key: 'finance', name: '财务法务' },
{ key: 'marketing', name: '营销推广' },
{ key: 'other', name: '其他' },
];
const PRODUCT_KINDS = [
{ key: '', name: '全部形态' },
{ key: 'saas', name: 'SaaS' },
{ key: 'api', name: 'API' },
{ key: 'cli', name: 'CLI' },
{ key: 'desktop', name: '桌面应用' },
{ key: 'mobile', name: '移动端' },
];
const BILLINGS = [
{ key: '', name: '全部计费' },
{ key: 'free', name: '免费' },
{ key: 'freemium', name: '免费增值' },
{ key: 'metered', name: '按量付费' },
{ key: 'subscription', name: '订阅' },
];
function badge(text: string, bg: string, fg: string): React.CSSProperties {
return {
fontSize: 11,
padding: '2px 8px',
borderRadius: 4,
background: bg,
color: fg,
fontWeight: 500,
};
}
function healthDot(status?: string): React.CSSProperties {
const c = status === 'healthy' ? '#10b981' : status === 'degraded' ? '#f59e0b' : status === 'down' ? '#ef4444' : '#9CA3AF';
return { display: 'inline-block', width: 8, height: 8, borderRadius: '50%', background: c, marginRight: 6 };
}
const cardBase: React.CSSProperties = {
border: '1px solid #E5E7EB',
borderRadius: 14,
padding: 20,
background: '#fff',
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
display: 'flex',
flexDirection: 'column',
transition: 'all 200ms cubic-bezier(0.2,0.8,0.2,1)',
};
const MarketProducts: React.FC = () => {
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [items, setItems] = useState<MarketProduct[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(12);
const [search, setSearch] = useState('');
const [category, setCategory] = useState('');
const [productKind, setProductKind] = useState('');
const [billing, setBilling] = useState('');
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 2500);
};
const load = useCallback(async () => {
setLoading(true);
try {
const res = await marketProductAPI.list({
category: category || undefined,
product_kind: productKind || undefined,
billing_plan: billing || undefined,
search: search || undefined,
page,
page_size: pageSize,
});
// 与既有后端约定: { code, data, message } — data 形如 { items, total, ... }
const data: any = (res as any).data || res;
setItems(data.items || []);
setTotal(data.total || 0);
} catch (e: any) {
showToast(e?.message || '加载产品市场失败', 'error');
} finally {
setLoading(false);
}
}, [category, productKind, billing, search, page, pageSize]);
useEffect(() => {
load();
}, [load]);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<div style={{ minHeight: '100%', background: '#FFFFFF', color: '#1A1F36', padding: '32px' }}>
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
{/* Header */}
<div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
<h1 style={{ fontSize: 24, fontWeight: 700, margin: 0, color: '#1A1F36' }}>应用市场</h1>
<span style={{ fontSize: 12, color: '#9CA3AF', background: '#F1F5F9', padding: '2px 10px', borderRadius: 10 }}>
应用产品 · 独立可部署
</span>
</div>
<p style={{ color: '#6B7280', fontSize: 14, margin: '6px 0 0 0', maxWidth: 720 }}>
浏览平台已上架的 AI 应用产品。每个产品均为独立可部署服务,通过 OIDC 单点登录接入,健康状态实时探针。
</p>
</div>
{/* 渐迁提示 */}
<div style={{ background: 'linear-gradient(135deg, #EEF0FF 0%, #F5F3FF 100%)', border: '1px solid #DDD6FE', borderRadius: 10, padding: '10px 16px', marginBottom: 20, fontSize: 13, color: '#5B5FED', display: 'flex', alignItems: 'center', gap: 8 }}>
<span>ℹ️</span>
<span>这里的"应用产品"区别于 <a href="/app-services" style={{ color: PRIMARY_COLOR, textDecoration: 'underline' }}>应用服务</a> 和 <a href="/market" style={{ color: PRIMARY_COLOR, textDecoration: 'underline' }}>技能市场</a>。如需安装可调用模块请前往技能市场。</span>
</div>
{/* Toast */}
{toast && (
<div style={{ position: 'fixed', top: 20, right: 20, zIndex: 9999, padding: '12px 24px', borderRadius: 8, background: toast.type === 'success' ? '#10b981' : '#ef4444', color: '#fff', fontSize: 14, boxShadow: '0 4px 12px rgba(0,0,0,0.15)' }}>{toast.msg}</div>
)}
{/* 过滤栏 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 24 }}>
<input
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
placeholder="搜索产品名称 / 简介 / 关键词…"
style={{ width: '100%', padding: '10px 16px', border: '1px solid #E5E7EB', borderRadius: 8, fontSize: 14, outline: 'none', boxSizing: 'border-box' }}
/>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<FilterChips label="分类" items={CATEGORIES} value={category} onChange={(v) => { setCategory(v); setPage(1); }} />
<FilterChips label="形态" items={PRODUCT_KINDS} value={productKind} onChange={(v) => { setProductKind(v); setPage(1); }} />
<FilterChips label="计费" items={BILLINGS} value={billing} onChange={(v) => { setBilling(v); setPage(1); }} />
</div>
</div>
{/* 卡片网格 */}
{loading ? (
<div style={{ padding: 60, textAlign: 'center', color: '#9CA3AF' }}>加载中…</div>
) : items.length === 0 ? (
<div style={{ padding: 80, textAlign: 'center', color: '#9CA3AF', background: '#F8F9FC', borderRadius: 12 }}>
<div style={{ fontSize: 48, marginBottom: 12 }}>🛰️</div>
<div style={{ fontSize: 16, marginBottom: 6 }}>暂无已上架的产品</div>
<div style={{ fontSize: 13 }}>请调整筛选条件,或等待新一轮产品上架</div>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(310px, 1fr))', gap: 16 }}>
{items.map((p) => {
const health = (p as any).health;
return (
<div
key={p.id}
onClick={() => navigate(`/marketplace/products/${p.id}`)}
style={{ ...cardBase, cursor: 'pointer' }}
onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-2px)'; e.currentTarget.style.boxShadow = '0 8px 24px rgba(91,95,237,0.08)'; e.currentTarget.style.borderColor = PRIMARY_COLOR; }}
onMouseLeave={(e) => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = '0 1px 3px rgba(0,0,0,0.04)'; e.currentTarget.style.borderColor = '#E5E7EB'; }}
>
{/* 头 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
<div style={{ width: 48, height: 48, borderRadius: 10, background: PRIMARY_COLOR + '15', color: PRIMARY_COLOR, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 22, overflow: 'hidden', flexShrink: 0 }}>
{p.icon_url ? <img src={p.icon_url} alt="" style={{ width: 32, height: 32, borderRadius: 6, objectFit: 'cover' }} /> : '🚀'}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 600, color: '#1A1F36', marginBottom: 4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<span style={badge((p.product_kind || '').toUpperCase(), '#EEF0FF', PRIMARY_COLOR)}>{p.product_kind}</span>
<span style={badge(p.billing_plan, '#F1F5F9', '#475569')}>{p.billing_plan}</span>
{p.status === 'active' && (
<span style={{ ...badge('运行中', '#ECFDF5', '#059669') }}>
<span style={healthDot('healthy')} />健康
</span>
)}
{p.status === 'suspended' && <span style={badge('已挂起', '#FEF3C7', '#D97706')}>已挂起</span>}
</div>
</div>
</div>
<p style={{ fontSize: 13, color: '#6B7280', lineHeight: 1.7, flex: 1, margin: '0 0 14px 0', display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{p.description || '暂无描述'}</p>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingTop: 12, borderTop: '1px solid #F1F5F9' }}>
<span style={{ fontSize: 12, color: '#9CA3AF' }}>{(p.published_at || '').slice(0, 10) || '未上架'}</span>
<button
onClick={(e) => { e.stopPropagation(); navigate(`/marketplace/products/${p.id}`); }}
style={{ padding: '7px 16px', background: PRIMARY_COLOR, border: 'none', borderRadius: 8, color: '#fff', fontSize: 13, fontWeight: 500, cursor: 'pointer' }}
>
{p.requires_approval ? '申请访问' : '立即查看'}
</button>
</div>
</div>
);
})}
</div>
)}
{/* 分页 */}
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, marginTop: 28, alignItems: 'center' }}>
<button disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))} style={{ ...pageBtn(), opacity: page <= 1 ? 0.4 : 1 }}>上一页</button>
<span style={{ fontSize: 13, color: '#6B7280' }}>第 {page} 页 / 共 {totalPages} 页({total} 个)</span>
<button disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)} style={{ ...pageBtn(), opacity: page >= totalPages ? 0.4 : 1 }}>下一页</button>
</div>
)}
</div>
</div>
);
};
function FilterChips({
label, items, value, onChange,
}: { label: string; items: { key: string; name: string }[]; value: string; onChange: (v: string) => void }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 12, color: '#9CA3AF', marginRight: 2 }}>{label}:</span>
{items.map((it) => {
const active = value === it.key;
return (
<button
key={it.key || 'all'}
onClick={() => onChange(it.key)}
style={{
padding: '5px 12px', borderRadius: 16, fontSize: 12, cursor: 'pointer',
border: '1px solid', borderColor: active ? PRIMARY_COLOR : '#E5E7EB',
background: active ? PRIMARY_COLOR : '#fff', color: active ? '#fff' : '#6B7280',
fontWeight: active ? 500 : 400,
}}
>{it.name}</button>
);
})}
</div>
);
}
function pageBtn(): React.CSSProperties {
return { padding: '8px 16px', background: '#F0F2F8', border: '1px solid #E5E7EB', borderRadius: 8, color: '#1A1F36', fontSize: 14, cursor: 'pointer' };
}
export default MarketProducts;