93 changed files with 10459 additions and 0 deletions
@ -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= |
|||
@ -0,0 +1,2 @@ |
|||
ali-agentscope-src/ |
|||
.env |
|||
File diff suppressed because it is too large
@ -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,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,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,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,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"] |
|||
@ -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]}..." |
|||
@ -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() |
|||
@ -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() |
|||
@ -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 |
|||
@ -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,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 |
|||
@ -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 |
|||
@ -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) |
|||
@ -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,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,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,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,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,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 |
|||
@ -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,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,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,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,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,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,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,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,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": ["消息对话", "文件处理", "任务通知", "工作流触发"], |
|||
}, |
|||
} |
|||
@ -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 |
|||
@ -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 |
|||
@ -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: |
|||
@ -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 |
|||
@ -0,0 +1,7 @@ |
|||
/// <reference types="vite/client" />
|
|||
|
|||
declare module '*.vue' { |
|||
import type { DefineComponent } from 'vue' |
|||
const component: DefineComponent<{}, {}, any> |
|||
export default component |
|||
} |
|||
@ -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> |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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" |
|||
} |
|||
} |
|||
@ -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> |
|||
@ -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'), |
|||
} |
|||
@ -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> |
|||
@ -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> |
|||
@ -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') |
|||
@ -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 |
|||
@ -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 } |
|||
}) |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -0,0 +1,3 @@ |
|||
{ |
|||
"extends": "./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"] |
|||
} |
|||
@ -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, |
|||
}, |
|||
}, |
|||
}, |
|||
}) |
|||
@ -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() |
|||
); |
|||
@ -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…
Reference in new issue