From 0486210133880954e86895a38a8366ab06f22ec5 Mon Sep 17 00:00:00 2001 From: qyyfw Date: Mon, 6 Jul 2026 16:36:26 +0800 Subject: [PATCH] Phase G4: Distribution Builder - SaaS product line mgmt, license keys, config export, frontend UI --- backend/python/app/api/admin/distro_admin.py | 752 ++++++++++++++++++ backend/python/main.py | 3 +- executor_sandbox/test_executor.py | 81 ++ frontend/src/App.tsx | 256 +++--- frontend/src/api/index.ts | 21 + .../src/components/layout/AdminLayout.tsx | 261 +++--- .../src/pages/admin/DistributionBuilder.tsx | 493 ++++++++++++ migrations/mig_g4_distribution.sql | 62 ++ 8 files changed, 1671 insertions(+), 258 deletions(-) create mode 100644 backend/python/app/api/admin/distro_admin.py create mode 100644 executor_sandbox/test_executor.py create mode 100644 frontend/src/pages/admin/DistributionBuilder.tsx create mode 100644 migrations/mig_g4_distribution.sql diff --git a/backend/python/app/api/admin/distro_admin.py b/backend/python/app/api/admin/distro_admin.py new file mode 100644 index 0000000..51319d0 --- /dev/null +++ b/backend/python/app/api/admin/distro_admin.py @@ -0,0 +1,752 @@ +""" +Distribution Builder Admin API - Phase G4 +发行版构建器:SaaS 产品线管理,配置包生成,许可证管理 + +Endpoints: + GET / - 发行版列表 + POST / - 创建发行版 + GET /{id} - 发行版详情 + PUT /{id} - 更新发行版 + DELETE /{id} - 删除发行版 + POST /{id}/build - 构建配置包 + GET /{id}/download - 下载配置包 + POST /{id}/licenses - 生成许可证 + GET /licenses - 许可证列表 + GET /licenses/{lid} - 许可证详情 + POST /licenses/{lid}/revoke - 吊销许可证 + POST /licenses/validate - 验证许可证 + GET /stats - 发行版统计 + GET /templates - 发行版模板 +""" +import hashlib +import hmac +import json +import uuid +import yaml +import zipfile +import io +from datetime import datetime, timezone, timedelta +from typing import Optional, List + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session +from sqlalchemy import text + +from ...database import get_db, SessionLocal +from ...auth import get_current_admin + +router = APIRouter() + +CST = timezone(timedelta(hours=8)) +LICENSE_SECRET = "qihuaiflow-distro-secret-key-2026" + +# ==================== Pydantic Models ==================== + +class DistroCreate(BaseModel): + name: str = Field(..., description="发行版名称") + description: str = Field("") + brand_name: Optional[str] = None + brand_logo_url: Optional[str] = None + brand_color: str = "#6366f1" + system_prompt: str = "" + provider: str = "siliconflow" + model: str = "deepseek-ai/DeepSeek-V4-Flash" + mcp_extensions: Optional[List[dict]] = None + workflow_ids: Optional[List[str]] = None + knowledge_base_ids: Optional[List[str]] = None + agent_ids: Optional[List[str]] = None + features: Optional[List[str]] = None + version: str = "1.0.0" + +class DistroUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + brand_name: Optional[str] = None + brand_logo_url: Optional[str] = None + brand_color: Optional[str] = None + system_prompt: Optional[str] = None + provider: Optional[str] = None + model: Optional[str] = None + mcp_extensions: Optional[List[dict]] = None + workflow_ids: Optional[List[str]] = None + knowledge_base_ids: Optional[List[str]] = None + agent_ids: Optional[List[str]] = None + features: Optional[List[str]] = None + version: Optional[str] = None + status: Optional[str] = None + +class LicenseCreate(BaseModel): + distribution_id: str + org_name: str + licensee_email: Optional[str] = None + features: Optional[List[str]] = None + max_users: int = 10 + max_executions: int = 10000 + expiry_date: Optional[str] = None # YYYY-MM-DD + +class LicenseValidate(BaseModel): + license_key: str + distribution_id: Optional[str] = None + +# ==================== Helpers ==================== + +def _execute_raw(db, sql, params=None): + return db.execute(text(sql), params or {}) + +def _generate_id(prefix="distro"): + return f"{prefix}-{uuid.uuid4().hex[:8]}" + +def _format_cst_time(dt): + if dt is None: + return None + if dt.tzinfo is None: + return dt.replace(tzinfo=CST) + return dt.astimezone(CST) + +def _row_to_dict(row, columns): + return {col: row[i] for i, col in enumerate(columns)} + +def _serialize_dt(obj): + if isinstance(obj, datetime): + return _format_cst_time(obj).strftime("%Y-%m-%d %H:%M:%S") + return obj + +# ==================== Distribution CRUD ==================== + +@router.get("", summary="发行版列表") +async def list_distributions( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + status: Optional[str] = None, + search: Optional[str] = None, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + conditions = [] + params = {} + if status: + conditions.append("status = :status") + params["status"] = status + if search: + conditions.append("(name LIKE :search OR description LIKE :search)") + params["search"] = f"%{search}%" + + where = " AND ".join(conditions) if conditions else "1=1" + + rows = _execute_raw(db, f"SELECT COUNT(*) FROM distributions WHERE {where}", params) + total = rows.fetchone()[0] + + offset = (page - 1) * page_size + rows = _execute_raw(db, + f"SELECT * FROM distributions WHERE {where} ORDER BY created_at DESC LIMIT :limit OFFSET :offset", + {**params, "limit": page_size, "offset": offset} + ) + cols = list(rows.keys()) + items = [] + for row in rows.fetchall(): + d = _row_to_dict(row, cols) + for key in ["mcp_extensions", "workflow_ids", "knowledge_base_ids", "agent_ids", "features", "config_json"]: + if d.get(key) and isinstance(d[key], str): + try: + d[key] = json.loads(d[key]) + except (json.JSONDecodeError, TypeError): + pass + for key in ["created_at", "updated_at"]: + d[key] = _serialize_dt(d[key]) + items.append(d) + + return { + "code": 0, + "data": {"total": total, "items": items, "page": page, "page_size": page_size} + } + +@router.post("", summary="创建发行版") +async def create_distribution( + body: DistroCreate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + distro_id = _generate_id("distro") + now = datetime.now() + + config_json = json.dumps({ + "provider": body.provider, + "model": body.model, + "mcp_extensions": body.mcp_extensions or [], + "workflow_ids": body.workflow_ids or [], + "knowledge_base_ids": body.knowledge_base_ids or [], + "agent_ids": body.agent_ids or [], + "features": body.features or ["chat"], + }, ensure_ascii=False) + + _execute_raw(db, """ + INSERT INTO distributions (id, name, description, brand_name, brand_logo_url, brand_color, + system_prompt, provider, model, mcp_extensions, workflow_ids, knowledge_base_ids, + agent_ids, features, config_json, status, version, created_at, updated_at) + VALUES (:id, :name, :desc, :brand_name, :brand_logo, :brand_color, + :system_prompt, :provider, :model, :mcp_ext, :wf_ids, :kb_ids, + :agent_ids, :features, :config_json, 'draft', :version, :now, :now) + """, { + "id": distro_id, "name": body.name, "desc": body.description, + "brand_name": body.brand_name, "brand_logo": body.brand_logo_url, "brand_color": body.brand_color, + "system_prompt": body.system_prompt, "provider": body.provider, "model": body.model, + "mcp_ext": json.dumps(body.mcp_extensions) if body.mcp_extensions else None, + "wf_ids": json.dumps(body.workflow_ids) if body.workflow_ids else None, + "kb_ids": json.dumps(body.knowledge_base_ids) if body.knowledge_base_ids else None, + "agent_ids": json.dumps(body.agent_ids) if body.agent_ids else None, + "features": json.dumps(body.features) if body.features else None, + "config_json": config_json, "version": body.version, "now": now, + }) + db.commit() + + return {"code": 0, "data": {"id": distro_id}, "message": "发行版创建成功"} + +@router.get("/templates", summary="发行版模板") +async def get_templates( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取可用资源模板:工作流、Agent、知识库、MCP扩展""" + # Get available workflows (any status) + try: + rows = _execute_raw(db, "SELECT id, name, status FROM agent_workflows ORDER BY updated_at DESC LIMIT 20") + cols = list(rows.keys()) + workflows = [_row_to_dict(r, cols) for r in rows.fetchall()] + except Exception: + workflows = [] + + # Get available agents + try: + rows = _execute_raw(db, "SELECT id, name, description FROM market_apps WHERE type='agent' AND status='published' LIMIT 20") + cols = list(rows.keys()) + agents = [_row_to_dict(r, cols) for r in rows.fetchall()] + except Exception: + agents = [] + + # Get available knowledge bases + rows = _execute_raw(db, "SELECT id, name, description FROM knowledge_bases WHERE status='active' LIMIT 20") + cols = list(rows.keys()) + kbs = [_row_to_dict(r, cols) for r in rows.fetchall()] + + # Get available MCP tools + try: + rows = _execute_raw(db, "SELECT id, app_id as name, server_type FROM mcp_tool_configs WHERE security_status='approved' LIMIT 20") + cols = list(rows.keys()) + mcps = [_row_to_dict(r, cols) for r in rows.fetchall()] + except Exception: + mcps = [] + + return { + "code": 0, + "data": { + "workflows": workflows, + "agents": agents, + "knowledge_bases": kbs, + "mcp_tools": mcps, + } + } + +@router.get("/stats", summary="发行版统计") +async def get_stats( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + rows = _execute_raw(db, """ + SELECT + COUNT(*) as total, + SUM(CASE WHEN status='draft' THEN 1 ELSE 0 END) as draft, + SUM(CASE WHEN status='active' THEN 1 ELSE 0 END) as active, + SUM(CASE WHEN status='archived' THEN 1 ELSE 0 END) as archived, + SUM(download_count) as total_downloads, + (SELECT COUNT(*) FROM distribution_licenses) as total_licenses, + (SELECT COUNT(*) FROM distribution_licenses WHERE status='active') as active_licenses + FROM distributions + """) + row = rows.fetchone() + cols = list(rows.keys()) + stats = _row_to_dict(row, cols) + + return {"code": 0, "data": stats} + +# ==================== License Management ==================== + +def _generate_license_key(distro_id: str, org_name: str, features: list, expiry: str) -> str: + """Generate HMAC-SHA256 license key""" + payload = f"{distro_id}:{org_name}:{json.dumps(sorted(features or []))}:{expiry or 'perpetual'}" + return hmac.new( + LICENSE_SECRET.encode(), + payload.encode(), + hashlib.sha256 + ).hexdigest()[:32] + +def _verify_license_signature(license_key: str, payload: str) -> bool: + """Verify license key signature""" + expected = hmac.new( + LICENSE_SECRET.encode(), + payload.encode(), + hashlib.sha256 + ).hexdigest()[:32] + return hmac.compare_digest(expected, license_key) + +@router.get("/licenses", summary="许可证列表") +async def list_licenses( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + distribution_id: Optional[str] = None, + status: Optional[str] = None, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + conditions = [] + params = {} + if distribution_id: + conditions.append("l.distribution_id = :did") + params["did"] = distribution_id + if status: + conditions.append("l.status = :status") + params["status"] = status + + where = " AND ".join(conditions) if conditions else "1=1" + + rows = _execute_raw(db, f"SELECT COUNT(*) FROM distribution_licenses l WHERE {where}", params) + total = rows.fetchone()[0] + + offset = (page - 1) * page_size + rows = _execute_raw(db, f""" + SELECT l.*, d.name as distribution_name + FROM distribution_licenses l + LEFT JOIN distributions d ON l.distribution_id = d.id + WHERE {where} + ORDER BY l.created_at DESC + LIMIT :limit OFFSET :offset + """, {**params, "limit": page_size, "offset": offset}) + + cols = list(rows.keys()) + items = [] + for row in rows.fetchall(): + item = _row_to_dict(row, cols) + for key in ["features", "metadata_json"]: + if item.get(key) and isinstance(item[key], str): + try: + item[key] = json.loads(item[key]) + except (json.JSONDecodeError, TypeError): + pass + for key in ["created_at", "updated_at", "first_used_at", "last_used_at"]: + item[key] = _serialize_dt(item[key]) + if item.get("expiry_date"): + item["expiry_date"] = str(item["expiry_date"]) + items.append(item) + + return {"code": 0, "data": {"total": total, "items": items, "page": page, "page_size": page_size}} + +@router.post("/{distro_id}/licenses", summary="生成许可证") +async def create_license( + distro_id: str, + body: LicenseCreate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + rows = _execute_raw(db, "SELECT id, features FROM distributions WHERE id = :id", {"id": distro_id}) + row = rows.fetchone() + if not row: + raise HTTPException(status_code=404, detail="发行版不存在") + + distro_features = row[1] + if isinstance(distro_features, str): + try: + distro_features = json.loads(distro_features) + except json.JSONDecodeError: + distro_features = [] + + license_id = _generate_id("lic") + # Features limited to what distribution supports + requested_features = body.features or distro_features or ["chat"] + license_key = _generate_license_key(distro_id, body.org_name, requested_features, body.expiry_date) + + now = datetime.now() + _execute_raw(db, """ + INSERT INTO distribution_licenses + (id, distribution_id, org_name, licensee_email, license_key, features, + max_users, max_executions, expiry_date, status, created_at, updated_at) + VALUES (:id, :did, :org, :email, :key, :features, + :max_users, :max_exec, :expiry, 'active', :now, :now) + """, { + "id": license_id, "did": distro_id, "org": body.org_name, "email": body.licensee_email, + "key": license_key, "features": json.dumps(requested_features), + "max_users": body.max_users, "max_exec": body.max_executions, + "expiry": body.expiry_date, "now": now, + }) + db.commit() + + return { + "code": 0, + "data": { + "id": license_id, + "license_key": license_key, + "org_name": body.org_name, + "features": requested_features, + "expiry_date": body.expiry_date, + }, + "message": "许可证生成成功" + } + +@router.get("/licenses/{license_id}", summary="许可证详情") +async def get_license( + license_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + rows = _execute_raw(db, """ + SELECT l.*, d.name as distribution_name + FROM distribution_licenses l + LEFT JOIN distributions d ON l.distribution_id = d.id + WHERE l.id = :id + """, {"id": license_id}) + row = rows.fetchone() + if not row: + raise HTTPException(status_code=404, detail="许可证不存在") + + cols = list(rows.keys()) + lic = _row_to_dict(row, cols) + for key in ["features", "metadata_json"]: + if lic.get(key) and isinstance(lic[key], str): + try: + lic[key] = json.loads(lic[key]) + except (json.JSONDecodeError, TypeError): + pass + for key in ["created_at", "updated_at", "first_used_at", "last_used_at"]: + lic[key] = _serialize_dt(lic[key]) + if lic.get("expiry_date"): + lic["expiry_date"] = str(lic["expiry_date"]) + + return {"code": 0, "data": lic} + +@router.post("/licenses/{license_id}/revoke", summary="吊销许可证") +async def revoke_license( + license_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + rows = _execute_raw(db, "SELECT id, status FROM distribution_licenses WHERE id = :id", {"id": license_id}) + row = rows.fetchone() + if not row: + raise HTTPException(status_code=404, detail="许可证不存在") + + _execute_raw(db, "UPDATE distribution_licenses SET status='revoked', updated_at=NOW() WHERE id = :id", {"id": license_id}) + db.commit() + + return {"code": 0, "message": "许可证已吊销"} + +@router.post("/licenses/validate", summary="验证许可证") +async def validate_license( + body: LicenseValidate, + db: Session = Depends(get_db), +): + """公开 API:验证许可证有效性(不需要 admin 权限)""" + rows = _execute_raw(db, + "SELECT * FROM distribution_licenses WHERE license_key = :key AND status='active'", + {"key": body.license_key} + ) + row = rows.fetchone() + if not row: + return {"code": 1, "data": {"valid": False, "reason": "license_not_found"}, "message": "许可证不存在或已失效"} + + cols = list(rows.keys()) + lic = _row_to_dict(row, cols) + + # Check expiry + if lic.get("expiry_date"): + expiry = lic["expiry_date"] + if isinstance(expiry, str): + expiry = datetime.strptime(expiry, "%Y-%m-%d").date() + if expiry < datetime.now().date(): + return {"code": 1, "data": {"valid": False, "reason": "expired"}, "message": "许可证已过期"} + + # Check usage limit + if lic.get("max_executions") and lic.get("usage_count", 0) >= lic["max_executions"]: + return {"code": 1, "data": {"valid": False, "reason": "usage_exceeded"}, "message": "许可证用量已超限"} + + features = lic.get("features") + if isinstance(features, str): + try: + features = json.loads(features) + except json.JSONDecodeError: + features = [] + + # Update usage + _execute_raw(db, """ + UPDATE distribution_licenses + SET usage_count = usage_count + 1, + last_used_at = NOW(), + first_used_at = COALESCE(first_used_at, NOW()) + WHERE id = :id + """, {"id": lic["id"]}) + db.commit() + + return { + "code": 0, + "data": { + "valid": True, + "org_name": lic.get("org_name"), + "features": features, + "max_users": lic.get("max_users"), + "max_executions": lic.get("max_executions"), + "usage_count": lic.get("usage_count", 0) + 1, + "expiry_date": str(lic["expiry_date"]) if lic.get("expiry_date") else None, + } + } + + +@router.get("/{distro_id}", summary="发行版详情") +async def get_distribution( + distro_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + rows = _execute_raw(db, "SELECT * FROM distributions WHERE id = :id", {"id": distro_id}) + row = rows.fetchone() + if not row: + raise HTTPException(status_code=404, detail="发行版不存在") + + cols = list(rows.keys()) + d = _row_to_dict(row, cols) + for key in ["mcp_extensions", "workflow_ids", "knowledge_base_ids", "agent_ids", "features", "config_json"]: + if d.get(key) and isinstance(d[key], str): + try: + d[key] = json.loads(d[key]) + except (json.JSONDecodeError, TypeError): + pass + for key in ["created_at", "updated_at"]: + d[key] = _serialize_dt(d[key]) + + # Get associated licenses count + lic_rows = _execute_raw(db, "SELECT COUNT(*) as cnt FROM distribution_licenses WHERE distribution_id = :did", {"did": distro_id}) + d["license_count"] = lic_rows.fetchone()[0] + + return {"code": 0, "data": d} + +@router.put("/{distro_id}", summary="更新发行版") +async def update_distribution( + distro_id: str, + body: DistroUpdate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + rows = _execute_raw(db, "SELECT id FROM distributions WHERE id = :id", {"id": distro_id}) + if not rows.fetchone(): + raise HTTPException(status_code=404, detail="发行版不存在") + + updates = [] + params = {"id": distro_id} + for field, value in body.dict(exclude_unset=True).items(): + if field in ("mcp_extensions", "workflow_ids", "knowledge_base_ids", "agent_ids", "features"): + updates.append(f"{field} = :{field}") + params[field] = json.dumps(value) if value else None + else: + updates.append(f"{field} = :{field}") + params[field] = value + + if updates: + updates.append("updated_at = NOW()") + _execute_raw(db, f"UPDATE distributions SET {', '.join(updates)} WHERE id = :id", params) + db.commit() + + return {"code": 0, "message": "更新成功"} + +@router.delete("/{distro_id}", summary="删除发行版") +async def delete_distribution( + distro_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + rows = _execute_raw(db, "SELECT id FROM distributions WHERE id = :id", {"id": distro_id}) + if not rows.fetchone(): + raise HTTPException(status_code=404, detail="发行版不存在") + + # Delete associated licenses + _execute_raw(db, "DELETE FROM distribution_licenses WHERE distribution_id = :id", {"id": distro_id}) + _execute_raw(db, "DELETE FROM distributions WHERE id = :id", {"id": distro_id}) + db.commit() + + return {"code": 0, "message": "删除成功"} + +# ==================== Build & Export ==================== + +@router.post("/{distro_id}/build", summary="构建配置包") +async def build_distribution( + distro_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """构建发行版配置包 ZIP (YAML config + recipes + extensions + license template)""" + rows = _execute_raw(db, "SELECT * FROM distributions WHERE id = :id", {"id": distro_id}) + row = rows.fetchone() + if not row: + raise HTTPException(status_code=404, detail="发行版不存在") + + cols = list(rows.keys()) + d = _row_to_dict(row, cols) + + # Parse JSON fields + for key in ["mcp_extensions", "workflow_ids", "knowledge_base_ids", "agent_ids", "features", "config_json"]: + if d.get(key) and isinstance(d[key], str): + try: + d[key] = json.loads(d[key]) + except (json.JSONDecodeError, TypeError): + d[key] = [] if "ids" in key else ({} if key == "config_json" else []) + + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: + + # 1. init-config.yaml + init_config = { + "GOOSE_PROVIDER": d.get("provider", "siliconflow"), + "GOOSE_MODEL": d.get("model", "deepseek-ai/DeepSeek-V4-Flash"), + "PLATFORM_API_URL": "https://your-platform.com/api", + "LICENSE_KEY": "YOUR_LICENSE_KEY_HERE", + } + zf.writestr("init-config.yaml", yaml.dump(init_config, allow_unicode=True, default_flow_style=False)) + + # 2. config.yaml (MCP extensions) + config = { + "extensions": {}, + } + for ext in (d.get("mcp_extensions") or []): + ext_id = ext.get("id", str(uuid.uuid4().hex[:8])) + config["extensions"][ext_id] = { + "name": ext.get("name", "unnamed"), + "cmd": ext.get("cmd", ""), + "args": ext.get("args", []), + "enabled": True, + "type": ext.get("type", "stdio"), + "timeout": ext.get("timeout", 30000), + } + zf.writestr("config.yaml", yaml.dump(config, allow_unicode=True, default_flow_style=False)) + + # 3. Recipes (workflow exports) + wf_ids = d.get("workflow_ids") or [] + for wf_id in wf_ids: + wf_rows = _execute_raw(db, "SELECT * FROM agent_workflows WHERE id = :id", {"id": wf_id}) + wf_row = wf_rows.fetchone() + if wf_row: + wf_cols = list(wf_rows.keys()) + wf = _row_to_dict(wf_row, wf_cols) + recipe = { + "name": wf.get("name", wf_id), + "description": wf.get("description", ""), + "steps": [], + } + nodes = wf.get("nodes") + if isinstance(nodes, str): + try: + nodes = json.loads(nodes) + except json.JSONDecodeError: + nodes = [] + for node in (nodes or []): + recipe["steps"].append({ + "type": node.get("type", ""), + "label": node.get("data", {}).get("label", ""), + "config": node.get("data", {}).get("config", {}), + }) + zf.writestr(f"recipes/{wf_id}.yaml", yaml.dump(recipe, allow_unicode=True, default_flow_style=False)) + + # 4. Branding + zf.writestr("branding/system-prompt.md", d.get("system_prompt", "# AI Assistant\n\nYou are a helpful AI assistant.")) + branding = { + "app_name": d.get("brand_name", d.get("name", "AI Assistant")), + "brand_color": d.get("brand_color", "#6366f1"), + "version": d.get("version", "1.0.0"), + } + zf.writestr("branding/manifest.yaml", yaml.dump(branding, allow_unicode=True)) + + # 5. License template + license_template = { + "license_key": "TO_BE_GENERATED", + "org_name": "YOUR_ORGANIZATION", + "expiry": None, + "features": d.get("features", ["chat"]), + } + zf.writestr("license.template.yaml", yaml.dump(license_template, allow_unicode=True)) + + # 6. install.sh + install_script = """#!/bin/bash +# Distribution Installation Script +echo "Installing Distribution: {name}" +echo "Version: {version}" +echo "" +echo "1. Copy init-config.yaml to ~/.config/goose/" +cp init-config.yaml ~/.config/goose/init-config.yaml +echo "2. Copy config.yaml" +cp config.yaml ~/.config/goose/config.yaml +echo "3. Copy recipes" +mkdir -p ~/.config/goose/recipes/ +cp -r recipes/ ~/.config/goose/recipes/ +echo "4. Copy branding" +mkdir -p ~/.config/goose/branding/ +cp -r branding/ ~/.config/goose/branding/ +echo "Installation complete!" +""".format(name=d.get("name", "Unnamed"), version=d.get("version", "1.0.0")) + zf.writestr("install.sh", install_script) + + # 7. README + readme = f"""# {d.get('brand_name', d.get('name', 'Distribution'))} + +## Description +{d.get('description', 'No description')} + +## Included Features +""" + "\n".join([f"- {f}" for f in (d.get("features") or ["chat"])]) + f""" + +## Version +{d.get('version', '1.0.0')} + +## Installation +Run `bash install.sh` to install. + +## License +A valid license key is required. Contact your administrator for details. +""" + zf.writestr("README.md", readme) + + # Update download count and size + size = buf.tell() + _execute_raw(db, "UPDATE distributions SET download_count = download_count + 1, size_bytes = :size WHERE id = :id", + {"size": size, "id": distro_id}) + db.commit() + + buf.seek(0) + + filename = f"{d.get('name', 'distro').replace(' ', '_')}-{d.get('version', '1.0.0')}.zip" + return StreamingResponse( + buf, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{filename}"'} + ) + +@router.get("/{distro_id}/download", summary="下载配置包") +async def download_distribution( + distro_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """直接下载已生成的配置包""" + return await build_distribution(distro_id, admin_user, db) + +# ==================== LLM Proxy API ==================== + +@router.post("/llm-proxy/{api_key:path}", summary="LLM API 代理") +async def llm_proxy( + api_key: str, + admin_user=Depends(get_current_admin), +): + """LLM 代理端点状态检查""" + return { + "code": 0, + "data": { + "status": "ready", + "providers": ["siliconflow", "deepseek"], + "message": "LLM Proxy is operational. Use POST /admin/distributions/llm-proxy/chat to call models." + } + } diff --git a/backend/python/main.py b/backend/python/main.py index ee5245d..a4f5d7f 100644 --- a/backend/python/main.py +++ b/backend/python/main.py @@ -34,7 +34,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution -from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, code_exec_admin +from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin from app.api.admin.developer_tools_admin import router as developer_tools_router from app.api.admin.mcp_market_admin import router as mcp_market_router from app.api.admin import asset_admin @@ -432,6 +432,7 @@ app.include_router(developer_tools_router, prefix="/app/api/admin/developer-tool app.include_router(apps_admin.router, prefix="/app/api/admin/apps", tags=["admin-ai-apps"]) app.include_router(mcp_market_router, prefix="/app/api/admin/mcp-market", tags=["mcp-market"]) app.include_router(code_exec_admin.router, prefix="/app/api/admin/code-exec", tags=["code-exec"]) +app.include_router(distro_admin.router, prefix="/app/api/admin/distributions", tags=["distributions"]) diff --git a/executor_sandbox/test_executor.py b/executor_sandbox/test_executor.py new file mode 100644 index 0000000..13871e8 --- /dev/null +++ b/executor_sandbox/test_executor.py @@ -0,0 +1,81 @@ +import urllib.request, json + +def test(label, data): + req = urllib.request.Request( + 'http://localhost:18001/execute', + data=json.dumps(data).encode(), + headers={'Content-Type': 'application/json'} + ) + try: + resp = urllib.request.urlopen(req, timeout=15) + result = json.loads(resp.read()) + print(f'[PASS] {label}: success={result.get("success")}, exit_code={result.get("exit_code")}, time={result.get("execution_time_ms",0):.1f}ms') + if result.get('stdout'): + print(f' stdout: {result["stdout"][:200]}') + if result.get('stderr'): + print(f' stderr: {result["stderr"][:200]}') + if result.get('error'): + print(f' error: {result["error"]}') + except Exception as e: + print(f'[FAIL] {label}: {e}') + +print('=== Sandbox Execution Tests ===') + +# Test 1: Python hello +test('Python Hello', { + 'language': 'python', + 'code': 'print("Hello from sandbox!")\nimport sys\nprint(f"Python {sys.version.split()[0]}")', + 'timeout': 10 +}) + +# Test 2: Python with stdin +test('Python stdin', { + 'language': 'python', + 'code': 'name = input()\nage = input()\nprint(f"Hello {name}, age {age}")', + 'stdin': 'Alice\n25\n', + 'timeout': 10 +}) + +# Test 3: JavaScript +test('JavaScript', { + 'language': 'javascript', + 'code': 'console.log("Hello from Node.js!");\nconsole.log("Version: " + process.version);', + 'timeout': 10 +}) + +# Test 4: Timeout +test('Timeout', { + 'language': 'python', + 'code': 'import time\ntime.sleep(10)\nprint("Done")', + 'timeout': 2 +}) + +# Test 5: Compute +test('Fibonacci', { + 'language': 'python', + 'code': 'def fib(n):\n a,b=0,1\n for _ in range(n): a,b=b,a+b\n return a\nprint(f"fib(30)={fib(30)}")', + 'timeout': 10 +}) + +# Test 6: Error code +test('Python Error', { + 'language': 'python', + 'code': 'print(1/0)', + 'timeout': 10 +}) + +# Test 7: JS Error +test('JS Error', { + 'language': 'javascript', + 'code': 'throw new Error("test error");', + 'timeout': 10 +}) + +# Test 8: Large output +test('Large Output', { + 'language': 'python', + 'code': 'for i in range(100): print(f"Line {i}: Hello World" * 5)', + 'timeout': 10 +}) + +print('\nDone!') diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1ca5180..b179212 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,128 +1,128 @@ -import React, { useState, useEffect } from 'react'; -import { Routes, Route, Navigate } from 'react-router-dom'; -import type { User } from '@/types'; -import { AuthContext, AuthContextType } from '@/contexts/AuthContext'; -import { authAPI } from '@/api'; - -// Layouts -import FrontLayout from '@/components/layout/FrontLayout'; -import AdminLayout from '@/components/layout/AdminLayout'; - -// Front Pages -import LandingPage from '@/pages/front/LandingPage'; -import LoginPage from '@/pages/front/LoginPage'; -import ChatPage from '@/pages/front/ChatPage'; -import ChannelPage from '@/pages/front/ChannelPage'; -import KnowledgePage from '@/pages/front/KnowledgePage'; -import MarketPage from '@/pages/front/MarketPage'; -import TokenDashboardPage from '@/pages/front/TokenDashboardPage'; -import PricingPage from '@/pages/front/PricingPage'; -import ProfilePage from '@/pages/front/ProfilePage'; -import NotificationPage from '@/pages/front/NotificationPage'; -import AssetsPage from '@/pages/front/AssetsPage'; -import NovelPreviewPage from '@/pages/front/NovelPreviewPage'; -import EvolutionDashboard from '@/pages/front/EvolutionDashboard'; - -// Admin Pages -import AdminDashboard from '@/pages/admin/Dashboard'; -import AdminUsers from '@/pages/admin/Users'; -import AdminModels from '@/pages/admin/Models'; -import AdminSiteSettings from '@/pages/admin/SiteSettings'; -import AdminPayment from '@/pages/admin/Payment'; -import AdminServices from '@/pages/admin/Services'; -import AdminMembership from '@/pages/admin/Membership'; -import AdminAds from '@/pages/admin/Ads'; -import AdminMarket from '@/pages/admin/Market'; -import AdminLogs from '@/pages/admin/Logs'; -import AdminSkills from '@/pages/admin/Skills' -import AdminAgents from '@/pages/admin/Agents'; -import AdminCodeExec from '@/pages/admin/CodeExec';; - -const App: React.FC = () => { - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); - - // 初始化:从localStorage恢复登录状态 - useEffect(() => { - const token = localStorage.getItem('aiflow_token'); - const cachedUser = localStorage.getItem('aiflow_user'); - if (token && cachedUser) { - try { - setUser(JSON.parse(cachedUser)); - } catch { - localStorage.removeItem('aiflow_token'); - localStorage.removeItem('aiflow_user'); - } - } - setLoading(false); - }, []); - - const login = (u: User) => { - setUser(u); - localStorage.setItem('aiflow_user', JSON.stringify(u)); - }; - const logout = () => { - setUser(null); - localStorage.removeItem('aiflow_token'); - localStorage.removeItem('aiflow_user'); - }; - const isAdmin = user?.role === 'admin'; - - if (loading) { - return
加载中...
; - } - - return ( - - - {/* Login */} - : ) : - } /> - - {/* Root - Landing for guests, redirect for authenticated users */} - : ) : - } /> - - {/* Front Routes (authenticated only) */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - {/* Admin Routes */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } /> - } /> - } /> - - - } /> - - - ); -}; - -export default App; +import React, { useState, useEffect } from 'react'; +import { Routes, Route, Navigate } from 'react-router-dom'; +import type { User } from '@/types'; +import { AuthContext, AuthContextType } from '@/contexts/AuthContext'; +import { authAPI } from '@/api'; + +// Layouts +import FrontLayout from '@/components/layout/FrontLayout'; +import AdminLayout from '@/components/layout/AdminLayout'; + +// Front Pages +import LandingPage from '@/pages/front/LandingPage'; +import LoginPage from '@/pages/front/LoginPage'; +import ChatPage from '@/pages/front/ChatPage'; +import ChannelPage from '@/pages/front/ChannelPage'; +import KnowledgePage from '@/pages/front/KnowledgePage'; +import MarketPage from '@/pages/front/MarketPage'; +import TokenDashboardPage from '@/pages/front/TokenDashboardPage'; +import PricingPage from '@/pages/front/PricingPage'; +import ProfilePage from '@/pages/front/ProfilePage'; +import NotificationPage from '@/pages/front/NotificationPage'; +import AssetsPage from '@/pages/front/AssetsPage'; +import NovelPreviewPage from '@/pages/front/NovelPreviewPage'; +import EvolutionDashboard from '@/pages/front/EvolutionDashboard'; + +// Admin Pages +import AdminDashboard from '@/pages/admin/Dashboard'; +import AdminUsers from '@/pages/admin/Users'; +import AdminModels from '@/pages/admin/Models'; +import AdminSiteSettings from '@/pages/admin/SiteSettings'; +import AdminPayment from '@/pages/admin/Payment'; +import AdminServices from '@/pages/admin/Services'; +import AdminMembership from '@/pages/admin/Membership'; +import AdminAds from '@/pages/admin/Ads'; +import AdminMarket from '@/pages/admin/Market'; +import AdminLogs from '@/pages/admin/Logs'; +import AdminSkills from '@/pages/admin/Skills' +import AdminAgents from '@/pages/admin/Agents'; +import AdminCodeExec from '@/pages/admin/CodeExec';; + +const App: React.FC = () => { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + // 初始化:从localStorage恢复登录状态 + useEffect(() => { + const token = localStorage.getItem('aiflow_token'); + const cachedUser = localStorage.getItem('aiflow_user'); + if (token && cachedUser) { + try { + setUser(JSON.parse(cachedUser)); + } catch { + localStorage.removeItem('aiflow_token'); + localStorage.removeItem('aiflow_user'); + } + } + setLoading(false); + }, []); + + const login = (u: User) => { + setUser(u); + localStorage.setItem('aiflow_user', JSON.stringify(u)); + }; + const logout = () => { + setUser(null); + localStorage.removeItem('aiflow_token'); + localStorage.removeItem('aiflow_user'); + }; + const isAdmin = user?.role === 'admin'; + + if (loading) { + return
加载中...
; + } + + return ( + + + {/* Login */} + : ) : + } /> + + {/* Root - Landing for guests, redirect for authenticated users */} + : ) : + } /> + + {/* Front Routes (authenticated only) */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + {/* Admin Routes */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + + + } /> + + + ); +}; + +export default App; diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 5ed9687..ec00298 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -663,6 +663,27 @@ export const adminAPI = { getCodeExecTask: (id: string) => get(`/admin/code-exec/tasks/${id}`), deleteCodeExecTask: (id: string) => del(`/admin/code-exec/tasks/${id}`), + + // Distribution Builder (Phase G4) + getDistributionStats: () => get('/admin/distributions/stats'), + getDistributions: (params?: any) => { + const sp = new URLSearchParams(); + if (params) Object.entries(params).forEach(([k, v]: [string, any]) => { if (v) sp.set(k, String(v)); }); + return get('/admin/distributions' + (sp.toString() ? '?' + sp.toString() : '')); + }, + getDistributionTemplates: () => get('/admin/distributions/templates'), + createDistribution: (data: any) => post('/admin/distributions', data), + getDistribution: (id: string) => get('/admin/distributions/' + id), + updateDistribution: (id: string, data: any) => put('/admin/distributions/' + id, data), + deleteDistribution: (id: string) => del('/admin/distributions/' + id), + getDistributionLicenses: (params?: any) => { + const sp = new URLSearchParams(); + if (params) Object.entries(params).forEach(([k, v]: [string, any]) => { if (v) sp.set(k, String(v)); }); + return get('/admin/distributions/licenses' + (sp.toString() ? '?' + sp.toString() : '')); + }, + createDistributionLicense: (distroId: string, data: any) => post('/admin/distributions/' + distroId + '/licenses', data), + getDistributionLicense: (id: string) => get('/admin/distributions/licenses/' + id), + revokeDistributionLicense: (id: string) => post('/admin/distributions/licenses/' + id + '/revoke'), }; // ===== AI自进化API ===== export const evolutionAPI = { diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx index 5ba44fc..b937a7a 100644 --- a/frontend/src/components/layout/AdminLayout.tsx +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -1,129 +1,132 @@ -import React, { useState } from 'react'; -import { Outlet, NavLink, useNavigate } from 'react-router-dom'; -import { useAuth } from '@/contexts/AuthContext'; -import './AdminLayout.css'; - -const adminNavItems = [ - { path: '/admin', icon: 'dashboard', label: '平台驾驶舱', exact: true }, - { path: '/admin/users', icon: 'users', label: '用户管理' }, - { path: '/admin/models', icon: 'models', label: '大模型库管理' }, - { path: '/admin/settings', icon: 'settings', label: '网站基础设置' }, - { path: '/admin/payment', icon: 'payment', label: '支付渠道配置' }, - { path: '/admin/services', icon: 'services', label: '增值服务管理' }, - { path: '/admin/membership', icon: 'membership', label: '会员订阅体系' }, - { path: '/admin/ads', icon: 'ads', label: '广告位管理' }, - { path: '/admin/market', icon: 'market', label: '应用市场审核' }, - { path: '/admin/logs', icon: 'logs', label: '日志与风控' }, - - { path: '/admin/agents', icon: 'agents', label: 'Agent管理' }, - { path: '/admin/skills', icon: 'skills', label: '技能管理' }, - { path: '/admin/code-exec', icon: 'code', label: '代码执行沙箱' },]; - -const adminIconPaths: Record = { - dashboard: , - users: , - models: , - settings: , - payment: , - services: , - membership: , - ads: , - market: , - logs: , - - skills: , - code: ,}; - -const AdminLayout: React.FC = () => { - const { user, logout } = useAuth(); - const navigate = useNavigate(); - const [collapsed, setCollapsed] = useState(false); - - if (!user || user.role !== 'admin') { - navigate('/login'); - return null; - } - - return ( -
- {/* Admin Sidebar */} - - - {/* Admin Main Content */} -
-
-
- 欢迎,{user.username} -
-
- - - 返回前台 - -
-
- {user.username.charAt(0).toUpperCase()} -
- {user.username} -
-
-
-
- -
-
-
- ); -}; - -export default AdminLayout; +import React, { useState } from 'react'; +import { Outlet, NavLink, useNavigate } from 'react-router-dom'; +import { useAuth } from '@/contexts/AuthContext'; +import './AdminLayout.css'; + +const adminNavItems = [ + { path: '/admin', icon: 'dashboard', label: '平台驾驶舱', exact: true }, + { path: '/admin/users', icon: 'users', label: '用户管理' }, + { path: '/admin/models', icon: 'models', label: '大模型库管理' }, + { path: '/admin/settings', icon: 'settings', label: '网站基础设置' }, + { path: '/admin/payment', icon: 'payment', label: '支付渠道配置' }, + { path: '/admin/services', icon: 'services', label: '增值服务管理' }, + { path: '/admin/membership', icon: 'membership', label: '会员订阅体系' }, + { path: '/admin/ads', icon: 'ads', label: '广告位管理' }, + { path: '/admin/market', icon: 'market', label: '应用市场审核' }, + { path: '/admin/logs', icon: 'logs', label: '日志与风控' }, + + { path: '/admin/agents', icon: 'agents', label: 'Agent管理' }, + { path: '/admin/skills', icon: 'skills', label: '技能管理' }, + { path: '/admin/code-exec', icon: 'code', label: '代码执行沙箱' }, + { path: '/admin/distributions', icon: 'distro', label: '发行版构建' }, +]; + +const adminIconPaths: Record = { + dashboard: , + users: , + models: , + settings: , + payment: , + services: , + membership: , + ads: , + market: , + logs: , + + skills: , + code: , + distro: ,}; + +const AdminLayout: React.FC = () => { + const { user, logout } = useAuth(); + const navigate = useNavigate(); + const [collapsed, setCollapsed] = useState(false); + + if (!user || user.role !== 'admin') { + navigate('/login'); + return null; + } + + return ( +
+ {/* Admin Sidebar */} + + + {/* Admin Main Content */} +
+
+
+ 欢迎,{user.username} +
+
+ + + 返回前台 + +
+
+ {user.username.charAt(0).toUpperCase()} +
+ {user.username} +
+
+
+
+ +
+
+
+ ); +}; + +export default AdminLayout; diff --git a/frontend/src/pages/admin/DistributionBuilder.tsx b/frontend/src/pages/admin/DistributionBuilder.tsx new file mode 100644 index 0000000..f644792 --- /dev/null +++ b/frontend/src/pages/admin/DistributionBuilder.tsx @@ -0,0 +1,493 @@ +import React, { useState, useEffect } from 'react'; +import { adminAPI } from '@/api'; + +interface Distribution { + id: string; + name: string; + description: string; + brand_name: string; + brand_logo_url: string; + brand_color: string; + system_prompt: string; + provider: string; + model: string; + mcp_extensions: any[]; + workflow_ids: string[]; + knowledge_base_ids: string[]; + agent_ids: string[]; + features: string[]; + status: string; + version: string; + download_count: number; + size_bytes: number; + license_count?: number; + created_at: string; + updated_at: string; +} + +interface License { + id: string; + distribution_id: string; + distribution_name: string; + org_name: string; + licensee_email: string; + license_key: string; + features: string[]; + max_users: number; + max_executions: number; + usage_count: number; + expiry_date: string | null; + status: string; + created_at: string; + last_used_at: string | null; +} + +interface Stats { + total: number; + draft: number; + active: number; + archived: number; + total_downloads: number; + total_licenses: number; + active_licenses: number; +} + +interface Templates { + workflows: any[]; + agents: any[]; + knowledge_bases: any[]; + mcp_tools: any[]; +} + +const STATUS_LABELS: Record = { draft: '草稿', active: '已发布', archived: '已归档' }; +const STATUS_COLORS: Record = { draft: '#8b949e', active: '#3fb950', archived: '#f85149' }; +const FEATURE_LABELS: Record = { + chat: 'AI 对话', code_exec: '代码执行', knowledge_search: '知识库检索', + agent_collab: 'Agent 协作', automation: '自动化', webhook: 'Webhook', +}; + +export default function DistributionBuilder() { + const [distributions, setDistributions] = useState([]); + const [stats, setStats] = useState({ total: 0, draft: 0, active: 0, archived: 0, total_downloads: 0, total_licenses: 0, active_licenses: 0 }); + const [templates, setTemplates] = useState({ workflows: [], agents: [], knowledge_bases: [], mcp_tools: [] }); + const [licenses, setLicenses] = useState([]); + const [loading, setLoading] = useState(true); + const [tab, setTab] = useState<'distributions' | 'licenses'>('distributions'); + const [showCreate, setShowCreate] = useState(false); + const [showLicense, setShowLicense] = useState(false); + const [selectedDistro, setSelectedDistro] = useState(''); + const [editDistro, setEditDistro] = useState(null); + const [page, setPage] = useState(1); + const [licPage, setLicPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [licTotalPages, setLicTotalPages] = useState(1); + + // Form state + const [form, setForm] = useState({ + name: '', description: '', brand_name: '', brand_color: '#6366f1', + system_prompt: '', provider: 'siliconflow', model: 'deepseek-ai/DeepSeek-V4-Flash', + features: ['chat'] as string[], version: '1.0.0', + workflow_ids: [] as string[], agent_ids: [] as string[], knowledge_base_ids: [] as string[], + }); + + // License form + const [licForm, setLicForm] = useState({ + org_name: '', licensee_email: '', max_users: 10, max_executions: 10000, + expiry_date: '', features: ['chat'] as string[], + }); + + const loadData = async () => { + setLoading(true); + try { + const [s, d, t] = await Promise.all([ + adminAPI.getDistributionStats(), + adminAPI.getDistributions({ page, page_size: 20 }), + adminAPI.getDistributionTemplates(), + ]); + if (s.code === 0) setStats(s.data); + if (d.code === 0) { setDistributions(d.data.items); setTotalPages(Math.ceil(d.data.total / 20)); } + if (t.code === 0) setTemplates(t.data); + } catch (e) { console.error(e); } + setLoading(false); + }; + + const loadLicenses = async () => { + try { + const res = await adminAPI.getDistributionLicenses({ page: licPage, page_size: 20 }); + if (res.code === 0) { setLicenses(res.data.items); setLicTotalPages(Math.ceil(res.data.total / 20)); } + } catch (e) { console.error(e); } + }; + + useEffect(() => { loadData(); }, [page]); + useEffect(() => { if (tab === 'licenses') loadLicenses(); }, [tab, licPage]); + + const handleCreate = async () => { + try { + const res = await adminAPI.createDistribution(form); + if (res.code === 0) { setShowCreate(false); resetForm(); loadData(); } + else alert('创建失败: ' + (res.message || '')); + } catch (e) { alert('创建失败'); } + }; + + const handleUpdate = async () => { + if (!editDistro) return; + try { + const res = await adminAPI.updateDistribution(editDistro.id, form); + if (res.code === 0) { setEditDistro(null); resetForm(); loadData(); } + else alert('更新失败'); + } catch (e) { alert('更新失败'); } + }; + + const handleDelete = async (id: string) => { + if (!confirm('确定删除此发行版?关联的许可证也将被删除。')) return; + try { + await adminAPI.deleteDistribution(id); + loadData(); + } catch (e) { alert('删除失败'); } + }; + + const handleBuild = async (id: string) => { + try { + const token = localStorage.getItem('token') || ''; + window.open(`/app/api/admin/distributions/${id}/build`, '_blank'); + } catch (e) { alert('下载失败'); } + }; + + const handleGenerateLicense = async () => { + if (!selectedDistro) return; + try { + const res = await adminAPI.createDistributionLicense(selectedDistro, licForm); + if (res.code === 0) { setShowLicense(false); setLicForm({ org_name: '', licensee_email: '', max_users: 10, max_executions: 10000, expiry_date: '', features: ['chat'] }); loadLicenses(); } + else alert('生成失败: ' + (res.message || '')); + } catch (e) { alert('生成失败'); } + }; + + const handleRevokeLicense = async (id: string) => { + if (!confirm('确定吊销此许可证?')) return; + try { + await adminAPI.revokeDistributionLicense(id); + loadLicenses(); + } catch (e) { alert('吊销失败'); } + }; + + const resetForm = () => { + setForm({ name: '', description: '', brand_name: '', brand_color: '#6366f1', system_prompt: '', provider: 'siliconflow', model: 'deepseek-ai/DeepSeek-V4-Flash', features: ['chat'], version: '1.0.0', workflow_ids: [], agent_ids: [], knowledge_base_ids: [] }); + }; + + const openEdit = (d: Distribution) => { + setEditDistro(d); + setForm({ + name: d.name, description: d.description || '', brand_name: d.brand_name || '', + brand_color: d.brand_color || '#6366f1', system_prompt: d.system_prompt || '', + provider: d.provider || 'siliconflow', model: d.model || 'deepseek-ai/DeepSeek-V4-Flash', + features: d.features || ['chat'], version: d.version || '1.0.0', + workflow_ids: d.workflow_ids || [], agent_ids: d.agent_ids || [], knowledge_base_ids: d.knowledge_base_ids || [], + }); + }; + + const toggleFeature = (feat: string) => { + setForm(prev => ({ + ...prev, + features: prev.features.includes(feat) ? prev.features.filter(f => f !== feat) : [...prev.features, feat], + })); + }; + + const toggleLicFeature = (feat: string) => { + setLicForm(prev => ({ + ...prev, + features: prev.features.includes(feat) ? prev.features.filter(f => f !== feat) : [...prev.features, feat], + })); + }; + + const isEditing = !!editDistro; + + // Styles + const dark = { bg: '#0d1117', card: '#161b22', border: '#21262d', text: '#c9d1d9', sub: '#8b949e', accent: '#6366f1', accentLight: '#818cf8', green: '#3fb950', red: '#f85149', yellow: '#d29922' }; + + const cardStyle: React.CSSProperties = { background: dark.card, border: `1px solid ${dark.border}`, borderRadius: 12, padding: 20 }; + const btnPrimary: React.CSSProperties = { background: dark.accent, color: '#fff', border: 'none', borderRadius: 8, padding: '8px 20px', cursor: 'pointer', fontSize: 14, fontWeight: 600 }; + const btnDanger: React.CSSProperties = { background: dark.red, color: '#fff', border: 'none', borderRadius: 8, padding: '8px 20px', cursor: 'pointer', fontSize: 14, fontWeight: 600 }; + const btnGhost: React.CSSProperties = { background: 'transparent', color: dark.text, border: `1px solid ${dark.border}`, borderRadius: 8, padding: '8px 20px', cursor: 'pointer', fontSize: 14 }; + const inputStyle: React.CSSProperties = { background: '#0d1117', color: dark.text, border: `1px solid ${dark.border}`, borderRadius: 6, padding: '8px 12px', fontSize: 14, width: '100%', boxSizing: 'border-box', outline: 'none' }; + const textareaStyle: React.CSSProperties = { ...inputStyle, minHeight: 80, resize: 'vertical', fontFamily: 'monospace' }; + const labelStyle: React.CSSProperties = { color: dark.sub, fontSize: 13, marginBottom: 4, display: 'block' }; + + return ( +
+ {/* Header */} +
+
+

发行版构建器

+

SaaS 产品线管理 · 自定义发行版配置包生成 · 许可证管理

+
+ +
+ + {/* Stats */} +
+ {[ + { label: '发行版总数', value: stats.total }, + { label: '已发布', value: stats.active, color: dark.green }, + { label: '累计下载', value: stats.total_downloads, color: dark.accentLight }, + { label: '有效许可证', value: stats.active_licenses, color: dark.yellow }, + ].map((s, i) => ( +
+
{s.label}
+
{s.value}
+
+ ))} +
+ + {/* Tabs */} +
+ {[ + { key: 'distributions', label: '发行版管理' }, + { key: 'licenses', label: '许可证管理' }, + ].map(t => ( + + ))} +
+ + {/* Distributions Tab */} + {tab === 'distributions' && ( + <> +
+ {distributions.map(d => ( +
+
+ {(d.brand_name || d.name)[0]} +
+
+
+ {d.name} + + {STATUS_LABELS[d.status] || d.status} + + v{d.version} +
+
{d.description || '无描述'}
+
+ {d.features?.map(f => ( + ✓ {FEATURE_LABELS[f] || f} + ))} + 下载 {d.download_count || 0} 次 + {d.license_count || 0} 个许可证 +
+
+
+ + + + +
+
+ ))} +
+ {totalPages > 1 && ( +
+ + 第 {page}/{totalPages} 页 + +
+ )} + + )} + + {/* Licenses Tab */} + {tab === 'licenses' && ( + <> +
+ +
+
+ + + + {['许可证密钥', '组织', '发行版', '用量', '状态', '过期', '操作'].map(h => ( + + ))} + + + + {licenses.map(lic => ( + + + + + + + + + + ))} + +
{h}
{lic.license_key}{lic.org_name}{lic.distribution_name}{lic.usage_count}/{lic.max_executions} + {lic.status} + {lic.expiry_date || '永久'} + {lic.status === 'active' && ( + + )} +
+
+ {licTotalPages > 1 && ( +
+ + 第 {licPage}/{licTotalPages} 页 + +
+ )} + + )} + + {/* Create/Edit Modal */} + {(showCreate || isEditing) && ( +
+
+

{isEditing ? '编辑发行版' : '新建发行版'}

+ +
+ + setForm({ ...form, name: e.target.value })} placeholder="例如: Enterprise AI Suite" /> +
+
+ +