Compare commits
3 Commits
204cb223a3
...
a9ddf3c9b4
| Author | SHA1 | Date | |
|---|---|---|---|
| a9ddf3c9b4 | |||
| b024a2bcb5 | |||
| a27736a832 |
@@ -1,11 +1,12 @@
|
|||||||
from fastapi import APIRouter, Depends, Query
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Any, Optional
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.routers.auth import get_current_user
|
from app.routers.auth import get_current_user
|
||||||
from app.services.log_service import LogService
|
from app.services.log_service import LogService, parse_datetime_filter, serialize_log
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/logs", tags=["Log"])
|
router = APIRouter(prefix="/api/logs", tags=["Log"])
|
||||||
|
|
||||||
@@ -15,14 +16,18 @@ class LogOut(BaseModel):
|
|||||||
level: str
|
level: str
|
||||||
type: str
|
type: str
|
||||||
user_id: Optional[str]
|
user_id: Optional[str]
|
||||||
|
request_id: Optional[str]
|
||||||
|
route: Optional[str]
|
||||||
|
method: Optional[str]
|
||||||
|
status_code: Optional[int]
|
||||||
|
error_type: Optional[str]
|
||||||
|
operation: Optional[str]
|
||||||
message: str
|
message: str
|
||||||
source: Optional[str]
|
source: Optional[str]
|
||||||
details: Optional[str]
|
details: Optional[dict[str, Any]]
|
||||||
duration_ms: Optional[str]
|
duration_ms: Optional[int]
|
||||||
created_at: str
|
created_at: Optional[str]
|
||||||
updated_at: str
|
updated_at: Optional[str]
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
|
|
||||||
|
|
||||||
class LogStatsOut(BaseModel):
|
class LogStatsOut(BaseModel):
|
||||||
@@ -43,12 +48,23 @@ async def list_logs(
|
|||||||
log_type: Optional[str] = Query(None, description="日志类型: agent/system/chat"),
|
log_type: Optional[str] = Query(None, description="日志类型: agent/system/chat"),
|
||||||
level: Optional[str] = Query(None, description="日志级别: debug/info/warning/error"),
|
level: Optional[str] = Query(None, description="日志级别: debug/info/warning/error"),
|
||||||
source: Optional[str] = Query(None, description="来源模块"),
|
source: Optional[str] = Query(None, description="来源模块"),
|
||||||
|
request_id: Optional[str] = Query(None, description="请求 ID"),
|
||||||
|
route: Optional[str] = Query(None, description="路由"),
|
||||||
|
operation: Optional[str] = Query(None, description="业务操作"),
|
||||||
|
status_code: Optional[int] = Query(None, description="HTTP 状态码"),
|
||||||
|
start_at: Optional[str] = Query(None, description="开始时间 ISO"),
|
||||||
|
end_at: Optional[str] = Query(None, description="结束时间 ISO"),
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
page_size: int = Query(50, ge=1, le=200),
|
page_size: int = Query(50, ge=1, le=200),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""查询日志列表"""
|
"""查询日志列表"""
|
||||||
|
start_dt = parse_datetime_filter(start_at)
|
||||||
|
end_dt = parse_datetime_filter(end_at)
|
||||||
|
if start_dt and end_dt and start_dt > end_dt:
|
||||||
|
raise HTTPException(status_code=422, detail="开始时间不能晚于结束时间")
|
||||||
|
|
||||||
svc = LogService(db)
|
svc = LogService(db)
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
logs, total = await svc.list_logs(
|
logs, total = await svc.list_logs(
|
||||||
@@ -56,11 +72,17 @@ async def list_logs(
|
|||||||
level=level,
|
level=level,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
source=source,
|
source=source,
|
||||||
|
request_id=request_id,
|
||||||
|
route=route,
|
||||||
|
operation=operation,
|
||||||
|
status_code=status_code,
|
||||||
|
start_at=start_dt,
|
||||||
|
end_at=end_dt,
|
||||||
limit=page_size,
|
limit=page_size,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
)
|
)
|
||||||
return LogQueryOut(
|
return LogQueryOut(
|
||||||
logs=[LogOut.model_validate(log) for log in logs],
|
logs=[LogOut.model_validate(serialize_log(log)) for log in logs],
|
||||||
total=total,
|
total=total,
|
||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
@@ -69,13 +91,37 @@ async def list_logs(
|
|||||||
|
|
||||||
@router.get("/stats", response_model=LogStatsOut)
|
@router.get("/stats", response_model=LogStatsOut)
|
||||||
async def get_log_stats(
|
async def get_log_stats(
|
||||||
hours: int = Query(24, ge=1, le=168),
|
log_type: Optional[str] = Query(None, description="日志类型: agent/system/chat"),
|
||||||
|
level: Optional[str] = Query(None, description="日志级别: debug/info/warning/error"),
|
||||||
|
source: Optional[str] = Query(None, description="来源模块"),
|
||||||
|
request_id: Optional[str] = Query(None, description="请求 ID"),
|
||||||
|
route: Optional[str] = Query(None, description="路由"),
|
||||||
|
operation: Optional[str] = Query(None, description="业务操作"),
|
||||||
|
status_code: Optional[int] = Query(None, description="HTTP 状态码"),
|
||||||
|
start_at: Optional[str] = Query(None, description="开始时间 ISO"),
|
||||||
|
end_at: Optional[str] = Query(None, description="结束时间 ISO"),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""获取日志统计"""
|
"""获取日志统计"""
|
||||||
|
start_dt = parse_datetime_filter(start_at)
|
||||||
|
end_dt = parse_datetime_filter(end_at)
|
||||||
|
if start_dt and end_dt and start_dt > end_dt:
|
||||||
|
raise HTTPException(status_code=422, detail="开始时间不能晚于结束时间")
|
||||||
|
|
||||||
svc = LogService(db)
|
svc = LogService(db)
|
||||||
stats = await svc.get_log_stats(hours=hours)
|
stats = await svc.get_log_stats(
|
||||||
|
log_type=log_type,
|
||||||
|
level=level,
|
||||||
|
user_id=current_user.id,
|
||||||
|
source=source,
|
||||||
|
request_id=request_id,
|
||||||
|
route=route,
|
||||||
|
operation=operation,
|
||||||
|
status_code=status_code,
|
||||||
|
start_at=start_dt,
|
||||||
|
end_at=end_dt,
|
||||||
|
)
|
||||||
return LogStatsOut(**stats)
|
return LogStatsOut(**stats)
|
||||||
|
|
||||||
|
|
||||||
@@ -89,5 +135,5 @@ async def get_recent_logs(
|
|||||||
):
|
):
|
||||||
"""获取最近的日志"""
|
"""获取最近的日志"""
|
||||||
svc = LogService(db)
|
svc = LogService(db)
|
||||||
logs = await svc.get_recent_logs(log_type=log_type, hours=hours, limit=limit)
|
logs = await svc.get_recent_logs(log_type=log_type, user_id=current_user.id, hours=hours, limit=limit)
|
||||||
return [LogOut.model_validate(log) for log in logs]
|
return [LogOut.model_validate(serialize_log(log)) for log in logs]
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Any, Optional
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, and_, desc, func
|
from sqlalchemy import select, and_, desc, func, or_
|
||||||
from app.models.log import Log, LogType, LogLevel
|
from app.models.log import Log, LogType, LogLevel
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -21,6 +21,19 @@ LEVEL_MAP = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_datetime_filter(value: Optional[str]) -> Optional[datetime]:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
normalized = value.strip()
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
normalized = normalized.replace("Z", "+00:00")
|
||||||
|
parsed = datetime.fromisoformat(normalized)
|
||||||
|
if parsed.tzinfo is not None:
|
||||||
|
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
class LogService:
|
class LogService:
|
||||||
def __init__(self, db: AsyncSession):
|
def __init__(self, db: AsyncSession):
|
||||||
self.db = db
|
self.db = db
|
||||||
@@ -34,16 +47,28 @@ class LogService:
|
|||||||
source: Optional[str] = None,
|
source: Optional[str] = None,
|
||||||
details: Optional[dict] = None,
|
details: Optional[dict] = None,
|
||||||
duration_ms: Optional[int] = None,
|
duration_ms: Optional[int] = None,
|
||||||
|
request_id: Optional[str] = None,
|
||||||
|
route: Optional[str] = None,
|
||||||
|
method: Optional[str] = None,
|
||||||
|
status_code: Optional[int] = None,
|
||||||
|
error_type: Optional[str] = None,
|
||||||
|
operation: Optional[str] = None,
|
||||||
) -> Log:
|
) -> Log:
|
||||||
"""记录日志"""
|
"""记录日志"""
|
||||||
log_entry = Log(
|
log_entry = Log(
|
||||||
level=level,
|
level=level,
|
||||||
type=log_type,
|
type=log_type,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
request_id=request_id,
|
||||||
|
route=route,
|
||||||
|
method=method,
|
||||||
|
status_code=status_code,
|
||||||
|
error_type=error_type,
|
||||||
|
operation=operation,
|
||||||
message=message,
|
message=message,
|
||||||
source=source,
|
source=source,
|
||||||
details=json.dumps(details, ensure_ascii=False) if details else None,
|
details=json.dumps(details, ensure_ascii=False) if details is not None else None,
|
||||||
duration_ms=str(duration_ms) if duration_ms else None,
|
duration_ms=int(duration_ms) if duration_ms is not None else None,
|
||||||
)
|
)
|
||||||
self.db.add(log_entry)
|
self.db.add(log_entry)
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
@@ -75,15 +100,30 @@ class LogService:
|
|||||||
level: str = "info",
|
level: str = "info",
|
||||||
source: Optional[str] = None,
|
source: Optional[str] = None,
|
||||||
details: Optional[dict] = None,
|
details: Optional[dict] = None,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
request_id: Optional[str] = None,
|
||||||
|
route: Optional[str] = None,
|
||||||
|
method: Optional[str] = None,
|
||||||
|
status_code: Optional[int] = None,
|
||||||
|
error_type: Optional[str] = None,
|
||||||
|
operation: Optional[str] = None,
|
||||||
|
duration_ms: Optional[int] = None,
|
||||||
) -> Log:
|
) -> Log:
|
||||||
"""记录系统运行日志"""
|
"""记录系统运行日志"""
|
||||||
return await self.log(
|
return await self.log(
|
||||||
message=message,
|
message=message,
|
||||||
level=level,
|
level=level,
|
||||||
log_type="system",
|
log_type="system",
|
||||||
user_id=None,
|
user_id=user_id,
|
||||||
source=source,
|
source=source,
|
||||||
details=details,
|
details=details,
|
||||||
|
request_id=request_id,
|
||||||
|
route=route,
|
||||||
|
method=method,
|
||||||
|
status_code=status_code,
|
||||||
|
error_type=error_type,
|
||||||
|
operation=operation,
|
||||||
|
duration_ms=duration_ms,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def chat_log(
|
async def chat_log(
|
||||||
@@ -104,12 +144,56 @@ class LogService:
|
|||||||
duration_ms=duration_ms,
|
duration_ms=duration_ms,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _build_conditions(
|
||||||
|
self,
|
||||||
|
log_type: Optional[str] = None,
|
||||||
|
level: Optional[str] = None,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
source: Optional[str] = None,
|
||||||
|
request_id: Optional[str] = None,
|
||||||
|
route: Optional[str] = None,
|
||||||
|
operation: Optional[str] = None,
|
||||||
|
status_code: Optional[int] = None,
|
||||||
|
start_at: Optional[datetime] = None,
|
||||||
|
end_at: Optional[datetime] = None,
|
||||||
|
) -> list[Any]:
|
||||||
|
conditions = []
|
||||||
|
|
||||||
|
if log_type:
|
||||||
|
conditions.append(Log.type == log_type)
|
||||||
|
if level:
|
||||||
|
conditions.append(Log.level == level)
|
||||||
|
if user_id:
|
||||||
|
conditions.append(or_(Log.user_id == user_id, Log.user_id.is_(None)))
|
||||||
|
if source:
|
||||||
|
conditions.append(Log.source == source)
|
||||||
|
if request_id:
|
||||||
|
conditions.append(Log.request_id == request_id)
|
||||||
|
if route:
|
||||||
|
conditions.append(Log.route == route)
|
||||||
|
if operation:
|
||||||
|
conditions.append(Log.operation == operation)
|
||||||
|
if status_code is not None:
|
||||||
|
conditions.append(Log.status_code == status_code)
|
||||||
|
if start_at is not None:
|
||||||
|
conditions.append(Log.created_at >= start_at)
|
||||||
|
if end_at is not None:
|
||||||
|
conditions.append(Log.created_at <= end_at)
|
||||||
|
|
||||||
|
return conditions
|
||||||
|
|
||||||
async def list_logs(
|
async def list_logs(
|
||||||
self,
|
self,
|
||||||
log_type: Optional[str] = None,
|
log_type: Optional[str] = None,
|
||||||
level: Optional[str] = None,
|
level: Optional[str] = None,
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
source: Optional[str] = None,
|
source: Optional[str] = None,
|
||||||
|
request_id: Optional[str] = None,
|
||||||
|
route: Optional[str] = None,
|
||||||
|
operation: Optional[str] = None,
|
||||||
|
status_code: Optional[int] = None,
|
||||||
|
start_at: Optional[datetime] = None,
|
||||||
|
end_at: Optional[datetime] = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
) -> tuple[list[Log], int]:
|
) -> tuple[list[Log], int]:
|
||||||
@@ -119,28 +203,27 @@ class LogService:
|
|||||||
Returns:
|
Returns:
|
||||||
(logs, total_count)
|
(logs, total_count)
|
||||||
"""
|
"""
|
||||||
conditions = []
|
conditions = self._build_conditions(
|
||||||
|
log_type=log_type,
|
||||||
|
level=level,
|
||||||
|
user_id=user_id,
|
||||||
|
source=source,
|
||||||
|
request_id=request_id,
|
||||||
|
route=route,
|
||||||
|
operation=operation,
|
||||||
|
status_code=status_code,
|
||||||
|
start_at=start_at,
|
||||||
|
end_at=end_at,
|
||||||
|
)
|
||||||
|
|
||||||
if log_type:
|
|
||||||
conditions.append(Log.type == log_type)
|
|
||||||
if level:
|
|
||||||
conditions.append(Log.level == level)
|
|
||||||
if user_id:
|
|
||||||
conditions.append(Log.user_id == user_id)
|
|
||||||
if source:
|
|
||||||
conditions.append(Log.source == source)
|
|
||||||
|
|
||||||
# 统计总数
|
|
||||||
count_query = select(func.count(Log.id))
|
count_query = select(func.count(Log.id))
|
||||||
if conditions:
|
if conditions:
|
||||||
count_query = count_query.where(and_(*conditions))
|
count_query = count_query.where(and_(*conditions))
|
||||||
total_result = await self.db.execute(count_query)
|
total_result = await self.db.execute(count_query)
|
||||||
total = total_result.scalar() or 0
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
# 查询列表
|
|
||||||
query = (
|
query = (
|
||||||
select(Log)
|
select(Log).where(and_(*conditions)) if conditions else select(Log)
|
||||||
.where(and_(*conditions)) if conditions else select(Log)
|
|
||||||
).order_by(desc(Log.created_at)).limit(limit).offset(offset)
|
).order_by(desc(Log.created_at)).limit(limit).offset(offset)
|
||||||
|
|
||||||
result = await self.db.execute(query)
|
result = await self.db.execute(query)
|
||||||
@@ -151,28 +234,48 @@ class LogService:
|
|||||||
async def get_recent_logs(
|
async def get_recent_logs(
|
||||||
self,
|
self,
|
||||||
log_type: Optional[str] = None,
|
log_type: Optional[str] = None,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
hours: int = 24,
|
hours: int = 24,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
) -> list[Log]:
|
) -> list[Log]:
|
||||||
"""获取最近的日志"""
|
"""获取最近的日志"""
|
||||||
since = datetime.utcnow() - timedelta(hours=hours)
|
end_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
conditions = [Log.created_at >= since]
|
start_at = end_at - timedelta(hours=hours)
|
||||||
|
conditions = self._build_conditions(
|
||||||
if log_type:
|
log_type=log_type,
|
||||||
conditions.append(Log.type == log_type)
|
user_id=user_id,
|
||||||
|
start_at=start_at,
|
||||||
query = (
|
end_at=end_at,
|
||||||
select(Log)
|
|
||||||
.where(and_(*conditions))
|
|
||||||
.order_by(desc(Log.created_at))
|
|
||||||
.limit(limit)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
query = select(Log).where(and_(*conditions)).order_by(desc(Log.created_at)).limit(limit)
|
||||||
result = await self.db.execute(query)
|
result = await self.db.execute(query)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
async def get_log_stats(self, hours: int = 24) -> dict:
|
async def get_log_stats(
|
||||||
|
self,
|
||||||
|
log_type: Optional[str] = None,
|
||||||
|
level: Optional[str] = None,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
source: Optional[str] = None,
|
||||||
|
request_id: Optional[str] = None,
|
||||||
|
route: Optional[str] = None,
|
||||||
|
operation: Optional[str] = None,
|
||||||
|
status_code: Optional[int] = None,
|
||||||
|
start_at: Optional[datetime] = None,
|
||||||
|
end_at: Optional[datetime] = None,
|
||||||
|
) -> dict:
|
||||||
"""获取日志统计"""
|
"""获取日志统计"""
|
||||||
since = datetime.utcnow() - timedelta(hours=hours)
|
base_conditions = self._build_conditions(
|
||||||
|
user_id=user_id,
|
||||||
|
source=source,
|
||||||
|
request_id=request_id,
|
||||||
|
route=route,
|
||||||
|
operation=operation,
|
||||||
|
status_code=status_code,
|
||||||
|
start_at=start_at,
|
||||||
|
end_at=end_at,
|
||||||
|
)
|
||||||
|
|
||||||
stats = {
|
stats = {
|
||||||
"total": 0,
|
"total": 0,
|
||||||
@@ -180,83 +283,59 @@ class LogService:
|
|||||||
"by_level": {"debug": 0, "info": 0, "warning": 0, "error": 0},
|
"by_level": {"debug": 0, "info": 0, "warning": 0, "error": 0},
|
||||||
}
|
}
|
||||||
|
|
||||||
# 按类型统计
|
total_conditions = list(base_conditions)
|
||||||
for log_type in ["agent", "system", "chat"]:
|
if log_type:
|
||||||
query = select(func.count(Log.id)).where(
|
total_conditions.append(Log.type == log_type)
|
||||||
and_(Log.type == log_type, Log.created_at >= since)
|
if level:
|
||||||
)
|
total_conditions.append(Log.level == level)
|
||||||
result = await self.db.execute(query)
|
total_query = select(func.count(Log.id)).where(and_(*total_conditions))
|
||||||
count = result.scalar() or 0
|
total_result = await self.db.execute(total_query)
|
||||||
stats["by_type"][log_type] = count
|
stats["total"] = total_result.scalar() or 0
|
||||||
stats["total"] += count
|
|
||||||
|
|
||||||
# 按级别统计
|
for current_type in ["agent", "system", "chat"]:
|
||||||
for level in ["debug", "info", "warning", "error"]:
|
conditions = list(base_conditions)
|
||||||
query = select(func.count(Log.id)).where(
|
conditions.append(Log.type == current_type)
|
||||||
and_(Log.level == level, Log.created_at >= since)
|
if level:
|
||||||
)
|
conditions.append(Log.level == level)
|
||||||
|
query = select(func.count(Log.id)).where(and_(*conditions))
|
||||||
result = await self.db.execute(query)
|
result = await self.db.execute(query)
|
||||||
stats["by_level"][level] = result.scalar() or 0
|
stats["by_type"][current_type] = result.scalar() or 0
|
||||||
|
|
||||||
|
for current_level in ["debug", "info", "warning", "error"]:
|
||||||
|
conditions = list(base_conditions)
|
||||||
|
if log_type:
|
||||||
|
conditions.append(Log.type == log_type)
|
||||||
|
conditions.append(Log.level == current_level)
|
||||||
|
query = select(func.count(Log.id)).where(and_(*conditions))
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
stats["by_level"][current_level] = result.scalar() or 0
|
||||||
|
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
|
|
||||||
# 全局日志记录函数,方便各处调用
|
def serialize_log(log: Log) -> dict[str, Any]:
|
||||||
_global_db_session = None
|
details = None
|
||||||
|
if log.details:
|
||||||
|
|
||||||
def set_log_session(db: AsyncSession):
|
|
||||||
"""设置全局日志会话"""
|
|
||||||
global _global_db_session
|
|
||||||
_global_db_session = db
|
|
||||||
|
|
||||||
|
|
||||||
def get_log_session() -> Optional[AsyncSession]:
|
|
||||||
"""获取全局日志会话"""
|
|
||||||
return _global_db_session
|
|
||||||
|
|
||||||
|
|
||||||
async def log_agent_event(
|
|
||||||
message: str,
|
|
||||||
user_id: Optional[str] = None,
|
|
||||||
source: Optional[str] = None,
|
|
||||||
details: Optional[dict] = None,
|
|
||||||
duration_ms: Optional[int] = None,
|
|
||||||
):
|
|
||||||
"""记录智能体事件到数据库"""
|
|
||||||
if _global_db_session:
|
|
||||||
try:
|
try:
|
||||||
svc = LogService(_global_db_session)
|
details = json.loads(log.details)
|
||||||
await svc.agent_log(message, user_id, source, details, duration_ms)
|
except json.JSONDecodeError:
|
||||||
except Exception as e:
|
details = {"raw": log.details}
|
||||||
logger.error(f"Failed to log agent event: {e}")
|
|
||||||
|
|
||||||
|
return {
|
||||||
async def log_system_event(
|
"id": log.id,
|
||||||
message: str,
|
"level": log.level,
|
||||||
level: str = "info",
|
"type": log.type,
|
||||||
source: Optional[str] = None,
|
"user_id": log.user_id,
|
||||||
details: Optional[dict] = None,
|
"request_id": log.request_id,
|
||||||
):
|
"route": log.route,
|
||||||
"""记录系统事件到数据库"""
|
"method": log.method,
|
||||||
if _global_db_session:
|
"status_code": log.status_code,
|
||||||
try:
|
"error_type": log.error_type,
|
||||||
svc = LogService(_global_db_session)
|
"operation": log.operation,
|
||||||
await svc.system_log(message, level, source, details)
|
"message": log.message,
|
||||||
except Exception as e:
|
"source": log.source,
|
||||||
logger.error(f"Failed to log system event: {e}")
|
"details": details,
|
||||||
|
"duration_ms": int(log.duration_ms) if log.duration_ms is not None else None,
|
||||||
|
"created_at": log.created_at.replace(tzinfo=timezone.utc).isoformat() if log.created_at else None,
|
||||||
async def log_chat_event(
|
"updated_at": log.updated_at.replace(tzinfo=timezone.utc).isoformat() if log.updated_at else None,
|
||||||
message: str,
|
}
|
||||||
user_id: str,
|
|
||||||
details: Optional[dict] = None,
|
|
||||||
duration_ms: Optional[int] = None,
|
|
||||||
):
|
|
||||||
"""记录聊天事件到数据库"""
|
|
||||||
if _global_db_session:
|
|
||||||
try:
|
|
||||||
svc = LogService(_global_db_session)
|
|
||||||
await svc.chat_log(message, user_id, details, duration_ms)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to log chat event: {e}")
|
|
||||||
|
|||||||
175
backend/tests/backend/app/services/test_log_service.py
Normal file
175
backend/tests/backend/app/services/test_log_service.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
import app.models # noqa: F401
|
||||||
|
from app.database import Base
|
||||||
|
from app.main import app
|
||||||
|
from app.models.log import Log
|
||||||
|
from app.models.user import User
|
||||||
|
from app.routers.auth import get_current_user
|
||||||
|
from app.database import get_db
|
||||||
|
from app.services.auth_service import get_password_hash
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def log_test_env(tmp_path):
|
||||||
|
db_path = tmp_path / 'test_logs.db'
|
||||||
|
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True)
|
||||||
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
current_user = User(
|
||||||
|
email='tester@example.com',
|
||||||
|
hashed_password=get_password_hash('secret123'),
|
||||||
|
full_name='Tester',
|
||||||
|
)
|
||||||
|
other_user = User(
|
||||||
|
email='other@example.com',
|
||||||
|
hashed_password=get_password_hash('secret123'),
|
||||||
|
full_name='Other',
|
||||||
|
)
|
||||||
|
session.add_all([current_user, other_user])
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
Log(
|
||||||
|
level='error',
|
||||||
|
type='system',
|
||||||
|
user_id=current_user.id,
|
||||||
|
request_id='req-target',
|
||||||
|
route='/api/settings',
|
||||||
|
method='PUT',
|
||||||
|
status_code=500,
|
||||||
|
operation='settings.save',
|
||||||
|
message='target error',
|
||||||
|
source='settings',
|
||||||
|
details=json.dumps({'scope': 'target'}),
|
||||||
|
created_at=now - timedelta(minutes=30),
|
||||||
|
updated_at=now - timedelta(minutes=30),
|
||||||
|
),
|
||||||
|
Log(
|
||||||
|
level='info',
|
||||||
|
type='system',
|
||||||
|
user_id=None,
|
||||||
|
request_id='req-global',
|
||||||
|
route='/api/health',
|
||||||
|
method='GET',
|
||||||
|
status_code=200,
|
||||||
|
operation='health.check',
|
||||||
|
message='global info',
|
||||||
|
source='http',
|
||||||
|
details=json.dumps({'scope': 'global'}),
|
||||||
|
created_at=now - timedelta(minutes=20),
|
||||||
|
updated_at=now - timedelta(minutes=20),
|
||||||
|
),
|
||||||
|
Log(
|
||||||
|
level='error',
|
||||||
|
type='system',
|
||||||
|
user_id=other_user.id,
|
||||||
|
request_id='req-other',
|
||||||
|
route='/api/secret',
|
||||||
|
method='GET',
|
||||||
|
status_code=500,
|
||||||
|
operation='secret.fail',
|
||||||
|
message='other user error',
|
||||||
|
source='secret',
|
||||||
|
details=json.dumps({'scope': 'other'}),
|
||||||
|
created_at=now - timedelta(minutes=10),
|
||||||
|
updated_at=now - timedelta(minutes=10),
|
||||||
|
),
|
||||||
|
Log(
|
||||||
|
level='warning',
|
||||||
|
type='chat',
|
||||||
|
user_id=current_user.id,
|
||||||
|
request_id='req-old',
|
||||||
|
route='/api/chat',
|
||||||
|
method='POST',
|
||||||
|
status_code=429,
|
||||||
|
operation='chat.send',
|
||||||
|
message='old warning',
|
||||||
|
source='chat',
|
||||||
|
details=json.dumps({'scope': 'old'}),
|
||||||
|
created_at=now - timedelta(days=10),
|
||||||
|
updated_at=now - timedelta(days=10),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(current_user)
|
||||||
|
|
||||||
|
async def override_get_db():
|
||||||
|
async with session_factory() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
async def override_get_current_user():
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_logs_list_filters_by_route_and_hides_other_user_records(log_test_env):
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
response = await client.get('/api/logs', params={'route': '/api/settings'})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload['total'] == 1
|
||||||
|
assert [log['request_id'] for log in payload['logs']] == ['req-target']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_logs_stats_uses_same_filters_as_list(log_test_env):
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
params = {
|
||||||
|
'route': '/api/settings',
|
||||||
|
'status_code': 500,
|
||||||
|
'level': 'error',
|
||||||
|
}
|
||||||
|
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
list_response = await client.get('/api/logs', params=params)
|
||||||
|
stats_response = await client.get('/api/logs/stats', params=params)
|
||||||
|
|
||||||
|
assert list_response.status_code == 200
|
||||||
|
assert stats_response.status_code == 200
|
||||||
|
|
||||||
|
list_payload = list_response.json()
|
||||||
|
stats_payload = stats_response.json()
|
||||||
|
|
||||||
|
assert list_payload['total'] == 1
|
||||||
|
assert stats_payload['total'] == 1
|
||||||
|
assert stats_payload['by_type']['system'] == 1
|
||||||
|
assert stats_payload['by_level']['error'] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_logs_rejects_invalid_datetime_range(log_test_env):
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
response = await client.get(
|
||||||
|
'/api/logs',
|
||||||
|
params={
|
||||||
|
'start_at': '2026-03-21T12:00:00+00:00',
|
||||||
|
'end_at': '2026-03-20T12:00:00+00:00',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import api from './index'
|
import api from './index'
|
||||||
|
|
||||||
|
export interface MessageAttachment {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: string
|
id: string
|
||||||
role: 'user' | 'assistant'
|
role: 'user' | 'assistant'
|
||||||
@@ -7,6 +14,7 @@ export interface Message {
|
|||||||
model?: string
|
model?: string
|
||||||
tokens_used?: number
|
tokens_used?: number
|
||||||
created_at: string
|
created_at: string
|
||||||
|
attachments?: MessageAttachment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Conversation {
|
export interface Conversation {
|
||||||
|
|||||||
@@ -1,27 +1,81 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000',
|
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:9527',
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function createRequestId() {
|
||||||
|
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||||
|
return crypto.randomUUID()
|
||||||
|
}
|
||||||
|
return `req-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDev() {
|
||||||
|
return Boolean(import.meta.env.DEV)
|
||||||
|
}
|
||||||
|
|
||||||
|
function debugLog(stage: string, payload: Record<string, unknown>) {
|
||||||
|
if (!isDev()) return
|
||||||
|
console.debug(`[api:${stage}]`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
// 请求拦截器:添加 Token
|
// 请求拦截器:添加 Token
|
||||||
api.interceptors.request.use((config) => {
|
api.interceptors.request.use((config) => {
|
||||||
const token = localStorage.getItem('access_token')
|
const token = localStorage.getItem('access_token')
|
||||||
|
const requestId = createRequestId()
|
||||||
|
config.headers = config.headers || {}
|
||||||
|
config.headers['X-Request-ID'] = requestId
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
|
;(config as typeof config & { metadata?: { startedAt: number; requestId: string } }).metadata = {
|
||||||
|
startedAt: Date.now(),
|
||||||
|
requestId,
|
||||||
|
}
|
||||||
|
debugLog('request', {
|
||||||
|
requestId,
|
||||||
|
method: config.method,
|
||||||
|
url: config.url,
|
||||||
|
params: config.params,
|
||||||
|
data: config.data,
|
||||||
|
})
|
||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
// 响应拦截器:处理错误
|
// 响应拦截器:处理错误
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => {
|
||||||
|
const metadata = (response.config as typeof response.config & { metadata?: { startedAt: number; requestId: string } }).metadata
|
||||||
|
debugLog('response', {
|
||||||
|
requestId: response.headers['x-request-id'] || metadata?.requestId,
|
||||||
|
method: response.config.method,
|
||||||
|
url: response.config.url,
|
||||||
|
status: response.status,
|
||||||
|
durationMs: metadata ? Date.now() - metadata.startedAt : undefined,
|
||||||
|
data: response.data,
|
||||||
|
})
|
||||||
|
return response
|
||||||
|
},
|
||||||
async (error) => {
|
async (error) => {
|
||||||
|
const metadata = (error.config as typeof error.config & { metadata?: { startedAt: number; requestId: string } })?.metadata
|
||||||
|
const requestId = error.response?.headers?.['x-request-id'] || metadata?.requestId
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
localStorage.removeItem('access_token')
|
localStorage.removeItem('access_token')
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
}
|
}
|
||||||
|
debugLog('error', {
|
||||||
|
requestId,
|
||||||
|
method: error.config?.method,
|
||||||
|
url: error.config?.url,
|
||||||
|
status: error.response?.status,
|
||||||
|
durationMs: metadata ? Date.now() - metadata.startedAt : undefined,
|
||||||
|
detail: error.response?.data,
|
||||||
|
})
|
||||||
|
if (requestId) {
|
||||||
|
error.requestId = requestId
|
||||||
|
}
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,12 +6,18 @@ export interface Log {
|
|||||||
level: 'debug' | 'info' | 'warning' | 'error'
|
level: 'debug' | 'info' | 'warning' | 'error'
|
||||||
type: 'agent' | 'system' | 'chat'
|
type: 'agent' | 'system' | 'chat'
|
||||||
user_id: string | null
|
user_id: string | null
|
||||||
|
request_id: string | null
|
||||||
|
route: string | null
|
||||||
|
method: string | null
|
||||||
|
status_code: number | null
|
||||||
|
error_type: string | null
|
||||||
|
operation: string | null
|
||||||
message: string
|
message: string
|
||||||
source: string | null
|
source: string | null
|
||||||
details: string | null
|
details: Record<string, unknown> | null
|
||||||
duration_ms: string | null
|
duration_ms: number | null
|
||||||
created_at: string
|
created_at: string | null
|
||||||
updated_at: string
|
updated_at: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LogStats {
|
export interface LogStats {
|
||||||
@@ -36,19 +42,27 @@ export interface LogQueryResult {
|
|||||||
page_size: number
|
page_size: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LogQueryParams {
|
||||||
|
log_type?: string
|
||||||
|
level?: string
|
||||||
|
source?: string
|
||||||
|
request_id?: string
|
||||||
|
route?: string
|
||||||
|
operation?: string
|
||||||
|
status_code?: number
|
||||||
|
start_at?: string
|
||||||
|
end_at?: string
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
}
|
||||||
|
|
||||||
export const logApi = {
|
export const logApi = {
|
||||||
list: (params?: {
|
list: (params?: LogQueryParams): Promise<AxiosResponse<LogQueryResult>> => {
|
||||||
log_type?: string
|
|
||||||
level?: string
|
|
||||||
source?: string
|
|
||||||
page?: number
|
|
||||||
page_size?: number
|
|
||||||
}): Promise<AxiosResponse<LogQueryResult>> => {
|
|
||||||
return api.get('/api/logs', { params })
|
return api.get('/api/logs', { params })
|
||||||
},
|
},
|
||||||
|
|
||||||
getStats: (hours?: number): Promise<AxiosResponse<LogStats>> => {
|
getStats: (params?: LogQueryParams): Promise<AxiosResponse<LogStats>> => {
|
||||||
return api.get('/api/logs/stats', { params: { hours } })
|
return api.get('/api/logs/stats', { params })
|
||||||
},
|
},
|
||||||
|
|
||||||
getRecent: (params?: {
|
getRecent: (params?: {
|
||||||
|
|||||||
34
frontend/src/app/navigation/nav.ts
Normal file
34
frontend/src/app/navigation/nav.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
BookOpen,
|
||||||
|
Bot,
|
||||||
|
CheckSquare,
|
||||||
|
LayoutGrid,
|
||||||
|
MessageCircle,
|
||||||
|
MessageSquare,
|
||||||
|
Network,
|
||||||
|
Settings,
|
||||||
|
Star,
|
||||||
|
Terminal,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
|
export interface NavItem {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
icon: LucideIcon
|
||||||
|
}
|
||||||
|
|
||||||
|
export const navItems: NavItem[] = [
|
||||||
|
{ name: '沟通系统', path: '/chat', icon: MessageCircle },
|
||||||
|
{ name: '智能链路', path: '/agents', icon: Bot },
|
||||||
|
{ name: '技能中心', path: '/skills', icon: Star },
|
||||||
|
{ name: '资料中枢', path: '/knowledge', icon: BookOpen },
|
||||||
|
{ name: '知识大脑', path: '/graph', icon: Network },
|
||||||
|
{ name: '任务矩阵', path: '/kanban', icon: LayoutGrid },
|
||||||
|
{ name: '任务调度', path: '/todo', icon: CheckSquare },
|
||||||
|
{ name: '信息交易所', path: '/forum', icon: MessageSquare },
|
||||||
|
{ name: '运行状态', path: '/stats', icon: Activity },
|
||||||
|
{ name: '运行日志', path: '/logs', icon: Terminal },
|
||||||
|
{ name: '系统设置', path: '/settings', icon: Settings },
|
||||||
|
]
|
||||||
21
frontend/src/app/router/index.ts
Normal file
21
frontend/src/app/router/index.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { routes } from './routes'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to, _from, next) => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
||||||
|
next('/login')
|
||||||
|
} else if (to.meta.guest && auth.isAuthenticated) {
|
||||||
|
next('/chat')
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
80
frontend/src/app/router/routes.ts
Normal file
80
frontend/src/app/router/routes.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
|
||||||
|
const appChildren: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
redirect: '/chat',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'chat',
|
||||||
|
name: 'chat',
|
||||||
|
component: () => import('@/pages/chat/index.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'knowledge',
|
||||||
|
name: 'knowledge',
|
||||||
|
component: () => import('@/pages/knowledge/index.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'graph',
|
||||||
|
name: 'graph',
|
||||||
|
component: () => import('@/pages/graph/index.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'kanban',
|
||||||
|
name: 'kanban',
|
||||||
|
component: () => import('@/pages/kanban/index.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'forum',
|
||||||
|
name: 'forum',
|
||||||
|
component: () => import('@/pages/forum/index.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'agents',
|
||||||
|
name: 'agents',
|
||||||
|
component: () => import('@/pages/agents/index.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'stats',
|
||||||
|
name: 'stats',
|
||||||
|
component: () => import('@/pages/stats/index.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'skills',
|
||||||
|
name: 'skills',
|
||||||
|
component: () => import('@/pages/skills/index.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'todo',
|
||||||
|
name: 'todo',
|
||||||
|
component: () => import('@/pages/todo/index.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings',
|
||||||
|
name: 'settings',
|
||||||
|
component: () => import('@/pages/settings/index.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'logs',
|
||||||
|
name: 'logs',
|
||||||
|
component: () => import('@/pages/logs/index.vue'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
component: () => import('@/pages/login/index.vue'),
|
||||||
|
meta: { guest: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: () => import('@/pages/app/layout.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
children: appChildren,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export { appChildren }
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { type FolderTree } from '@/api/folder'
|
import { type FolderTree as FolderTreeNode } from '@/api/folder'
|
||||||
import { Folder, FolderOpen, ChevronRight, Plus, Edit2, Trash2 } from 'lucide-vue-next'
|
import { Folder, FolderOpen, ChevronRight, Plus, Edit2, Trash2 } from 'lucide-vue-next'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
folders: FolderTree[]
|
folders: FolderTreeNode[]
|
||||||
selectedId?: string | null
|
selectedId?: string | null
|
||||||
onSelect: (folder: FolderTree) => void
|
onSelect: (folder: FolderTreeNode) => void
|
||||||
onCreate: (parentId: string | null) => void
|
onCreate: (parentId: string | null) => void
|
||||||
onRename: (folder: FolderTree) => void
|
onRename: (folder: FolderTreeNode) => void
|
||||||
onDelete: (folder: FolderTree) => void
|
onDelete: (folder: FolderTreeNode) => void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const expandedIds = ref<Set<string>>(new Set())
|
const expandedIds = ref<Set<string>>(new Set())
|
||||||
@@ -22,7 +22,7 @@ function toggleExpand(id: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleContextMenu(e: MouseEvent, folder: FolderTree) {
|
function handleContextMenu(e: MouseEvent, _folder: FolderTreeNode) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
// 显示右键菜单
|
// 显示右键菜单
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { MessageCircle, BookOpen, Network, LayoutGrid, MessageSquare, LogOut, Cpu, Bot, Activity, CheckSquare, Settings, Star, Terminal } from 'lucide-vue-next'
|
import { LogOut, Cpu } from 'lucide-vue-next'
|
||||||
|
import { navItems } from '@/app/navigation/nav'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
const navItems = [
|
|
||||||
{ name: '沟通系统', path: '/chat', icon: MessageCircle },
|
|
||||||
{ name: '智能链路', path: '/agents', icon: Bot },
|
|
||||||
{ name: '技能中心', path: '/skills', icon: Star },
|
|
||||||
{ name: '资料中枢', path: '/knowledge', icon: BookOpen },
|
|
||||||
{ name: '知识大脑', path: '/graph', icon: Network },
|
|
||||||
{ name: '任务矩阵', path: '/kanban', icon: LayoutGrid },
|
|
||||||
{ name: '任务调度', path: '/todo', icon: CheckSquare },
|
|
||||||
{ name: '信息交易所', path: '/forum', icon: MessageSquare },
|
|
||||||
{ name: '运行状态', path: '/stats', icon: Activity },
|
|
||||||
{ name: '运行日志', path: '/logs', icon: Terminal },
|
|
||||||
{ name: '系统设置', path: '/settings', icon: Settings },
|
|
||||||
]
|
|
||||||
|
|
||||||
function isActive(path: string) {
|
function isActive(path: string) {
|
||||||
return route.path === path
|
return route.path === path
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,16 @@ watch(() => props.isExpanded, (expanded, wasExpanded) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(() => props.model, (model) => {
|
||||||
|
if (!props.isExpanded) {
|
||||||
|
editingModel.value = { ...model }
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
watch(editingModel, (model) => {
|
||||||
|
emit('update', { ...model })
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
// 当 test 通过后,props.model.enabled 会更新,需要同步到 editingModel
|
// 当 test 通过后,props.model.enabled 会更新,需要同步到 editingModel
|
||||||
watch(() => props.model.enabled, (enabled) => {
|
watch(() => props.model.enabled, (enabled) => {
|
||||||
editingModel.value.enabled = enabled
|
editingModel.value.enabled = enabled
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createApp } from 'vue'
|
|||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import ElementPlus from 'element-plus'
|
import ElementPlus from 'element-plus'
|
||||||
import 'element-plus/dist/index.css'
|
import 'element-plus/dist/index.css'
|
||||||
import router from './router'
|
import router from './app/router'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,7 @@
|
|||||||
:key="sub.id"
|
:key="sub.id"
|
||||||
:ref="el => setSubRef(sub.id, el as HTMLElement)"
|
:ref="el => setSubRef(sub.id, el as HTMLElement)"
|
||||||
class="node-card node-sub"
|
class="node-card node-sub"
|
||||||
:class="{ selected: selectedAgentId === sub.id, disabled: !sub.enabled }"
|
:class="{ selected: selectedAgentId === sub.id, disabled: !localAgents[sub.id]?.enabled }"
|
||||||
:style="getSubNodeStyle(sub)"
|
:style="getSubNodeStyle(sub)"
|
||||||
@click="selectAgent(sub.id)"
|
@click="selectAgent(sub.id)"
|
||||||
>
|
>
|
||||||
@@ -217,7 +217,6 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { animate, type AnimationControls } from 'motion'
|
|
||||||
import { RefreshCw, X, Plus } from 'lucide-vue-next'
|
import { RefreshCw, X, Plus } from 'lucide-vue-next'
|
||||||
import { DEFAULT_AGENTS, RELATION_LABELS } from '@/data/agents'
|
import { DEFAULT_AGENTS, RELATION_LABELS } from '@/data/agents'
|
||||||
import type { Agent } from '@/data/agents'
|
import type { Agent } from '@/data/agents'
|
||||||
@@ -232,22 +231,31 @@ const SUB_TOP = 350 // px from top
|
|||||||
const SUB_XS = [12.5, 37.5, 62.5, 87.5]
|
const SUB_XS = [12.5, 37.5, 62.5, 87.5]
|
||||||
|
|
||||||
// ── Sub-agent static data ────────────────────────────────────────
|
// ── Sub-agent static data ────────────────────────────────────────
|
||||||
const subAgents = [
|
interface SubAgentCard {
|
||||||
{ id: 'planner', name: 'PLANNER', role: '规划者', description: '制定任务计划,拆解复杂目标为可执行步骤', relLabel: RELATION_LABELS['master-planner'] },
|
id: string
|
||||||
{ id: 'executor', name: 'EXECUTOR', role: '执行者', description: '调用工具执行具体操作,创建/更新/删除系统资源', relLabel: RELATION_LABELS['master-executor'] },
|
name: string
|
||||||
|
role: string
|
||||||
|
description: string
|
||||||
|
relLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const subAgents: SubAgentCard[] = [
|
||||||
|
{ id: 'planner', name: 'PLANNER', role: '规划者', description: '制定任务计划,拆解复杂目标为可执行步骤', relLabel: RELATION_LABELS['master-planner'] },
|
||||||
|
{ id: 'executor', name: 'EXECUTOR', role: '执行者', description: '调用工具执行具体操作,创建/更新/删除系统资源', relLabel: RELATION_LABELS['master-executor'] },
|
||||||
{ id: 'librarian', name: 'LIBRARIAN', role: '知识官', description: '管理知识库和知识图谱,检索相关信息,更新记忆', relLabel: RELATION_LABELS['master-librarian'] },
|
{ id: 'librarian', name: 'LIBRARIAN', role: '知识官', description: '管理知识库和知识图谱,检索相关信息,更新记忆', relLabel: RELATION_LABELS['master-librarian'] },
|
||||||
{ id: 'analyst', name: 'ANALYST', role: '分析师', description: '分析工作数据,生成统计报告,提供洞察建议', relLabel: RELATION_LABELS['master-analyst'] },
|
{ id: 'analyst', name: 'ANALYST', role: '分析师', description: '分析工作数据,生成统计报告,提供洞察建议', relLabel: RELATION_LABELS['master-analyst'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
type PlaybackHandle = ReturnType<typeof window.setTimeout>
|
||||||
|
|
||||||
|
|
||||||
// ── Refs ────────────────────────────────────────────────────────
|
// ── Refs ────────────────────────────────────────────────────────
|
||||||
const canvasRef = ref<HTMLElement>()
|
const canvasRef = ref<HTMLElement | null>(null)
|
||||||
const svgRef = ref<SVGElement>()
|
const svgRef = ref<SVGElement | null>(null)
|
||||||
const masterCardRef = ref<HTMLElement>()
|
const masterCardRef = ref<HTMLElement | null>(null)
|
||||||
const subRefs: Record<string, HTMLElement> = {}
|
const subRefs: Record<string, HTMLElement> = {}
|
||||||
const masterAnim = ref<AnimationControls | null>(null)
|
const cleanupFns: Array<() => void> = []
|
||||||
const subAnims: Record<string, AnimationControls> = {}
|
const hoverResetTimers: Record<string, PlaybackHandle | null> = {}
|
||||||
const hoverAnims: Record<string, AnimationControls | null> = {}
|
|
||||||
// Background particles
|
// Background particles
|
||||||
const bgParticles = Array.from({ length: 60 }, (_, i) => {
|
const bgParticles = Array.from({ length: 60 }, (_, i) => {
|
||||||
const d = 3 + Math.random() * 5
|
const d = 3 + Math.random() * 5
|
||||||
@@ -311,7 +319,7 @@ const masterNodeStyle = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function getSubNodeStyle(sub: (typeof subAgents)[0]) {
|
function getSubNodeStyle(sub: SubAgentCard) {
|
||||||
const idx = subAgents.findIndex(s => s.id === sub.id)
|
const idx = subAgents.findIndex(s => s.id === sub.id)
|
||||||
const pct = SUB_XS[idx] ?? 50
|
const pct = SUB_XS[idx] ?? 50
|
||||||
const { x } = pxToSvg(pct, SUB_TOP)
|
const { x } = pxToSvg(pct, SUB_TOP)
|
||||||
@@ -419,30 +427,109 @@ async function refreshStats() {
|
|||||||
|
|
||||||
function onPulseEnd() { firingLine.value = null; activeLine.value = null }
|
function onPulseEnd() { firingLine.value = null; activeLine.value = null }
|
||||||
|
|
||||||
|
function stopTimer(timer: PlaybackHandle | null) {
|
||||||
|
if (timer) window.clearTimeout(timer)
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTransition(
|
||||||
|
el: Element,
|
||||||
|
keyframes: Keyframe[],
|
||||||
|
options: KeyframeAnimationOptions,
|
||||||
|
done?: () => void,
|
||||||
|
) {
|
||||||
|
const animation = (el as HTMLElement).animate(keyframes, {
|
||||||
|
fill: 'forwards',
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
|
||||||
|
const finish = () => done?.()
|
||||||
|
animation.addEventListener('finish', finish, { once: true })
|
||||||
|
cleanupFns.push(() => animation.cancel())
|
||||||
|
return animation
|
||||||
|
}
|
||||||
|
|
||||||
// ── Motion helpers ───────────────────────────────────────────────
|
// ── Motion helpers ───────────────────────────────────────────────
|
||||||
function animateIn(el: Element) { animate(el, { opacity: [0, 1], x: [80, 0] }, { duration: 0.35, easing: [0.4, 0, 0.2, 1] }).play() }
|
function animateIn(el: Element, done: () => void) {
|
||||||
function animateOut(el: Element) { animate(el, { opacity: [1, 0], x: [0, 80] }, { duration: 0.25, easing: [0.4, 0, 1, 1] }).play() }
|
runTransition(
|
||||||
function fadeIn(el: Element) { animate(el, { opacity: [0, 1] }, { duration: 0.25 }).play() }
|
el,
|
||||||
function fadeOut(el: Element) { animate(el, { opacity: [1, 0] }, { duration: 0.2 }).play() }
|
[
|
||||||
|
{ opacity: 0, transform: 'translateX(80px)' },
|
||||||
|
{ opacity: 1, transform: 'translateX(0)' },
|
||||||
|
],
|
||||||
|
{ duration: 350, easing: 'cubic-bezier(0.4, 0, 0.2, 1)' },
|
||||||
|
done,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function animateOut(el: Element, done: () => void) {
|
||||||
|
runTransition(
|
||||||
|
el,
|
||||||
|
[
|
||||||
|
{ opacity: 1, transform: 'translateX(0)' },
|
||||||
|
{ opacity: 0, transform: 'translateX(80px)' },
|
||||||
|
],
|
||||||
|
{ duration: 250, easing: 'cubic-bezier(0.4, 0, 1, 1)' },
|
||||||
|
done,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fadeIn(el: Element, done: () => void) {
|
||||||
|
runTransition(el, [{ opacity: 0 }, { opacity: 1 }], { duration: 250 }, done)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fadeOut(el: Element, done: () => void) {
|
||||||
|
runTransition(el, [{ opacity: 1 }, { opacity: 0 }], { duration: 200 }, done)
|
||||||
|
}
|
||||||
|
|
||||||
function playEntranceAnimations() {
|
function playEntranceAnimations() {
|
||||||
if (masterCardRef.value) {
|
if (masterCardRef.value) {
|
||||||
masterAnim.value = animate(masterCardRef.value, { opacity: [0, 1], y: [20, 0] }, { duration: 0.6, easing: [0.16, 1, 0.3, 1] })
|
runTransition(
|
||||||
|
masterCardRef.value,
|
||||||
|
[
|
||||||
|
{ opacity: 0, transform: 'translateY(20px)' },
|
||||||
|
{ opacity: 1, transform: 'translateY(0)' },
|
||||||
|
],
|
||||||
|
{ duration: 600, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
subAgents.forEach((sub, idx) => {
|
subAgents.forEach((sub, idx) => {
|
||||||
const el = subRefs[sub.id]
|
const el = subRefs[sub.id]
|
||||||
if (!el) return
|
if (!el) return
|
||||||
const anim = animate(el, { opacity: [0, 1], y: [20, 0] }, { duration: 0.5, delay: 0.15 + idx * 0.1, easing: [0.16, 1, 0.3, 1] })
|
|
||||||
subAnims[sub.id] = anim
|
runTransition(
|
||||||
const hoverAnim = animate(el, { y: [0, -4] }, { duration: 0.2, easing: [0.34, 1.56, 0.64, 1] })
|
el,
|
||||||
hoverAnim.pause()
|
[
|
||||||
hoverAnims[sub.id] = hoverAnim
|
{ opacity: 0, transform: 'translateY(20px)' },
|
||||||
el.addEventListener('mouseenter', () => {
|
{ opacity: 1, transform: 'translateY(0)' },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
duration: 500,
|
||||||
|
delay: 150 + idx * 100,
|
||||||
|
easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
if (!localAgents[sub.id]?.enabled) return
|
if (!localAgents[sub.id]?.enabled) return
|
||||||
hoverAnim.direction = 'forward'; hoverAnim.play()
|
stopTimer(hoverResetTimers[sub.id] ?? null)
|
||||||
})
|
el.style.transform = 'translateY(-4px)'
|
||||||
el.addEventListener('mouseleave', () => {
|
}
|
||||||
hoverAnim.direction = 'reverse'; hoverAnim.play()
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
stopTimer(hoverResetTimers[sub.id] ?? null)
|
||||||
|
hoverResetTimers[sub.id] = window.setTimeout(() => {
|
||||||
|
el.style.transform = ''
|
||||||
|
hoverResetTimers[sub.id] = null
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
el.addEventListener('mouseenter', handleMouseEnter)
|
||||||
|
el.addEventListener('mouseleave', handleMouseLeave)
|
||||||
|
cleanupFns.push(() => {
|
||||||
|
stopTimer(hoverResetTimers[sub.id] ?? null)
|
||||||
|
el.removeEventListener('mouseenter', handleMouseEnter)
|
||||||
|
el.removeEventListener('mouseleave', handleMouseLeave)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -466,9 +553,7 @@ onMounted(async () => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (pollInterval) clearInterval(pollInterval)
|
if (pollInterval) clearInterval(pollInterval)
|
||||||
resizeObserver?.disconnect()
|
resizeObserver?.disconnect()
|
||||||
masterAnim.value?.stop()
|
cleanupFns.forEach(cleanup => cleanup())
|
||||||
Object.values(subAnims).forEach(a => a.stop())
|
|
||||||
Object.values(hoverAnims).forEach(a => a?.stop())
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
210
frontend/src/pages/chat/composables/useChatView.ts
Normal file
210
frontend/src/pages/chat/composables/useChatView.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import { nextTick, onMounted, ref } from 'vue'
|
||||||
|
import { useConversationStore } from '@/stores/conversation'
|
||||||
|
import { conversationApi, type Message } from '@/api/conversation'
|
||||||
|
import { documentApi } from '@/api/document'
|
||||||
|
|
||||||
|
export interface SelectedFile {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageWithAttachments extends Message {
|
||||||
|
attachments?: SelectedFile[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChatView() {
|
||||||
|
const store = useConversationStore()
|
||||||
|
const inputMessage = ref('')
|
||||||
|
const isSending = ref(false)
|
||||||
|
const chatContainer = ref<HTMLElement>()
|
||||||
|
const inputRef = ref<HTMLTextAreaElement>()
|
||||||
|
const isTyping = ref(false)
|
||||||
|
const fileInputRef = ref<HTMLInputElement>()
|
||||||
|
const showEmojiPicker = ref(false)
|
||||||
|
const selectedFiles = ref<SelectedFile[]>([])
|
||||||
|
|
||||||
|
async function sendMessage() {
|
||||||
|
if (!inputMessage.value.trim() || isSending.value) return
|
||||||
|
|
||||||
|
isSending.value = true
|
||||||
|
isTyping.value = true
|
||||||
|
const text = inputMessage.value.trim()
|
||||||
|
const attachments = [...selectedFiles.value]
|
||||||
|
inputMessage.value = ''
|
||||||
|
|
||||||
|
store.addMessage({
|
||||||
|
id: `temp-${Date.now()}`,
|
||||||
|
role: 'user',
|
||||||
|
content: text,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
attachments,
|
||||||
|
} as MessageWithAttachments)
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
scrollToBottom()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await conversationApi.chat(text, store.currentConversationId || undefined, attachments.map((file) => file.id))
|
||||||
|
selectedFiles.value = []
|
||||||
|
isTyping.value = false
|
||||||
|
store.addMessage({
|
||||||
|
id: response.data.message_id,
|
||||||
|
role: 'assistant',
|
||||||
|
content: response.data.content,
|
||||||
|
model: response.data.agent_name,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
if (!store.currentConversationId) {
|
||||||
|
store.setCurrentConversation(response.data.conversation_id)
|
||||||
|
await loadConversations()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
isTyping.value = false
|
||||||
|
console.error('发送失败:', error)
|
||||||
|
store.addMessage({
|
||||||
|
id: `err-${Date.now()}`,
|
||||||
|
role: 'assistant',
|
||||||
|
content: '抱歉,连接失败。请检查服务状态。',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
isSending.value = false
|
||||||
|
await nextTick()
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConversations() {
|
||||||
|
try {
|
||||||
|
const response = await conversationApi.list()
|
||||||
|
store.setConversations(response.data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载对话列表失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectConversation(id: string) {
|
||||||
|
store.setCurrentConversation(id)
|
||||||
|
store.setMessages([])
|
||||||
|
try {
|
||||||
|
const response = await conversationApi.getMessages(id)
|
||||||
|
store.setMessages(response.data)
|
||||||
|
await nextTick()
|
||||||
|
scrollToBottom()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载消息失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function newConversation() {
|
||||||
|
store.setCurrentConversation('')
|
||||||
|
store.setMessages([])
|
||||||
|
inputRef.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteConversation(id: string, event: Event) {
|
||||||
|
event.stopPropagation()
|
||||||
|
try {
|
||||||
|
await conversationApi.delete(id)
|
||||||
|
store.removeConversation(id)
|
||||||
|
if (store.currentConversationId === id) {
|
||||||
|
store.setCurrentConversation('')
|
||||||
|
store.setMessages([])
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
if (chatContainer.value) {
|
||||||
|
chatContainer.value.scrollTop = chatContainer.value.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(dateStr: string) {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatConvDate(dateStr: string) {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now.getTime() - date.getTime()
|
||||||
|
if (diff < 60000) return '刚刚'
|
||||||
|
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
|
||||||
|
if (diff < 86400000) return '今天'
|
||||||
|
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoResize(event: Event) {
|
||||||
|
const element = event.target as HTMLTextAreaElement
|
||||||
|
element.style.height = 'auto'
|
||||||
|
element.style.height = `${Math.min(element.scrollHeight, 120)}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileSelect(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
if (!input.files?.length) return
|
||||||
|
|
||||||
|
for (const file of input.files) {
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
alert(`文件 ${file.name} 超过10MB限制`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await documentApi.upload(file)
|
||||||
|
selectedFiles.value.push({
|
||||||
|
id: response.data.id,
|
||||||
|
name: file.name,
|
||||||
|
type: file.type,
|
||||||
|
size: file.size,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('上传失败:', error)
|
||||||
|
alert(`文件 ${file.name} 上传失败`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fileInputRef.value) {
|
||||||
|
fileInputRef.value.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertEmoji(emoji: string) {
|
||||||
|
inputMessage.value += emoji
|
||||||
|
showEmojiPicker.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFilePicker() {
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadConversations()
|
||||||
|
inputRef.value?.focus()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
inputMessage,
|
||||||
|
isSending,
|
||||||
|
chatContainer,
|
||||||
|
inputRef,
|
||||||
|
isTyping,
|
||||||
|
fileInputRef,
|
||||||
|
showEmojiPicker,
|
||||||
|
selectedFiles,
|
||||||
|
sendMessage,
|
||||||
|
selectConversation,
|
||||||
|
newConversation,
|
||||||
|
deleteConversation,
|
||||||
|
formatTime,
|
||||||
|
formatConvDate,
|
||||||
|
autoResize,
|
||||||
|
handleFileSelect,
|
||||||
|
insertEmoji,
|
||||||
|
openFilePicker,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,180 +1,29 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, nextTick, watch } from 'vue'
|
|
||||||
import { useConversationStore } from '@/stores/conversation'
|
|
||||||
import { conversationApi } from '@/api/conversation'
|
|
||||||
import { documentApi } from '@/api/document'
|
|
||||||
import { MessageCircle, Trash2, Send, Sparkles, CornerDownLeft, Paperclip, Smile } from 'lucide-vue-next'
|
import { MessageCircle, Trash2, Send, Sparkles, CornerDownLeft, Paperclip, Smile } from 'lucide-vue-next'
|
||||||
import EmojiPicker from '@/components/chat/EmojiPicker.vue'
|
import EmojiPicker from '@/components/chat/EmojiPicker.vue'
|
||||||
import FileMessage from '@/components/chat/FileMessage.vue'
|
import FileMessage from '@/components/chat/FileMessage.vue'
|
||||||
|
import { useChatView } from '@/pages/chat/composables/useChatView'
|
||||||
|
|
||||||
const store = useConversationStore()
|
const {
|
||||||
const inputMessage = ref('')
|
store,
|
||||||
const isSending = ref(false)
|
inputMessage,
|
||||||
const chatContainer = ref<HTMLElement>()
|
isSending,
|
||||||
const inputRef = ref<HTMLTextAreaElement>()
|
chatContainer,
|
||||||
const isTyping = ref(false)
|
inputRef,
|
||||||
const fileInputRef = ref<HTMLInputElement>()
|
isTyping,
|
||||||
const showEmojiPicker = ref(false)
|
fileInputRef,
|
||||||
const selectedFiles = ref<{ id: string; name: string; type: string; size: number }[]>([])
|
showEmojiPicker,
|
||||||
|
sendMessage,
|
||||||
async function sendMessage() {
|
selectConversation,
|
||||||
if (!inputMessage.value.trim() || isSending.value) return
|
newConversation,
|
||||||
|
deleteConversation,
|
||||||
isSending.value = true
|
formatTime,
|
||||||
isTyping.value = true
|
formatConvDate,
|
||||||
const text = inputMessage.value.trim()
|
autoResize,
|
||||||
inputMessage.value = ''
|
handleFileSelect,
|
||||||
|
insertEmoji,
|
||||||
store.addMessage({
|
openFilePicker,
|
||||||
id: `temp-${Date.now()}`,
|
} = useChatView()
|
||||||
role: 'user',
|
|
||||||
content: text,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
|
|
||||||
await nextTick()
|
|
||||||
scrollToBottom()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await conversationApi.chat(text, store.currentConversationId || undefined, selectedFiles.value.map(f => f.id))
|
|
||||||
selectedFiles.value = []
|
|
||||||
isTyping.value = false
|
|
||||||
store.addMessage({
|
|
||||||
id: response.data.message_id,
|
|
||||||
role: 'assistant',
|
|
||||||
content: response.data.content,
|
|
||||||
model: response.data.agent_name,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
if (!store.currentConversationId) {
|
|
||||||
store.setCurrentConversation(response.data.conversation_id)
|
|
||||||
await loadConversations()
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
isTyping.value = false
|
|
||||||
console.error('发送失败:', e)
|
|
||||||
store.addMessage({
|
|
||||||
id: `err-${Date.now()}`,
|
|
||||||
role: 'assistant',
|
|
||||||
content: '抱歉,连接失败。请检查服务状态。',
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
isSending.value = false
|
|
||||||
await nextTick()
|
|
||||||
scrollToBottom()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadConversations() {
|
|
||||||
try {
|
|
||||||
const response = await conversationApi.list()
|
|
||||||
store.setConversations(response.data)
|
|
||||||
} catch (e) {
|
|
||||||
console.error('加载对话列表失败:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectConversation(id: string) {
|
|
||||||
store.setCurrentConversation(id)
|
|
||||||
store.setMessages([])
|
|
||||||
try {
|
|
||||||
const response = await conversationApi.getMessages(id)
|
|
||||||
store.setMessages(response.data)
|
|
||||||
await nextTick()
|
|
||||||
scrollToBottom()
|
|
||||||
} catch (e) {
|
|
||||||
console.error('加载消息失败:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function newConversation() {
|
|
||||||
store.setCurrentConversation('')
|
|
||||||
store.setMessages([])
|
|
||||||
inputRef.value?.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteConversation(id: string, e: Event) {
|
|
||||||
e.stopPropagation()
|
|
||||||
try {
|
|
||||||
await conversationApi.delete(id)
|
|
||||||
store.removeConversation(id)
|
|
||||||
if (store.currentConversationId === id) {
|
|
||||||
store.setCurrentConversation('')
|
|
||||||
store.setMessages([])
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('删除失败:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollToBottom() {
|
|
||||||
if (chatContainer.value) {
|
|
||||||
chatContainer.value.scrollTop = chatContainer.value.scrollHeight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(dateStr: string) {
|
|
||||||
const d = new Date(dateStr)
|
|
||||||
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatConvDate(dateStr: string) {
|
|
||||||
const d = new Date(dateStr)
|
|
||||||
const now = new Date()
|
|
||||||
const diff = now.getTime() - d.getTime()
|
|
||||||
if (diff < 60000) return '刚刚'
|
|
||||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
|
|
||||||
if (diff < 86400000) return '今天'
|
|
||||||
return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function autoResize(e: Event) {
|
|
||||||
const el = e.target as HTMLTextAreaElement
|
|
||||||
el.style.height = 'auto'
|
|
||||||
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleFileSelect(e: Event) {
|
|
||||||
const input = e.target as HTMLInputElement
|
|
||||||
if (!input.files?.length) return
|
|
||||||
|
|
||||||
for (const file of input.files) {
|
|
||||||
if (file.size > 10 * 1024 * 1024) {
|
|
||||||
alert(`文件 ${file.name} 超过10MB限制`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const response = await documentApi.upload(file)
|
|
||||||
selectedFiles.value.push({
|
|
||||||
id: response.data.id,
|
|
||||||
name: file.name,
|
|
||||||
type: file.type,
|
|
||||||
size: file.size,
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
console.error('上传失败:', e)
|
|
||||||
alert(`文件 ${file.name} 上传失败`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (fileInputRef.value) {
|
|
||||||
fileInputRef.value.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function insertEmoji(emoji: string) {
|
|
||||||
inputMessage.value += emoji
|
|
||||||
showEmojiPicker.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function openFilePicker() {
|
|
||||||
fileInputRef.value?.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadConversations()
|
|
||||||
inputRef.value?.focus()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -272,7 +121,9 @@ onMounted(() => {
|
|||||||
<FileMessage
|
<FileMessage
|
||||||
v-for="att in msg.attachments"
|
v-for="att in msg.attachments"
|
||||||
:key="att.id"
|
:key="att.id"
|
||||||
:file="att"
|
:filename="att.name"
|
||||||
|
:file-type="att.type"
|
||||||
|
:file-size="att.size"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -818,7 +669,9 @@ onMounted(() => {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
resize: none;
|
resize: none;
|
||||||
max-height: 120px;
|
max-height: 120px;
|
||||||
padding: 0;
|
padding: 8px 0;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-frame textarea::placeholder { color: var(--text-dim); }
|
.input-frame textarea::placeholder { color: var(--text-dim); }
|
||||||
@@ -39,13 +39,13 @@ function formatDate(dateStr: string) {
|
|||||||
return d.toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
return d.toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCategoryLabel(cat: string) {
|
function getCategoryLabel(cat?: string) {
|
||||||
const map: Record<string, { label: string; color: string }> = {
|
const map: Record<string, { label: string; color: string }> = {
|
||||||
discussion: { label: 'DISCUSSION', color: 'var(--accent-cyan)' },
|
discussion: { label: 'DISCUSSION', color: 'var(--accent-cyan)' },
|
||||||
instruction: { label: 'INSTRUCTION', color: 'var(--accent-amber)' },
|
instruction: { label: 'INSTRUCTION', color: 'var(--accent-amber)' },
|
||||||
question: { label: 'QUESTION', color: 'var(--accent-green)' },
|
question: { label: 'QUESTION', color: 'var(--accent-green)' },
|
||||||
}
|
}
|
||||||
return map[cat] || map.discussion
|
return map[cat ?? 'discussion'] || map.discussion
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => { loadPosts() })
|
onMounted(() => { loadPosts() })
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, nextTick } from 'vue'
|
import { ref, onMounted, nextTick } from 'vue'
|
||||||
import { graphApi } from '@/api/graph'
|
import { graphApi } from '@/api/graph'
|
||||||
import { Network, RefreshCw, Info, Maximize2, Hexagon } from 'lucide-vue-next'
|
import { Network, RefreshCw, Info, Hexagon } from 'lucide-vue-next'
|
||||||
import type { KGNode, KGEdge } from '@/api/graph'
|
import type { KGNode, KGEdge } from '@/api/graph'
|
||||||
|
|
||||||
const nodes = ref<KGNode[]>([])
|
const nodes = ref<KGNode[]>([])
|
||||||
344
frontend/src/pages/knowledge/composables/useKnowledgeView.ts
Normal file
344
frontend/src/pages/knowledge/composables/useKnowledgeView.ts
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { documentApi, type Document } from '@/api/document'
|
||||||
|
import { folderApi, type FolderTree } from '@/api/folder'
|
||||||
|
|
||||||
|
export function useKnowledgeView() {
|
||||||
|
const folders = ref<FolderTree[]>([])
|
||||||
|
const documents = ref<Document[]>([])
|
||||||
|
const currentFolderId = ref<string | null>(null)
|
||||||
|
const isUploading = ref(false)
|
||||||
|
const isLoadingDocuments = ref(false)
|
||||||
|
const uploadError = ref('')
|
||||||
|
const uploadInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const showNewFolderDialog = ref(false)
|
||||||
|
const newFolderName = ref('')
|
||||||
|
const newFolderParentId = ref<string | null>(null)
|
||||||
|
|
||||||
|
const showRenameDialog = ref(false)
|
||||||
|
const renameFolderName = ref('')
|
||||||
|
const renamingFolder = ref<FolderTree | null>(null)
|
||||||
|
|
||||||
|
const showDeleteDialog = ref(false)
|
||||||
|
const deletingFolder = ref<FolderTree | null>(null)
|
||||||
|
|
||||||
|
const showDocumentDialog = ref(false)
|
||||||
|
const activeDocument = ref<Document | null>(null)
|
||||||
|
const activeDocumentContent = ref('')
|
||||||
|
const isLoadingDocumentContent = ref(false)
|
||||||
|
|
||||||
|
const folderMap = computed(() => {
|
||||||
|
const map = new Map<string, FolderTree>()
|
||||||
|
|
||||||
|
function walk(nodes: FolderTree[]) {
|
||||||
|
for (const node of nodes) {
|
||||||
|
map.set(node.id, node)
|
||||||
|
if (node.children?.length) {
|
||||||
|
walk(node.children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(folders.value)
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentFolder = computed(() => {
|
||||||
|
if (!currentFolderId.value) return null
|
||||||
|
return folderMap.value.get(currentFolderId.value) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const isRoot = computed(() => currentFolderId.value === null)
|
||||||
|
|
||||||
|
const visibleFolders = computed(() => {
|
||||||
|
if (isRoot.value) return folders.value
|
||||||
|
return currentFolder.value?.children ?? []
|
||||||
|
})
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
const items: Array<{ id: string | null; name: string }> = [{ id: null, name: '根目录' }]
|
||||||
|
|
||||||
|
if (!currentFolder.value) {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
const chain: FolderTree[] = []
|
||||||
|
let cursor: FolderTree | null = currentFolder.value
|
||||||
|
|
||||||
|
while (cursor) {
|
||||||
|
chain.unshift(cursor)
|
||||||
|
cursor = cursor.parent_id ? folderMap.value.get(cursor.parent_id) ?? null : null
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const folder of chain) {
|
||||||
|
items.push({ id: folder.id, name: folder.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
const explorerTitle = computed(() => {
|
||||||
|
if (isRoot.value) {
|
||||||
|
return `${visibleFolders.value.length} 个文件夹`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${visibleFolders.value.length} 个文件夹 · ${documents.value.length} 个文件`
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadFolders() {
|
||||||
|
try {
|
||||||
|
const response = await folderApi.getTree()
|
||||||
|
folders.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载文件夹失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDocumentsByFolder(folderId: string | null) {
|
||||||
|
if (!folderId) {
|
||||||
|
documents.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingDocuments.value = true
|
||||||
|
try {
|
||||||
|
const response = await documentApi.list(folderId)
|
||||||
|
documents.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载文档失败:', error)
|
||||||
|
} finally {
|
||||||
|
isLoadingDocuments.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enterFolder(folder: FolderTree) {
|
||||||
|
currentFolderId.value = folder.id
|
||||||
|
await loadDocumentsByFolder(folder.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goToFolder(folderId: string | null) {
|
||||||
|
currentFolderId.value = folderId
|
||||||
|
await loadDocumentsByFolder(folderId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goBack() {
|
||||||
|
if (!currentFolder.value) return
|
||||||
|
await goToFolder(currentFolder.value.parent_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerUpload() {
|
||||||
|
if (isRoot.value) return
|
||||||
|
uploadInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
const file = target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
if (!currentFolderId.value) {
|
||||||
|
uploadError.value = '请先进入目标文件夹后再上传文件'
|
||||||
|
window.setTimeout(() => {
|
||||||
|
uploadError.value = ''
|
||||||
|
}, 3000)
|
||||||
|
target.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isUploading.value = true
|
||||||
|
try {
|
||||||
|
await documentApi.upload(file, currentFolderId.value)
|
||||||
|
await loadDocumentsByFolder(currentFolderId.value)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('上传失败:', error)
|
||||||
|
} finally {
|
||||||
|
isUploading.value = false
|
||||||
|
target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteDocument(id: string) {
|
||||||
|
try {
|
||||||
|
await documentApi.delete(id)
|
||||||
|
documents.value = documents.value.filter((doc) => doc.id !== id)
|
||||||
|
|
||||||
|
if (activeDocument.value?.id === id) {
|
||||||
|
closeDocumentDialog()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNewFolderDialog(parentId: string | null = null) {
|
||||||
|
newFolderParentId.value = parentId
|
||||||
|
newFolderName.value = ''
|
||||||
|
showNewFolderDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFolder() {
|
||||||
|
if (!newFolderName.value.trim()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await folderApi.create({
|
||||||
|
name: newFolderName.value.trim(),
|
||||||
|
parent_id: newFolderParentId.value,
|
||||||
|
})
|
||||||
|
await loadFolders()
|
||||||
|
showNewFolderDialog.value = false
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建文件夹失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRenameDialog(folder: FolderTree) {
|
||||||
|
renamingFolder.value = folder
|
||||||
|
renameFolderName.value = folder.name
|
||||||
|
showRenameDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameFolder() {
|
||||||
|
if (!renamingFolder.value || !renameFolderName.value.trim()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await folderApi.rename(renamingFolder.value.id, { name: renameFolderName.value.trim() })
|
||||||
|
await loadFolders()
|
||||||
|
showRenameDialog.value = false
|
||||||
|
renamingFolder.value = null
|
||||||
|
} catch (error) {
|
||||||
|
console.error('重命名文件夹失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteDialog(folder: FolderTree) {
|
||||||
|
deletingFolder.value = folder
|
||||||
|
showDeleteDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFolderInTree(nodes: FolderTree[], targetId: string) {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.id === targetId) return true
|
||||||
|
if (node.children?.length && isFolderInTree(node.children, targetId)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFolder() {
|
||||||
|
if (!deletingFolder.value) return
|
||||||
|
|
||||||
|
const deletingId = deletingFolder.value.id
|
||||||
|
const fallbackParentId = deletingFolder.value.parent_id
|
||||||
|
|
||||||
|
try {
|
||||||
|
await folderApi.delete(deletingId)
|
||||||
|
await loadFolders()
|
||||||
|
|
||||||
|
if (currentFolderId.value && !isFolderInTree(folders.value, currentFolderId.value)) {
|
||||||
|
currentFolderId.value = fallbackParentId
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadDocumentsByFolder(currentFolderId.value)
|
||||||
|
showDeleteDialog.value = false
|
||||||
|
deletingFolder.value = null
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除文件夹失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDocument(doc: Document) {
|
||||||
|
activeDocument.value = doc
|
||||||
|
activeDocumentContent.value = ''
|
||||||
|
showDocumentDialog.value = true
|
||||||
|
isLoadingDocumentContent.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await documentApi.getContent(doc.id)
|
||||||
|
const content = response.data as string | { content?: string }
|
||||||
|
activeDocumentContent.value = typeof content === 'string' ? content : content.content ?? ''
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载文档内容失败:', error)
|
||||||
|
activeDocumentContent.value = '暂时无法加载文档内容。'
|
||||||
|
} finally {
|
||||||
|
isLoadingDocumentContent.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDocumentDialog() {
|
||||||
|
showDocumentDialog.value = false
|
||||||
|
activeDocument.value = null
|
||||||
|
activeDocumentContent.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileTypeColor(type: string) {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
pdf: '#f87171',
|
||||||
|
md: '#60a5fa',
|
||||||
|
txt: '#34d399',
|
||||||
|
docx: '#a78bfa',
|
||||||
|
}
|
||||||
|
return colors[type] || '#9ca3af'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(fileSize: number) {
|
||||||
|
if (fileSize < 1024) return `${fileSize} B`
|
||||||
|
if (fileSize < 1024 * 1024) return `${(fileSize / 1024).toFixed(1)} KB`
|
||||||
|
return `${(fileSize / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: string) {
|
||||||
|
return new Date(date).toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadFolders()
|
||||||
|
await loadDocumentsByFolder(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
folders,
|
||||||
|
documents,
|
||||||
|
currentFolderId,
|
||||||
|
isUploading,
|
||||||
|
isLoadingDocuments,
|
||||||
|
uploadError,
|
||||||
|
uploadInput,
|
||||||
|
showNewFolderDialog,
|
||||||
|
newFolderName,
|
||||||
|
newFolderParentId,
|
||||||
|
showRenameDialog,
|
||||||
|
renameFolderName,
|
||||||
|
renamingFolder,
|
||||||
|
showDeleteDialog,
|
||||||
|
deletingFolder,
|
||||||
|
showDocumentDialog,
|
||||||
|
activeDocument,
|
||||||
|
activeDocumentContent,
|
||||||
|
isLoadingDocumentContent,
|
||||||
|
currentFolder,
|
||||||
|
isRoot,
|
||||||
|
visibleFolders,
|
||||||
|
breadcrumbs,
|
||||||
|
explorerTitle,
|
||||||
|
enterFolder,
|
||||||
|
goToFolder,
|
||||||
|
goBack,
|
||||||
|
triggerUpload,
|
||||||
|
handleUpload,
|
||||||
|
handleDeleteDocument,
|
||||||
|
openNewFolderDialog,
|
||||||
|
createFolder,
|
||||||
|
openRenameDialog,
|
||||||
|
renameFolder,
|
||||||
|
openDeleteDialog,
|
||||||
|
deleteFolder,
|
||||||
|
openDocument,
|
||||||
|
closeDocumentDialog,
|
||||||
|
getFileTypeColor,
|
||||||
|
formatFileSize,
|
||||||
|
formatDate,
|
||||||
|
}
|
||||||
|
}
|
||||||
1336
frontend/src/pages/knowledge/index.vue
Normal file
1336
frontend/src/pages/knowledge/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
760
frontend/src/pages/logs/index.vue
Normal file
760
frontend/src/pages/logs/index.vue
Normal file
@@ -0,0 +1,760 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { logApi, type Log, type LogQueryParams, type LogStats } from '@/api/log'
|
||||||
|
import { Terminal, RefreshCw, Bot, MessageSquare, Settings, AlertCircle, ChevronDown, ChevronRight } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
type TimePreset = '1h' | '6h' | '24h' | '7d' | 'custom'
|
||||||
|
|
||||||
|
interface LogFilters {
|
||||||
|
preset: TimePreset
|
||||||
|
start_at: string
|
||||||
|
end_at: string
|
||||||
|
request_id: string
|
||||||
|
route: string
|
||||||
|
operation: string
|
||||||
|
status_code: string
|
||||||
|
log_type: string
|
||||||
|
level: string
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = ref<Log[]>([])
|
||||||
|
const stats = ref<LogStats | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const expandedLogId = ref<string | null>(null)
|
||||||
|
const autoRefresh = ref(false)
|
||||||
|
let refreshInterval: ReturnType<typeof window.setInterval> | null = null
|
||||||
|
|
||||||
|
const pageSizeOptions = [20, 50, 100]
|
||||||
|
const timePresets: Array<{ value: TimePreset; label: string }> = [
|
||||||
|
{ value: '1h', label: '1h' },
|
||||||
|
{ value: '6h', label: '6h' },
|
||||||
|
{ value: '24h', label: '24h' },
|
||||||
|
{ value: '7d', label: '7d' },
|
||||||
|
{ value: 'custom', label: '自定义' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const logTypes = [
|
||||||
|
{ value: '', label: '全部', icon: null },
|
||||||
|
{ value: 'agent', label: '智能体', icon: Bot },
|
||||||
|
{ value: 'system', label: '系统', icon: Settings },
|
||||||
|
{ value: 'chat', label: '问答', icon: MessageSquare },
|
||||||
|
]
|
||||||
|
|
||||||
|
const logLevels = [
|
||||||
|
{ value: '', label: '全部' },
|
||||||
|
{ value: 'debug', label: '调试' },
|
||||||
|
{ value: 'info', label: '信息' },
|
||||||
|
{ value: 'warning', label: '警告' },
|
||||||
|
{ value: 'error', label: '错误' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const statusCodeOptions = [
|
||||||
|
{ value: '', label: '全部状态' },
|
||||||
|
{ value: '200', label: '200' },
|
||||||
|
{ value: '400', label: '400' },
|
||||||
|
{ value: '401', label: '401' },
|
||||||
|
{ value: '404', label: '404' },
|
||||||
|
{ value: '422', label: '422' },
|
||||||
|
{ value: '500', label: '500' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const levelColors: Record<string, string> = {
|
||||||
|
debug: 'var(--text-dim)',
|
||||||
|
info: 'var(--accent-cyan)',
|
||||||
|
warning: 'var(--accent-amber)',
|
||||||
|
error: 'var(--accent-red)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeLabels: Record<string, string> = {
|
||||||
|
agent: '智能体',
|
||||||
|
system: '系统',
|
||||||
|
chat: '问答',
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDatetimeLocalValue(date: Date): string {
|
||||||
|
const offsetMs = date.getTimezoneOffset() * 60 * 1000
|
||||||
|
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPresetRange(preset: Exclude<TimePreset, 'custom'>) {
|
||||||
|
const end = new Date()
|
||||||
|
const start = new Date(end)
|
||||||
|
const hours = preset === '1h' ? 1 : preset === '6h' ? 6 : preset === '24h' ? 24 : 24 * 7
|
||||||
|
start.setHours(start.getHours() - hours)
|
||||||
|
return {
|
||||||
|
start_at: toDatetimeLocalValue(start),
|
||||||
|
end_at: toDatetimeLocalValue(end),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultFilters(): LogFilters {
|
||||||
|
return {
|
||||||
|
preset: '24h',
|
||||||
|
...getPresetRange('24h'),
|
||||||
|
request_id: '',
|
||||||
|
route: '',
|
||||||
|
operation: '',
|
||||||
|
status_code: '',
|
||||||
|
log_type: '',
|
||||||
|
level: '',
|
||||||
|
page: 1,
|
||||||
|
page_size: 50,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = ref<LogFilters>(createDefaultFilters())
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / filters.value.page_size)))
|
||||||
|
const visiblePageNumbers = computed(() => {
|
||||||
|
const totalPageCount = totalPages.value
|
||||||
|
const currentPage = filters.value.page
|
||||||
|
if (totalPageCount <= 5) {
|
||||||
|
return Array.from({ length: totalPageCount }, (_, index) => index + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let startPage = Math.max(1, currentPage - 2)
|
||||||
|
let endPage = Math.min(totalPageCount, startPage + 4)
|
||||||
|
|
||||||
|
if (endPage - startPage < 4) {
|
||||||
|
startPage = Math.max(1, endPage - 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from({ length: endPage - startPage + 1 }, (_, index) => startPage + index)
|
||||||
|
})
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
function formatTime(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return '-'
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
if (Number.isNaN(d.getTime())) return dateStr
|
||||||
|
return d.toLocaleString('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number | null): string {
|
||||||
|
if (ms == null) return ''
|
||||||
|
if (ms < 1000) return `${ms}ms`
|
||||||
|
return `${(ms / 1000).toFixed(1)}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDetails(details: Record<string, unknown> | null): string {
|
||||||
|
if (!details) return ''
|
||||||
|
return JSON.stringify(details, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDetails(logId: string) {
|
||||||
|
expandedLogId.value = expandedLogId.value === logId ? null : logId
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFilters(): LogQueryParams {
|
||||||
|
return {
|
||||||
|
log_type: filters.value.log_type || undefined,
|
||||||
|
level: filters.value.level || undefined,
|
||||||
|
request_id: filters.value.request_id.trim() || undefined,
|
||||||
|
route: filters.value.route.trim() || undefined,
|
||||||
|
operation: filters.value.operation.trim() || undefined,
|
||||||
|
status_code: filters.value.status_code ? Number(filters.value.status_code) : undefined,
|
||||||
|
start_at: filters.value.start_at ? new Date(filters.value.start_at).toISOString() : undefined,
|
||||||
|
end_at: filters.value.end_at ? new Date(filters.value.end_at).toISOString() : undefined,
|
||||||
|
page: filters.value.page,
|
||||||
|
page_size: filters.value.page_size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDateRange() {
|
||||||
|
if (filters.value.start_at && filters.value.end_at && filters.value.start_at > filters.value.end_at) {
|
||||||
|
errorMessage.value = '开始时间不能晚于结束时间'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLogs() {
|
||||||
|
loading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
try {
|
||||||
|
const requestedPage = filters.value.page
|
||||||
|
const res = await logApi.list(normalizeFilters())
|
||||||
|
logs.value = res.data.logs
|
||||||
|
total.value = res.data.total
|
||||||
|
const maxPage = Math.max(1, Math.ceil(res.data.total / filters.value.page_size))
|
||||||
|
if (requestedPage > maxPage) {
|
||||||
|
filters.value.page = maxPage
|
||||||
|
if (maxPage !== requestedPage) {
|
||||||
|
await fetchLogs()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
errorMessage.value = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '加载日志失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStats() {
|
||||||
|
try {
|
||||||
|
const res = await logApi.getStats(normalizeFilters())
|
||||||
|
stats.value = res.data
|
||||||
|
} catch {
|
||||||
|
if (!errorMessage.value) {
|
||||||
|
errorMessage.value = '加载日志统计失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
if (!validateDateRange()) return
|
||||||
|
await Promise.all([fetchLogs(), fetchStats()])
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
filters.value.page = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
filters.value = createDefaultFilters()
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPreset(preset: TimePreset) {
|
||||||
|
filters.value.preset = preset
|
||||||
|
if (preset !== 'custom') {
|
||||||
|
const range = getPresetRange(preset)
|
||||||
|
filters.value.start_at = range.start_at
|
||||||
|
filters.value.end_at = range.end_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markCustomRange() {
|
||||||
|
filters.value.preset = 'custom'
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAutoRefresh() {
|
||||||
|
autoRefresh.value = !autoRefresh.value
|
||||||
|
if (autoRefresh.value) {
|
||||||
|
refreshInterval = window.setInterval(() => {
|
||||||
|
loadData()
|
||||||
|
}, 5000)
|
||||||
|
} else if (refreshInterval) {
|
||||||
|
clearInterval(refreshInterval)
|
||||||
|
refreshInterval = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToPage(page: number) {
|
||||||
|
if (page < 1 || page > totalPages.value || page === filters.value.page) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filters.value.page = page
|
||||||
|
fetchLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevPage() {
|
||||||
|
goToPage(filters.value.page - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextPage() {
|
||||||
|
goToPage(filters.value.page + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePageSize(size: number) {
|
||||||
|
filters.value.page_size = size
|
||||||
|
filters.value.page = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadData)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (refreshInterval) {
|
||||||
|
clearInterval(refreshInterval)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="log-view">
|
||||||
|
<div class="view-header">
|
||||||
|
<div class="header-title">
|
||||||
|
<Terminal :size="16" />
|
||||||
|
<span>运行日志</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="btn-icon" :class="{ active: autoRefresh }" @click="toggleAutoRefresh" title="自动刷新">
|
||||||
|
<RefreshCw :size="14" :class="{ spinning: autoRefresh }" />
|
||||||
|
</button>
|
||||||
|
<button class="btn-icon" @click="loadData" title="刷新">
|
||||||
|
<RefreshCw :size="14" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="stats" class="stats-overview">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">总计</div>
|
||||||
|
<div class="stat-value">{{ stats.total }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">智能体</div>
|
||||||
|
<div class="stat-value cyan">{{ stats.by_type.agent }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">系统</div>
|
||||||
|
<div class="stat-value purple">{{ stats.by_type.system }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">问答</div>
|
||||||
|
<div class="stat-value amber">{{ stats.by_type.chat }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">错误</div>
|
||||||
|
<div class="stat-value red">{{ stats.by_level.error }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<div class="toolbar-row presets-row">
|
||||||
|
<button
|
||||||
|
v-for="preset in timePresets"
|
||||||
|
:key="preset.value"
|
||||||
|
class="filter-btn"
|
||||||
|
:class="{ active: filters.preset === preset.value }"
|
||||||
|
@click="applyPreset(preset.value)"
|
||||||
|
>
|
||||||
|
{{ preset.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-row fields-row">
|
||||||
|
<input v-model="filters.start_at" class="filter-input" type="datetime-local" @input="markCustomRange" />
|
||||||
|
<input v-model="filters.end_at" class="filter-input" type="datetime-local" @input="markCustomRange" />
|
||||||
|
<input v-model="filters.request_id" class="filter-input" type="text" placeholder="Request ID" />
|
||||||
|
<input v-model="filters.route" class="filter-input" type="text" placeholder="Route" />
|
||||||
|
<input v-model="filters.operation" class="filter-input" type="text" placeholder="Operation" />
|
||||||
|
<select v-model="filters.status_code" class="filter-select">
|
||||||
|
<option v-for="option in statusCodeOptions" :key="option.label" :value="option.value">{{ option.label }}</option>
|
||||||
|
</select>
|
||||||
|
<select v-model="filters.log_type" class="filter-select">
|
||||||
|
<option v-for="option in logTypes" :key="option.label" :value="option.value">{{ option.label }}</option>
|
||||||
|
</select>
|
||||||
|
<select v-model="filters.level" class="filter-select">
|
||||||
|
<option v-for="option in logLevels" :key="option.label" :value="option.value">{{ option.label }}</option>
|
||||||
|
</select>
|
||||||
|
<button class="action-btn primary" @click="applyFilters">查询</button>
|
||||||
|
<button class="action-btn" @click="resetFilters">重置</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="log-list">
|
||||||
|
<div v-if="loading && logs.length === 0" class="loading-state">
|
||||||
|
<RefreshCw :size="20" class="spinning" />
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="errorMessage" class="empty-state error-state">
|
||||||
|
<AlertCircle :size="32" />
|
||||||
|
<span>{{ errorMessage }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="logs.length === 0" class="empty-state">
|
||||||
|
<Terminal :size="32" />
|
||||||
|
<span>暂无日志</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="log-items">
|
||||||
|
<div v-for="log in logs" :key="log.id" class="log-item">
|
||||||
|
<div class="log-summary" @click="toggleDetails(log.id)">
|
||||||
|
<div class="log-meta">
|
||||||
|
<span class="log-type" :class="log.type">{{ typeLabels[log.type] || log.type }}</span>
|
||||||
|
<span class="log-level" :style="{ color: levelColors[log.level] }">{{ log.level }}</span>
|
||||||
|
<span v-if="log.duration_ms !== null" class="log-duration">{{ formatDuration(log.duration_ms) }}</span>
|
||||||
|
<span v-if="log.status_code" class="log-status">HTTP {{ log.status_code }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="log-message">{{ log.message }}</div>
|
||||||
|
<div class="log-footer">
|
||||||
|
<span v-if="log.source" class="log-source">{{ log.source }}</span>
|
||||||
|
<span v-if="log.operation" class="log-operation">{{ log.operation }}</span>
|
||||||
|
<span class="log-time">{{ formatTime(log.created_at) }}</span>
|
||||||
|
<span class="log-expand">
|
||||||
|
<ChevronDown v-if="expandedLogId === log.id" :size="14" />
|
||||||
|
<ChevronRight v-else :size="14" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="expandedLogId === log.id" class="log-details">
|
||||||
|
<div v-if="log.request_id" class="detail-row"><strong>Request ID:</strong> {{ log.request_id }}</div>
|
||||||
|
<div v-if="log.route || log.method" class="detail-row"><strong>Route:</strong> {{ log.method || '-' }} {{ log.route || '-' }}</div>
|
||||||
|
<div v-if="log.error_type" class="detail-row"><strong>Error:</strong> {{ log.error_type }}</div>
|
||||||
|
<pre v-if="log.details" class="detail-json">{{ formatDetails(log.details) }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination">
|
||||||
|
<div class="page-summary">共 {{ total }} 条 · 第 {{ filters.page }} / {{ totalPages }} 页</div>
|
||||||
|
<div class="page-controls">
|
||||||
|
<button class="page-btn" :disabled="filters.page === 1" @click="prevPage">上一页</button>
|
||||||
|
<button
|
||||||
|
v-for="page in visiblePageNumbers"
|
||||||
|
:key="page"
|
||||||
|
class="page-btn page-number"
|
||||||
|
:class="{ active: page === filters.page }"
|
||||||
|
@click="goToPage(page)"
|
||||||
|
>
|
||||||
|
{{ page }}
|
||||||
|
</button>
|
||||||
|
<button class="page-btn" :disabled="filters.page >= totalPages" @click="nextPage">下一页</button>
|
||||||
|
<select class="filter-select page-size-select" :value="filters.page_size" @change="changePageSize(Number(($event.target as HTMLSelectElement).value))">
|
||||||
|
<option v-for="size in pageSizeOptions" :key="size" :value="size">{{ size }}/页</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.log-view {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 24px;
|
||||||
|
background: var(--bg-void);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover, .btn-icon.active {
|
||||||
|
background: var(--accent-cyan-dim);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon .spinning {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-overview {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value.cyan { color: var(--accent-cyan); }
|
||||||
|
.stat-value.purple { color: var(--accent-purple); }
|
||||||
|
.stat-value.amber { color: var(--accent-amber); }
|
||||||
|
.stat-value.red { color: var(--accent-red); }
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn,
|
||||||
|
.filter-input,
|
||||||
|
.filter-select,
|
||||||
|
.action-btn,
|
||||||
|
.page-btn {
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn,
|
||||||
|
.action-btn,
|
||||||
|
.page-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.active,
|
||||||
|
.filter-btn:hover,
|
||||||
|
.action-btn:hover,
|
||||||
|
.page-btn:hover:not(:disabled),
|
||||||
|
.page-btn.active,
|
||||||
|
.filter-input:focus,
|
||||||
|
.filter-select:focus {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.active,
|
||||||
|
.action-btn.primary,
|
||||||
|
.page-btn.active {
|
||||||
|
background: var(--accent-cyan-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input,
|
||||||
|
.filter-select {
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
background: var(--bg-void);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state,
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 60px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-items {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item {
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-summary {
|
||||||
|
padding: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item:hover {
|
||||||
|
background: rgba(0, 245, 212, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-type {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-type.agent {
|
||||||
|
background: rgba(0, 245, 212, 0.1);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-type.system {
|
||||||
|
background: rgba(168, 85, 247, 0.1);
|
||||||
|
color: var(--accent-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-type.chat {
|
||||||
|
background: rgba(249, 168, 37, 0.1);
|
||||||
|
color: var(--accent-amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-level {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-duration {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-message {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-status,
|
||||||
|
.log-operation,
|
||||||
|
.log-expand {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-details {
|
||||||
|
padding: 0 12px 12px;
|
||||||
|
border-top: 1px solid var(--border-dim);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-json {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--bg-void);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow-x: auto;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-summary {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-number {
|
||||||
|
min-width: 36px;
|
||||||
|
padding-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-size-select {
|
||||||
|
min-width: 90px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
314
frontend/src/pages/settings/composables/useSettingsView.ts
Normal file
314
frontend/src/pages/settings/composables/useSettingsView.ts
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { settingsApi, type LLMConfig, type LLMModelConfig, type LLMType, type SchedulerConfig } from '@/api/settings'
|
||||||
|
|
||||||
|
type ToastState = {
|
||||||
|
show: boolean
|
||||||
|
message: string
|
||||||
|
type: 'success' | 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditingSnapshot = {
|
||||||
|
type: string
|
||||||
|
index: number
|
||||||
|
data: LLMModelConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProfileState = {
|
||||||
|
email: string
|
||||||
|
full_name: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneLLMConfig(config: LLMConfig): LLMConfig {
|
||||||
|
return JSON.parse(JSON.stringify(config)) as LLMConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneSchedulerConfig(config: SchedulerConfig): SchedulerConfig {
|
||||||
|
return JSON.parse(JSON.stringify(config)) as SchedulerConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown, fallback: string) {
|
||||||
|
return (error as { response?: { data?: { detail?: string } } })?.response?.data?.detail || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSettingsView() {
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const savingModel = ref<string | null>(null)
|
||||||
|
const toast = ref<ToastState>({
|
||||||
|
show: false,
|
||||||
|
message: '',
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
|
||||||
|
const expandedRow = ref<string | null>(null)
|
||||||
|
const editingSnapshot = ref<EditingSnapshot | null>(null)
|
||||||
|
|
||||||
|
const profile = ref<ProfileState>({
|
||||||
|
email: '',
|
||||||
|
full_name: '',
|
||||||
|
created_at: '',
|
||||||
|
})
|
||||||
|
const originalProfile = ref({ email: '', full_name: '' })
|
||||||
|
const newPassword = ref('')
|
||||||
|
|
||||||
|
const llmConfig = ref<LLMConfig>({
|
||||||
|
chat: [],
|
||||||
|
vlm: [],
|
||||||
|
embedding: [],
|
||||||
|
rerank: [],
|
||||||
|
})
|
||||||
|
const originalLlmConfig = ref<LLMConfig>({
|
||||||
|
chat: [],
|
||||||
|
vlm: [],
|
||||||
|
embedding: [],
|
||||||
|
rerank: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const schedulerConfig = ref<SchedulerConfig>({
|
||||||
|
daily_plan_time: '08:00',
|
||||||
|
forum_scan_interval_minutes: 30,
|
||||||
|
todo_ai_generate_time: '08:00',
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
const originalSchedulerConfig = ref<SchedulerConfig>({})
|
||||||
|
|
||||||
|
const showRequiredWarning = computed(() => {
|
||||||
|
return (llmConfig.value.chat?.length || 0) === 0 ||
|
||||||
|
(llmConfig.value.embedding?.length || 0) === 0 ||
|
||||||
|
(llmConfig.value.rerank?.length || 0) === 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const isProfileDirty = computed(() => {
|
||||||
|
return profile.value.full_name !== originalProfile.value.full_name || newPassword.value !== ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const isSchedulerDirty = computed(() => {
|
||||||
|
return JSON.stringify(schedulerConfig.value) !== JSON.stringify(originalSchedulerConfig.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
function showToast(message: string, type: 'success' | 'error' = 'success') {
|
||||||
|
toast.value = { show: true, message, type }
|
||||||
|
window.setTimeout(() => {
|
||||||
|
toast.value.show = false
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyModel(type: string): LLMModelConfig {
|
||||||
|
return {
|
||||||
|
name: `${type.toUpperCase()}-${Date.now()}`,
|
||||||
|
provider: 'openai',
|
||||||
|
model: type === 'chat'
|
||||||
|
? 'gpt-4o'
|
||||||
|
: type === 'vlm'
|
||||||
|
? 'gpt-4o'
|
||||||
|
: type === 'embedding'
|
||||||
|
? 'text-embedding-3-small'
|
||||||
|
: 'bge-reranker-v2',
|
||||||
|
base_url: '',
|
||||||
|
api_key: '',
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRowKey(type: string, index: number): string {
|
||||||
|
return `${type}-${index}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function addModel(type: string) {
|
||||||
|
if (!llmConfig.value[type as keyof LLMConfig]) {
|
||||||
|
llmConfig.value[type as keyof LLMConfig] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((type === 'embedding' || type === 'rerank') &&
|
||||||
|
llmConfig.value[type as keyof LLMConfig]!.length >= 1) {
|
||||||
|
showToast(`${type === 'embedding' ? 'Embedding' : 'Rerank'} 最多配置 1 个`, 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newModel = createEmptyModel(type)
|
||||||
|
llmConfig.value[type as keyof LLMConfig]!.push(newModel)
|
||||||
|
const newIndex = llmConfig.value[type as keyof LLMConfig]!.length - 1
|
||||||
|
expandedRow.value = getRowKey(type, newIndex)
|
||||||
|
editingSnapshot.value = { type, index: newIndex, data: JSON.parse(JSON.stringify(newModel)) as LLMModelConfig }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeModel(type: string, index: number) {
|
||||||
|
if ((type === 'embedding' || type === 'rerank') &&
|
||||||
|
llmConfig.value[type as keyof LLMConfig]!.length <= 1) {
|
||||||
|
showToast(`${type === 'embedding' ? 'Embedding' : 'Rerank'} 为知识库必填,至少保留 1 个`, 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
llmConfig.value[type as keyof LLMConfig]!.splice(index, 1)
|
||||||
|
expandedRow.value = null
|
||||||
|
editingSnapshot.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
await settingsApi.updateLLM(llmConfig.value)
|
||||||
|
originalLlmConfig.value = cloneLLMConfig(llmConfig.value)
|
||||||
|
showToast('删除成功')
|
||||||
|
} catch (error: unknown) {
|
||||||
|
showToast(getErrorMessage(error, '删除失败'), 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRow(type: string, index: number, model: LLMModelConfig) {
|
||||||
|
const key = getRowKey(type, index)
|
||||||
|
if (expandedRow.value === key) {
|
||||||
|
expandedRow.value = null
|
||||||
|
editingSnapshot.value = null
|
||||||
|
} else {
|
||||||
|
editingSnapshot.value = { type, index, data: JSON.parse(JSON.stringify(model)) as LLMModelConfig }
|
||||||
|
expandedRow.value = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateModel(type: string, index: number, model: LLMModelConfig) {
|
||||||
|
llmConfig.value[type as keyof LLMConfig]![index] = model
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await settingsApi.get()
|
||||||
|
profile.value = {
|
||||||
|
email: response.data.profile.email,
|
||||||
|
full_name: response.data.profile.full_name || '',
|
||||||
|
created_at: response.data.profile.created_at,
|
||||||
|
}
|
||||||
|
originalProfile.value = { email: profile.value.email, full_name: profile.value.full_name }
|
||||||
|
|
||||||
|
if (response.data.llm_config) {
|
||||||
|
llmConfig.value = {
|
||||||
|
chat: response.data.llm_config.chat || [],
|
||||||
|
vlm: response.data.llm_config.vlm || [],
|
||||||
|
embedding: response.data.llm_config.embedding || [],
|
||||||
|
rerank: response.data.llm_config.rerank || [],
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
llmConfig.value = { chat: [], vlm: [], embedding: [], rerank: [] }
|
||||||
|
}
|
||||||
|
originalLlmConfig.value = cloneLLMConfig(llmConfig.value)
|
||||||
|
|
||||||
|
if (response.data.scheduler_config && Object.keys(response.data.scheduler_config).length > 0) {
|
||||||
|
schedulerConfig.value = response.data.scheduler_config as SchedulerConfig
|
||||||
|
}
|
||||||
|
originalSchedulerConfig.value = cloneSchedulerConfig(schedulerConfig.value)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载设置失败', error)
|
||||||
|
showToast('加载设置失败', 'error')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProfile() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await settingsApi.updateProfile({
|
||||||
|
full_name: profile.value.full_name,
|
||||||
|
password: newPassword.value || undefined,
|
||||||
|
})
|
||||||
|
originalProfile.value = { email: profile.value.email, full_name: profile.value.full_name }
|
||||||
|
newPassword.value = ''
|
||||||
|
showToast('资料保存成功')
|
||||||
|
} catch (error: unknown) {
|
||||||
|
showToast(getErrorMessage(error, '保存失败'), 'error')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveModel(type: string, index: number, model: LLMModelConfig) {
|
||||||
|
const key = getRowKey(type, index)
|
||||||
|
llmConfig.value[type as keyof LLMConfig]![index] = JSON.parse(JSON.stringify(model)) as LLMModelConfig
|
||||||
|
savingModel.value = key
|
||||||
|
|
||||||
|
try {
|
||||||
|
await settingsApi.updateLLM(llmConfig.value)
|
||||||
|
originalLlmConfig.value = cloneLLMConfig(llmConfig.value)
|
||||||
|
expandedRow.value = null
|
||||||
|
editingSnapshot.value = null
|
||||||
|
showToast('保存成功')
|
||||||
|
} catch (error: unknown) {
|
||||||
|
showToast(getErrorMessage(error, '保存失败'), 'error')
|
||||||
|
} finally {
|
||||||
|
savingModel.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testModel(type: string, index: number, model: LLMModelConfig) {
|
||||||
|
try {
|
||||||
|
const response = await settingsApi.testLLM({
|
||||||
|
type: type as LLMType,
|
||||||
|
provider: model.provider,
|
||||||
|
model: model.model,
|
||||||
|
base_url: model.base_url,
|
||||||
|
api_key: model.api_key,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
llmConfig.value[type as keyof LLMConfig]![index].enabled = true
|
||||||
|
showToast('连接成功')
|
||||||
|
} else {
|
||||||
|
llmConfig.value[type as keyof LLMConfig]![index].enabled = false
|
||||||
|
showToast(`连接失败: ${response.data.error}`, 'error')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
llmConfig.value[type as keyof LLMConfig]![index].enabled = false
|
||||||
|
showToast('测试连接失败', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveScheduler() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await settingsApi.updateScheduler(schedulerConfig.value)
|
||||||
|
originalSchedulerConfig.value = cloneSchedulerConfig(schedulerConfig.value)
|
||||||
|
showToast('定时任务配置保存成功')
|
||||||
|
} catch (error: unknown) {
|
||||||
|
showToast(getErrorMessage(error, '保存失败'), 'error')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetProfile() {
|
||||||
|
profile.value.full_name = originalProfile.value.full_name
|
||||||
|
newPassword.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetScheduler() {
|
||||||
|
schedulerConfig.value = cloneSchedulerConfig(originalSchedulerConfig.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadSettings)
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
savingModel,
|
||||||
|
toast,
|
||||||
|
expandedRow,
|
||||||
|
editingSnapshot,
|
||||||
|
showRequiredWarning,
|
||||||
|
profile,
|
||||||
|
newPassword,
|
||||||
|
llmConfig,
|
||||||
|
schedulerConfig,
|
||||||
|
isProfileDirty,
|
||||||
|
isSchedulerDirty,
|
||||||
|
addModel,
|
||||||
|
removeModel,
|
||||||
|
getRowKey,
|
||||||
|
toggleRow,
|
||||||
|
updateModel,
|
||||||
|
saveProfile,
|
||||||
|
saveModel,
|
||||||
|
testModel,
|
||||||
|
saveScheduler,
|
||||||
|
resetProfile,
|
||||||
|
resetScheduler,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,321 +1,32 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
|
||||||
import { settingsApi, type LLMConfig, type SchedulerConfig, type LLMModelConfig, type LLMProvider } from '@/api/settings'
|
|
||||||
import LLMTableRow from '@/components/settings/LLMTableRow.vue'
|
import LLMTableRow from '@/components/settings/LLMTableRow.vue'
|
||||||
import { Save, RotateCcw, Plus } from 'lucide-vue-next'
|
import { Save, RotateCcw, Plus } from 'lucide-vue-next'
|
||||||
|
import { useSettingsView } from '@/pages/settings/composables/useSettingsView'
|
||||||
|
|
||||||
// 状态
|
const {
|
||||||
const loading = ref(false)
|
loading,
|
||||||
const saving = ref(false)
|
saving,
|
||||||
const savingModel = ref<string | null>(null) // 当前正在保存的模型 key
|
toast,
|
||||||
const toast = ref<{ show: boolean; message: string; type: 'success' | 'error' }>({
|
expandedRow,
|
||||||
show: false,
|
showRequiredWarning,
|
||||||
message: '',
|
profile,
|
||||||
type: 'success'
|
newPassword,
|
||||||
})
|
llmConfig,
|
||||||
|
schedulerConfig,
|
||||||
// 展开的行
|
isProfileDirty,
|
||||||
const expandedRow = ref<string | null>(null) // 'chat-0', 'vlm-0' 等
|
isSchedulerDirty,
|
||||||
|
addModel,
|
||||||
// 当前正在编辑的模型快照(用于取消时恢复)
|
removeModel,
|
||||||
const editingSnapshot = ref<{ type: string; index: number; data: LLMModelConfig } | null>(null)
|
getRowKey,
|
||||||
|
toggleRow,
|
||||||
// 必填警告
|
updateModel,
|
||||||
const showRequiredWarning = computed(() => {
|
saveProfile,
|
||||||
return llmConfig.value.chat.length === 0 ||
|
saveModel,
|
||||||
llmConfig.value.embedding.length === 0 ||
|
testModel,
|
||||||
llmConfig.value.rerank.length === 0
|
saveScheduler,
|
||||||
})
|
resetProfile,
|
||||||
|
resetScheduler,
|
||||||
// 用户资料
|
} = useSettingsView()
|
||||||
const profile = ref({
|
|
||||||
email: '',
|
|
||||||
full_name: '',
|
|
||||||
created_at: ''
|
|
||||||
})
|
|
||||||
const originalProfile = ref({ email: '', full_name: '' })
|
|
||||||
const newPassword = ref('')
|
|
||||||
|
|
||||||
// LLM 配置 - 每种类型支持多个模型
|
|
||||||
const llmConfig = ref<LLMConfig>({
|
|
||||||
chat: [],
|
|
||||||
vlm: [],
|
|
||||||
embedding: [],
|
|
||||||
rerank: []
|
|
||||||
})
|
|
||||||
const originalLlmConfig = ref<LLMConfig>({
|
|
||||||
chat: [],
|
|
||||||
vlm: [],
|
|
||||||
embedding: [],
|
|
||||||
rerank: []
|
|
||||||
})
|
|
||||||
|
|
||||||
// 定时任务配置
|
|
||||||
const schedulerConfig = ref<SchedulerConfig>({
|
|
||||||
daily_plan_time: '08:00',
|
|
||||||
forum_scan_interval_minutes: 30,
|
|
||||||
todo_ai_generate_time: '08:00',
|
|
||||||
enabled: true
|
|
||||||
})
|
|
||||||
const originalSchedulerConfig = ref<SchedulerConfig>({})
|
|
||||||
|
|
||||||
// 是否有修改
|
|
||||||
const isProfileDirty = computed(() => {
|
|
||||||
return profile.value.full_name !== originalProfile.value.full_name || newPassword.value !== ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const isLlmDirty = computed(() => {
|
|
||||||
return JSON.stringify(llmConfig.value) !== JSON.stringify(originalLlmConfig.value)
|
|
||||||
})
|
|
||||||
|
|
||||||
const isSchedulerDirty = computed(() => {
|
|
||||||
return JSON.stringify(schedulerConfig.value) !== JSON.stringify(originalSchedulerConfig.value)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 检查单个模型是否有未保存的更改
|
|
||||||
function isModelDirty(type: string, index: number): boolean {
|
|
||||||
const original = originalLlmConfig.value[type as keyof LLMConfig]?.[index]
|
|
||||||
const current = llmConfig.value[type as keyof LLMConfig]?.[index]
|
|
||||||
if (!original || !current) return false
|
|
||||||
return JSON.stringify(original) !== JSON.stringify(current)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建空的模型配置
|
|
||||||
function createEmptyModel(type: string): LLMModelConfig {
|
|
||||||
return {
|
|
||||||
name: `${type.toUpperCase()}-${Date.now()}`,
|
|
||||||
provider: 'openai',
|
|
||||||
model: type === 'chat' ? 'gpt-4o' : type === 'vlm' ? 'gpt-4o' : type === 'embedding' ? 'text-embedding-3-small' : 'bge-reranker-v2',
|
|
||||||
base_url: '',
|
|
||||||
api_key: '',
|
|
||||||
enabled: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加模型
|
|
||||||
function addModel(type: string) {
|
|
||||||
if (!llmConfig.value[type as keyof LLMConfig]) {
|
|
||||||
llmConfig.value[type as keyof LLMConfig] = []
|
|
||||||
}
|
|
||||||
// embedding/rerank 最多 1 个
|
|
||||||
if ((type === 'embedding' || type === 'rerank') &&
|
|
||||||
llmConfig.value[type as keyof LLMConfig]!.length >= 1) {
|
|
||||||
showToast(`${type === 'embedding' ? 'Embedding' : 'Rerank'} 最多配置 1 个`, 'error')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const newModel = createEmptyModel(type)
|
|
||||||
llmConfig.value[type as keyof LLMConfig]!.push(newModel)
|
|
||||||
// 自动展开新添加的行
|
|
||||||
const newIndex = llmConfig.value[type as keyof LLMConfig]!.length - 1
|
|
||||||
expandedRow.value = getRowKey(type, newIndex)
|
|
||||||
editingSnapshot.value = { type, index: newIndex, data: JSON.parse(JSON.stringify(newModel)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除模型
|
|
||||||
async function removeModel(type: string, index: number) {
|
|
||||||
// embedding/rerank 为知识库必填,至少保留 1 个
|
|
||||||
if ((type === 'embedding' || type === 'rerank') &&
|
|
||||||
llmConfig.value[type as keyof LLMConfig]!.length <= 1) {
|
|
||||||
showToast(`${type === 'embedding' ? 'Embedding' : 'Rerank'} 为知识库必填,至少保留 1 个`, 'error')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
llmConfig.value[type as keyof LLMConfig]!.splice(index, 1)
|
|
||||||
expandedRow.value = null
|
|
||||||
editingSnapshot.value = null
|
|
||||||
// 自动保存到后端
|
|
||||||
try {
|
|
||||||
await settingsApi.updateLLM(llmConfig.value)
|
|
||||||
originalLlmConfig.value = JSON.parse(JSON.stringify(llmConfig.value))
|
|
||||||
showToast('删除成功')
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '删除失败'
|
|
||||||
showToast(msg, 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 行标识
|
|
||||||
function getRowKey(type: string, index: number): string {
|
|
||||||
return `${type}-${index}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换行展开
|
|
||||||
function toggleRow(type: string, index: number, model: LLMModelConfig) {
|
|
||||||
const key = getRowKey(type, index)
|
|
||||||
if (expandedRow.value === key) {
|
|
||||||
expandedRow.value = null
|
|
||||||
editingSnapshot.value = null
|
|
||||||
} else {
|
|
||||||
// 保存快照用于取消
|
|
||||||
editingSnapshot.value = { type, index, data: JSON.parse(JSON.stringify(model)) }
|
|
||||||
expandedRow.value = key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 取消编辑
|
|
||||||
function cancelEdit(type: string, index: number) {
|
|
||||||
if (editingSnapshot.value && editingSnapshot.value.type === type && editingSnapshot.value.index === index) {
|
|
||||||
// 恢复原始数据
|
|
||||||
llmConfig.value[type as keyof LLMConfig]![index] = editingSnapshot.value.data
|
|
||||||
}
|
|
||||||
expandedRow.value = null
|
|
||||||
editingSnapshot.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新模型
|
|
||||||
function updateModel(type: string, index: number, model: LLMModelConfig) {
|
|
||||||
llmConfig.value[type as keyof LLMConfig]![index] = model
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载设置
|
|
||||||
async function loadSettings() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const res = await settingsApi.get()
|
|
||||||
profile.value = {
|
|
||||||
email: res.data.profile.email,
|
|
||||||
full_name: res.data.profile.full_name || '',
|
|
||||||
created_at: res.data.profile.created_at
|
|
||||||
}
|
|
||||||
originalProfile.value = { ...profile.value }
|
|
||||||
|
|
||||||
// 加载 LLM 配置
|
|
||||||
if (res.data.llm_config) {
|
|
||||||
llmConfig.value = {
|
|
||||||
chat: res.data.llm_config.chat || [],
|
|
||||||
vlm: res.data.llm_config.vlm || [],
|
|
||||||
embedding: res.data.llm_config.embedding || [],
|
|
||||||
rerank: res.data.llm_config.rerank || []
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
llmConfig.value = { chat: [], vlm: [], embedding: [], rerank: [] }
|
|
||||||
}
|
|
||||||
originalLlmConfig.value = JSON.parse(JSON.stringify(llmConfig.value))
|
|
||||||
|
|
||||||
if (res.data.scheduler_config && Object.keys(res.data.scheduler_config).length > 0) {
|
|
||||||
schedulerConfig.value = res.data.scheduler_config as SchedulerConfig
|
|
||||||
}
|
|
||||||
originalSchedulerConfig.value = JSON.parse(JSON.stringify(schedulerConfig.value))
|
|
||||||
} catch (e) {
|
|
||||||
console.error('加载设置失败', e)
|
|
||||||
showToast('加载设置失败', 'error')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存资料
|
|
||||||
async function saveProfile() {
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
await settingsApi.updateProfile({
|
|
||||||
full_name: profile.value.full_name,
|
|
||||||
password: newPassword.value || undefined
|
|
||||||
})
|
|
||||||
originalProfile.value = { email: profile.value.email, full_name: profile.value.full_name }
|
|
||||||
newPassword.value = ''
|
|
||||||
showToast('资料保存成功')
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败'
|
|
||||||
showToast(msg, 'error')
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存 LLM 配置
|
|
||||||
async function saveLLM() {
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
await settingsApi.updateLLM(llmConfig.value)
|
|
||||||
originalLlmConfig.value = JSON.parse(JSON.stringify(llmConfig.value))
|
|
||||||
showToast('LLM 配置保存成功')
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败'
|
|
||||||
showToast(msg, 'error')
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存单个模型
|
|
||||||
async function saveModel(type: string, index: number) {
|
|
||||||
const key = getRowKey(type, index)
|
|
||||||
savingModel.value = key
|
|
||||||
try {
|
|
||||||
// 发送完整的配置(包含该类型的所有模型)
|
|
||||||
await settingsApi.updateLLM(llmConfig.value)
|
|
||||||
|
|
||||||
// 更新原始配置(深拷贝当前完整配置)
|
|
||||||
originalLlmConfig.value = JSON.parse(JSON.stringify(llmConfig.value))
|
|
||||||
|
|
||||||
// 关闭展开的行
|
|
||||||
expandedRow.value = null
|
|
||||||
editingSnapshot.value = null
|
|
||||||
showToast('保存成功')
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败'
|
|
||||||
showToast(msg, 'error')
|
|
||||||
} finally {
|
|
||||||
savingModel.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试 LLM 连接
|
|
||||||
async function testModel(type: string, index: number, model: LLMModelConfig) {
|
|
||||||
try {
|
|
||||||
const res = await settingsApi.testLLM({ type: type as any, ...model })
|
|
||||||
if (res.data.success) {
|
|
||||||
// 测试通过,标记为可用
|
|
||||||
llmConfig.value[type as keyof LLMConfig]![index].enabled = true
|
|
||||||
showToast('连接成功')
|
|
||||||
} else {
|
|
||||||
llmConfig.value[type as keyof LLMConfig]![index].enabled = false
|
|
||||||
showToast(`连接失败: ${res.data.error}`, 'error')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
llmConfig.value[type as keyof LLMConfig]![index].enabled = false
|
|
||||||
showToast('测试连接失败', 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存定时任务配置
|
|
||||||
async function saveScheduler() {
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
await settingsApi.updateScheduler(schedulerConfig.value)
|
|
||||||
originalSchedulerConfig.value = JSON.parse(JSON.stringify(schedulerConfig.value))
|
|
||||||
showToast('定时任务配置保存成功')
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败'
|
|
||||||
showToast(msg, 'error')
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重置表单
|
|
||||||
function resetProfile() {
|
|
||||||
profile.value.full_name = originalProfile.value.full_name
|
|
||||||
newPassword.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetLLM() {
|
|
||||||
llmConfig.value = JSON.parse(JSON.stringify(originalLlmConfig.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetScheduler() {
|
|
||||||
schedulerConfig.value = JSON.parse(JSON.stringify(originalSchedulerConfig.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toast 提示
|
|
||||||
function showToast(message: string, type: 'success' | 'error' = 'success') {
|
|
||||||
toast.value = { show: true, message, type }
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.value.show = false
|
|
||||||
}, 3000)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadSettings)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -403,7 +114,7 @@ onMounted(loadSettings)
|
|||||||
@update="(m) => updateModel('chat', index, m)"
|
@update="(m) => updateModel('chat', index, m)"
|
||||||
@delete="removeModel('chat', index)"
|
@delete="removeModel('chat', index)"
|
||||||
@test="(m) => testModel('chat', index, m)"
|
@test="(m) => testModel('chat', index, m)"
|
||||||
@save="(m) => saveModel('chat', index)"
|
@save="(m) => saveModel('chat', index, m)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -427,7 +138,7 @@ onMounted(loadSettings)
|
|||||||
@update="(m) => updateModel('vlm', index, m)"
|
@update="(m) => updateModel('vlm', index, m)"
|
||||||
@delete="removeModel('vlm', index)"
|
@delete="removeModel('vlm', index)"
|
||||||
@test="(m) => testModel('vlm', index, m)"
|
@test="(m) => testModel('vlm', index, m)"
|
||||||
@save="(m) => saveModel('vlm', index)"
|
@save="(m) => saveModel('vlm', index, m)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -451,7 +162,7 @@ onMounted(loadSettings)
|
|||||||
@update="(m) => updateModel('embedding', index, m)"
|
@update="(m) => updateModel('embedding', index, m)"
|
||||||
@delete="removeModel('embedding', index)"
|
@delete="removeModel('embedding', index)"
|
||||||
@test="(m) => testModel('embedding', index, m)"
|
@test="(m) => testModel('embedding', index, m)"
|
||||||
@save="(m) => saveModel('embedding', index)"
|
@save="(m) => saveModel('embedding', index, m)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -475,7 +186,7 @@ onMounted(loadSettings)
|
|||||||
@update="(m) => updateModel('rerank', index, m)"
|
@update="(m) => updateModel('rerank', index, m)"
|
||||||
@delete="removeModel('rerank', index)"
|
@delete="removeModel('rerank', index)"
|
||||||
@test="(m) => testModel('rerank', index, m)"
|
@test="(m) => testModel('rerank', index, m)"
|
||||||
@save="(m) => saveModel('rerank', index)"
|
@save="(m) => saveModel('rerank', index, m)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,5 +1,27 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
|
function animateIn(el: Element, done: () => void) {
|
||||||
|
const target = el as HTMLElement
|
||||||
|
target.animate(
|
||||||
|
[
|
||||||
|
{ opacity: '0', transform: 'translateY(12px)' },
|
||||||
|
{ opacity: '1', transform: 'translateY(0)' },
|
||||||
|
],
|
||||||
|
{ duration: 220, easing: 'ease-out', fill: 'forwards' },
|
||||||
|
).finished.finally(done)
|
||||||
|
}
|
||||||
|
|
||||||
|
function animateOut(el: Element, done: () => void) {
|
||||||
|
const target = el as HTMLElement
|
||||||
|
target.animate(
|
||||||
|
[
|
||||||
|
{ opacity: '1', transform: 'translateY(0)' },
|
||||||
|
{ opacity: '0', transform: 'translateY(12px)' },
|
||||||
|
],
|
||||||
|
{ duration: 180, easing: 'ease-in', fill: 'forwards' },
|
||||||
|
).finished.finally(done)
|
||||||
|
}
|
||||||
import { skillApi, type Skill, type SkillCreate } from '@/api/skill'
|
import { skillApi, type Skill, type SkillCreate } from '@/api/skill'
|
||||||
import { Bot, Plus, Edit2, Trash2, Eye, EyeOff, Copy, X } from 'lucide-vue-next'
|
import { Bot, Plus, Edit2, Trash2, Eye, EyeOff, Copy, X } from 'lucide-vue-next'
|
||||||
|
|
||||||
@@ -2,12 +2,16 @@
|
|||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import * as statsApi from '@/api/stats'
|
import * as statsApi from '@/api/stats'
|
||||||
import { Cpu, HardDrive, MemoryStick, Clock, TrendingUp, Tag } from 'lucide-vue-next'
|
import { Cpu, HardDrive, MemoryStick, Clock, TrendingUp, Tag } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const reloadPage = () => globalThis.location.reload()
|
||||||
import SectionHeader from '@/components/stats/SectionHeader.vue'
|
import SectionHeader from '@/components/stats/SectionHeader.vue'
|
||||||
import MetricCard from '@/components/stats/MetricCard.vue'
|
import MetricCard from '@/components/stats/MetricCard.vue'
|
||||||
import SummaryRow from '@/components/stats/SummaryRow.vue'
|
|
||||||
import MiniLineChart from '@/components/stats/MiniLineChart.vue'
|
import MiniLineChart from '@/components/stats/MiniLineChart.vue'
|
||||||
import MiniBarChart from '@/components/stats/MiniBarChart.vue'
|
import MiniBarChart from '@/components/stats/MiniBarChart.vue'
|
||||||
|
|
||||||
|
type DailyPoint = { date: string; count: number }
|
||||||
|
type HourlyPoint = { hour: number; count: number }
|
||||||
|
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
const hasError = ref(false)
|
const hasError = ref(false)
|
||||||
|
|
||||||
@@ -63,27 +67,31 @@ onMounted(async () => {
|
|||||||
|
|
||||||
// 图表数据转换
|
// 图表数据转换
|
||||||
const convChartData = computed(() =>
|
const convChartData = computed(() =>
|
||||||
conversationStats.value?.daily_conversations?.map((d: any) => ({ date: d.date, value: d.count })) || []
|
conversationStats.value?.daily_conversations?.map((d: DailyPoint) => ({ date: d.date, value: d.count })) || []
|
||||||
)
|
)
|
||||||
|
|
||||||
const knowChartData = computed(() =>
|
const knowChartData = computed(() =>
|
||||||
knowledgeStats.value?.daily_new_tags?.map((d: any) => ({ date: d.date, value: d.count })) || []
|
knowledgeStats.value?.daily_new_tags?.map((d: DailyPoint) => ({ date: d.date, value: d.count })) || []
|
||||||
)
|
)
|
||||||
|
|
||||||
const kanbanNewData = computed(() =>
|
const kanbanNewData = computed(() =>
|
||||||
kanbanStats.value?.daily_new_tasks?.map((d: any) => d.count) || []
|
kanbanStats.value?.daily_new_tasks?.map((d: DailyPoint) => d.count) || []
|
||||||
)
|
)
|
||||||
const kanbanDoneData = computed(() =>
|
const kanbanDoneData = computed(() =>
|
||||||
kanbanStats.value?.daily_completed_tasks?.map((d: any) => d.count) || []
|
kanbanStats.value?.daily_completed_tasks?.map((d: DailyPoint) => d.count) || []
|
||||||
)
|
)
|
||||||
|
|
||||||
const communityChartData = computed(() =>
|
const communityChartData = computed(() =>
|
||||||
communityStats.value?.daily_posts?.map((d: any) => ({ date: d.date, value: d.count })) || []
|
communityStats.value?.daily_posts?.map((d: DailyPoint) => ({ date: d.date, value: d.count })) || []
|
||||||
)
|
)
|
||||||
|
|
||||||
const hourlyActivityData = computed(() =>
|
const hourlyActivityData = computed(() =>
|
||||||
personalInsights.value?.hourly_activity?.map((h: any) => h.count) || []
|
personalInsights.value?.hourly_activity?.map((h: HourlyPoint) => h.count) || []
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const convBarValues = computed(() => convChartData.value.map((d: { date: string; value: number }) => d.value))
|
||||||
|
const knowBarValues = computed(() => knowChartData.value.map((d: { date: string; value: number }) => d.value))
|
||||||
|
const communityBarValues = computed(() => communityChartData.value.map((d: { date: string; value: number }) => d.value))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -99,7 +107,7 @@ const hourlyActivityData = computed(() =>
|
|||||||
|
|
||||||
<div v-else-if="hasError" class="error-state">
|
<div v-else-if="hasError" class="error-state">
|
||||||
<span>Failed to load stats</span>
|
<span>Failed to load stats</span>
|
||||||
<button @click="() => window.location.reload()">Refresh</button>
|
<button @click="reloadPage">Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="stats-content">
|
<div v-else class="stats-content">
|
||||||
@@ -142,14 +150,14 @@ const hourlyActivityData = computed(() =>
|
|||||||
<div class="stat-bar-label">对话数</div>
|
<div class="stat-bar-label">对话数</div>
|
||||||
<div class="stat-bar-value">{{ formatNumber(conversationStats?.totals?.conversations || 0) }}</div>
|
<div class="stat-bar-value">{{ formatNumber(conversationStats?.totals?.conversations || 0) }}</div>
|
||||||
<div class="stat-bar-chart">
|
<div class="stat-bar-chart">
|
||||||
<MiniBarChart v-if="convChartData.length > 0" :data="convChartData.map(d => d.value)" color="var(--accent-cyan)" :height="30" />
|
<MiniBarChart v-if="convChartData.length > 0" :data="convBarValues" color="var(--accent-cyan)" :height="30" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-bar-item">
|
<div class="stat-bar-item">
|
||||||
<div class="stat-bar-label">消息数</div>
|
<div class="stat-bar-label">消息数</div>
|
||||||
<div class="stat-bar-value">{{ formatNumber(conversationStats?.totals?.messages || 0) }}</div>
|
<div class="stat-bar-value">{{ formatNumber(conversationStats?.totals?.messages || 0) }}</div>
|
||||||
<div class="stat-bar-chart">
|
<div class="stat-bar-chart">
|
||||||
<MiniBarChart v-if="convChartData.length > 0" :data="convChartData.map(d => d.value)" color="var(--accent-purple)" :height="30" />
|
<MiniBarChart v-if="convChartData.length > 0" :data="convBarValues" color="var(--accent-purple)" :height="30" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-bar-item">
|
<div class="stat-bar-item">
|
||||||
@@ -177,14 +185,14 @@ const hourlyActivityData = computed(() =>
|
|||||||
<div class="stat-bar-label">新标签</div>
|
<div class="stat-bar-label">新标签</div>
|
||||||
<div class="stat-bar-value">{{ formatNumber(knowledgeStats?.totals?.new_tags || 0) }}</div>
|
<div class="stat-bar-value">{{ formatNumber(knowledgeStats?.totals?.new_tags || 0) }}</div>
|
||||||
<div class="stat-bar-chart">
|
<div class="stat-bar-chart">
|
||||||
<MiniBarChart v-if="knowChartData.length > 0" :data="knowChartData.map(d => d.value)" color="var(--accent-purple)" :height="30" />
|
<MiniBarChart v-if="knowChartData.length > 0" :data="knowBarValues" color="var(--accent-purple)" :height="30" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-bar-item">
|
<div class="stat-bar-item">
|
||||||
<div class="stat-bar-label">文档数</div>
|
<div class="stat-bar-label">文档数</div>
|
||||||
<div class="stat-bar-value">{{ formatNumber(knowledgeStats?.totals?.documents || 0) }}</div>
|
<div class="stat-bar-value">{{ formatNumber(knowledgeStats?.totals?.documents || 0) }}</div>
|
||||||
<div class="stat-bar-chart">
|
<div class="stat-bar-chart">
|
||||||
<MiniBarChart v-if="knowChartData.length > 0" :data="knowChartData.map(d => d.value)" color="var(--accent-cyan)" :height="30" />
|
<MiniBarChart v-if="knowChartData.length > 0" :data="knowBarValues" color="var(--accent-cyan)" :height="30" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-bar-item">
|
<div class="stat-bar-item">
|
||||||
@@ -233,14 +241,14 @@ const hourlyActivityData = computed(() =>
|
|||||||
<div class="stat-bar-label">帖子数</div>
|
<div class="stat-bar-label">帖子数</div>
|
||||||
<div class="stat-bar-value">{{ formatNumber(communityStats?.totals?.posts || 0) }}</div>
|
<div class="stat-bar-value">{{ formatNumber(communityStats?.totals?.posts || 0) }}</div>
|
||||||
<div class="stat-bar-chart">
|
<div class="stat-bar-chart">
|
||||||
<MiniBarChart v-if="communityChartData.length > 0" :data="communityChartData.map(d => d.value)" color="var(--accent-amber)" :height="30" />
|
<MiniBarChart v-if="communityChartData.length > 0" :data="communityBarValues" color="var(--accent-amber)" :height="30" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-bar-item">
|
<div class="stat-bar-item">
|
||||||
<div class="stat-bar-label">回复数</div>
|
<div class="stat-bar-label">回复数</div>
|
||||||
<div class="stat-bar-value">{{ formatNumber(communityStats?.totals?.replies || 0) }}</div>
|
<div class="stat-bar-value">{{ formatNumber(communityStats?.totals?.replies || 0) }}</div>
|
||||||
<div class="stat-bar-chart">
|
<div class="stat-bar-chart">
|
||||||
<MiniBarChart v-if="communityChartData.length > 0" :data="communityChartData.map(d => d.value)" color="var(--accent-purple)" :height="30" />
|
<MiniBarChart v-if="communityChartData.length > 0" :data="communityBarValues" color="var(--accent-purple)" :height="30" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-bar-item">
|
<div class="stat-bar-item">
|
||||||
@@ -1,83 +1,10 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { Terminal } from 'lucide-vue-next'
|
import { routes } from '@/app/router/routes'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes: [
|
routes,
|
||||||
{
|
|
||||||
path: '/login',
|
|
||||||
name: 'login',
|
|
||||||
component: () => import('@/views/LoginView.vue'),
|
|
||||||
meta: { guest: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/',
|
|
||||||
component: () => import('@/views/LayoutView.vue'),
|
|
||||||
meta: { requiresAuth: true },
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: '',
|
|
||||||
redirect: '/chat',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'chat',
|
|
||||||
name: 'chat',
|
|
||||||
component: () => import('@/views/ChatView.vue'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'knowledge',
|
|
||||||
name: 'knowledge',
|
|
||||||
component: () => import('@/views/KnowledgeView.vue'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'graph',
|
|
||||||
name: 'graph',
|
|
||||||
component: () => import('@/views/GraphView.vue'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'kanban',
|
|
||||||
name: 'kanban',
|
|
||||||
component: () => import('@/views/KanbanView.vue'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'forum',
|
|
||||||
name: 'forum',
|
|
||||||
component: () => import('@/views/ForumView.vue'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'agents',
|
|
||||||
name: 'agents',
|
|
||||||
component: () => import('@/views/AgentView.vue'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'stats',
|
|
||||||
name: 'stats',
|
|
||||||
component: () => import('@/views/StatsView.vue'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'skills',
|
|
||||||
name: 'skills',
|
|
||||||
component: () => import('@/views/SkillView.vue'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'todo',
|
|
||||||
name: 'todo',
|
|
||||||
component: () => import('@/views/TodoView.vue'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'settings',
|
|
||||||
name: 'settings',
|
|
||||||
component: () => import('@/views/SettingsView.vue'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'logs',
|
|
||||||
name: 'logs',
|
|
||||||
component: () => import('@/views/LogView.vue'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
router.beforeEach((to, _from, next) => {
|
router.beforeEach((to, _from, next) => {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,519 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
||||||
import { logApi, type Log, type LogStats } from '@/api/log'
|
|
||||||
import { Terminal, RefreshCw, Filter, Clock, Bot, MessageSquare, Settings, AlertCircle } from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const logs = ref<Log[]>([])
|
|
||||||
const stats = ref<LogStats | null>(null)
|
|
||||||
const loading = ref(false)
|
|
||||||
const currentPage = ref(1)
|
|
||||||
const pageSize = ref(50)
|
|
||||||
const total = ref(0)
|
|
||||||
|
|
||||||
// Filters
|
|
||||||
const selectedType = ref<string | null>(null)
|
|
||||||
const selectedLevel = ref<string | null>(null)
|
|
||||||
|
|
||||||
// Auto-refresh
|
|
||||||
const autoRefresh = ref(false)
|
|
||||||
let refreshInterval: number | null = null
|
|
||||||
|
|
||||||
const logTypes = [
|
|
||||||
{ value: null, label: '全部' },
|
|
||||||
{ value: 'agent', label: '智能体', icon: Bot },
|
|
||||||
{ value: 'system', label: '系统', icon: Settings },
|
|
||||||
{ value: 'chat', label: '问答', icon: MessageSquare },
|
|
||||||
]
|
|
||||||
|
|
||||||
const logLevels = [
|
|
||||||
{ value: null, label: '全部' },
|
|
||||||
{ value: 'debug', label: '调试' },
|
|
||||||
{ value: 'info', label: '信息' },
|
|
||||||
{ value: 'warning', label: '警告' },
|
|
||||||
{ value: 'error', label: '错误' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const levelColors: Record<string, string> = {
|
|
||||||
debug: 'var(--text-dim)',
|
|
||||||
info: 'var(--accent-cyan)',
|
|
||||||
warning: 'var(--accent-amber)',
|
|
||||||
error: 'var(--accent-red)',
|
|
||||||
}
|
|
||||||
|
|
||||||
const typeLabels: Record<string, string> = {
|
|
||||||
agent: '智能体',
|
|
||||||
system: '系统',
|
|
||||||
chat: '问答',
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(dateStr: string): string {
|
|
||||||
const d = new Date(dateStr)
|
|
||||||
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(ms: string | null): string {
|
|
||||||
if (!ms) return ''
|
|
||||||
const msNum = parseInt(ms)
|
|
||||||
if (msNum < 1000) return `${msNum}ms`
|
|
||||||
return `${(msNum / 1000).toFixed(1)}s`
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchLogs() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const res = await logApi.list({
|
|
||||||
log_type: selectedType.value || undefined,
|
|
||||||
level: selectedLevel.value || undefined,
|
|
||||||
page: currentPage.value,
|
|
||||||
page_size: pageSize.value,
|
|
||||||
})
|
|
||||||
logs.value = res.data.logs
|
|
||||||
total.value = res.data.total
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to fetch logs:', e)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchStats() {
|
|
||||||
try {
|
|
||||||
const res = await logApi.getStats(24)
|
|
||||||
stats.value = res.data
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to fetch stats:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadData() {
|
|
||||||
await Promise.all([fetchLogs(), fetchStats()])
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleAutoRefresh() {
|
|
||||||
autoRefresh.value = !autoRefresh.value
|
|
||||||
if (autoRefresh.value) {
|
|
||||||
refreshInterval = window.setInterval(() => {
|
|
||||||
fetchLogs()
|
|
||||||
}, 5000)
|
|
||||||
} else if (refreshInterval) {
|
|
||||||
clearInterval(refreshInterval)
|
|
||||||
refreshInterval = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterByType(type: string | null) {
|
|
||||||
selectedType.value = type
|
|
||||||
currentPage.value = 1
|
|
||||||
fetchLogs()
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterByLevel(level: string | null) {
|
|
||||||
selectedLevel.value = level
|
|
||||||
currentPage.value = 1
|
|
||||||
fetchLogs()
|
|
||||||
}
|
|
||||||
|
|
||||||
function prevPage() {
|
|
||||||
if (currentPage.value > 1) {
|
|
||||||
currentPage.value--
|
|
||||||
fetchLogs()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextPage() {
|
|
||||||
if (currentPage.value * pageSize.value < total.value) {
|
|
||||||
currentPage.value++
|
|
||||||
fetchLogs()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadData)
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (refreshInterval) {
|
|
||||||
clearInterval(refreshInterval)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="log-view">
|
|
||||||
<div class="view-header">
|
|
||||||
<div class="header-title">
|
|
||||||
<Terminal :size="16" />
|
|
||||||
<span>运行日志</span>
|
|
||||||
</div>
|
|
||||||
<div class="header-actions">
|
|
||||||
<button class="btn-icon" :class="{ active: autoRefresh }" @click="toggleAutoRefresh" title="自动刷新">
|
|
||||||
<RefreshCw :size="14" :class="{ spinning: autoRefresh }" />
|
|
||||||
</button>
|
|
||||||
<button class="btn-icon" @click="loadData" title="刷新">
|
|
||||||
<RefreshCw :size="14" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stats Overview -->
|
|
||||||
<div v-if="stats" class="stats-overview">
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-label">总计</div>
|
|
||||||
<div class="stat-value">{{ stats.total }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-label">智能体</div>
|
|
||||||
<div class="stat-value cyan">{{ stats.by_type.agent }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-label">系统</div>
|
|
||||||
<div class="stat-value purple">{{ stats.by_type.system }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-label">问答</div>
|
|
||||||
<div class="stat-value amber">{{ stats.by_type.chat }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card" v-if="stats.by_level.error > 0">
|
|
||||||
<div class="stat-label">错误</div>
|
|
||||||
<div class="stat-value red">{{ stats.by_level.error }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filters -->
|
|
||||||
<div class="filters">
|
|
||||||
<div class="filter-group">
|
|
||||||
<span class="filter-label">类型:</span>
|
|
||||||
<button
|
|
||||||
v-for="t in logTypes"
|
|
||||||
:key="t.value ?? 'all'"
|
|
||||||
class="filter-btn"
|
|
||||||
:class="{ active: selectedType === t.value }"
|
|
||||||
@click="filterByType(t.value)"
|
|
||||||
>
|
|
||||||
<component :is="t.icon" :size="12" v-if="t.icon" />
|
|
||||||
{{ t.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="filter-group">
|
|
||||||
<span class="filter-label">级别:</span>
|
|
||||||
<button
|
|
||||||
v-for="l in logLevels"
|
|
||||||
:key="l.value ?? 'all'"
|
|
||||||
class="filter-btn"
|
|
||||||
:class="{ active: selectedLevel === l.value }"
|
|
||||||
@click="filterByLevel(l.value)"
|
|
||||||
>
|
|
||||||
{{ l.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Log List -->
|
|
||||||
<div class="log-list">
|
|
||||||
<div v-if="loading && logs.length === 0" class="loading-state">
|
|
||||||
<RefreshCw :size="20" class="spinning" />
|
|
||||||
<span>加载中...</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="logs.length === 0" class="empty-state">
|
|
||||||
<Terminal :size="32" />
|
|
||||||
<span>暂无日志</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="log-items">
|
|
||||||
<div v-for="log in logs" :key="log.id" class="log-item">
|
|
||||||
<div class="log-meta">
|
|
||||||
<span class="log-type" :class="log.type">{{ typeLabels[log.type] }}</span>
|
|
||||||
<span class="log-level" :style="{ color: levelColors[log.level] }">{{ log.level }}</span>
|
|
||||||
<span v-if="log.duration_ms" class="log-duration">{{ formatDuration(log.duration_ms) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="log-message">{{ log.message }}</div>
|
|
||||||
<div class="log-footer">
|
|
||||||
<span v-if="log.source" class="log-source">{{ log.source }}</span>
|
|
||||||
<span class="log-time">{{ formatTime(log.created_at) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
<div class="pagination" v-if="total > pageSize">
|
|
||||||
<button class="page-btn" :disabled="currentPage === 1" @click="prevPage">上一页</button>
|
|
||||||
<span class="page-info">{{ currentPage }} / {{ Math.ceil(total / pageSize) }}</span>
|
|
||||||
<button class="page-btn" :disabled="currentPage * pageSize >= total" @click="nextPage">下一页</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.log-view {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 24px;
|
|
||||||
background: var(--bg-void);
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon {
|
|
||||||
padding: 8px;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-dim);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon:hover, .btn-icon.active {
|
|
||||||
background: var(--accent-cyan-dim);
|
|
||||||
border-color: var(--accent-cyan);
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon .spinning {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stats Overview */
|
|
||||||
.stats-overview {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
flex: 1;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 12px 16px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
color: var(--text-dim);
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value.cyan { color: var(--accent-cyan); }
|
|
||||||
.stat-value.purple { color: var(--accent-purple); }
|
|
||||||
.stat-value.amber { color: var(--accent-amber); }
|
|
||||||
.stat-value.red { color: var(--accent-red); }
|
|
||||||
|
|
||||||
/* Filters */
|
|
||||||
.filters {
|
|
||||||
display: flex;
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-group {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-label {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-size: 11px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn:hover {
|
|
||||||
border-color: var(--accent-cyan);
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn.active {
|
|
||||||
background: var(--accent-cyan-dim);
|
|
||||||
border-color: var(--accent-cyan);
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Log List */
|
|
||||||
.log-list {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-state,
|
|
||||||
.empty-state {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 60px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-items {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-item {
|
|
||||||
padding: 12px;
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
transition: background var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-item:hover {
|
|
||||||
background: rgba(0, 245, 212, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-type {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-type.agent {
|
|
||||||
background: rgba(0, 245, 212, 0.1);
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-type.system {
|
|
||||||
background: rgba(168, 85, 247, 0.1);
|
|
||||||
color: var(--accent-purple);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-type.chat {
|
|
||||||
background: rgba(249, 168, 37, 0.1);
|
|
||||||
color: var(--accent-amber);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-level {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-duration {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-message {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
line-height: 1.5;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pagination */
|
|
||||||
.pagination {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-btn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-btn:hover:not(:disabled) {
|
|
||||||
border-color: var(--accent-cyan);
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-btn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-info {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -12,7 +12,7 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8000',
|
target: 'http://localhost:9527',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user