Compare commits

..

3 Commits

Author SHA1 Message Date
a9ddf3c9b4 feat(frontend): migrate runtime log page and restore build
Move the runtime log screen into the new pages structure, add compact page navigation, and apply the minimal component fixes needed to keep the refactored frontend buildable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:16:19 +08:00
b024a2bcb5 refactor(frontend): move views into app and pages structure
Reorganize the frontend around app-level routing and page modules so the runtime and feature screens share a clearer navigation and composition layout for future work.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:13:12 +08:00
a27736a832 feat(logs): unify filtering across list and stats
Make runtime log queries support request correlation and date-range diagnostics with shared filtering semantics so the log page can use one consistent contract.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:11:41 +08:00
33 changed files with 3850 additions and 2313 deletions

View File

@@ -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 pydantic import BaseModel
from typing import Optional
from typing import Any, Optional
from app.database import get_db
from app.models.user import 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"])
@@ -15,14 +16,18 @@ class LogOut(BaseModel):
level: str
type: 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
source: Optional[str]
details: Optional[str]
duration_ms: Optional[str]
created_at: str
updated_at: str
model_config = {"from_attributes": True}
details: Optional[dict[str, Any]]
duration_ms: Optional[int]
created_at: Optional[str]
updated_at: Optional[str]
class LogStatsOut(BaseModel):
@@ -43,12 +48,23 @@ async def list_logs(
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"),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
current_user: User = Depends(get_current_user),
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)
offset = (page - 1) * page_size
logs, total = await svc.list_logs(
@@ -56,11 +72,17 @@ async def list_logs(
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,
limit=page_size,
offset=offset,
)
return LogQueryOut(
logs=[LogOut.model_validate(log) for log in logs],
logs=[LogOut.model_validate(serialize_log(log)) for log in logs],
total=total,
page=page,
page_size=page_size,
@@ -69,13 +91,37 @@ async def list_logs(
@router.get("/stats", response_model=LogStatsOut)
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),
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)
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)
@@ -89,5 +135,5 @@ async def get_recent_logs(
):
"""获取最近的日志"""
svc = LogService(db)
logs = await svc.get_recent_logs(log_type=log_type, hours=hours, limit=limit)
return [LogOut.model_validate(log) for log in logs]
logs = await svc.get_recent_logs(log_type=log_type, user_id=current_user.id, hours=hours, limit=limit)
return [LogOut.model_validate(serialize_log(log)) for log in logs]

View File

@@ -4,10 +4,10 @@
"""
import json
import logging
from datetime import datetime, timedelta
from typing import Optional
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
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
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:
def __init__(self, db: AsyncSession):
self.db = db
@@ -34,16 +47,28 @@ class LogService:
source: Optional[str] = None,
details: Optional[dict] = 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_entry = Log(
level=level,
type=log_type,
user_id=user_id,
request_id=request_id,
route=route,
method=method,
status_code=status_code,
error_type=error_type,
operation=operation,
message=message,
source=source,
details=json.dumps(details, ensure_ascii=False) if details else None,
duration_ms=str(duration_ms) if duration_ms else None,
details=json.dumps(details, ensure_ascii=False) if details is not None else None,
duration_ms=int(duration_ms) if duration_ms is not None else None,
)
self.db.add(log_entry)
await self.db.commit()
@@ -75,15 +100,30 @@ class LogService:
level: str = "info",
source: Optional[str] = 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:
"""记录系统运行日志"""
return await self.log(
message=message,
level=level,
log_type="system",
user_id=None,
user_id=user_id,
source=source,
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(
@@ -104,12 +144,56 @@ class LogService:
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(
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,
limit: int = 100,
offset: int = 0,
) -> tuple[list[Log], int]:
@@ -119,28 +203,27 @@ class LogService:
Returns:
(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))
if conditions:
count_query = count_query.where(and_(*conditions))
total_result = await self.db.execute(count_query)
total = total_result.scalar() or 0
# 查询列表
query = (
select(Log)
.where(and_(*conditions)) if conditions else select(Log)
select(Log).where(and_(*conditions)) if conditions else select(Log)
).order_by(desc(Log.created_at)).limit(limit).offset(offset)
result = await self.db.execute(query)
@@ -151,28 +234,48 @@ class LogService:
async def get_recent_logs(
self,
log_type: Optional[str] = None,
user_id: Optional[str] = None,
hours: int = 24,
limit: int = 100,
) -> list[Log]:
"""获取最近的日志"""
since = datetime.utcnow() - timedelta(hours=hours)
conditions = [Log.created_at >= since]
if log_type:
conditions.append(Log.type == log_type)
query = (
select(Log)
.where(and_(*conditions))
.order_by(desc(Log.created_at))
.limit(limit)
end_at = datetime.now(timezone.utc).replace(tzinfo=None)
start_at = end_at - timedelta(hours=hours)
conditions = self._build_conditions(
log_type=log_type,
user_id=user_id,
start_at=start_at,
end_at=end_at,
)
query = select(Log).where(and_(*conditions)).order_by(desc(Log.created_at)).limit(limit)
result = await self.db.execute(query)
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 = {
"total": 0,
@@ -180,83 +283,59 @@ class LogService:
"by_level": {"debug": 0, "info": 0, "warning": 0, "error": 0},
}
# 按类型统计
for log_type in ["agent", "system", "chat"]:
query = select(func.count(Log.id)).where(
and_(Log.type == log_type, Log.created_at >= since)
)
result = await self.db.execute(query)
count = result.scalar() or 0
stats["by_type"][log_type] = count
stats["total"] += count
total_conditions = list(base_conditions)
if log_type:
total_conditions.append(Log.type == log_type)
if level:
total_conditions.append(Log.level == level)
total_query = select(func.count(Log.id)).where(and_(*total_conditions))
total_result = await self.db.execute(total_query)
stats["total"] = total_result.scalar() or 0
# 按级别统计
for level in ["debug", "info", "warning", "error"]:
query = select(func.count(Log.id)).where(
and_(Log.level == level, Log.created_at >= since)
)
for current_type in ["agent", "system", "chat"]:
conditions = list(base_conditions)
conditions.append(Log.type == current_type)
if level:
conditions.append(Log.level == level)
query = select(func.count(Log.id)).where(and_(*conditions))
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
# 全局日志记录函数,方便各处调用
_global_db_session = None
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:
def serialize_log(log: Log) -> dict[str, Any]:
details = None
if log.details:
try:
svc = LogService(_global_db_session)
await svc.agent_log(message, user_id, source, details, duration_ms)
except Exception as e:
logger.error(f"Failed to log agent event: {e}")
details = json.loads(log.details)
except json.JSONDecodeError:
details = {"raw": log.details}
async def log_system_event(
message: str,
level: str = "info",
source: Optional[str] = None,
details: Optional[dict] = None,
):
"""记录系统事件到数据库"""
if _global_db_session:
try:
svc = LogService(_global_db_session)
await svc.system_log(message, level, source, details)
except Exception as e:
logger.error(f"Failed to log system event: {e}")
async def log_chat_event(
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}")
return {
"id": log.id,
"level": log.level,
"type": log.type,
"user_id": log.user_id,
"request_id": log.request_id,
"route": log.route,
"method": log.method,
"status_code": log.status_code,
"error_type": log.error_type,
"operation": log.operation,
"message": log.message,
"source": log.source,
"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,
"updated_at": log.updated_at.replace(tzinfo=timezone.utc).isoformat() if log.updated_at else None,
}

View 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

View File

@@ -1,5 +1,12 @@
import api from './index'
export interface MessageAttachment {
id: string
name: string
type: string
size: number
}
export interface Message {
id: string
role: 'user' | 'assistant'
@@ -7,6 +14,7 @@ export interface Message {
model?: string
tokens_used?: number
created_at: string
attachments?: MessageAttachment[]
}
export interface Conversation {

View File

@@ -1,27 +1,81 @@
import axios from 'axios'
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,
})
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
api.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
const requestId = createRequestId()
config.headers = config.headers || {}
config.headers['X-Request-ID'] = requestId
if (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
})
// 响应拦截器:处理错误
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) => {
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) {
localStorage.removeItem('access_token')
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)
}
)

View File

@@ -6,12 +6,18 @@ export interface Log {
level: 'debug' | 'info' | 'warning' | 'error'
type: 'agent' | 'system' | 'chat'
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
source: string | null
details: string | null
duration_ms: string | null
created_at: string
updated_at: string
details: Record<string, unknown> | null
duration_ms: number | null
created_at: string | null
updated_at: string | null
}
export interface LogStats {
@@ -36,19 +42,27 @@ export interface LogQueryResult {
page_size: number
}
export const logApi = {
list: (params?: {
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
}): Promise<AxiosResponse<LogQueryResult>> => {
}
export const logApi = {
list: (params?: LogQueryParams): Promise<AxiosResponse<LogQueryResult>> => {
return api.get('/api/logs', { params })
},
getStats: (hours?: number): Promise<AxiosResponse<LogStats>> => {
return api.get('/api/logs/stats', { params: { hours } })
getStats: (params?: LogQueryParams): Promise<AxiosResponse<LogStats>> => {
return api.get('/api/logs/stats', { params })
},
getRecent: (params?: {

View 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 },
]

View 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

View 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 }

View File

@@ -1,15 +1,15 @@
<script setup lang="ts">
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'
const props = defineProps<{
folders: FolderTree[]
folders: FolderTreeNode[]
selectedId?: string | null
onSelect: (folder: FolderTree) => void
onSelect: (folder: FolderTreeNode) => void
onCreate: (parentId: string | null) => void
onRename: (folder: FolderTree) => void
onDelete: (folder: FolderTree) => void
onRename: (folder: FolderTreeNode) => void
onDelete: (folder: FolderTreeNode) => void
}>()
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()
// 显示右键菜单
}

View File

@@ -1,26 +1,13 @@
<script setup lang="ts">
import { useRouter, useRoute } from 'vue-router'
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 route = useRoute()
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) {
return route.path === path
}

View File

@@ -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
watch(() => props.model.enabled, (enabled) => {
editingModel.value.enabled = enabled

View File

@@ -2,7 +2,7 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import router from './router'
import router from './app/router'
import App from './App.vue'
import './style.css'

View File

@@ -95,7 +95,7 @@
:key="sub.id"
:ref="el => setSubRef(sub.id, el as HTMLElement)"
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)"
@click="selectAgent(sub.id)"
>
@@ -217,7 +217,6 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { animate, type AnimationControls } from 'motion'
import { RefreshCw, X, Plus } from 'lucide-vue-next'
import { DEFAULT_AGENTS, RELATION_LABELS } 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]
// Sub-agent static data
const subAgents = [
interface SubAgentCard {
id: string
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: 'analyst', name: 'ANALYST', role: '分析师', description: '分析工作数据,生成统计报告,提供洞察建议', relLabel: RELATION_LABELS['master-analyst'] },
]
type PlaybackHandle = ReturnType<typeof window.setTimeout>
// Refs
const canvasRef = ref<HTMLElement>()
const svgRef = ref<SVGElement>()
const masterCardRef = ref<HTMLElement>()
const canvasRef = ref<HTMLElement | null>(null)
const svgRef = ref<SVGElement | null>(null)
const masterCardRef = ref<HTMLElement | null>(null)
const subRefs: Record<string, HTMLElement> = {}
const masterAnim = ref<AnimationControls | null>(null)
const subAnims: Record<string, AnimationControls> = {}
const hoverAnims: Record<string, AnimationControls | null> = {}
const cleanupFns: Array<() => void> = []
const hoverResetTimers: Record<string, PlaybackHandle | null> = {}
// Background particles
const bgParticles = Array.from({ length: 60 }, (_, i) => {
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 pct = SUB_XS[idx] ?? 50
const { x } = pxToSvg(pct, SUB_TOP)
@@ -419,30 +427,109 @@ async function refreshStats() {
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
function animateIn(el: Element) { animate(el, { opacity: [0, 1], x: [80, 0] }, { duration: 0.35, easing: [0.4, 0, 0.2, 1] }).play() }
function animateOut(el: Element) { animate(el, { opacity: [1, 0], x: [0, 80] }, { duration: 0.25, easing: [0.4, 0, 1, 1] }).play() }
function fadeIn(el: Element) { animate(el, { opacity: [0, 1] }, { duration: 0.25 }).play() }
function fadeOut(el: Element) { animate(el, { opacity: [1, 0] }, { duration: 0.2 }).play() }
function animateIn(el: Element, done: () => void) {
runTransition(
el,
[
{ 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() {
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) => {
const el = subRefs[sub.id]
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
const hoverAnim = animate(el, { y: [0, -4] }, { duration: 0.2, easing: [0.34, 1.56, 0.64, 1] })
hoverAnim.pause()
hoverAnims[sub.id] = hoverAnim
el.addEventListener('mouseenter', () => {
runTransition(
el,
[
{ opacity: 0, transform: 'translateY(20px)' },
{ 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
hoverAnim.direction = 'forward'; hoverAnim.play()
})
el.addEventListener('mouseleave', () => {
hoverAnim.direction = 'reverse'; hoverAnim.play()
stopTimer(hoverResetTimers[sub.id] ?? null)
el.style.transform = 'translateY(-4px)'
}
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(() => {
if (pollInterval) clearInterval(pollInterval)
resizeObserver?.disconnect()
masterAnim.value?.stop()
Object.values(subAnims).forEach(a => a.stop())
Object.values(hoverAnims).forEach(a => a?.stop())
cleanupFns.forEach(cleanup => cleanup())
})
</script>

View 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,
}
}

View File

@@ -1,180 +1,29 @@
<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 EmojiPicker from '@/components/chat/EmojiPicker.vue'
import FileMessage from '@/components/chat/FileMessage.vue'
import { useChatView } from '@/pages/chat/composables/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<{ id: string; name: string; type: string; size: number }[]>([])
async function sendMessage() {
if (!inputMessage.value.trim() || isSending.value) return
isSending.value = true
isTyping.value = true
const text = inputMessage.value.trim()
inputMessage.value = ''
store.addMessage({
id: `temp-${Date.now()}`,
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()
})
const {
store,
inputMessage,
isSending,
chatContainer,
inputRef,
isTyping,
fileInputRef,
showEmojiPicker,
sendMessage,
selectConversation,
newConversation,
deleteConversation,
formatTime,
formatConvDate,
autoResize,
handleFileSelect,
insertEmoji,
openFilePicker,
} = useChatView()
</script>
<template>
@@ -272,7 +121,9 @@ onMounted(() => {
<FileMessage
v-for="att in msg.attachments"
:key="att.id"
:file="att"
:filename="att.name"
:file-type="att.type"
:file-size="att.size"
/>
</div>
</div>
@@ -818,7 +669,9 @@ onMounted(() => {
line-height: 1.6;
resize: none;
max-height: 120px;
padding: 0;
padding: 8px 0;
vertical-align: middle;
overflow: hidden;
}
.input-frame textarea::placeholder { color: var(--text-dim); }

View File

@@ -39,13 +39,13 @@ function formatDate(dateStr: string) {
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 }> = {
discussion: { label: 'DISCUSSION', color: 'var(--accent-cyan)' },
instruction: { label: 'INSTRUCTION', color: 'var(--accent-amber)' },
question: { label: 'QUESTION', color: 'var(--accent-green)' },
}
return map[cat] || map.discussion
return map[cat ?? 'discussion'] || map.discussion
}
onMounted(() => { loadPosts() })

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
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'
const nodes = ref<KGNode[]>([])

View 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,
}
}

File diff suppressed because it is too large Load Diff

View 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>

View 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,
}
}

View File

@@ -1,321 +1,32 @@
<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 { Save, RotateCcw, Plus } from 'lucide-vue-next'
import { useSettingsView } from '@/pages/settings/composables/useSettingsView'
//
const loading = ref(false)
const saving = ref(false)
const savingModel = ref<string | null>(null) // key
const toast = ref<{ show: boolean; message: string; type: 'success' | 'error' }>({
show: false,
message: '',
type: 'success'
})
//
const expandedRow = ref<string | null>(null) // 'chat-0', 'vlm-0'
//
const editingSnapshot = ref<{ type: string; index: number; data: LLMModelConfig } | null>(null)
//
const showRequiredWarning = computed(() => {
return llmConfig.value.chat.length === 0 ||
llmConfig.value.embedding.length === 0 ||
llmConfig.value.rerank.length === 0
})
//
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)
const {
loading,
saving,
toast,
expandedRow,
showRequiredWarning,
profile,
newPassword,
llmConfig,
schedulerConfig,
isProfileDirty,
isSchedulerDirty,
addModel,
removeModel,
getRowKey,
toggleRow,
updateModel,
saveProfile,
saveModel,
testModel,
saveScheduler,
resetProfile,
resetScheduler,
} = useSettingsView()
</script>
<template>
@@ -403,7 +114,7 @@ onMounted(loadSettings)
@update="(m) => updateModel('chat', index, m)"
@delete="removeModel('chat', index)"
@test="(m) => testModel('chat', index, m)"
@save="(m) => saveModel('chat', index)"
@save="(m) => saveModel('chat', index, m)"
/>
</div>
</div>
@@ -427,7 +138,7 @@ onMounted(loadSettings)
@update="(m) => updateModel('vlm', index, m)"
@delete="removeModel('vlm', index)"
@test="(m) => testModel('vlm', index, m)"
@save="(m) => saveModel('vlm', index)"
@save="(m) => saveModel('vlm', index, m)"
/>
</div>
</div>
@@ -451,7 +162,7 @@ onMounted(loadSettings)
@update="(m) => updateModel('embedding', index, m)"
@delete="removeModel('embedding', index)"
@test="(m) => testModel('embedding', index, m)"
@save="(m) => saveModel('embedding', index)"
@save="(m) => saveModel('embedding', index, m)"
/>
</div>
</div>
@@ -475,7 +186,7 @@ onMounted(loadSettings)
@update="(m) => updateModel('rerank', index, m)"
@delete="removeModel('rerank', index)"
@test="(m) => testModel('rerank', index, m)"
@save="(m) => saveModel('rerank', index)"
@save="(m) => saveModel('rerank', index, m)"
/>
</div>
</div>

View File

@@ -1,5 +1,27 @@
<script setup lang="ts">
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 { Bot, Plus, Edit2, Trash2, Eye, EyeOff, Copy, X } from 'lucide-vue-next'

View File

@@ -2,12 +2,16 @@
import { ref, onMounted, computed } from 'vue'
import * as statsApi from '@/api/stats'
import { Cpu, HardDrive, MemoryStick, Clock, TrendingUp, Tag } from 'lucide-vue-next'
const reloadPage = () => globalThis.location.reload()
import SectionHeader from '@/components/stats/SectionHeader.vue'
import MetricCard from '@/components/stats/MetricCard.vue'
import SummaryRow from '@/components/stats/SummaryRow.vue'
import MiniLineChart from '@/components/stats/MiniLineChart.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 hasError = ref(false)
@@ -63,27 +67,31 @@ onMounted(async () => {
//
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(() =>
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(() =>
kanbanStats.value?.daily_new_tasks?.map((d: any) => d.count) || []
kanbanStats.value?.daily_new_tasks?.map((d: DailyPoint) => d.count) || []
)
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(() =>
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(() =>
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>
<template>
@@ -99,7 +107,7 @@ const hourlyActivityData = computed(() =>
<div v-else-if="hasError" class="error-state">
<span>Failed to load stats</span>
<button @click="() => window.location.reload()">Refresh</button>
<button @click="reloadPage">Refresh</button>
</div>
<div v-else class="stats-content">
@@ -142,14 +150,14 @@ const hourlyActivityData = computed(() =>
<div class="stat-bar-label">对话数</div>
<div class="stat-bar-value">{{ formatNumber(conversationStats?.totals?.conversations || 0) }}</div>
<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 class="stat-bar-item">
<div class="stat-bar-label">消息数</div>
<div class="stat-bar-value">{{ formatNumber(conversationStats?.totals?.messages || 0) }}</div>
<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 class="stat-bar-item">
@@ -177,14 +185,14 @@ const hourlyActivityData = computed(() =>
<div class="stat-bar-label">新标签</div>
<div class="stat-bar-value">{{ formatNumber(knowledgeStats?.totals?.new_tags || 0) }}</div>
<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 class="stat-bar-item">
<div class="stat-bar-label">文档数</div>
<div class="stat-bar-value">{{ formatNumber(knowledgeStats?.totals?.documents || 0) }}</div>
<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 class="stat-bar-item">
@@ -233,14 +241,14 @@ const hourlyActivityData = computed(() =>
<div class="stat-bar-label">帖子数</div>
<div class="stat-bar-value">{{ formatNumber(communityStats?.totals?.posts || 0) }}</div>
<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 class="stat-bar-item">
<div class="stat-bar-label">回复数</div>
<div class="stat-bar-value">{{ formatNumber(communityStats?.totals?.replies || 0) }}</div>
<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 class="stat-bar-item">

View File

@@ -1,83 +1,10 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { Terminal } from 'lucide-vue-next'
import { routes } from '@/app/router/routes'
const router = createRouter({
history: createWebHistory(),
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'),
},
],
},
],
routes,
})
router.beforeEach((to, _from, next) => {

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -12,7 +12,7 @@ export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
target: 'http://localhost:9527',
changeOrigin: true,
},
},