Compare commits
4 Commits
adc1933038
...
3d152fc8eb
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d152fc8eb | |||
| db70a3a6a8 | |||
| d480aa4c1d | |||
| 7ffc6c8e23 |
@@ -229,3 +229,7 @@ _tmp_*
|
||||
fix_redis.sh
|
||||
/test/
|
||||
backend/scripts/create_test_agent.py
|
||||
|
||||
# 补充忽略 (2026-07-09 WIP 提交): 新增构建产物
|
||||
dist-deploy/
|
||||
dist-v2/
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
@@ -895,12 +896,37 @@ async def get_agent_performance(
|
||||
async def get_system_logs(
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(50, ge=1, le=200, description="每页条数"),
|
||||
config_key: Optional[str] = Query(None, description="按配置键精确筛选"),
|
||||
changed_by: Optional[str] = Query(None, description="按操作人 agent_id 精确筛选"),
|
||||
from_time: Optional[datetime] = Query(None, alias="from", description="变更时间起始(ISO8601, 闭区间)"),
|
||||
to_time: Optional[datetime] = Query(None, alias="to", description="变更时间截止(ISO8601, 闭区间)"),
|
||||
admin: Agent = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取系统日志(配置变更日志)。"""
|
||||
result = await admin_service.get_system_logs(db, page=page, page_size=page_size)
|
||||
return success_response(data=result)# ---------- GET /api/admin/integrations/lianruan/terminals/{devname}/detail ----------
|
||||
"""获取系统日志(配置变更日志),支持配置键/操作人/时间范围筛选。
|
||||
|
||||
Args:
|
||||
config_key: 按配置键精确匹配(可选)
|
||||
changed_by: 按操作人 agent_id 精确匹配(可选)
|
||||
from_time: 变更时间起始(别名 from,可选)
|
||||
to_time: 变更时间截止(别名 to,可选)
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含筛选后的配置变更日志
|
||||
"""
|
||||
result = await admin_service.get_system_logs(
|
||||
db,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
config_key=config_key,
|
||||
changed_by=changed_by,
|
||||
from_time=from_time,
|
||||
to_time=to_time,
|
||||
)
|
||||
return success_response(data=result)
|
||||
|
||||
|
||||
# ---------- GET /api/admin/integrations/lianruan/terminals/{devname}/detail ----------
|
||||
@router.get("/integrations/lianruan/terminals/{devname}/detail")
|
||||
async def get_lianruan_terminal_detail(
|
||||
devname: str,
|
||||
|
||||
@@ -13,12 +13,14 @@ import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_current_user, UserInfo, require_role
|
||||
from app.dependencies import get_current_user, UserInfo, require_role, dep_redis
|
||||
from app.database import get_db
|
||||
from app.models.employee import Employee
|
||||
from app.models.role import Role
|
||||
from app.models.role_mapping_rule import RoleMappingRule
|
||||
from app.models.user_role import UserRole
|
||||
@@ -30,6 +32,7 @@ from app.schemas.role import (
|
||||
RoleResponse,
|
||||
UserRoleResponse,
|
||||
)
|
||||
from app.services.employee_directory import get_org_directory, resolve_target
|
||||
from app.utils.response import AppException, success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -146,22 +149,27 @@ async def list_user_role_assignments(
|
||||
"""
|
||||
# 使用 LEFT OUTER JOIN:即使 user_roles.role_id 在 roles 表中已不存在
|
||||
# (例如角色被重建导致 UUID 变化),也保留该条分配记录,避免已分配用户被静默隐藏。
|
||||
# 同时 LEFT JOIN employees 表获取员工姓名。
|
||||
# assigned_at 定义为 NOT NULL,nulls_last() 无意义,直接降序即可(SQLite/PG 通用)。
|
||||
stmt = (
|
||||
select(UserRole, Role)
|
||||
select(UserRole, Role, Employee)
|
||||
.outerjoin(Role, UserRole.role_id == Role.id)
|
||||
.outerjoin(Employee, UserRole.employee_id == Employee.employee_id)
|
||||
.order_by(UserRole.assigned_at.desc())
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
assignments = []
|
||||
for user_role, role in rows:
|
||||
for user_role, role, employee in rows:
|
||||
# role 可能为 None(孤儿记录),做兜底展示,而不是丢弃该用户
|
||||
role_name = role.name if role else "unknown"
|
||||
role_display = (role.display_name or role.name) if role else "未知角色"
|
||||
# employee 可能为 None(员工已从组织架构移除)
|
||||
employee_name = employee.name if employee else ""
|
||||
assignments.append({
|
||||
"employee_id": user_role.employee_id,
|
||||
"employee_name": employee_name,
|
||||
"role_name": role_name,
|
||||
"role_display_name": role_display,
|
||||
"source": user_role.source or "manual",
|
||||
@@ -183,22 +191,46 @@ async def assign_role(
|
||||
body: RoleAssignRequest,
|
||||
admin: UserInfo = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(dep_redis),
|
||||
):
|
||||
"""手动分配角色。
|
||||
|
||||
为指定用户分配角色,记录分配者和分配原因。
|
||||
支持输入「员工账号 或 姓名」:
|
||||
- 先经 employee_directory.resolve_target 解析为企微 UserID;
|
||||
- 再用企微通讯录实时校验该员工确实属于企微组织架构(不在组织内则拒绝)。
|
||||
安全限制:禁止管理员给自己分配角色。
|
||||
|
||||
Args:
|
||||
body: 分配角色请求
|
||||
body: 分配角色请求(target=账号/姓名,或兼容 employee_id)
|
||||
admin: 管理员(权限校验)
|
||||
db: 数据库会话
|
||||
redis: Redis 客户端(组织目录缓存用)
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式
|
||||
"""
|
||||
# 取待解析的目标(优先 target,兼容旧 employee_id)
|
||||
raw = (body.target or body.employee_id or "").strip()
|
||||
if not raw:
|
||||
raise AppException(4001, "请输入员工账号或姓名")
|
||||
|
||||
# 解析 + 实时校验:是否为企微组织架构内的真实员工
|
||||
resolved = await resolve_target(raw, db, redis)
|
||||
if resolved.get("ambiguous"):
|
||||
cands = resolved["candidates"]
|
||||
names = "、".join(f"{c['name']}({c['employee_id']})" for c in cands[:5])
|
||||
raise AppException(
|
||||
4016,
|
||||
f"匹配到多名员工,请改用员工账号精确分配:{names}",
|
||||
)
|
||||
if not resolved.get("found"):
|
||||
raise AppException(4017, resolved.get("reason", "员工不存在"))
|
||||
|
||||
employee_id = resolved["employee_id"]
|
||||
|
||||
# 安全限制:禁止管理员给自己分配角色
|
||||
if body.employee_id == admin.employee_id:
|
||||
if admin.employee_id and employee_id == admin.employee_id:
|
||||
raise AppException(4014, "不能给自己分配角色")
|
||||
|
||||
# 查询目标角色
|
||||
@@ -211,7 +243,7 @@ async def assign_role(
|
||||
|
||||
# 检查是否已拥有该角色
|
||||
existing_stmt = select(UserRole).where(
|
||||
UserRole.employee_id == body.employee_id,
|
||||
UserRole.employee_id == employee_id,
|
||||
UserRole.role_id == role.id,
|
||||
)
|
||||
existing_result = await db.execute(existing_stmt)
|
||||
@@ -222,7 +254,7 @@ async def assign_role(
|
||||
|
||||
# 创建用户角色关联
|
||||
user_role = UserRole(
|
||||
employee_id=body.employee_id,
|
||||
employee_id=employee_id,
|
||||
role_id=role.id,
|
||||
source="manual",
|
||||
assigned_by=admin.employee_id,
|
||||
@@ -230,11 +262,50 @@ async def assign_role(
|
||||
db.add(user_role)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"管理员 {_mask_sensitive_data(admin.employee_id)} 为用户 {_mask_sensitive_data(body.employee_id)} 分配角色 {body.role_name},原因:{body.reason}")
|
||||
logger.info(f"管理员 {_mask_sensitive_data(admin.employee_id)} 为用户 {_mask_sensitive_data(employee_id)} 分配角色 {body.role_name},原因:{body.reason}")
|
||||
|
||||
return success_response(message=f"角色 {body.role_name} 分配成功")
|
||||
|
||||
|
||||
# ---------- GET /api/admin/roles/employees/search ----------
|
||||
@router.get("/employees/search")
|
||||
async def search_employees(
|
||||
q: str = Query("", description="姓名或员工账号关键字"),
|
||||
admin: UserInfo = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(dep_redis),
|
||||
):
|
||||
"""员工目录搜索(分配角色时自动补全用)。
|
||||
|
||||
返回匹配关键字的员工候选(账号 + 姓名 + 部门),并在 full_directory 字段
|
||||
标明当前目录是否覆盖全公司(True=企微全组织;False=仅已登录员工,需开通
|
||||
通讯录读取权限才能搜索全公司)。
|
||||
|
||||
Args:
|
||||
q: 搜索关键字(姓名或账号,至少 1 个字符)
|
||||
admin: 管理员(权限校验)
|
||||
db: 数据库会话
|
||||
redis: Redis 客户端
|
||||
|
||||
Returns:
|
||||
Dict: { items: [...], full_directory: bool|None }
|
||||
"""
|
||||
q = (q or "").strip()
|
||||
if len(q) < 1:
|
||||
return success_response(data={"items": [], "full_directory": None})
|
||||
|
||||
directory, full = await get_org_directory(db, redis)
|
||||
ql = q.lower()
|
||||
hits = [
|
||||
m for m in directory
|
||||
if ql in (m["employee_id"] or "").lower()
|
||||
or ql in (m["name"] or "").lower()
|
||||
]
|
||||
# 优先精确账号命中,其次姓名包含
|
||||
items = hits[:20]
|
||||
return success_response(data={"items": items, "full_directory": full})
|
||||
|
||||
|
||||
# ---------- POST /api/admin/roles/revoke ----------
|
||||
@router.post("/revoke")
|
||||
async def revoke_role(
|
||||
|
||||
@@ -243,6 +243,7 @@ async def send_message(
|
||||
try:
|
||||
# 构建消息载荷
|
||||
msg_payload = MessageResponse.model_validate(message).model_dump()
|
||||
msg_payload["message_id"] = msg_payload["id"]
|
||||
|
||||
# 构建 WebSocket 事件
|
||||
ws_event = {
|
||||
|
||||
@@ -243,6 +243,12 @@ api_router.include_router(auth_wecom_sso_router, tags=["企微SSO"])
|
||||
from app.api.audit_logs import router as audit_logs_router
|
||||
api_router.include_router(audit_logs_router, tags=["审计日志"])
|
||||
|
||||
# 运行期日志 API (standard SOP 第三阶段 — 管理后台日志体系)
|
||||
# GET /api/admin/runtime-logs — 分页 + 多条件筛选(级别/时间/关键字)
|
||||
# GET /api/admin/runtime-logs?download=true — 命中行文本下载(附件)
|
||||
from app.api.runtime_logs import router as runtime_logs_router
|
||||
api_router.include_router(runtime_logs_router, tags=["运行期日志"])
|
||||
|
||||
# 阶段5 自动化闭环 API
|
||||
# POST /itportal/automation/sessions — 创建自动化会话
|
||||
# GET /itportal/automation/sessions — 会话列表
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 运行期日志 API (standard SOP 第三阶段)
|
||||
# =============================================================================
|
||||
# 说明:管理后台查看后端运行期日志
|
||||
# GET /admin/runtime-logs 正常查询(分页 + 级别/时间/关键字筛选)
|
||||
# GET /admin/runtime-logs?download=true 下载命中行(text/plain 附件)
|
||||
# 权限:require_admin(非 admin 返回 403,由依赖装饰器强制)
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.dependencies import require_admin, get_current_user, UserInfo
|
||||
from app.services.runtime_log_service import (
|
||||
iter_runtime_log_lines,
|
||||
query_runtime_logs,
|
||||
)
|
||||
from app.utils.response import success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/runtime-logs", tags=["运行期日志"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
@require_admin
|
||||
async def get_runtime_logs(
|
||||
level: str = Query("INFO", description="日志级别阈值(DEBUG/INFO/WARNING/ERROR/CRITICAL),返回 >= 该级别"),
|
||||
from_time: Optional[datetime] = Query(None, alias="from", description="起始时间(ISO8601)"),
|
||||
to_time: Optional[datetime] = Query(None, alias="to", description="结束时间(ISO8601)"),
|
||||
keyword: Optional[str] = Query(None, description="按消息关键字子串筛选(大小写不敏感)"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(50, ge=1, le=500, description="每页条数"),
|
||||
download: bool = Query(False, description="true 时返回命中行文本下载"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
):
|
||||
"""查询后端运行期日志(分页 + 级别/时间/关键字筛选)。
|
||||
|
||||
权限:仅 admin 角色可访问,非 admin 由 require_admin 装饰器返回 403。
|
||||
"""
|
||||
# 下载模式:流式返回命中行文本,触发浏览器文件下载
|
||||
if download:
|
||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
headers = {
|
||||
"Content-Disposition": f'attachment; filename="runtime-logs-{ts}.log"'
|
||||
}
|
||||
return StreamingResponse(
|
||||
iter_runtime_log_lines(
|
||||
level=level,
|
||||
from_time=from_time,
|
||||
to_time=to_time,
|
||||
keyword=keyword,
|
||||
),
|
||||
media_type="text/plain; charset=utf-8",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
# 普通查询:分页返回结构化条目
|
||||
result = await query_runtime_logs(
|
||||
level=level,
|
||||
from_time=from_time,
|
||||
to_time=to_time,
|
||||
keyword=keyword,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return success_response(data=result)
|
||||
@@ -6,6 +6,7 @@
|
||||
# 所有配置项集中管理,避免散落在代码各处
|
||||
# =============================================================================
|
||||
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
@@ -72,6 +73,14 @@ class Settings(BaseSettings):
|
||||
# CORS 允许的源地址(逗号分隔的字符串)
|
||||
cors_origins: str = "http://localhost:5173,http://localhost:5174,http://localhost:5175"
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 运行期日志目录(供"运行期日志"管理页面读取)
|
||||
# ----------------------------------------------------------------------
|
||||
# 后端运行期日志(JSON 格式,由 utils.logging_config.setup_logging 写入)
|
||||
# 所在目录。容器内默认 /app/logs,可通过环境变量 RUNTIME_LOG_DIR 覆盖;
|
||||
# 宿主机需将真实日志目录挂载到该路径(见 docker-compose.yml backend 卷)。
|
||||
RUNTIME_LOG_DIR: str = os.getenv("RUNTIME_LOG_DIR", "/app/logs")
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AI 服务配置(Dify)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@@ -28,6 +28,8 @@ from app.api.router import api_router
|
||||
from app.dependencies import init_shared_services, cleanup_shared_services
|
||||
# 导入异常处理器和异常类
|
||||
from app.utils.response import AppException, app_exception_handler, success_response
|
||||
# 导入日志配置(运行期日志文件接线:standard SOP 第三阶段 · 运行期日志 D 页依赖)
|
||||
from app.utils.logging_config import setup_logging
|
||||
# 导入定时任务
|
||||
from app.tasks.reminder_task import check_unreplied_sessions
|
||||
|
||||
@@ -75,6 +77,18 @@ async def lifespan(app: FastAPI):
|
||||
在应用关闭时执行清理操作。
|
||||
"""
|
||||
# ===== 启动事件 =====
|
||||
# 运行期日志接线(standard SOP 第三阶段 · 运行期日志 D 页依赖):
|
||||
# 在 stdout handler 之外,将结构化 JSON 日志写入 settings.RUNTIME_LOG_DIR/wecom-it-desk.log
|
||||
# (RotatingFileHandler:单文件上限 20MB,保留 5 个备份,复用 JSONFormatter)。
|
||||
# 目录不可创建/写入时自动降级为仅 stdout,不阻塞启动。
|
||||
# 注:setup_logging 会清空 root logger 已有 handler(含模块级 basicConfig),
|
||||
# 并以 console(stdout) + 文件(JSON) 重新配置,避免重复输出。
|
||||
setup_logging(
|
||||
level=os.getenv("LOG_LEVEL", "INFO"),
|
||||
json_format=True,
|
||||
log_dir=settings.RUNTIME_LOG_DIR,
|
||||
)
|
||||
|
||||
logger.info("🚀 企微IT智能服务台启动中...")
|
||||
|
||||
# 校验关键配置项(防止生产环境忘记配置导致静默失败)
|
||||
|
||||
@@ -80,12 +80,14 @@ class RoleAssignRequest(BaseModel):
|
||||
"""角色分配请求 Schema。
|
||||
|
||||
Attributes:
|
||||
employee_id: 企微 UserID
|
||||
target: 员工账号或姓名(自动解析为企微 UserID 并校验组织内存在性)【推荐】
|
||||
employee_id: 兼容旧字段,直接传企微 UserID
|
||||
role_name: 角色标识(user/agent/admin)
|
||||
reason: 分配原因(可选)
|
||||
"""
|
||||
|
||||
employee_id: str = Field(..., min_length=1, max_length=100, description="企微 UserID")
|
||||
target: Optional[str] = Field(None, min_length=1, max_length=100, description="员工账号或姓名(自动解析)")
|
||||
employee_id: Optional[str] = Field(None, min_length=1, max_length=100, description="兼容字段:企微 UserID")
|
||||
role_name: str = Field(..., min_length=1, max_length=50, description="角色标识")
|
||||
reason: Optional[str] = Field(None, max_length=500, description="分配原因")
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.agent import Agent
|
||||
from app.models.config_change_log import ConfigChangeLog
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.message import Message
|
||||
from app.models.quick_reply_template import QuickReplyTemplate
|
||||
@@ -451,7 +452,10 @@ async def update_config(
|
||||
|
||||
old_value = config.config_value
|
||||
|
||||
# 写入变更日志
|
||||
# ===== A+B 双写(决策2:配置变更同时进入 config_change_logs 与 audit_logs)=====
|
||||
# 表 A:config_change_logs(既有,供"配置变更历史"页)
|
||||
# 表 B:audit_logs 的 config_change 事件(供"安全审计日志"页,复用 record_audit_log 统一构造)
|
||||
# 两表在同一 DB session 内 add,由调用方统一 commit(与 record_audit_log 约定一致:本函数不 commit)。
|
||||
change_log = ConfigChangeLog(
|
||||
config_key=key,
|
||||
old_value=old_value,
|
||||
@@ -460,6 +464,26 @@ async def update_config(
|
||||
)
|
||||
db.add(change_log)
|
||||
|
||||
# 表 B:audit_logs —— 严格复用 app.services.audit_log_service.record_audit_log 的构造方式
|
||||
# (字段命名 / 默认值保持一致)。update_config 为服务层、无 FastAPI Request 上下文,
|
||||
# 故 ip_address / user_agent 置 None(record_audit_log 在无 request 时同样取 None)。
|
||||
audit_log = AuditLog(
|
||||
employee_id=agent_id,
|
||||
action="config_change",
|
||||
resource="system_config",
|
||||
resource_id=key,
|
||||
details={
|
||||
"old": old_value,
|
||||
"new": value,
|
||||
"operator": agent_id,
|
||||
},
|
||||
result="success",
|
||||
ip_address=None,
|
||||
user_agent=None,
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
db.add(audit_log)
|
||||
|
||||
# 更新配置值
|
||||
config.config_value = value
|
||||
config.updated_at = datetime.now()
|
||||
@@ -1690,19 +1714,53 @@ async def get_system_logs(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
config_key: Optional[str] = None,
|
||||
changed_by: Optional[str] = None,
|
||||
from_time: Optional[datetime] = None,
|
||||
to_time: Optional[datetime] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""获取系统日志(配置变更日志)。"""
|
||||
count_result = await db.execute(select(func.count(ConfigChangeLog.id)))
|
||||
total = count_result.scalar() or 0
|
||||
"""获取系统日志(配置变更日志),支持配置键/操作人/时间范围筛选。
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
result = await db.execute(
|
||||
select(ConfigChangeLog)
|
||||
Args:
|
||||
db: 数据库会话
|
||||
page: 页码,从 1 开始
|
||||
page_size: 每页条数
|
||||
config_key: 按配置键精确筛选(可选,空则不追加条件)
|
||||
changed_by: 按操作人 agent_id 精确筛选(可选)
|
||||
from_time: 变更时间起始(闭区间,可选)
|
||||
to_time: 变更时间截止(闭区间,可选)
|
||||
|
||||
Returns:
|
||||
Dict: {items, total, page, page_size}
|
||||
"""
|
||||
# 动态构建筛选条件(空参数不追加条件,保持旧行为)
|
||||
conditions = []
|
||||
if config_key:
|
||||
conditions.append(ConfigChangeLog.config_key == config_key)
|
||||
if changed_by:
|
||||
conditions.append(ConfigChangeLog.changed_by == changed_by)
|
||||
if from_time:
|
||||
conditions.append(ConfigChangeLog.changed_at >= from_time)
|
||||
if to_time:
|
||||
conditions.append(ConfigChangeLog.changed_at <= to_time)
|
||||
|
||||
# 总数(带筛选)
|
||||
count_stmt = select(func.count(ConfigChangeLog.id))
|
||||
if conditions:
|
||||
count_stmt = count_stmt.where(and_(*conditions))
|
||||
total = (await db.execute(count_stmt)).scalar() or 0
|
||||
|
||||
# 分页查询(带筛选,按变更时间倒序)
|
||||
query = select(ConfigChangeLog)
|
||||
if conditions:
|
||||
query = query.where(and_(*conditions))
|
||||
query = (
|
||||
query
|
||||
.order_by(ConfigChangeLog.changed_at.desc())
|
||||
.offset(offset)
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
logs = list(result.scalars().all())
|
||||
logs = list((await db.execute(query)).scalars().all())
|
||||
|
||||
agent_ids = list({log.changed_by for log in logs if log.changed_by})
|
||||
agent_names: Dict[str, str] = {}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
# =============================================================================
|
||||
# 员工目录解析服务
|
||||
# =============================================================================
|
||||
# 说明:角色分配时,将管理员输入的「员工账号 或 姓名」解析为企微 UserID,
|
||||
# 并校验该员工确实属于企微组织架构(需求:分配角色时按姓名/账号自动转换 + 校验)。
|
||||
#
|
||||
# 数据源优先级(自动适配,无需改代码即可在权限开通后升级):
|
||||
# 1. 企微通讯录(实时):
|
||||
# - get_user_info(userid) 校验账号是否为组织内真实员工
|
||||
# - get_department_members(1, 1) 拉取全组织架构,用于「姓名 -> 账号」匹配
|
||||
# - 需要企微应用具备「通讯录读取」权限;权限不足(errcode 60011)时自动降级
|
||||
# 2. 本地 employees 表(仅登录过的员工):作为降级目录,保证功能在缺权限时仍可用
|
||||
#
|
||||
# 设计目标:无论企微权限是否齐全,分配功能都可用;权限齐全时自动获得全公司
|
||||
# 姓名搜索能力(full_directory=True),缺权限时仅覆盖已登录员工。
|
||||
# =============================================================================
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.employee import Employee
|
||||
from app.services.wecom_service import WecomService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 组织目录 Redis 缓存 key 与 TTL(10 分钟,避免频繁调用企微通讯录 API)
|
||||
ORG_DIRECTORY_CACHE_KEY = "wecom:org_directory"
|
||||
ORG_DIRECTORY_CACHE_TTL = 600
|
||||
|
||||
|
||||
async def get_org_directory(
|
||||
db: AsyncSession,
|
||||
redis: Optional[aioredis.Redis],
|
||||
) -> Tuple[List[Dict[str, Any]], bool]:
|
||||
"""获取「组织目录」(员工账号 + 姓名 列表),用于姓名 -> 账号匹配。
|
||||
|
||||
优先返回缓存;缓存未命中时尝试从企微通讯录拉全组织(需通讯录读取权限)。
|
||||
若企微权限不足或调用失败,降级到本地 employees 表。
|
||||
|
||||
Returns:
|
||||
(directory, full_directory)
|
||||
- directory: [{"employee_id": str, "name": str, "department": str}, ...]
|
||||
- full_directory: True=来自企微全组织(覆盖全公司);False=仅本地已登录员工
|
||||
"""
|
||||
# 1. 尝试命中缓存(缓存一定来自企微全组织,full=True)
|
||||
if redis:
|
||||
try:
|
||||
raw = await redis.get(ORG_DIRECTORY_CACHE_KEY)
|
||||
if raw:
|
||||
logger.debug("命中组织目录缓存")
|
||||
return json.loads(raw.decode("utf-8")), True
|
||||
except Exception as e:
|
||||
logger.warning(f"读取组织目录缓存失败(降级): {e}")
|
||||
|
||||
# 2. 尝试从企微通讯录拉全组织
|
||||
wecom = WecomService(redis_client=redis)
|
||||
try:
|
||||
members = await wecom.get_department_members(1, 1)
|
||||
directory = [
|
||||
{
|
||||
"employee_id": m.get("userid", ""),
|
||||
"name": m.get("name", "") or "",
|
||||
"department": ",".join(str(d) for d in (m.get("department") or [])),
|
||||
}
|
||||
for m in members
|
||||
if m.get("userid")
|
||||
]
|
||||
# 写入缓存(仅全组织结果缓存,降级结果不缓存以免长期误用)
|
||||
if redis:
|
||||
try:
|
||||
await redis.setex(
|
||||
ORG_DIRECTORY_CACHE_KEY,
|
||||
ORG_DIRECTORY_CACHE_TTL,
|
||||
json.dumps(directory, ensure_ascii=False),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"写入组织目录缓存失败: {e}")
|
||||
logger.info(f"组织目录来自企微全组织,共 {len(directory)} 人")
|
||||
return directory, True
|
||||
except Exception as e:
|
||||
err_text = str(e)
|
||||
if "60011" in err_text or "privilege" in err_text.lower():
|
||||
logger.warning("企微通讯录部门读取权限不足,降级到本地 employees 表")
|
||||
else:
|
||||
logger.warning(f"企微通讯录获取失败,降级本地: {err_text}")
|
||||
|
||||
# 3. 降级:本地 employees 表(仅登录过的员工)
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(Employee.employee_id, Employee.name).where(Employee.employee_id != "")
|
||||
)
|
||||
rows = result.all()
|
||||
directory = [
|
||||
{"employee_id": r[0], "name": r[1] or "", "department": ""} for r in rows
|
||||
]
|
||||
logger.info(f"组织目录降级到本地 employees 表,共 {len(directory)} 人")
|
||||
return directory, False
|
||||
except Exception as e:
|
||||
logger.error(f"本地 employees 表查询失败: {e}")
|
||||
return [], False
|
||||
|
||||
|
||||
async def resolve_target(
|
||||
target: str,
|
||||
db: AsyncSession,
|
||||
redis: Optional[aioredis.Redis] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""将输入的「员工账号 或 姓名」解析为企微 UserID,并校验组织内存在性。
|
||||
|
||||
Returns(结构化结果,由调用方翻译为响应/异常):
|
||||
{"found": True, "employee_id": str, "name": str, "source": str}
|
||||
{"found": False, "reason": str, "suggestion": str}
|
||||
{"ambiguous": True, "candidates": [{"employee_id","name","department"}, ...]}
|
||||
"""
|
||||
target = (target or "").strip()
|
||||
if not target:
|
||||
return {
|
||||
"found": False,
|
||||
"reason": "请输入员工账号或姓名",
|
||||
"suggestion": "请填写企微员工账号或姓名后重试",
|
||||
}
|
||||
|
||||
wecom = WecomService(redis_client=redis)
|
||||
|
||||
# 1) 先尝试按 userid 实时校验(企微组织内真实员工)
|
||||
try:
|
||||
info = await wecom.get_user_info(target)
|
||||
# 成功 -> target 本身就是有效 userid
|
||||
return {
|
||||
"found": True,
|
||||
"employee_id": info.get("userid") or target,
|
||||
"name": info.get("name") or "",
|
||||
"source": "wecom_userid",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"get_user_info('{target}') 未命中(将尝试按姓名解析): {e}")
|
||||
|
||||
# 2) 按姓名(包含)解析
|
||||
directory, full = await get_org_directory(db, redis)
|
||||
ql = target.lower()
|
||||
# 精确 userid 匹配优先(目录里可能存在)
|
||||
exact = [m for m in directory if m["employee_id"] and m["employee_id"].lower() == ql]
|
||||
# 姓名包含匹配
|
||||
name_hits = [m for m in directory if m["name"] and ql in m["name"].lower()]
|
||||
matches = exact if exact else name_hits
|
||||
|
||||
if len(matches) == 1:
|
||||
m = matches[0]
|
||||
# 二次实时校验该 userid 确实在组织内(网络可用时)
|
||||
try:
|
||||
info = await wecom.get_user_info(m["employee_id"])
|
||||
return {
|
||||
"found": True,
|
||||
"employee_id": info.get("userid") or m["employee_id"],
|
||||
"name": info.get("name") or m.get("name", ""),
|
||||
"source": "wecom_name" if full else "local_name",
|
||||
}
|
||||
except Exception:
|
||||
# 实时校验失败(网络/权限),但目录里有 -> 仍可用
|
||||
return {
|
||||
"found": True,
|
||||
"employee_id": m["employee_id"],
|
||||
"name": m.get("name", ""),
|
||||
"source": "local_name",
|
||||
}
|
||||
|
||||
if len(matches) > 1:
|
||||
return {
|
||||
"ambiguous": True,
|
||||
"candidates": [
|
||||
{
|
||||
"employee_id": m["employee_id"],
|
||||
"name": m["name"],
|
||||
"department": m.get("department", ""),
|
||||
}
|
||||
for m in matches[:10]
|
||||
],
|
||||
}
|
||||
|
||||
# 未找到
|
||||
if full:
|
||||
return {
|
||||
"found": False,
|
||||
"reason": f"企微组织架构中未找到匹配「{target}」的员工",
|
||||
"suggestion": "请确认姓名/账号拼写,或改为输入员工账号",
|
||||
}
|
||||
return {
|
||||
"found": False,
|
||||
"reason": f"未找到匹配「{target}」的员工",
|
||||
"suggestion": "当前仅能按姓名搜索已登录过本系统的员工;请直接输入员工账号,或为企微应用开通「通讯录读取」权限以搜索全公司",
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 运行期日志服务
|
||||
# =============================================================================
|
||||
# 说明:读取后端运行期日志目录(config.RUNTIME_LOG_DIR)下的 *.log 文件,
|
||||
# 逐行 json.loads(容错跳过非 JSON 行),按 级别 >= 阈值 / 时间区间 /
|
||||
# 关键字 过滤,按时间倒序分页返回结构化条目;
|
||||
# download 模式返回原始 JSON 行(供流式下载)。
|
||||
# 目录不存在 / 不可读时返回空结果(不抛 500)。
|
||||
# =============================================================================
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any, AsyncGenerator, Dict, List, Optional
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 日志级别数值映射(用于 >= 阈值 比较)
|
||||
_LEVEL_ORDER = {
|
||||
"DEBUG": 10,
|
||||
"INFO": 20,
|
||||
"WARNING": 30,
|
||||
"ERROR": 40,
|
||||
"CRITICAL": 50,
|
||||
}
|
||||
|
||||
# 轮转备份命名形如 wecom-it-desk.log.1 / .2 ...
|
||||
_ROTATED_RE = re.compile(r"\.log\.\d+$")
|
||||
|
||||
|
||||
def _level_num(level: str) -> int:
|
||||
"""将级别名转为数值(用于 >= 阈值比较)。"""
|
||||
return _LEVEL_ORDER.get((level or "INFO").upper(), 20)
|
||||
|
||||
|
||||
def _parse_ts(ts: Optional[str]) -> datetime:
|
||||
"""解析 JSONFormatter 写入的时间戳(ISO8601,可能带 Z)。
|
||||
|
||||
解析失败时返回 datetime.min,使其排在最后(不影响过滤语义)。
|
||||
"""
|
||||
if not ts:
|
||||
return datetime.min
|
||||
try:
|
||||
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
||||
except (ValueError, TypeError):
|
||||
try:
|
||||
return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%S.%f")
|
||||
except (ValueError, TypeError):
|
||||
return datetime.min
|
||||
|
||||
|
||||
def _list_log_files() -> List[str]:
|
||||
"""列出运行期日志目录下所有 .log 文件(含轮转备份)。
|
||||
|
||||
目录不存在 / 不可读时返回空列表(不抛异常)。
|
||||
"""
|
||||
log_dir = getattr(settings, "RUNTIME_LOG_DIR", None) or "/app/logs"
|
||||
if not log_dir or not os.path.isdir(log_dir):
|
||||
return []
|
||||
files: List[str] = []
|
||||
try:
|
||||
for name in os.listdir(log_dir):
|
||||
if name.endswith(".log") or _ROTATED_RE.search(name):
|
||||
full = os.path.join(log_dir, name)
|
||||
if os.path.isfile(full):
|
||||
files.append(full)
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.warning(f"读取运行期日志目录失败: {log_dir}: {e}")
|
||||
return []
|
||||
# 按文件名排序,保证读取顺序稳定(主日志优先于轮转备份)
|
||||
files.sort()
|
||||
return files
|
||||
|
||||
|
||||
def _iter_raw_lines() -> List[str]:
|
||||
"""逐文件读取所有日志行(原始文本,已去除行尾换行)。"""
|
||||
lines: List[str] = []
|
||||
for path in _list_log_files():
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
for line in f:
|
||||
stripped = line.rstrip("\n").rstrip("\r")
|
||||
if stripped:
|
||||
lines.append(stripped)
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.warning(f"读取运行期日志文件失败: {path}: {e}")
|
||||
continue
|
||||
return lines
|
||||
|
||||
|
||||
def _match(
|
||||
entry: Dict[str, Any],
|
||||
level_th: int,
|
||||
from_time: Optional[datetime],
|
||||
to_time: Optional[datetime],
|
||||
keyword: Optional[str],
|
||||
) -> bool:
|
||||
"""判断单条日志是否命中筛选条件。"""
|
||||
# 级别阈值:仅保留 >= 阈值的条目
|
||||
if _level_num(entry.get("level", "INFO")) < level_th:
|
||||
return False
|
||||
# 时间区间(闭区间)
|
||||
ts = _parse_ts(entry.get("timestamp"))
|
||||
if from_time and ts < from_time:
|
||||
return False
|
||||
if to_time and ts > to_time:
|
||||
return False
|
||||
# 关键字:消息子串(大小写不敏感);为空则跳过该过滤
|
||||
if keyword:
|
||||
msg = str(entry.get("message", ""))
|
||||
if keyword.lower() not in msg.lower():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def query_runtime_logs(
|
||||
level: str = "INFO",
|
||||
from_time: Optional[datetime] = None,
|
||||
to_time: Optional[datetime] = None,
|
||||
keyword: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> Dict[str, Any]:
|
||||
"""查询运行期日志(分页 + 多条件筛选)。
|
||||
|
||||
读取 config.RUNTIME_LOG_DIR 下 *.log(含轮转备份),逐行 json.loads
|
||||
(容错跳过非 JSON 行),按 级别>=阈值 / 时间区间 / 关键字 过滤,
|
||||
按时间倒序分页返回结构化条目。
|
||||
|
||||
目录不存在 / 不可读时返回空列表(不抛 500)。
|
||||
|
||||
Args:
|
||||
level: 级别阈值(DEBUG/INFO/WARNING/ERROR/CRITICAL),返回 >= 该级别
|
||||
from_time: 起始时间(可选)
|
||||
to_time: 结束时间(可选)
|
||||
keyword: 消息关键字子串(可选,大小写不敏感)
|
||||
page: 页码,从 1 开始
|
||||
page_size: 每页条数
|
||||
|
||||
Returns:
|
||||
Dict: {items, total, page, page_size}
|
||||
"""
|
||||
level_th = _level_num(level)
|
||||
entries: List[Dict[str, Any]] = []
|
||||
for raw in _iter_raw_lines():
|
||||
try:
|
||||
entry = json.loads(raw)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
# 容错:跳过非 JSON 行
|
||||
continue
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if _match(entry, level_th, from_time, to_time, keyword):
|
||||
entries.append(entry)
|
||||
|
||||
# 按时间倒序(新 -> 旧)
|
||||
entries.sort(key=lambda e: _parse_ts(e.get("timestamp")), reverse=True)
|
||||
|
||||
total = len(entries)
|
||||
page = max(1, page)
|
||||
page_size = max(1, page_size)
|
||||
start = (page - 1) * page_size
|
||||
page_items = entries[start:start + page_size]
|
||||
|
||||
items = []
|
||||
for e in page_items:
|
||||
items.append({
|
||||
"timestamp": e.get("timestamp", ""),
|
||||
"level": e.get("level", ""),
|
||||
"logger": e.get("logger", ""),
|
||||
"message": e.get("message", ""),
|
||||
"module": e.get("module", ""),
|
||||
"function": e.get("function", ""),
|
||||
"line": e.get("line", ""),
|
||||
"request_id": e.get("request_id"),
|
||||
"user_id": e.get("user_id"),
|
||||
})
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
|
||||
async def iter_runtime_log_lines(
|
||||
level: str = "INFO",
|
||||
from_time: Optional[datetime] = None,
|
||||
to_time: Optional[datetime] = None,
|
||||
keyword: Optional[str] = None,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""流式返回命中的原始 JSON 行(供下载)。
|
||||
|
||||
逐行读取并过滤,命中即 yield 原始行文本(带换行),
|
||||
便于 StreamingResponse 直接流式输出为 .log 文件。
|
||||
|
||||
Args:
|
||||
level: 级别阈值
|
||||
from_time: 起始时间(可选)
|
||||
to_time: 结束时间(可选)
|
||||
keyword: 消息关键字子串(可选)
|
||||
|
||||
Yields:
|
||||
str: 命中的原始 JSON 日志行(含行尾换行)
|
||||
"""
|
||||
level_th = _level_num(level)
|
||||
for raw in _iter_raw_lines():
|
||||
try:
|
||||
entry = json.loads(raw)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if _match(entry, level_th, from_time, to_time, keyword):
|
||||
yield raw + "\n"
|
||||
@@ -6,9 +6,11 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class JSONFormatter(logging.Formatter):
|
||||
@@ -51,12 +53,16 @@ class PlainFormatter(logging.Formatter):
|
||||
)
|
||||
|
||||
|
||||
def setup_logging(level: str = "INFO", json_format: bool = False) -> None:
|
||||
def setup_logging(level: str = "INFO", json_format: bool = False, log_dir: Optional[str] = None) -> None:
|
||||
"""配置日志系统
|
||||
|
||||
Args:
|
||||
level: 日志级别 (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
json_format: 是否使用 JSON 格式输出
|
||||
log_dir: 运行期日志文件目录。若目录存在或可被创建,则在 stdout handler
|
||||
之外额外写入 <log_dir>/wecom-it-desk.log
|
||||
(RotatingFileHandler,单文件上限 20MB,保留 5 个备份,复用 JSONFormatter)。
|
||||
为空 / 不可创建时仅保留 stdout handler,不影响启动。
|
||||
"""
|
||||
log_level = getattr(logging, level.upper(), logging.INFO)
|
||||
|
||||
@@ -68,7 +74,7 @@ def setup_logging(level: str = "INFO", json_format: bool = False) -> None:
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
|
||||
# 创建 console handler
|
||||
# 创建 console handler(保持原有 stdout 行为不变)
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(log_level)
|
||||
|
||||
@@ -81,6 +87,27 @@ def setup_logging(level: str = "INFO", json_format: bool = False) -> None:
|
||||
console_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
# 运行期日志文件 handler(独立于 stdout,便于"运行期日志"管理页面读取)
|
||||
# 始终使用 JSONFormatter,保证日志文件为结构化 JSON 行,便于解析与下载。
|
||||
if log_dir:
|
||||
try:
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
log_file = os.path.join(log_dir, "wecom-it-desk.log")
|
||||
file_handler = RotatingFileHandler(
|
||||
log_file,
|
||||
maxBytes=20 * 1024 * 1024, # 单文件上限 20MB
|
||||
backupCount=5, # 保留 5 个轮转备份
|
||||
encoding="utf-8",
|
||||
)
|
||||
file_handler.setLevel(log_level)
|
||||
file_handler.setFormatter(JSONFormatter())
|
||||
root_logger.addHandler(file_handler)
|
||||
except (OSError, PermissionError) as e:
|
||||
# 目录不可创建 / 不可写:不阻塞启动,仅记录告警,降级为仅 stdout 日志
|
||||
logging.getLogger(__name__).warning(
|
||||
f"无法为运行期日志创建文件 handler(log_dir={log_dir}): {e};仅保留 stdout 日志。"
|
||||
)
|
||||
|
||||
# 设置第三方库日志级别
|
||||
logging.getLogger("uvicorn").setLevel(logging.WARNING)
|
||||
logging.getLogger("fastapi").setLevel(logging.WARNING)
|
||||
|
||||
@@ -136,8 +136,11 @@ services:
|
||||
- BACKEND_PORT=8000
|
||||
# 上传文件目录(持久化)
|
||||
- UPLOAD_DIR=/app/uploads
|
||||
# 运行期日志目录(供"运行期日志"管理页面读取,需与下方 volumes 挂载一致)
|
||||
- RUNTIME_LOG_DIR=/app/logs
|
||||
volumes:
|
||||
- backend-uploads:/app/uploads
|
||||
- ${RUNTIME_LOG_HOST_DIR:-/var/log/wecom-it-desk}:/app/logs
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -1071,6 +1071,30 @@ ALTER TABLE agents ADD COLUMN mfa_bound_at TIMESTAMP;
|
||||
|
||||
**开发周期**:4-6周
|
||||
|
||||
#### 管理后台日志体系(贯穿各阶段,2026-07-09 增补)
|
||||
|
||||
> **为什么单列**:管理后台长期存在 5 类「日志」机制概念混淆(配置变更历史 / 安全审计日志 / 自动化动作日志 / 运行期结构化日志 / 会话消息记录)。本小节统一定义与边界,支撑阶段四「日志标准」,并将用户 2026-07-09 拍板的 5 项决策纳入 PRD。详细定义、接口、字段、验收标准见 `docs/04-功能设计/系统日志与审计日志-产品与设计.md`。
|
||||
|
||||
**五类日志定位**:
|
||||
|
||||
| 编号 | 名称 | 落点 | 核心用途 | 实现状态 |
|
||||
|------|------|------|----------|----------|
|
||||
| A | 配置变更历史(系统日志) | `config_change_logs` | 配置项前后值 diff | 已实现(前端无筛选) |
|
||||
| B | 安全审计日志(操作日志) | `audit_logs` | 安全行为事件 | 已实现(部分事件待补) |
|
||||
| C | 自动化动作日志 | `auto_action_logs` | 外部系统调用出入参 | 已实现(落表为主) |
|
||||
| D | 运行期结构化日志 | stdout → Docker 文件 | 应用运行排障 | 后端已实现,查看页未落地 |
|
||||
| E | 会话 / 消息记录 | `conversations` / `messages` | 业务主数据 | 已实现 |
|
||||
|
||||
**用户已拍板 5 项决策(2026-07-09,宋献)**:
|
||||
|
||||
| # | 决策 | 优先级 | 验收要点 |
|
||||
|---|------|--------|----------|
|
||||
| 1 | 侧边栏「系统日志」区分命名:配置变更历史(A) vs 安全审计日志(B) | P1 | 两个独立清晰入口,无「系统日志」歧义统称 |
|
||||
| 2 | 配置变更维持 A+B 双写,不收敛 | P0 | `PUT /api/admin/configs/{key}` 同时写两表(`config_change` 事件) |
|
||||
| 3 | A 页补充「配置键 / 操作人 / 时间」筛选(后端 `GET /api/admin/system-logs` 加参) | P1 | 三筛选可用,后端参数支持 |
|
||||
| 4 | D 运行期日志查看:按级别 / 时间筛选 + 下载(权限 admin) | P0 | 页面 + 后端接口落地(`.txt` / `.log`) |
|
||||
| 5 | 补《系统日志/审计日志 产品+设计文档》,并入 PRD/架构、列项目任务 | — | 本文档即交付,看板登记 #101–#104 |
|
||||
|
||||
#### 阶段五:自动/辅助审核、开单、结单
|
||||
|
||||
> **本阶段解决痛点**:多系统切换效率问题(延伸痛点4/5,进一步提升人效)。
|
||||
|
||||
@@ -294,6 +294,18 @@ OAuth2 授权完成(用户端) / 登录成功(坐席/管理端)
|
||||
- 会话监控
|
||||
- 知识图谱管理
|
||||
|
||||
#### 7.1.1 日志体系(2026-07-09 增补)
|
||||
|
||||
> 管理后台 5 类「日志」机制统一归口,详细定义、接口、字段、权限见 `docs/04-功能设计/系统日志与审计日志-产品与设计.md`。
|
||||
|
||||
| 编号 | 日志 | 表 / 存储 | 接口路径 | 权限 | 状态 |
|
||||
|------|------|-----------|----------|------|------|
|
||||
| A | 配置变更历史 | `config_change_logs` | `GET /api/admin/system-logs`(写:`PUT /api/admin/configs/{key}`) | admin | 已实现(无筛选) |
|
||||
| B | 安全审计日志 | `audit_logs` | `GET /admin/audit-logs` | `audit_log:read:all`(admin / auditor) | 已实现(事件待补) |
|
||||
| C | 自动化动作日志 | `auto_action_logs` | (落表,暂未暴露列表接口) | — | 已实现 |
|
||||
| D | 运行期结构化日志 | stdout → Docker 文件 | 待开发:`GET /api/admin/runtime-logs`(筛选 + 下载) | admin | 查看页未落地 |
|
||||
| E | 会话 / 消息记录 | `conversations` / `messages` | 会话管理相关接口 | agent / admin | 已实现(业务主数据) |
|
||||
|
||||
### 7.2 坐席工作台模块
|
||||
|
||||
**技术栈**:Vue 3 + TypeScript + Element Plus + Pinia
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
>
|
||||
> 📝 **更新规则**:每次 Claude 完成 / 开始 / 阻塞重要任务,会主动更新本文件。你也可以自己改(纯 markdown,git 跟踪)。
|
||||
|
||||
最后更新:**2026-07-09 09:20**(WS 子协议修复部署生产 + 合入 origin/main;方案A E2E 2026-07-08 通过)
|
||||
最后更新:**2026-07-09**(WS 子协议修复部署生产 + 合入 origin/main;方案A E2E 2026-07-08 通过;+ 日志体系 5 项决策登记 #101–#104)
|
||||
|
||||
---
|
||||
|
||||
@@ -151,6 +151,7 @@
|
||||
| #48 | v1.0 收窄 set_real_ip_from | 🔴 P0 | 现 allow 0.0.0.0/0 是临时方案,正式上线前必须改精确代理 IP |
|
||||
| #81 | 敏感词检测 + 语气优化 | 🔴 P0 | v0.7.1 开发内容,文本安全过滤 ⚠️验真:隐私检测Bug+仅警告 |
|
||||
| #90 | 身份认证问题修复 | ✅已完成 | ✅Portal→H5 token传递修复:路由守卫接收token后调用fetchEmployeeInfo()获取用户信息 |
|
||||
| #104 | 运行期结构化日志查看页(D) | 🔴 P0 | 决策4:落地「按级别/时间筛选 + 下载」页面与后端接口(`GET /api/admin/runtime-logs`),权限 admin;产品规格见 `docs/04-功能设计/系统日志与审计日志-产品与设计.md`;状态:待开发 |
|
||||
|
||||
---
|
||||
|
||||
@@ -166,6 +167,9 @@
|
||||
| #100 | 消息推送策略优化与超时提醒 | ✅已完成:坐席回复仅推 H5,超时未回复发送企微提醒,10分钟后标记待关闭 |
|
||||
| 🆕 | 修 64 pre-existing 测试失败 | Role.data_scope 缺字段 / WecomService DI / test_message_experience 等 |
|
||||
| #75 | 头像同步功能完善 | 登录全路径强制同步 + 前端首字降级;代码已交付(12/12 单测 + 103 回归),**业务效果待端到端验真** | 🆕 等用户通知执行 |
|
||||
| #101 | 管理后台日志命名区分(决策1) | 侧边栏「系统日志」明确区分「配置变更历史(A)」与「安全审计日志(B)」,避免管理员混淆;状态:待开发 |
|
||||
| #102 | 配置变更 A+B 双写边界确认(决策2) | 维持 `PUT /api/admin/configs/{key}` 同时写 `config_change_logs` 与 `audit_logs` 的 `config_change` 事件,不收敛;代码已实现,本文档锁定边界;状态:设计中 |
|
||||
| #103 | 配置变更历史页筛选(决策3) | A 页补充「配置键/操作人/时间」筛选,后端 `GET /api/admin/system-logs` 增加对应过滤参数;状态:待开发 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,6 +5,21 @@
|
||||
// 所有函数返回 axios response,调用方从 response 获取业务数据
|
||||
|
||||
import apiClient from './index'
|
||||
import axios from 'axios'
|
||||
|
||||
// 运行期日志下载使用独立 axios 实例:响应为 blob(文件流),
|
||||
// 不能走 apiClient 的 JSON 信封拦截器(会把 blob 当成 {code,message,data} 解析而报错)。
|
||||
const downloadClient = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
})
|
||||
downloadClient.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
import type {
|
||||
DashboardOverview,
|
||||
ConfigGroup,
|
||||
@@ -29,6 +44,7 @@ import type {
|
||||
RoleRevokeRequest,
|
||||
RoleMappingRule,
|
||||
RoleMappingRuleRequest,
|
||||
EmployeeSearchResponse,
|
||||
} from '@/types'
|
||||
|
||||
// ==========================================================================
|
||||
@@ -387,6 +403,13 @@ export function assignRole(
|
||||
return apiClient.post('/admin/roles/assign', data)
|
||||
}
|
||||
|
||||
/** 员工目录搜索(分配角色时按姓名/账号自动补全) */
|
||||
export function searchEmployees(
|
||||
q: string
|
||||
): Promise<{ data: { code: number; data: EmployeeSearchResponse; message: string } }> {
|
||||
return apiClient.get('/admin/roles/employees/search', { params: { q } })
|
||||
}
|
||||
|
||||
/** 撤销用户角色 */
|
||||
export function revokeRole(
|
||||
data: RoleRevokeRequest
|
||||
@@ -454,14 +477,65 @@ export function getAgentPerformance(params?: {
|
||||
// P2: 系统日志
|
||||
// ==========================================================================
|
||||
|
||||
/** 获取系统日志(配置变更日志) */
|
||||
/** 获取系统日志(配置变更历史),支持配置键/操作人/时间范围筛选 */
|
||||
export function getSystemLogs(params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
config_key?: string
|
||||
changed_by?: string
|
||||
from?: string
|
||||
to?: string
|
||||
}): Promise<{ data: { code: number; data: { items: any[]; total: number; page: number; page_size: number }; message: string } }> {
|
||||
return apiClient.get('/admin/system-logs', { params })
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 安全审计日志 (B) — 复用既有后端 GET /admin/audit-logs
|
||||
// ==========================================================================
|
||||
|
||||
/** 获取安全审计日志(分页 + 操作人/操作类型/时间范围筛选) */
|
||||
export function getAuditLogs(params?: {
|
||||
employee_id?: string
|
||||
action?: string
|
||||
from?: string
|
||||
to?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}): Promise<{ data: { code: number; data: { items: any[]; total: number; page: number; page_size: number }; message: string } }> {
|
||||
return apiClient.get('/admin/audit-logs', { params })
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 运行期日志 (D) — 新增后端 GET /admin/runtime-logs
|
||||
// ==========================================================================
|
||||
|
||||
/** 获取运行期日志(分页 + 级别/时间/关键字筛选) */
|
||||
export function getRuntimeLogs(params?: {
|
||||
level?: string
|
||||
from?: string
|
||||
to?: string
|
||||
keyword?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}): Promise<{ data: { code: number; data: { items: any[]; total: number; page: number; page_size: number }; message: string } }> {
|
||||
return apiClient.get('/admin/runtime-logs', { params })
|
||||
}
|
||||
|
||||
/** 下载运行期日志(命中行文本,blob 流,responseType: blob) */
|
||||
export function getRuntimeLogDownload(params?: {
|
||||
level?: string
|
||||
from?: string
|
||||
to?: string
|
||||
keyword?: string
|
||||
}): Promise<Blob> {
|
||||
return downloadClient
|
||||
.get('/admin/runtime-logs', {
|
||||
params: { ...params, download: true },
|
||||
responseType: 'blob',
|
||||
})
|
||||
.then((res) => res.data as Blob)
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// P2: 满意度评价统计
|
||||
// ==========================================================================
|
||||
@@ -633,4 +707,7 @@ export const adminApi = {
|
||||
getAgentPerformance,
|
||||
getSystemLogs,
|
||||
getEvaluationStats,
|
||||
getAuditLogs,
|
||||
getRuntimeLogs,
|
||||
getRuntimeLogDownload,
|
||||
}
|
||||
|
||||
@@ -97,7 +97,15 @@
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/system-logs">
|
||||
<el-icon><Notebook /></el-icon>
|
||||
<span>系统日志</span>
|
||||
<span>配置变更历史</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/audit-logs">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>安全审计日志</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/runtime-logs">
|
||||
<el-icon><Warning /></el-icon>
|
||||
<span>运行期日志</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/evaluation-stats">
|
||||
<el-icon><Star /></el-icon>
|
||||
|
||||
@@ -116,7 +116,21 @@ const routes = [
|
||||
path: 'system-logs',
|
||||
name: 'SystemLogs',
|
||||
component: () => import('@/views/SystemLogs.vue'),
|
||||
meta: { title: '系统日志', requiresAuth: true },
|
||||
meta: { title: '配置变更历史', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
// 安全审计日志(复用既有后端 GET /api/admin/audit-logs)
|
||||
path: 'audit-logs',
|
||||
name: 'AuditLogs',
|
||||
component: () => import('@/views/AuditLogs.vue'),
|
||||
meta: { title: '安全审计日志', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
// 运行期日志(新增后端 GET /api/admin/runtime-logs)
|
||||
path: 'runtime-logs',
|
||||
name: 'RuntimeLogs',
|
||||
component: () => import('@/views/RuntimeLogs.vue'),
|
||||
meta: { title: '运行期日志', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
// P2 占位页:主题模板
|
||||
|
||||
@@ -433,6 +433,7 @@ export interface Role {
|
||||
export interface UserRole {
|
||||
id: string
|
||||
employee_id: string // 企微 UserID
|
||||
employee_name?: string // 员工姓名(可选,后端 JOIN 返回)
|
||||
role_id: string // 关联角色 ID
|
||||
role_name: string // 角色标识
|
||||
role_display_name: string // 角色显示名称
|
||||
@@ -474,9 +475,23 @@ export const MAPPING_SOURCE_LABELS: Record<MappingSourceSource, string> = {
|
||||
ehr_position: 'eHR 岗位',
|
||||
}
|
||||
|
||||
/** 员工目录候选(搜索自动补全用) */
|
||||
export interface EmployeeCandidate {
|
||||
employee_id: string
|
||||
name: string
|
||||
department: string
|
||||
}
|
||||
|
||||
/** 员工搜索响应 */
|
||||
export interface EmployeeSearchResponse {
|
||||
items: EmployeeCandidate[]
|
||||
full_directory: boolean | null // true=企微全组织;false=仅已登录员工;null=未搜索
|
||||
}
|
||||
|
||||
/** 分配角色请求 */
|
||||
export interface RoleAssignRequest {
|
||||
employee_id: string
|
||||
target?: string // 员工账号或姓名(推荐,后端自动解析)
|
||||
employee_id?: string // 兼容旧字段:直接传企微 UserID
|
||||
role_name: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<!-- ================================================================== -->
|
||||
<!-- 安全审计日志页面(B) — 复用后端 GET /admin/audit-logs -->
|
||||
<!-- 列:时间 / 操作人 / 操作类型 / 资源 / 结果 / 详情 / IP -->
|
||||
<!-- 筛选:操作人 / 操作类型 / 时间范围 -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="audit-logs">
|
||||
<!-- 筛选栏 -->
|
||||
<el-card class="filter-bar" shadow="never">
|
||||
<el-form :inline="true" @submit.prevent>
|
||||
<el-form-item label="操作人">
|
||||
<el-input
|
||||
v-model="filters.employeeId"
|
||||
placeholder="employee_id"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@keyup.enter="handleFilter"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="操作类型">
|
||||
<el-select
|
||||
v-model="filters.action"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
style="width: 200px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in actionOptions"
|
||||
:key="item"
|
||||
:label="item"
|
||||
:value="item"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="时间范围">
|
||||
<el-date-picker
|
||||
v-model="filters.dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
:shortcuts="dateShortcuts"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleFilter">查询</el-button>
|
||||
<el-button @click="resetFilter">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 日志列表 -->
|
||||
<el-table :data="logs" v-loading="loading" stripe style="width: 100%">
|
||||
<el-table-column type="expand">
|
||||
<template #default="{ row }">
|
||||
<div class="detail-block">
|
||||
<div class="detail-title">详情 (details)</div>
|
||||
<pre class="detail-json">{{ formatDetails(row.details) }}</pre>
|
||||
<div v-if="row.resource_id" class="detail-meta">
|
||||
<span>资源ID:{{ row.resource_id }}</span>
|
||||
<span>UA:{{ row.user_agent || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="时间" width="170">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="employee_id" label="操作人" width="130" show-overflow-tooltip />
|
||||
<el-table-column prop="action" label="操作类型" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain">{{ row.action || '—' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="resource" label="资源" width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="result" label="结果" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
size="small"
|
||||
:type="row.result === 'success' ? 'success' : 'danger'"
|
||||
>
|
||||
{{ row.result || '—' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ip_address" label="IP" width="140" show-overflow-tooltip />
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-bar">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="loadLogs"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// =============================================================================
|
||||
// 安全审计日志页面逻辑(B)
|
||||
// =============================================================================
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { getAuditLogs } from '@/api/admin'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// ---------- 状态 ----------
|
||||
const loading = ref(false)
|
||||
const logs = ref<any[]>([])
|
||||
|
||||
const filters = reactive({
|
||||
employeeId: '',
|
||||
action: '',
|
||||
dateRange: null as null | [string, string],
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
// 常见审计操作类型(可选,也支持用户自定义输入)
|
||||
const actionOptions = [
|
||||
'config_change',
|
||||
'login',
|
||||
'logout',
|
||||
'permission_change',
|
||||
'role_assign',
|
||||
'role_revoke',
|
||||
'agent_create',
|
||||
'agent_update',
|
||||
'agent_delete',
|
||||
'integration_update',
|
||||
]
|
||||
|
||||
// 时间快捷选项
|
||||
const dateShortcuts = [
|
||||
{
|
||||
text: '最近24小时',
|
||||
value: () => {
|
||||
const end = new Date()
|
||||
const start = new Date()
|
||||
start.setTime(start.getTime() - 3600 * 1000 * 24)
|
||||
return [start, end]
|
||||
},
|
||||
},
|
||||
{
|
||||
text: '最近7天',
|
||||
value: () => {
|
||||
const end = new Date()
|
||||
const start = new Date()
|
||||
start.setTime(start.getTime() - 3600 * 1000 * 24 * 7)
|
||||
return [start, end]
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// ---------- 构建请求参数 ----------
|
||||
function buildParams(): Record<string, any> {
|
||||
const params: Record<string, any> = {
|
||||
page: pagination.page,
|
||||
page_size: pagination.pageSize,
|
||||
}
|
||||
// 空筛选等价于原行为:仅分页
|
||||
if (filters.employeeId) params.employee_id = filters.employeeId
|
||||
if (filters.action) params.action = filters.action
|
||||
if (filters.dateRange && filters.dateRange.length === 2) {
|
||||
// 闭区间:结束日期补全到当天 23:59:59,确保整日命中
|
||||
params.from = `${filters.dateRange[0]}T00:00:00`
|
||||
params.to = `${filters.dateRange[1]}T23:59:59`
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// ---------- 数据加载 ----------
|
||||
async function loadLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await getAuditLogs(buildParams())
|
||||
if (data.code === 0) {
|
||||
logs.value = data.data.items || []
|
||||
pagination.total = data.data.total || 0
|
||||
}
|
||||
} catch (err: any) {
|
||||
ElMessage.error('加载安全审计日志失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 事件处理 ----------
|
||||
function handleFilter() {
|
||||
pagination.page = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
function resetFilter() {
|
||||
filters.employeeId = ''
|
||||
filters.action = ''
|
||||
filters.dateRange = null
|
||||
pagination.page = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
pagination.page = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
// ---------- 工具函数 ----------
|
||||
function formatTime(iso: string): string {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return iso
|
||||
return d.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function formatDetails(details: any): string {
|
||||
if (details === null || details === undefined) return '—'
|
||||
try {
|
||||
return JSON.stringify(details, null, 2)
|
||||
} catch {
|
||||
return String(details)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 生命周期 ----------
|
||||
onMounted(() => loadLogs())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.audit-logs {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
margin-bottom: 16px;
|
||||
border: none;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.detail-block {
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-secondary, #f5f7fa);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.detail-json {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: 'Menlo', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
@@ -33,7 +33,7 @@
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
placeholder="搜索员工姓名 / Employee ID"
|
||||
placeholder="搜索员工姓名 / 员工账号"
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
style="width: 260px"
|
||||
@@ -78,7 +78,7 @@
|
||||
size="small"
|
||||
empty-text="暂无用户记录"
|
||||
>
|
||||
<el-table-column prop="employee_id" label="Employee ID" min-width="140" />
|
||||
<el-table-column prop="employee_id" label="员工账号" min-width="140" />
|
||||
<el-table-column prop="name" label="姓名" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.name">{{ row.name }}</span>
|
||||
|
||||
@@ -96,8 +96,14 @@
|
||||
stripe
|
||||
size="small"
|
||||
empty-text="暂无用户角色记录"
|
||||
class="user-roles-table"
|
||||
>
|
||||
<el-table-column prop="employee_id" label="员工 ID" min-width="140" />
|
||||
<el-table-column prop="employee_id" label="员工账号" min-width="120" />
|
||||
<el-table-column prop="employee_name" label="姓名" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<span class="employee-name">{{ row.employee_name || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="role_display_name" label="角色" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getRoleTagType(row.role_name)" size="small">
|
||||
@@ -214,11 +220,25 @@
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="员工 ID(企微 UserID)">
|
||||
<el-input
|
||||
v-model="assignForm.employee_id"
|
||||
placeholder="输入企微 UserID"
|
||||
/>
|
||||
<el-form-item label="员工账号或姓名">
|
||||
<el-autocomplete
|
||||
v-model="assignForm.target"
|
||||
:fetch-suggestions="querySearchEmployees"
|
||||
:filter-method="filterEmployeeSuggestions"
|
||||
placeholder="输入员工账号或姓名,自动匹配"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@select="onSelectEmployee"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div class="emp-option">
|
||||
<span class="emp-name">{{ item.name || '—' }}</span>
|
||||
<span class="emp-id">{{ item.employee_id }}</span>
|
||||
<span v-if="item.department" class="emp-dept">{{ item.department }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-autocomplete>
|
||||
<div class="form-hint">支持直接输入员工账号(如 tangzhenzhen),或输入姓名自动转换;系统会实时校验该员工是否在企微组织架构内。</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标角色">
|
||||
<el-select v-model="assignForm.role_name" placeholder="选择角色" style="width: 100%">
|
||||
@@ -260,7 +280,7 @@
|
||||
<div v-if="revokingUserRole" class="revoke-info">
|
||||
<p>确认撤销以下角色分配?</p>
|
||||
<div class="revoke-detail">
|
||||
<div><strong>员工:</strong>{{ revokingUserRole.employee_id }}</div>
|
||||
<div><strong>员工:</strong>{{ revokingUserRole.employee_id }} <span v-if="revokingUserRole.employee_name">({{ revokingUserRole.employee_name }})</span></div>
|
||||
<div><strong>角色:</strong>{{ revokingUserRole.role_display_name }} ({{ revokingUserRole.role_name }})</div>
|
||||
<div><strong>来源:</strong>{{ ROLE_SOURCE_LABELS[revokingUserRole.source as UserRoleSource] || revokingUserRole.source }}</div>
|
||||
</div>
|
||||
@@ -353,6 +373,7 @@ import {
|
||||
getUserRoleAssignments,
|
||||
createRoleMappingRule,
|
||||
deleteRoleMappingRule,
|
||||
searchEmployees,
|
||||
} from '@/api/admin'
|
||||
import type {
|
||||
Role,
|
||||
@@ -360,6 +381,7 @@ import type {
|
||||
UserRoleSource,
|
||||
RoleMappingRule,
|
||||
MappingSourceSource,
|
||||
EmployeeCandidate,
|
||||
} from '@/types'
|
||||
import { ROLE_SOURCE_LABELS, MAPPING_SOURCE_LABELS } from '@/types'
|
||||
|
||||
@@ -377,7 +399,7 @@ const mappingRules = ref<RoleMappingRule[]>([])
|
||||
const assignDialogVisible = ref<boolean>(false)
|
||||
const assigning = ref<boolean>(false)
|
||||
const assignForm = reactive({
|
||||
employee_id: '',
|
||||
target: '',
|
||||
role_name: 'agent',
|
||||
reason: '',
|
||||
})
|
||||
@@ -557,16 +579,56 @@ function unwrapList<T>(resp: unknown): T[] {
|
||||
|
||||
/** 打开分配角色对话框 */
|
||||
function showAssignDialog(): void {
|
||||
assignForm.employee_id = ''
|
||||
assignForm.target = ''
|
||||
assignForm.role_name = 'agent'
|
||||
assignForm.reason = ''
|
||||
assignDialogVisible.value = true
|
||||
}
|
||||
|
||||
/** 员工目录搜索(自动补全候选) */
|
||||
async function querySearchEmployees(
|
||||
queryString: string,
|
||||
cb: (results: Array<Record<string, unknown>>) => void,
|
||||
): Promise<void> {
|
||||
const q = (queryString || '').trim()
|
||||
if (!q) {
|
||||
cb([])
|
||||
return
|
||||
}
|
||||
try {
|
||||
const resp = await searchEmployees(q)
|
||||
// 响应拦截器已解包:resp.data 即后端 data 字段(EmployeeSearchResponse)
|
||||
const body = (resp as { data?: EmployeeSearchResponse }).data
|
||||
const items = (body?.items || []) as EmployeeCandidate[]
|
||||
// el-autocomplete 以 value 作为回填文本,这里回填员工账号(精确)
|
||||
cb(items.map((it) => ({ ...it, value: it.employee_id }) as Record<string, unknown>))
|
||||
} catch {
|
||||
cb([])
|
||||
}
|
||||
}
|
||||
|
||||
/** 选中候选时,确保输入框回填为员工账号(精确值) */
|
||||
function onSelectEmployee(item: Record<string, unknown>): void {
|
||||
assignForm.target = String(item.employee_id || '')
|
||||
}
|
||||
|
||||
/** 自定义过滤方法:支持按员工账号或姓名搜索(解决无法输入中文姓名的 bug) */
|
||||
function filterEmployeeSuggestions(
|
||||
queryString: string,
|
||||
item: Record<string, unknown>,
|
||||
): boolean {
|
||||
if (!queryString) return true
|
||||
const q = queryString.toLowerCase()
|
||||
const id = String(item.employee_id || '').toLowerCase()
|
||||
const name = String(item.name || '').toLowerCase()
|
||||
// 同时匹配账号和姓名,支持中文
|
||||
return id.includes(q) || name.includes(q)
|
||||
}
|
||||
|
||||
/** 提交分配角色 */
|
||||
async function handleAssign(): Promise<void> {
|
||||
if (!assignForm.employee_id.trim()) {
|
||||
ElMessage.warning('请输入员工 ID')
|
||||
if (!assignForm.target.trim()) {
|
||||
ElMessage.warning('请输入员工账号或姓名')
|
||||
return
|
||||
}
|
||||
if (!assignForm.role_name) {
|
||||
@@ -577,7 +639,7 @@ async function handleAssign(): Promise<void> {
|
||||
assigning.value = true
|
||||
try {
|
||||
await assignRole({
|
||||
employee_id: assignForm.employee_id.trim(),
|
||||
target: assignForm.target.trim(),
|
||||
role_name: assignForm.role_name,
|
||||
reason: assignForm.reason || undefined,
|
||||
})
|
||||
@@ -586,7 +648,7 @@ async function handleAssign(): Promise<void> {
|
||||
// 刷新数据
|
||||
await loadData()
|
||||
} catch {
|
||||
ElMessage.error('分配失败,请检查员工 ID 是否正确')
|
||||
ElMessage.error('分配失败,请检查员工账号或姓名是否正确')
|
||||
} finally {
|
||||
assigning.value = false
|
||||
}
|
||||
@@ -698,6 +760,33 @@ async function loadData(): Promise<void> {
|
||||
/* 页面容器 */
|
||||
}
|
||||
|
||||
/* 分配角色:员工账号/姓名自动补全选项 */
|
||||
.emp-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.emp-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.emp-id {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.emp-dept {
|
||||
color: var(--el-color-primary);
|
||||
font-size: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -841,6 +930,32 @@ async function loadData(): Promise<void> {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 表格样式增强:解决白底看不清问题 */
|
||||
.user-roles-table :deep(.el-table__header th) {
|
||||
background-color: var(--bg-tertiary) !important;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-roles-table :deep(.el-table__body td) {
|
||||
background-color: #fafafa;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.user-roles-table :deep(.el-table__body tr:hover > td) {
|
||||
background-color: #f0f7ff !important;
|
||||
}
|
||||
|
||||
.user-roles-table :deep(.el-table__row--striped td) {
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
/* 员工姓名样式 */
|
||||
.employee-name {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 来源标签 */
|
||||
.source-badge {
|
||||
display: inline-block;
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<!-- ================================================================== -->
|
||||
<!-- 运行期日志页面(D) — 后端 GET /admin/runtime-logs -->
|
||||
<!-- 列:时间 / 级别 / logger / 消息 / request_id -->
|
||||
<!-- 筛选:级别 / 时间范围 / 关键字;支持下载命中行 -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="runtime-logs">
|
||||
<!-- 筛选栏 -->
|
||||
<el-card class="filter-bar" shadow="never">
|
||||
<el-form :inline="true" @submit.prevent>
|
||||
<el-form-item label="级别">
|
||||
<el-select
|
||||
v-model="filters.level"
|
||||
placeholder="级别阈值"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
>
|
||||
<el-option label="DEBUG" value="DEBUG" />
|
||||
<el-option label="INFO" value="INFO" />
|
||||
<el-option label="WARNING" value="WARNING" />
|
||||
<el-option label="ERROR" value="ERROR" />
|
||||
<el-option label="CRITICAL" value="CRITICAL" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关键字">
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
placeholder="消息关键字(子串)"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
@keyup.enter="handleFilter"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="时间范围">
|
||||
<el-date-picker
|
||||
v-model="filters.dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
:shortcuts="dateShortcuts"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleFilter">查询</el-button>
|
||||
<el-button @click="resetFilter">重置</el-button>
|
||||
<el-button type="success" :loading="downloading" @click="handleDownload">
|
||||
下载命中行
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 日志列表 -->
|
||||
<el-table :data="logs" v-loading="loading" stripe style="width: 100%">
|
||||
<el-table-column type="expand">
|
||||
<template #default="{ row }">
|
||||
<div class="detail-block">
|
||||
<div class="detail-meta">
|
||||
<span>模块:{{ row.module || '—' }}</span>
|
||||
<span>函数:{{ row.function || '—' }}</span>
|
||||
<span>行号:{{ row.line || '—' }}</span>
|
||||
</div>
|
||||
<pre class="detail-message">{{ row.message || '—' }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="timestamp" label="时间" width="170">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.timestamp) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="level" label="级别" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="levelTagType(row.level)" size="small" effect="dark">
|
||||
{{ row.level || '—' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="logger" label="logger" width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="message" label="消息" min-width="320" show-overflow-tooltip />
|
||||
<el-table-column prop="request_id" label="request_id" width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ row.request_id || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-bar">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="loadLogs"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// =============================================================================
|
||||
// 运行期日志页面逻辑(D)
|
||||
// =============================================================================
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { getRuntimeLogs, getRuntimeLogDownload } from '@/api/admin'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// ---------- 状态 ----------
|
||||
const loading = ref(false)
|
||||
const downloading = ref(false)
|
||||
const logs = ref<any[]>([])
|
||||
|
||||
const filters = reactive({
|
||||
level: 'INFO',
|
||||
keyword: '',
|
||||
dateRange: null as null | [string, string],
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
// 时间快捷选项
|
||||
const dateShortcuts = [
|
||||
{
|
||||
text: '最近24小时',
|
||||
value: () => {
|
||||
const end = new Date()
|
||||
const start = new Date()
|
||||
start.setTime(start.getTime() - 3600 * 1000 * 24)
|
||||
return [start, end]
|
||||
},
|
||||
},
|
||||
{
|
||||
text: '最近7天',
|
||||
value: () => {
|
||||
const end = new Date()
|
||||
const start = new Date()
|
||||
start.setTime(start.getTime() - 3600 * 1000 * 24 * 7)
|
||||
return [start, end]
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// ---------- 构建请求参数 ----------
|
||||
function buildParams(download = false): Record<string, any> {
|
||||
const params: Record<string, any> = {}
|
||||
if (!download) {
|
||||
params.page = pagination.page
|
||||
params.page_size = pagination.pageSize
|
||||
}
|
||||
// 空筛选等价于全部:仅按默认级别
|
||||
if (filters.level) params.level = filters.level
|
||||
if (filters.keyword) params.keyword = filters.keyword
|
||||
if (filters.dateRange && filters.dateRange.length === 2) {
|
||||
// 闭区间:结束日期补全到当天 23:59:59,确保整日命中
|
||||
params.from = `${filters.dateRange[0]}T00:00:00`
|
||||
params.to = `${filters.dateRange[1]}T23:59:59`
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// ---------- 数据加载 ----------
|
||||
async function loadLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await getRuntimeLogs(buildParams())
|
||||
if (data.code === 0) {
|
||||
logs.value = data.data.items || []
|
||||
pagination.total = data.data.total || 0
|
||||
}
|
||||
} catch (err: any) {
|
||||
ElMessage.error('加载运行期日志失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 下载命中行 ----------
|
||||
async function handleDownload() {
|
||||
downloading.value = true
|
||||
try {
|
||||
const blob = await getRuntimeLogDownload(buildParams(true))
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
a.download = `runtime-logs-${ts}.log`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
ElMessage.success('下载已开始')
|
||||
} catch (err: any) {
|
||||
ElMessage.error('下载运行期日志失败')
|
||||
} finally {
|
||||
downloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 事件处理 ----------
|
||||
function handleFilter() {
|
||||
pagination.page = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
function resetFilter() {
|
||||
filters.level = 'INFO'
|
||||
filters.keyword = ''
|
||||
filters.dateRange = null
|
||||
pagination.page = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
pagination.page = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
// ---------- 工具函数 ----------
|
||||
function levelTagType(level: string): '' | 'success' | 'warning' | 'danger' | 'info' {
|
||||
switch ((level || '').toUpperCase()) {
|
||||
case 'DEBUG':
|
||||
return 'info'
|
||||
case 'INFO':
|
||||
return 'success'
|
||||
case 'WARNING':
|
||||
return 'warning'
|
||||
case 'ERROR':
|
||||
case 'CRITICAL':
|
||||
return 'danger'
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return iso
|
||||
return d.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 生命周期 ----------
|
||||
onMounted(() => loadLogs())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.runtime-logs {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
margin-bottom: 16px;
|
||||
border: none;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.detail-block {
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-secondary, #f5f7fa);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.detail-message {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: 'Menlo', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -106,7 +106,7 @@
|
||||
<h4>基本信息</h4>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="员工姓名">{{ conversationDetail.employee_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="员工ID">{{ conversationDetail.employee_id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="员工账号">{{ conversationDetail.employee_id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="部门">{{ conversationDetail.department || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="岗位">{{ conversationDetail.position || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
|
||||
@@ -1,8 +1,47 @@
|
||||
<template>
|
||||
<!-- ================================================================== -->
|
||||
<!-- 系统日志页面 — 配置变更历史记录 -->
|
||||
<!-- 配置变更历史页面(原"系统日志") — 配置变更记录 + 多维筛选 -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="system-logs">
|
||||
<!-- 筛选栏 -->
|
||||
<el-card class="filter-bar" shadow="never">
|
||||
<el-form :inline="true" @submit.prevent>
|
||||
<el-form-item label="配置键">
|
||||
<el-input
|
||||
v-model="filters.configKey"
|
||||
placeholder="如 emergency_mode"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
@keyup.enter="handleFilter"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="操作人">
|
||||
<el-input
|
||||
v-model="filters.changedBy"
|
||||
placeholder="agent_id"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@keyup.enter="handleFilter"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="时间范围">
|
||||
<el-date-picker
|
||||
v-model="filters.dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
:shortcuts="dateShortcuts"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleFilter">查询</el-button>
|
||||
<el-button @click="resetFilter">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 日志列表 -->
|
||||
<el-table :data="logs" v-loading="loading" stripe style="width: 100%">
|
||||
<el-table-column prop="changed_at" label="时间" width="170">
|
||||
@@ -45,7 +84,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
// =============================================================================
|
||||
// 系统日志页面逻辑
|
||||
// 配置变更历史页面逻辑
|
||||
// =============================================================================
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { getSystemLogs } from '@/api/admin'
|
||||
@@ -55,32 +94,87 @@ import { ElMessage } from 'element-plus'
|
||||
const loading = ref(false)
|
||||
const logs = ref<any[]>([])
|
||||
|
||||
const filters = reactive({
|
||||
configKey: '',
|
||||
changedBy: '',
|
||||
dateRange: null as null | [string, string],
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
// 时间快捷选项
|
||||
const dateShortcuts = [
|
||||
{
|
||||
text: '最近24小时',
|
||||
value: () => {
|
||||
const end = new Date()
|
||||
const start = new Date()
|
||||
start.setTime(start.getTime() - 3600 * 1000 * 24)
|
||||
return [start, end]
|
||||
},
|
||||
},
|
||||
{
|
||||
text: '最近7天',
|
||||
value: () => {
|
||||
const end = new Date()
|
||||
const start = new Date()
|
||||
start.setTime(start.getTime() - 3600 * 1000 * 24 * 7)
|
||||
return [start, end]
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// ---------- 构建请求参数 ----------
|
||||
function buildParams(): Record<string, any> {
|
||||
const params: Record<string, any> = {
|
||||
page: pagination.page,
|
||||
page_size: pagination.pageSize,
|
||||
}
|
||||
// 空筛选等价于原行为:仅分页
|
||||
if (filters.configKey) params.config_key = filters.configKey
|
||||
if (filters.changedBy) params.changed_by = filters.changedBy
|
||||
if (filters.dateRange && filters.dateRange.length === 2) {
|
||||
// 闭区间:结束日期补全到当天 23:59:59,确保整日命中
|
||||
params.from = `${filters.dateRange[0]}T00:00:00`
|
||||
params.to = `${filters.dateRange[1]}T23:59:59`
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// ---------- 数据加载 ----------
|
||||
async function loadLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await getSystemLogs({
|
||||
page: pagination.page,
|
||||
page_size: pagination.pageSize,
|
||||
})
|
||||
const { data } = await getSystemLogs(buildParams())
|
||||
if (data.code === 0) {
|
||||
logs.value = data.data.items || []
|
||||
pagination.total = data.data.total || 0
|
||||
}
|
||||
} catch (err: any) {
|
||||
ElMessage.error('加载系统日志失败')
|
||||
ElMessage.error('加载配置变更历史失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 事件处理 ----------
|
||||
function handleFilter() {
|
||||
pagination.page = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
function resetFilter() {
|
||||
filters.configKey = ''
|
||||
filters.changedBy = ''
|
||||
filters.dateRange = null
|
||||
pagination.page = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
pagination.page = 1
|
||||
loadLogs()
|
||||
@@ -90,6 +184,7 @@ function handleSizeChange() {
|
||||
function formatTime(iso: string): string {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return iso
|
||||
return d.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
@@ -109,6 +204,12 @@ onMounted(() => loadLogs())
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
margin-bottom: 16px;
|
||||
border: none;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -21,10 +21,10 @@
|
||||
<div class="login-form">
|
||||
<van-field
|
||||
v-model="employeeId"
|
||||
label="员工ID"
|
||||
placeholder="请输入企微员工 UserID"
|
||||
label="员工账号"
|
||||
placeholder="请输入企微员工账号(UserID)"
|
||||
clearable
|
||||
:rules="[{ required: true, message: '请输入员工ID' }]"
|
||||
:rules="[{ required: true, message: '请输入员工账号' }]"
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
|
||||
@@ -156,7 +156,7 @@ function handleResetPassword(): void {
|
||||
async function handleLogin(): Promise<void> {
|
||||
const id = employeeId.value.trim()
|
||||
if (!id) {
|
||||
showToast('请输入员工ID')
|
||||
showToast('请输入员工账号')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user