stage3: 技能/应用市场解耦 - 前端拆分 (/marketplace/products + /admin/market-products)
This commit is contained in:
1 parent
c1f8b164bb
commit
048671870a
7 files changed
+1035
No files matched your search
@@ -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 />} />
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user