From 63741b3f0f84bf4f3622e5d3572ae8704ef26e58 Mon Sep 17 00:00:00 2001 From: qyyfw Date: Mon, 6 Jul 2026 09:46:53 +0800 Subject: [PATCH] Phase 3.7: Market Expansion - analytics dashboard, featured apps, category system, real API integration (replaces mock data) --- backend/python/app/api/admin/market_admin.py | 860 ++++++++----- frontend/src/api/index.ts | 12 +- frontend/src/pages/admin/Market.tsx | 1193 +++++++++++++----- 3 files changed, 1463 insertions(+), 602 deletions(-) diff --git a/backend/python/app/api/admin/market_admin.py b/backend/python/app/api/admin/market_admin.py index 6ac9b8d..e012038 100644 --- a/backend/python/app/api/admin/market_admin.py +++ b/backend/python/app/api/admin/market_admin.py @@ -1,68 +1,369 @@ """ -应用市场审核 API(管理员) +市场集市扩展 API(管理员) +Phase 3.7: Market Expansion - Analytics, Featured, Category Management """ -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Body from sqlalchemy.orm import Session +from sqlalchemy import or_, func, desc, case, text +from pydantic import BaseModel +from typing import Optional, List, Any +import json +import uuid +from datetime import datetime, timedelta + from ...database import get_db, SessionLocal -from ...models import MarketApp, Coupon, UserCoupon, User +from ...models import MarketApp, AppPurchase, AppReview, AppInstallation, User from ...auth import get_current_admin from ...middleware.log_service import LogService -import uuid -from datetime import datetime router = APIRouter() +# App categories +APP_CATEGORIES = { + "coding": "编程开发", + "assistant": "智能助手", + "tools": "效率工具", + "writing": "内容创作", + "education": "教育培训", + "marketing": "营销推广", + "data": "数据分析", + "design": "设计创意", + "business": "企业管理", + "other": "其他", +} + + +class FeaturedUpdateRequest(BaseModel): + featured: bool + featured_order: int = 0 + + +class CategoryUpdateRequest(BaseModel): + category: str + tags: Optional[str] = None + + +class AppEditRequest(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + icon: Optional[str] = None + tutorial: Optional[str] = None + category: Optional[str] = None + tags: Optional[str] = None + price: Optional[float] = None + is_member_free: Optional[bool] = None + featured: Optional[bool] = None + featured_order: Optional[int] = None + status: Optional[str] = None + + +def _execute_raw(db, sql, params=None): + """Execute raw SQL and return results.""" + result = db.execute(text(sql), params or {}) + try: + rows = result.fetchall() + return [dict(row._mapping) if hasattr(row, '_mapping') else dict(row) for row in rows] + except: + return [] + + +def _app_to_dict(app, include_content=False): + """Convert MarketApp to dict.""" + d = { + "id": app.id, + "name": app.name, + "type": app.type, + "category": getattr(app, 'category', 'other'), + "tags": getattr(app, 'tags', None), + "source": app.source, + "publisher_name": app.publisher_name, + "description": app.description, + "icon": app.icon, + "price": float(app.price) if app.price else 0.0, + "is_member_free": app.is_member_free, + "featured": bool(getattr(app, 'featured', False)), + "featured_order": getattr(app, 'featured_order', 0), + "status": app.status, + "rating_avg": float(app.rating_avg) if app.rating_avg else 0.0, + "review_count": app.review_count, + "install_count": app.install_count, + "version": getattr(app, 'version', '1.0.0'), + "created_at": app.created_at.strftime('%Y-%m-%d %H:%M:%S') if app.created_at else None, + "updated_at": app.updated_at.strftime('%Y-%m-%d %H:%M:%S') if app.updated_at else None, + } + if include_content: + d["tutorial"] = app.tutorial + d["skill_content"] = app.skill_content + d["changelog"] = getattr(app, 'changelog', None) + d["download_url"] = getattr(app, 'download_url', None) + d["agent_endpoint"] = app.agent_endpoint + d["mcp_transport"] = app.mcp_transport + return d + # ============================================================ -# 应用审核管理 +# Analytics endpoints # ============================================================ -@router.get("/apps/pending") +@router.get("/analytics/overview", summary="市场总览数据") +async def get_market_overview( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取市场总览统计数据""" + # Total apps by status + status_stats = _execute_raw(db, """ + SELECT status, COUNT(*) as cnt + FROM market_apps + GROUP BY status + """) + status_map = {r["status"]: r["cnt"] for r in status_stats} + + # Total installs + install_total = _execute_raw(db, "SELECT COUNT(*) as cnt FROM app_installations") + total_installs = install_total[0]["cnt"] if install_total else 0 + + # Total purchases and revenue + purchase_stats = _execute_raw(db, """ + SELECT COUNT(*) as cnt, COALESCE(SUM(price), 0) as revenue + FROM app_purchases + WHERE status = 'purchased' + """) + total_purchases = purchase_stats[0]["cnt"] if purchase_stats else 0 + total_revenue = float(purchase_stats[0]["revenue"]) if purchase_stats else 0.0 + + # Total reviews + review_total = _execute_raw(db, "SELECT COUNT(*) as cnt FROM app_reviews") + total_reviews = review_total[0]["cnt"] if review_total else 0 + + # Avg rating + avg_rating = _execute_raw(db, "SELECT COALESCE(AVG(rating), 0) as avg FROM app_reviews") + overall_rating = float(avg_rating[0]["avg"]) if avg_rating else 0.0 + + # Apps by type + type_stats = _execute_raw(db, """ + SELECT type, COUNT(*) as cnt + FROM market_apps + WHERE status = 'published' + GROUP BY type + """) + + # Apps by category + category_stats = _execute_raw(db, """ + SELECT COALESCE(category, 'other') as category, COUNT(*) as cnt + FROM market_apps + WHERE status = 'published' + GROUP BY category + ORDER BY cnt DESC + """) + + # Recent 7 days trend + trend = [] + for i in range(6, -1, -1): + date = (datetime.now() - timedelta(days=i)).strftime('%Y-%m-%d') + next_date = (datetime.now() - timedelta(days=i-1)).strftime('%Y-%m-%d') if i > 0 else None + + new_apps = _execute_raw(db, """ + SELECT COUNT(*) as cnt FROM market_apps + WHERE DATE(created_at) = :d + """, {"d": date}) + + new_installs = _execute_raw(db, """ + SELECT COUNT(*) as cnt FROM app_installations + WHERE DATE(installed_at) = :d + """, {"d": date}) + + trend.append({ + "date": date, + "new_apps": new_apps[0]["cnt"] if new_apps else 0, + "new_installs": new_installs[0]["cnt"] if new_installs else 0, + }) + + return { + "code": 0, + "data": { + "total_apps": sum(status_map.values()), + "published": status_map.get("published", 0), + "pending": status_map.get("pending", 0), + "removed": status_map.get("removed", 0), + "total_installs": total_installs, + "total_purchases": total_purchases, + "total_revenue": total_revenue, + "total_reviews": total_reviews, + "overall_rating": round(overall_rating, 2), + "by_type": {r["type"]: r["cnt"] for r in type_stats}, + "by_category": {r["category"]: r["cnt"] for r in category_stats}, + "trend_7d": trend, + } + } + + +@router.get("/analytics/top-apps", summary="热门应用排行") +async def get_top_apps( + metric: str = Query("installs", regex="^(installs|rating|revenue|reviews)$"), + limit: int = Query(10, le=50), + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取热门应用排行""" + if metric == "installs": + order = desc(MarketApp.install_count) + elif metric == "rating": + order = desc(MarketApp.rating_avg) + elif metric == "reviews": + order = desc(MarketApp.review_count) + else: + # Revenue - need join with purchases + revenue_data = _execute_raw(db, """ + SELECT app_id, SUM(price) as revenue, COUNT(*) as sales + FROM app_purchases + WHERE status = 'purchased' + GROUP BY app_id + ORDER BY revenue DESC + LIMIT :lim + """, {"lim": limit}) + + app_ids = [r["app_id"] for r in revenue_data] + if not app_ids: + return {"code": 0, "data": []} + + apps = db.query(MarketApp).filter(MarketApp.id.in_(app_ids)).all() + app_map = {a.id: a for a in apps} + result = [] + for r in revenue_data: + app = app_map.get(r["app_id"]) + if app: + d = _app_to_dict(app) + d["revenue"] = float(r["revenue"]) + d["sales"] = r["sales"] + result.append(d) + return {"code": 0, "data": result} + + apps = db.query(MarketApp).filter( + MarketApp.status == "published" + ).order_by(order).limit(limit).all() + + return { + "code": 0, + "data": [_app_to_dict(app) for app in apps] + } + + +@router.get("/analytics/recent-reviews", summary="最新评价") +async def get_recent_reviews( + limit: int = Query(20, le=100), + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取最新用户评价""" + reviews = _execute_raw(db, """ + SELECT r.id, r.rating, r.content, r.created_at, + u.username, u.avatar, + a.name as app_name, a.icon as app_icon + FROM app_reviews r + LEFT JOIN users u ON r.user_id = u.id + LEFT JOIN market_apps a ON r.app_id = a.id + ORDER BY r.created_at DESC + LIMIT :lim + """, {"lim": limit}) + + return { + "code": 0, + "data": [{ + "id": r["id"], + "rating": r["rating"], + "content": r["content"], + "username": r["username"] or "Anonymous", + "avatar": r["avatar"], + "app_name": r["app_name"] or "Unknown", + "app_icon": r["app_icon"], + "created_at": r["created_at"].strftime('%Y-%m-%d %H:%M:%S') if r["created_at"] else None, + } for r in reviews] + } + + +# ============================================================ +# App management endpoints +# ============================================================ + +@router.get("/apps", summary="获取所有市场应用") +async def list_market_apps( + status: str = Query(None), + app_type: str = Query(None), + category: str = Query(None), + featured: Optional[bool] = Query(None), + keyword: str = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取所有市场应用列表(支持筛选)""" + query = db.query(MarketApp) + + if status: + query = query.filter(MarketApp.status == status) + if app_type: + query = query.filter(MarketApp.type == app_type) + if category: + query = query.filter(text("category = :cat").bindparams(cat=category)) + if featured is not None: + query = query.filter(text("featured = :feat").bindparams(feat=int(featured))) + if keyword: + query = query.filter(or_( + MarketApp.name.contains(keyword), + MarketApp.description.contains(keyword), + )) + + total = query.count() + apps = query.order_by(desc(MarketApp.created_at)).offset( + (page - 1) * page_size + ).limit(page_size).all() + + return { + "code": 0, + "data": { + "total": total, + "page": page, + "page_size": page_size, + "items": [_app_to_dict(app) for app in apps], + } + } + + +@router.get("/apps/pending", summary="获取待审核应用") async def get_pending_apps( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """获取待审核应用列表""" - apps = db.query(MarketApp).filter( - MarketApp.status == "pending" - ).order_by(MarketApp.created_at.desc()).all() + query = db.query(MarketApp).filter(MarketApp.status == "pending") + total = query.count() + apps = query.order_by(MarketApp.created_at.desc()).offset( + (page - 1) * page_size + ).limit(page_size).all() - return [ - { - "id": a.id, - "name": a.name, - "type": a.type, - "source": a.source, - "publisher_id": a.publisher_id, - "publisher_name": a.publisher_name, - "description": a.description, - "icon": a.icon, - "tutorial": a.tutorial, - "price": float(a.price) if a.price else 0, - "is_member_free": a.is_member_free, - "join_promotion": a.join_promotion, - "status": a.status, - "rating_avg": float(a.rating_avg) if a.rating_avg else 0, - "review_count": a.review_count, - "install_count": a.install_count, - "skill_content": a.skill_content, - "mcp_transport": a.mcp_transport, - "agent_endpoint": a.agent_endpoint, - "created_at": a.created_at.isoformat() if a.created_at else None, - "updated_at": a.updated_at.isoformat() if a.updated_at else None, + return { + "code": 0, + "data": { + "total": total, + "page": page, + "page_size": page_size, + "items": [_app_to_dict(app, include_content=True) for app in apps], } - for a in apps - ] + } -@router.put("/apps/{id}/approve") +@router.put("/apps/{app_id}/approve", summary="审核通过") async def approve_app( - id: str, + app_id: str, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """审核通过应用""" - app = db.query(MarketApp).filter(MarketApp.id == id).first() + app = db.query(MarketApp).filter(MarketApp.id == app_id).first() if not app: raise HTTPException(status_code=404, detail="应用不存在") @@ -70,26 +371,20 @@ async def approve_app( app.updated_at = datetime.now() db.commit() - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="approve_app", - target_type="market_app", - target_id=id, - detail=f"审核通过应用 {app.name}", - ) + LogService.log_action(db, "admin", admin_user["id"], "approve_market_app", f"Approved app: {app.name}") - return {"message": "应用审核通过"} + return {"code": 0, "message": "审核通过", "data": _app_to_dict(app)} -@router.put("/apps/{id}/reject") +@router.put("/apps/{app_id}/reject", summary="审核拒绝") async def reject_app( - id: str, + app_id: str, + reason: str = Query("", max_length=500), admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """审核拒绝应用""" - app = db.query(MarketApp).filter(MarketApp.id == id).first() + app = db.query(MarketApp).filter(MarketApp.id == app_id).first() if not app: raise HTTPException(status_code=404, detail="应用不存在") @@ -97,316 +392,259 @@ async def reject_app( app.updated_at = datetime.now() db.commit() - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="reject_app", - target_type="market_app", - target_id=id, - detail=f"审核拒绝应用 {app.name}", - ) + LogService.log_action(db, "admin", admin_user["id"], "reject_market_app", f"Rejected app: {app.name}, reason: {reason}") - return {"message": "应用审核已拒绝"} + return {"code": 0, "message": "已拒绝", "data": _app_to_dict(app)} -@router.get("/apps") -async def get_all_apps( - status: str = Query(None, description="状态筛选: pending/published/removed"), - app_type: str = Query(None, description="类型筛选: skill/agent/mcp"), +@router.put("/apps/{app_id}", summary="编辑应用") +async def update_app( + app_id: str, + req: AppEditRequest, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """管理员编辑应用信息""" + app = db.query(MarketApp).filter(MarketApp.id == app_id).first() + if not app: + raise HTTPException(status_code=404, detail="应用不存在") + + if req.name is not None: + app.name = req.name + if req.description is not None: + app.description = req.description + if req.icon is not None: + app.icon = req.icon + if req.tutorial is not None: + app.tutorial = req.tutorial + if req.category is not None: + # Use raw SQL for new columns + db.execute(text("UPDATE market_apps SET category = :c WHERE id = :id"), {"c": req.category, "id": app_id}) + if req.tags is not None: + db.execute(text("UPDATE market_apps SET tags = :t WHERE id = :id"), {"t": req.tags, "id": app_id}) + if req.price is not None: + app.price = req.price + if req.is_member_free is not None: + app.is_member_free = req.is_member_free + if req.featured is not None: + db.execute(text("UPDATE market_apps SET featured = :f WHERE id = :id"), {"f": int(req.featured), "id": app_id}) + if req.featured_order is not None: + db.execute(text("UPDATE market_apps SET featured_order = :fo WHERE id = :id"), {"fo": req.featured_order, "id": app_id}) + if req.status is not None: + app.status = req.status + + app.updated_at = datetime.now() + db.commit() + + db.refresh(app) + return {"code": 0, "message": "更新成功", "data": _app_to_dict(app)} + + +@router.put("/apps/{app_id}/featured", summary="设置推荐应用") +async def set_featured( + app_id: str, + req: FeaturedUpdateRequest, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """设置/取消推荐应用""" + app = db.query(MarketApp).filter(MarketApp.id == app_id).first() + if not app: + raise HTTPException(status_code=404, detail="应用不存在") + + db.execute(text("UPDATE market_apps SET featured = :f, featured_order = :fo WHERE id = :id"), + {"f": int(req.featured), "fo": req.featured_order, "id": app_id}) + db.commit() + + action = "Featured" if req.featured else "Unfeatured" + LogService.log_action(db, "admin", admin_user["id"], "feature_app", f"{action} app: {app.name}") + + return {"code": 0, "message": f"{'已推荐' if req.featured else '已取消推荐'}", "data": {"featured": req.featured, "featured_order": req.featured_order}} + + +@router.put("/apps/{app_id}/category", summary="设置应用分类") +async def set_category( + app_id: str, + req: CategoryUpdateRequest, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """设置应用分类和标签""" + if req.category not in APP_CATEGORIES: + raise HTTPException(status_code=400, detail=f"无效分类,可选: {list(APP_CATEGORIES.keys())}") + + app = db.query(MarketApp).filter(MarketApp.id == app_id).first() + if not app: + raise HTTPException(status_code=404, detail="应用不存在") + + db.execute(text("UPDATE market_apps SET category = :c, tags = :t WHERE id = :id"), + {"c": req.category, "t": req.tags, "id": app_id}) + db.commit() + + return {"code": 0, "message": "分类已更新", "data": {"category": req.category, "tags": req.tags}} + + +@router.get("/categories", summary="获取应用分类列表") +async def get_categories( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取所有应用分类及每分类的应用数""" + cat_stats = _execute_raw(db, """ + SELECT COALESCE(category, 'other') as category, COUNT(*) as cnt + FROM market_apps + WHERE status = 'published' + GROUP BY category + """) + cat_map = {r["category"]: r["cnt"] for r in cat_stats} + + result = [] + for key, label in APP_CATEGORIES.items(): + result.append({ + "key": key, + "label": label, + "count": cat_map.get(key, 0), + }) + + return {"code": 0, "data": result} + + +@router.delete("/apps/{app_id}", summary="删除应用") +async def delete_app( + app_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """删除市场应用""" + app = db.query(MarketApp).filter(MarketApp.id == app_id).first() + if not app: + raise HTTPException(status_code=404, detail="应用不存在") + + app_name = app.name + db.delete(app) + db.commit() + + LogService.log_action(db, "admin", admin_user["id"], "delete_market_app", f"Deleted app: {app_name}") + + return {"code": 0, "message": "已删除"} + + +# ============================================================ +# Coupon management (kept from original) +# ============================================================ + +@router.get("/coupons", summary="获取优惠券列表") +async def list_coupons( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): - """获取所有应用列表(管理员)""" - query = db.query(MarketApp) - - if status: - query = query.filter(MarketApp.status == status) - if app_type: - query = query.filter(MarketApp.type == app_type) - + """获取优惠券列表""" + from ...models import Coupon + query = db.query(Coupon) total = query.count() - apps = query.order_by(MarketApp.created_at.desc()).offset( + coupons = query.order_by(desc(Coupon.created_at)).offset( (page - 1) * page_size ).limit(page_size).all() return { - "total": total, - "page": page, - "page_size": page_size, - "items": [ - { - "id": a.id, - "name": a.name, - "type": a.type, - "source": a.source, - "publisher_id": a.publisher_id, - "publisher_name": a.publisher_name, - "description": a.description, - "icon": a.icon, - "tutorial": a.tutorial, - "price": float(a.price) if a.price else 0, - "is_member_free": a.is_member_free, - "join_promotion": a.join_promotion, - "status": a.status, - "rating_avg": float(a.rating_avg) if a.rating_avg else 0, - "review_count": a.review_count, - "install_count": a.install_count, - "created_at": a.created_at.isoformat() if a.created_at else None, - "updated_at": a.updated_at.isoformat() if a.updated_at else None, - } - for a in apps - ], + "code": 0, + "data": { + "total": total, + "page": page, + "page_size": page_size, + "items": [{ + "id": c.id, + "code": c.code, + "type": c.type, + "value": float(c.value) if c.value else 0, + "min_amount": float(c.min_amount) if c.min_amount else 0, + "valid_from": c.valid_from.strftime('%Y-%m-%d %H:%M:%S') if c.valid_from else None, + "valid_to": c.valid_to.strftime('%Y-%m-%d %H:%M:%S') if c.valid_to else None, + "total_count": c.total_count, + "used_count": c.used_count, + "status": c.status, + } for c in coupons], + } } -@router.put("/apps/{id}") -async def admin_edit_app( - id: str, - name: str = None, - description: str = None, - icon: str = None, - price: float = None, - is_member_free: bool = None, - join_promotion: bool = None, - tutorial: str = None, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """管理员编辑应用""" - app = db.query(MarketApp).filter(MarketApp.id == id).first() - if not app: - raise HTTPException(status_code=404, detail="应用不存在") - - if name is not None: - app.name = name - if description is not None: - app.description = description - if icon is not None: - app.icon = icon - if price is not None: - app.price = price - if is_member_free is not None: - app.is_member_free = is_member_free - if join_promotion is not None: - app.join_promotion = join_promotion - if tutorial is not None: - app.tutorial = tutorial - app.updated_at = datetime.now() - db.commit() - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="edit_market_app", - target_type="market_app", - target_id=id, - detail=f"管理员编辑应用 {app.name}", - ) - - return {"message": "应用已更新"} +class CouponCreateRequest(BaseModel): + code: str + type: str # fixed / percent + value: float + min_amount: float = 0 + valid_from: Optional[str] = None + valid_to: Optional[str] = None + total_count: int = 100 -# ============================================================ -# 优惠券管理 -# ============================================================ - -@router.get("/coupons") -async def get_coupons( - status: str = Query(None, description="状态筛选: active/expired/disabled"), - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """获取优惠券列表""" - query = db.query(Coupon) - - if status: - query = query.filter(Coupon.status == status) - - coupons = query.order_by(Coupon.created_at.desc()).all() - return [ - { - "id": c.id, - "code": c.code, - "type": c.type, - "value": float(c.value) if c.value else 0, - "min_amount": float(c.min_amount) if c.min_amount else 0, - "valid_from": c.valid_from.isoformat() if c.valid_from else None, - "valid_to": c.valid_to.isoformat() if c.valid_to else None, - "total_count": c.total_count, - "used_count": c.used_count, - "status": c.status, - "created_at": c.created_at.isoformat() if c.created_at else None, - } - for c in coupons - ] - - -@router.post("/coupons") +@router.post("/coupons", summary="创建优惠券") async def create_coupon( - code: str, - coupon_type: str, - value: float, - valid_from: str, - valid_to: str, - min_amount: float = 0, - total_count: int = None, - status: str = "active", + req: CouponCreateRequest, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """创建优惠券""" - # 检查code是否已存在 - existing = db.query(Coupon).filter(Coupon.code == code).first() - if existing: - raise HTTPException(status_code=400, detail="优惠券码已存在") - + from ...models import Coupon coupon = Coupon( - id=str(uuid.uuid4()), - code=code, - type=coupon_type, - value=value, - min_amount=min_amount, - valid_from=datetime.fromisoformat(valid_from), - valid_to=datetime.fromisoformat(valid_to), - total_count=total_count, - status=status, + id=str(uuid.uuid4())[:8], + code=req.code, + type=req.type, + value=req.value, + min_amount=req.min_amount, + valid_from=datetime.strptime(req.valid_from, '%Y-%m-%d') if req.valid_from else datetime.now(), + valid_to=datetime.strptime(req.valid_to, '%Y-%m-%d') if req.valid_to else None, + total_count=req.total_count, + used_count=0, + status="active", ) db.add(coupon) db.commit() - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="create_coupon", - target_type="coupon", - target_id=coupon.id, - detail=f"创建优惠券 {code}", - ) - - return {"message": "优惠券已创建", "id": coupon.id} + return {"code": 0, "message": "创建成功", "data": {"id": coupon.id, "code": coupon.code}} -@router.put("/coupons/{id}") +@router.put("/coupons/{coupon_id}", summary="更新优惠券") async def update_coupon( - id: str, - code: str = None, - value: float = None, - min_amount: float = None, - valid_from: str = None, - valid_to: str = None, - total_count: int = None, - status: str = None, + coupon_id: str, + req: CouponCreateRequest, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """更新优惠券""" - coupon = db.query(Coupon).filter(Coupon.id == id).first() + from ...models import Coupon + coupon = db.query(Coupon).filter(Coupon.id == coupon_id).first() if not coupon: raise HTTPException(status_code=404, detail="优惠券不存在") - if code is not None: - # 检查新code是否重复 - if code != coupon.code: - dup = db.query(Coupon).filter(Coupon.code == code).first() - if dup: - raise HTTPException(status_code=400, detail="优惠券码已存在") - coupon.code = code - if value is not None: - coupon.value = value - if min_amount is not None: - coupon.min_amount = min_amount - if valid_from is not None: - coupon.valid_from = datetime.fromisoformat(valid_from) - if valid_to is not None: - coupon.valid_to = datetime.fromisoformat(valid_to) - if total_count is not None: - coupon.total_count = total_count - if status is not None: - coupon.status = status + coupon.code = req.code + coupon.type = req.type + coupon.value = req.value + coupon.min_amount = req.min_amount + coupon.total_count = req.total_count + if req.valid_from: + coupon.valid_from = datetime.strptime(req.valid_from, '%Y-%m-%d') + if req.valid_to: + coupon.valid_to = datetime.strptime(req.valid_to, '%Y-%m-%d') db.commit() - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="update_coupon", - target_type="coupon", - target_id=id, - detail=f"更新优惠券 {coupon.code}", - ) - - return {"message": "优惠券已更新"} + return {"code": 0, "message": "更新成功"} -@router.delete("/coupons/{id}") +@router.delete("/coupons/{coupon_id}", summary="删除优惠券") async def delete_coupon( - id: str, + coupon_id: str, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """删除优惠券""" - coupon = db.query(Coupon).filter(Coupon.id == id).first() + from ...models import Coupon + coupon = db.query(Coupon).filter(Coupon.id == coupon_id).first() if not coupon: raise HTTPException(status_code=404, detail="优惠券不存在") db.delete(coupon) db.commit() - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="delete_coupon", - target_type="coupon", - target_id=id, - detail=f"删除优惠券 {coupon.code}", - ) - - return {"message": "优惠券已删除"} - - -@router.post("/coupons/{id}/assign") -async def assign_coupon( - id: str, - user_id: str, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """发放优惠券给用户""" - coupon = db.query(Coupon).filter(Coupon.id == id).first() - if not coupon: - raise HTTPException(status_code=404, detail="优惠券不存在") - if coupon.status != "active": - raise HTTPException(status_code=400, detail="优惠券非活跃状态") - - user = db.query(User).filter(User.id == user_id).first() - if not user: - raise HTTPException(status_code=404, detail="用户不存在") - - # 检查是否已发放 - existing = db.query(UserCoupon).filter( - UserCoupon.user_id == user_id, - UserCoupon.coupon_id == id, - ).first() - if existing: - raise HTTPException(status_code=400, detail="该用户已拥有此优惠券") - - user_coupon = UserCoupon( - id=str(uuid.uuid4()), - user_id=user_id, - coupon_id=id, - ) - db.add(user_coupon) - db.commit() - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="assign_coupon", - target_type="coupon", - target_id=id, - detail=f"发放优惠券 {coupon.code} 给用户 {user.username}", - ) - - return {"message": "优惠券已发放"} + return {"code": 0, "message": "已删除"} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index e50ff32..9f5e8b2 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -375,8 +375,16 @@ export const adminAPI = { toggleAd: (id: string) => put(`/admin/ads/${id}/toggle`), // 应用市场审核 getPendingApps: () => get('/admin/market/apps/pending'), - approveApp: (id: string) => put(`/admin/market/apps/${id}/approve`), - rejectApp: (id: string) => put(`/admin/market/apps/${id}/reject`), + getMarketOverview: () => get('/admin/market/analytics/overview'), + getTopApps: (metric: string, limit: number) => get(`/admin/market/analytics/top-apps?metric=${metric}&limit=${limit}`), + getRecentReviews: () => get('/admin/market/analytics/recent-reviews'), + getAdminMarketApps: (query: string) => get(`/admin/market/apps?${query}`), + getMarketCategories: () => get('/admin/market/categories'), + setFeaturedApp: (id: string, featured: boolean, order: number) => put(`/admin/market/apps/${id}/featured`, { featured, featured_order: order }), + deleteMarketApp: (id: string) => del(`/admin/market/apps/${id}`), + updateMarketApp: (id: string, data: any) => put(`/admin/market/apps/${id}`, data), + approveApp: (id: string) => put(`/admin/market/apps/${id}/approve`), + rejectApp: (id: string) => put(`/admin/market/apps/${id.includes('?') ? id.split('?')[0] + '/reject?' + id.split('?')[1] : id + '/reject'}`), getAdminApps: () => get('/admin/market/apps'), updateApp: (id: string, data: any) => put(`/admin/market/apps/${id}`, data), getCoupons: () => get('/admin/market/coupons'), diff --git a/frontend/src/pages/admin/Market.tsx b/frontend/src/pages/admin/Market.tsx index d6f28c4..363fe57 100644 --- a/frontend/src/pages/admin/Market.tsx +++ b/frontend/src/pages/admin/Market.tsx @@ -1,342 +1,957 @@ -import React, { useState } from 'react'; -import { mockApps, mockCoupons } from '@/mock/data'; -import type { MarketApp, Coupon } from '@/types'; +import React, { useState, useEffect, useCallback } from 'react'; +import { adminAPI } from '@/api'; -const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => ( - - {children} - -); +// ============================================================ +// Types +// ============================================================ +interface MarketAppItem { + id: string; + name: string; + type: string; + category: string; + tags: string | null; + source: string; + publisher_name: string | null; + description: string | null; + icon: string | null; + price: number; + is_member_free: boolean; + featured: boolean; + featured_order: number; + status: string; + rating_avg: number; + review_count: number; + install_count: number; + version: string; + created_at: string | null; + updated_at: string | null; + tutorial?: string; + skill_content?: string; +} +interface OverviewData { + total_apps: number; + published: number; + pending: number; + removed: number; + total_installs: number; + total_purchases: number; + total_revenue: number; + total_reviews: number; + overall_rating: number; + by_type: Record; + by_category: Record; + trend_7d: Array<{ date: string; new_apps: number; new_installs: number }>; +} + +interface CouponItem { + id: string; + code: string; + type: string; + value: number; + min_amount: number; + valid_from: string | null; + valid_to: string | null; + total_count: number; + used_count: number; + status: string; +} + +interface ReviewItem { + id: string; + rating: number; + content: string; + username: string; + avatar: string | null; + app_name: string; + app_icon: string | null; + created_at: string | null; +} + +const CATEGORY_LABELS: Record = { + coding: '编程开发', assistant: '智能助手', tools: '效率工具', + writing: '内容创作', education: '教育培训', marketing: '营销推广', + data: '数据分析', design: '设计创意', business: '企业管理', other: '其他', +}; + +const TYPE_LABELS: Record = { + skill: 'Skills工具', agent: '智能Agent', mcp: 'MCP协议', +}; + +const STATUS_LABELS: Record = { + pending: '待审核', published: '已发布', removed: '已下架', +}; + +const STATUS_COLORS: Record = { + pending: 'var(--color-warning)', published: 'var(--color-success)', removed: 'var(--color-danger)', +}; + +// ============================================================ +// Icon helpers +// ============================================================ +const Icon = ({ name, size = 18, color = 'currentColor' }: { name: string; size?: number; color?: string }) => { + const icons: Record = { + chart: 'M3 3v18h18M7 14l4-4 4 4 5-5', store: 'M3 9l1-5h16l1 5M4 9v11h16V9M9 14h6', + check: 'M20 6L9 17l-5-5', close: 'M18 6L6 18M6 6l12 12', edit: 'M11 4H4v16h16v-7M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4 9.5-9.5z', + star: 'M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z', + trash: 'M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2', + tag: 'M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z', + plus: 'M12 5v14M5 12h14', search: 'M11 4a7 7 0 100 14 7 7 0 000-14zM21 21l-4.35-4.35', + download: 'M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3', + refresh: 'M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15', + trending: 'M23 6l-9.5 9.5-5-5L1 18', eye: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z', + }; + return ( + + + + ); +}; + +// ============================================================ +// Main Component +// ============================================================ const Market: React.FC = () => { - const [apps, setApps] = useState(mockApps); - const [coupons, setCoupons] = useState(mockCoupons); - const [activeTab, setActiveTab] = useState<'review' | 'coupon' | 'official'>('review'); - const [showCouponModal, setShowCouponModal] = useState(false); + const [activeTab, setActiveTab] = useState<'overview' | 'apps' | 'pending' | 'coupons' | 'reviews'>('overview'); + const [loading, setLoading] = useState(false); - const [couponForm, setCouponForm] = useState({ - code: '', type: 'fixed' as 'fixed' | 'percent', value: 0, minAmount: 0, - validFrom: '', validTo: '', - }); + // Overview data + const [overview, setOverview] = useState(null); + const [topApps, setTopApps] = useState([]); + const [topMetric, setTopMetric] = useState('installs'); - const pendingApps = apps.filter(a => a.status === 'pending'); - const publishedOfficialApps = apps.filter(a => a.source === 'official' && a.status !== 'removed'); + // Apps list + const [apps, setApps] = useState([]); + const [appsTotal, setAppsTotal] = useState(0); + const [appsPage, setAppsPage] = useState(1); + const [filterStatus, setFilterStatus] = useState(''); + const [filterType, setFilterType] = useState(''); + const [filterCategory, setFilterCategory] = useState(''); + const [filterKeyword, setFilterKeyword] = useState(''); - const handleApprove = (id: string) => { - setApps(prev => prev.map(a => a.id === id ? { ...a, status: 'published' as const } : a)); - }; + // Pending + const [pendingApps, setPendingApps] = useState([]); + const [pendingTotal, setPendingTotal] = useState(0); - const handleReject = (id: string) => { - setApps(prev => prev.map(a => a.id === id ? { ...a, status: 'removed' as const } : a)); - }; + // Coupons + const [coupons, setCoupons] = useState([]); + const [couponModal, setCouponModal] = useState(false); + const [editingCoupon, setEditingCoupon] = useState(null); - const handleToggleOfficial = (id: string) => { - setApps(prev => prev.map(a => { - if (a.id === id) { - const newStatus: MarketApp['status'] = a.status === 'published' ? 'removed' : 'published'; - return { ...a, status: newStatus }; + // Reviews + const [reviews, setReviews] = useState([]); + + // Edit modal + const [editApp, setEditApp] = useState(null); + + const loadOverview = useCallback(async () => { + setLoading(true); + try { + const res = await adminAPI.getMarketOverview(); + if (res?.code === 0) setOverview(res.data); + const topRes = await adminAPI.getTopApps(topMetric, 10); + if (topRes?.code === 0) setTopApps(topRes.data || []); + } catch (e) { console.error(e); } + setLoading(false); + }, [topMetric]); + + const loadApps = useCallback(async () => { + setLoading(true); + try { + const params = new URLSearchParams(); + if (filterStatus) params.set('status', filterStatus); + if (filterType) params.set('app_type', filterType); + if (filterCategory) params.set('category', filterCategory); + if (filterKeyword) params.set('keyword', filterKeyword); + params.set('page', String(appsPage)); + params.set('page_size', '20'); + const res = await adminAPI.getAdminMarketApps(params.toString()); + if (res?.code === 0) { + const d = res.data as any; + setApps(d?.items || []); + setAppsTotal(d?.total || 0); } - return a; - })); + } catch (e) { console.error(e); } + setLoading(false); + }, [filterStatus, filterType, filterCategory, filterKeyword, appsPage]); + + const loadPending = useCallback(async () => { + setLoading(true); + try { + const res = await adminAPI.getPendingApps(); + if (res?.code === 0) { + const d = res.data as any; + setPendingApps(d?.items || d || []); + setPendingTotal(d?.total || (Array.isArray(d) ? d.length : 0)); + } + } catch (e) { console.error(e); } + setLoading(false); + }, []); + + const loadCoupons = useCallback(async () => { + setLoading(true); + try { + const res = await adminAPI.getCoupons(); + if (res?.code === 0) { const d = res.data as any; setCoupons(d?.items || d || []); } + } catch (e) { console.error(e); } + setLoading(false); + }, []); + + const loadReviews = useCallback(async () => { + setLoading(true); + try { + const res = await adminAPI.getRecentReviews(); + if (res?.code === 0) setReviews(res.data || []); + } catch (e) { console.error(e); } + setLoading(false); + }, []); + + useEffect(() => { + if (activeTab === 'overview') loadOverview(); + else if (activeTab === 'apps') loadApps(); + else if (activeTab === 'pending') loadPending(); + else if (activeTab === 'coupons') loadCoupons(); + else if (activeTab === 'reviews') loadReviews(); + }, [activeTab, loadOverview, loadApps, loadPending, loadCoupons, loadReviews]); + + // Actions + const handleApprove = async (id: string) => { + if (!confirm('确认审核通过?')) return; + try { + await adminAPI.approveApp(id); + loadPending(); + loadOverview(); + } catch (e) { alert('操作失败'); } }; - const handleCreateCoupon = () => { - if (!couponForm.code.trim() || !couponForm.validFrom || !couponForm.validTo) return; - setCoupons(prev => [...prev, { - id: 'cp' + Date.now(), - ...couponForm, - status: 'active', - }]); - setShowCouponModal(false); - setCouponForm({ code: '', type: 'fixed', value: 0, minAmount: 0, validFrom: '', validTo: '' }); + const handleReject = async (id: string) => { + const reason = prompt('拒绝原因:', ''); + if (reason === null) return; + try { + await adminAPI.rejectApp(id + '?reason=' + encodeURIComponent(reason)); + loadPending(); + } catch (e) { alert('操作失败'); } }; - const handleDeleteCoupon = (id: string) => { - setCoupons(prev => prev.filter(c => c.id !== id)); + const handleToggleFeatured = async (app: MarketAppItem) => { + try { + await adminAPI.setFeaturedApp(app.id, !app.featured, app.featured ? 0 : 1); + loadApps(); + } catch (e) { alert('操作失败'); } }; - const formatDate = (d: string) => { - const dt = new Date(d); - return dt.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + const handleDeleteApp = async (id: string, name: string) => { + if (!confirm(`确认删除应用「${name}」?此操作不可恢复!`)) return; + try { + await adminAPI.deleteMarketApp(id); + loadApps(); + } catch (e) { alert('操作失败'); } + }; + + const handleDeleteCoupon = async (id: string) => { + if (!confirm('确认删除此优惠券?')) return; + try { + await adminAPI.deleteCoupon(id); + loadCoupons(); + } catch (e) { alert('操作失败'); } }; const tabs = [ - { key: 'review' as const, label: '待审核应用', count: pendingApps.length }, - { key: 'coupon' as const, label: '优惠券管理', count: coupons.length }, - { key: 'official' as const, label: '官方应用管理', count: publishedOfficialApps.length }, - ]; + { key: 'overview', label: '数据总览', icon: 'chart' }, + { key: 'apps', label: '应用管理', icon: 'store' }, + { key: 'pending', label: `待审核${pendingTotal > 0 ? `(${pendingTotal})` : ''}`, icon: 'eye' }, + { key: 'coupons', label: '优惠券', icon: 'tag' }, + { key: 'reviews', label: '用户评价', icon: 'star' }, + ] as const; return ( -
-

应用市场审核管理

+
+ {/* Header */} +
+

+ 市场管理 +

+

+ 管理应用市场、审核提交、分析数据、配置优惠券 +

+
{/* Tabs */} -
+
{tabs.map(tab => ( ))}
- {/* Pending Review Tab */} - {activeTab === 'review' && ( -
-
-

待审核应用

-
-
- - - - - - - - - - - - - - {pendingApps.map(app => ( - - - - - - - - - - ))} - {pendingApps.length === 0 && ( - - - - )} - -
应用名类型发布者价格状态发布时间操作
{app.name} - - {app.type.toUpperCase()} - - {app.publisherName}{app.price} 元待审核{formatDate(app.createdAt)} -
- - -
-
- 暂无待审核应用 -
-
+ {/* Loading */} + {loading && ( +
+
+ 加载中...
)} - {/* Coupon Management Tab */} - {activeTab === 'coupon' && ( - <> -
-
-

优惠券列表

- -
-
- - - - - - - - - - - - - - {coupons.map(c => ( - - - - - - - - - - ))} - -
兑换码类型面值最低消费有效期状态操作
{c.code} - - {c.type === 'fixed' ? '固定金额' : '百分比'} - - - {c.type === 'fixed' ? `${c.value} 元` : `${c.value}%`} - {c.minAmount > 0 ? `${c.minAmount} 元` : '无门槛'}{c.validFrom} 至 {c.validTo} - - {c.status === 'active' ? '有效' : '已过期'} - - - -
-
-
- + {/* Content */} + {!loading && activeTab === 'overview' && overview && ( + )} - {/* Official App Management Tab */} - {activeTab === 'official' && ( -
-
-

官方应用列表

-
-
- - - - - - - - - - - - - - - {publishedOfficialApps.map(app => ( - - - - - - - - - - - ))} - -
应用名类型价格会员免费安装量评分状态操作
{app.name} - - {app.type.toUpperCase()} - - {app.price} 元 - - {app.isMemberFree ? '是' : '否'} - - {app.installCount.toLocaleString()} -
- - {app.ratingAvg} -
-
- - {app.status === 'published' ? '已上架' : '已下架'} - - - -
+ {!loading && activeTab === 'apps' && ( + + )} + + {!loading && activeTab === 'pending' && ( + + )} + + {!loading && activeTab === 'coupons' && ( + { setEditingCoupon(null); setCouponModal(true); }} + onEdit={(c) => { setEditingCoupon(c); setCouponModal(true); }} + onDelete={handleDeleteCoupon} + /> + )} + + {!loading && activeTab === 'reviews' && ( + + )} + + {/* Edit Modal */} + {editApp && ( + setEditApp(null)} onSaved={() => { setEditApp(null); loadApps(); }} /> + )} + + {/* Coupon Modal */} + {couponModal && ( + setCouponModal(false)} onSaved={() => { setCouponModal(false); loadCoupons(); }} /> + )} +
+ ); +}; + +// ============================================================ +// Overview Component +// ============================================================ +const MarketOverview: React.FC<{ overview: OverviewData; topApps: MarketAppItem[]; topMetric: string; onMetricChange: (v: string) => void }> = ({ + overview, topApps, topMetric, onMetricChange +}) => { + const stats = [ + { label: '总应用数', value: overview.total_apps, sub: `${overview.published} 已发布`, color: 'var(--color-primary)' }, + { label: '总安装量', value: overview.total_installs, sub: `${overview.pending} 待审核`, color: 'var(--color-success)' }, + { label: '总收入', value: `¥${overview.total_revenue.toFixed(2)}`, sub: `${overview.total_purchases} 笔购买`, color: 'var(--color-warning)' }, + { label: '平均评分', value: overview.overall_rating.toFixed(1), sub: `${overview.total_reviews} 条评价`, color: 'var(--color-danger)' }, + ]; + + const maxTrend = Math.max(...overview.trend_7d.map(t => Math.max(t.new_apps, t.new_installs)), 1); + + return ( +
+ {/* Stat cards */} +
+ {stats.map((s, i) => ( +
+
{s.label}
+
{s.value}
+
{s.sub}
+ ))} +
+ + {/* 7-day trend */} +
+

+ 7日趋势 +

+
+ {overview.trend_7d.map((t, i) => ( +
+
+
+
+
+ + {t.date.slice(5)} + +
+ ))}
- )} +
+ + + 新应用 + + + + 新安装 + +
+
- {/* Create Coupon Modal */} - {showCouponModal && ( + {/* Category distribution + Top apps */} +
+ {/* Categories */}
setShowCouponModal(false)}> -
e.stopPropagation()}> -

创建优惠券

- -
- - setCouponForm({ ...couponForm, code: e.target.value })} placeholder="例如: NEW2026" /> + background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '20px', + border: '1px solid var(--color-border)', + }}> +

分类分布

+ {Object.entries(overview.by_category).map(([cat, cnt]) => ( +
+ {CATEGORY_LABELS[cat] || cat} + {cnt}
+ ))} + {Object.keys(overview.by_category).length === 0 && ( +
暂无数据
+ )} +
-
-
- - -
-
- - setCouponForm({ ...couponForm, value: Number(e.target.value) })} /> -
-
- -
-
- - setCouponForm({ ...couponForm, minAmount: Number(e.target.value) })} /> -
-
- - setCouponForm({ ...couponForm, validFrom: e.target.value })} /> -
-
- -
- - setCouponForm({ ...couponForm, validTo: e.target.value })} /> -
- -
- - -
+ {/* Top apps */} +
+
+

热门应用

+
+ {topApps.map((app, i) => ( +
+ + #{i + 1} + +
+
+ {app.name} +
+
+ {TYPE_LABELS[app.type] || app.type} · {CATEGORY_LABELS[app.category] || app.category} +
+
+
+ {topMetric === 'installs' && `${app.install_count}`} + {topMetric === 'rating' && `${app.rating_avg.toFixed(1)}`} + {topMetric === 'reviews' && `${app.review_count}`} + {topMetric === 'revenue' && `¥${(app as any).revenue?.toFixed(0) || 0}`} +
+
+ ))} + {topApps.length === 0 && ( +
暂无数据
+ )} +
+
+
+ ); +}; + +// ============================================================ +// Apps Management Component +// ============================================================ +const AppsManagement: React.FC<{ + apps: MarketAppItem[]; total: number; page: number; onPageChange: (p: number) => void; + filterStatus: string; setFilterStatus: (v: string) => void; + filterType: string; setFilterType: (v: string) => void; + filterCategory: string; setFilterCategory: (v: string) => void; + filterKeyword: string; setFilterKeyword: (v: string) => void; + onEdit: (app: MarketAppItem) => void; onToggleFeatured: (app: MarketAppItem) => void; onDelete: (id: string, name: string) => void; +}> = ({ apps, total, page, onPageChange, filterStatus, setFilterStatus, filterType, setFilterType, filterCategory, setFilterCategory, filterKeyword, setFilterKeyword, onEdit, onToggleFeatured, onDelete }) => { + const totalPages = Math.ceil(total / 20); + + return ( +
+ {/* Filters */} +
+ setFilterKeyword(e.target.value)} + style={{ flex: 1, minWidth: '200px', padding: '8px 12px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} + /> + + + +
+ + {/* Table */} +
+ + + + {['应用', '类型', '分类', '价格', '安装', '评分', '状态', '推荐', '操作'].map(h => ( + + ))} + + + + {apps.map(app => ( + + + + + + + + + + + + ))} + +
{h}
+
+ {app.icon && { (e.target as HTMLImageElement).style.display = 'none'; }} />} +
+
{app.name}
+
{app.publisher_name || '—'}
+
+
+
{TYPE_LABELS[app.type] || app.type}{CATEGORY_LABELS[app.category] || app.category} + {app.price === 0 ? 免费 : `¥${app.price}`} + {app.is_member_free && 会员免费} + {app.install_count} + {app.rating_avg > 0 ? `★ ${app.rating_avg.toFixed(1)} (${app.review_count})` : '—'} + + + {STATUS_LABELS[app.status] || app.status} + + + + +
+ + +
+
+ {apps.length === 0 && ( +
暂无应用
+ )} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + {page} / {totalPages} +
)}
); }; +// ============================================================ +// Pending Apps Component +// ============================================================ +const PendingApps: React.FC<{ apps: MarketAppItem[]; onApprove: (id: string) => void; onReject: (id: string) => void }> = ({ apps, onApprove, onReject }) => ( +
+ {apps.length === 0 ? ( +
+ +

没有待审核的应用

+
+ ) : ( +
+ {apps.map(app => ( +
+ {app.icon ? ( + { (e.target as HTMLImageElement).style.display = 'none'; }} /> + ) : ( +
+ +
+ )} +
+
+

{app.name}

+ + {TYPE_LABELS[app.type] || app.type} + + + by {app.publisher_name || 'Unknown'} + +
+

+ {app.description || '无描述'} +

+ {app.skill_content && ( +
+ 查看 Skill 内容 +
+                    {app.skill_content.slice(0, 500)}{app.skill_content.length > 500 ? '...' : ''}
+                  
+
+ )} +
+ + +
+
+
+ ))} +
+ )} +
+); + +// ============================================================ +// Coupons Component +// ============================================================ +const CouponsManagement: React.FC<{ + coupons: CouponItem[]; onAdd: () => void; onEdit: (c: CouponItem) => void; onDelete: (id: string) => void; +}> = ({ coupons, onAdd, onEdit, onDelete }) => ( +
+
+ +
+
+ + + + {['优惠券码', '类型', '面值', '最低消费', '有效期', '使用情况', '状态', '操作'].map(h => ( + + ))} + + + + {coupons.map(c => ( + + + + + + + + + + + ))} + +
{h}
{c.code}{c.type === 'fixed' ? '固定金额' : '百分比'}{c.type === 'fixed' ? `¥${c.value}` : `${c.value}%`}¥{c.min_amount} + {c.valid_from?.slice(0, 10) || '—'} ~ {c.valid_to?.slice(0, 10) || '长期'} + {c.used_count} / {c.total_count} + + {c.status === 'active' ? '有效' : '已停用'} + + +
+ + +
+
+ {coupons.length === 0 && ( +
暂无优惠券
+ )} +
+
+); + +// ============================================================ +// Reviews List Component +// ============================================================ +const ReviewsList: React.FC<{ reviews: ReviewItem[] }> = ({ reviews }) => ( +
+ {reviews.length === 0 ? ( +
+ +

暂无用户评价

+
+ ) : ( + reviews.map(r => ( +
+
+ {r.avatar ? ( + + ) : ( +
+ {r.username?.charAt(0).toUpperCase() || 'U'} +
+ )} + {r.username} + + {'★'.repeat(r.rating)}{'☆'.repeat(5 - r.rating)} + + + on {r.app_name} + + + {r.created_at?.slice(0, 16)} + +
+

+ {r.content} +

+
+ )) + )} +
+); + +// ============================================================ +// Edit App Modal +// ============================================================ +const EditAppModal: React.FC<{ app: MarketAppItem; onClose: () => void; onSaved: () => void }> = ({ app, onClose, onSaved }) => { + const [form, setForm] = useState({ + name: app.name, + description: app.description || '', + category: app.category || 'other', + tags: app.tags || '', + price: app.price, + is_member_free: app.is_member_free, + featured: app.featured, + featured_order: app.featured_order, + status: app.status, + }); + const [saving, setSaving] = useState(false); + + const handleSave = async () => { + setSaving(true); + try { + await adminAPI.updateMarketApp(app.id, form); + onSaved(); + } catch (e) { + alert('保存失败'); + } + setSaving(false); + }; + + return ( +
+
e.stopPropagation()}> +

编辑应用

+ +
+
+ + setForm({ ...form, name: e.target.value })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} /> +
+
+ +