[feat]:[FL-206][新增维度管理功能]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-28 11:01:16 +08:00
parent 31ee65f745
commit 6d52f24ef3
10 changed files with 1161 additions and 1 deletions
+2
View File
@@ -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)
+103
View File
@@ -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)
+1
View File
@@ -560,6 +560,7 @@ def init_db() -> None:
atp_asset,
audit_log,
auth_session,
dimension_item,
elevation,
file_storage,
fl_analysis,
+2 -1
View File
@@ -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",
+43
View File
@@ -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)
+60
View File
@@ -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)
+225
View File
@@ -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
+33
View File
@@ -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)
+669
View File
@@ -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<CreateDimensionValues>();
const [editForm] = Form.useForm<EditDimensionValues>();
const [deletingId, setDeletingId] = useState<string | null>(null);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editingItem, setEditingItem] = useState<DimensionItem | null>(null);
const [selectedDimensionType, setSelectedDimensionType] = useState<string | undefined>(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<HTMLDivElement | null>(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<DimensionItem>;
},
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<DimensionItem>;
},
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: (
<Space>
<Typography.Text strong>{node.name}</Typography.Text>
<Typography.Text type="secondary">({node.code})</Typography.Text>
<Tag color={node.is_enabled ? "green" : "default"}>{statusLabel(node.is_enabled)}</Tag>
</Space>
),
children: node.children ? buildTreeData(node.children) : [],
}));
};
const columns: ColumnsType<DimensionItem> = [
{
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) => (
<Tag color={value ? "green" : "default"}>{statusLabel(value)}</Tag>
),
},
{
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 (
<Space wrap>
<Button
size="small"
disabled={rowBusy || !canManage}
onClick={() => openEditModal(row)}
>
</Button>
<Popconfirm
title={`确认删除维度项 ${row.name}${row.code})?`}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true, loading: deleteLoading }}
onConfirm={() => deleteDimensionMutation.mutate(row.id)}
disabled={rowBusy || !canManage}
>
<Button danger size="small" loading={deleteLoading} disabled={rowBusy || !canManage}>
</Button>
</Popconfirm>
<Dropdown menu={{ items: moreMenuItems }} trigger={["click"]}>
<Button size="small" disabled={rowBusy || !canManage} icon={<MoreOutlined />} />
</Dropdown>
</Space>
);
},
},
];
if (initializing) {
return (
<div className="flex min-h-[240px] items-center justify-center">
<Spin tip="初始化中..." />
</div>
);
}
if (!user) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
<p className="text-sm text-[var(--gray-11)]">访</p>
<Link
href="/"
className="inline-flex w-fit items-center justify-center rounded-md border border-[var(--gray-6)] bg-[var(--gray-a2)] px-4 py-2 text-sm font-medium text-[var(--gray-12)] transition hover:bg-[var(--gray-a3)]"
>
</Link>
</main>
);
}
if (!canRead) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
<p className="text-sm text-[var(--gray-11)]">访 `dimension.read` `dimension.manage`</p>
<Link
href="/"
className="inline-flex w-fit items-center justify-center rounded-md border border-[var(--gray-6)] bg-[var(--gray-a2)] px-4 py-2 text-sm font-medium text-[var(--gray-12)] transition hover:bg-[var(--gray-a3)]"
>
</Link>
</main>
);
}
return (
<div className="flex min-h-0 flex-1 flex-col">
<Card
title="维度管理"
extra={
<Space>
<Select
value={viewMode}
onChange={setViewMode}
options={[
{ value: "tree", label: "树形视图" },
{ value: "table", label: "表格视图" },
]}
style={{ width: 120 }}
/>
{canManage && (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
</Button>
)}
</Space>
}
>
<Form layout="inline" style={{ marginBottom: 16 }}>
<Form.Item label="维度类型">
<Select
value={selectedDimensionType}
allowClear
placeholder="全部"
options={DIMENSION_TYPES}
onChange={(value) => {
setSelectedDimensionType(value);
setPagination((prev) => ({ ...prev, current: 1 }));
}}
style={{ width: 180 }}
/>
</Form.Item>
</Form>
{viewMode === "tree" ? (
<div style={{ minHeight: 300 }}>
{treeQuery.isLoading ? (
<div className="flex min-h-[240px] items-center justify-center">
<Spin tip="加载中..." />
</div>
) : treeData.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="未找到符合筛选条件的维度项。"
/>
) : (
<Tree
treeData={buildTreeData(treeData)}
defaultExpandAll
showLine
/>
)}
</div>
) : (
<div ref={tableScrollAnchorRef}>
<Table<DimensionItem>
rowKey="id"
dataSource={dimensions}
columns={columns}
loading={dimensionsQuery.isLoading}
tableLayout="fixed"
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: Math.max(dimensionsQuery.data?.total ?? 0, 1),
showSizeChanger: true,
pageSizeOptions: [20, 50, 100, 200],
showTotal: () => `${dimensionsQuery.data?.total ?? 0}`,
hideOnSinglePage: false,
style: { marginBottom: 0 },
onChange: (page, pageSize) => {
setPagination({ current: page, pageSize });
},
}}
scroll={{ y: tableScrollY }}
locale={{
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="未找到符合筛选条件的维度项。"
/>
),
}}
/>
</div>
)}
</Card>
<Modal
title="新增维度项"
open={createModalOpen}
destroyOnClose
onCancel={closeCreateModal}
onOk={() => createForm.submit()}
okText="创建"
cancelText="取消"
confirmLoading={createDimensionMutation.isPending}
>
<Form<CreateDimensionValues>
form={createForm}
layout="vertical"
onFinish={handleCreateDimension}
autoComplete="off"
initialValues={{ is_enabled: true, sort_order: 0 }}
>
<Form.Item
label="维度类型"
name="dimension_type"
rules={[{ required: true, message: "请选择维度类型" }]}
>
<Select
placeholder="请选择维度类型"
options={DIMENSION_TYPES}
/>
</Form.Item>
<Form.Item
label="编码"
name="code"
rules={[
{ required: true, message: "请输入编码" },
{ min: 1, message: "编码至少 1 位" },
{ max: 128, message: "编码不能超过 128 位" },
]}
>
<Input placeholder="例如 110kv" />
</Form.Item>
<Form.Item
label="名称"
name="name"
rules={[
{ required: true, message: "请输入名称" },
{ min: 1, message: "名称至少 1 位" },
{ max: 255, message: "名称不能超过 255 位" },
]}
>
<Input placeholder="例如 110千伏" />
</Form.Item>
<Form.Item label="父节点ID" name="parent_id">
<Input placeholder="留空表示顶级节点" />
</Form.Item>
<Form.Item label="描述" name="description">
<Input.TextArea rows={3} placeholder="可选" maxLength={2000} />
</Form.Item>
<Form.Item label="启用状态" name="is_enabled" valuePropName="checked">
<Select
options={[
{ value: true, label: "启用" },
{ value: false, label: "禁用" },
]}
/>
</Form.Item>
<Form.Item
label="排序"
name="sort_order"
rules={[{ required: true, message: "请输入排序值" }]}
>
<Input type="number" placeholder="0" />
</Form.Item>
</Form>
</Modal>
<Modal
title={editingItem ? `编辑维度项:${editingItem.name}${editingItem.code}` : "编辑维度项"}
open={!!editingItem}
destroyOnClose
onCancel={closeEditModal}
onOk={() => editForm.submit()}
okText="保存"
cancelText="取消"
confirmLoading={updateDimensionMutation.isPending}
>
<Form<EditDimensionValues>
form={editForm}
layout="vertical"
onFinish={handleSubmitEdit}
autoComplete="off"
>
<Form.Item
label="编码"
name="code"
rules={[
{ required: true, message: "请输入编码" },
{ min: 1, message: "编码至少 1 位" },
{ max: 128, message: "编码不能超过 128 位" },
]}
>
<Input placeholder="例如 110kv" />
</Form.Item>
<Form.Item
label="名称"
name="name"
rules={[
{ required: true, message: "请输入名称" },
{ min: 1, message: "名称至少 1 位" },
{ max: 255, message: "名称不能超过 255 位" },
]}
>
<Input placeholder="例如 110千伏" />
</Form.Item>
<Form.Item label="父节点ID" name="parent_id">
<Input placeholder="留空表示顶级节点" />
</Form.Item>
<Form.Item label="描述" name="description">
<Input.TextArea rows={3} placeholder="可选" maxLength={2000} />
</Form.Item>
<Form.Item label="启用状态" name="is_enabled" valuePropName="checked">
<Select
options={[
{ value: true, label: "启用" },
{ value: false, label: "禁用" },
]}
/>
</Form.Item>
<Form.Item
label="排序"
name="sort_order"
rules={[{ required: true, message: "请输入排序值" }]}
>
<Input type="number" placeholder="0" />
</Form.Item>
</Form>
</Modal>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
export type DimensionItem = {
id: string;
dimension_type: string;
code: string;
name: string;
parent_id: string | null;
description: string | null;
is_enabled: boolean;
sort_order: number;
create_date: string;
create_user: string | null;
update_date: string;
update_user: string | null;
};
export type DimensionItemTreeNode = DimensionItem & {
children: DimensionItemTreeNode[];
};
export type DimensionItemListResponse = {
items: DimensionItem[];
total: number;
};