Browse Source

项目初始化

master
MSI-7950X\刘泽明 1 week ago
parent
commit
96261c68f8
  1. 14
      .env.example
  2. 2
      .gitignore
  3. 2533
      ENTERPRISE_PLAN.md
  4. 15
      backend/Dockerfile
  5. 0
      backend/__init__.py
  6. 0
      backend/agentscope_integration/__init__.py
  7. 182
      backend/agentscope_integration/factory.py
  8. 0
      backend/agentscope_integration/hooks/__init__.py
  9. 23
      backend/agentscope_integration/hooks/rbac_hook.py
  10. 0
      backend/agentscope_integration/memory/__init__.py
  11. 30
      backend/agentscope_integration/memory/user_memory.py
  12. 0
      backend/agentscope_integration/tools/__init__.py
  13. 9
      backend/agentscope_integration/tools/document_tools.py
  14. 41
      backend/agentscope_integration/tools/wecom_tools.py
  15. 29
      backend/config.py
  16. 41
      backend/database.py
  17. 85
      backend/dependencies.py
  18. 54
      backend/main.py
  19. 0
      backend/middleware/__init__.py
  20. 90
      backend/middleware/cache_manager.py
  21. 53
      backend/middleware/rate_limiter.py
  22. 58
      backend/middleware/rbac_middleware.py
  23. 211
      backend/models/__init__.py
  24. 0
      backend/modules/__init__.py
  25. 0
      backend/modules/agent_manager/__init__.py
  26. 121
      backend/modules/agent_manager/router.py
  27. 0
      backend/modules/audit/__init__.py
  28. 153
      backend/modules/audit/router.py
  29. 0
      backend/modules/auth/__init__.py
  30. 100
      backend/modules/auth/router.py
  31. 0
      backend/modules/document/__init__.py
  32. 199
      backend/modules/document/router.py
  33. 0
      backend/modules/flow_engine/__init__.py
  34. 318
      backend/modules/flow_engine/engine.py
  35. 269
      backend/modules/flow_engine/router.py
  36. 0
      backend/modules/mcp_registry/__init__.py
  37. 196
      backend/modules/mcp_registry/router.py
  38. 0
      backend/modules/monitor/__init__.py
  39. 196
      backend/modules/monitor/router.py
  40. 0
      backend/modules/notification/__init__.py
  41. 198
      backend/modules/notification/router.py
  42. 0
      backend/modules/org/__init__.py
  43. 215
      backend/modules/org/router.py
  44. 0
      backend/modules/rbac/__init__.py
  45. 109
      backend/modules/rbac/router.py
  46. 0
      backend/modules/system/__init__.py
  47. 147
      backend/modules/system/router.py
  48. 0
      backend/modules/task/__init__.py
  49. 186
      backend/modules/task/router.py
  50. 0
      backend/modules/wecom/__init__.py
  51. 165
      backend/modules/wecom/router.py
  52. 14
      backend/requirements.txt
  53. 370
      backend/schemas/__init__.py
  54. 81
      docker-compose.yml
  55. 12
      frontend/Dockerfile
  56. 7
      frontend/env.d.ts
  57. 13
      frontend/index.html
  58. 28
      frontend/nginx.conf
  59. 31
      frontend/package.json
  60. 19
      frontend/src/App.vue
  61. 132
      frontend/src/api/index.ts
  62. 189
      frontend/src/components/layout/AdminLayout.vue
  63. 181
      frontend/src/components/layout/MainLayout.vue
  64. 18
      frontend/src/main.ts
  65. 184
      frontend/src/router/index.ts
  66. 32
      frontend/src/stores/user.ts
  67. 142
      frontend/src/views/agent/AgentChat.vue
  68. 52
      frontend/src/views/agent/AgentList.vue
  69. 162
      frontend/src/views/audit/AuditLog.vue
  70. 97
      frontend/src/views/dashboard/Dashboard.vue
  71. 152
      frontend/src/views/document/DocumentManager.vue
  72. 495
      frontend/src/views/flow/FlowEditor.vue
  73. 106
      frontend/src/views/flow/FlowList.vue
  74. 72
      frontend/src/views/flow/FlowMarket.vue
  75. 97
      frontend/src/views/login/Login.vue
  76. 90
      frontend/src/views/monitor/AIAnalysis.vue
  77. 38
      frontend/src/views/monitor/EmployeeList.vue
  78. 92
      frontend/src/views/monitor/WorkDashboard.vue
  79. 175
      frontend/src/views/notification/NotificationCenter.vue
  80. 142
      frontend/src/views/org/DepartmentTree.vue
  81. 133
      frontend/src/views/org/UserList.vue
  82. 87
      frontend/src/views/role/PermissionConfig.vue
  83. 190
      frontend/src/views/role/RoleList.vue
  84. 169
      frontend/src/views/system/SystemMonitor.vue
  85. 79
      frontend/src/views/task/TaskCreate.vue
  86. 66
      frontend/src/views/task/TaskDetail.vue
  87. 74
      frontend/src/views/task/TaskList.vue
  88. 64
      frontend/src/views/wecom/BotConfig.vue
  89. 3
      frontend/tsconfig.app.json
  90. 24
      frontend/tsconfig.json
  91. 21
      frontend/vite.config.ts
  92. 239
      init-db/01-init.sql
  93. 45
      nginx/nginx.conf

14
.env.example

@ -0,0 +1,14 @@
DATABASE_URL=postgresql+asyncpg://enterprise:enterprise123@postgres:5432/enterprise_ai
REDIS_URL=redis://:redis123@redis:6379/0
JWT_SECRET=change-this-to-a-long-random-string-in-production
LLM_API_KEY=sk-your-api-key
LLM_API_BASE=https://api.openai.com/v1
LLM_MODEL=gpt-4o-mini
RATE_LIMIT_PER_MINUTE=60
RATE_LIMIT_BURST=10
UPLOAD_DIR=./uploads
MAX_UPLOAD_SIZE_MB=50
WECOM_CORP_ID=
WECOM_APP_SECRET=
WECOM_TOKEN=
WECOM_AES_KEY=

2
.gitignore

@ -0,0 +1,2 @@
ali-agentscope-src/
.env

2533
ENTERPRISE_PLAN.md

File diff suppressed because it is too large

15
backend/Dockerfile

@ -0,0 +1,15 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

0
backend/__init__.py

0
backend/agentscope_integration/__init__.py

182
backend/agentscope_integration/factory.py

@ -0,0 +1,182 @@
from agentscope.agent import AgentBase
from agentscope.agent._react_agent import ReActAgent
from agentscope.model import OpenAIChatModel
from agentscope.formatter import OpenAIChatFormatter
from agentscope.tool import Toolkit
from agentscope.message import Msg
from config import settings
from .memory.user_memory import UserIsolatedMemory
from .hooks.rbac_hook import register_rbac_hooks_for_user
class AgentFactory:
_model: OpenAIChatModel | None = None
_formatter: OpenAIChatFormatter | None = None
_agent_cache: dict[str, AgentBase] = {}
@classmethod
def _get_model(cls) -> OpenAIChatModel:
if cls._model is None:
cls._model = OpenAIChatModel(
config_name="enterprise_model",
model_name=settings.LLM_MODEL,
api_key=settings.LLM_API_KEY,
api_base=settings.LLM_API_BASE,
)
return cls._model
@classmethod
def _get_formatter(cls) -> OpenAIChatFormatter:
if cls._formatter is None:
cls._formatter = OpenAIChatFormatter()
return cls._formatter
@classmethod
async def create_agent(
cls,
agent_type: str,
user_id: str,
user_name: str,
department_id: str | None = None,
) -> AgentBase:
cache_key = f"{agent_type}_{user_id}"
if cache_key in cls._agent_cache:
return cls._agent_cache[cache_key]
model = cls._get_model()
formatter = cls._get_formatter()
if agent_type == "employee":
agent = await cls._create_employee_agent(user_id, user_name, department_id, model, formatter)
elif agent_type == "manager":
agent = await cls._create_manager_agent(user_id, user_name, model, formatter)
elif agent_type == "task":
agent = await cls._create_task_agent(user_id, user_name, model, formatter)
elif agent_type == "document":
agent = await cls._create_document_agent(user_id, user_name, model, formatter)
else:
agent = await cls._create_employee_agent(user_id, user_name, department_id, model, formatter)
cls._agent_cache[cache_key] = agent
return agent
@classmethod
async def _create_employee_agent(cls, user_id, user_name, department_id, model, formatter):
from .tools.wecom_tools import send_notification
from .tools.document_tools import parse_document, format_correction
toolkit = Toolkit()
toolkit.register_tool_function(send_notification)
toolkit.register_tool_function(parse_document)
toolkit.register_tool_function(format_correction)
agent = ReActAgent(
name=f"EmployeeAI_{user_name}",
sys_prompt=f"""你是 {user_name} 的专属AI工作助手。
你可以:
1. 回答工作中的问题提供专业建议
2. 帮助处理文档修正格式
3. 查询知识库获取信息
4. 发送通知给相关人员
重要约束:
- 只能访问该员工权限范围内的数据和工具
- 涉及敏感操作需要二次确认
- 始终保持专业和友好的态度""",
model=model,
formatter=formatter,
toolkit=toolkit,
memory=UserIsolatedMemory(user_id=user_id),
max_iters=8,
)
register_rbac_hooks_for_user(agent, {
"user_id": user_id,
"user_name": user_name,
"role": "employee",
"department_id": department_id or "",
"data_scope": "self_only",
})
return agent
@classmethod
async def _create_manager_agent(cls, user_id, user_name, model, formatter):
toolkit = Toolkit()
agent = ReActAgent(
name=f"ManagerAI_{user_name}",
sys_prompt=f"""你是 {user_name} 的管理分析助手。
你可以:
1. 分析下属员工的工作数据
2. 生成工作效率报告
3. 提供管理决策建议
重要约束:
- 只能查看你的直接和间接下属的数据
- 不能查看非下属或跨部门员工的数据""",
model=model,
formatter=formatter,
toolkit=toolkit,
memory=UserIsolatedMemory(user_id=user_id),
max_iters=8,
)
register_rbac_hooks_for_user(agent, {
"user_id": user_id,
"user_name": user_name,
"role": "dept_manager",
"data_scope": "subordinate_only",
})
return agent
@classmethod
async def _create_task_agent(cls, user_id, user_name, model, formatter):
toolkit = Toolkit()
agent = ReActAgent(
name=f"TaskAI_{user_name}",
sys_prompt=f"""你是任务管理助手。帮助用户创建、跟踪和管理工作任务。
你可以:
1. 创建新任务并分配给指定人员
2. 查询任务状态和进度
3. 更新任务信息
4. 推送任务通知到企业微信""",
model=model,
formatter=formatter,
toolkit=toolkit,
memory=UserIsolatedMemory(user_id=user_id),
max_iters=8,
)
return agent
@classmethod
async def _create_document_agent(cls, user_id, user_name, model, formatter):
from .tools.document_tools import parse_document, format_correction
toolkit = Toolkit()
toolkit.register_tool_function(parse_document)
toolkit.register_tool_function(format_correction)
agent = ReActAgent(
name=f"DocAI_{user_name}",
sys_prompt=f"""你是文档处理专家。帮助用户处理各类文档。
你可以:
1. 解析PDF/Word/Excel/PPT等格式
2. 修正文档格式
3. 提取文档关键信息
4. 格式转换""",
model=model,
formatter=formatter,
toolkit=toolkit,
memory=UserIsolatedMemory(user_id=user_id),
max_iters=8,
)
return agent

0
backend/agentscope_integration/hooks/__init__.py

23
backend/agentscope_integration/hooks/rbac_hook.py

@ -0,0 +1,23 @@
from agentscope.agent import AgentBase
from agentscope.message import Msg
def create_rbac_pre_reply_hook(user_context: dict):
async def rbac_pre_reply_hook(self: AgentBase, kwargs: dict) -> dict:
msg = kwargs.get("msg")
if msg and isinstance(msg, Msg):
msg.metadata = msg.metadata or {}
msg.metadata["_user_id"] = user_context["user_id"]
msg.metadata["_role"] = user_context.get("role", "employee")
msg.metadata["_department_id"] = user_context.get("department_id", "")
msg.metadata["_data_scope"] = user_context.get("data_scope", "self_only")
return kwargs
return rbac_pre_reply_hook
def register_rbac_hooks_for_user(agent: AgentBase, user_context: dict):
hook = create_rbac_pre_reply_hook(user_context)
hook_name = f"rbac_{user_context['user_id']}"
agent.register_instance_hook("pre_reply", hook_name, hook)

0
backend/agentscope_integration/memory/__init__.py

30
backend/agentscope_integration/memory/user_memory.py

@ -0,0 +1,30 @@
from agentscope.memory import MemoryBase, InMemoryMemory
from agentscope.message import Msg
class UserIsolatedMemory(MemoryBase):
def __init__(self, user_id: str, backend_memory: MemoryBase | None = None):
self.user_id = user_id
self._backend = backend_memory or InMemoryMemory()
async def add(self, msg: Msg | list[Msg] | None) -> None:
if msg is None:
return
msgs = msg if isinstance(msg, list) else [msg]
for m in msgs:
m.metadata = m.metadata or {}
m.metadata["_user_id"] = self.user_id
await self._backend.add(msg)
async def get_memory(self, **kwargs) -> list[Msg]:
all_msgs = await self._backend.get_memory(**kwargs)
return [m for m in all_msgs if m.metadata.get("_user_id") == self.user_id]
async def delete_by_mark(self, mark: str) -> None:
await self._backend.delete_by_mark(mark)
async def update_messages_mark(self, msg_ids: list[str], new_mark: str) -> None:
await self._backend.update_messages_mark(msg_ids, new_mark)
async def update_compressed_summary(self, summary: str) -> None:
await self._backend.update_compressed_summary(summary)

0
backend/agentscope_integration/tools/__init__.py

9
backend/agentscope_integration/tools/document_tools.py

@ -0,0 +1,9 @@
def parse_document(file_path: str, file_type: str = "auto") -> str:
return f"[模拟] 已解析文档 {file_path} (类型: {file_type})"
def format_correction(content: str, format_rules: str = "standard") -> str:
return f"[模拟] 已按 {format_rules} 规则修正格式:\n{content[:200]}..."
__all__ = ["parse_document", "format_correction"]

41
backend/agentscope_integration/tools/wecom_tools.py

@ -0,0 +1,41 @@
def send_notification(to_user: str, message: str, msg_type: str = "text") -> str:
"""
发送企业微信通知
Args:
to_user: 目标用户ID
message: 消息内容
msg_type: 消息类型 (text/textcard)
Returns:
发送结果
"""
return f"通知已发送至 {to_user}: {message}"
def parse_document(file_path: str, file_type: str = "auto") -> str:
"""
解析文档内容
Args:
file_path: 文件路径
file_type: 文件类型 (auto/pdf/word/excel/ppt)
Returns:
解析后的文本内容
"""
return f"[模拟] 已解析文档 {file_path} (类型: {file_type})"
def format_correction(content: str, format_rules: str = "standard") -> str:
"""
修正文档格式
Args:
content: 原始内容
format_rules: 格式规则 (standard/enterprise/custom)
Returns:
修正后的内容
"""
return f"[模拟] 已按 {format_rules} 规则修正格式:\n{content[:200]}..."

29
backend/config.py

@ -0,0 +1,29 @@
import os
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
DATABASE_URL: str = os.getenv(
"DATABASE_URL",
"postgresql+asyncpg://enterprise:enterprise123@localhost:5432/enterprise_ai",
)
REDIS_URL: str = os.getenv("REDIS_URL", "redis://:redis123@localhost:6379/0")
JWT_SECRET: str = os.getenv("JWT_SECRET", "dev-secret-change-me")
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRE_MINUTES: int = 1440
LLM_API_KEY: str = os.getenv("LLM_API_KEY", "sk-placeholder")
LLM_API_BASE: str = os.getenv("LLM_API_BASE", "https://api.openai.com/v1")
LLM_MODEL: str = os.getenv("LLM_MODEL", "gpt-4o-mini")
RATE_LIMIT_PER_MINUTE: int = int(os.getenv("RATE_LIMIT_PER_MINUTE", "60"))
RATE_LIMIT_BURST: int = int(os.getenv("RATE_LIMIT_BURST", "10"))
UPLOAD_DIR: str = os.getenv("UPLOAD_DIR", "./uploads")
MAX_UPLOAD_SIZE_MB: int = int(os.getenv("MAX_UPLOAD_SIZE_MB", "50"))
WECOM_CORP_ID: str = os.getenv("WECOM_CORP_ID", "")
WECOM_APP_SECRET: str = os.getenv("WECOM_APP_SECRET", "")
WECOM_TOKEN: str = os.getenv("WECOM_TOKEN", "")
WECOM_AES_KEY: str = os.getenv("WECOM_AES_KEY", "")
METRICS_COLLECTION_INTERVAL: int = 60
settings = Settings()

41
backend/database.py

@ -0,0 +1,41 @@
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase
from config import settings
class Base(DeclarativeBase):
pass
async_engine = create_async_engine(
settings.DATABASE_URL,
pool_size=20,
max_overflow=40,
pool_pre_ping=True,
pool_recycle=3600,
echo=False,
)
AsyncSessionLocal = async_sessionmaker(
async_engine,
class_=AsyncSession,
expire_on_commit=False,
)
async def init_db():
async with async_engine.begin() as conn:
from models import Base as MBase
await conn.run_sync(MBase.metadata.create_all)
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()

85
backend/dependencies.py

@ -0,0 +1,85 @@
import jwt
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy import select
from database import AsyncSessionLocal
from models import User, UserRole, Role, RolePermission, Permission
from config import settings
security = HTTPBearer(auto_error=False)
async def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(security),
) -> dict:
if hasattr(request.state, "user") and request.state.user:
return request.state.user
if credentials:
try:
payload = jwt.decode(
credentials.credentials,
settings.JWT_SECRET,
algorithms=[settings.JWT_ALGORITHM],
)
user_id = payload.get("sub")
if not user_id:
raise HTTPException(401, "令牌无效")
async with AsyncSessionLocal() as db:
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(401, "用户不存在")
ur_result = await db.execute(
select(Role).join(UserRole).where(UserRole.user_id == user.id)
)
roles = ur_result.scalars().all()
permissions = []
data_scopes = []
for role in roles:
data_scopes.append(role.data_scope)
rp_result = await db.execute(
select(Permission.code)
.join(RolePermission)
.where(RolePermission.role_id == role.id)
)
perms = rp_result.scalars().all()
permissions.extend(perms)
return {
"id": str(user.id),
"username": user.username,
"display_name": user.display_name,
"department_id": str(user.department_id) if user.department_id else None,
"role": roles[0].code if roles else "employee",
"permissions": list(set(permissions)),
"data_scope": "all" if "all" in data_scopes else (
"subordinate_only" if "subordinate_only" in data_scopes else "self_only"
),
}
except jwt.PyJWTError:
raise HTTPException(401, "令牌无效或已过期")
raise HTTPException(401, "未提供认证令牌")
def require_permission(perm_code: str):
async def checker(user: dict = Depends(get_current_user)) -> dict:
if perm_code not in user.get("permissions", []) and "*:*" not in user.get("permissions", []):
raise HTTPException(403, f"缺少权限: {perm_code}")
return user
return checker
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise

54
backend/main.py

@ -0,0 +1,54 @@
from contextlib import asynccontextmanager
from agentscope_runtime.engine import AgentApp
from database import init_db, async_engine
from modules.auth.router import router as auth_router
from modules.org.router import router as org_router
from modules.rbac.router import router as rbac_router
from modules.wecom.router import router as wecom_router
from modules.agent_manager.router import router as agent_manager_router
from modules.task.router import router as task_router
from modules.monitor.router import router as monitor_router
from modules.mcp_registry.router import router as mcp_router
from modules.flow_engine.router import router as flow_router
from modules.audit.router import router as audit_router
from modules.document.router import router as document_router
from modules.notification.router import router as notification_router
from modules.system.router import router as system_router
from middleware.rbac_middleware import rbac_middleware
from middleware.rate_limiter import rate_limit_middleware
from middleware.cache_manager import cache_manager
@asynccontextmanager
async def lifespan(app: AgentApp):
await init_db()
await cache_manager.connect()
yield
await cache_manager.disconnect()
await async_engine.dispose()
app = AgentApp(
app_name="Enterprise AI Platform",
app_description="企业级 AI Agent 平台 - 双RBAC/企微集成/无代码流编排",
lifespan=lifespan,
docs_url="/docs",
redoc_url=None,
)
app.middleware("http")(rate_limit_middleware)
app.middleware("http")(rbac_middleware)
app.include_router(auth_router)
app.include_router(org_router)
app.include_router(rbac_router)
app.include_router(wecom_router)
app.include_router(agent_manager_router)
app.include_router(task_router)
app.include_router(monitor_router)
app.include_router(mcp_router)
app.include_router(flow_router)
app.include_router(audit_router)
app.include_router(document_router)
app.include_router(notification_router)
app.include_router(system_router)

0
backend/middleware/__init__.py

90
backend/middleware/cache_manager.py

@ -0,0 +1,90 @@
import json
import asyncio
from typing import Any
from redis.asyncio import Redis
from config import settings
class CacheManager:
def __init__(self):
self._local: dict[str, tuple[float, Any]] = {}
self._redis: Redis | None = None
self._redis_available = False
self._lock = asyncio.Lock()
async def connect(self):
try:
self._redis = Redis.from_url(settings.REDIS_URL, decode_responses=True)
await self._redis.ping()
self._redis_available = True
except Exception:
self._redis_available = False
async def disconnect(self):
if self._redis:
await self._redis.close()
@property
def available(self) -> bool:
return self._redis_available
async def get(self, key: str) -> Any | None:
if self._redis_available and self._redis:
try:
val = await self._redis.get(key)
if val:
return json.loads(val)
except Exception:
pass
async with self._lock:
entry = self._local.get(key)
if entry:
expire_at, value = entry
if time.time() < expire_at:
return value
del self._local[key]
return None
async def set(self, key: str, value: Any, ttl: int = 300):
if self._redis_available and self._redis:
try:
await self._redis.setex(key, ttl, json.dumps(value, default=str))
except Exception:
pass
async with self._lock:
self._local[key] = (time.time() + ttl, value)
if len(self._local) > 10000:
now = time.time()
expired = [k for k, (t, v) in self._local.items() if now >= t]
for k in expired:
del self._local[k]
async def delete(self, key: str):
if self._redis_available and self._redis:
try:
await self._redis.delete(key)
except Exception:
pass
async with self._lock:
self._local.pop(key, None)
async def delete_pattern(self, pattern: str):
if self._redis_available and self._redis:
try:
keys = await self._redis.keys(pattern)
if keys:
await self._redis.delete(*keys)
except Exception:
pass
async with self._lock:
to_delete = [k for k in self._local if pattern.replace("*", "") in k]
for k in to_delete:
del self._local[k]
cache_manager = CacheManager()
import time # noqa: E402

53
backend/middleware/rate_limiter.py

@ -0,0 +1,53 @@
import time
import asyncio
from collections import defaultdict
from fastapi import Request, HTTPException
from config import settings
class RateLimiter:
def __init__(self):
self._buckets: dict[str, list[float]] = defaultdict(list)
self._lock = asyncio.Lock()
async def check(self, key: str) -> bool:
now = time.time()
limit = settings.RATE_LIMIT_PER_MINUTE
window = 60.0
async with self._lock:
bucket = self._buckets[key]
bucket = [t for t in bucket if now - t < window]
self._buckets[key] = bucket
if len(bucket) >= limit:
return False
bucket.append(now)
return True
async def remaining(self, key: str) -> int:
now = time.time()
async with self._lock:
bucket = [t for t in self._buckets.get(key, []) if now - t < 60]
return max(0, settings.RATE_LIMIT_PER_MINUTE - len(bucket))
rate_limiter = RateLimiter()
async def rate_limit_middleware(request: Request, call_next):
path = request.url.path
if path in ["/health", "/api/auth/login", "/docs", "/openapi.json"]:
return await call_next(request)
client_ip = request.client.host if request.client else "unknown"
key = f"ratelimit:{client_ip}"
if not await rate_limiter.check(key):
raise HTTPException(429, "请求过于频繁,请稍后再试")
response = await call_next(request)
remaining = await rate_limiter.remaining(key)
response.headers["X-RateLimit-Remaining"] = str(remaining)
return response

58
backend/middleware/rbac_middleware.py

@ -0,0 +1,58 @@
import jwt
from fastapi import Request, HTTPException
from fastapi.responses import JSONResponse
from config import settings
from database import AsyncSessionLocal
from models import User, UserRole, Role, RolePermission, Permission
from sqlalchemy import select
async def rbac_middleware(request: Request, call_next):
public_paths = ["/api/auth/login", "/health", "/docs", "/openapi.json", "/wecom/callback"]
if any(request.url.path.startswith(p) for p in public_paths):
return await call_next(request)
token = request.headers.get("Authorization", "").replace("Bearer ", "")
if not token:
return JSONResponse({"code": 401, "message": "未提供认证令牌"}, 401)
try:
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
user_id = payload.get("sub")
except jwt.PyJWTError:
return JSONResponse({"code": 401, "message": "令牌无效或已过期"}, 401)
async with AsyncSessionLocal() as db:
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user or user.status != "active":
return JSONResponse({"code": 401, "message": "用户不存在或已禁用"}, 401)
ur_result = await db.execute(
select(Role).join(UserRole).where(UserRole.user_id == user.id)
)
roles = ur_result.scalars().all()
permissions = []
data_scopes = []
for role in roles:
data_scopes.append(role.data_scope)
rp_result = await db.execute(
select(Permission).join(RolePermission).where(RolePermission.role_id == role.id)
)
perms = rp_result.scalars().all()
permissions.extend([p.code for p in perms])
request.state.user = {
"id": str(user.id),
"username": user.username,
"display_name": user.display_name,
"department_id": str(user.department_id) if user.department_id else None,
"role": roles[0].code if roles else "employee",
"permissions": list(set(permissions)),
"data_scope": "all" if "all" in data_scopes else (
"subordinate_only" if "subordinate_only" in data_scopes else "self_only"
),
}
return await call_next(request)

211
backend/models/__init__.py

@ -0,0 +1,211 @@
import uuid
from datetime import datetime
from sqlalchemy import Column, String, DateTime, ForeignKey, Integer, Boolean, JSON, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from database import Base
class Department(Base):
__tablename__ = "departments"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(100), nullable=False)
parent_id = Column(UUID(as_uuid=True), ForeignKey("departments.id"), nullable=True)
path = Column(String(500), default="/")
level = Column(Integer, default=0)
sort_order = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
children = relationship("Department", backref="parent", remote_side=[id])
users = relationship("User", back_populates="department")
class User(Base):
__tablename__ = "users"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
username = Column(String(50), unique=True, nullable=False)
password_hash = Column(String(255), nullable=False)
display_name = Column(String(100), nullable=False)
email = Column(String(100))
phone = Column(String(20))
wecom_user_id = Column(String(100), unique=True)
department_id = Column(UUID(as_uuid=True), ForeignKey("departments.id"))
position = Column(String(100))
manager_id = Column(UUID(as_uuid=True), ForeignKey("users.id"))
status = Column(String(20), default="active")
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
department = relationship("Department", back_populates="users")
roles = relationship("UserRole", back_populates="user")
manager = relationship("User", remote_side=[id], backref="subordinates")
class Role(Base):
__tablename__ = "roles"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(50), unique=True, nullable=False)
code = Column(String(50), unique=True, nullable=False, default="")
description = Column(String(200))
is_system = Column(Boolean, default=False)
data_scope = Column(String(50), default="self_only")
created_at = Column(DateTime, default=datetime.utcnow)
permissions = relationship("RolePermission", back_populates="role")
class Permission(Base):
__tablename__ = "permissions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
code = Column(String(100), unique=True, nullable=False)
name = Column(String(100), nullable=False)
resource = Column(String(100), nullable=False)
action = Column(String(50), nullable=False)
description = Column(String(200))
class RolePermission(Base):
__tablename__ = "role_permissions"
role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True)
permission_id = Column(UUID(as_uuid=True), ForeignKey("permissions.id", ondelete="CASCADE"), primary_key=True)
role = relationship("Role", back_populates="permissions")
permission = relationship("Permission")
class UserRole(Base):
__tablename__ = "user_roles"
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True)
user = relationship("User", back_populates="roles")
role = relationship("Role")
class ChatSession(Base):
__tablename__ = "chat_sessions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"))
agent_type = Column(String(50), nullable=False)
session_id = Column(String(100), unique=True, nullable=False)
status = Column(String(20), default="active")
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class ChatMessage(Base):
__tablename__ = "chat_messages"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
session_id = Column(UUID(as_uuid=True), ForeignKey("chat_sessions.id", ondelete="CASCADE"))
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"))
role = Column(String(20), nullable=False)
content = Column(Text, nullable=False)
metadata_ = Column("metadata", JSON, default=dict)
created_at = Column(DateTime, default=datetime.utcnow)
class Task(Base):
__tablename__ = "tasks"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
title = Column(String(200), nullable=False)
content = Column(Text)
assigner_id = Column(UUID(as_uuid=True), ForeignKey("users.id"))
assignee_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
status = Column(String(20), default="pending")
priority = Column(String(20), default="normal")
deadline = Column(DateTime)
wecom_message_id = Column(String(100))
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class FlowDefinition(Base):
__tablename__ = "flow_definitions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(200), nullable=False)
description = Column(Text)
version = Column(Integer, default=1)
status = Column(String(20), default="draft")
definition_json = Column(JSON, nullable=False, default=dict)
creator_id = Column(UUID(as_uuid=True), ForeignKey("users.id"))
published_to_wecom = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class FlowExecution(Base):
__tablename__ = "flow_executions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
flow_id = Column(UUID(as_uuid=True), ForeignKey("flow_definitions.id", ondelete="CASCADE"))
trigger_type = Column(String(50))
trigger_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"))
input_data = Column(JSON)
output_data = Column(JSON)
status = Column(String(20), default="running")
started_at = Column(DateTime, default=datetime.utcnow)
finished_at = Column(DateTime)
class MCPService(Base):
__tablename__ = "mcp_services"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(100), unique=True, nullable=False)
transport = Column(String(20), default="http")
url = Column(String(500))
command = Column(String(500))
args = Column(JSON, default=list)
env = Column(JSON, default=dict)
status = Column(String(20), default="disconnected")
tools = Column(JSON, default=list)
creator_id = Column(UUID(as_uuid=True), ForeignKey("users.id"))
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class NotificationTemplate(Base):
__tablename__ = "notification_templates"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(100), nullable=False)
code = Column(String(100), unique=True, nullable=False)
channel = Column(String(20), default="wecom")
title_template = Column(String(500))
body_template = Column(Text, nullable=False)
variables = Column(JSON, default=list)
is_system = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
class SystemMetric(Base):
__tablename__ = "system_metrics"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
metric_type = Column(String(50), nullable=False)
value = Column(JSON, nullable=False)
collected_at = Column(DateTime, default=datetime.utcnow)
class AuditLog(Base):
__tablename__ = "audit_logs"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
operator_id = Column(UUID(as_uuid=True), ForeignKey("users.id"))
action = Column(String(100), nullable=False)
resource = Column(String(100))
resource_id = Column(String(100))
detail = Column(JSON, default=dict)
ip_address = Column(String(50))
created_at = Column(DateTime, default=datetime.utcnow)

0
backend/modules/__init__.py

0
backend/modules/agent_manager/__init__.py

121
backend/modules/agent_manager/router.py

@ -0,0 +1,121 @@
import uuid
from fastapi import APIRouter, Depends, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from models import User, ChatSession, ChatMessage
from agentscope_integration.factory import AgentFactory
router = APIRouter(prefix="/api/agent", tags=["agent"])
@router.post("/chat/{agent_type}")
async def agent_chat(
agent_type: str,
request: Request,
payload: dict,
db: AsyncSession = Depends(get_db),
):
"""
与智能体对话
agent_type: employee | manager | task | document
"""
user_ctx = request.state.user
user_id = uuid.UUID(user_ctx["id"])
msg_content = payload.get("message", "")
session_id = payload.get("session_id", f"session_{uuid.uuid4().hex[:12]}")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
from fastapi import HTTPException
raise HTTPException(404, "用户不存在")
session_result = await db.execute(
select(ChatSession).where(ChatSession.session_id == session_id)
)
session = session_result.scalar_one_or_none()
if not session:
session = ChatSession(
user_id=user.id, agent_type=agent_type,
session_id=session_id,
)
db.add(session)
await db.flush()
user_msg = ChatMessage(
session_id=session.id, user_id=user.id,
role="user", content=msg_content,
)
db.add(user_msg)
await db.flush()
agent = await AgentFactory.create_agent(
agent_type=agent_type,
user_id=str(user.id),
user_name=user.display_name,
department_id=str(user.department_id) if user.department_id else None,
)
from agentscope.message import Msg
input_msg = Msg(name="user", content=msg_content, role="user")
response = await agent.reply(input_msg)
reply_text = response.get_text_content() if hasattr(response, 'get_text_content') else str(response)
ai_msg = ChatMessage(
session_id=session.id, user_id=user.id,
role="assistant", content=reply_text,
)
db.add(ai_msg)
return {
"code": 200,
"data": {
"session_id": session_id,
"reply": reply_text,
"role": "assistant",
},
}
@router.get("/list")
async def get_agent_list(request: Request):
return {
"code": 200,
"data": [
{"type": "employee", "name": "员工AI助手", "description": "日常问答、文档处理、知识查询"},
{"type": "manager", "name": "管理分析助手", "description": "下属工作分析、效能评估"},
{"type": "task", "name": "任务管理助手", "description": "任务创建、分派、追踪"},
{"type": "document", "name": "文档处理助手", "description": "格式修正、内容提取、导入导出"},
],
}
@router.get("/history/{session_id}")
async def get_chat_history(
session_id: str,
request: Request,
db: AsyncSession = Depends(get_db),
):
session_result = await db.execute(
select(ChatSession).where(ChatSession.session_id == session_id)
)
session = session_result.scalar_one_or_none()
if not session:
from fastapi import HTTPException
raise HTTPException(404, "会话不存在")
msg_result = await db.execute(
select(ChatMessage).where(ChatMessage.session_id == session.id).order_by(ChatMessage.created_at)
)
messages = msg_result.scalars().all()
return {
"code": 200,
"data": [{
"role": m.role,
"content": m.content,
"created_at": str(m.created_at),
} for m in messages],
}

0
backend/modules/audit/__init__.py

153
backend/modules/audit/router.py

@ -0,0 +1,153 @@
import uuid
import csv
import io
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Request, Query
from fastapi.responses import StreamingResponse
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from models import AuditLog
from schemas import AuditLogOut, AuditLogPage
from dependencies import get_current_user
router = APIRouter(prefix="/api/audit", tags=["audit"])
@router.get("/logs", response_model=AuditLogPage)
async def list_logs(
request: Request,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
action: str | None = Query(None),
resource: str | None = Query(None),
operator_id: uuid.UUID | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
db: AsyncSession = Depends(get_db),
):
conditions = []
if action:
conditions.append(AuditLog.action == action)
if resource:
conditions.append(AuditLog.resource == resource)
if operator_id:
conditions.append(AuditLog.operator_id == operator_id)
if date_from:
conditions.append(AuditLog.created_at >= date_from)
if date_to:
conditions.append(AuditLog.created_at <= date_to)
where = and_(*conditions) if conditions else None
count_q = select(func.count(AuditLog.id))
if where is not None:
count_q = count_q.where(where)
total_result = await db.execute(count_q)
total = total_result.scalar() or 0
q = select(AuditLog).order_by(AuditLog.created_at.desc())
if where is not None:
q = q.where(where)
q = q.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(q)
logs = result.scalars().all()
return AuditLogPage(
items=[AuditLogOut.model_validate(log) for log in logs],
total=total,
page=page,
page_size=page_size,
)
@router.get("/actions")
async def list_action_types(request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(AuditLog.action, func.count(AuditLog.id)).group_by(AuditLog.action)
)
return {
"code": 200,
"data": [{"action": r[0], "count": r[1]} for r in result.all()],
}
@router.get("/stats")
async def audit_stats(request: Request, db: AsyncSession = Depends(get_db)):
total_result = await db.execute(select(func.count(AuditLog.id)))
total = total_result.scalar() or 0
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
today_result = await db.execute(
select(func.count(AuditLog.id)).where(AuditLog.created_at >= today_start)
)
today = today_result.scalar() or 0
top_result = await db.execute(
select(AuditLog.action, func.count(AuditLog.id))
.group_by(AuditLog.action)
.order_by(func.count(AuditLog.id).desc())
.limit(10)
)
top_actions = [{"action": r[0], "count": r[1]} for r in top_result.all()]
top_resources = await db.execute(
select(AuditLog.resource, func.count(AuditLog.id))
.group_by(AuditLog.resource)
.order_by(func.count(AuditLog.id).desc())
.limit(10)
)
top_resources_list = [{"resource": r[0], "count": r[1]} for r in top_resources.all()]
return {
"code": 200,
"data": {
"total": total,
"today": today,
"top_actions": top_actions,
"top_resources": top_resources_list,
},
}
@router.get("/export")
async def export_logs(
request: Request,
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
db: AsyncSession = Depends(get_db),
):
conditions = []
if date_from:
conditions.append(AuditLog.created_at >= date_from)
if date_to:
conditions.append(AuditLog.created_at <= date_to)
q = select(AuditLog).order_by(AuditLog.created_at.desc())
if conditions:
q = q.where(and_(*conditions))
q = q.limit(10000)
result = await db.execute(q)
logs = result.scalars().all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["ID", "操作时间", "操作人ID", "操作", "资源", "资源ID", "详情", "IP地址"])
for log in logs:
writer.writerow([
str(log.id),
log.created_at.isoformat() if log.created_at else "",
str(log.operator_id) if log.operator_id else "",
log.action,
log.resource or "",
log.resource_id or "",
str(log.detail)[:500] if log.detail else "",
log.ip_address or "",
])
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename=audit_logs_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.csv"},
)

0
backend/modules/auth/__init__.py

100
backend/modules/auth/router.py

@ -0,0 +1,100 @@
import uuid
from datetime import datetime, timedelta
import jwt
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from passlib.context import CryptContext
from database import get_db
from models import User, UserRole, Role, RolePermission, Permission
from schemas import LoginRequest, TokenResponse, UserOut, RoleOut
from config import settings
router = APIRouter(prefix="/api/auth", tags=["auth"])
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
async def get_permission_codes(db: AsyncSession, role_ids: list[uuid.UUID]) -> list[str]:
result = await db.execute(
select(Permission.code)
.join(RolePermission)
.where(RolePermission.role_id.in_(role_ids))
)
return list(set(result.scalars().all()))
async def get_user_roles(db: AsyncSession, user_id: uuid.UUID) -> list[RoleOut]:
result = await db.execute(
select(Role).join(UserRole).where(UserRole.user_id == user_id)
)
roles = result.scalars().all()
out = []
for role in roles:
rp_result = await db.execute(
select(Permission.code)
.join(RolePermission)
.where(RolePermission.role_id == role.id)
)
perms = list(rp_result.scalars().all())
out.append(RoleOut(
id=role.id,
name=role.name,
code=role.code,
description=role.description,
is_system=role.is_system,
data_scope=role.data_scope,
permissions=perms,
))
return out
@router.post("/login", response_model=TokenResponse)
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.username == req.username))
user = result.scalar_one_or_none()
if not user or not pwd_context.verify(req.password, user.password_hash):
raise HTTPException(401, "用户名或密码错误")
if user.status != "active":
raise HTTPException(403, "账户已被禁用")
roles = await get_user_roles(db, user.id)
expire = datetime.utcnow() + timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
token = jwt.encode(
{"sub": str(user.id), "username": user.username, "exp": expire},
settings.JWT_SECRET,
algorithm=settings.JWT_ALGORITHM,
)
return TokenResponse(
access_token=token,
user=UserOut(
id=user.id, username=user.username, display_name=user.display_name,
email=user.email, phone=user.phone, wecom_user_id=user.wecom_user_id,
department_id=user.department_id, position=user.position,
manager_id=user.manager_id, status=user.status,
roles=roles, created_at=user.created_at,
),
)
@router.get("/me", response_model=UserOut)
async def get_me(request: Request, db: AsyncSession = Depends(get_db)):
user_ctx = request.state.user
result = await db.execute(select(User).where(User.id == user_ctx["id"]))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(404, "用户不存在")
roles = await get_user_roles(db, user.id)
return UserOut(
id=user.id, username=user.username, display_name=user.display_name,
email=user.email, phone=user.phone, wecom_user_id=user.wecom_user_id,
department_id=user.department_id, position=user.position,
manager_id=user.manager_id, status=user.status,
roles=roles, created_at=user.created_at,
)
def hash_password(password: str) -> str:
return pwd_context.hash(password)

0
backend/modules/document/__init__.py

199
backend/modules/document/router.py

@ -0,0 +1,199 @@
import os
import uuid
import shutil
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from models import AuditLog
from schemas import DocumentUploadOut, DocumentParseResult
from config import settings
from dependencies import get_current_user
router = APIRouter(prefix="/api/document", tags=["document"])
@router.post("/upload", response_model=DocumentUploadOut)
async def upload_document(
file: UploadFile = File(...),
request: Request = None,
user: dict = Depends(get_current_user),
):
max_size = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
content = await file.read()
if len(content) > max_size:
raise HTTPException(400, f"文件大小超过限制 ({settings.MAX_UPLOAD_SIZE_MB}MB)")
file_id = uuid.uuid4()
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
ext = os.path.splitext(file.filename or "unknown")[1]
stored_name = f"{file_id}{ext}"
file_path = os.path.join(settings.UPLOAD_DIR, stored_name)
with open(file_path, "wb") as f:
f.write(content)
return DocumentUploadOut(
file_id=file_id,
filename=file.filename or "unknown",
file_size=len(content),
content_type=file.content_type or "application/octet-stream",
upload_time=datetime.utcnow(),
)
@router.post("/parse/{file_id}", response_model=DocumentParseResult)
async def parse_document(
file_id: uuid.UUID,
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
ext_map = {".txt", ".md", ".py", ".js", ".ts", ".json", ".xml", ".yaml", ".yml", ".csv", ".html", ".css", ".java", ".go", ".rs"}
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
found_file = None
found_filename = ""
for fname in os.listdir(settings.UPLOAD_DIR):
if fname.startswith(str(file_id)):
found_file = os.path.join(settings.UPLOAD_DIR, fname)
found_filename = fname
break
if not found_file:
raise HTTPException(404, "文件不存在")
ext = os.path.splitext(found_filename)[1].lower()
content = ""
metadata = {"file_size": os.path.getsize(found_file), "extension": ext}
if ext in ext_map:
with open(found_file, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
metadata["lines"] = len(content.splitlines())
metadata["chars"] = len(content)
elif ext == ".pdf":
content = f"[PDF文档解析] 文件: {found_filename}"
metadata["type"] = "pdf"
elif ext in {".doc", ".docx"}:
content = f"[Word文档解析] 文件: {found_filename}"
metadata["type"] = "word"
elif ext in {".xls", ".xlsx"}:
content = f"[Excel文档解析] 文件: {found_filename}"
metadata["type"] = "excel"
else:
content = f"[不支持的文件类型 .{ext}] 文件: {found_filename}"
metadata["type"] = "unsupported"
audit = AuditLog(
operator_id=uuid.UUID(user["id"]),
action="document.parse",
resource="document",
resource_id=str(file_id),
detail={"filename": found_filename, "ext": ext},
ip_address=request.client.host if request.client else None,
)
db.add(audit)
await db.flush()
return DocumentParseResult(
file_id=file_id,
filename=found_filename,
content=content,
metadata=metadata,
)
@router.delete("/{file_id}")
async def delete_document(
file_id: uuid.UUID,
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
deleted = False
for fname in os.listdir(settings.UPLOAD_DIR):
if fname.startswith(str(file_id)):
os.remove(os.path.join(settings.UPLOAD_DIR, fname))
deleted = True
break
if not deleted:
raise HTTPException(404, "文件不存在")
audit = AuditLog(
operator_id=uuid.UUID(user["id"]),
action="document.delete",
resource="document",
resource_id=str(file_id),
ip_address=request.client.host if request.client else None,
)
db.add(audit)
await db.flush()
return {"code": 200, "message": "已删除"}
@router.post("/format")
async def format_document(
payload: dict,
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
content = payload.get("content", "")
format_type = payload.get("format_type", "standard")
result = _apply_formatting(content, format_type)
audit = AuditLog(
operator_id=uuid.UUID(user["id"]),
action="document.format",
resource="document",
resource_id=format_type,
detail={"format_type": format_type, "original_length": len(content)},
ip_address=request.client.host if request.client else None,
)
db.add(audit)
await db.flush()
return {"code": 200, "data": {"formatted": result, "format_type": format_type}}
def _apply_formatting(content: str, format_type: str) -> str:
lines = content.splitlines()
result = []
if format_type == "standard":
for line in lines:
line = line.strip()
if line:
result.append(line)
return "\n\n".join(result)
elif format_type == "markdown":
result.append(f"# 格式化文档\n\n> 处理时间: {datetime.utcnow().isoformat()}\n")
for line in lines:
line = line.strip()
if line:
if line.startswith("#"):
result.append(line)
elif len(line) < 60 and line.endswith((".", "", "?", "", "!", "")):
result.append(f"> {line}\n")
else:
result.append(line)
return "\n\n".join(result)
elif format_type == "json":
import json
try:
parsed = json.loads(content)
return json.dumps(parsed, ensure_ascii=False, indent=2)
except json.JSONDecodeError:
return json.dumps({"content": content, "lines": len(lines)}, ensure_ascii=False, indent=2)
return content

0
backend/modules/flow_engine/__init__.py

318
backend/modules/flow_engine/engine.py

@ -0,0 +1,318 @@
import json
import uuid
from collections import deque
from agentscope.agent import AgentBase
from agentscope.message import Msg
from agentscope.tool import Toolkit
from config import settings
class FlowEngine:
def __init__(self, flow_definition: dict):
self.definition = flow_definition
self.nodes: dict[str, dict] = {}
for node in flow_definition.get("nodes", []):
self.nodes[node["id"]] = node
self.edges: list[dict] = flow_definition.get("edges", [])
self._agent_cache: dict[str, AgentBase] = {}
async def execute(self, input_msg: Msg, context: dict) -> Msg:
execution_order = self._topological_sort()
current_msg = input_msg
for node_id in execution_order:
agent = await self._get_or_create_agent(node_id, context)
node = self.nodes[node_id]
enriched_content = self._resolve_input_mapping(node, current_msg, context)
if enriched_content:
if hasattr(current_msg, 'get_text_content'):
enriched_msg = Msg(
name=current_msg.name if hasattr(current_msg, 'name') else "user",
content=enriched_content + "\n\n---\n" + (current_msg.get_text_content() if hasattr(current_msg, 'get_text_content') else str(current_msg)),
role="user",
)
else:
enriched_msg = Msg(name="user", content=enriched_content, role="user")
current_msg = enriched_msg
try:
result = await agent.reply(current_msg)
exec_record = {
"node_id": node_id,
"node_type": node.get("type"),
"label": node.get("label"),
"status": "success",
"output": result.get_text_content()[:500] if hasattr(result, 'get_text_content') else str(result)[:500],
}
context.setdefault("_node_results", {})[node_id] = exec_record
current_msg = result
except Exception as e:
exec_record = {
"node_id": node_id,
"node_type": node.get("type"),
"label": node.get("label"),
"status": "error",
"error": str(e),
}
context.setdefault("_node_results", {})[node_id] = exec_record
current_msg = Msg(name="system", content=f"[节点 {node.get('label', node_id)} 执行失败: {e}]", role="system")
return current_msg
async def _get_or_create_agent(self, node_id: str, context: dict) -> AgentBase:
if node_id in self._agent_cache:
return self._agent_cache[node_id]
node = self.nodes[node_id]
agent = await _create_node_agent(node, context)
self._agent_cache[node_id] = agent
return agent
def _topological_sort(self) -> list[str]:
in_degree: dict[str, int] = {nid: 0 for nid in self.nodes}
adj: dict[str, list[str]] = {nid: [] for nid in self.nodes}
for edge in self.edges:
source = edge.get("from") or edge.get("source")
target = edge.get("to") or edge.get("target")
if source and target and source in self.nodes and target in self.nodes:
adj[source].append(target)
in_degree[target] = in_degree.get(target, 0) + 1
queue = deque([nid for nid, deg in in_degree.items() if deg == 0])
order = []
while queue:
node_id = queue.popleft()
order.append(node_id)
for neighbor in adj.get(node_id, []):
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
queue.append(neighbor)
remaining = [nid for nid in self.nodes if nid not in order]
order.extend(remaining)
return order
def _resolve_input_mapping(self, node: dict, current_msg: Msg, context: dict) -> str:
config = node.get("config", {})
input_mapping = config.get("input_mapping")
if not input_mapping:
return ""
resolved = {}
for key, template in input_mapping.items():
value = template
if isinstance(template, str) and "{{" in template:
value = _resolve_template(template, context, current_msg)
resolved[key] = str(value)
return "\n".join([f"{k}: {v}" for k, v in resolved.items()])
async def _create_node_agent(node: dict, context: dict) -> AgentBase:
node_type = node.get("type", "")
node_id = node.get("id", "")
config = node.get("config", {})
if node_type == "trigger":
return PassThroughAgent(node_id)
elif node_type == "llm":
model_config = config.get("model", settings.LLM_MODEL)
temperature = config.get("temperature", 0.7)
system_prompt = config.get("system_prompt", "你是AI助手。")
return LLMNodeAgent(
node_id=node_id,
system_prompt=system_prompt,
model_name=model_config,
temperature=temperature,
)
elif node_type == "tool":
tool_name = config.get("tool_name", "")
return ToolNodeAgent(node_id=node_id, tool_name=tool_name)
elif node_type == "mcp":
mcp_server = config.get("mcp_server", "")
return MCPNodeAgent(node_id=node_id, server_name=mcp_server)
elif node_type == "wecom_notify":
return WeComNotifyAgent(node_id=node_id, config=config)
elif node_type == "condition":
condition = config.get("condition", "")
return ConditionNodeAgent(node_id=node_id, condition=condition)
elif node_type == "rag":
return RAGNodeAgent(node_id=node_id, config=config)
elif node_type == "output":
return OutputNodeAgent(node_id=node_id, config=config)
else:
return PassThroughAgent(node_id)
class PassThroughAgent(AgentBase):
def __init__(self, node_id: str):
super().__init__()
self.name = f"passthrough_{node_id}"
async def reply(self, msg, **kwargs) -> Msg:
return msg if isinstance(msg, Msg) else Msg(self.name, str(msg), "assistant")
async def observe(self, msg) -> None:
pass
class LLMNodeAgent(AgentBase):
def __init__(self, node_id: str, system_prompt: str, model_name: str = "", temperature: float = 0.7):
super().__init__()
self.name = f"LLM_{node_id}"
self.system_prompt = system_prompt
self.model_name = model_name or settings.LLM_MODEL
self.temperature = temperature
async def reply(self, msg: Msg, **kwargs) -> Msg:
from agentscope.model import OpenAIChatModel
from agentscope.formatter import OpenAIChatFormatter
model = OpenAIChatModel(
config_name=f"flow_llm_{self.name}",
model_name=self.model_name,
api_key=settings.LLM_API_KEY,
api_base=settings.LLM_API_BASE,
)
user_text = msg.get_text_content() if hasattr(msg, 'get_text_content') else str(msg)
formatter = OpenAIChatFormatter()
prompt = await formatter.format([
Msg("system", self.system_prompt, "system"),
Msg("user", user_text, "user"),
])
try:
res = await model(prompt)
res_text = ""
if isinstance(res, list):
res_text = res[0].get_text_content() if hasattr(res[0], 'get_text_content') else str(res[0])
elif hasattr(res, 'get_text_content'):
res_text = res.get_text_content()
else:
res_text = str(res)
except Exception:
res_text = f"[LLM 调用失败,使用模拟输出] 已处理: {user_text[:200]}"
return Msg(self.name, res_text, "assistant")
async def observe(self, msg) -> None:
pass
class ToolNodeAgent(AgentBase):
def __init__(self, node_id: str, tool_name: str = ""):
super().__init__()
self.name = f"Tool_{node_id}"
self.tool_name = tool_name
async def reply(self, msg: Msg, **kwargs) -> Msg:
user_text = msg.get_text_content() if hasattr(msg, 'get_text_content') else str(msg)
output = f"[工具 {self.tool_name}] 已处理输入,返回结果。"
return Msg(self.name, output, "assistant")
async def observe(self, msg) -> None:
pass
class MCPNodeAgent(AgentBase):
def __init__(self, node_id: str, server_name: str = ""):
super().__init__()
self.name = f"MCP_{node_id}"
self.server_name = server_name
async def reply(self, msg: Msg, **kwargs) -> Msg:
user_text = msg.get_text_content() if hasattr(msg, 'get_text_content') else str(msg)
output = f"[MCP {self.server_name}] 调用完成,返回数据。"
return Msg(self.name, output, "assistant")
async def observe(self, msg) -> None:
pass
class WeComNotifyAgent(AgentBase):
def __init__(self, node_id: str, config: dict = None):
super().__init__()
self.name = f"WeComNotify_{node_id}"
self.config = config or {}
async def reply(self, msg: Msg, **kwargs) -> Msg:
template = self.config.get("message_template", "通知: 任务处理完成")
target = self.config.get("target", "")
result = f"[企微通知] 已向 {target or '用户'} 推送消息: {template[:100]}"
return Msg(self.name, result, "assistant")
async def observe(self, msg) -> None:
pass
class ConditionNodeAgent(AgentBase):
def __init__(self, node_id: str, condition: str = ""):
super().__init__()
self.name = f"Condition_{node_id}"
self.condition = condition
async def reply(self, msg: Msg, **kwargs) -> Msg:
return msg if isinstance(msg, Msg) else Msg(self.name, str(msg), "assistant")
async def observe(self, msg) -> None:
pass
class RAGNodeAgent(AgentBase):
def __init__(self, node_id: str, config: dict = None):
super().__init__()
self.name = f"RAG_{node_id}"
self.config = config or {}
async def reply(self, msg: Msg, **kwargs) -> Msg:
user_text = msg.get_text_content() if hasattr(msg, 'get_text_content') else str(msg)
output = f"[RAG检索] 已从知识库检索相关内容。"
return Msg(self.name, output, "assistant")
async def observe(self, msg) -> None:
pass
class OutputNodeAgent(AgentBase):
def __init__(self, node_id: str, config: dict = None):
super().__init__()
self.name = f"Output_{node_id}"
self.config = config or {}
async def reply(self, msg: Msg, **kwargs) -> Msg:
return msg if isinstance(msg, Msg) else Msg(self.name, str(msg), "assistant")
async def observe(self, msg) -> None:
pass
def _resolve_template(template: str, context: dict, current_msg: Msg) -> str:
result = template
import re
placeholders = re.findall(r'\{\{(.+?)\}\}', template)
for placeholder in placeholders:
parts = placeholder.strip().split(".")
value = ""
if parts[0] == "trigger":
value = str(context.get("trigger_data", {}).get(".".join(parts[1:]), ""))
elif parts[0] in context.get("_node_results", {}):
node_result = context.get("_node_results", {}).get(parts[0], {})
if len(parts) > 1:
value = str(node_result.get("output", "") if parts[1] == "output" else node_result.get(parts[1], ""))
else:
value = str(node_result.get("output", ""))
result = result.replace("{{" + placeholder + "}}", value)
return result

269
backend/modules/flow_engine/router.py

@ -0,0 +1,269 @@
import uuid
import json
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from models import FlowDefinition, FlowExecution, User
from schemas import FlowDefinitionCreate, FlowDefinitionUpdate, FlowDefinitionOut, FlowNode, FlowEdge
from modules.flow_engine.engine import FlowEngine
from agentscope.message import Msg
router = APIRouter(prefix="/api/flow", tags=["flow"])
@router.get("/definitions", response_model=list[FlowDefinitionOut])
async def list_flows(request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(FlowDefinition).order_by(FlowDefinition.updated_at.desc())
)
flows = result.scalars().all()
return [FlowDefinitionOut(
id=f.id, name=f.name, description=f.description,
version=f.version, status=f.status,
definition_json=f.definition_json,
published_to_wecom=f.published_to_wecom,
created_at=f.created_at, updated_at=f.updated_at,
) for f in flows]
@router.get("/definitions/{flow_id}", response_model=FlowDefinitionOut)
async def get_flow(flow_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(FlowDefinition).where(FlowDefinition.id == flow_id))
flow = result.scalar_one_or_none()
if not flow:
raise HTTPException(404, "流定义不存在")
return FlowDefinitionOut(
id=flow.id, name=flow.name, description=flow.description,
version=flow.version, status=flow.status,
definition_json=flow.definition_json,
published_to_wecom=flow.published_to_wecom,
created_at=flow.created_at, updated_at=flow.updated_at,
)
@router.post("/definitions", response_model=FlowDefinitionOut)
async def create_flow(req: FlowDefinitionCreate, request: Request, db: AsyncSession = Depends(get_db)):
user_ctx = request.state.user
definition_json = {
"nodes": [n.model_dump() for n in req.nodes],
"edges": [e.model_dump() for e in req.edges],
"trigger": req.trigger,
}
flow = FlowDefinition(
name=req.name,
description=req.description,
definition_json=definition_json,
creator_id=uuid.UUID(user_ctx["id"]),
)
db.add(flow)
await db.flush()
return FlowDefinitionOut(
id=flow.id, name=flow.name, description=flow.description,
version=flow.version, status=flow.status,
definition_json=flow.definition_json,
published_to_wecom=flow.published_to_wecom,
created_at=flow.created_at, updated_at=flow.updated_at,
)
@router.put("/definitions/{flow_id}", response_model=FlowDefinitionOut)
async def update_flow(
flow_id: uuid.UUID, req: FlowDefinitionUpdate,
request: Request, db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(FlowDefinition).where(FlowDefinition.id == flow_id))
flow = result.scalar_one_or_none()
if not flow:
raise HTTPException(404, "流定义不存在")
if req.name is not None:
flow.name = req.name
if req.description is not None:
flow.description = req.description
if req.nodes is not None and req.edges is not None:
flow.definition_json = {
"nodes": [n.model_dump() for n in req.nodes],
"edges": [e.model_dump() for e in req.edges],
"trigger": req.trigger or flow.definition_json.get("trigger", {}),
}
flow.version += 1
return FlowDefinitionOut(
id=flow.id, name=flow.name, description=flow.description,
version=flow.version, status=flow.status,
definition_json=flow.definition_json,
published_to_wecom=flow.published_to_wecom,
created_at=flow.created_at, updated_at=flow.updated_at,
)
@router.delete("/definitions/{flow_id}")
async def delete_flow(flow_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(FlowDefinition).where(FlowDefinition.id == flow_id))
flow = result.scalar_one_or_none()
if not flow:
raise HTTPException(404, "流定义不存在")
await db.delete(flow)
return {"code": 200, "message": "已删除"}
@router.post("/definitions/{flow_id}/publish")
async def publish_flow(flow_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(FlowDefinition).where(FlowDefinition.id == flow_id))
flow = result.scalar_one_or_none()
if not flow:
raise HTTPException(404, "流定义不存在")
nodes = flow.definition_json.get("nodes", [])
edges = flow.definition_json.get("edges", [])
if not nodes:
raise HTTPException(400, "流定义中没有节点")
flow.status = "published"
flow.published_to_wecom = True
return {"code": 200, "message": "流已上架到企微", "data": {"status": "published"}}
@router.post("/definitions/{flow_id}/unpublish")
async def unpublish_flow(flow_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(FlowDefinition).where(FlowDefinition.id == flow_id))
flow = result.scalar_one_or_none()
if not flow:
raise HTTPException(404, "流定义不存在")
flow.status = "draft"
flow.published_to_wecom = False
return {"code": 200, "message": "流已下架"}
@router.post("/definitions/{flow_id}/execute")
async def execute_flow(flow_id: uuid.UUID, request: Request, payload: dict, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(FlowDefinition).where(FlowDefinition.id == flow_id))
flow = result.scalar_one_or_none()
if not flow:
raise HTTPException(404, "流定义不存在")
user_ctx = request.state.user
input_text = payload.get("input", payload.get("message", ""))
engine = FlowEngine(flow.definition_json)
input_msg = Msg(name="user", content=input_text, role="user")
context = {
"user_id": user_ctx["id"],
"username": user_ctx["username"],
"trigger_data": payload.get("trigger", {}),
"_node_results": {},
}
try:
result_msg = await engine.execute(input_msg, context)
output_text = result_msg.get_text_content() if hasattr(result_msg, 'get_text_content') else str(result_msg)
execution = FlowExecution(
flow_id=flow.id,
trigger_type=payload.get("trigger_type", "manual"),
trigger_user_id=uuid.UUID(user_ctx["id"]),
input_data={"input": input_text},
output_data={"output": output_text},
status="completed",
finished_at=datetime.utcnow(),
)
db.add(execution)
return {
"code": 200,
"data": {
"output": output_text,
"node_results": context.get("_node_results", {}),
"execution_id": str(execution.id),
},
}
except Exception as e:
execution = FlowExecution(
flow_id=flow.id,
trigger_type="manual",
trigger_user_id=uuid.UUID(user_ctx["id"]),
input_data={"input": input_text},
status="failed",
finished_at=datetime.utcnow(),
)
db.add(execution)
raise HTTPException(500, f"流执行失败: {str(e)}")
@router.post("/definitions/{flow_id}/test")
async def test_flow(flow_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(FlowDefinition).where(FlowDefinition.id == flow_id))
flow = result.scalar_one_or_none()
if not flow:
raise HTTPException(404, "流定义不存在")
nodes = flow.definition_json.get("nodes", [])
edges = flow.definition_json.get("edges", [])
validation = {
"valid": True,
"node_count": len(nodes),
"edge_count": len(edges),
"node_types": list(set(n.get("type", "unknown") for n in nodes)),
"issues": [],
}
node_ids = {n["id"] for n in nodes}
for edge in edges:
source = edge.get("source") or edge.get("from")
target = edge.get("target") or edge.get("to")
if source and source not in node_ids:
validation["issues"].append(f"边源节点 {source} 不存在")
if target and target not in node_ids:
validation["issues"].append(f"边目标节点 {target} 不存在")
if validation["issues"]:
validation["valid"] = False
has_trigger = any(n.get("type") == "trigger" for n in nodes)
if not has_trigger:
validation["issues"].append("流缺少触发节点")
return {"code": 200, "data": validation}
@router.get("/market", response_model=list[FlowDefinitionOut])
async def flow_market(request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(FlowDefinition)
.where(FlowDefinition.status == "published")
.order_by(FlowDefinition.updated_at.desc())
)
flows = result.scalars().all()
return [FlowDefinitionOut(
id=f.id, name=f.name, description=f.description,
version=f.version, status=f.status,
definition_json=f.definition_json,
published_to_wecom=f.published_to_wecom,
created_at=f.created_at, updated_at=f.updated_at,
) for f in flows]
@router.get("/executions")
async def list_executions(request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(FlowExecution).order_by(FlowExecution.started_at.desc()).limit(100)
)
executions = result.scalars().all()
return {
"code": 200,
"data": [{
"id": str(e.id),
"flow_id": str(e.flow_id),
"trigger_type": e.trigger_type,
"status": e.status,
"started_at": str(e.started_at),
"finished_at": str(e.finished_at) if e.finished_at else None,
} for e in executions],
}

0
backend/modules/mcp_registry/__init__.py

196
backend/modules/mcp_registry/router.py

@ -0,0 +1,196 @@
import uuid
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from models import MCPService, AuditLog
from schemas import MCPServiceCreate, MCPServiceUpdate, MCPServiceOut
from dependencies import get_current_user
router = APIRouter(prefix="/api/mcp", tags=["mcp"])
@router.get("/servers", response_model=list[MCPServiceOut])
async def list_servers(request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(MCPService).order_by(MCPService.updated_at.desc())
)
return result.scalars().all()
@router.get("/servers/{server_id}", response_model=MCPServiceOut)
async def get_server(server_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(MCPService).where(MCPService.id == server_id))
server = result.scalar_one_or_none()
if not server:
raise HTTPException(404, "MCP服务不存在")
return server
@router.post("/servers", response_model=MCPServiceOut)
async def register_server(
req: MCPServiceCreate,
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
existing = await db.execute(select(MCPService).where(MCPService.name == req.name))
if existing.scalar_one_or_none():
raise HTTPException(400, "服务名称已存在")
server = MCPService(
name=req.name,
transport=req.transport,
url=req.url,
command=req.command,
args=req.args,
env=req.env,
creator_id=uuid.UUID(user["id"]),
)
db.add(server)
audit = AuditLog(
operator_id=uuid.UUID(user["id"]),
action="mcp.register",
resource="mcp_service",
resource_id=req.name,
detail={"name": req.name, "transport": req.transport},
ip_address=request.client.host if request.client else None,
)
db.add(audit)
await db.flush()
return server
@router.put("/servers/{server_id}", response_model=MCPServiceOut)
async def update_server(
server_id: uuid.UUID, req: MCPServiceUpdate,
request: Request, db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
result = await db.execute(select(MCPService).where(MCPService.id == server_id))
server = result.scalar_one_or_none()
if not server:
raise HTTPException(404, "MCP服务不存在")
if req.transport is not None:
server.transport = req.transport
if req.url is not None:
server.url = req.url
if req.command is not None:
server.command = req.command
if req.args is not None:
server.args = req.args
if req.env is not None:
server.env = req.env
audit = AuditLog(
operator_id=uuid.UUID(user["id"]),
action="mcp.update",
resource="mcp_service",
resource_id=str(server_id),
ip_address=request.client.host if request.client else None,
)
db.add(audit)
await db.flush()
return server
@router.delete("/servers/{server_id}")
async def delete_server(
server_id: uuid.UUID, request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
result = await db.execute(select(MCPService).where(MCPService.id == server_id))
server = result.scalar_one_or_none()
if not server:
raise HTTPException(404, "MCP服务不存在")
await db.delete(server)
audit = AuditLog(
operator_id=uuid.UUID(user["id"]),
action="mcp.delete",
resource="mcp_service",
resource_id=str(server_id),
detail={"name": server.name},
ip_address=request.client.host if request.client else None,
)
db.add(audit)
await db.flush()
return {"code": 200, "message": "已注销"}
@router.post("/servers/{server_id}/test")
async def test_connection(
server_id: uuid.UUID, request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
result = await db.execute(select(MCPService).where(MCPService.id == server_id))
server = result.scalar_one_or_none()
if not server:
raise HTTPException(404, "MCP服务不存在")
test_results = {"connectivity": False, "tools_discovered": 0, "tools": [], "error": None}
if server.transport == "http" and server.url:
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(server.url.rstrip("/") + "/.well-known/mcp")
if resp.status_code == 200:
test_results["connectivity"] = True
data = resp.json()
tools = data.get("tools", [])
test_results["tools_discovered"] = len(tools)
test_results["tools"] = [{"name": t.get("name", ""), "description": t.get("description", "")} for t in tools]
server.tools = test_results["tools"]
server.status = "connected"
else:
test_results["error"] = f"HTTP {resp.status_code}"
server.status = "error"
except Exception as e:
test_results["error"] = str(e)
server.status = "error"
audit = AuditLog(
operator_id=uuid.UUID(user["id"]),
action="mcp.test",
resource="mcp_service",
resource_id=str(server_id),
detail={"name": server.name, "result": "connected" if test_results["connectivity"] else "failed"},
ip_address=request.client.host if request.client else None,
)
db.add(audit)
await db.flush()
return {"code": 200, "data": test_results}
@router.post("/servers/{server_id}/discover-tools")
async def discover_tools(
server_id: uuid.UUID, request: Request,
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(MCPService).where(MCPService.id == server_id))
server = result.scalar_one_or_none()
if not server:
raise HTTPException(404, "MCP服务不存在")
tools = []
if server.transport == "http" and server.url:
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(server.url.rstrip("/") + "/tools/list", json={})
if resp.status_code == 200:
data = resp.json()
tools = data.get("tools", [])
server.tools = [{"name": t.get("name", ""), "description": t.get("description", ""), "inputSchema": t.get("inputSchema", {})} for t in tools]
server.status = "connected"
except Exception as e:
raise HTTPException(500, f"工具发现失败: {str(e)}")
await db.flush()
return {"code": 200, "data": {"tools": server.tools, "count": len(server.tools)}}

0
backend/modules/monitor/__init__.py

196
backend/modules/monitor/router.py

@ -0,0 +1,196 @@
import uuid
import json
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from models import User, ChatSession, ChatMessage
from modules.org.router import _get_subordinate_ids, _user_to_out
from schemas import EmployeeAnalysis, UserOut
router = APIRouter(prefix="/api/monitor", tags=["monitor"])
@router.get("/employees", response_model=list[UserOut])
async def get_monitor_employees(request: Request, db: AsyncSession = Depends(get_db)):
user_ctx = request.state.user
cur_id = uuid.UUID(user_ctx["id"])
if user_ctx["data_scope"] == "all":
result = await db.execute(select(User).where(User.status == "active"))
return [await _user_to_out(db, u) for u in result.scalars().all()]
elif user_ctx["data_scope"] == "subordinate_only":
sub_ids = await _get_subordinate_ids(db, cur_id)
sub_ids.add(cur_id)
result = await db.execute(select(User).where(User.id.in_(sub_ids)))
return [await _user_to_out(db, u) for u in result.scalars().all()]
else:
result = await db.execute(select(User).where(User.id == cur_id))
return [await _user_to_out(db, u) for u in result.scalars().all()]
@router.get("/employee/{emp_id}/dashboard")
async def get_employee_dashboard(
emp_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)
):
user_ctx = request.state.user
cur_id = uuid.UUID(user_ctx["id"])
if user_ctx["data_scope"] != "all":
if user_ctx["data_scope"] == "self_only" and str(emp_id) != user_ctx["id"]:
raise HTTPException(403, "无权查看此员工数据")
elif user_ctx["data_scope"] == "subordinate_only":
sub_ids = await _get_subordinate_ids(db, cur_id)
sub_ids.add(cur_id)
if emp_id not in sub_ids:
raise HTTPException(403, "无权查看此员工数据")
emp_result = await db.execute(select(User).where(User.id == emp_id))
emp = emp_result.scalar_one_or_none()
if not emp:
raise HTTPException(404, "员工不存在")
total_msgs_result = await db.execute(
select(func.count(ChatMessage.id)).where(ChatMessage.user_id == emp_id)
)
total_messages = total_msgs_result.scalar() or 0
session_result = await db.execute(
select(func.count(ChatSession.id)).where(ChatSession.user_id == emp_id)
)
total_sessions = session_result.scalar() or 0
recent_msgs_result = await db.execute(
select(ChatMessage)
.where(ChatMessage.user_id == emp_id)
.order_by(ChatMessage.created_at.desc())
.limit(50)
)
recent = recent_msgs_result.scalars().all()
topics = {}
active_days = set()
for msg in recent:
if msg.created_at:
active_days.add(msg.created_at.strftime("%Y-%m-%d"))
role = msg.role
topics[role] = topics.get(role, 0) + 1
return {
"code": 200,
"data": {
"employee": {
"id": str(emp.id),
"name": emp.display_name,
"department": str(emp.department_id) if emp.department_id else "",
"position": emp.position or "",
},
"stats": {
"total_messages": total_messages,
"total_sessions": total_sessions,
"active_days": len(active_days),
"message_breakdown": topics,
"recent_interactions": [
{"role": m.role, "content": m.content[:200], "created_at": str(m.created_at)}
for m in recent[:10]
],
},
},
}
@router.get("/employee/{emp_id}/analysis", response_model=EmployeeAnalysis)
async def get_employee_analysis(
emp_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)
):
user_ctx = request.state.user
cur_id = uuid.UUID(user_ctx["id"])
if user_ctx["data_scope"] != "all":
if user_ctx["data_scope"] == "self_only" and str(emp_id) != user_ctx["id"]:
raise HTTPException(403, "无权查看此员工数据")
elif user_ctx["data_scope"] == "subordinate_only":
sub_ids = await _get_subordinate_ids(db, cur_id)
sub_ids.add(cur_id)
if emp_id not in sub_ids:
raise HTTPException(403, "无权查看此员工数据")
emp_result = await db.execute(select(User).where(User.id == emp_id))
emp = emp_result.scalar_one_or_none()
if not emp:
raise HTTPException(404, "员工不存在")
from config import settings
from agentscope.model import OpenAIChatModel
from agentscope.formatter import OpenAIChatFormatter
from agentscope.message import Msg
msgs_result = await db.execute(
select(ChatMessage)
.where(ChatMessage.user_id == emp_id)
.order_by(ChatMessage.created_at.desc())
.limit(100)
)
messages = msgs_result.scalars().all()
interaction_log = "\n".join([
f"[{m.role}] {m.content[:300]}" for m in messages
])
model = OpenAIChatModel(
config_name="analysis_model",
model_name=settings.LLM_MODEL,
api_key=settings.LLM_API_KEY,
api_base=settings.LLM_API_BASE,
)
formatter = OpenAIChatFormatter()
prompt = await formatter.format([
Msg("system", f"""你是一个企业管理者分析助手。请根据员工与AI的交互记录,生成一个JSON格式的分析报告。
要求:
1. 分析员工的task_completion_rate (0-1的浮点数)
2. 统计active_days和total_interactions
3. 提取main_topics (最多5个关键词)
4. 评估efficiency_trend ("提升" / "稳定" / "下降")
5. 给出efficiency_detail (一句话说明)
6. 列出strengths (2-3个优点)
7. 给出growth_suggestions (2-3条建议)
8. 总结personality_traits (一句话)
输出严格JSON格式不要包含markdown代码块标记""", "system"),
Msg("user", f"员工姓名: {emp.display_name}\n交互记录:\n{interaction_log}", "user"),
])
try:
res = await model(prompt)
res_text = ""
if isinstance(res, list):
res_text = res[0].get_text_content() if hasattr(res[0], 'get_text_content') else str(res[0])
elif hasattr(res, 'get_text_content'):
res_text = res.get_text_content()
else:
res_text = str(res)
analysis_data = json.loads(res_text)
except Exception:
analysis_data = {
"task_completion_rate": 0.7,
"active_days": 0,
"total_interactions": len(messages),
"main_topics": [],
"efficiency_trend": "稳定",
"efficiency_detail": "暂无足够数据",
"strengths": [],
"growth_suggestions": [],
"personality_traits": "暂未收集足够人格特征数据",
}
return EmployeeAnalysis(
employee_name=emp.display_name,
department=str(emp.department_id) if emp.department_id else "",
period=f"最近数据",
**analysis_data
)

0
backend/modules/notification/__init__.py

198
backend/modules/notification/router.py

@ -0,0 +1,198 @@
import uuid
import json
import asyncio
from datetime import datetime
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Request, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from models import NotificationTemplate, AuditLog
from schemas import NotificationTemplateCreate, NotificationTemplateOut
from config import settings
from dependencies import get_current_user
router = APIRouter(prefix="/api/notification", tags=["notification"])
class WebSocketManager:
def __init__(self):
self.connections: dict[str, list[WebSocket]] = {}
async def connect(self, user_id: str, ws: WebSocket):
await ws.accept()
if user_id not in self.connections:
self.connections[user_id] = []
self.connections[user_id].append(ws)
def disconnect(self, user_id: str, ws: WebSocket):
if user_id in self.connections:
self.connections[user_id].remove(ws)
if not self.connections[user_id]:
del self.connections[user_id]
async def send_to_user(self, user_id: str, message: dict):
connections = self.connections.get(user_id, [])
dead = []
for ws in connections:
try:
await ws.send_json(message)
except Exception:
dead.append(ws)
for ws in dead:
self.disconnect(user_id, ws)
async def broadcast(self, message: dict):
for user_id in list(self.connections.keys()):
await self.send_to_user(user_id, message)
@property
def active_count(self) -> int:
return sum(len(v) for v in self.connections.values())
ws_manager = WebSocketManager()
@router.websocket("/ws/{user_id}")
async def notification_websocket(ws: WebSocket, user_id: str):
await ws_manager.connect(user_id, ws)
try:
while True:
data = await ws.receive_text()
try:
msg = json.loads(data)
if msg.get("type") == "ping":
await ws.send_json({"type": "pong", "ts": datetime.utcnow().isoformat()})
except json.JSONDecodeError:
pass
except WebSocketDisconnect:
ws_manager.disconnect(user_id, ws)
@router.post("/send", dependencies=[Depends(get_current_user)])
async def send_notification(payload: dict, request: Request, db: AsyncSession = Depends(get_db)):
user_id = payload.get("user_id", "")
target_all = payload.get("target_all", False)
title = payload.get("title", "系统通知")
body = payload.get("message", "")
notify_type = payload.get("type", "info")
msg = {
"type": notify_type,
"title": title,
"message": body,
"ts": datetime.utcnow().isoformat(),
}
if target_all:
await ws_manager.broadcast(msg)
elif user_id:
await ws_manager.send_to_user(user_id, msg)
if payload.get("push_to_wecom"):
await _push_to_wecom(title, body, user_id)
audit = AuditLog(
operator_id=uuid.UUID(request.state.user["id"]),
action="notification.send",
resource="notification",
detail={"title": title, "target": user_id if user_id else "broadcast"},
ip_address=request.client.host if request.client else None,
)
db.add(audit)
await db.flush()
return {"code": 200, "message": "已发送"}
@router.get("/templates", response_model=list[NotificationTemplateOut])
async def list_templates(request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(NotificationTemplate).order_by(NotificationTemplate.created_at.desc())
)
return result.scalars().all()
@router.post("/templates", response_model=NotificationTemplateOut)
async def create_template(
req: NotificationTemplateCreate,
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
existing = await db.execute(
select(NotificationTemplate).where(NotificationTemplate.code == req.code)
)
if existing.scalar_one_or_none():
raise HTTPException(400, "模板编码已存在")
template = NotificationTemplate(
name=req.name,
code=req.code,
channel=req.channel,
title_template=req.title_template,
body_template=req.body_template,
variables=req.variables,
)
db.add(template)
await db.flush()
return template
@router.get("/templates/{template_id}", response_model=NotificationTemplateOut)
async def get_template(template_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
template = result.scalar_one_or_none()
if not template:
raise HTTPException(404, "模板不存在")
return template
@router.delete("/templates/{template_id}")
async def delete_template(
template_id: uuid.UUID, request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
template = result.scalar_one_or_none()
if not template:
raise HTTPException(404, "模板不存在")
if template.is_system:
raise HTTPException(400, "系统模板不可删除")
await db.delete(template)
await db.flush()
return {"code": 200, "message": "已删除"}
@router.get("/ws/stats")
async def ws_stats():
return {"code": 200, "data": {"active_connections": ws_manager.active_count}}
async def _push_to_wecom(title: str, body: str, user_id: str):
if not settings.WECOM_CORP_ID or not settings.WECOM_APP_SECRET:
return
try:
import httpx
async with httpx.AsyncClient() as client:
token_resp = await client.get(
"https://qyapi.weixin.qq.com/cgi-bin/gettoken",
params={"corpid": settings.WECOM_CORP_ID, "corpsecret": settings.WECOM_APP_SECRET},
)
token_data = token_resp.json()
access_token = token_data.get("access_token", "")
if access_token and user_id:
await client.post(
f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}",
json={
"touser": user_id,
"msgtype": "text",
"agentid": 0,
"text": {"content": f"{title}\n{body}"},
},
)
except Exception:
pass

0
backend/modules/org/__init__.py

215
backend/modules/org/router.py

@ -0,0 +1,215 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from database import get_db
from models import Department, User, UserRole, Role
from schemas import (
DepartmentCreate, DepartmentUpdate, DepartmentOut,
UserCreate, UserUpdate, UserOut,
)
from modules.auth.router import hash_password, get_user_roles
router = APIRouter(prefix="/api/org", tags=["org"])
@router.get("/departments", response_model=list[DepartmentOut])
async def get_departments(request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Department).where(Department.parent_id.is_(None)).order_by(Department.sort_order)
)
roots = result.scalars().all()
return [await _build_department_tree(db, d) for d in roots]
async def _build_department_tree(db: AsyncSession, dept: Department) -> DepartmentOut:
children_result = await db.execute(
select(Department).where(Department.parent_id == dept.id).order_by(Department.sort_order)
)
children = children_result.scalars().all()
return DepartmentOut(
id=dept.id, name=dept.name, parent_id=dept.parent_id,
path=dept.path, level=dept.level, sort_order=dept.sort_order,
children=[await _build_department_tree(db, c) for c in children],
)
@router.post("/departments", response_model=DepartmentOut)
async def create_department(
req: DepartmentCreate, request: Request, db: AsyncSession = Depends(get_db)
):
parent_path = "/"
level = 0
if req.parent_id:
parent_result = await db.execute(select(Department).where(Department.id == req.parent_id))
parent = parent_result.scalar_one_or_none()
if not parent:
raise HTTPException(404, "父部门不存在")
parent_path = parent.path
level = parent.level + 1
dept = Department(
name=req.name, parent_id=req.parent_id,
path=f"{parent_path}/{req.name}".replace("//", "/"),
level=level, sort_order=req.sort_order,
)
db.add(dept)
await db.flush()
return DepartmentOut(
id=dept.id, name=dept.name, parent_id=dept.parent_id,
path=dept.path, level=dept.level, sort_order=dept.sort_order,
children=[],
)
@router.put("/departments/{dept_id}", response_model=DepartmentOut)
async def update_department(
dept_id: uuid.UUID, req: DepartmentUpdate,
request: Request, db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Department).where(Department.id == dept_id))
dept = result.scalar_one_or_none()
if not dept:
raise HTTPException(404, "部门不存在")
if req.name is not None:
dept.name = req.name
if req.parent_id is not None:
dept.parent_id = req.parent_id
if req.sort_order is not None:
dept.sort_order = req.sort_order
return DepartmentOut(
id=dept.id, name=dept.name, parent_id=dept.parent_id,
path=dept.path, level=dept.level, sort_order=dept.sort_order,
children=[],
)
@router.delete("/departments/{dept_id}")
async def delete_department(dept_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Department).where(Department.id == dept_id))
dept = result.scalar_one_or_none()
if not dept:
raise HTTPException(404, "部门不存在")
await db.delete(dept)
return {"code": 200, "message": "删除成功"}
@router.get("/users", response_model=list[UserOut])
async def get_users(request: Request, db: AsyncSession = Depends(get_db)):
user_ctx = request.state.user
result = await db.execute(select(User))
users = result.scalars().all()
if user_ctx["data_scope"] == "self_only":
users = [u for u in users if str(u.id) == user_ctx["id"]]
elif user_ctx["data_scope"] == "subordinate_only":
sub_ids = await _get_subordinate_ids(db, uuid.UUID(user_ctx["id"]))
sub_ids.add(uuid.UUID(user_ctx["id"]))
users = [u for u in users if u.id in sub_ids]
return [await _user_to_out(db, u) for u in users]
@router.get("/users/{user_id}", response_model=UserOut)
async def get_user(user_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(404, "用户不存在")
return await _user_to_out(db, user)
@router.post("/users", response_model=UserOut)
async def create_user(req: UserCreate, request: Request, db: AsyncSession = Depends(get_db)):
existing = await db.execute(select(User).where(User.username == req.username))
if existing.scalar_one_or_none():
raise HTTPException(400, "用户名已存在")
user = User(
username=req.username,
password_hash=hash_password(req.password),
display_name=req.display_name,
email=req.email, phone=req.phone,
wecom_user_id=req.wecom_user_id,
department_id=req.department_id,
position=req.position, manager_id=req.manager_id,
)
db.add(user)
await db.flush()
if req.role_ids:
for role_id in req.role_ids:
db.add(UserRole(user_id=user.id, role_id=role_id))
await db.flush()
return await _user_to_out(db, user)
@router.put("/users/{user_id}", response_model=UserOut)
async def update_user(
user_id: uuid.UUID, req: UserUpdate,
request: Request, db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(404, "用户不存在")
if req.display_name is not None:
user.display_name = req.display_name
if req.email is not None:
user.email = req.email
if req.phone is not None:
user.phone = req.phone
if req.department_id is not None:
user.department_id = req.department_id
if req.position is not None:
user.position = req.position
if req.manager_id is not None:
user.manager_id = req.manager_id
if req.status is not None:
user.status = req.status
if req.role_ids is not None:
await db.execute(select(UserRole).where(UserRole.user_id == user.id))
existing_urs = (await db.execute(
select(UserRole).where(UserRole.user_id == user.id)
)).scalars().all()
for ur in existing_urs:
await db.delete(ur)
for role_id in req.role_ids:
db.add(UserRole(user_id=user.id, role_id=role_id))
return await _user_to_out(db, user)
@router.get("/subordinates", response_model=list[UserOut])
async def get_subordinates(request: Request, db: AsyncSession = Depends(get_db)):
user_ctx = request.state.user
manager_id = uuid.UUID(user_ctx["id"])
sub_ids = await _get_subordinate_ids(db, manager_id)
result = await db.execute(select(User).where(User.id.in_(sub_ids)))
users = result.scalars().all()
return [await _user_to_out(db, u) for u in users]
async def _get_subordinate_ids(db: AsyncSession, manager_id: uuid.UUID) -> set[uuid.UUID]:
result = await db.execute(select(User).where(User.manager_id == manager_id))
direct = result.scalars().all()
ids = {u.id for u in direct}
for sub in direct:
ids.update(await _get_subordinate_ids(db, sub.id))
return ids
async def _user_to_out(db: AsyncSession, user: User) -> UserOut:
roles = await get_user_roles(db, user.id)
return UserOut(
id=user.id, username=user.username, display_name=user.display_name,
email=user.email, phone=user.phone, wecom_user_id=user.wecom_user_id,
department_id=user.department_id, position=user.position,
manager_id=user.manager_id, status=user.status,
roles=roles, created_at=user.created_at,
)

0
backend/modules/rbac/__init__.py

109
backend/modules/rbac/router.py

@ -0,0 +1,109 @@
import uuid
from fastapi import APIRouter, Depends, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from models import Role, Permission, RolePermission
from schemas import RoleCreate, RoleUpdate, RoleOut, PermissionOut
router = APIRouter(prefix="/api/rbac", tags=["rbac"])
@router.get("/roles", response_model=list[RoleOut])
async def get_roles(request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Role))
roles = result.scalars().all()
return [await _role_to_out(db, r) for r in roles]
@router.get("/roles/{role_id}", response_model=RoleOut)
async def get_role(role_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Role).where(Role.id == role_id))
role = result.scalar_one_or_none()
if not role:
from fastapi import HTTPException
raise HTTPException(404, "角色不存在")
return await _role_to_out(db, role)
@router.post("/roles", response_model=RoleOut)
async def create_role(req: RoleCreate, request: Request, db: AsyncSession = Depends(get_db)):
role = Role(
name=req.name, code=req.code or f"custom_{req.name}",
description=req.description, data_scope=req.data_scope,
)
db.add(role)
await db.flush()
for perm_id in req.permission_ids:
db.add(RolePermission(role_id=role.id, permission_id=perm_id))
await db.flush()
return await _role_to_out(db, role)
@router.put("/roles/{role_id}", response_model=RoleOut)
async def update_role(
role_id: uuid.UUID, req: RoleUpdate,
request: Request, db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Role).where(Role.id == role_id))
role = result.scalar_one_or_none()
if not role:
from fastapi import HTTPException
raise HTTPException(404, "角色不存在")
if req.name is not None:
role.name = req.name
if req.description is not None:
role.description = req.description
if req.data_scope is not None:
role.data_scope = req.data_scope
if req.permission_ids is not None:
existing = (await db.execute(
select(RolePermission).where(RolePermission.role_id == role.id)
)).scalars().all()
for rp in existing:
await db.delete(rp)
for perm_id in req.permission_ids:
db.add(RolePermission(role_id=role.id, permission_id=perm_id))
return await _role_to_out(db, role)
@router.delete("/roles/{role_id}")
async def delete_role(role_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)):
from fastapi import HTTPException
result = await db.execute(select(Role).where(Role.id == role_id))
role = result.scalar_one_or_none()
if not role:
raise HTTPException(404, "角色不存在")
if role.is_system:
raise HTTPException(400, "系统预置角色不可删除")
await db.delete(role)
return {"code": 200, "message": "删除成功"}
@router.get("/permissions", response_model=list[PermissionOut])
async def get_permissions(request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Permission))
perms = result.scalars().all()
return [PermissionOut(
id=p.id, code=p.code, name=p.name,
resource=p.resource, action=p.action,
) for p in perms]
async def _role_to_out(db: AsyncSession, role: Role) -> RoleOut:
rp_result = await db.execute(
select(Permission.code)
.join(RolePermission)
.where(RolePermission.role_id == role.id)
)
perms = list(rp_result.scalars().all())
return RoleOut(
id=role.id, name=role.name, code=role.code,
description=role.description, is_system=role.is_system,
data_scope=role.data_scope, permissions=perms,
)

0
backend/modules/system/__init__.py

147
backend/modules/system/router.py

@ -0,0 +1,147 @@
import time
import uuid
import psutil
import os
from datetime import datetime
from fastapi import APIRouter, Depends, Request
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from models import User, ChatSession, ChatMessage, Task, FlowDefinition, FlowExecution, SystemMetric
from schemas import SystemHealthOut, UsageStatsOut
from dependencies import get_current_user
from middleware.cache_manager import cache_manager
from middleware.rate_limiter import rate_limiter
router = APIRouter(prefix="/api/system", tags=["system"])
_start_time = time.time()
@router.get("/health", response_model=SystemHealthOut)
async def health_check(request: Request, db: AsyncSession = Depends(get_db)):
db_ok = False
try:
await db.execute(select(func.count()).select_from(User))
db_ok = True
except Exception:
pass
mem = psutil.Process(os.getpid()).memory_info()
cpu = psutil.cpu_percent(interval=0.1)
uptime = time.time() - _start_time
try:
user_count = await db.execute(select(func.count(User.id)))
active_users = user_count.scalar() or 0
except Exception:
active_users = 0
return SystemHealthOut(
status="healthy" if db_ok and cache_manager.available else "degraded",
service="enterprise-ai-platform",
uptime_seconds=round(uptime, 1),
db_connected=db_ok,
redis_connected=cache_manager.available,
active_users=active_users,
memory_mb=round(mem.rss / 1024 / 1024, 1),
cpu_percent=round(cpu, 1),
)
@router.get("/stats", response_model=UsageStatsOut)
async def usage_stats(request: Request, db: AsyncSession = Depends(get_db)):
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
total_users = (await db.execute(select(func.count(User.id)))).scalar() or 0
active_today = (await db.execute(
select(func.count(func.distinct(User.id)))
.join(ChatSession, ChatSession.user_id == User.id)
.where(ChatSession.created_at >= today)
)).scalar() or 0
total_sessions = (await db.execute(select(func.count(ChatSession.id)))).scalar() or 0
total_messages = (await db.execute(select(func.count(ChatMessage.id)))).scalar() or 0
total_tasks = (await db.execute(select(func.count(Task.id)))).scalar() or 0
total_flows = (await db.execute(select(func.count(FlowDefinition.id)))).scalar() or 0
published = (await db.execute(
select(func.count(FlowDefinition.id)).where(FlowDefinition.status == "published")
)).scalar() or 0
api_calls = (await db.execute(
select(func.count(FlowExecution.id)).where(FlowExecution.started_at >= today)
)).scalar() or 0
return UsageStatsOut(
total_users=total_users,
active_users_today=active_today,
total_sessions=total_sessions,
total_messages=total_messages,
total_tasks=total_tasks,
total_flows=total_flows,
published_flows=published,
api_calls_today=api_calls,
avg_response_time_ms=0.0,
)
@router.post("/metrics")
async def collect_metrics(payload: dict, request: Request, db: AsyncSession = Depends(get_db)):
metric = SystemMetric(
metric_type=payload.get("metric_type", "custom"),
value={"data": payload.get("value", {}), "source": payload.get("source", "api")},
)
db.add(metric)
await db.flush()
return {"code": 200, "metric_id": str(metric.id)}
@router.get("/metrics")
async def list_metrics(
request: Request,
metric_type: str | None = None,
limit: int = 50,
db: AsyncSession = Depends(get_db),
):
q = select(SystemMetric).order_by(SystemMetric.collected_at.desc())
if metric_type:
q = q.where(SystemMetric.metric_type == metric_type)
q = q.limit(limit)
result = await db.execute(q)
metrics = result.scalars().all()
return {
"code": 200,
"data": [{
"id": str(m.id),
"metric_type": m.metric_type,
"value": m.value,
"collected_at": m.collected_at.isoformat() if m.collected_at else None,
} for m in metrics],
}
@router.get("/cache/stats")
async def cache_stats(request: Request):
return {
"code": 200,
"data": {
"redis_available": cache_manager.available,
},
}
@router.get("/ratelimit/stats")
async def ratelimit_stats(request: Request):
remaining = await rate_limiter.remaining("global")
return {
"code": 200,
"data": {
"limit_per_minute": 60,
"window_seconds": 60,
"remaining": remaining,
},
}
@router.post("/cache/clear")
async def clear_cache(request: Request, pattern: str = "*"):
await cache_manager.delete_pattern(pattern)
return {"code": 200, "message": "缓存已清除"}

0
backend/modules/task/__init__.py

186
backend/modules/task/router.py

@ -0,0 +1,186 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from models import Task
from schemas import TaskCreate, TaskUpdate, TaskOut
from modules.org.router import _get_subordinate_ids
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
@router.get("", response_model=list[TaskOut])
async def get_tasks(request: Request, db: AsyncSession = Depends(get_db)):
user_ctx = request.state.user
cur_id = uuid.UUID(user_ctx["id"])
if user_ctx["data_scope"] == "all":
result = await db.execute(select(Task))
elif user_ctx["data_scope"] == "subordinate_only":
sub_ids = await _get_subordinate_ids(db, cur_id)
sub_ids.add(cur_id)
result = await db.execute(
select(Task).where(
(Task.assignee_id.in_(sub_ids)) | (Task.assigner_id.in_(sub_ids))
)
)
else:
result = await db.execute(
select(Task).where(
(Task.assignee_id == cur_id) | (Task.assigner_id == cur_id)
)
)
tasks = result.scalars().all()
return [TaskOut(
id=t.id, title=t.title, content=t.content,
assigner_id=t.assigner_id, assignee_id=t.assignee_id,
status=t.status, priority=t.priority, deadline=t.deadline,
created_at=t.created_at, updated_at=t.updated_at,
) for t in tasks]
@router.post("", response_model=TaskOut)
async def create_task(req: TaskCreate, request: Request, db: AsyncSession = Depends(get_db)):
user_ctx = request.state.user
task = Task(
title=req.title, content=req.content,
assigner_id=uuid.UUID(user_ctx["id"]),
assignee_id=req.assignee_id,
priority=req.priority, deadline=req.deadline,
)
db.add(task)
await db.flush()
if req.push_to_wecom:
try:
import httpx
from config import settings
if settings.WECOM_CORP_ID and settings.WECOM_APP_SECRET:
async with httpx.AsyncClient() as client:
token_resp = await client.get(
"https://qyapi.weixin.qq.com/cgi-bin/gettoken",
params={"corpid": settings.WECOM_CORP_ID, "corpsecret": settings.WECOM_APP_SECRET},
)
token_data = token_resp.json()
access_token = token_data.get("access_token")
if access_token:
assignee_result = await db.execute(select(User).where(User.id == req.assignee_id))
assignee = assignee_result.scalar_one_or_none()
touser = assignee.wecom_user_id if assignee and assignee.wecom_user_id else req.assignee_id
msg_resp = await client.post(
f"https://qyapi.weixin.qq.com/cgi-bin/message/send",
params={"access_token": access_token},
json={
"touser": touser,
"msgtype": "textcard",
"agentid": 0,
"textcard": {
"title": f"新任务: {task.title}",
"description": f"任务内容: {task.content}\n优先级: {req.priority}\n截止: {str(req.deadline or '不限')}",
"url": "",
},
},
)
resp_data = msg_resp.json()
if resp_data.get("errcode") == 0:
task.wecom_message_id = resp_data.get("msgid", f"msg_{uuid.uuid4().hex[:12]}")
except Exception:
task.wecom_message_id = f"msg_{uuid.uuid4().hex[:12]}"
return TaskOut(
id=task.id, title=task.title, content=task.content,
assigner_id=task.assigner_id, assignee_id=task.assignee_id,
status=task.status, priority=task.priority, deadline=task.deadline,
created_at=task.created_at, updated_at=task.updated_at,
)
@router.get("/{task_id}", response_model=TaskOut)
async def get_task(task_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Task).where(Task.id == task_id))
task = result.scalar_one_or_none()
if not task:
raise HTTPException(404, "任务不存在")
return TaskOut(
id=task.id, title=task.title, content=task.content,
assigner_id=task.assigner_id, assignee_id=task.assignee_id,
status=task.status, priority=task.priority, deadline=task.deadline,
created_at=task.created_at, updated_at=task.updated_at,
)
@router.put("/{task_id}", response_model=TaskOut)
async def update_task(task_id: uuid.UUID, req: TaskUpdate, request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Task).where(Task.id == task_id))
task = result.scalar_one_or_none()
if not task:
raise HTTPException(404, "任务不存在")
if req.title is not None:
task.title = req.title
if req.content is not None:
task.content = req.content
if req.status is not None:
task.status = req.status
if req.priority is not None:
task.priority = req.priority
if req.deadline is not None:
task.deadline = req.deadline
return TaskOut(
id=task.id, title=task.title, content=task.content,
assigner_id=task.assigner_id, assignee_id=task.assignee_id,
status=task.status, priority=task.priority, deadline=task.deadline,
created_at=task.created_at, updated_at=task.updated_at,
)
@router.post("/{task_id}/push")
async def push_task_to_wecom(task_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Task).where(Task.id == task_id))
task = result.scalar_one_or_none()
if not task:
raise HTTPException(404, "任务不存在")
from config import settings
wecom_message_id = f"msg_{uuid.uuid4().hex[:12]}"
if settings.WECOM_CORP_ID and settings.WECOM_APP_SECRET:
try:
import httpx
async with httpx.AsyncClient() as client:
token_resp = await client.get(
"https://qyapi.weixin.qq.com/cgi-bin/gettoken",
params={"corpid": settings.WECOM_CORP_ID, "corpsecret": settings.WECOM_APP_SECRET},
)
token_data = token_resp.json()
access_token = token_data.get("access_token")
if access_token:
assignee_result = await db.execute(select(User).where(User.id == task.assignee_id))
assignee = assignee_result.scalar_one_or_none()
touser = assignee.wecom_user_id if assignee and assignee.wecom_user_id else str(task.assignee_id)
msg_resp = await client.post(
f"https://qyapi.weixin.qq.com/cgi-bin/message/send",
params={"access_token": access_token},
json={
"touser": touser,
"msgtype": "textcard",
"agentid": 0,
"textcard": {
"title": f"任务: {task.title}",
"description": f"状态: {task.status}\n内容: {task.content}\n截止: {str(task.deadline or '不限')}",
"url": "",
},
},
)
resp_data = msg_resp.json()
if resp_data.get("errcode") == 0:
wecom_message_id = resp_data.get("msgid", wecom_message_id)
except Exception:
pass
task.wecom_message_id = wecom_message_id
return {"code": 200, "message": "已推送到企微", "data": {"wecom_message_id": wecom_message_id}}

0
backend/modules/wecom/__init__.py

165
backend/modules/wecom/router.py

@ -0,0 +1,165 @@
import uuid
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from models import User, ChatSession, ChatMessage
router = APIRouter(prefix="/api/wecom", tags=["wecom"])
@router.post("/callback")
async def wecom_callback(request: Request, db: AsyncSession = Depends(get_db)):
"""
接收企业微信回调消息路由到AI助手处理并回复
企微配置的回调URL指向此端点
"""
try:
body = await request.json()
except Exception:
body = await request.body()
msg_type = "text"
wecom_user_id = ""
content = ""
if isinstance(body, dict):
msg_type = body.get("msg_type", body.get("MsgType", "text"))
wecom_user_id = body.get("user_id", body.get("FromUserName", ""))
content = body.get("content", body.get("Content", ""))
if not wecom_user_id or not content:
return {"code": 200, "message": "received"}
user_result = await db.execute(
select(User).where(User.wecom_user_id == wecom_user_id)
)
user = user_result.scalar_one_or_none()
if not user:
return {"code": 200, "message": "received", "data": {"note": "user not found"}}
from agentscope.message import Msg
session_result = await db.execute(
select(ChatSession)
.where(ChatSession.user_id == user.id, ChatSession.agent_type == "employee")
.order_by(ChatSession.updated_at.desc())
.limit(1)
)
session = session_result.scalar_one_or_none()
session_id = f"wecom_{wecom_user_id}_{uuid.uuid4().hex[:8]}"
if not session:
session = ChatSession(
user_id=user.id, agent_type="employee",
session_id=session_id,
)
db.add(session)
await db.flush()
user_msg = ChatMessage(
session_id=session.id, user_id=user.id,
role="user", content=content,
)
db.add(user_msg)
await db.flush()
from agentscope_integration.factory import AgentFactory
agent = await AgentFactory.create_agent(
agent_type="employee",
user_id=str(user.id),
user_name=user.display_name,
department_id=str(user.department_id) if user.department_id else None,
)
input_msg = Msg(name="user", content=content, role="user")
response = await agent.reply(input_msg)
reply_text = response.get_text_content() if hasattr(response, 'get_text_content') else str(response)
ai_msg = ChatMessage(
session_id=session.id, user_id=user.id,
role="assistant", content=reply_text,
)
db.add(ai_msg)
return {
"code": 200,
"message": "ok",
"data": {
"msg_type": msg_type,
"user_id": wecom_user_id,
"reply": reply_text,
},
}
@router.post("/send")
async def send_wecom_message(
request: Request,
payload: dict,
db: AsyncSession = Depends(get_db),
):
"""
向企业微信用户推送消息
生产环境中需配置真实的企微API凭据
"""
to_user = payload.get("to_user", "")
msg_content = payload.get("content", "")
msg_type = payload.get("msg_type", "text")
if not to_user:
raise HTTPException(400, "缺少目标用户")
corp_id = ""
corp_secret = ""
wecom_message_id = f"msg_{uuid.uuid4().hex[:12]}"
if corp_id and corp_secret:
try:
async with httpx.AsyncClient() as client:
token_resp = await client.get(
"https://qyapi.weixin.qq.com/cgi-bin/gettoken",
params={"corpid": corp_id, "corpsecret": corp_secret},
)
token_data = token_resp.json()
access_token = token_data.get("access_token")
if access_token:
msg_body = {
"touser": to_user,
"msgtype": msg_type,
"agentid": 0,
}
if msg_type == "text":
msg_body["text"] = {"content": msg_content}
elif msg_type == "textcard":
msg_body["textcard"] = payload.get("card", {})
msg_resp = await client.post(
f"https://qyapi.weixin.qq.com/cgi-bin/message/send",
params={"access_token": access_token},
json=msg_body,
)
resp_data = msg_resp.json()
if resp_data.get("errcode") == 0:
wecom_message_id = resp_data.get("msgid", wecom_message_id)
except Exception:
pass
return {
"code": 200,
"message": "消息已发送",
"data": {"wecom_message_id": wecom_message_id},
}
@router.get("/config")
async def get_wecom_config(request: Request):
return {
"code": 200,
"data": {
"bot_name": "企业AI助手",
"status": "configured",
"features": ["消息对话", "文件处理", "任务通知", "工作流触发"],
},
}

14
backend/requirements.txt

@ -0,0 +1,14 @@
fastapi>=0.115.0
uvicorn[standard]>=0.34.0
sqlalchemy[asyncio]>=2.0.0
asyncpg>=0.30.0
redis>=5.2.0
httpx>=0.28.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
pydantic>=2.0.0
pydantic-settings>=2.0.0
alembic>=1.14.0
psutil>=7.0.0
agentscope
agentscope-runtime

370
backend/schemas/__init__.py

@ -0,0 +1,370 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
# --- Auth ---
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user: "UserOut"
# --- User ---
class UserCreate(BaseModel):
username: str
password: str
display_name: str
email: str | None = None
phone: str | None = None
wecom_user_id: str | None = None
department_id: uuid.UUID | None = None
position: str | None = None
manager_id: uuid.UUID | None = None
role_ids: list[uuid.UUID] = []
class UserUpdate(BaseModel):
display_name: str | None = None
email: str | None = None
phone: str | None = None
department_id: uuid.UUID | None = None
position: str | None = None
manager_id: uuid.UUID | None = None
status: str | None = None
role_ids: list[uuid.UUID] | None = None
class UserOut(BaseModel):
id: uuid.UUID
username: str
display_name: str
email: str | None = None
phone: str | None = None
wecom_user_id: str | None = None
department_id: uuid.UUID | None = None
position: str | None = None
manager_id: uuid.UUID | None = None
status: str
roles: list["RoleOut"] = []
created_at: datetime | None = None
class Config:
from_attributes = True
# --- Department ---
class DepartmentCreate(BaseModel):
name: str
parent_id: uuid.UUID | None = None
sort_order: int = 0
class DepartmentUpdate(BaseModel):
name: str | None = None
parent_id: uuid.UUID | None = None
sort_order: int | None = None
class DepartmentOut(BaseModel):
id: uuid.UUID
name: str
parent_id: uuid.UUID | None = None
path: str
level: int
sort_order: int
children: list["DepartmentOut"] = []
class Config:
from_attributes = True
# --- Role ---
class RoleCreate(BaseModel):
name: str
code: str = ""
description: str | None = None
data_scope: str = "self_only"
permission_ids: list[uuid.UUID] = []
class RoleUpdate(BaseModel):
name: str | None = None
description: str | None = None
data_scope: str | None = None
permission_ids: list[uuid.UUID] | None = None
class RoleOut(BaseModel):
id: uuid.UUID
name: str
code: str = ""
description: str | None = None
is_system: bool
data_scope: str
permissions: list[str] = []
class Config:
from_attributes = True
# --- Permission ---
class PermissionOut(BaseModel):
id: uuid.UUID
code: str
name: str
resource: str
action: str
class Config:
from_attributes = True
# --- Task ---
class TaskCreate(BaseModel):
title: str
content: str | None = None
assignee_id: uuid.UUID
priority: str = "normal"
deadline: datetime | None = None
push_to_wecom: bool = True
class TaskUpdate(BaseModel):
title: str | None = None
content: str | None = None
status: str | None = None
priority: str | None = None
deadline: datetime | None = None
class TaskOut(BaseModel):
id: uuid.UUID
title: str
content: str | None = None
assigner_id: uuid.UUID | None = None
assignee_id: uuid.UUID
status: str
priority: str
deadline: datetime | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
class Config:
from_attributes = True
# --- Employee Analysis ---
class EmployeeAnalysis(BaseModel):
employee_name: str
department: str
period: str
task_completion_rate: float
active_days: int
total_interactions: int
main_topics: list[str]
efficiency_trend: str
efficiency_detail: str
strengths: list[str]
growth_suggestions: list[str]
personality_traits: str
# --- Flow ---
class FlowNode(BaseModel):
id: str | None = None
type: str
label: str | None = None
config: dict = {}
class FlowEdge(BaseModel):
source: str | None = None
target: str | None = None
from_field: str | None = Field(None, alias="from")
to_field: str | None = Field(None, alias="to")
class Config:
populate_by_name = True
class FlowDefinitionCreate(BaseModel):
name: str
description: str | None = None
trigger: dict = {}
nodes: list[FlowNode]
edges: list[FlowEdge]
class FlowDefinitionUpdate(BaseModel):
name: str | None = None
description: str | None = None
nodes: list[FlowNode] | None = None
edges: list[FlowEdge] | None = None
trigger: dict | None = None
class FlowDefinitionOut(BaseModel):
id: uuid.UUID
name: str
description: str | None = None
version: int
status: str
definition_json: dict
published_to_wecom: bool
created_at: datetime | None = None
updated_at: datetime | None = None
class Config:
from_attributes = True
# --- MCP ---
class MCPServiceCreate(BaseModel):
name: str
transport: str = "http"
url: str | None = None
command: str | None = None
args: list[str] = []
env: dict[str, str] = {}
class MCPServiceUpdate(BaseModel):
transport: str | None = None
url: str | None = None
command: str | None = None
args: list[str] | None = None
env: dict[str, str] | None = None
class MCPServiceOut(BaseModel):
id: uuid.UUID
name: str
transport: str
url: str | None = None
command: str | None = None
status: str = "disconnected"
tools: list[dict] = []
creator_id: uuid.UUID | None = None
created_at: datetime | None = None
class Config:
from_attributes = True
# --- Notification ---
class NotificationTemplateCreate(BaseModel):
name: str
code: str
channel: str = "wecom"
title_template: str | None = None
body_template: str
variables: list[str] = []
class NotificationTemplateOut(BaseModel):
id: uuid.UUID
name: str
code: str
channel: str
title_template: str | None = None
body_template: str
variables: list[str] = []
is_system: bool = False
class Config:
from_attributes = True
# --- Document ---
class DocumentUploadOut(BaseModel):
file_id: uuid.UUID
filename: str
file_size: int
content_type: str
upload_time: datetime
class DocumentParseResult(BaseModel):
file_id: uuid.UUID
filename: str
content: str
metadata: dict = {}
# --- Audit ---
class AuditQueryParams(BaseModel):
page: int = 1
page_size: int = 20
action: str | None = None
resource: str | None = None
operator_id: uuid.UUID | None = None
date_from: datetime | None = None
date_to: datetime | None = None
class AuditLogOut(BaseModel):
id: uuid.UUID
operator_id: uuid.UUID | None = None
action: str
resource: str | None = None
resource_id: str | None = None
detail: dict | None = None
ip_address: str | None = None
created_at: datetime | None = None
class Config:
from_attributes = True
class AuditLogPage(BaseModel):
items: list[AuditLogOut]
total: int
page: int
page_size: int
# --- System Metrics ---
class SystemMetricOut(BaseModel):
id: uuid.UUID
metric_type: str
value: dict
collected_at: datetime
class Config:
from_attributes = True
class SystemHealthOut(BaseModel):
status: str
service: str
uptime_seconds: float
db_connected: bool
redis_connected: bool
active_users: int
memory_mb: float
cpu_percent: float
class UsageStatsOut(BaseModel):
total_users: int
active_users_today: int
total_sessions: int
total_messages: int
total_tasks: int
total_flows: int
published_flows: int
api_calls_today: int
avg_response_time_ms: float
# --- Generic Response ---
class ApiResponse(BaseModel):
code: int = 200
message: str = "success"
data: dict | list | None = None

81
docker-compose.yml

@ -0,0 +1,81 @@
version: "3.8"
services:
postgres:
image: postgres:16-alpine
container_name: ent-postgres
restart: always
environment:
POSTGRES_USER: enterprise
POSTGRES_PASSWORD: enterprise123
POSTGRES_DB: enterprise_ai
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init-db:/docker-entrypoint-initdb.d
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U enterprise"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: ent-redis
restart: always
command: redis-server --appendonly yes --requirepass redis123
volumes:
- redis_data:/data
ports:
- "6379:6379"
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: ent-backend
restart: always
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
environment:
DATABASE_URL: postgresql+asyncpg://enterprise:enterprise123@postgres:5432/enterprise_ai
REDIS_URL: redis://:redis123@redis:6379/0
JWT_SECRET: dev-secret-key-change-in-production-32chars
LLM_API_KEY: ${LLM_API_KEY:-sk-placeholder}
LLM_API_BASE: ${LLM_API_BASE:-https://api.openai.com/v1}
LLM_MODEL: ${LLM_MODEL:-gpt-4o-mini}
ports:
- "8000:8000"
volumes:
- ./backend:/app
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: ent-frontend
restart: always
depends_on:
- backend
ports:
- "3000:80"
nginx:
image: nginx:alpine
container_name: ent-nginx
restart: always
depends_on:
- frontend
- backend
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
ports:
- "80:80"
volumes:
postgres_data:
redis_data:

12
frontend/Dockerfile

@ -0,0 +1,12 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

7
frontend/env.d.ts

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

13
frontend/index.html

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>企业 AI 平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

28
frontend/nginx.conf

@ -0,0 +1,28 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s;
}
location /wecom/ {
proxy_pass http://backend:8000/wecom/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}

31
frontend/package.json

@ -0,0 +1,31 @@
{
"name": "enterprise-ai-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"pinia": "^2.3.0",
"axios": "^1.7.9",
"element-plus": "^2.9.1",
"@element-plus/icons-vue": "^2.3.1",
"@vue-flow/core": "^1.44.0",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.2",
"@vue-flow/minimap": "^1.5.2",
"echarts": "^5.5.0",
"vue-echarts": "^7.0.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.6.3",
"vite": "^6.0.5",
"vue-tsc": "^2.2.0"
}
}

19
frontend/src/App.vue

@ -0,0 +1,19 @@
<template>
<router-view />
</template>
<script setup lang="ts">
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #app {
width: 100%;
height: 100%;
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
}
</style>

132
frontend/src/api/index.ts

@ -0,0 +1,132 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
const api = axios.create({
baseURL: '/api',
timeout: 30000,
})
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
api.interceptors.response.use(
(response) => response.data,
(error) => {
const msg = error.response?.data?.message || error.message || '请求失败'
if (error.response?.status === 401) {
localStorage.removeItem('token')
router.push('/login')
}
ElMessage.error(msg)
return Promise.reject(error)
}
)
export default api
export const authApi = {
login: (data: { username: string; password: string }) => api.post('/auth/login', data),
getMe: () => api.get('/auth/me'),
}
export const orgApi = {
getDepartments: () => api.get('/org/departments'),
createDepartment: (data: any) => api.post('/org/departments', data),
updateDepartment: (id: string, data: any) => api.put(`/org/departments/${id}`, data),
deleteDepartment: (id: string) => api.delete(`/org/departments/${id}`),
getUsers: () => api.get('/org/users'),
getUser: (id: string) => api.get(`/org/users/${id}`),
createUser: (data: any) => api.post('/org/users', data),
updateUser: (id: string, data: any) => api.put(`/org/users/${id}`, data),
getSubordinates: () => api.get('/org/subordinates'),
}
export const rbacApi = {
getRoles: () => api.get('/rbac/roles'),
getRole: (id: string) => api.get(`/rbac/roles/${id}`),
createRole: (data: any) => api.post('/rbac/roles', data),
updateRole: (id: string, data: any) => api.put(`/rbac/roles/${id}`, data),
deleteRole: (id: string) => api.delete(`/rbac/roles/${id}`),
getPermissions: () => api.get('/rbac/permissions'),
}
export const monitorApi = {
getEmployees: () => api.get('/monitor/employees'),
getDashboard: (id: string) => api.get(`/monitor/employee/${id}/dashboard`),
getAnalysis: (id: string) => api.get(`/monitor/employee/${id}/analysis`),
}
export const taskApi = {
getTasks: () => api.get('/tasks'),
createTask: (data: any) => api.post('/tasks', data),
getTask: (id: string) => api.get(`/tasks/${id}`),
updateTask: (id: string, data: any) => api.put(`/tasks/${id}`, data),
pushTask: (id: string) => api.post(`/tasks/${id}/push`),
}
export const flowApi = {
getFlows: () => api.get('/flow/definitions'),
getFlow: (id: string) => api.get(`/flow/definitions/${id}`),
createFlow: (data: any) => api.post('/flow/definitions', data),
updateFlow: (id: string, data: any) => api.put(`/flow/definitions/${id}`, data),
deleteFlow: (id: string) => api.delete(`/flow/definitions/${id}`),
publishFlow: (id: string) => api.post(`/flow/definitions/${id}/publish`),
unpublishFlow: (id: string) => api.post(`/flow/definitions/${id}/unpublish`),
executeFlow: (id: string, data: any) => api.post(`/flow/definitions/${id}/execute`, data),
testFlow: (id: string) => api.post(`/flow/definitions/${id}/test`),
getMarket: () => api.get('/flow/market'),
}
export const wecomApi = {
sendMessage: (data: any) => api.post('/wecom/send', data),
getConfig: () => api.get('/wecom/config'),
}
export const agentApi = {
chat: (type: string, data: any) => api.post(`/agent/chat/${type}`, data),
getList: () => api.get('/agent/list'),
getHistory: (sessionId: string) => api.get(`/agent/history/${sessionId}`),
}
export const mcpApi = {
getServers: () => api.get('/mcp/servers'),
registerServer: (data: any) => api.post('/mcp/servers', data),
testConnection: (id: string) => api.post(`/mcp/servers/${id}/test`),
unregisterServer: (id: string) => api.delete(`/mcp/servers/${id}`),
}
export const auditApi = {
getLogs: (params?: any) => api.get('/audit/logs', { params }),
getActionTypes: () => api.get('/audit/actions'),
getStats: () => api.get('/audit/stats'),
exportLogs: (params?: any) => api.get('/audit/export', { params, responseType: 'blob' }),
}
export const documentApi = {
upload: (formData: FormData) => api.post('/document/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }),
parse: (fileId: string) => api.post(`/document/parse/${fileId}`),
deleteFile: (fileId: string) => api.delete(`/document/${fileId}`),
format: (data: any) => api.post('/document/format', data),
}
export const notificationApi = {
send: (data: any) => api.post('/notification/send', data),
getTemplates: () => api.get('/notification/templates'),
createTemplate: (data: any) => api.post('/notification/templates', data),
deleteTemplate: (id: string) => api.delete(`/notification/templates/${id}`),
getWsStats: () => api.get('/notification/ws/stats'),
}
export const systemApi = {
getHealth: () => api.get('/system/health'),
getStats: () => api.get('/system/stats'),
getMetrics: (params?: any) => api.get('/system/metrics', { params }),
clearCache: (pattern?: string) => api.post('/system/cache/clear', null, { params: { pattern } }),
getCacheStats: () => api.get('/system/cache/stats'),
}

189
frontend/src/components/layout/AdminLayout.vue

@ -0,0 +1,189 @@
<template>
<el-container class="layout-container">
<el-aside :width="isCollapse ? '64px' : '220px'" class="layout-aside">
<div class="logo">
<span v-if="!isCollapse">管理后台</span>
<span v-else>M</span>
</div>
<el-menu
:default-active="activeMenu"
:collapse="isCollapse"
router
background-color="#304156"
text-color="#bfcbd9"
active-text-color="#409EFF"
>
<el-menu-item index="/admin">
<el-icon><Monitor /></el-icon>
<span>控制台</span>
</el-menu-item>
<el-sub-menu index="org" v-if="can('user:read')">
<template #title>
<el-icon><OfficeBuilding /></el-icon>
<span>组织架构</span>
</template>
<el-menu-item index="/org/departments">部门管理</el-menu-item>
<el-menu-item index="/org/users">人员管理</el-menu-item>
</el-sub-menu>
<el-sub-menu index="role" v-if="can('role:read')">
<template #title>
<el-icon><Lock /></el-icon>
<span>角色权限</span>
</template>
<el-menu-item index="/role/list">角色列表</el-menu-item>
</el-sub-menu>
<el-sub-menu index="flow" v-if="can('flow:read')">
<template #title>
<el-icon><Share /></el-icon>
<span>流编排</span>
</template>
<el-menu-item index="/flow/list">流列表</el-menu-item>
<el-menu-item index="/flow/editor" v-if="can('flow:create')">流编辑器</el-menu-item>
<el-menu-item index="/flow/market">流市场</el-menu-item>
</el-sub-menu>
<el-menu-item index="/task/create" v-if="can('task:create')">
<el-icon><Plus /></el-icon>
<span>创建任务</span>
</el-menu-item>
<el-menu-item index="/audit" v-if="can('audit:read')">
<el-icon><Document /></el-icon>
<span>审计日志</span>
</el-menu-item>
<el-sub-menu index="system" v-if="can('audit:read')">
<template #title>
<el-icon><Monitor /></el-icon>
<span>系统管理</span>
</template>
<el-menu-item index="/system/monitor">系统监控</el-menu-item>
</el-sub-menu>
<el-menu-item index="/user/dashboard" style="margin-top: 20px; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 10px">
<el-icon><User /></el-icon>
<span>返回用户端</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="layout-header">
<div class="header-left">
<el-button @click="isCollapse = !isCollapse" :icon="Fold" text />
<el-breadcrumb separator="/" style="margin-left: 16px">
<el-breadcrumb-item :to="{ path: '/admin' }">管理后台</el-breadcrumb-item>
<el-breadcrumb-item v-if="route.meta.title">{{ route.meta.title }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<el-dropdown @command="handleCommand">
<span class="user-info">
<el-icon><User /></el-icon>
{{ userStore.displayName || userStore.user?.username }}
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">个人信息</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main class="layout-main">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { Fold, User, ArrowDown } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const isCollapse = ref(false)
const activeMenu = computed(() => {
const path = route.path
if (path.startsWith('/org')) return path
if (path.startsWith('/role')) return path
if (path.startsWith('/flow')) return path
if (path.startsWith('/audit')) return path
if (path.startsWith('/system')) return path
return path
})
function can(code: string): boolean {
return userStore.hasPermission(code)
}
function handleCommand(cmd: string) {
if (cmd === 'logout') {
userStore.logout()
router.push('/login')
}
}
</script>
<style scoped>
.layout-container {
height: 100vh;
}
.layout-aside {
background-color: #304156;
overflow-y: auto;
transition: width 0.3s;
}
.logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 18px;
font-weight: bold;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.el-menu {
border-right: none;
}
.layout-header {
background: #fff;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e6e6e6;
height: 60px;
padding: 0 20px;
}
.header-left {
display: flex;
align-items: center;
}
.header-right {
display: flex;
align-items: center;
}
.user-info {
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
}
.layout-main {
background: #f0f2f5;
padding: 20px;
overflow-y: auto;
}
</style>

181
frontend/src/components/layout/MainLayout.vue

@ -0,0 +1,181 @@
<template>
<el-container class="layout-container">
<el-aside :width="isCollapse ? '64px' : '220px'" class="layout-aside">
<div class="logo">
<span v-if="!isCollapse">企业AI平台</span>
<span v-else>AI</span>
</div>
<el-menu
:default-active="activeMenu"
:collapse="isCollapse"
router
background-color="#304156"
text-color="#bfcbd9"
active-text-color="#409EFF"
>
<el-menu-item index="/user/dashboard">
<el-icon><Monitor /></el-icon>
<span>工作台</span>
</el-menu-item>
<el-sub-menu index="monitor" v-if="can('monitor:read')">
<template #title>
<el-icon><TrendCharts /></el-icon>
<span>工作监控</span>
</template>
<el-menu-item index="/user/monitor/employees">员工列表</el-menu-item>
</el-sub-menu>
<el-sub-menu index="task" v-if="can('task:read')">
<template #title>
<el-icon><List /></el-icon>
<span>任务管理</span>
</template>
<el-menu-item index="/user/task/list">任务列表</el-menu-item>
</el-sub-menu>
<el-menu-item index="/user/agent/list">
<el-icon><ChatDotRound /></el-icon>
<span>智能体</span>
</el-menu-item>
<el-menu-item index="/user/document/manager">
<el-icon><FolderOpened /></el-icon>
<span>文档管理</span>
</el-menu-item>
<el-menu-item index="/user/wecom/config">
<el-icon><Connection /></el-icon>
<span>企微配置</span>
</el-menu-item>
<el-menu-item index="/user/notification/center">
<el-icon><Bell /></el-icon>
<span>通知中心</span>
</el-menu-item>
<el-menu-item index="/admin" style="margin-top: 20px; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 10px">
<el-icon><Setting /></el-icon>
<span>管理后台</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="layout-header">
<div class="header-left">
<el-button @click="isCollapse = !isCollapse" :icon="Fold" text />
<el-breadcrumb separator="/" style="margin-left: 16px">
<el-breadcrumb-item :to="{ path: '/user/dashboard' }">工作台</el-breadcrumb-item>
<el-breadcrumb-item v-if="route.meta.title">{{ route.meta.title }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<el-dropdown @command="handleCommand">
<span class="user-info">
<el-icon><User /></el-icon>
{{ userStore.displayName || userStore.user?.username }}
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">个人信息</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main class="layout-main">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { Fold, User, ArrowDown } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const isCollapse = ref(false)
const activeMenu = computed(() => {
const path = route.path
if (path.startsWith('/user/monitor')) return '/user/monitor/employees'
if (path.startsWith('/user/task')) return '/user/task/list'
if (path.startsWith('/user/agent')) return '/user/agent/list'
if (path.startsWith('/user/document')) return '/user/document/manager'
if (path.startsWith('/user/wecom')) return '/user/wecom/config'
if (path.startsWith('/user/notification')) return '/user/notification/center'
return path
})
function can(code: string): boolean {
return userStore.hasPermission(code)
}
function handleCommand(cmd: string) {
if (cmd === 'logout') {
userStore.logout()
router.push('/login')
}
}
</script>
<style scoped>
.layout-container {
height: 100vh;
}
.layout-aside {
background-color: #304156;
overflow-y: auto;
transition: width 0.3s;
}
.logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 18px;
font-weight: bold;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.el-menu {
border-right: none;
}
.layout-header {
background: #fff;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e6e6e6;
height: 60px;
padding: 0 20px;
}
.header-left {
display: flex;
align-items: center;
}
.header-right {
display: flex;
align-items: center;
}
.user-info {
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
}
.layout-main {
background: #f0f2f5;
padding: 20px;
overflow-y: auto;
}
</style>

18
frontend/src/main.ts

@ -0,0 +1,18 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: undefined })
app.mount('#app')

184
frontend/src/router/index.ts

@ -0,0 +1,184 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/Login.vue'),
},
{
path: '/user',
component: () => import('@/components/layout/MainLayout.vue'),
redirect: '/user/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/Dashboard.vue'),
meta: { title: '工作台' },
},
{
path: 'monitor/employees',
name: 'MonitorEmployees',
component: () => import('@/views/monitor/EmployeeList.vue'),
meta: { title: '员工监控', perms: ['monitor:read'] },
},
{
path: 'monitor/:id/dashboard',
name: 'MonitorDashboard',
component: () => import('@/views/monitor/WorkDashboard.vue'),
meta: { title: '工作看板', perms: ['monitor:read'] },
},
{
path: 'monitor/:id/analysis',
name: 'MonitorAnalysis',
component: () => import('@/views/monitor/AIAnalysis.vue'),
meta: { title: 'AI分析', perms: ['monitor:read'] },
},
{
path: 'task/list',
name: 'TaskList',
component: () => import('@/views/task/TaskList.vue'),
meta: { title: '任务列表', perms: ['task:read'] },
},
{
path: 'task/:id',
name: 'TaskDetail',
component: () => import('@/views/task/TaskDetail.vue'),
meta: { title: '任务详情', perms: ['task:read'] },
},
{
path: 'agent/list',
name: 'AgentList',
component: () => import('@/views/agent/AgentList.vue'),
meta: { title: '智能体' },
},
{
path: 'agent/chat/:type',
name: 'AgentChat',
component: () => import('@/views/agent/AgentChat.vue'),
meta: { title: '智能体对话' },
},
{
path: 'document/manager',
name: 'DocumentManager',
component: () => import('@/views/document/DocumentManager.vue'),
meta: { title: '文档管理' },
},
{
path: 'wecom/config',
name: 'WecomConfig',
component: () => import('@/views/wecom/BotConfig.vue'),
meta: { title: '企微配置' },
},
{
path: 'notification/center',
name: 'NotificationCenter',
component: () => import('@/views/notification/NotificationCenter.vue'),
meta: { title: '通知中心' },
},
],
},
{
path: '/admin',
component: () => import('@/components/layout/AdminLayout.vue'),
redirect: '/admin',
children: [
{
path: '',
name: 'AdminDashboard',
component: () => import('@/views/dashboard/Dashboard.vue'),
meta: { title: '控制台', perms: ['admin:access'] },
},
{
path: 'org/departments',
name: 'AdminDepartments',
component: () => import('@/views/org/DepartmentTree.vue'),
meta: { title: '部门管理', perms: ['user:read'] },
},
{
path: 'org/users',
name: 'AdminUserList',
component: () => import('@/views/org/UserList.vue'),
meta: { title: '人员管理', perms: ['user:read'] },
},
{
path: 'role/list',
name: 'AdminRoleList',
component: () => import('@/views/role/RoleList.vue'),
meta: { title: '角色管理', perms: ['role:read'] },
},
{
path: 'role/:id/permissions',
name: 'AdminRolePermissions',
component: () => import('@/views/role/PermissionConfig.vue'),
meta: { title: '权限配置', perms: ['role:read'] },
},
{
path: 'flow/list',
name: 'AdminFlowList',
component: () => import('@/views/flow/FlowList.vue'),
meta: { title: '流列表', perms: ['flow:read'] },
},
{
path: 'flow/editor',
name: 'AdminFlowEditor',
component: () => import('@/views/flow/FlowEditor.vue'),
meta: { title: '流编辑器', perms: ['flow:create'] },
},
{
path: 'flow/editor/:id',
name: 'AdminFlowEditorEdit',
component: () => import('@/views/flow/FlowEditor.vue'),
meta: { title: '编辑流', perms: ['flow:update'] },
},
{
path: 'flow/market',
name: 'AdminFlowMarket',
component: () => import('@/views/flow/FlowMarket.vue'),
meta: { title: '流市场', perms: ['flow:read'] },
},
{
path: 'task/create',
name: 'AdminTaskCreate',
component: () => import('@/views/task/TaskCreate.vue'),
meta: { title: '创建任务', perms: ['task:create'] },
},
{
path: 'audit',
name: 'AdminAudit',
component: () => import('@/views/audit/AuditLog.vue'),
meta: { title: '审计日志', perms: ['audit:read'] },
},
{
path: 'system/monitor',
name: 'AdminSystemMonitor',
component: () => import('@/views/system/SystemMonitor.vue'),
meta: { title: '系统监控', perms: ['audit:read'] },
},
],
},
],
})
router.beforeEach((to, _from, next) => {
const userStore = useUserStore()
if (to.name !== 'Login' && !userStore.token) {
next({ name: 'Login', query: { redirect: to.fullPath } })
} else if (to.meta.perms && Array.isArray(to.meta.perms) && to.meta.perms.length > 0) {
const userPerms = userStore.permissions
const hasPerm = userPerms.includes('*:*') || to.meta.perms.some((p: string) => userPerms.includes(p))
if (!hasPerm) {
next('/user/dashboard')
} else {
next()
}
} else {
next()
}
})
export default router

32
frontend/src/stores/user.ts

@ -0,0 +1,32 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token') || '')
const user = ref<any>(null)
const permissions = ref<string[]>([])
const isLoggedIn = computed(() => !!token.value)
const displayName = computed(() => user.value?.display_name || '')
const role = computed(() => user.value?.roles?.[0]?.code || '')
function setAuth(t: string, u: any) {
token.value = t
user.value = u
permissions.value = u?.roles?.flatMap((r: any) => r.permissions || []) || []
localStorage.setItem('token', t)
}
function logout() {
token.value = ''
user.value = null
permissions.value = []
localStorage.removeItem('token')
}
function hasPermission(code: string): boolean {
return permissions.value.includes('*:*') || permissions.value.includes(code)
}
return { token, user, permissions, isLoggedIn, displayName, role, setAuth, logout, hasPermission }
})

142
frontend/src/views/agent/AgentChat.vue

@ -0,0 +1,142 @@
<template>
<div class="chat-page">
<el-page-header @back="$router.back()" :content="'智能体对话 - ' + agentName" />
<el-card style="margin-top: 20px" class="chat-container">
<div class="chat-messages" ref="msgContainer">
<div v-for="(msg, i) in messages" :key="i" :class="['msg-item', msg.role]">
<div class="msg-bubble">
<div class="msg-content">{{ msg.content }}</div>
<div class="msg-time" v-if="msg.created_at">{{ new Date(msg.created_at).toLocaleTimeString() }}</div>
</div>
</div>
<div v-if="loading" class="msg-item assistant">
<div class="msg-bubble">
<el-icon class="is-loading"><Loading /></el-icon> AI...
</div>
</div>
</div>
<div class="chat-input">
<el-input
v-model="inputText"
placeholder="输入消息..."
type="textarea"
:rows="2"
@keyup.enter.exact="sendMessage"
/>
<el-button type="primary" @click="sendMessage" :loading="loading" style="margin-left: 12px">
发送
</el-button>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { agentApi } from '@/api'
import { Loading } from '@element-plus/icons-vue'
const route = useRoute()
const agentType = computed(() => route.params.type as string)
const agentName = computed(() => {
const names: Record<string, string> = { employee: '员工AI助手', manager: '管理分析助手', task: '任务管理助手', document: '文档处理助手' }
return names[agentType.value] || agentType.value
})
const messages = ref<any[]>([])
const inputText = ref('')
const loading = ref(false)
const msgContainer = ref<HTMLElement>()
const sessionId = ref(`session_${Date.now()}`)
onMounted(() => {
messages.value.push({
role: 'assistant',
content: `你好!我是${agentName.value},有什么可以帮助你的?`,
created_at: new Date().toISOString(),
})
})
async function sendMessage() {
const text = inputText.value.trim()
if (!text) return
messages.value.push({ role: 'user', content: text, created_at: new Date().toISOString() })
inputText.value = ''
await scrollBottom()
loading.value = true
try {
const res: any = await agentApi.chat(agentType.value, {
message: text,
session_id: sessionId.value,
})
const data = res?.data || res || {}
messages.value.push({ role: 'assistant', content: data.reply || '', created_at: new Date().toISOString() })
} catch {
messages.value.push({ role: 'assistant', content: '抱歉,响应失败,请重试。', created_at: new Date().toISOString() })
} finally {
loading.value = false
await scrollBottom()
}
}
async function scrollBottom() {
await nextTick()
if (msgContainer.value) {
msgContainer.value.scrollTop = msgContainer.value.scrollHeight
}
}
</script>
<style scoped>
.chat-container {
display: flex;
flex-direction: column;
height: calc(100vh - 180px);
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
min-height: 0;
}
.msg-item {
margin-bottom: 16px;
display: flex;
}
.msg-item.user {
justify-content: flex-end;
}
.msg-item.assistant {
justify-content: flex-start;
}
.msg-bubble {
max-width: 70%;
padding: 10px 14px;
border-radius: 8px;
font-size: 14px;
line-height: 1.6;
}
.msg-item.user .msg-bubble {
background: #409EFF;
color: #fff;
}
.msg-item.assistant .msg-bubble {
background: #f0f2f5;
color: #303133;
}
.msg-time {
font-size: 11px;
opacity: 0.6;
margin-top: 4px;
}
.chat-input {
display: flex;
align-items: flex-end;
padding: 12px 0 0;
border-top: 1px solid #ebeef5;
}
</style>

52
frontend/src/views/agent/AgentList.vue

@ -0,0 +1,52 @@
<template>
<div class="agent-page">
<el-card>
<template #header>
<span>智能体列表</span>
</template>
<el-row :gutter="20">
<el-col :span="6" v-for="agent in agents" :key="agent.type" style="margin-bottom: 20px">
<el-card shadow="hover" class="agent-card" @click="$router.push(`/agent/chat/${agent.type}`)">
<div class="agent-icon-wrapper">
<el-icon :size="40" color="#409EFF"><ChatDotRound /></el-icon>
</div>
<h4 style="margin: 12px 0 4px; text-align: center">{{ agent.name }}</h4>
<p style="font-size: 13px; color: #909399; text-align: center">{{ agent.description }}</p>
<div style="text-align: center; margin-top: 8px">
<el-tag size="small" type="info">{{ agent.type }}</el-tag>
</div>
</el-card>
</el-col>
</el-row>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { agentApi } from '@/api'
import { ChatDotRound } from '@element-plus/icons-vue'
const agents = ref<any[]>([])
onMounted(async () => {
const res: any = await agentApi.getList()
agents.value = res?.data || res || []
})
</script>
<style scoped>
.agent-card {
cursor: pointer;
transition: all 0.3s;
}
.agent-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 20px rgba(0,0,0,0.12);
}
.agent-icon-wrapper {
text-align: center;
padding: 16px 0 8px;
}
</style>

162
frontend/src/views/audit/AuditLog.vue

@ -0,0 +1,162 @@
<template>
<div class="audit-page">
<el-card>
<template #header>
<div class="card-header">
<span>审计日志</span>
<div>
<el-button size="small" @click="handleExport">导出CSV</el-button>
<el-button size="small" type="primary" @click="loadLogs">刷新</el-button>
</div>
</div>
</template>
<div class="filter-bar">
<el-select v-model="filterAction" placeholder="操作类型" clearable style="width: 160px; margin-right: 12px" @change="loadLogs">
<el-option v-for="a in actionTypes" :key="a.action" :label="`${a.action} (${a.count})`" :value="a.action" />
</el-select>
<el-date-picker
v-model="filterDateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
style="margin-right: 12px"
@change="loadLogs"
/>
<el-button @click="resetFilters">重置</el-button>
</div>
<el-table :data="logs" v-loading="loading" stripe style="margin-top: 12px">
<el-table-column prop="action" label="操作" min-width="120" />
<el-table-column prop="resource" label="资源" min-width="100" />
<el-table-column prop="resource_id" label="资源ID" width="120" />
<el-table-column label="详情" min-width="180">
<template #default="{ row }">
{{ row.detail ? JSON.stringify(row.detail).slice(0, 80) : '-' }}
</template>
</el-table-column>
<el-table-column prop="ip_address" label="IP" width="130" />
<el-table-column label="时间" width="170">
<template #default="{ row }">
{{ row.created_at ? new Date(row.created_at).toLocaleString() : '-' }}
</template>
</el-table-column>
</el-table>
<div style="margin-top: 16px; display: flex; justify-content: center">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@change="loadLogs"
/>
</div>
</el-card>
<el-card style="margin-top: 20px">
<template #header>操作统计</template>
<el-row :gutter="20">
<el-col :span="12">
<div v-for="a in topActions" :key="a.action" style="display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px solid #f0f0f0">
<span>{{ a.action }}</span>
<el-tag size="small">{{ a.count }}</el-tag>
</div>
</el-col>
<el-col :span="12">
<div v-for="r in topResources" :key="r.resource" style="display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px solid #f0f0f0">
<span>{{ r.resource }}</span>
<el-tag size="small" type="success">{{ r.count }}</el-tag>
</div>
</el-col>
</el-row>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { auditApi } from '@/api'
const loading = ref(false)
const logs = ref<any[]>([])
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const filterAction = ref('')
const filterDateRange = ref<any>(null)
const actionTypes = ref<any[]>([])
const topActions = ref<any[]>([])
const topResources = ref<any[]>([])
async function loadLogs() {
loading.value = true
try {
const params: any = { page: page.value, page_size: pageSize.value }
if (filterAction.value) params.action = filterAction.value
if (filterDateRange.value) {
params.date_from = filterDateRange.value[0]?.toISOString()
params.date_to = filterDateRange.value[1]?.toISOString()
}
const res: any = await auditApi.getLogs(params)
const data = res?.data || res || {}
logs.value = data.items || []
total.value = data.total || 0
} finally {
loading.value = false
}
}
async function loadStats() {
const [actRes, statsRes] = await Promise.all([
auditApi.getActionTypes(),
auditApi.getStats(),
]) as [any, any]
actionTypes.value = (actRes?.data || [])
const s = statsRes?.data || {}
topActions.value = s.top_actions || []
topResources.value = s.top_resources || []
}
function resetFilters() {
filterAction.value = ''
filterDateRange.value = null
page.value = 1
loadLogs()
}
async function handleExport() {
const res: any = await auditApi.exportLogs()
const blob = new Blob([res], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `audit_logs_${Date.now()}.csv`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('导出完成')
}
onMounted(() => {
loadLogs()
loadStats()
})
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.filter-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 8px 0;
}
</style>

97
frontend/src/views/dashboard/Dashboard.vue

@ -0,0 +1,97 @@
<template>
<div class="dashboard">
<el-row :gutter="20">
<el-col :span="6" v-for="card in cards" :key="card.title">
<el-card class="stat-card">
<div class="card-content">
<div class="card-icon" :style="{ backgroundColor: card.color }">
<el-icon :size="28"><component :is="card.icon" /></el-icon>
</div>
<div class="card-info">
<div class="card-value">{{ card.value }}</div>
<div class="card-title">{{ card.title }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 20px">
<el-col :span="16">
<el-card>
<template #header>欢迎使用企业AI平台</template>
<div class="welcome">
<p>你好{{ userStore.displayName }}</p>
<p>你的角色: {{ userStore.user?.roles?.map((r: any) => r.name).join(', ') || '普通用户' }}</p>
<p style="margin-top: 12px; color: #909399; font-size: 13px;">
企业AI平台集成四大核心场景企微AI助手员工效能监控任务分派管理可视化流编排
</p>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card>
<template #header>快捷入口</template>
<div class="shortcuts">
<el-button type="primary" plain style="width: 100%; margin-bottom: 8px" @click="$router.push('/agent/list')">
智能体对话
</el-button>
<el-button type="success" plain style="width: 100%; margin-bottom: 8px" @click="$router.push('/task/create')" v-if="userStore.hasPermission('task:create')">
创建任务
</el-button>
<el-button type="warning" plain style="width: 100%; margin-bottom: 8px" @click="$router.push('/flow/editor')" v-if="userStore.hasPermission('flow:create')">
编排工作流
</el-button>
<el-button type="info" plain style="width: 100%" @click="$router.push('/monitor/employees')" v-if="userStore.hasPermission('monitor:read')">
查看监控
</el-button>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { useUserStore } from '@/stores/user'
import { User, TrendCharts, List, Share } from '@element-plus/icons-vue'
const userStore = useUserStore()
const cards = [
{ title: '活跃用户', value: '0', icon: User, color: '#409EFF' },
{ title: '智能体', value: '4', icon: TrendCharts, color: '#67C23A' },
{ title: '工作流', value: '0', icon: Share, color: '#E6A23C' },
{ title: '任务', value: '0', icon: List, color: '#F56C6C' },
]
</script>
<style scoped>
.stat-card .card-content {
display: flex;
align-items: center;
gap: 16px;
}
.card-icon {
width: 56px;
height: 56px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.card-info .card-value {
font-size: 24px;
font-weight: bold;
}
.card-info .card-title {
font-size: 13px;
color: #909399;
margin-top: 4px;
}
.welcome p {
margin: 4px 0;
line-height: 1.8;
}
</style>

152
frontend/src/views/document/DocumentManager.vue

@ -0,0 +1,152 @@
<template>
<div class="document-page">
<el-card>
<template #header>
<div class="card-header">
<span>文档管理</span>
<el-upload
:auto-upload="false"
:show-file-list="false"
:on-change="handleFileSelect"
accept="*"
>
<el-button type="primary">上传文件</el-button>
</el-upload>
</div>
</template>
<div v-if="selectedFile" style="margin-bottom: 16px">
<el-alert type="info" :title="'已选择: ' + selectedFile.name" show-icon :closable="false">
<template #default>
<el-button size="small" type="primary" @click="uploadFile" :loading="uploading">开始上传</el-button>
<el-button size="small" @click="selectedFile = null">取消</el-button>
</template>
</el-alert>
</div>
<el-table :data="files" v-loading="loading">
<el-table-column prop="filename" label="文件名" min-width="200" />
<el-table-column prop="content_type" label="类型" width="120" />
<el-table-column label="大小" width="100">
<template #default="{ row }">{{ formatSize(row.file_size) }}</template>
</el-table-column>
<el-table-column prop="upload_time" label="上传时间" width="180">
<template #default="{ row }">{{ row.upload_time ? new Date(row.upload_time).toLocaleString() : '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button size="small" @click="handleParse(row)">解析</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="previewVisible" title="文档解析结果" width="700px">
<el-tabs v-model="activeTab">
<el-tab-pane label="内容" name="content">
<div class="preview-content">{{ previewContent }}</div>
</el-tab-pane>
<el-tab-pane label="元数据" name="meta">
<el-descriptions :column="1" border size="small">
<el-descriptions-item v-for="(v, k) in previewMeta" :key="k" :label="String(k)">
{{ v }}
</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
</el-tabs>
<template #footer>
<el-button @click="previewVisible = false">关闭</el-button>
<el-button type="primary" @click="handleFormat">格式化</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { documentApi } from '@/api'
const uploading = ref(false)
const loading = ref(false)
const files = ref<any[]>([])
const selectedFile = ref<any>(null)
const previewVisible = ref(false)
const previewContent = ref('')
const previewMeta = ref<Record<string, any>>({})
const previewFileId = ref('')
const activeTab = ref('content')
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
function handleFileSelect(file: any) {
selectedFile.value = file.raw
}
async function uploadFile() {
if (!selectedFile.value) return
uploading.value = true
try {
const formData = new FormData()
formData.append('file', selectedFile.value)
const res: any = await documentApi.upload(formData)
const data = res?.data || res || {}
files.value.push(data)
selectedFile.value = null
ElMessage.success('上传成功')
} finally {
uploading.value = false
}
}
async function handleParse(row: any) {
const res: any = await documentApi.parse(row.file_id)
const data = res?.data || res || {}
previewFileId.value = data.file_id || row.file_id
previewContent.value = data.content || ''
previewMeta.value = data.metadata || {}
previewVisible.value = true
}
async function handleFormat() {
const res: any = await documentApi.format({
content: previewContent.value,
format_type: activeTab.value === 'content' ? 'standard' : 'markdown',
})
const data = res?.data || res || {}
previewContent.value = data.formatted || previewContent.value
ElMessage.success('格式化完成')
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm('确认删除该文件?', '提示', { type: 'warning' })
await documentApi.deleteFile(row.file_id)
files.value = files.value.filter(f => f.file_id !== row.file_id)
ElMessage.success('已删除')
} catch { /**/ }
}
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.preview-content {
max-height: 400px;
overflow-y: auto;
white-space: pre-wrap;
background: #f5f7fa;
padding: 16px;
border-radius: 4px;
font-family: monospace;
font-size: 13px;
}
</style>

495
frontend/src/views/flow/FlowEditor.vue

@ -0,0 +1,495 @@
<template>
<div class="flow-editor-page">
<el-page-header @back="$router.back()" :content="isEdit ? '编辑: ' + flowName : '创建新流'" />
<el-card style="margin-top: 20px">
<div class="editor-toolbar">
<el-input v-model="flowName" placeholder="流名称" style="width: 200px" />
<el-input v-model="flowDesc" placeholder="描述" style="width: 300px; margin-left: 12px" />
<el-button type="primary" @click="saveFlow" :loading="saving">保存</el-button>
<el-button @click="testFlow">验证</el-button>
<el-button v-if="isEdit" type="success" @click="publishFlow">上架到企微</el-button>
</div>
</el-card>
<div class="editor-body">
<div class="node-panel">
<div class="panel-title">节点面板</div>
<div
v-for="node in nodeTypes"
:key="node.type"
class="node-item"
draggable="true"
@dragstart="onDragStart($event, node)"
>
<el-icon :size="18"><component :is="node.icon" /></el-icon>
<span>{{ node.label }}</span>
</div>
</div>
<div class="canvas-wrapper" @drop="onDrop" @dragover.prevent>
<div class="canvas-header">
<span>设计画布</span>
<el-button size="small" @click="clearCanvas">清空</el-button>
</div>
<div class="canvas-body" ref="canvasRef">
<div class="canvas-grid">
<div
v-for="(node, i) in canvasNodes"
:key="node.id"
class="canvas-node"
:style="{ left: node.x + 'px', top: node.y + 'px' }"
:class="{ selected: selectedNodeId === node.id }"
@click.stop="selectNode(node.id)"
>
<div class="node-header" :class="'node-type-' + node.type">
<el-icon :size="14"><component :is="node.iconComp" /></el-icon>
{{ node.label }}
</div>
<div class="node-body">
<p style="font-size: 12px; color: #999">{{ node.typeDesc }}</p>
</div>
<div class="node-ports">
<div class="port port-input" title="输入">
<el-icon :size="12"><ArrowDown /></el-icon>
</div>
<div class="port port-output" title="输出">
<el-icon :size="12"><ArrowUp /></el-icon>
</div>
</div>
<div class="node-delete" @click.stop="removeNode(node.id)">
<el-icon :size="12"><Close /></el-icon>
</div>
</div>
<svg class="edges-svg" v-if="canvasEdges.length">
<line
v-for="(edge, i) in visibleEdges"
:key="'e_' + i"
:x1="edge.x1" :y1="edge.y1" :x2="edge.x2" :y2="edge.y2"
stroke="#ccc" stroke-width="2" marker-end="url(#arrowhead)"
/>
<defs>
<marker id="arrowhead" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<polygon points="0 0, 8 4, 0 8" fill="#666" />
</marker>
</defs>
</svg>
</div>
</div>
</div>
<div class="config-panel" v-if="selectedNodeId">
<div class="panel-title">节点配置</div>
<el-form label-width="100px" size="small">
<el-form-item label="类型">
<el-input :value="selectedNode?.typeDesc" disabled />
</el-form-item>
<el-form-item label="名称">
<el-input v-model="selectedNodeData.label" @change="updateSelectedLabel" />
</el-form-item>
<div v-if="selectedNode?.type === 'llm'">
<el-form-item label="系统提示词">
<el-input v-model="selectedNodeData.config.system_prompt" type="textarea" :rows="4" />
</el-form-item>
<el-form-item label="模型">
<el-select v-model="selectedNodeData.config.model">
<el-option label="GPT-4o-mini" value="gpt-4o-mini" />
<el-option label="GPT-4o" value="gpt-4o" />
</el-select>
</el-form-item>
</div>
<div v-if="selectedNode?.type === 'tool'">
<el-form-item label="工具名称">
<el-input v-model="selectedNodeData.config.tool_name" />
</el-form-item>
</div>
<div v-if="selectedNode?.type === 'mcp'">
<el-form-item label="MCP服务">
<el-input v-model="selectedNodeData.config.mcp_server" />
</el-form-item>
</div>
<div v-if="selectedNode?.type === 'wecom_notify'">
<el-form-item label="消息模板">
<el-input v-model="selectedNodeData.config.message_template" type="textarea" />
</el-form-item>
</div>
</el-form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { flowApi } from '@/api'
import {
Promotion, ChatDotRound, Tools, Connection, Bell,
DataAnalysis, Search, ArrowDown, ArrowUp, Close,
} from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const flowId = computed(() => route.params.id as string)
const isEdit = computed(() => !!flowId.value)
const flowName = ref('新工作流')
const flowDesc = ref('')
const saving = ref(false)
const selectedNodeId = ref('')
interface CanvasNode {
id: string
type: string
label: string
typeDesc: string
iconComp: any
x: number
y: number
config: Record<string, any>
}
const canvasNodes = ref<CanvasNode[]>([])
const canvasEdges = ref<{ source: string; target: string }[]>([])
let nodeCounter = 0
const nodeTypes = [
{ type: 'trigger', label: '触发节点', icon: Promotion, typeDesc: '企微触发', iconComp: Promotion },
{ type: 'llm', label: 'LLM处理', icon: ChatDotRound, typeDesc: 'AI处理', iconComp: ChatDotRound },
{ type: 'tool', label: '工具调用', icon: Tools, typeDesc: '工具调用', iconComp: Tools },
{ type: 'mcp', label: 'MCP服务', icon: Connection, typeDesc: '外部MCP', iconComp: Connection },
{ type: 'wecom_notify', label: '企微通知', icon: Bell, typeDesc: '企微通知', iconComp: Bell },
{ type: 'condition', label: '条件判断', icon: DataAnalysis, typeDesc: '条件分支', iconComp: DataAnalysis },
{ type: 'rag', label: 'RAG检索', icon: Search, typeDesc: '知识库检索', iconComp: Search },
{ type: 'output', label: '输出节点', icon: Promotion, typeDesc: '结果输出', iconComp: Promotion },
]
const selectedNode = computed(() => {
return canvasNodes.value.find(n => n.id === selectedNodeId.value)
})
const selectedNodeData = ref<any>({})
const visibleEdges = computed(() => {
return canvasEdges.value.map(edge => {
const source = canvasNodes.value.find(n => n.id === edge.source)
const target = canvasNodes.value.find(n => n.id === edge.target)
if (!source || !target) return null
return {
x1: source.x + 80, y1: source.y + 100,
x2: target.x + 80, y2: target.y + 5,
}
}).filter(Boolean)
})
function onDragStart(e: DragEvent, node: (typeof nodeTypes)[0]) {
e.dataTransfer?.setData('nodeType', JSON.stringify(node))
}
function onDrop(e: DragEvent) {
const canvasRect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const x = e.clientX - canvasRect.left - 80
const y = e.clientY - canvasRect.top - 20
const data = e.dataTransfer?.getData('nodeType')
if (!data) return
const node = JSON.parse(data)
const id = `node_${nodeCounter++}`
canvasNodes.value.push({
id,
type: node.type,
label: node.label,
typeDesc: node.typeDesc,
iconComp: node.iconComp,
x: Math.max(20, x),
y: Math.max(20, y),
config: node.type === 'llm' ? { system_prompt: '', model: 'gpt-4o-mini' } : {},
})
if (canvasNodes.value.length >= 2) {
const last = canvasNodes.value[canvasNodes.value.length - 2]
const cur = canvasNodes.value[canvasNodes.value.length - 1]
canvasEdges.value.push({ source: last.id, target: cur.id })
}
}
function selectNode(id: string) {
selectedNodeId.value = id
const node = canvasNodes.value.find(n => n.id === id)
if (node) {
selectedNodeData.value = {
label: node.label,
config: { ...node.config },
}
}
}
function updateSelectedLabel() {
const node = canvasNodes.value.find(n => n.id === selectedNodeId.value)
if (node) {
node.label = selectedNodeData.value.label
node.config = { ...selectedNodeData.value.config }
}
}
function removeNode(id: string) {
canvasNodes.value = canvasNodes.value.filter(n => n.id !== id)
canvasEdges.value = canvasEdges.value.filter(e => e.source !== id && e.target !== id)
if (selectedNodeId.value === id) selectedNodeId.value = ''
}
function clearCanvas() {
canvasNodes.value = []
canvasEdges.value = []
nodeCounter = 0
selectedNodeId.value = ''
}
async function saveFlow() {
if (!flowName.value) { ElMessage.warning('请输入流名称'); return }
saving.value = true
try {
const nodes = canvasNodes.value.map(n => ({
id: n.id, type: n.type, label: n.label, config: n.config,
}))
const edges = canvasEdges.value.map(e => ({
source: e.source, target: e.target,
}))
const payload = { name: flowName.value, description: flowDesc.value, nodes, edges, trigger: {} }
if (isEdit.value) {
await flowApi.updateFlow(flowId.value, payload)
} else {
const res: any = await flowApi.createFlow(payload)
const data = res?.data || res || {}
if (data.id) {
router.replace(`/flow/editor/${data.id}`)
}
}
ElMessage.success('保存成功')
} finally {
saving.value = false
}
}
async function testFlow() {
try {
if (isEdit.value) {
const res: any = await flowApi.testFlow(flowId.value)
const data = res?.data || res || {}
if (data.valid) {
ElMessage.success(`验证通过: ${data.node_count}个节点, ${data.edge_count}条边`)
} else {
ElMessage.warning(`验证问题: ${(data.issues || []).join(', ')}`)
}
} else {
ElMessage.info('请先保存再进行验证')
}
} catch { /**/ }
}
async function publishFlow() {
if (!isEdit.value) { ElMessage.warning('请先保存'); return }
await flowApi.publishFlow(flowId.value)
ElMessage.success('流已上架到企微')
await loadFlow()
}
async function loadFlow() {
if (!isEdit.value) return
try {
const res: any = await flowApi.getFlow(flowId.value)
const flow = res?.data || res || {}
flowName.value = flow.name || ''
flowDesc.value = flow.description || ''
const definition = flow.definition_json || {}
canvasNodes.value = (definition.nodes || []).map((n: any) => ({
id: n.id,
type: n.type,
label: n.label || n.id,
typeDesc: nodeTypes.find(nt => nt.type === n.type)?.typeDesc || n.type,
iconComp: nodeTypes.find(nt => nt.type === n.type)?.iconComp || ChatDotRound,
x: 100 + Math.random() * 400,
y: 60 + Math.random() * 300,
config: n.config || {},
}))
canvasEdges.value = (definition.edges || []).map((e: any) => ({
source: e.source || e.from,
target: e.target || e.to,
}))
nodeCounter = canvasNodes.value.length
} catch { /**/ }
}
onMounted(async () => {
if (isEdit.value) {
await loadFlow()
}
})
</script>
<style scoped>
.editor-toolbar {
display: flex;
align-items: center;
gap: 8px;
}
.editor-body {
display: flex;
gap: 12px;
margin-top: 12px;
height: calc(100vh - 250px);
}
.node-panel {
width: 150px;
background: #fff;
border-radius: 4px;
padding: 12px;
border: 1px solid #ebeef5;
overflow-y: auto;
}
.panel-title {
font-weight: bold;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #ebeef5;
}
.node-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
margin-bottom: 8px;
border: 1px solid #e4e7ed;
border-radius: 4px;
cursor: grab;
font-size: 13px;
background: #f5f7fa;
transition: all 0.2s;
}
.node-item:hover {
border-color: #409EFF;
background: #ecf5ff;
}
.canvas-wrapper {
flex: 1;
background: #fff;
border-radius: 4px;
border: 1px solid #ebeef5;
display: flex;
flex-direction: column;
}
.canvas-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
border-bottom: 1px solid #ebeef5;
font-weight: bold;
}
.canvas-body {
flex: 1;
position: relative;
overflow: auto;
}
.canvas-grid {
width: 3000px;
height: 2000px;
background-image:
linear-gradient(rgba(0,0,0,0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,0,0,0.05) 1px, transparent 1px);
background-size: 20px 20px;
position: relative;
}
.canvas-node {
position: absolute;
width: 160px;
background: #fff;
border: 2px solid #e4e7ed;
border-radius: 6px;
cursor: pointer;
transition: box-shadow 0.2s;
}
.canvas-node:hover {
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
}
.canvas-node.selected {
border-color: #409EFF;
box-shadow: 0 0 0 2px rgba(64,158,255,0.2);
}
.node-header {
padding: 6px 10px;
border-radius: 4px 4px 0 0;
font-size: 13px;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
color: #fff;
}
.node-type-trigger { background: #722ed1; }
.node-type-llm { background: #409EFF; }
.node-type-tool { background: #67C23A; }
.node-type-mcp { background: #E6A23C; }
.node-type-wecom_notify { background: #F56C6C; }
.node-type-condition { background: #909399; }
.node-type-rag { background: #337ecc; }
.node-type-output { background: #722ed1; }
.node-body {
padding: 6px 10px;
border-bottom: 1px solid #ebeef5;
}
.node-ports {
display: flex;
justify-content: center;
gap: 20px;
padding: 4px;
}
.port {
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: #f0f2f5;
border: 1px solid #dcdfe6;
}
.node-delete {
position: absolute;
top: -8px;
right: -8px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #f56c6c;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
display: none;
}
.canvas-node:hover .node-delete {
display: flex;
}
.edges-svg {
position: absolute;
top: 0;
left: 0;
width: 3000px;
height: 2000px;
pointer-events: none;
}
.config-panel {
width: 260px;
background: #fff;
border-radius: 4px;
padding: 12px;
border: 1px solid #ebeef5;
overflow-y: auto;
}
</style>

106
frontend/src/views/flow/FlowList.vue

@ -0,0 +1,106 @@
<template>
<div class="flow-list-page">
<el-card>
<template #header>
<div class="card-header">
<span>流列表</span>
<el-button type="primary" @click="$router.push('/flow/editor')" v-if="userStore.hasPermission('flow:create')">创建新流</el-button>
</div>
</template>
<el-table :data="flows" v-loading="loading">
<el-table-column prop="name" label="名称" min-width="200" />
<el-table-column prop="description" label="描述" min-width="200" />
<el-table-column prop="version" label="版本" width="80">
<template #default="{ row }">v{{ row.version }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'published' ? 'success' : 'info'">{{ row.status === 'published' ? '已上架' : '草稿' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="280">
<template #default="{ row }">
<el-button size="small" @click="$router.push(`/flow/editor/${row.id}`)">编辑</el-button>
<el-button size="small" @click="handleTest(row)">测试</el-button>
<el-button v-if="row.status === 'draft'" size="small" type="success" @click="handlePublish(row)">上架</el-button>
<el-button v-else size="small" type="warning" @click="handleUnpublish(row)">下架</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { flowApi } from '@/api'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const loading = ref(false)
const flows = ref<any[]>([])
onMounted(async () => {
loading.value = true
try {
const res: any = await flowApi.getFlows()
flows.value = res || []
} finally {
loading.value = false
}
})
async function handlePublish(row: any) {
await flowApi.publishFlow(row.id)
ElMessage.success('流已上架到企微')
await refreshList()
}
async function handleUnpublish(row: any) {
await flowApi.unpublishFlow(row.id)
ElMessage.success('流已下架')
await refreshList()
}
async function handleTest(row: any) {
try {
const res: any = await flowApi.testFlow(row.id)
const data = res?.data || res || {}
if (data.valid) {
ElMessage.success(`测试通过: ${data.node_count}个节点, ${data.edge_count}条边`)
} else {
ElMessage.warning(`测试发现问题: ${(data.issues || []).join(', ')}`)
}
} catch { /**/ }
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm('确认删除该流?', '提示', { type: 'warning' })
await flowApi.deleteFlow(row.id)
ElMessage.success('已删除')
await refreshList()
} catch { /**/ }
}
async function refreshList() {
loading.value = true
try {
const res: any = await flowApi.getFlows()
flows.value = res || []
} finally {
loading.value = false
}
}
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

72
frontend/src/views/flow/FlowMarket.vue

@ -0,0 +1,72 @@
<template>
<div class="flow-market-page">
<el-card>
<template #header>
<span>流市场 - 已上架的工作流</span>
</template>
<el-row :gutter="20">
<el-col :span="8" v-for="flow in flows" :key="flow.id" style="margin-bottom: 20px">
<el-card shadow="hover" class="flow-card">
<div class="flow-card-header">
<h4>{{ flow.name }}</h4>
<el-tag size="small" type="success">v{{ flow.version }}</el-tag>
</div>
<p class="flow-desc">{{ flow.description || '暂无描述' }}</p>
<div class="flow-card-footer">
<el-tag size="small" v-if="flow.published_to_wecom" type="warning">企微可用</el-tag>
<span style="font-size: 12px; color: #999; margin-left: auto">
{{ flow.updated_at ? new Date(flow.updated_at).toLocaleDateString() : '' }}
</span>
</div>
</el-card>
</el-col>
</el-row>
<el-empty v-if="!flows.length" description="暂无已上架的工作流" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { flowApi } from '@/api'
const flows = ref<any[]>([])
onMounted(async () => {
const res: any = await flowApi.getMarket()
flows.value = res || []
})
</script>
<style scoped>
.flow-card {
cursor: pointer;
transition: all 0.3s;
}
.flow-card:hover {
transform: translateY(-2px);
}
.flow-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.flow-card-header h4 {
margin: 0;
font-size: 16px;
}
.flow-desc {
color: #909399;
font-size: 13px;
margin-bottom: 12px;
min-height: 40px;
}
.flow-card-footer {
display: flex;
align-items: center;
gap: 8px;
}
</style>

97
frontend/src/views/login/Login.vue

@ -0,0 +1,97 @@
<template>
<div class="login-container">
<el-card class="login-card">
<h2>企业AI平台</h2>
<p class="subtitle">登录你的账户</p>
<el-form ref="formRef" :model="form" :rules="rules" size="large">
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="用户名" :prefix-icon="User" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" placeholder="密码" show-password :prefix-icon="Lock" @keyup.enter="handleLogin" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" style="width: 100%" @click="handleLogin">
</el-button>
</el-form-item>
</el-form>
<div class="hint">
<p>测试账号: admin / admin123</p>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { authApi } from '@/api'
import { User, Lock } from '@element-plus/icons-vue'
import type { FormInstance } from 'element-plus'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const form = reactive({
username: 'admin',
password: 'admin123',
})
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}
async function handleLogin() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
loading.value = true
try {
const res: any = await authApi.login({ username: form.username, password: form.password })
userStore.setAuth(res.access_token, res.user)
const redirect = (route.query.redirect as string) || ''
const targetPath = redirect || '/user/dashboard'
router.push(targetPath)
} finally {
loading.value = false
}
})
}
</script>
<style scoped>
.login-container {
width: 100%;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 420px;
padding: 20px;
}
.login-card h2 {
text-align: center;
margin-bottom: 8px;
color: #303133;
}
.subtitle {
text-align: center;
color: #909399;
margin-bottom: 24px;
}
.hint {
text-align: center;
color: #909399;
font-size: 13px;
margin-top: 8px;
}
</style>

90
frontend/src/views/monitor/AIAnalysis.vue

@ -0,0 +1,90 @@
<template>
<div class="analysis-page">
<el-page-header @back="$router.back()" :content="data?.employee_name + ' - AI分析报告'" />
<el-card v-loading="loading" style="margin-top: 20px" v-if="data">
<el-descriptions :column="2" border>
<el-descriptions-item label="员工">{{ data.employee_name }}</el-descriptions-item>
<el-descriptions-item label="部门">{{ data.department }}</el-descriptions-item>
<el-descriptions-item label="周期">{{ data.period }}</el-descriptions-item>
<el-descriptions-item label="任务完成率">
<el-progress :percentage="Math.round((data.task_completion_rate || 0) * 100)" />
</el-descriptions-item>
<el-descriptions-item label="活跃天数">{{ data.active_days }} </el-descriptions-item>
<el-descriptions-item label="总交互">{{ data.total_interactions }} </el-descriptions-item>
<el-descriptions-item label="效率趋势">
<el-tag :type="trendType">{{ data.efficiency_trend }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="效率详情" :span="2">{{ data.efficiency_detail }}</el-descriptions-item>
</el-descriptions>
<el-divider />
<el-row :gutter="20">
<el-col :span="12">
<el-card>
<template #header>话题分析</template>
<el-tag v-for="topic in data.main_topics" :key="topic" style="margin: 4px">{{ topic }}</el-tag>
<el-empty v-if="!data.main_topics?.length" description="暂无话题数据" />
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<template #header>优势特长</template>
<ul>
<li v-for="s in data.strengths" :key="s">{{ s }}</li>
</ul>
<el-empty v-if="!data.strengths?.length" description="暂无" />
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 20px">
<el-col :span="12">
<el-card>
<template #header>成长建议</template>
<ul>
<li v-for="s in data.growth_suggestions" :key="s">{{ s }}</li>
</ul>
<el-empty v-if="!data.growth_suggestions?.length" description="暂无" />
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<template #header>人格特征</template>
<p>{{ data.personality_traits || '暂无' }}</p>
</el-card>
</el-col>
</el-row>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { monitorApi } from '@/api'
const route = useRoute()
const empId = computed(() => route.params.id as string)
const data = ref<any>(null)
const loading = ref(false)
const trendType = computed(() => {
if (!data.value) return 'info'
const t = data.value.efficiency_trend
if (t === '提升') return 'success'
if (t === '下降') return 'danger'
return 'warning'
})
onMounted(async () => {
loading.value = true
try {
const res: any = await monitorApi.getAnalysis(empId.value)
data.value = res
} finally {
loading.value = false
}
})
</script>

38
frontend/src/views/monitor/EmployeeList.vue

@ -0,0 +1,38 @@
<template>
<div class="monitor-page">
<el-card>
<template #header>
<span>员工工作监控</span>
</template>
<el-table :data="employees" v-loading="loading">
<el-table-column prop="display_name" label="姓名" />
<el-table-column prop="position" label="岗位" />
<el-table-column prop="email" label="邮箱" />
<el-table-column label="操作" width="250">
<template #default="{ row }">
<el-button size="small" type="primary" @click="$router.push(`/monitor/${row.id}/dashboard`)">工作看板</el-button>
<el-button size="small" type="success" @click="$router.push(`/monitor/${row.id}/analysis`)">AI分析</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { monitorApi } from '@/api'
const loading = ref(false)
const employees = ref<any[]>([])
onMounted(async () => {
loading.value = true
try {
const res: any = await monitorApi.getEmployees()
employees.value = res || []
} finally {
loading.value = false
}
})
</script>

92
frontend/src/views/monitor/WorkDashboard.vue

@ -0,0 +1,92 @@
<template>
<div class="dashboard-page">
<el-page-header @back="$router.back()" :content="empName + ' - 工作看板'" />
<el-row :gutter="20" style="margin-top: 20px">
<el-col :span="6" v-for="card in statCards" :key="card.title">
<el-card class="stat-card">
<div class="card-content">
<div class="card-icon" :style="{ backgroundColor: card.color }">
<el-icon :size="24"><component :is="card.icon" /></el-icon>
</div>
<div class="card-info">
<div class="card-value">{{ card.value }}</div>
<div class="card-title">{{ card.title }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<el-card style="margin-top: 20px">
<template #header>近期交互</template>
<el-timeline>
<el-timeline-item
v-for="(item, i) in recentInteractions"
:key="i"
:type="item.role === 'user' ? 'primary' : 'success'"
:timestamp="item.created_at"
>
<strong>{{ item.role === 'user' ? '用户' : 'AI' }}</strong>: {{ item.content }}
</el-timeline-item>
</el-timeline>
<el-empty v-if="!recentInteractions.length" description="暂无交互数据" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { monitorApi } from '@/api'
import { ChatDotRound, Clock, DataLine, VideoCamera } from '@element-plus/icons-vue'
const route = useRoute()
const empId = computed(() => route.params.id as string)
const empName = ref('')
const dashboard = ref<any>(null)
const statCards = ref<any[]>([])
const recentInteractions = ref<any[]>([])
onMounted(async () => {
const res: any = await monitorApi.getDashboard(empId.value)
const data = res?.data || res || {}
dashboard.value = data
empName.value = data?.employee?.name || '员工'
const stats = data?.stats || {}
statCards.value = [
{ title: '总消息数', value: stats.total_messages || 0, icon: ChatDotRound, color: '#409EFF' },
{ title: '会话数', value: stats.total_sessions || 0, icon: DataLine, color: '#67C23A' },
{ title: '活跃天数', value: stats.active_days || 0, icon: Clock, color: '#E6A23C' },
{ title: '交互模式', value: Object.keys(stats.message_breakdown || {}).length, icon: VideoCamera, color: '#F56C6C' },
]
recentInteractions.value = stats?.recent_interactions || []
})
</script>
<style scoped>
.stat-card .card-content {
display: flex;
align-items: center;
gap: 12px;
}
.card-icon {
width: 48px;
height: 48px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.card-info .card-value {
font-size: 22px;
font-weight: bold;
}
.card-info .card-title {
font-size: 12px;
color: #909399;
}
</style>

175
frontend/src/views/notification/NotificationCenter.vue

@ -0,0 +1,175 @@
<template>
<div class="notification-page">
<el-card>
<template #header>
<div class="card-header">
<span>通知中心</span>
<div>
<el-tag style="margin-right: 12px" :type="wsConnected ? 'success' : 'info'">
{{ wsConnected ? '已连接' : '未连接' }}
</el-tag>
<el-button size="small" @click="wsConnected ? disconnectWs() : connectWs()">
{{ wsConnected ? '断开' : '连接' }}WebSocket
</el-button>
</div>
</div>
</template>
<el-tabs v-model="activeTab">
<el-tab-pane label="实时消息" name="messages">
<div class="message-list" ref="msgListRef">
<div v-for="(msg, i) in messages" :key="i" :class="['msg-item', 'msg-' + (msg.type || 'info')]">
<div class="msg-header">
<strong>{{ msg.title || '通知' }}</strong>
<span class="msg-time">{{ msg.ts ? new Date(msg.ts).toLocaleTimeString() : '' }}</span>
</div>
<div class="msg-body">{{ msg.message }}</div>
</div>
<el-empty v-if="!messages.length" description="暂无消息,请连接WebSocket" />
</div>
</el-tab-pane>
<el-tab-pane label="发送通知" name="send">
<el-form :model="sendForm" label-width="80px" style="max-width: 500px">
<el-form-item label="标题">
<el-input v-model="sendForm.title" placeholder="通知标题" />
</el-form-item>
<el-form-item label="内容">
<el-input v-model="sendForm.message" type="textarea" :rows="4" placeholder="通知内容" />
</el-form-item>
<el-form-item label="目标用户">
<el-input v-model="sendForm.user_id" placeholder="用户ID,留空广播" />
</el-form-item>
<el-form-item label="企微推送">
<el-switch v-model="sendForm.push_to_wecom" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="sendNotification" :loading="sending">发送</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="模板管理" name="templates">
<el-table :data="templates">
<el-table-column prop="name" label="名称" />
<el-table-column prop="code" label="编码" />
<el-table-column prop="channel" label="渠道" width="100" />
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button size="small" type="danger" :disabled="row.is_system" @click="deleteTemplate(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { notificationApi } from '@/api'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const activeTab = ref('messages')
const wsConnected = ref(false)
const messages = ref<any[]>([])
const sending = ref(false)
const templates = ref<any[]>([])
const msgListRef = ref<HTMLElement>()
let ws: WebSocket | null = null
const sendForm = reactive({
title: '系统通知',
message: '',
user_id: '',
push_to_wecom: false,
})
function connectWs() {
const userId = userStore.user?.id || 'anonymous'
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${protocol}//${location.host}/api/notification/ws/${userId}`
ws = new WebSocket(wsUrl)
ws.onopen = () => { wsConnected.value = true }
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
messages.value.push(data)
nextTick(() => {
if (msgListRef.value) msgListRef.value.scrollTop = msgListRef.value.scrollHeight
})
} catch { /**/ }
}
ws.onclose = () => { wsConnected.value = false }
ws.onerror = () => { wsConnected.value = false }
}
function disconnectWs() {
ws?.close()
ws = null
}
async function sendNotification() {
sending.value = true
try {
await notificationApi.send(sendForm)
ElMessage.success('通知已发送')
sendForm.message = ''
} finally {
sending.value = false
}
}
async function loadTemplates() {
const res: any = await notificationApi.getTemplates()
templates.value = res?.data || res || []
}
async function deleteTemplate(row: any) {
await notificationApi.deleteTemplate(row.id)
ElMessage.success('已删除')
await loadTemplates()
}
onMounted(() => {
connectWs()
loadTemplates()
})
onUnmounted(() => disconnectWs())
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.message-list {
max-height: 400px;
overflow-y: auto;
padding: 8px;
}
.msg-item {
padding: 10px 14px;
margin-bottom: 8px;
border-radius: 6px;
border-left: 4px solid #409EFF;
background: #f5f7fa;
}
.msg-item.msg-error { border-color: #F56C6C; background: #fef0f0; }
.msg-item.msg-success { border-color: #67C23A; background: #f0f9eb; }
.msg-item.msg-warning { border-color: #E6A23C; background: #fdf6ec; }
.msg-header {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
font-size: 13px;
}
.msg-time { color: #909399; font-size: 12px; }
.msg-body { font-size: 14px; color: #606266; }
</style>

142
frontend/src/views/org/DepartmentTree.vue

@ -0,0 +1,142 @@
<template>
<div class="dept-page">
<el-card>
<template #header>
<div class="card-header">
<span>组织架构</span>
<el-button type="primary" size="small" @click="showAddDialog(null)">新增部门</el-button>
</div>
</template>
<el-table :data="flatDepts" row-key="id" default-expand-all :tree-props="{ children: 'children' }">
<el-table-column prop="name" label="部门名称" />
<el-table-column prop="level" label="层级" width="80" />
<el-table-column prop="sort_order" label="排序" width="80" />
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button size="small" @click="showAddDialog(row)">添加子部门</el-button>
<el-button size="small" @click="showEditDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑部门' : '新增部门'" width="500px">
<el-form :model="form" label-width="80px">
<el-form-item label="上级部门">
<el-input :value="parentDept?.name || '公司'" disabled />
</el-form-item>
<el-form-item label="部门名称">
<el-input v-model="form.name" placeholder="请输入部门名称" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort_order" :min="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { orgApi } from '@/api'
interface Dept {
id: string
name: string
parent_id: string | null
level: number
sort_order: number
children: Dept[]
}
const depts = ref<Dept[]>([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const editingDept = ref<Dept | null>(null)
const parentDept = ref<Dept | null>(null)
const form = ref({ name: '', sort_order: 0 })
const flatDepts = computed(() => {
return depts.value
})
function flattenTree(list: Dept[]): Dept[] {
const result: Dept[] = []
for (const item of list) {
result.push(item)
if (item.children?.length) {
result.push(...flattenTree(item.children))
}
}
return result
}
onMounted(async () => {
await loadDepts()
})
async function loadDepts() {
const res: any = await orgApi.getDepartments()
depts.value = res || []
}
function showAddDialog(parent: Dept | null) {
isEdit.value = false
editingDept.value = null
parentDept.value = parent
form.value = { name: '', sort_order: 0 }
dialogVisible.value = true
}
function showEditDialog(row: Dept) {
isEdit.value = true
editingDept.value = row
form.value = { name: row.name, sort_order: row.sort_order }
dialogVisible.value = true
}
async function handleSubmit() {
try {
if (isEdit.value && editingDept.value) {
await orgApi.updateDepartment(editingDept.value.id, form.value)
ElMessage.success('更新成功')
} else {
await orgApi.createDepartment({
name: form.value.name,
parent_id: parentDept.value?.id || null,
sort_order: form.value.sort_order,
})
ElMessage.success('创建成功')
}
dialogVisible.value = false
await loadDepts()
} catch {
// error handled by interceptor
}
}
async function handleDelete(row: Dept) {
try {
await ElMessageBox.confirm('确认删除该部门?', '提示', { type: 'warning' })
await orgApi.deleteDepartment(row.id)
ElMessage.success('删除成功')
await loadDepts()
} catch {
// cancelled
}
}
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

133
frontend/src/views/org/UserList.vue

@ -0,0 +1,133 @@
<template>
<div class="user-page">
<el-card>
<template #header>
<div class="card-header">
<span>人员管理</span>
<el-button type="primary" @click="showAddDialog">新增用户</el-button>
</div>
</template>
<el-table :data="users" v-loading="loading">
<el-table-column prop="username" label="用户名" />
<el-table-column prop="display_name" label="姓名" />
<el-table-column prop="position" label="岗位" />
<el-table-column prop="email" label="邮箱" />
<el-table-column prop="status" label="状态">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'danger'">{{ row.status === 'active' ? '正常' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="160">
<template #default="{ row }">
<el-button size="small" @click="showEditDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleToggleStatus(row)">
{{ row.status === 'active' ? '禁用' : '启用' }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑用户' : '新增用户'" width="600px">
<el-form :model="form" label-width="80px">
<el-form-item label="用户名" v-if="!isEdit">
<el-input v-model="form.username" />
</el-form-item>
<el-form-item label="姓名">
<el-input v-model="form.display_name" />
</el-form-item>
<el-form-item label="密码" v-if="!isEdit">
<el-input v-model="form.password" type="password" show-password />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="form.email" />
</el-form-item>
<el-form-item label="岗位">
<el-input v-model="form.position" />
</el-form-item>
<el-form-item label="企微ID">
<el-input v-model="form.wecom_user_id" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { orgApi } from '@/api'
const loading = ref(false)
const users = ref<any[]>([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const editingUser = ref<any>(null)
const form = ref<any>({})
onMounted(async () => {
await loadUsers()
})
async function loadUsers() {
loading.value = true
try {
const res: any = await orgApi.getUsers()
users.value = res || []
} finally {
loading.value = false
}
}
function showAddDialog() {
isEdit.value = false
editingUser.value = null
form.value = { username: '', display_name: '', password: '123456', email: '', position: '', wecom_user_id: '' }
dialogVisible.value = true
}
function showEditDialog(row: any) {
isEdit.value = true
editingUser.value = row
form.value = {
display_name: row.display_name,
email: row.email,
position: row.position,
wecom_user_id: row.wecom_user_id,
}
dialogVisible.value = true
}
async function handleSubmit() {
try {
if (isEdit.value) {
await orgApi.updateUser(editingUser.value.id, form.value)
ElMessage.success('更新成功')
} else {
await orgApi.createUser(form.value)
ElMessage.success('创建成功')
}
dialogVisible.value = false
await loadUsers()
} catch { /**/ }
}
async function handleToggleStatus(row: any) {
const newStatus = row.status === 'active' ? 'inactive' : 'active'
await orgApi.updateUser(row.id, { status: newStatus })
await loadUsers()
ElMessage.success('操作成功')
}
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

87
frontend/src/views/role/PermissionConfig.vue

@ -0,0 +1,87 @@
<template>
<div class="perm-page">
<el-card>
<template #header>
<div class="card-header">
<span>权限配置 - {{ roleName }}</span>
<el-button @click="$router.back()">返回角色列表</el-button>
</div>
</template>
<el-descriptions :column="2" border style="margin-bottom: 20px">
<el-descriptions-item label="角色">{{ roleName }}</el-descriptions-item>
<el-descriptions-item label="数据范围">
<el-tag>{{ dataScopeLabel(currentRole?.data_scope) }}</el-tag>
</el-descriptions-item>
</el-descriptions>
<el-card>
<template #header>功能权限</template>
<el-checkbox-group v-model="selectedPerms">
<el-row :gutter="[16, 16]">
<el-col :span="8" v-for="perm in allPerms" :key="perm.id">
<el-checkbox :value="perm.code" :label="perm.code">
{{ perm.name }}
</el-checkbox>
</el-col>
</el-row>
</el-checkbox-group>
<div style="margin-top: 20px">
<el-button type="primary" @click="savePermissions" :loading="saving">保存权限</el-button>
</div>
</el-card>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { rbacApi } from '@/api'
const route = useRoute()
const roleId = computed(() => route.params.id as string)
const currentRole = ref<any>(null)
const roleName = computed(() => currentRole.value?.name || '')
const allPerms = ref<any[]>([])
const selectedPerms = ref<string[]>([])
const saving = ref(false)
const dataScopeLabels: Record<string, string> = {
all: '全部', subordinate_only: '下属', dept_only: '部门', self_only: '仅自己',
}
function dataScopeLabel(s: string): string {
return dataScopeLabels[s] || s
}
onMounted(async () => {
const [role, perms] = await Promise.all([
rbacApi.getRole(roleId.value),
rbacApi.getPermissions(),
]) as [any, any]
currentRole.value = role
allPerms.value = perms || []
selectedPerms.value = role?.permissions || []
})
async function savePermissions() {
saving.value = true
try {
const permIds = allPerms.value
.filter((p: any) => selectedPerms.value.includes(p.code))
.map((p: any) => p.id)
await rbacApi.updateRole(roleId.value, { permission_ids: permIds })
ElMessage.success('权限已保存')
} catch { /**/ } finally {
saving.value = false
}
}
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

190
frontend/src/views/role/RoleList.vue

@ -0,0 +1,190 @@
<template>
<div class="role-page">
<el-card>
<template #header>
<div class="card-header">
<span>角色管理</span>
<el-button type="primary" @click="showAddDialog">新增角色</el-button>
</div>
</template>
<el-table :data="roles" v-loading="loading">
<el-table-column prop="name" label="角色名称" />
<el-table-column prop="code" label="角色编码" />
<el-table-column prop="description" label="描述" />
<el-table-column prop="data_scope" label="数据范围">
<template #default="{ row }">
<el-tag>{{ dataScopeLabel(row.data_scope) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="is_system" label="类型" width="100">
<template #default="{ row }">
<el-tag :type="row.is_system ? 'info' : 'success'">{{ row.is_system ? '系统' : '自定义' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="250">
<template #default="{ row }">
<el-button size="small" @click="showPermissionDialog(row)">配置权限</el-button>
<el-button size="small" @click="showEditDialog(row)">编辑</el-button>
<el-button size="small" type="danger" :disabled="row.is_system" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑角色' : '新增角色'" width="500px">
<el-form :model="form" label-width="80px">
<el-form-item label="角色名称">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="编码">
<el-input v-model="form.code" :disabled="isEdit" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" />
</el-form-item>
<el-form-item label="数据范围">
<el-select v-model="form.data_scope">
<el-option label="仅自己" value="self_only" />
<el-option label="下属" value="subordinate_only" />
<el-option label="部门" value="dept_only" />
<el-option label="全部" value="all" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
<el-dialog v-model="permDialogVisible" title="配置权限" width="600px">
<el-tree
:data="permTreeData"
show-checkbox
node-key="id"
:default-checked-keys="checkedPermIds"
:props="{ label: 'name' }"
ref="permTreeRef"
default-expand-all
/>
<template #footer>
<el-button @click="permDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handlePermSubmit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { rbacApi } from '@/api'
const loading = ref(false)
const roles = ref<any[]>([])
const allPerms = ref<any[]>([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const editingRole = ref<any>(null)
const form = ref<any>({ name: '', code: '', description: '', data_scope: 'self_only' })
const permDialogVisible = ref(false)
const permTreeRef = ref()
const checkedPermIds = ref<string[]>([])
const dataScopeLabels: Record<string, string> = {
all: '全部',
subordinate_only: '下属',
dept_only: '部门',
self_only: '仅自己',
}
function dataScopeLabel(s: string): string {
return dataScopeLabels[s] || s
}
const permTreeData = ref<any[]>([])
onMounted(async () => {
await loadRoles()
await loadPerms()
})
async function loadRoles() {
loading.value = true
try {
const res: any = await rbacApi.getRoles()
roles.value = res || []
} finally {
loading.value = false
}
}
async function loadPerms() {
const res: any = await rbacApi.getPermissions()
allPerms.value = res || []
permTreeData.value = res?.map((p: any) => ({ id: p.id, name: `${p.name} (${p.code})` })) || []
}
function showAddDialog() {
isEdit.value = false
editingRole.value = null
form.value = { name: '', code: '', description: '', data_scope: 'self_only' }
dialogVisible.value = true
}
function showEditDialog(row: any) {
isEdit.value = true
editingRole.value = row
form.value = { name: row.name, code: row.code, description: row.description, data_scope: row.data_scope }
dialogVisible.value = true
}
async function showPermissionDialog(row: any) {
editingRole.value = row
checkedPermIds.value = row.permission_ids || []
const role: any = await rbacApi.getRole(row.id)
checkedPermIds.value = allPerms.value
.filter((p: any) => role.permissions?.includes(p.code))
.map((p: any) => p.id)
permDialogVisible.value = true
}
async function handleSubmit() {
try {
if (isEdit.value) {
await rbacApi.updateRole(editingRole.value.id, form.value)
ElMessage.success('更新成功')
} else {
await rbacApi.createRole(form.value)
ElMessage.success('创建成功')
}
dialogVisible.value = false
await loadRoles()
} catch { /**/ }
}
async function handlePermSubmit() {
const checked = permTreeRef.value?.getCheckedKeys() || []
await rbacApi.updateRole(editingRole.value.id, { permission_ids: checked })
ElMessage.success('权限配置已保存')
permDialogVisible.value = false
await loadRoles()
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm('确认删除该角色?', '提示', { type: 'warning' })
await rbacApi.deleteRole(row.id)
ElMessage.success('删除成功')
await loadRoles()
} catch { /**/ }
}
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

169
frontend/src/views/system/SystemMonitor.vue

@ -0,0 +1,169 @@
<template>
<div class="system-page">
<el-page-header @back="$router.back()" content="系统监控" />
<el-row :gutter="20" style="margin-top: 20px">
<el-col :span="6" v-for="card in healthCards" :key="card.label">
<el-card class="stat-card">
<div class="card-content">
<div class="card-icon" :style="{ backgroundColor: card.color }">
<el-icon :size="28"><component :is="card.icon" /></el-icon>
</div>
<div class="card-info">
<div class="card-value">{{ card.value }}</div>
<div class="card-title">{{ card.label }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 20px">
<el-col :span="12">
<el-card>
<template #header>系统指标</template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="服务状态">
<el-tag :type="health?.status === 'healthy' ? 'success' : 'warning'">
{{ health?.status || '-' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="运行时间">{{ formatUptime(health?.uptime_seconds) }}</el-descriptions-item>
<el-descriptions-item label="数据库">
<el-tag :type="health?.db_connected ? 'success' : 'danger'" size="small">
{{ health?.db_connected ? '正常' : '异常' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="Redis">
<el-tag :type="health?.redis_connected ? 'success' : 'danger'" size="small">
{{ health?.redis_connected ? '正常' : '不可用' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="内存使用">{{ health?.memory_mb }} MB</el-descriptions-item>
<el-descriptions-item label="CPU">{{ health?.cpu_percent }}%</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<template #header>使用统计</template>
<el-row :gutter="12">
<el-col :span="12" v-for="s in usageItems" :key="s.label">
<div style="text-align: center; padding: 16px">
<div style="font-size: 28px; font-weight: bold; color: #409EFF">{{ s.value }}</div>
<div style="font-size: 13px; color: #909399; margin-top: 4px">{{ s.label }}</div>
</div>
</el-col>
</el-row>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 20px">
<el-col :span="12">
<el-card>
<template #header>
<div class="card-header">
<span>缓存状态</span>
<el-button size="small" @click="handleClearCache">清除所有缓存</el-button>
</div>
</template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="Redis可用">
<el-tag :type="cacheStats?.redis_available ? 'success' : 'info'" size="small">
{{ cacheStats?.redis_available ? '可用' : '降级到本地' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<template #header>快速操作</template>
<el-space>
<el-button @click="refreshAll"><el-icon><Refresh /></el-icon> </el-button>
<el-button type="warning" @click="handleClearCache">清除缓存</el-button>
</el-space>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { systemApi } from '@/api'
import { Connection, Memo, Timer, VideoCamera, Refresh } from '@element-plus/icons-vue'
const health = ref<any>({})
const stats = ref<any>({})
const cacheStats = ref<any>({})
const healthCards = computed(() => [
{ label: '活跃用户', value: stats.value?.active_users_today ?? '-', icon: Connection, color: '#409EFF' },
{ label: '总任务数', value: stats.value?.total_tasks ?? '-', icon: Memo, color: '#67C23A' },
{ label: '总流数量', value: stats.value?.total_flows ?? '-', icon: Timer, color: '#E6A23C' },
{ label: '已上架流', value: stats.value?.published_flows ?? '-', icon: VideoCamera, color: '#F56C6C' },
])
const usageItems = computed(() => [
{ label: '总用户', value: stats.value?.total_users ?? '-' },
{ label: '总会话', value: stats.value?.total_sessions ?? '-' },
{ label: '总消息', value: stats.value?.total_messages ?? '-' },
{ label: '今日API调用', value: stats.value?.api_calls_today ?? '-' },
])
function formatUptime(sec: number | undefined): string {
if (!sec) return '-'
const h = Math.floor(sec / 3600)
const m = Math.floor((sec % 3600) / 60)
return `${h}h ${m}m`
}
onMounted(() => refreshAll())
async function refreshAll() {
try {
const [h, s, c] = await Promise.all([
systemApi.getHealth(),
systemApi.getStats(),
systemApi.getCacheStats(),
]) as [any, any, any]
health.value = h?.data || h || {}
stats.value = s?.data || s || {}
cacheStats.value = c?.data || c || {}
} catch { /**/ }
}
async function handleClearCache() {
try {
await ElMessageBox.confirm('确认清除所有缓存?', '提示', { type: 'warning' })
await systemApi.clearCache()
ElMessage.success('缓存已清除')
await refreshAll()
} catch { /**/ }
}
</script>
<style scoped>
.stat-card .card-content {
display: flex;
align-items: center;
gap: 16px;
}
.card-icon {
width: 56px; height: 56px;
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
color: #fff;
}
.card-info .card-value { font-size: 24px; font-weight: bold; }
.card-info .card-title { font-size: 13px; color: #909399; }
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

79
frontend/src/views/task/TaskCreate.vue

@ -0,0 +1,79 @@
<template>
<div class="task-create-page">
<el-page-header @back="$router.back()" content="创建任务" />
<el-card style="margin-top: 20px; max-width: 800px">
<el-form :model="form" label-width="100px">
<el-form-item label="任务标题" required>
<el-input v-model="form.title" placeholder="请输入任务标题" />
</el-form-item>
<el-form-item label="任务内容">
<el-input v-model="form.content" type="textarea" :rows="6" placeholder="请输入任务详情" />
</el-form-item>
<el-form-item label="指派给" required>
<el-select v-model="form.assignee_id" placeholder="选择员工" filterable style="width: 100%">
<el-option v-for="u in subordinates" :key="u.id" :label="u.display_name" :value="u.id" />
</el-select>
</el-form-item>
<el-form-item label="优先级">
<el-radio-group v-model="form.priority">
<el-radio-button value="low"></el-radio-button>
<el-radio-button value="normal"></el-radio-button>
<el-radio-button value="high"></el-radio-button>
<el-radio-button value="urgent">紧急</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="截止日期">
<el-date-picker v-model="form.deadline" type="datetime" placeholder="选择日期" style="width: 100%" />
</el-form-item>
<el-form-item label="推送企微">
<el-switch v-model="form.push_to_wecom" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleCreate" :loading="submitting">创建任务</el-button>
<el-button @click="$router.back()">取消</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { taskApi, orgApi } from '@/api'
const router = useRouter()
const submitting = ref(false)
const subordinates = ref<any[]>([])
const form = reactive({
title: '',
content: '',
assignee_id: '',
priority: 'normal',
deadline: null,
push_to_wecom: true,
})
onMounted(async () => {
const res: any = await orgApi.getSubordinates()
subordinates.value = res || []
})
async function handleCreate() {
if (!form.title || !form.assignee_id) {
ElMessage.warning('请填写任务标题并选择员工')
return
}
submitting.value = true
try {
await taskApi.createTask(form)
ElMessage.success('任务已创建')
router.push('/task/list')
} finally {
submitting.value = false
}
}
</script>

66
frontend/src/views/task/TaskDetail.vue

@ -0,0 +1,66 @@
<template>
<div class="task-detail-page">
<el-page-header @back="$router.back()" :content="task?.title || '任务详情'" />
<el-card style="margin-top: 20px" v-if="task">
<el-descriptions :column="2" border>
<el-descriptions-item label="标题">{{ task.title }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag>{{ statusLabel(task.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="优先级">
<el-tag :type="priorityType(task.priority)">{{ priorityLabel(task.priority) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="截止日期">{{ task.deadline ? new Date(task.deadline).toLocaleDateString() : '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ task.created_at ? new Date(task.created_at).toLocaleDateString() : '-' }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ task.updated_at ? new Date(task.updated_at).toLocaleDateString() : '-' }}</el-descriptions-item>
<el-descriptions-item label="内容" :span="2">{{ task.content || '无' }}</el-descriptions-item>
</el-descriptions>
<div style="margin-top: 20px">
<el-select v-model="newStatus" style="width: 200px; margin-right: 12px">
<el-option label="待处理" value="pending" />
<el-option label="进行中" value="in_progress" />
<el-option label="已完成" value="completed" />
<el-option label="已取消" value="cancelled" />
</el-select>
<el-button type="primary" @click="updateStatus">更新状态</el-button>
<el-button @click="handlePush">推送到企微</el-button>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { taskApi } from '@/api'
const route = useRoute()
const taskId = computed(() => route.params.id as string)
const task = ref<any>(null)
const newStatus = ref('pending')
const statusLabel = (s: string) => ({ pending: '待处理', in_progress: '进行中', completed: '已完成', cancelled: '已取消' } as any)[s] || s
const priorityLabel = (p: string) => ({ low: '低', normal: '中', high: '高', urgent: '紧急' } as any)[p] || p
const priorityType = (p: string) => ({ low: 'info', normal: '', high: 'warning', urgent: 'danger' } as any)[p] || ''
onMounted(async () => {
const res: any = await taskApi.getTask(taskId.value)
task.value = res
newStatus.value = res.status
})
async function updateStatus() {
await taskApi.updateTask(taskId.value, { status: newStatus.value })
ElMessage.success('状态已更新')
const res: any = await taskApi.getTask(taskId.value)
task.value = res
}
async function handlePush() {
await taskApi.pushTask(taskId.value)
ElMessage.success('已推送到企微')
}
</script>

74
frontend/src/views/task/TaskList.vue

@ -0,0 +1,74 @@
<template>
<div class="task-page">
<el-card>
<template #header>
<div class="card-header">
<span>任务列表</span>
<el-button type="primary" @click="$router.push('/task/create')" v-if="userStore.hasPermission('task:create')">创建任务</el-button>
</div>
</template>
<el-table :data="tasks" v-loading="loading">
<el-table-column prop="title" label="标题" min-width="200" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="priority" label="优先级" width="100">
<template #default="{ row }">
<el-tag :type="priorityType(row.priority)">{{ priorityLabel(row.priority) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="deadline" label="截止日期" width="180">
<template #default="{ row }">{{ row.deadline ? new Date(row.deadline).toLocaleDateString() : '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button size="small" @click="$router.push(`/task/${row.id}`)">详情</el-button>
<el-button size="small" type="warning" @click="handlePush(row)">推送企微</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { taskApi } from '@/api'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const loading = ref(false)
const tasks = ref<any[]>([])
const statusLabel = (s: string) => ({ pending: '待处理', in_progress: '进行中', completed: '已完成', cancelled: '已取消' } as any)[s] || s
const statusType = (s: string) => ({ pending: 'info', in_progress: 'warning', completed: 'success', cancelled: 'danger' } as any)[s] || 'info'
const priorityLabel = (p: string) => ({ low: '低', normal: '中', high: '高', urgent: '紧急' } as any)[p] || p
const priorityType = (p: string) => ({ low: 'info', normal: '', high: 'warning', urgent: 'danger' } as any)[p] || ''
onMounted(async () => {
loading.value = true
try {
const res: any = await taskApi.getTasks()
tasks.value = res || []
} finally {
loading.value = false
}
})
async function handlePush(row: any) {
await taskApi.pushTask(row.id)
ElMessage.success('已推送')
}
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

64
frontend/src/views/wecom/BotConfig.vue

@ -0,0 +1,64 @@
<template>
<div class="wecom-page">
<el-card>
<template #header>
<span>企业微信配置</span>
</template>
<el-descriptions :column="1" border>
<el-descriptions-item label="机器人名称">{{ config?.bot_name || '企业AI助手' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag type="success">已配置</el-tag>
</el-descriptions-item>
<el-descriptions-item label="支持功能">
<el-tag v-for="f in config?.features" :key="f" style="margin-right: 8px">{{ f }}</el-tag>
</el-descriptions-item>
</el-descriptions>
<el-divider />
<el-form label-width="120px">
<el-alert
title="企业微信集成"
type="info"
description="请在企业微信管理后台创建应用,获取 CorpID、Secret,并配置回调URL指向本服务的 /api/wecom/callback"
show-icon
:closable="false"
style="margin-bottom: 20px"
/>
<el-form-item label="CorpID">
<el-input placeholder="企业微信 CorpID" />
</el-form-item>
<el-form-item label="Secret">
<el-input type="password" show-password placeholder="应用 Secret" />
</el-form-item>
<el-form-item label="Token">
<el-input placeholder="回调 Token" />
</el-form-item>
<el-form-item label="AES Key">
<el-input placeholder="消息加密 AES Key" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveConfig">保存配置</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { wecomApi } from '@/api'
const config = ref<any>(null)
onMounted(async () => {
const res: any = await wecomApi.getConfig()
config.value = res?.data || res || {}
})
function saveConfig() {
ElMessage.success('配置已保存')
}
</script>

3
frontend/tsconfig.app.json

@ -0,0 +1,3 @@
{
"extends": "./tsconfig.json"
}

24
frontend/tsconfig.json

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "env.d.ts"]
}

21
frontend/vite.config.ts

@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})

239
init-db/01-init.sql

@ -0,0 +1,239 @@
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE departments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL,
parent_id UUID REFERENCES departments(id),
path VARCHAR(500) NOT NULL DEFAULT '/',
level INT NOT NULL DEFAULT 0,
sort_order INT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(100) NOT NULL,
email VARCHAR(100),
phone VARCHAR(20),
wecom_user_id VARCHAR(100) UNIQUE,
department_id UUID REFERENCES departments(id),
position VARCHAR(100),
manager_id UUID REFERENCES users(id),
status VARCHAR(20) DEFAULT 'active',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE roles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(50) UNIQUE NOT NULL,
code VARCHAR(50) UNIQUE NOT NULL,
description VARCHAR(200),
is_system BOOLEAN DEFAULT FALSE,
data_scope VARCHAR(50) DEFAULT 'self_only',
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE permissions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
code VARCHAR(100) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
resource VARCHAR(100) NOT NULL,
action VARCHAR(50) NOT NULL,
description VARCHAR(200)
);
CREATE TABLE role_permissions (
role_id UUID REFERENCES roles(id) ON DELETE CASCADE,
permission_id UUID REFERENCES permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE user_roles (
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
role_id UUID REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
CREATE TABLE chat_sessions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
agent_type VARCHAR(50) NOT NULL,
session_id VARCHAR(100) UNIQUE NOT NULL,
status VARCHAR(20) DEFAULT 'active',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE chat_messages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
session_id UUID REFERENCES chat_sessions(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL,
content TEXT NOT NULL,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title VARCHAR(200) NOT NULL,
content TEXT,
assigner_id UUID REFERENCES users(id),
assignee_id UUID REFERENCES users(id) NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
priority VARCHAR(20) DEFAULT 'normal',
deadline TIMESTAMP,
wecom_message_id VARCHAR(100),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE flow_definitions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(200) NOT NULL,
description TEXT,
version INT DEFAULT 1,
status VARCHAR(20) DEFAULT 'draft',
definition_json JSONB NOT NULL,
creator_id UUID REFERENCES users(id),
published_to_wecom BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE flow_executions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
flow_id UUID REFERENCES flow_definitions(id) ON DELETE CASCADE,
trigger_type VARCHAR(50),
trigger_user_id UUID REFERENCES users(id),
input_data JSONB,
output_data JSONB,
status VARCHAR(20) DEFAULT 'running',
started_at TIMESTAMP DEFAULT NOW(),
finished_at TIMESTAMP
);
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
operator_id UUID REFERENCES users(id),
action VARCHAR(100) NOT NULL,
resource VARCHAR(100),
resource_id VARCHAR(100),
detail JSONB DEFAULT '{}',
ip_address VARCHAR(50),
created_at TIMESTAMP DEFAULT NOW()
);
-- ============================================================
-- 种子数据
-- ============================================================
INSERT INTO departments (id, name, path, level, sort_order) VALUES
('00000000-0000-0000-0000-000000000001', '公司', '/公司', 0, 0),
('00000000-0000-0000-0000-000000000002', '技术部', '/公司/技术部', 1, 1),
('00000000-0000-0000-0000-000000000003', '市场部', '/公司/市场部', 1, 2),
('00000000-0000-0000-0000-000000000004', '人事部', '/公司/人事部', 1, 3);
INSERT INTO roles (id, name, code, description, is_system, data_scope) VALUES
('10000000-0000-0000-0000-000000000001', '超级管理员', 'super_admin', '全部功能权限', TRUE, 'all'),
('10000000-0000-0000-0000-000000000002', '部门经理', 'dept_manager', '部门管理权限', TRUE, 'subordinate_only'),
('10000000-0000-0000-0000-000000000003', '组长', 'team_lead', '组管理权限', TRUE, 'subordinate_only'),
('10000000-0000-0000-0000-000000000004', '普通员工', 'employee', '基础权限', TRUE, 'self_only'),
('10000000-0000-0000-0000-000000000005', '工作流编辑', 'workflow_editor', '流编排权限', TRUE, 'self_only');
INSERT INTO permissions (id, code, name, resource, action) VALUES
('20000000-0000-0000-0000-000000000001', 'user:create', '创建用户', 'user', 'create'),
('20000000-0000-0000-0000-000000000002', 'user:read', '查看用户', 'user', 'read'),
('20000000-0000-0000-0000-000000000003', 'user:update', '更新用户', 'user', 'update'),
('20000000-0000-0000-0000-000000000004', 'user:delete', '删除用户', 'user', 'delete'),
('20000000-0000-0000-0000-000000000005', 'dept:read', '查看部门', 'department', 'read'),
('20000000-0000-0000-0000-000000000006', 'dept:create', '创建部门', 'department', 'create'),
('20000000-0000-0000-0000-000000000007', 'role:read', '查看角色', 'role', 'read'),
('20000000-0000-0000-0000-000000000008', 'role:update', '更新角色权限', 'role', 'update'),
('20000000-0000-0000-0000-000000000009', 'monitor:read', '查看工作监控', 'monitor', 'read'),
('20000000-0000-0000-0000-000000000010', 'analysis:read', '查看AI分析报告', 'analysis', 'read'),
('20000000-0000-0000-0000-000000000011', 'task:create', '创建任务', 'task', 'create'),
('20000000-0000-0000-0000-000000000012', 'task:read', '查看任务', 'task', 'read'),
('20000000-0000-0000-0000-000000000013', 'flow:create', '创建流', 'flow', 'create'),
('20000000-0000-0000-0000-000000000014', 'flow:update', '更新流', 'flow', 'update'),
('20000000-0000-0000-0000-000000000015', 'flow:read', '查看流', 'flow', 'read'),
('20000000-0000-0000-0000-000000000016', 'flow:publish', '上架流', 'flow', 'publish'),
('20000000-0000-0000-0000-000000000017', 'audit:read', '查看审计日志', 'audit', 'read'),
('20000000-0000-0000-0000-000000000018', 'self:read', '查看个人信息', 'self', 'read');
-- super_admin: all permissions
INSERT INTO role_permissions (role_id, permission_id)
SELECT '10000000-0000-0000-0000-000000000001', id FROM permissions;
-- dept_manager
INSERT INTO role_permissions (role_id, permission_id) VALUES
('10000000-0000-0000-0000-000000000002', '20000000-0000-0000-0000-000000000009'),
('10000000-0000-0000-0000-000000000002', '20000000-0000-0000-0000-000000000010'),
('10000000-0000-0000-0000-000000000002', '20000000-0000-0000-0000-000000000011'),
('10000000-0000-0000-0000-000000000002', '20000000-0000-0000-0000-000000000012');
-- team_lead
INSERT INTO role_permissions (role_id, permission_id) VALUES
('10000000-0000-0000-0000-000000000003', '20000000-0000-0000-0000-000000000009'),
('10000000-0000-0000-0000-000000000003', '20000000-0000-0000-0000-000000000011'),
('10000000-0000-0000-0000-000000000003', '20000000-0000-0000-0000-000000000012');
-- employee
INSERT INTO role_permissions (role_id, permission_id) VALUES
('10000000-0000-0000-0000-000000000004', '20000000-0000-0000-0000-000000000018'),
('10000000-0000-0000-0000-000000000004', '20000000-0000-0000-0000-000000000012');
-- workflow_editor
INSERT INTO role_permissions (role_id, permission_id) VALUES
('10000000-0000-0000-0000-000000000005', '20000000-0000-0000-0000-000000000013'),
('10000000-0000-0000-0000-000000000005', '20000000-0000-0000-0000-000000000014'),
('10000000-0000-0000-0000-000000000005', '20000000-0000-0000-0000-000000000015'),
('10000000-0000-0000-0000-000000000005', '20000000-0000-0000-0000-000000000016');
-- 默认用户 (密码: admin123)
INSERT INTO users (id, username, password_hash, display_name, department_id, position, status) VALUES
('30000000-0000-0000-0000-000000000001', 'admin', '$2b$12$LJ3m4ys3Lk0TSwHCpNqrAODgL4A.Y6FuRzOEDx4eCEoQIq.Z/EZ2y', '系统管理员', '00000000-0000-0000-0000-000000000001', '管理员', 'active');
INSERT INTO user_roles (user_id, role_id) VALUES
('30000000-0000-0000-0000-000000000001', '10000000-0000-0000-0000-000000000001');
-- MCP 服务注册表
CREATE TABLE IF NOT EXISTS mcp_services (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) UNIQUE NOT NULL,
transport VARCHAR(20) DEFAULT 'http',
url VARCHAR(500),
command VARCHAR(500),
args JSONB DEFAULT '[]',
env JSONB DEFAULT '{}',
status VARCHAR(20) DEFAULT 'disconnected',
tools JSONB DEFAULT '[]',
creator_id UUID REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- 通知模板表
CREATE TABLE IF NOT EXISTS notification_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
code VARCHAR(100) UNIQUE NOT NULL,
channel VARCHAR(20) DEFAULT 'wecom',
title_template VARCHAR(500),
body_template TEXT NOT NULL,
variables JSONB DEFAULT '[]',
is_system BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
);
-- 系统指标表
CREATE TABLE IF NOT EXISTS system_metrics (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
metric_type VARCHAR(50) NOT NULL,
value JSONB NOT NULL,
collected_at TIMESTAMP DEFAULT NOW()
);

45
nginx/nginx.conf

@ -0,0 +1,45 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
upstream backend_api {
server backend:8000;
}
upstream frontend_app {
server frontend:80;
}
server {
listen 80;
location / {
proxy_pass http://frontend_app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /api/ {
proxy_pass http://backend_api/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s;
}
location /wecom/ {
proxy_pass http://backend_api/wecom/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
}
Loading…
Cancel
Save