From 6d52f24ef32f529b6905e40ffef15f45bc1ba003 Mon Sep 17 00:00:00 2001 From: chengkai3 Date: Sun, 28 Jun 2026 11:01:16 +0800 Subject: [PATCH] =?UTF-8?q?[feat]:[FL-206][=E6=96=B0=E5=A2=9E=E7=BB=B4?= =?UTF-8?q?=E5=BA=A6=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 Co-authored-by: multica-agent --- api/app/api/router.py | 2 + api/app/api/v1/dimensions.py | 103 ++++ api/app/core/database.py | 1 + api/app/models/__init__.py | 3 +- api/app/models/dimension_item.py | 43 ++ api/app/schemas/dimension_item.py | 60 ++ api/app/services/dimension_item_service.py | 225 +++++++ migrations/add_dimension_item.sql | 33 + web/src/app/admin/dimensions/page.tsx | 669 +++++++++++++++++++++ web/src/types/dimension.ts | 23 + 10 files changed, 1161 insertions(+), 1 deletion(-) create mode 100644 api/app/api/v1/dimensions.py create mode 100644 api/app/models/dimension_item.py create mode 100644 api/app/schemas/dimension_item.py create mode 100644 api/app/services/dimension_item_service.py create mode 100644 migrations/add_dimension_item.sql create mode 100644 web/src/app/admin/dimensions/page.tsx create mode 100644 web/src/types/dimension.ts diff --git a/api/app/api/router.py b/api/app/api/router.py index 70b31a3..8a5925e 100644 --- a/api/app/api/router.py +++ b/api/app/api/router.py @@ -5,6 +5,7 @@ from .v1.admin_files import router as admin_files_router from .v1.ai_chat import router as ai_chat_router from .v1.atp_assets import router as atp_assets_router from .v1.auth import router as auth_router +from .v1.dimensions import router as dimensions_router from .v1.documents import router as documents_router from .v1.elevation import router as elevation_router from .v1.fault_recurrence import router as fault_recurrence_router @@ -29,6 +30,7 @@ v1_router.include_router(admin_router) v1_router.include_router(admin_files_router) v1_router.include_router(ai_chat_router) v1_router.include_router(atp_assets_router) +v1_router.include_router(dimensions_router) v1_router.include_router(documents_router) v1_router.include_router(task_monitor_router) v1_router.include_router(scheduled_tasks_router) diff --git a/api/app/api/v1/dimensions.py b/api/app/api/v1/dimensions.py new file mode 100644 index 0000000..17b860f --- /dev/null +++ b/api/app/api/v1/dimensions.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from ...core.database import get_db +from ...core.dependencies import CurrentUser, require_any_permission, require_enabled_menu_route, require_permission +from ...schemas.dimension_item import ( + DimensionItemCreateRequest, + DimensionItemListResponse, + DimensionItemSummary, + DimensionItemTreeNode, + DimensionItemUpdateRequest, +) +from ...services.dimension_item_service import ( + create_dimension_item, + delete_dimension_item, + get_dimension_item_by_id, + get_dimension_tree, + list_dimension_items, + serialize_dimension_item, + update_dimension_item, +) + +router = APIRouter(prefix="/dimensions", tags=["dimensions"], dependencies=[Depends(require_enabled_menu_route)]) + + +@router.get("", response_model=DimensionItemListResponse) +def get_dimension_item_list( + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + keyword: str | None = Query(default=None), + dimension_type: str | None = Query(default=None), + enabled: bool | None = Query(default=None), + _: CurrentUser = Depends(require_any_permission("dimension.read", "dimension.manage")), + db: Session = Depends(get_db), +) -> DimensionItemListResponse: + return list_dimension_items( + db, + limit=limit, + offset=offset, + keyword=keyword, + dimension_type=dimension_type, + enabled=enabled, + ) + + +@router.get("/tree", response_model=list[DimensionItemTreeNode]) +def get_dimension_item_tree( + dimension_type: str | None = Query(default=None), + _: CurrentUser = Depends(require_any_permission("dimension.read", "dimension.manage")), + db: Session = Depends(get_db), +) -> list[DimensionItemTreeNode]: + return get_dimension_tree(db, dimension_type=dimension_type) + + +@router.post("", response_model=DimensionItemSummary) +def create_dimension_item_endpoint( + payload: DimensionItemCreateRequest, + current_user: CurrentUser = Depends(require_permission("dimension.manage")), + db: Session = Depends(get_db), +) -> DimensionItemSummary: + created = create_dimension_item(db, payload, actor=current_user.user) + if not created: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="维度项编码已存在或父节点不存在") + return created + + +@router.patch("/{item_id}", response_model=DimensionItemSummary) +def update_dimension_item_endpoint( + item_id: str, + payload: DimensionItemUpdateRequest, + current_user: CurrentUser = Depends(require_permission("dimension.manage")), + db: Session = Depends(get_db), +) -> DimensionItemSummary: + updated = update_dimension_item(db, item_id, payload, actor=current_user.user) + if not updated: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="维度项不存在或更新失败") + return updated + + +@router.delete("/{item_id}") +def delete_dimension_item_endpoint( + item_id: str, + _: CurrentUser = Depends(require_permission("dimension.manage")), + db: Session = Depends(get_db), +) -> dict[str, bool]: + deleted = delete_dimension_item(db, item_id) + if not deleted: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="维度项不存在或存在子节点") + return {"success": True} + + +@router.get("/{item_id}", response_model=DimensionItemSummary) +def get_dimension_item_detail( + item_id: str, + _: CurrentUser = Depends(require_any_permission("dimension.read", "dimension.manage")), + db: Session = Depends(get_db), +) -> DimensionItemSummary: + item = get_dimension_item_by_id(db, item_id) + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="维度项不存在") + return serialize_dimension_item(item) diff --git a/api/app/core/database.py b/api/app/core/database.py index 48964a6..d7ba6c3 100644 --- a/api/app/core/database.py +++ b/api/app/core/database.py @@ -560,6 +560,7 @@ def init_db() -> None: atp_asset, audit_log, auth_session, + dimension_item, elevation, file_storage, fl_analysis, diff --git a/api/app/models/__init__.py b/api/app/models/__init__.py index 1471483..fb21085 100644 --- a/api/app/models/__init__.py +++ b/api/app/models/__init__.py @@ -4,13 +4,14 @@ Import all model modules during package initialization so SQLAlchemy can resolve string-based relationships regardless of route/service import order. """ -from . import ai_chat, atp_asset, audit_log, auth_session, document, elevation, file_storage, fl_analysis, lightning_event, lightning_sample, line, line_tower, menu, object_group, rbac, scheduled_task, system_message, system_param, tower_model, tower_profile, user, wine, worker_registry +from . import ai_chat, atp_asset, audit_log, auth_session, dimension_item, document, elevation, file_storage, fl_analysis, lightning_event, lightning_sample, line, line_tower, menu, object_group, rbac, scheduled_task, system_message, system_param, tower_model, tower_profile, user, wine, worker_registry __all__ = [ "ai_chat", "atp_asset", "audit_log", "auth_session", + "dimension_item", "document", "elevation", "file_storage", diff --git a/api/app/models/dimension_item.py b/api/app/models/dimension_item.py new file mode 100644 index 0000000..4605778 --- /dev/null +++ b/api/app/models/dimension_item.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import uuid4 + +from sqlalchemy import Boolean, DateTime, Index, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from ..core.database import Base +from .base import utcnow + + +class DimensionItem(Base): + __tablename__ = "dimension_item" + __table_args__ = ( + Index("idx_dimension_item_type", "dimension_type"), + Index("idx_dimension_item_parent", "parent_id"), + Index("idx_dimension_item_code", "code"), + Index("idx_dimension_item_enabled", "is_enabled"), + Index("idx_dimension_item_sort", "sort_order"), + ) + + id: Mapped[str] = mapped_column( + String(32), + primary_key=True, + default=lambda: uuid4().hex, + ) + dimension_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + code: Mapped[str] = mapped_column(String(128), nullable=False, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + parent_id: Mapped[str | None] = mapped_column(String(32), index=True) + description: Mapped[str | None] = mapped_column(Text) + is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True) + sort_order: Mapped[int] = mapped_column(Integer, default=0, index=True) + + create_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True) + create_user: Mapped[str | None] = mapped_column(String(64), index=True) + update_date: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=utcnow, + onupdate=utcnow, + ) + update_user: Mapped[str | None] = mapped_column(String(64), index=True) diff --git a/api/app/schemas/dimension_item.py b/api/app/schemas/dimension_item.py new file mode 100644 index 0000000..6ebfcab --- /dev/null +++ b/api/app/schemas/dimension_item.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class DimensionItemSummary(BaseModel): + id: str + dimension_type: str + code: str + name: str + parent_id: str | None = None + description: str | None = None + is_enabled: bool = True + sort_order: int = 0 + create_date: datetime + create_user: str | None = None + update_date: datetime + update_user: str | None = None + + +class DimensionItemTreeNode(BaseModel): + id: str + dimension_type: str + code: str + name: str + parent_id: str | None = None + description: str | None = None + is_enabled: bool = True + sort_order: int = 0 + create_date: datetime + create_user: str | None = None + update_date: datetime + update_user: str | None = None + children: list[DimensionItemTreeNode] = Field(default_factory=list) + + +class DimensionItemListResponse(BaseModel): + items: list[DimensionItemSummary] + total: int + + +class DimensionItemCreateRequest(BaseModel): + dimension_type: str = Field(min_length=1, max_length=64) + code: str = Field(min_length=1, max_length=128) + name: str = Field(min_length=1, max_length=255) + parent_id: str | None = None + description: str | None = Field(default=None, max_length=2000) + is_enabled: bool = True + sort_order: int = Field(default=0, ge=0, le=1_000_000) + + +class DimensionItemUpdateRequest(BaseModel): + code: str | None = Field(default=None, min_length=1, max_length=128) + name: str | None = Field(default=None, min_length=1, max_length=255) + parent_id: str | None = None + description: str | None = Field(default=None, max_length=2000) + is_enabled: bool | None = None + sort_order: int | None = Field(default=None, ge=0, le=1_000_000) diff --git a/api/app/services/dimension_item_service.py b/api/app/services/dimension_item_service.py new file mode 100644 index 0000000..cce6596 --- /dev/null +++ b/api/app/services/dimension_item_service.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session + +from ..models.base import utcnow +from ..models.dimension_item import DimensionItem +from ..models.user import User +from ..schemas.dimension_item import ( + DimensionItemCreateRequest, + DimensionItemListResponse, + DimensionItemSummary, + DimensionItemTreeNode, + DimensionItemUpdateRequest, +) +from .push_service import publish_topic + +DIMENSION_ITEM_TOPIC = "admin.dimension-items" + + +def serialize_dimension_item(item: DimensionItem) -> DimensionItemSummary: + return DimensionItemSummary( + id=item.id, + dimension_type=item.dimension_type, + code=item.code, + name=item.name, + parent_id=item.parent_id, + description=item.description, + is_enabled=item.is_enabled, + sort_order=item.sort_order, + create_date=item.create_date, + create_user=item.create_user, + update_date=item.update_date, + update_user=item.update_user, + ) + + +def list_dimension_items( + db: Session, + *, + limit: int, + offset: int, + keyword: str | None, + dimension_type: str | None, + enabled: bool | None, +) -> DimensionItemListResponse: + stmt = select(DimensionItem) + total_stmt = select(func.count()).select_from(DimensionItem) + + if dimension_type: + stmt = stmt.where(DimensionItem.dimension_type == dimension_type) + total_stmt = total_stmt.where(DimensionItem.dimension_type == dimension_type) + + normalized_keyword = (keyword or "").strip() + if normalized_keyword: + like = f"%{normalized_keyword}%" + predicate = or_( + DimensionItem.code.ilike(like), + DimensionItem.name.ilike(like), + ) + stmt = stmt.where(predicate) + total_stmt = total_stmt.where(predicate) + + if enabled is not None: + stmt = stmt.where(DimensionItem.is_enabled == enabled) + total_stmt = total_stmt.where(DimensionItem.is_enabled == enabled) + + total = int(db.scalar(total_stmt) or 0) + stmt = stmt.order_by(DimensionItem.sort_order, DimensionItem.create_date.desc()).limit(limit).offset(offset) + items = list(db.scalars(stmt).all()) + + return DimensionItemListResponse( + items=[serialize_dimension_item(item) for item in items], + total=total, + ) + + +def get_dimension_tree( + db: Session, + dimension_type: str | None = None, +) -> list[DimensionItemTreeNode]: + stmt = select(DimensionItem).order_by(DimensionItem.sort_order, DimensionItem.create_date) + + if dimension_type: + stmt = stmt.where(DimensionItem.dimension_type == dimension_type) + + items = list(db.scalars(stmt).all()) + + item_map: dict[str, DimensionItemTreeNode] = {} + for item in items: + item_map[item.id] = DimensionItemTreeNode( + id=item.id, + dimension_type=item.dimension_type, + code=item.code, + name=item.name, + parent_id=item.parent_id, + description=item.description, + is_enabled=item.is_enabled, + sort_order=item.sort_order, + create_date=item.create_date, + create_user=item.create_user, + update_date=item.update_date, + update_user=item.update_user, + children=[], + ) + + root_nodes: list[DimensionItemTreeNode] = [] + for node in item_map.values(): + if node.parent_id and node.parent_id in item_map: + item_map[node.parent_id].children.append(node) + else: + root_nodes.append(node) + + return root_nodes + + +def get_dimension_item_by_id(db: Session, item_id: str) -> DimensionItem | None: + return db.scalar(select(DimensionItem).where(DimensionItem.id == item_id)) + + +def create_dimension_item( + db: Session, + payload: DimensionItemCreateRequest, + actor: User, +) -> DimensionItemSummary | None: + existing = db.scalar( + select(DimensionItem).where( + DimensionItem.dimension_type == payload.dimension_type, + DimensionItem.code == payload.code, + ) + ) + if existing: + return None + + if payload.parent_id: + parent = get_dimension_item_by_id(db, payload.parent_id) + if not parent: + return None + + item = DimensionItem( + dimension_type=payload.dimension_type, + code=payload.code, + name=payload.name, + parent_id=payload.parent_id, + description=payload.description, + is_enabled=payload.is_enabled, + sort_order=payload.sort_order, + create_user=actor.id, + update_user=actor.id, + ) + db.add(item) + db.commit() + db.refresh(item) + + publish_topic(DIMENSION_ITEM_TOPIC) + return serialize_dimension_item(item) + + +def update_dimension_item( + db: Session, + item_id: str, + payload: DimensionItemUpdateRequest, + actor: User, +) -> DimensionItemSummary | None: + item = get_dimension_item_by_id(db, item_id) + if not item: + return None + + if payload.code is not None: + existing = db.scalar( + select(DimensionItem).where( + DimensionItem.dimension_type == item.dimension_type, + DimensionItem.code == payload.code, + DimensionItem.id != item_id, + ) + ) + if existing: + return None + item.code = payload.code + + if payload.name is not None: + item.name = payload.name + + if payload.parent_id is not None: + if payload.parent_id: + parent = get_dimension_item_by_id(db, payload.parent_id) + if not parent: + return None + if payload.parent_id == item_id: + return None + item.parent_id = payload.parent_id + + if payload.description is not None: + item.description = payload.description + + if payload.is_enabled is not None: + item.is_enabled = payload.is_enabled + + if payload.sort_order is not None: + item.sort_order = payload.sort_order + + item.update_date = utcnow() + item.update_user = actor.id + + db.commit() + db.refresh(item) + + publish_topic(DIMENSION_ITEM_TOPIC) + return serialize_dimension_item(item) + + +def delete_dimension_item(db: Session, item_id: str) -> bool: + item = get_dimension_item_by_id(db, item_id) + if not item: + return False + + children = db.scalars(select(DimensionItem).where(DimensionItem.parent_id == item_id)).all() + if children: + return False + + db.delete(item) + db.commit() + + publish_topic(DIMENSION_ITEM_TOPIC) + return True diff --git a/migrations/add_dimension_item.sql b/migrations/add_dimension_item.sql new file mode 100644 index 0000000..c2b08a5 --- /dev/null +++ b/migrations/add_dimension_item.sql @@ -0,0 +1,33 @@ +-- Migration: Add dimension_item table for dimension management +-- Date: 2026-06-28 +-- Description: Create dimension_item table to support managing voltage levels, tower types, scenarios, and arrester combinations in a tree structure + +CREATE TABLE IF NOT EXISTS dimension_item ( + id VARCHAR(32) PRIMARY KEY, + dimension_type VARCHAR(64) NOT NULL, + code VARCHAR(128) NOT NULL, + name VARCHAR(255) NOT NULL, + parent_id VARCHAR(32), + description TEXT, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + sort_order INTEGER NOT NULL DEFAULT 0, + create_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'), + create_user VARCHAR(64), + update_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'), + update_user VARCHAR(64) +); + +CREATE INDEX idx_dimension_item_type ON dimension_item(dimension_type); +CREATE INDEX idx_dimension_item_parent ON dimension_item(parent_id); +CREATE INDEX idx_dimension_item_code ON dimension_item(code); +CREATE INDEX idx_dimension_item_enabled ON dimension_item(is_enabled); +CREATE INDEX idx_dimension_item_sort ON dimension_item(sort_order); +CREATE INDEX idx_dimension_item_create_date ON dimension_item(create_date); +CREATE INDEX idx_dimension_item_create_user ON dimension_item(create_user); +CREATE INDEX idx_dimension_item_update_user ON dimension_item(update_user); + +-- Notes: +-- - dimension_type values: 'voltage_level', 'tower_type', 'scenario', 'arrester_combination' +-- - parent_id references dimension_item.id for tree structure (NULL for root nodes) +-- - code should be unique within the same dimension_type +-- - sort_order determines display order (lower values first) diff --git a/web/src/app/admin/dimensions/page.tsx b/web/src/app/admin/dimensions/page.tsx new file mode 100644 index 0000000..0b4c29e --- /dev/null +++ b/web/src/app/admin/dimensions/page.tsx @@ -0,0 +1,669 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Button, + Card, + Dropdown, + Empty, + Form, + Input, + Modal, + Popconfirm, + Select, + Space, + Spin, + Table, + Tag, + Tree, + Typography, + type MenuProps, +} from "antd"; +import { MoreOutlined, EditOutlined, PlusOutlined } from "@ant-design/icons"; +import type { ColumnsType } from "antd/es/table"; +import type { DataNode } from "antd/es/tree"; +import Link from "next/link"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { useAuth } from "@/components/auth-provider"; +import { useToastFeedback } from "@/hooks/use-toast-feedback"; +import { useTopicSubscription } from "@/hooks/use-topic-subscription"; +import { readApiError } from "@/lib/api"; +import type { DimensionItem, DimensionItemListResponse, DimensionItemTreeNode } from "@/types/dimension"; + +type CreateDimensionValues = { + dimension_type: string; + code: string; + name: string; + parent_id?: string; + description?: string; + is_enabled: boolean; + sort_order: number; +}; + +type EditDimensionValues = { + code: string; + name: string; + parent_id?: string; + description?: string; + is_enabled: boolean; + sort_order: number; +}; + +const DIMENSION_TYPES = [ + { value: "voltage_level", label: "电压等级" }, + { value: "tower_type", label: "塔型" }, + { value: "scenario", label: "场景" }, + { value: "arrester_combination", label: "避雷器组合" }, +]; + +function dimensionTypeLabel(type: string): string { + const found = DIMENSION_TYPES.find((t) => t.value === type); + return found ? found.label : type; +} + +function statusLabel(enabled: boolean): string { + return enabled ? "启用" : "禁用"; +} + +const DIMENSIONS_TABLE_MIN_SCROLL_Y = 180; + +export default function AdminDimensionsPage() { + const { user, initializing, fetchWithAuth, hasPermission } = useAuth(); + const queryClient = useQueryClient(); + + const [createForm] = Form.useForm(); + const [editForm] = Form.useForm(); + + const [deletingId, setDeletingId] = useState(null); + const [createModalOpen, setCreateModalOpen] = useState(false); + const [editingItem, setEditingItem] = useState(null); + const [selectedDimensionType, setSelectedDimensionType] = useState(undefined); + const [viewMode, setViewMode] = useState<"table" | "tree">("tree"); + const [pagination, setPagination] = useState({ current: 1, pageSize: 50 }); + const [tableScrollY, setTableScrollY] = useState(DIMENSIONS_TABLE_MIN_SCROLL_Y); + const tableScrollAnchorRef = useRef(null); + + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + + const canManage = hasPermission("dimension.manage"); + const canRead = hasPermission("dimension.read") || canManage; + const { current: paginationCurrent, pageSize: paginationPageSize } = pagination; + + const dimensionsQueryParams = useMemo(() => { + const params = new URLSearchParams(); + params.set("limit", String(paginationPageSize)); + params.set("offset", String((paginationCurrent - 1) * paginationPageSize)); + if (selectedDimensionType) { + params.set("dimension_type", selectedDimensionType); + } + return params.toString(); + }, [paginationCurrent, paginationPageSize, selectedDimensionType]); + + const dimensionsPath = `/api/v1/dimensions?${dimensionsQueryParams}`; + const treeQueryParams = selectedDimensionType ? `?dimension_type=${selectedDimensionType}` : ""; + const treePath = `/api/v1/dimensions/tree${treeQueryParams}`; + + const loadDimensions = useCallback(async () => { + const response = await fetchWithAuth(dimensionsPath); + if (!response.ok) throw new Error(await readApiError(response)); + return (await response.json()) as DimensionItemListResponse; + }, [fetchWithAuth, dimensionsPath]); + + const loadTree = useCallback(async () => { + const response = await fetchWithAuth(treePath); + if (!response.ok) throw new Error(await readApiError(response)); + return (await response.json()) as DimensionItemTreeNode[]; + }, [fetchWithAuth, treePath]); + + const dimensionsQuery = useQuery({ + queryKey: ["admin.dimensions", dimensionsQueryParams], + queryFn: loadDimensions, + enabled: !!user && canRead && viewMode === "table", + }); + + const treeQuery = useQuery({ + queryKey: ["admin.dimensions.tree", treeQueryParams], + queryFn: loadTree, + enabled: !!user && canRead && viewMode === "tree", + }); + + useTopicSubscription( + "admin.dimension-items", + useCallback(() => { + if (!user || !canRead) return; + void queryClient.invalidateQueries({ queryKey: ["admin.dimensions"] }); + void queryClient.invalidateQueries({ queryKey: ["admin.dimensions.tree"] }); + }, [canRead, queryClient, user]), + ); + + const dimensions = useMemo(() => dimensionsQuery.data?.items ?? [], [dimensionsQuery.data?.items]); + const treeData = useMemo(() => treeQuery.data ?? [], [treeQuery.data]); + + const refreshData = async () => { + await queryClient.refetchQueries({ queryKey: ["admin.dimensions"] }); + await queryClient.refetchQueries({ queryKey: ["admin.dimensions.tree"] }); + }; + + const createDimensionMutation = useMutation({ + mutationFn: async (values: CreateDimensionValues) => { + const response = await fetchWithAuth("/api/v1/dimensions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values), + }); + if (!response.ok) throw new Error(await readApiError(response)); + return response.json() as Promise; + }, + onSuccess: async () => { + setSuccess("维度项已创建"); + setError(""); + createForm.resetFields(); + setCreateModalOpen(false); + await refreshData(); + }, + onError: (candidate) => { + setSuccess(""); + setError(candidate instanceof Error ? candidate.message : "创建维度项失败"); + }, + }); + + const updateDimensionMutation = useMutation({ + mutationFn: async ({ itemId, payload }: { itemId: string; payload: EditDimensionValues }) => { + const response = await fetchWithAuth(`/api/v1/dimensions/${itemId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!response.ok) throw new Error(await readApiError(response)); + return response.json() as Promise; + }, + onSuccess: async () => { + setSuccess("维度项已更新"); + setEditingItem(null); + editForm.resetFields(); + await refreshData(); + }, + onError: (candidate) => { + setSuccess(""); + setError(candidate instanceof Error ? candidate.message : "更新维度项失败"); + }, + }); + + const deleteDimensionMutation = useMutation({ + mutationFn: async (itemId: string) => { + const response = await fetchWithAuth(`/api/v1/dimensions/${itemId}`, { method: "DELETE" }); + if (!response.ok) throw new Error(await readApiError(response)); + return response.json() as Promise<{ success: boolean }>; + }, + onMutate: (itemId) => { + setDeletingId(itemId); + setError(""); + setSuccess(""); + }, + onSuccess: async () => { + setSuccess("维度项已删除"); + await refreshData(); + }, + onError: (candidate) => { + setSuccess(""); + setError(candidate instanceof Error ? candidate.message : "删除维度项失败"); + }, + onSettled: () => setDeletingId(null), + }); + + const handleCreateDimension = async (values: CreateDimensionValues) => { + setError(""); + setSuccess(""); + createDimensionMutation.mutate(values); + }; + + const openEditModal = (item: DimensionItem) => { + setError(""); + setSuccess(""); + setEditingItem(item); + editForm.setFieldsValue({ + code: item.code, + name: item.name, + parent_id: item.parent_id || undefined, + description: item.description || undefined, + is_enabled: item.is_enabled, + sort_order: item.sort_order, + }); + }; + + const closeEditModal = () => { + if (updateDimensionMutation.isPending) return; + setEditingItem(null); + editForm.resetFields(); + }; + + const handleSubmitEdit = async (values: EditDimensionValues) => { + if (!editingItem) return; + updateDimensionMutation.mutate({ itemId: editingItem.id, payload: values }); + }; + + const openCreateModal = () => { + setError(""); + setSuccess(""); + createForm.resetFields(); + if (selectedDimensionType) { + createForm.setFieldsValue({ dimension_type: selectedDimensionType }); + } + setCreateModalOpen(true); + }; + + const closeCreateModal = () => { + if (createDimensionMutation.isPending) return; + setCreateModalOpen(false); + createForm.resetFields(); + }; + + const queryError = (dimensionsQuery.error instanceof Error ? dimensionsQuery.error.message : "") || + (treeQuery.error instanceof Error ? treeQuery.error.message : ""); + const anyError = error || queryError; + + useToastFeedback({ + errorMessage: anyError, + successMessage: success, + clearError: () => setError(""), + clearSuccess: () => setSuccess(""), + }); + + const buildTreeData = (nodes: DimensionItemTreeNode[]): DataNode[] => { + return nodes.map((node) => ({ + key: node.id, + title: ( + + {node.name} + ({node.code}) + {statusLabel(node.is_enabled)} + + ), + children: node.children ? buildTreeData(node.children) : [], + })); + }; + + const columns: ColumnsType = [ + { + title: "维度类型", + dataIndex: "dimension_type", + width: 120, + render: (value: string) => dimensionTypeLabel(value), + }, + { + title: "编码", + dataIndex: "code", + width: 140, + }, + { + title: "名称", + dataIndex: "name", + width: 180, + }, + { + title: "父节点ID", + dataIndex: "parent_id", + width: 120, + render: (value: string | null) => value || "-", + }, + { + title: "描述", + dataIndex: "description", + width: 200, + render: (value: string | null) => value || "-", + }, + { + title: "状态", + dataIndex: "is_enabled", + width: 100, + align: "center", + render: (value: boolean) => ( + {statusLabel(value)} + ), + }, + { + title: "排序", + dataIndex: "sort_order", + width: 80, + align: "center", + }, + { + title: "操作", + key: "actions", + width: 160, + render: (_value, row) => { + const deleteLoading = deletingId === row.id; + const rowBusy = deleteLoading; + + const moreMenuItems: MenuProps["items"] = [ + { + key: "toggle-status", + label: row.is_enabled ? "禁用" : "启用", + disabled: rowBusy, + onClick: () => { + updateDimensionMutation.mutate({ + itemId: row.id, + payload: { + code: row.code, + name: row.name, + parent_id: row.parent_id || undefined, + description: row.description || undefined, + is_enabled: !row.is_enabled, + sort_order: row.sort_order, + }, + }); + }, + }, + ]; + + return ( + + + + deleteDimensionMutation.mutate(row.id)} + disabled={rowBusy || !canManage} + > + + + + +