chore: sync workspace changes

This commit is contained in:
chengkai3
2026-04-29 23:10:25 +08:00
parent 4f491bb01c
commit c8ba704e40
13 changed files with 1218 additions and 144 deletions
+174 -8
View File
@@ -54,8 +54,10 @@ jobs:
file: ./api/Dockerfile
push: true
build-args: |
PYTHON_BASE_IMAGE=${{ vars.PYTHON_BASE_IMAGE || 'docker.m.daocloud.io/library/python:3.11-slim' }}
PIP_INDEX_URL=${{ secrets.PIP_INDEX_URL || vars.PIP_INDEX_URL || 'https://pypi.org/simple' }}
PIP_DEFAULT_TIMEOUT=${{ vars.PIP_DEFAULT_TIMEOUT || '120' }}
PIP_DEFAULT_TIMEOUT=${{ vars.PIP_DEFAULT_TIMEOUT || '300' }}
PIP_RETRIES=${{ vars.PIP_RETRIES || '20' }}
tags: |
${{ steps.vars.outputs.api_image }}:${{ steps.vars.outputs.image_tag }}
${{ steps.vars.outputs.api_image }}:latest
@@ -69,7 +71,8 @@ jobs:
file: ./web/Dockerfile
push: true
build-args: |
NEXT_PUBLIC_API_BASE_URL=${{ vars.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:8000' }}
NODE_BASE_IMAGE=${{ vars.NODE_BASE_IMAGE || 'docker.m.daocloud.io/library/node:22-alpine' }}
NEXT_PUBLIC_API_BASE_URL=${{ vars.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000' }}
tags: |
${{ steps.vars.outputs.web_image }}:${{ steps.vars.outputs.image_tag }}
${{ steps.vars.outputs.web_image }}:latest
@@ -103,7 +106,7 @@ jobs:
API_IMAGE: ${{ needs.build-and-push.outputs.api_image }}
WEB_IMAGE: ${{ needs.build-and-push.outputs.web_image }}
IMAGE_TAG: ${{ needs.build-and-push.outputs.image_tag }}
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:8000' }}
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000' }}
GHCR_USERNAME: ${{ github.actor }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -145,20 +148,88 @@ jobs:
start_period: 10s
restart: unless-stopped
redis:
image: ${REDIS_IMAGE:-docker.m.daocloud.io/library/redis:7-alpine}
container_name: fquiz-redis
command: redis-server --appendonly yes
ports:
- "${REDIS_PORT:-6379}:6379"
volumes:
- fquiz_redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
start_period: 5s
restart: unless-stopped
minio:
image: ${MINIO_IMAGE:-minio/minio:latest}
container_name: fquiz-minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin}
ports:
- "${MINIO_API_PORT:-9000}:9000"
- "${MINIO_CONSOLE_PORT:-9001}:9001"
volumes:
- fquiz_minio_data:/data
restart: unless-stopped
minio-init:
image: ${MINIO_MC_IMAGE:-minio/mc:latest}
container_name: fquiz-minio-init
depends_on:
minio:
condition: service_started
environment:
MINIO_ENDPOINT: ${MINIO_ENDPOINT:-http://minio:9000}
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin}
MINIO_BUCKET: ${MINIO_BUCKET:-fquiz-files}
entrypoint: /bin/sh
command:
- -c
- >
until mc alias set local "$MINIO_ENDPOINT" "$MINIO_ACCESS_KEY" "$MINIO_SECRET_KEY"; do
sleep 1;
done;
mc mb -p "local/$MINIO_BUCKET" || true;
restart: "no"
api:
image: ${API_IMAGE}
container_name: fquiz-api
depends_on:
db:
condition: service_healthy
minio:
condition: service_started
minio-init:
condition: service_completed_successfully
environment:
API_HOST: ${API_HOST:-0.0.0.0}
API_PORT: ${API_PORT:-8000}
API_CORS_ORIGINS: ${API_CORS_ORIGINS:-http://localhost:3000,http://127.0.0.1:3000}
API_CORS_ORIGIN_REGEX: ${API_CORS_ORIGIN_REGEX:-}
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://fquiz:fquiz@db:5432/fquiz}
DATABASE_URL: ${DATABASE_URL:-}
DB_HOST: ${DB_HOST:-db}
DB_PORT: ${DB_PORT:-5432}
DB_NAME: ${DB_NAME:-postgres}
DB_SCHEMA: ${DB_SCHEMA:-public}
DB_USERNAME: ${DB_USERNAME:-fquiz}
DB_PASSWORD: ${DB_PASSWORD:-fquiz}
FILE_VFS_ROOT: ${FILE_VFS_ROOT:-./data/vfs}
MINIO_ENABLED: ${MINIO_ENABLED:-true}
MINIO_ENDPOINT: ${MINIO_ENDPOINT:-http://minio:9000}
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin}
MINIO_BUCKET: ${MINIO_BUCKET:-fquiz-files}
MINIO_REGION: ${MINIO_REGION:-us-east-1}
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-this-in-production}
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-15}
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-480}
REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-30}
REFRESH_COOKIE_SECURE: ${REFRESH_COOKIE_SECURE:-false}
REFRESH_COOKIE_SAMESITE: ${REFRESH_COOKIE_SAMESITE:-lax}
@@ -166,6 +237,14 @@ jobs:
LLM_REQUEST_TIMEOUT_SECONDS: ${LLM_REQUEST_TIMEOUT_SECONDS:-60}
CHAT_CONTEXT_MESSAGE_LIMIT: ${CHAT_CONTEXT_MESSAGE_LIMIT:-12}
CHAT_DEFAULT_SYSTEM_PROMPT: ${CHAT_DEFAULT_SYSTEM_PROMPT:-You are a helpful assistant.}
CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://redis:6379/0}
CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/1}
CELERY_TIMEZONE: ${CELERY_TIMEZONE:-Asia/Shanghai}
SCHEDULER_EXPIRE_INTERVAL_SECONDS: ${SCHEDULER_EXPIRE_INTERVAL_SECONDS:-60}
WINE_BINARY_PATH: ${WINE_BINARY_PATH:-wine}
WINE_ALLOWED_ROOT: ${WINE_ALLOWED_ROOT:-./data/wine}
WINE_DEFAULT_TIMEOUT_SECONDS: ${WINE_DEFAULT_TIMEOUT_SECONDS:-300}
WINE_MAX_TIMEOUT_SECONDS: ${WINE_MAX_TIMEOUT_SECONDS:-1800}
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-admin@example.com}
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin}
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-change-me-strong-password}
@@ -179,6 +258,64 @@ jobs:
start_period: 10s
restart: unless-stopped
celery-worker:
image: ${API_IMAGE}
container_name: fquiz-celery-worker
command:
- celery
- -A
- app.core.celery_app.celery_app
- worker
- --loglevel=${CELERY_LOG_LEVEL:-INFO}
- --concurrency=${CELERY_WORKER_CONCURRENCY:-2}
depends_on:
api:
condition: service_healthy
redis:
condition: service_healthy
environment:
DATABASE_URL: ${DATABASE_URL:-}
DB_HOST: ${DB_HOST:-db}
DB_PORT: ${DB_PORT:-5432}
DB_NAME: ${DB_NAME:-postgres}
DB_SCHEMA: ${DB_SCHEMA:-public}
DB_USERNAME: ${DB_USERNAME:-fquiz}
DB_PASSWORD: ${DB_PASSWORD:-fquiz}
CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://redis:6379/0}
CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/1}
CELERY_TIMEZONE: ${CELERY_TIMEZONE:-Asia/Shanghai}
SCHEDULER_EXPIRE_INTERVAL_SECONDS: ${SCHEDULER_EXPIRE_INTERVAL_SECONDS:-60}
restart: unless-stopped
celery-beat:
image: ${API_IMAGE}
container_name: fquiz-celery-beat
command:
- celery
- -A
- app.core.celery_app.celery_app
- beat
- --loglevel=${CELERY_LOG_LEVEL:-INFO}
- --schedule=/tmp/celerybeat-schedule
depends_on:
api:
condition: service_healthy
redis:
condition: service_healthy
environment:
DATABASE_URL: ${DATABASE_URL:-}
DB_HOST: ${DB_HOST:-db}
DB_PORT: ${DB_PORT:-5432}
DB_NAME: ${DB_NAME:-postgres}
DB_SCHEMA: ${DB_SCHEMA:-public}
DB_USERNAME: ${DB_USERNAME:-fquiz}
DB_PASSWORD: ${DB_PASSWORD:-fquiz}
CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://redis:6379/0}
CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/1}
CELERY_TIMEZONE: ${CELERY_TIMEZONE:-Asia/Shanghai}
SCHEDULER_EXPIRE_INTERVAL_SECONDS: ${SCHEDULER_EXPIRE_INTERVAL_SECONDS:-60}
restart: unless-stopped
web:
image: ${WEB_IMAGE}
container_name: fquiz-web
@@ -194,18 +331,33 @@ jobs:
volumes:
fquiz_db_data:
fquiz_redis_data:
fquiz_minio_data:
YAML
if [ ! -f .env ]; then
cat > .env <<'ENV'
NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8000
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
API_HOST=0.0.0.0
API_PORT=8000
API_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
API_CORS_ORIGIN_REGEX=
DATABASE_URL=postgresql+psycopg://fquiz:fquiz@db:5432/fquiz
DATABASE_URL=
DB_HOST=db
DB_PORT=5432
DB_NAME=postgres
DB_SCHEMA=public
DB_USERNAME=fquiz
DB_PASSWORD=fquiz
FILE_VFS_ROOT=./data/vfs
MINIO_ENABLED=true
MINIO_ENDPOINT=http://minio:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=fquiz-files
MINIO_REGION=us-east-1
JWT_SECRET_KEY=change-this-in-production
ACCESS_TOKEN_EXPIRE_MINUTES=15
ACCESS_TOKEN_EXPIRE_MINUTES=480
REFRESH_TOKEN_EXPIRE_DAYS=30
REFRESH_COOKIE_SECURE=false
REFRESH_COOKIE_SAMESITE=lax
@@ -213,6 +365,14 @@ jobs:
LLM_REQUEST_TIMEOUT_SECONDS=60
CHAT_CONTEXT_MESSAGE_LIMIT=12
CHAT_DEFAULT_SYSTEM_PROMPT=You are a helpful assistant.
CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/1
CELERY_TIMEZONE=Asia/Shanghai
SCHEDULER_EXPIRE_INTERVAL_SECONDS=60
WINE_BINARY_PATH=wine
WINE_ALLOWED_ROOT=./data/wine
WINE_DEFAULT_TIMEOUT_SECONDS=300
WINE_MAX_TIMEOUT_SECONDS=1800
INITIAL_ADMIN_EMAIL=admin@example.com
INITIAL_ADMIN_USERNAME=admin
INITIAL_ADMIN_PASSWORD=change-me-strong-password
@@ -221,6 +381,12 @@ jobs:
POSTGRES_PASSWORD=fquiz
POSTGRES_PORT=5433
POSTGRES_IMAGE=docker.m.daocloud.io/pgvector/pgvector:pg16
REDIS_IMAGE=docker.m.daocloud.io/library/redis:7-alpine
REDIS_PORT=6379
MINIO_IMAGE=minio/minio:latest
MINIO_MC_IMAGE=minio/mc:latest
MINIO_API_PORT=9000
MINIO_CONSOLE_PORT=9001
ENV
echo "[warn] .env 不存在,已写入默认模板,请尽快改成生产配置。"
fi
+17
View File
@@ -50,6 +50,7 @@
- 宿主机 DB 暴露端口统一走 `POSTGRES_PORT`(默认 `5433`),用于规避与宿主机已有 PostgreSQL(常见 `5432`)冲突;容器内连接仍保持 `db:5432`
- CORS 来源控制采用“双轨配置”:`API_CORS_ORIGINS`(精确列表)+ `API_CORS_ORIGIN_REGEX`(正则,可选);`API_CORS_ORIGINS` 支持 `*` 和通配符域名并在后端转换为 `allow_origin_regex`
- GitHub Actions 使用 `appleboy/ssh-action` 部署时,慢网环境需显式设置 `command_timeout`(建议 `45m`)并为 `docker compose pull` 增加重试,避免出现 `Run Command Timeout` 直接中断发布。
- GitHub Actions 的部署编排需与仓库 `docker-compose.yml` 服务拓扑保持一致,至少包含 `db/api/web/redis/minio/minio-init/celery-worker/celery-beat`;避免在 workflow 内维护“精简版 compose”导致运行时能力缺失(如 Celery/MinIO)。
- `docker compose up -d` 不会重建 `build` 类型服务镜像;本项目 `web` 无源码挂载且运行 Next.js 生产构建产物,前端代码变更后需执行 `docker compose up --build -d web`(必要时先 `docker compose build --no-cache web`)。
- `api` 构建若在拉取 `docker.m.daocloud.io/library/python:3.11-slim` 时出现 manifest `EOF`,优先重试 `docker compose build api`;若持续失败,可在 `.env` 覆盖 `PYTHON_BASE_IMAGE=python:3.11-slim` 走 Docker Hub 兜底。
@@ -214,6 +215,8 @@
- 后台表格行内“操作”入口推荐统一为下拉菜单形态,优先复用 `web/src/components/row-action-menu.tsx`,避免页面内重复堆叠小按钮并降低操作列宽度波动。
- Phase B 样板页已落地:`/admin/users``/admin/requirements``/admin/menus`;后续页面迁移默认保持“业务逻辑不动,仅替换操作入口承载组件”的最小改动策略。
- 后台左侧导航默认不展示“系统菜单”标题与底部“当前角色/账号状态”文案,避免重复信息占用导航空间(移动端抽屉同样不显示该标题)。
- 后台左侧导航收缩按钮固定在侧栏左下角(桌面端),不再放在顶部 Header;移动端仍保留顶部“打开菜单”按钮。
- 后台菜单项图标渲染口径:优先使用后端返回 `menu.icon` 映射,其次按 `menu.path` 回退,最终使用目录/叶子通用图标兜底,确保菜单无空图标。
## 数据库连接口径(2026-04-23
@@ -249,6 +252,7 @@
- 新增页面如需 AntD 高级能力,可直接引入 `antd`,但需保持与现有主题和交互风格一致。
- 兼容说明:`web/src/types/antd.d.ts` 仅保留 `antd/dist/reset.css` 声明,禁止再写 `declare module "antd"`;否则会覆盖官方类型并导致 `Form.useForm<T>` 等泛型调用在 `next build` 的 TypeScript 阶段失败。
- `web/src/components/ui-antd.tsx` 作为兼容层时,若自定义 `type/variant/size/checked` 等语义,必须先 `Omit` 掉对应 AntD 原生同名字段再重定义,否则会触发联合类型冲突并阻断 Docker 构建。
- `web/src/app/admin/page.tsx`(后台首页)已采用 AntD 标准化信息布局:统计卡片 + `Segmented` 分组筛选 + `Card.Meta` 模块卡片;权限判断与路由不变,筛选默认值固定 `all` 并在权限变化后自动回落。
## 需求管理兼容口径(2026-04-22
@@ -859,6 +863,19 @@
- 后台壳层 `web/src/app/admin/layout.tsx` 不再渲染内容区顶部公共信息块(Breadcrumb + 页面标题 + 页面描述)。
- 后台页面默认直接进入业务内容区,避免在每个页面重复展示“模块标题 + 描述文案”。
## WebSocket STOMP 口径(2026-04-26
- 实时推送链路已升级为“双协议并存”:
- 旧协议:`/api/v1/ws`JSON 消息)。
- 新协议:`/api/v1/ws/stomp`STOMP over WebSocket)。
- 鉴权口径不变:两条链路均通过 `POST /api/v1/ws/ticket` 获取一次性 ticket,连接时携带 `?ticket=...`
- 前端默认连接已切换到 STOMP
- `web/src/components/ws-provider.tsx` 走 STOMP 握手与 `SUBSCRIBE/UNSUBSCRIBE`
- 业务侧 `useTopicSubscription` API 不变。
- 后端推送入口口径不变:
- 继续通过 `api/app/services/push_service.py` -> `ws_connection_manager.publish*` 广播事件;
- 管理器内部按连接协议分别编码为 JSON 或 STOMP `MESSAGE` 帧。
## ATP 模型管理口径(2026-04-26
- ATP 功能一期定位为“ATPDraw 产物版本管理 + ATP 引擎调用”:
+202 -9
View File
@@ -1,11 +1,18 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...core.dependencies import CurrentUser, get_current_user
from ...services.legacy_authz_service import get_user_authorization, is_user_enabled
from ...services.stomp_protocol import (
STOMP_SUBPROTOCOLS,
build_stomp_frame,
destination_to_topic,
parse_stomp_frames,
select_stomp_version,
)
from ...services.topic_registry import get_auto_topics, validate_topic_subscription
from ...services.user_service import get_user_by_id
from ...services.ws_manager import ws_connection_manager
@@ -23,27 +30,36 @@ def create_ws_ticket(
return WsTicketResponse(ticket=ticket, expires_in=expires_in)
@router.websocket("")
async def websocket_endpoint(websocket: WebSocket, db: Session = Depends(get_db)) -> None:
async def _authenticate_websocket(
websocket: WebSocket,
db: Session,
) -> tuple[str, set[str], set[str]] | None:
ticket = websocket.query_params.get("ticket")
user_id = ws_ticket_service.consume(ticket)
if not user_id:
await websocket.close(code=4401, reason="invalid_ws_ticket")
return
return None
user = get_user_by_id(db, user_id)
if not user or not is_user_enabled(user.status):
await websocket.close(code=4403, reason="user_not_allowed")
return
return None
authz = get_user_authorization(db, user.id)
role_codes = authz.role_codes
permission_codes = authz.permission_codes
return user.id, authz.role_codes, authz.permission_codes
@router.websocket("")
async def websocket_endpoint(websocket: WebSocket, db: Session = Depends(get_db)) -> None:
auth_result = await _authenticate_websocket(websocket, db)
if not auth_result:
return
user_id, role_codes, permission_codes = auth_result
await websocket.accept()
connection = await ws_connection_manager.register(
websocket,
user_id=user.id,
user_id=user_id,
role_codes=role_codes,
permission_codes=permission_codes,
)
@@ -52,7 +68,7 @@ async def websocket_endpoint(websocket: WebSocket, db: Session = Depends(get_db)
{
"type": "ready",
"connection_id": connection.connection_id,
"user_id": user.id,
"user_id": user_id,
"auto_topics": sorted(get_auto_topics()),
}
)
@@ -107,3 +123,180 @@ async def websocket_endpoint(websocket: WebSocket, db: Session = Depends(get_db)
pass
finally:
await ws_connection_manager.unregister(connection.connection_id)
async def _accept_stomp_socket(websocket: WebSocket) -> None:
offered = websocket.headers.get("sec-websocket-protocol", "")
offered_set = {item.strip() for item in offered.split(",") if item.strip()}
for protocol in STOMP_SUBPROTOCOLS:
if protocol in offered_set:
await websocket.accept(subprotocol=protocol)
return
await websocket.accept()
async def _send_stomp_error(websocket: WebSocket, message: str, *, code: str | None = None) -> None:
body = code if code else message
await websocket.send_text(
build_stomp_frame(
"ERROR",
headers={
"message": message,
"content-type": "text/plain",
},
body=body,
)
)
async def _send_stomp_receipt_if_requested(websocket: WebSocket, frame_headers: dict[str, str]) -> None:
receipt_id = frame_headers.get("receipt")
if not receipt_id:
return
await websocket.send_text(build_stomp_frame("RECEIPT", headers={"receipt-id": receipt_id}))
@router.websocket("/stomp")
async def websocket_stomp_endpoint(websocket: WebSocket, db: Session = Depends(get_db)) -> None:
auth_result = await _authenticate_websocket(websocket, db)
if not auth_result:
return
user_id, role_codes, permission_codes = auth_result
await _accept_stomp_socket(websocket)
connection = None
connected = False
try:
while True:
raw_payload = await websocket.receive_text()
try:
frames = parse_stomp_frames(raw_payload)
except ValueError as exc:
await _send_stomp_error(websocket, "Invalid STOMP frame", code=str(exc))
continue
for frame in frames:
if not connected:
if frame.command not in {"CONNECT", "STOMP"}:
await _send_stomp_error(
websocket,
"First STOMP frame must be CONNECT",
code="connect_required",
)
await websocket.close(code=1002, reason="connect_required")
return
version = select_stomp_version(frame.headers.get("accept-version"))
if not version:
await _send_stomp_error(
websocket,
"Unsupported STOMP version",
code="unsupported_version",
)
await websocket.close(code=1002, reason="unsupported_version")
return
connection = await ws_connection_manager.register(
websocket,
user_id=user_id,
role_codes=role_codes,
permission_codes=permission_codes,
protocol="stomp",
)
connected = True
await websocket.send_text(
build_stomp_frame(
"CONNECTED",
headers={
"version": version,
"session": connection.connection_id,
"server": "fquiz-stomp/1.0",
"heart-beat": "0,0",
},
)
)
await _send_stomp_receipt_if_requested(websocket, frame.headers)
continue
if frame.command == "SUBSCRIBE":
destination = frame.headers.get("destination", "")
subscription_id = frame.headers.get("id", "").strip()
topic = destination_to_topic(destination)
if not topic or not subscription_id:
await _send_stomp_error(
websocket,
"SUBSCRIBE requires destination and id",
code="invalid_subscribe",
)
continue
is_allowed, reason = validate_topic_subscription(
topic,
role_codes=connection.role_codes,
permission_codes=connection.permission_codes,
)
if not is_allowed:
await _send_stomp_error(
websocket,
f"Subscription forbidden: {topic}",
code=reason or "forbidden",
)
continue
await ws_connection_manager.subscribe(
connection.connection_id,
[topic],
subscription_ids={topic: subscription_id},
)
await _send_stomp_receipt_if_requested(websocket, frame.headers)
continue
if frame.command == "UNSUBSCRIBE":
subscription_id = frame.headers.get("id", "").strip()
destination = frame.headers.get("destination", "")
if subscription_id:
await ws_connection_manager.unsubscribe_by_subscription_id(
connection.connection_id,
subscription_id,
)
else:
topic = destination_to_topic(destination)
if topic:
await ws_connection_manager.unsubscribe(connection.connection_id, [topic])
await _send_stomp_receipt_if_requested(websocket, frame.headers)
continue
if frame.command == "DISCONNECT":
await _send_stomp_receipt_if_requested(websocket, frame.headers)
await websocket.close(code=1000, reason="client_disconnect")
return
if frame.command == "SEND":
destination = frame.headers.get("destination", "")
if destination not in {"", "/app/ping"}:
await _send_stomp_error(
websocket,
f"SEND destination not supported: {destination}",
code="unsupported_destination",
)
continue
await _send_stomp_receipt_if_requested(websocket, frame.headers)
continue
if frame.command in {"ACK", "NACK", "BEGIN", "COMMIT", "ABORT"}:
await _send_stomp_receipt_if_requested(websocket, frame.headers)
continue
await _send_stomp_error(
websocket,
f"Unsupported STOMP command: {frame.command}",
code="unsupported_command",
)
except WebSocketDisconnect:
pass
finally:
if connection:
await ws_connection_manager.unregister(connection.connection_id)
+136
View File
@@ -0,0 +1,136 @@
from __future__ import annotations
from dataclasses import dataclass
SUPPORTED_STOMP_VERSIONS = ("1.2", "1.1", "1.0")
STOMP_SUBPROTOCOLS = ("v12.stomp", "v11.stomp", "v10.stomp")
TOPIC_DESTINATION_PREFIX = "/topic/"
@dataclass(frozen=True)
class StompFrame:
command: str
headers: dict[str, str]
body: str = ""
def select_stomp_version(accept_version_header: str | None) -> str | None:
if not accept_version_header:
return "1.0"
requested = {version.strip() for version in accept_version_header.split(",") if version.strip()}
for version in SUPPORTED_STOMP_VERSIONS:
if version in requested:
return version
return None
def topic_to_destination(topic: str) -> str:
return f"{TOPIC_DESTINATION_PREFIX}{topic}"
def destination_to_topic(destination: str) -> str | None:
if destination.startswith(TOPIC_DESTINATION_PREFIX):
topic = destination[len(TOPIC_DESTINATION_PREFIX):].strip()
return topic if topic else None
return None
def build_stomp_frame(command: str, *, headers: dict[str, str] | None = None, body: str = "") -> str:
lines = [command]
for key, value in (headers or {}).items():
lines.append(f"{_escape_header(key)}:{_escape_header(value)}")
lines.append("")
return "\n".join(lines) + body + "\x00"
def parse_stomp_frames(payload: str) -> list[StompFrame]:
frames: list[StompFrame] = []
cursor = 0
data = payload.replace("\r\n", "\n")
while cursor < len(data):
while cursor < len(data) and data[cursor] == "\n":
cursor += 1
if cursor >= len(data):
break
terminator = data.find("\x00", cursor)
if terminator < 0:
raise ValueError("frame_terminator_missing")
raw_frame = data[cursor:terminator]
cursor = terminator + 1
if not raw_frame:
continue
frames.append(_parse_single_frame(raw_frame))
return frames
def _parse_single_frame(raw_frame: str) -> StompFrame:
header_blob, has_body, body = raw_frame.partition("\n\n")
header_lines = [line.rstrip("\r") for line in header_blob.split("\n")]
if not header_lines:
raise ValueError("missing_command")
command = header_lines[0].strip().upper()
if not command:
raise ValueError("missing_command")
headers: dict[str, str] = {}
for line in header_lines[1:]:
if not line:
continue
if ":" not in line:
raise ValueError("malformed_header")
key, value = line.split(":", 1)
headers[_unescape_header(key)] = _unescape_header(value)
if not has_body:
body = ""
if "content-length" in headers:
try:
size = int(headers["content-length"])
except ValueError as exc:
raise ValueError("invalid_content_length") from exc
if size < 0:
raise ValueError("invalid_content_length")
body = body[:size]
return StompFrame(command=command, headers=headers, body=body)
def _escape_header(value: str) -> str:
return (
value.replace("\\", "\\\\")
.replace("\r", "\\r")
.replace("\n", "\\n")
.replace(":", "\\c")
)
def _unescape_header(value: str) -> str:
result = ""
cursor = 0
while cursor < len(value):
char = value[cursor]
if char != "\\":
result += char
cursor += 1
continue
cursor += 1
if cursor >= len(value):
result += "\\"
break
escaped = value[cursor]
cursor += 1
if escaped == "r":
result += "\r"
elif escaped == "n":
result += "\n"
elif escaped == "c":
result += ":"
else:
result += escaped
return result
+106 -19
View File
@@ -1,12 +1,15 @@
from __future__ import annotations
import asyncio
import json
from dataclasses import dataclass, field
from typing import Literal
from uuid import uuid4
from fastapi import WebSocket
from ..schemas.ws import WsEventEnvelope
from .stomp_protocol import build_stomp_frame, topic_to_destination
from .topic_registry import get_auto_topics, validate_topic_subscription
@@ -18,6 +21,9 @@ class WsConnection:
role_codes: set[str]
permission_codes: set[str]
subscribed_topics: set[str] = field(default_factory=set)
protocol: Literal["json", "stomp"] = "json"
stomp_topic_subscriptions: dict[str, set[str]] = field(default_factory=dict)
stomp_subscription_topics: dict[str, str] = field(default_factory=dict)
class WsConnectionManager:
@@ -33,6 +39,7 @@ class WsConnectionManager:
user_id: str,
role_codes: set[str],
permission_codes: set[str],
protocol: Literal["json", "stomp"] = "json",
) -> WsConnection:
connection = WsConnection(
websocket=websocket,
@@ -41,6 +48,7 @@ class WsConnectionManager:
role_codes=set(role_codes),
permission_codes=set(permission_codes),
subscribed_topics=set(get_auto_topics()),
protocol=protocol,
)
async with self._lock:
self._connections[connection.connection_id] = connection
@@ -52,18 +60,37 @@ class WsConnectionManager:
async with self._lock:
self._remove_connection_locked(connection_id)
async def subscribe(self, connection_id: str, topics: list[str]) -> list[str]:
async def subscribe(
self,
connection_id: str,
topics: list[str],
*,
subscription_ids: dict[str, str] | None = None,
) -> list[str]:
accepted: list[str] = []
async with self._lock:
connection = self._connections.get(connection_id)
if not connection:
return accepted
for topic in topics:
if topic in connection.subscribed_topics:
continue
if topic not in connection.subscribed_topics:
connection.subscribed_topics.add(topic)
self._topic_connections.setdefault(topic, set()).add(connection_id)
accepted.append(topic)
if connection.protocol != "stomp" or not subscription_ids:
continue
subscription_id = subscription_ids.get(topic)
if not subscription_id:
continue
previous_topic = connection.stomp_subscription_topics.get(subscription_id)
if previous_topic and previous_topic != topic:
previous_ids = connection.stomp_topic_subscriptions.get(previous_topic)
if previous_ids:
previous_ids.discard(subscription_id)
if not previous_ids:
connection.stomp_topic_subscriptions.pop(previous_topic, None)
connection.stomp_subscription_topics[subscription_id] = topic
connection.stomp_topic_subscriptions.setdefault(topic, set()).add(subscription_id)
return accepted
async def unsubscribe(self, connection_id: str, topics: list[str]) -> list[str]:
@@ -75,15 +102,37 @@ class WsConnectionManager:
for topic in topics:
if topic not in connection.subscribed_topics or topic in get_auto_topics():
continue
connection.subscribed_topics.discard(topic)
subscribers = self._topic_connections.get(topic)
if subscribers:
subscribers.discard(connection_id)
if not subscribers:
self._topic_connections.pop(topic, None)
self._remove_topic_subscription_locked(connection, topic)
removed.append(topic)
return removed
async def unsubscribe_by_subscription_id(
self,
connection_id: str,
subscription_id: str,
) -> str | None:
async with self._lock:
connection = self._connections.get(connection_id)
if not connection:
return None
topic = connection.stomp_subscription_topics.pop(subscription_id, None)
if not topic:
return None
topic_subscriptions = connection.stomp_topic_subscriptions.get(topic)
if topic_subscriptions:
topic_subscriptions.discard(subscription_id)
if not topic_subscriptions:
connection.stomp_topic_subscriptions.pop(topic, None)
if topic in get_auto_topics():
return topic
if topic in connection.subscribed_topics and not connection.stomp_topic_subscriptions.get(topic):
self._remove_topic_subscription_locked(connection, topic)
return topic
async def refresh_user_authorization(
self,
user_id: str,
@@ -113,12 +162,7 @@ class WsConnectionManager:
)
if is_allowed:
continue
connection.subscribed_topics.discard(topic)
subscribers = self._topic_connections.get(topic)
if subscribers:
subscribers.discard(connection.connection_id)
if not subscribers:
self._topic_connections.pop(topic, None)
self._remove_topic_subscription_locked(connection, topic)
removed_topics.append(topic)
if removed_topics:
@@ -126,6 +170,8 @@ class WsConnectionManager:
stale_ids: list[str] = []
for connection, removed_topics in notifications:
if connection.protocol != "json":
continue
try:
await connection.websocket.send_json(
{
@@ -174,11 +220,11 @@ class WsConnectionManager:
if not connections:
return
payload = {"type": "event", "event": event.model_dump(mode="json")}
event_payload = event.model_dump(mode="json")
stale_ids: list[str] = []
for connection in connections:
try:
await connection.websocket.send_json(payload)
await self._send_event_to_connection(connection, event, event_payload)
except Exception:
stale_ids.append(connection.connection_id)
for connection_id in stale_ids:
@@ -193,16 +239,57 @@ class WsConnectionManager:
]
if not connections:
return
payload = {"type": "event", "event": event.model_dump(mode="json")}
event_payload = event.model_dump(mode="json")
stale_ids: list[str] = []
for connection in connections:
try:
await connection.websocket.send_json(payload)
await self._send_event_to_connection(connection, event, event_payload)
except Exception:
stale_ids.append(connection.connection_id)
for connection_id in stale_ids:
await self.unregister(connection_id)
async def _send_event_to_connection(
self,
connection: WsConnection,
event: WsEventEnvelope,
event_payload: dict,
) -> None:
if connection.protocol == "stomp":
body = json.dumps(event_payload, separators=(",", ":"), ensure_ascii=False)
destination = topic_to_destination(event.topic)
subscription_ids = sorted(connection.stomp_topic_subscriptions.get(event.topic, set()))
if not subscription_ids:
subscription_ids = [event.topic]
for subscription_id in subscription_ids:
await connection.websocket.send_text(
build_stomp_frame(
"MESSAGE",
headers={
"subscription": subscription_id,
"destination": destination,
"message-id": event.id,
"content-type": "application/json",
},
body=body,
)
)
return
await connection.websocket.send_json({"type": "event", "event": event_payload})
def _remove_topic_subscription_locked(self, connection: WsConnection, topic: str) -> None:
connection.subscribed_topics.discard(topic)
subscribers = self._topic_connections.get(topic)
if subscribers:
subscribers.discard(connection.connection_id)
if not subscribers:
self._topic_connections.pop(topic, None)
subscription_ids = connection.stomp_topic_subscriptions.pop(topic, set())
for subscription_id in subscription_ids:
connection.stomp_subscription_topics.pop(subscription_id, None)
def _remove_connection_locked(self, connection_id: str) -> None:
connection = self._connections.pop(connection_id, None)
if not connection:
+31
View File
@@ -530,3 +530,34 @@
- 风险与影响:
- 当前 `link` 字段为单向关联(`bidirectional=false`);需求表未自动新增反向聚合列。如需在需求表中直接看 issue 明细,可后续补一个双向/反向展示字段。
## Work Log - 接入 WebSocket STOMP 机制(2026-04-26
- 背景:
- 用户要求在当前 `fquiz` 系统落地 Socket STOMP 机制。
- 本次改动(最小闭环):
- 后端新增 STOMP 协议支持:
- 新增 `api/app/services/stomp_protocol.py`,提供 STOMP 帧编解码、版本协商与 topic/destination 映射。
- `api/app/api/v1/ws.py` 新增 `GET /api/v1/ws/stomp` WebSocket 端点(ticket 鉴权保持不变),支持:
- `CONNECT/STOMP`
- `SUBSCRIBE/UNSUBSCRIBE`
- `DISCONNECT`
- `SEND /app/ping`(收据语义)
- 保留原有 `/api/v1/ws` JSON 协议端点,确保兼容。
- 推送管理器双协议化:
- `api/app/services/ws_manager.py` 扩展为 JSON/STOMP 双协议连接管理与事件分发。
- 新增 STOMP 订阅 id 到 topic 的映射处理,保证 `MESSAGE.subscription` 可回填对应订阅。
- 现有 `publish_topic/publish_to_user` 调用链不变。
- 前端切换到 STOMP
- `web/src/components/ws-provider.tsx` 改为通过 `/api/v1/ws/stomp` 连接并发送 STOMP 帧。
- 保持 `useWS` / `useTopicSubscription` 现有 API 不变,业务页面无须改动。
- 新增 `web/src/lib/stomp.ts` 作为前端 STOMP 帧编解码工具。
- 验证:
- `python3 -m py_compile api/app/services/stomp_protocol.py api/app/services/ws_manager.py api/app/api/v1/ws.py` -> 通过。
- `npm --workspace web exec tsc --noEmit --pretty false` -> 通过。
- 风险与影响:
- 目前后端 STOMP 路径已支持核心命令(CONNECT/SUBSCRIBE/UNSUBSCRIBE/DISCONNECT);事务相关命令仅做兼容性接收,不做事务语义实现。
- 权限变化后,STOMP 连接上的非法 topic 会被后台移除但不再下发 JSON `unsubscribed` 提示;前端依赖重连后重新协商订阅。
+78
View File
@@ -0,0 +1,78 @@
## Work Log - 后台首页按 Ant Design 规范优化(2026-04-27
- 背景:
- 用户要求“按照 ant design 规范优化页面”。
- 本次按最小闭环默认命中后台首页 `/admin`,不改接口、权限与路由,仅优化页面组织与组件用法。
- 本次改动:
- `web/src/app/admin/page.tsx`
- 新增模块分组筛选:使用 `Segmented` 展示“全部 + 各业务分类”并联动卡片列表。
- 新增筛选结果统计:在顶部统计区增加“筛选结果”卡片。
- 卡片内容结构标准化:模块卡片改为 `Card.Meta + Typography.Paragraph(ellipsis) + Tag`,信息层级更符合 AntD 卡片模式。
- 加入分类色标映射(`CATEGORY_COLORS`),保持类别可读性。
- 保留仓库既有 `Card` 类型兼容方案(`AntCard` 强转),并扩展 `Meta` 子组件,兼容 React 19 + 当前 AntD 类型约束。
- 权限逻辑不变:仍由 `visible(hasPermission)` 决定模块可见性。
- AntD CLI 校验(按技能要求):
- 变更前 API 查询(JSON):
- `info Card/Grid/Statistic/Typography/Tag/Avatar/Empty/Space/Segmented/Badge/Flex --format json`
- 变更后规范检查:
- `npx @ant-design/cli lint web/src/app/admin/page.tsx --format json` -> 无问题(`total=0`)。
- 验证:
- `npm --workspace web exec tsc --noEmit --pretty false` -> 通过。
- 风险与影响:
- 影响范围仅前端后台首页展示层(`/admin`)。
- 未改动后端接口、RBAC、菜单数据与路由注册,功能行为保持不变。
## Work Log - 左侧菜单收缩按钮下沉到左下角并补齐菜单图标(2026-04-27)
- 背景:
- 用户要求“左侧菜单收缩按钮放到左下角,给菜单都配上图标”。
- 本次改动:
- `web/src/app/admin/layout.tsx`
- 桌面端收缩按钮从顶部 `Header` 移除,改为侧栏底部按钮(左下角)。
- 侧栏菜单构建函数新增图标注入:优先使用后端 `menu.icon`,其次按 `menu.path` 回退,最后按“目录/叶子”使用默认图标兜底,确保每个菜单都有图标。
- 增加默认菜单图标映射,覆盖当前 seed 中 `LayoutDashboard/Users/ShieldCheck/MenuSquare/Settings2/Network/Zap/Map/RadarChart/Experiment/FolderTree/FileText/Terminal`
- `web/src/app/globals.css`
- 侧栏改为“菜单区域滚动 + 底部按钮固定”布局,保证按钮稳定停靠在左下角且不挤占菜单可视区。
- AntD CLI 校验(按技能要求):
- 变更前 API 查询(JSON):
- `info Layout/Menu/Button --format json`
- 变更后规范检查:
- `npx @ant-design/cli lint web/src/app/admin/layout.tsx --format json` -> 无问题(`total=0`)。
- 验证:
- `npm --workspace web exec tsc --noEmit --pretty false` -> 通过。
- 风险与影响:
- 仅影响后台壳层导航展示与交互,不改动路由、权限与接口数据结构。
- 若后端返回了未收录的新 `menu.icon` 字符串,会走路径或通用兜底图标,不会出现“无图标”空位。
## Work Log - 打包更新镜像并重启(2026-04-27)
- 背景:
- 用户要求“打包更新镜像并重启”。
- 本次执行:
- `docker compose build`
- `docker compose up -d`
- 验证:
- `docker compose ps`
- `api/web/celery-worker/celery-beat/db/redis/minio` 均为 `Up`,其中 `api/db/redis``healthy`
- `curl -fsS http://127.0.0.1:8000/health`
- 返回 `{"status":"ok","service":"fquiz-api","version":"0.1.0"}`
- `curl -I -fsS http://127.0.0.1:3000/`
- 返回 `HTTP/1.1 200 OK`
- `docker compose logs --tail=80 api/web/celery-worker/celery-beat`
- API 启动完成且 `/health` 返回 200。
- Web Ready。
- Celery worker/beat 启动并连接 Redis。
- 风险与影响:
- 发布过程中重建并重启了 `api/web/celery-worker/celery-beat`,存在短时服务切换窗口。
- Celery worker 存在既有 root 运行告警(非本次新增),当前服务可用。
+32
View File
@@ -0,0 +1,32 @@
## Work Log - GitHub Actions workflow 与 Docker 拓扑对齐(2026-04-29
- 背景:
- 用户反馈现有 GitHub workflow 与仓库实际 Docker 构建/部署配置不匹配,要求改造 workflow。
- 本次改动:
- 文件:`.github/workflows/main.yml`
- 构建阶段对齐:
- API 镜像构建参数补齐 `PYTHON_BASE_IMAGE``PIP_RETRIES`,并将 `PIP_DEFAULT_TIMEOUT` 默认值改为 `300`
- Web 镜像构建参数补齐 `NODE_BASE_IMAGE`,并统一 `NEXT_PUBLIC_API_BASE_URL` 默认值为 `http://localhost:8000`
- 部署阶段对齐:
- workflow 内生成的 `docker-compose.prod.yml``db/api/web` 扩展为与仓库主 compose 一致的核心拓扑:
- `db`
- `redis`
- `minio`
- `minio-init`
- `api`
- `celery-worker`
- `celery-beat`
- `web`
- 补齐 `api` 运行所需的 `DB_* / MINIO_* / CELERY_* / WINE_*` 环境变量口径。
-`ACCESS_TOKEN_EXPIRE_MINUTES` 默认值从 `15` 校正为 `480`,与仓库基线一致。
- `.env` 模板补齐 Redis/MinIO/Celery/Wine 相关配置默认值。
- 验证:
- 通过 `git diff -- .github/workflows/main.yml` 逐项核对 workflow 变更。
- 通过 `nl -ba .github/workflows/main.yml` 抽查关键片段(构建参数、compose 服务拓扑、.env 模板)确认已对齐。
- 本次未执行远端部署或 GitHub Actions 实跑。
- 风险与影响:
- 影响范围限定在 CI/CD workflow,不涉及应用业务代码。
- 部署拓扑扩展后,目标服务器需具备额外镜像拉取与运行资源(Redis/MinIO/Celery)。
+78 -9
View File
@@ -4,8 +4,18 @@ import Link from "next/link";
import { useCallback, useEffect, useMemo, useState, type ComponentType, type ReactNode, type SVGProps } from "react";
import { usePathname } from "next/navigation";
import Icon, {
AppstoreOutlined,
BgColorsOutlined,
CodeOutlined,
CompressOutlined,
DashboardOutlined,
DeploymentUnitOutlined,
ExperimentOutlined,
FileOutlined,
FileSearchOutlined,
FolderOpenOutlined,
FolderOutlined,
GlobalOutlined,
HomeOutlined,
LinkOutlined,
LogoutOutlined,
@@ -13,10 +23,15 @@ import Icon, {
MenuOutlined,
MenuUnfoldOutlined,
MoonOutlined,
RadarChartOutlined,
SafetyCertificateOutlined,
SettingOutlined,
ShopOutlined,
SmileOutlined,
SunOutlined,
SyncOutlined,
TeamOutlined,
ThunderboltOutlined,
UserOutlined,
} from "@ant-design/icons";
import {
@@ -92,14 +107,66 @@ function isActivePath(pathname: string, menuPath: string | null): boolean {
type AntdMenuItems = NonNullable<MenuProps["items"]>;
const MENU_ICON_BY_NAME: Record<string, ReactNode> = {
LayoutDashboard: <DashboardOutlined />,
Users: <TeamOutlined />,
ShieldCheck: <SafetyCertificateOutlined />,
MenuSquare: <AppstoreOutlined />,
Settings2: <SettingOutlined />,
Network: <DeploymentUnitOutlined />,
Zap: <ThunderboltOutlined />,
Map: <GlobalOutlined />,
RadarChart: <RadarChartOutlined />,
Experiment: <ExperimentOutlined />,
FolderTree: <FolderOpenOutlined />,
FileText: <FileSearchOutlined />,
Terminal: <CodeOutlined />,
};
const MENU_ICON_BY_PATH: Record<string, ReactNode> = {
"/dashboard": <DashboardOutlined />,
"/users": <TeamOutlined />,
"/roles": <SafetyCertificateOutlined />,
"/menus": <AppstoreOutlined />,
"/system-params": <SettingOutlined />,
"/power-lines": <DeploymentUnitOutlined />,
"/power-lines/atp-viewer": <ExperimentOutlined />,
"/lightning-currents": <ThunderboltOutlined />,
"/lightning-distribution": <GlobalOutlined />,
"/task-monitor": <RadarChartOutlined />,
"/files": <FolderOpenOutlined />,
"/syslog": <FileSearchOutlined />,
"/wine-runner": <CodeOutlined />,
};
function resolveMenuIcon(iconName: string | null, menuPath: string | null, hasChildren: boolean): ReactNode {
if (iconName) {
const namedIcon = MENU_ICON_BY_NAME[iconName.trim()];
if (namedIcon) {
return namedIcon;
}
}
if (menuPath) {
const pathIcon = MENU_ICON_BY_PATH[menuPath];
if (pathIcon) {
return pathIcon;
}
}
return hasChildren ? <FolderOutlined /> : <FileOutlined />;
}
function buildMenuItems(items: MenuTreeItem[]): AntdMenuItems {
return items.map((item) => {
const children = buildMenuItems(item.children);
const label = item.path ? <Link href={item.path}>{item.name}</Link> : item.name;
const icon = resolveMenuIcon(item.icon, item.path, children.length > 0);
if (children.length > 0) {
return {
key: item.id,
icon,
label,
children,
};
@@ -107,6 +174,7 @@ function buildMenuItems(items: MenuTreeItem[]): AntdMenuItems {
return {
key: item.id,
icon,
label,
disabled: !item.path,
};
@@ -420,14 +488,6 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
onClick={() => setMobileMenuOpen(true)}
/>
)}
{isDesktop && (
<Button
aria-label={siderCollapsed ? "展开菜单" : "收起菜单"}
icon={siderCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
type="text"
onClick={() => setSiderCollapsed((previous) => !previous)}
/>
)}
<Link className="admin-design-brand" href="/dashboard">
<span className="admin-design-logo">Q</span>
<Typography.Text strong>fquiz</Typography.Text>
@@ -475,7 +535,16 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
width={256}
>
<div className="admin-design-sider-inner">
{navigationMenu}
<div className="admin-design-sider-menu">{navigationMenu}</div>
<Button
className="admin-design-sider-toggle"
aria-label={siderCollapsed ? "展开菜单" : "收起菜单"}
icon={siderCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
type="text"
onClick={() => setSiderCollapsed((previous) => !previous)}
>
{!siderCollapsed ? "收起菜单" : null}
</Button>
</div>
</Sider>
)}
+96 -24
View File
@@ -14,12 +14,15 @@ import {
SettingOutlined,
TeamOutlined,
} from "@ant-design/icons";
import { Avatar, Card, Col, Empty, Row, Space, Statistic, Tag, Typography, type CardProps } from "antd";
import type { ComponentType, ReactNode } from "react";
import { Avatar, Card, Col, Empty, Row, Segmented, Space, Statistic, Tag, Typography, type CardProps } from "antd";
import type { ComponentType } from "react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useAuth } from "@/components/auth-provider";
const AntCard = Card as unknown as ComponentType<CardProps>;
const AntCard = Card as unknown as ComponentType<CardProps> & {
Meta: typeof Card.Meta;
};
type DashboardCard = {
href: string;
@@ -30,6 +33,15 @@ type DashboardCard = {
visible: (hasPermission: (code: string) => boolean) => boolean;
};
const CATEGORY_COLORS: Record<string, string> = {
: "blue",
: "geekblue",
: "cyan",
: "purple",
: "gold",
: "magenta",
};
const CARDS: DashboardCard[] = [
{
href: "/users",
@@ -133,8 +145,40 @@ const CARDS: DashboardCard[] = [
export default function AdminHomePage() {
const { hasPermission, user } = useAuth();
const visibleCards = CARDS.filter((item) => item.visible(hasPermission));
const categoryCount = new Set(visibleCards.map((item) => item.category)).size;
const [activeCategory, setActiveCategory] = useState<string>("all");
const visibleCards = useMemo(() => CARDS.filter((item) => item.visible(hasPermission)), [hasPermission]);
const categoryStats = useMemo(() => {
const stats = new Map<string, number>();
for (const item of visibleCards) {
stats.set(item.category, (stats.get(item.category) ?? 0) + 1);
}
return stats;
}, [visibleCards]);
useEffect(() => {
if (activeCategory !== "all" && !categoryStats.has(activeCategory)) {
setActiveCategory("all");
}
}, [activeCategory, categoryStats]);
const categoryOptions = useMemo(
() => [
{ label: `全部 (${visibleCards.length})`, value: "all" },
...Array.from(categoryStats.entries()).map(([category, count]) => ({
label: `${category} (${count})`,
value: category,
})),
],
[categoryStats, visibleCards.length],
);
const filteredCards = useMemo(() => {
if (activeCategory === "all") {
return visibleCards;
}
return visibleCards.filter((item) => item.category === activeCategory);
}, [activeCategory, visibleCards]);
if (visibleCards.length === 0) {
return (
@@ -147,49 +191,77 @@ export default function AdminHomePage() {
return (
<Space direction="vertical" size={24} style={{ width: "100%" }}>
<Row gutter={[16, 16]}>
<Col xs={24} md={8}>
<AntCard>
<Col xs={24} sm={12} lg={6}>
<AntCard size="small">
<Statistic title="可访问模块" value={visibleCards.length} suffix="个" />
</AntCard>
</Col>
<Col xs={24} md={8}>
<AntCard>
<Statistic title="业务分组" value={categoryCount} suffix="类" />
<Col xs={24} sm={12} lg={6}>
<AntCard size="small">
<Statistic title="业务分组" value={categoryStats.size} suffix="类" />
</AntCard>
</Col>
<Col xs={24} md={8}>
<AntCard>
<Col xs={24} sm={12} lg={6}>
<AntCard size="small">
<Statistic title="筛选结果" value={filteredCards.length} suffix="个" />
</AntCard>
</Col>
<Col xs={24} sm={12} lg={6}>
<AntCard size="small">
<Statistic title="当前角色" value={user?.role_codes.length ?? 0} suffix="个" />
</AntCard>
</Col>
</Row>
<div>
<Space align="baseline" style={{ marginBottom: 16 }}>
<Space direction="vertical" size={12} style={{ width: "100%", marginBottom: 16 }}>
<Space align="baseline">
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary"> Ant Design </Typography.Text>
<Typography.Text type="secondary"></Typography.Text>
</Space>
<Segmented
block
options={categoryOptions}
value={activeCategory}
onChange={(value) => setActiveCategory(String(value))}
/>
</Space>
<Row gutter={[16, 16]}>
{visibleCards.map((item) => (
{filteredCards.map((item) => (
<Col key={item.href} xs={24} sm={12} xl={8} xxl={6}>
<Link href={item.href} style={{ display: "block", height: "100%" }}>
<AntCard hoverable style={{ height: "100%" }}>
<Space align="start" size={12}>
<AntCard
hoverable
size="small"
style={{ height: "100%" }}
styles={{ body: { height: "100%" } }}
>
<Space direction="vertical" size={10} style={{ width: "100%" }}>
<AntCard.Meta
avatar={
<Avatar
icon={item.icon}
shape="square"
style={{ backgroundColor: "var(--ant-color-primary)" }}
/>
<Space direction="vertical" size={4}>
<Space size={8} wrap>
<Typography.Text strong>{item.title}</Typography.Text>
<Tag color="blue">{item.category}</Tag>
</Space>
<Typography.Text type="secondary">{item.description}</Typography.Text>
</Space>
}
title={item.title}
description={
<Typography.Paragraph
ellipsis={{ rows: 2, tooltip: item.description }}
style={{ marginBottom: 0 }}
type="secondary"
>
{item.description}
</Typography.Paragraph>
}
/>
<Tag color={CATEGORY_COLORS[item.category] ?? "blue"} style={{ width: "fit-content" }}>
{item.category}
</Tag>
</Space>
</AntCard>
</Link>
+14 -2
View File
@@ -109,18 +109,30 @@ body {
position: sticky;
top: 64px;
height: calc(100vh - 64px);
overflow: auto;
overflow: hidden;
border-right: 1px solid var(--ant-color-border-secondary);
}
.admin-design-sider-inner {
display: flex;
min-height: 100%;
min-height: 0;
height: 100%;
flex-direction: column;
gap: 12px;
padding: 16px 12px;
}
.admin-design-sider-menu {
min-height: 0;
flex: 1;
overflow: auto;
}
.admin-design-sider-toggle {
margin-top: auto;
justify-content: flex-start;
}
.admin-design-main {
min-width: 0;
}
+121 -59
View File
@@ -13,7 +13,8 @@ import {
import { useAuth } from "@/components/auth-provider";
import { getApiBaseUrl } from "@/lib/api";
import type { WsEventEnvelope, WsServerMessage, WsTicketResponse } from "@/types/ws";
import { buildStompFrame, parseStompFrames, topicToDestination } from "@/lib/stomp";
import type { WsEventEnvelope, WsTicketResponse } from "@/types/ws";
type TopicHandler = (event: WsEventEnvelope) => void;
@@ -36,6 +37,7 @@ export function WSProvider({ children }: { children: React.ReactNode }) {
const queryClient = useQueryClient();
const { user, fetchWithAuth, logout, refreshAccessToken } = useAuth();
const socketRef = useRef<WebSocket | null>(null);
const stompConnectedRef = useRef(false);
const reconnectTimerRef = useRef<number | null>(null);
const reconnectAttemptRef = useRef(0);
const desiredTopicsRef = useRef<Set<string>>(new Set());
@@ -65,62 +67,41 @@ export function WSProvider({ children }: { children: React.ReactNode }) {
const hasSeenEvent = (eventId: string) => seenEventIdsRef.current.includes(eventId);
const connect = useCallback(async () => {
if (!userIdRef.current) {
const subscriptionIdForTopic = (topic: string) => `topic:${topic}`;
const sendSubscribeFrame = useCallback((topic: string) => {
const socket = socketRef.current;
if (!socket || socket.readyState !== WebSocket.OPEN || !stompConnectedRef.current) {
return;
}
if (socketRef.current) {
if (
socketRef.current.readyState === WebSocket.OPEN
|| socketRef.current.readyState === WebSocket.CONNECTING
) {
socket.send(
buildStompFrame({
command: "SUBSCRIBE",
headers: {
id: subscriptionIdForTopic(topic),
destination: topicToDestination(topic),
},
}),
);
}, []);
const sendUnsubscribeFrame = useCallback((topic: string) => {
const socket = socketRef.current;
if (!socket || socket.readyState !== WebSocket.OPEN || !stompConnectedRef.current) {
return;
}
}
socket.send(
buildStompFrame({
command: "UNSUBSCRIBE",
headers: { id: subscriptionIdForTopic(topic) },
}),
);
}, []);
const ticketRes = await fetchWithAuth("/api/v1/ws/ticket", { method: "POST" });
if (!ticketRes.ok) {
const handleIncomingEvent = useCallback((event: WsEventEnvelope) => {
if (!event || typeof event.id !== "string" || typeof event.topic !== "string") {
return;
}
const ticketPayload = (await ticketRes.json()) as WsTicketResponse;
const socket = new WebSocket(`${toWebSocketUrl("/api/v1/ws")}?ticket=${encodeURIComponent(ticketPayload.ticket)}`);
socketRef.current = socket;
socket.onopen = () => {
setConnected(true);
reconnectAttemptRef.current = 0;
const topics = Array.from(desiredTopicsRef.current);
if (topics.length > 0) {
socket.send(JSON.stringify({ type: "subscribe", topics }));
}
};
socket.onmessage = (message) => {
let parsed: WsServerMessage;
try {
parsed = JSON.parse(message.data) as WsServerMessage;
} catch {
return;
}
if (parsed.type === "ready") {
const topics = Array.from(desiredTopicsRef.current);
if (topics.length > 0) {
socket.send(JSON.stringify({ type: "subscribe", topics }));
}
return;
}
if (parsed.type === "unsubscribed") {
for (const topic of parsed.topics) {
desiredTopicsRef.current.delete(topic);
handlersRef.current.delete(topic);
}
return;
}
if (parsed.type === "event") {
const event = parsed.event;
if (hasSeenEvent(event.id)) {
return;
}
@@ -152,16 +133,98 @@ export function WSProvider({ children }: { children: React.ReactNode }) {
}
const handlers = handlersRef.current.get(event.topic);
if (handlers) {
if (!handlers) {
return;
}
for (const handler of handlers) {
handler(event);
}
}, [logout, queryClient, refreshAccessToken]);
const connect = useCallback(async () => {
if (!userIdRef.current) {
return;
}
if (socketRef.current) {
if (
socketRef.current.readyState === WebSocket.OPEN
|| socketRef.current.readyState === WebSocket.CONNECTING
) {
return;
}
}
const ticketRes = await fetchWithAuth("/api/v1/ws/ticket", { method: "POST" });
if (!ticketRes.ok) {
return;
}
const ticketPayload = (await ticketRes.json()) as WsTicketResponse;
const socket = new WebSocket(
`${toWebSocketUrl("/api/v1/ws/stomp")}?ticket=${encodeURIComponent(ticketPayload.ticket)}`,
["v12.stomp", "v11.stomp", "v10.stomp"],
);
socketRef.current = socket;
stompConnectedRef.current = false;
socket.onopen = () => {
socket.send(
buildStompFrame({
command: "CONNECT",
headers: {
"accept-version": "1.2,1.1,1.0",
"heart-beat": "10000,10000",
},
}),
);
};
socket.onmessage = (message) => {
if (typeof message.data !== "string") {
return;
}
let frames;
try {
frames = parseStompFrames(message.data);
} catch {
return;
}
for (const frame of frames) {
if (frame.command === "CONNECTED") {
stompConnectedRef.current = true;
setConnected(true);
reconnectAttemptRef.current = 0;
for (const topic of desiredTopicsRef.current) {
sendSubscribeFrame(topic);
}
continue;
}
if (frame.command === "MESSAGE") {
if (!frame.body) {
continue;
}
try {
const event = JSON.parse(frame.body) as WsEventEnvelope;
handleIncomingEvent(event);
} catch {
continue;
}
continue;
}
if (frame.command === "ERROR") {
if (frame.body?.includes("user_not_allowed")) {
void logout();
}
}
}
};
socket.onclose = async (event) => {
setConnected(false);
stompConnectedRef.current = false;
if (socketRef.current === socket) {
socketRef.current = null;
}
@@ -180,7 +243,7 @@ export function WSProvider({ children }: { children: React.ReactNode }) {
void connectRef.current?.();
}, delay);
};
}, [clearReconnectTimer, fetchWithAuth, logout, queryClient, refreshAccessToken]);
}, [clearReconnectTimer, fetchWithAuth, handleIncomingEvent, logout, sendSubscribeFrame]);
useEffect(() => {
connectRef.current = connect;
@@ -191,6 +254,7 @@ export function WSProvider({ children }: { children: React.ReactNode }) {
clearReconnectTimer();
socketRef.current?.close();
socketRef.current = null;
stompConnectedRef.current = false;
desiredTopicsRef.current.clear();
handlersRef.current.clear();
if (connected) {
@@ -214,8 +278,8 @@ export function WSProvider({ children }: { children: React.ReactNode }) {
const isNewTopic = !desiredTopicsRef.current.has(topic);
desiredTopicsRef.current.add(topic);
if (isNewTopic && socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(JSON.stringify({ type: "subscribe", topics: [topic] }));
if (isNewTopic) {
sendSubscribeFrame(topic);
}
return () => {
@@ -229,15 +293,13 @@ export function WSProvider({ children }: { children: React.ReactNode }) {
}
handlersRef.current.delete(topic);
desiredTopicsRef.current.delete(topic);
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(JSON.stringify({ type: "unsubscribe", topics: [topic] }));
}
sendUnsubscribeFrame(topic);
};
}, []);
}, [sendSubscribeFrame, sendUnsubscribeFrame]);
const sendPing = useCallback(() => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(JSON.stringify({ type: "ping", ts: Date.now() }));
socketRef.current.send("\n");
}
}, []);
+119
View File
@@ -0,0 +1,119 @@
export type StompFrame = {
command: string;
headers: Record<string, string>;
body?: string;
};
const TOPIC_DESTINATION_PREFIX = "/topic/";
export function topicToDestination(topic: string): string {
return `${TOPIC_DESTINATION_PREFIX}${topic}`;
}
export function buildStompFrame(frame: StompFrame): string {
const lines: string[] = [frame.command];
for (const [key, value] of Object.entries(frame.headers)) {
lines.push(`${escapeHeader(key)}:${escapeHeader(value)}`);
}
lines.push("");
return `${lines.join("\n")}${frame.body ?? ""}\u0000`;
}
export function parseStompFrames(payload: string): StompFrame[] {
const normalized = payload.replace(/\r\n/g, "\n");
const frames: StompFrame[] = [];
let cursor = 0;
while (cursor < normalized.length) {
while (cursor < normalized.length && normalized[cursor] === "\n") {
cursor += 1;
}
if (cursor >= normalized.length) {
break;
}
const terminator = normalized.indexOf("\u0000", cursor);
if (terminator < 0) {
throw new Error("frame_terminator_missing");
}
const raw = normalized.slice(cursor, terminator);
cursor = terminator + 1;
if (!raw) {
continue;
}
frames.push(parseSingleFrame(raw));
}
return frames;
}
function parseSingleFrame(raw: string): StompFrame {
const boundary = raw.indexOf("\n\n");
const headerBlob = boundary >= 0 ? raw.slice(0, boundary) : raw;
let body = boundary >= 0 ? raw.slice(boundary + 2) : "";
const headerLines = headerBlob.split("\n");
const command = headerLines[0]?.trim().toUpperCase();
if (!command) {
throw new Error("missing_command");
}
const headers: Record<string, string> = {};
for (const line of headerLines.slice(1)) {
if (!line) {
continue;
}
const separatorIndex = line.indexOf(":");
if (separatorIndex < 0) {
throw new Error("malformed_header");
}
const key = unescapeHeader(line.slice(0, separatorIndex));
const value = unescapeHeader(line.slice(separatorIndex + 1));
headers[key] = value;
}
if (headers["content-length"]) {
const contentLength = Number.parseInt(headers["content-length"], 10);
if (Number.isNaN(contentLength) || contentLength < 0) {
throw new Error("invalid_content_length");
}
body = body.slice(0, contentLength);
}
return { command, headers, body };
}
function escapeHeader(value: string): string {
return value
.replaceAll("\\", "\\\\")
.replaceAll("\r", "\\r")
.replaceAll("\n", "\\n")
.replaceAll(":", "\\c");
}
function unescapeHeader(value: string): string {
let result = "";
for (let index = 0; index < value.length; index += 1) {
const char = value[index];
if (char !== "\\") {
result += char;
continue;
}
const next = value[index + 1];
if (!next) {
result += "\\";
continue;
}
index += 1;
if (next === "r") {
result += "\r";
} else if (next === "n") {
result += "\n";
} else if (next === "c") {
result += ":";
} else {
result += next;
}
}
return result;
}