diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 148c024..d544556 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 =
加载中...
; @@ -127,6 +130,8 @@ const App: React.FC = () => { } /> } /> } /> + } /> + } /> {/* Admin Routes */} @@ -141,6 +146,7 @@ const App: React.FC = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/marketProductAPI.ts b/frontend/src/api/marketProductAPI.ts new file mode 100644 index 0000000..199b338 --- /dev/null +++ b/frontend/src/api/marketProductAPI.ts @@ -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 | 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( + endpoint: string, + options: RequestInit = {} +): Promise<{ code: number; data: T; message: string }> { + const token = getToken(); + const headers: Record = { + 'Content-Type': 'application/json', + ...((options.headers as Record) || {}), + }; + 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(`/market/products${qs ? `?${qs}` : ''}`); + }, + + // 详情(含 health 与当前用户 has_access) + detail: (id: string) => request(`/market/products/${id}`), + + // 申请访问(仅 requires_approval=true 才需走此) + requestAccess: (id: string, payload?: { note?: string }) => + request(`/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(`/admin/market-products${qs ? `?${qs}` : ''}`); + }, + + // 审核/挂起/恢复/下线(后台) + review: ( + id: string, + payload: { action: 'approve' | 'suspend' | 'reactivate' | 'deprecate'; reason?: string } + ) => + request(`/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) => + request(`/admin/market-products`, { + method: 'POST', + body: JSON.stringify(payload), + }), +}; + +export default marketProductAPI; diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx index 19cea14..26e5e9f 100644 --- a/frontend/src/components/layout/AdminLayout.tsx +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -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: '应用产品' }, ], }, diff --git a/frontend/src/components/layout/FrontLayout.tsx b/frontend/src/components/layout/FrontLayout.tsx index 9974a09..d62e792 100644 --- a/frontend/src/components/layout/FrontLayout.tsx +++ b/frontend/src/components/layout/FrontLayout.tsx @@ -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 = { knowledge: , market: , apps: , + novel: , token: , pricing: , profile: , diff --git a/frontend/src/pages/admin/MarketProductsAdmin.tsx b/frontend/src/pages/admin/MarketProductsAdmin.tsx new file mode 100644 index 0000000..5d81b40 --- /dev/null +++ b/frontend/src/pages/admin/MarketProductsAdmin.tsx @@ -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 = { + 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([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [statusFilter, setStatusFilter] = useState(''); + const [search, setSearch] = useState(''); + const [selected, setSelected] = useState(null); + const [healthHistory, setHealthHistory] = useState([]); + 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 ( +
+
+
+

应用产品管理

+

+ 审核 / 挂起 / 恢复 / 下线 独立可部署产品(MuMuAINovel 等)。共 {total} 条记录。 +

+
+ + {toast && ( +
{toast.msg}
+ )} + + {/* 过滤栏 */} +
+ { 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' }} + /> + +
+ +
+ {/* 左: 表格 */} +
+ {loading ? ( +
加载中…
+ ) : items.length === 0 ? ( +
+
📭
+
暂无产品记录
+
+ ) : ( + + + + + + + + + + + + + + {items.map((p) => { + const sc = STATUS_COLORS[p.status] || { bg: '#F1F5F9', fg: '#475569', label: p.status }; + return ( + onSelect(p)} + style={{ borderBottom: '1px solid #F1F5F9', cursor: 'pointer', background: selected?.id === p.id ? '#FAFBFF' : 'transparent' }} + > + + + + + + + + + ); + })} + +
名称Slug形态计费状态上架时间操作
+
+
🚀
+ {p.name} +
+
{p.slug}{p.product_kind}{p.billing_plan} + {sc.label} + {(p.published_at || '').slice(0, 10) || '—'} e.stopPropagation()}> +
+ {p.status === 'pending' && ( + + )} + {p.status === 'active' && ( + + )} + {p.status === 'suspended' && ( + + )} + {p.status !== 'deprecated' && ( + + )} +
+
+ )} + {totalPages > 1 && ( +
+ + 第 {page} / {totalPages} 页 + +
+ )} +
+ + {/* 右: 详情侧栏 */} + {selected && ( +
+
+
{selected.name}
+ +
+ + + + + + + + +
+
健康探针历史 ({healthHistory.length} 条)
+ {healthHistory.length === 0 ? ( +
暂无记录
+ ) : ( +
+ {healthHistory.slice(0, 15).map((h) => { + const c = h.status === 'healthy' ? '#10b981' : h.status === 'degraded' ? '#f59e0b' : '#ef4444'; + return ( +
+ ● {h.status} + {h.latency_ms != null ? `${h.latency_ms}ms` : '—'} · {fmtDate(h.checked_at).slice(5, 16)} +
+ ); + })} +
+ )} +
+ + +
+ )} +
+
+
+ ); +}; + +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 ( +
+ {label} + {value} +
+ ); +} + +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; diff --git a/frontend/src/pages/front/MarketProductDetail.tsx b/frontend/src/pages/front/MarketProductDetail.tsx new file mode 100644 index 0000000..7d2af28 --- /dev/null +++ b/frontend/src/pages/front/MarketProductDetail.tsx @@ -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(null); + const [tab, setTab] = useState('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 ( +
+ 加载中… +
+ ); + } + + if (!product) { + return ( +
+
未找到该产品
+ +
+ ); + } + + const health = product.health; + const healthOk = health?.status === 'healthy'; + const healthColor = healthOk ? '#10b981' : health?.status === 'degraded' ? '#f59e0b' : health?.status === 'down' ? '#ef4444' : '#9CA3AF'; + + return ( +
+
+ {/* Toast */} + {toast && ( +
{toast.msg}
+ )} + + {/* Breadcrumb */} + + + {/* Hero banner */} +
+
+ {product.icon_url ? : '🚀'} +
+
+
+

{product.name}

+ {product.product_kind} + {product.billing_plan} + + + {health?.status || '未知'} · {health?.latency_ms != null ? `${health.latency_ms} ms` : '—'} + + {product.status === 'suspended' && 已挂起} +
+

{product.description || '暂无描述'}

+
+ v{product.metadata?.version || '1.0.0'} + · 上架于 {fmtDate(product.published_at)} + {product.requires_approval && · 需申请访问} +
+
+
+ +
+ {/* 左: tabs 区 */} +
+ {/* tabs header */} +
+ {(['overview', 'guide'] as Tab[]).map((t) => ( + + ))} +
+
+ {tab === 'overview' && ( +
+
+ + + + +
+
+ {product.scopes && product.scopes.length > 0 ? ( +
+ {product.scopes.map((s) => ( + {s} + ))} +
+ ) :
无特殊授权范围
} +
+
+
+ 最近一次探针: {health?.status || '未知'} · {fmtDate(health?.checked_at)} + {health?.message &&
备注: {health.message}
} +
+
+
+ )} + {tab === 'guide' && ( +
+
+

+ 本产品采用 OIDC 单点登录。点击右侧"{product.requires_approval ? '申请访问' : '立即打开'}"按钮后, + 平台会生成一次性 OIDC 跳转地址,自动携带短期 Bearer Token。重定向到产品端后将获得你的身份与权限范围。 +

+
+
+
+{`# 1. 获取跳转地址(后端已封装为 launch_url,浏览器可直接跳转)
+POST /app/api/market/products/${product.id}/launch
+Authorization: Bearer 
+
+# 2. 跳转后产品在 Authorization 头拿到短期 token,验证身份后进入主界面
+GET ${product.endpoint_url || 'https://.../api/me'}
+Authorization: Bearer `}
+                    
+
+
+
    +
  • OIDC token 有效期 15 分钟,过期需重新跳转获取。
  • +
  • 产品运行独立容器,故障与平台主服务隔离。
  • +
  • 如需取消授权,在产品内登出或联系客服。
  • +
+
+
+ )} +
+
+ + {/* 右: 申请/打开 sticky */} +
+
+ {!product.has_access ? ( + <> +
+ {product.requires_approval ? '申请访问权限' : '立即使用'} +
+ {product.requires_approval && ( +