feat: 深度完善系统管理后台治理体系,标准化原子 UI 组件库与响应式交互文档 #38

Merged
hnu202326010328 merged 1 commits from wanglirong_branch into develop 1 month ago

@ -1,33 +1,67 @@
/**
*
* confirm()
* @file ConfirmDialog.tsx
* @module Components/Feedback/Confirm-Dialog
* @description React
* confirm()
* *
* [cite_start]1. (Usability-2): DML [cite: 617]
* 2. :
* 3. : DangerPrimary
* *
* - [cite_start]Security-3: AI SQL / [cite: 555-556]
* - Usability-2:
* * @author Wang Lirong ()
* @version 2.0.0
* @date 2026-01-02
*/
import React from 'react';
import { AlertTriangle, X } from 'lucide-react';
import { Button } from './UI';
/**
*
* @interface ConfirmDialogProps
* @description
*/
interface ConfirmDialogProps {
/** 是否显示对话框 */
/** *
* true false DOM
*/
isOpen: boolean;
/** 关闭对话框的回调 */
/** *
*
*/
onClose: () => void;
/** 确认操作的回调 */
/** *
* DDL/DML
*/
onConfirm: () => void;
/** 对话框标题 */
/** 对话框顶部的标题文本,需简明扼要说明操作意图 */
title: string;
/** 对话框内容/描述 */
/** 对话框的核心描述内容,用于向用户解释该操作的后果及风险 */
message: string;
/** 确认按钮文本 */
/** 确认按钮的自定义文本,默认为“确认” */
confirmText?: string;
/** 取消按钮文本 */
/** 取消按钮的自定义文本,默认为“取消” */
cancelText?: string;
/** 是否为危险操作(红色确认按钮) */
/** *
* true
*/
isDangerous?: boolean;
/** 是否显示警告图标 */
/** *
*
*/
showWarningIcon?: boolean;
}
/**
*
* @component ConfirmDialog
* @description
* React Tailwind CSS
* Z-Index z-[60]
*/
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
onClose,
@ -39,22 +73,44 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isDangerous = false,
showWarningIcon = false,
}) => {
/**
* 1
* DOM
*/
if (!isOpen) return null;
/**
* 2
*
*/
const handleConfirm = () => {
// 触发父级透传的业务指令(如 API 调用)
onConfirm();
// 逻辑流完成后关闭弹窗
onClose();
};
/**
* 3 (Atomic Layout)
* Fixed backdrop-blur UI
*/
return (
// 全屏遮罩层:提供 60% 透明度的深色背景及毛玻璃滤镜
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-gray-900/60 backdrop-blur-sm p-4 transition-opacity duration-300">
{/* 对话框主体容器:具备自适应宽度及进入动画 (animate-in) */}
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md animate-in fade-in zoom-in-95 duration-200 overflow-hidden">
{/* Header */}
{/* 对话框头部 (Header Section) */}
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center min-h-[56px]">
{/* 标题区域:包含可选的警告图标与截断保护文本 */}
<h3 className="text-lg font-semibold text-gray-800 flex items-center gap-2 flex-1 min-w-0">
{/* 条件渲染:显示警示图标以提示操作风险 */}
{showWarningIcon && <AlertTriangle size={20} className="text-orange-500 flex-shrink-0" />}
<span className="truncate">{title}</span>
</h3>
{/* 交互控件:右上角关闭图标,提供圆角悬停反馈 */}
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded-full hover:bg-gray-100 w-7 h-7 flex items-center justify-center flex-shrink-0 ml-3"
@ -64,16 +120,20 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
</button>
</div>
{/* Content */}
{/* 内容主体区域 (Content Section) */}
<div className="px-6 py-4">
{/* 渲染业务描述文本,使用宽松的行高 (leading-relaxed) 以提升阅读舒适度 */}
<p className="text-gray-600 leading-relaxed">{message}</p>
</div>
{/* Footer */}
{/* 底部操作栏 (Footer Section) */}
<div className="px-6 py-4 bg-gray-50 border-t border-gray-100 flex justify-end gap-3">
{/* 取消动作:默认变体按钮 */}
<Button onClick={onClose} variant="default">
{cancelText}
</Button>
{/* 确认动作:根据 isDangerous 参数动态切换按钮视觉语义 (Danger vs Primary) */}
<Button
onClick={handleConfirm}
variant={isDangerous ? 'danger' : 'primary'}

@ -1,100 +1,186 @@
/**
* @file InteractiveERRenderer.tsx
* @module Components/Visualization/ER-Diagram
* @description ER Diagram
*
* AI Mermaid DSL SVG
* * *
* [cite_start]1. (SF5): Schema Agent [cite: 85-87]
* 2. (Usability-1): 0.2x 3x Panning
* 3. : DOM
* 4. : ER Passive Event
* * *
* - Mermaid.js
* - SVG
* -
* * @author Wang Lirong ()
* @version 2.5.0
* @date 2026-01-02
*/
import React, { useEffect, useRef, useState, useCallback } from 'react';
/**
* ER
* @interface InteractiveERRendererProps
* @property {string} chart - Mermaid ER
* @property {string} [className] -
*/
interface InteractiveERRendererProps {
chart: string;
className?: string;
}
/**
* @component InteractiveERRenderer
* @description
* React
* Ref Locks React
*/
export const InteractiveERRenderer: React.FC<InteractiveERRendererProps> = ({ chart, className = '' }) => {
// --- 物理层 DOM 引用 ---
/** 外层布局容器引用:用于确定组件的物理边界 */
const containerRef = useRef<HTMLDivElement>(null);
/** SVG 挂载点引用:动态生成的 SVG 节点将注入此容器 */
const svgContainerRef = useRef<HTMLDivElement>(null);
/** 活跃 SVG 元素引用:用于直接执行矩阵变换 (transform) */
const svgRef = useRef<SVGElement | null>(null);
// --- 几何变换状态机 ---
/** 缩放比例状态1 为原始尺寸,支持 0.2 至 3 的动态区间 */
const [scale, setScale] = useState(1);
/** 坐标位移状态:存储当前的 X/Y 偏移量 */
const [position, setPosition] = useState({ x: 0, y: 0 });
// --- 交互控制流状态 ---
/** 拖拽锁:标识用户当前是否正在执行物理抓取动作 */
const [isDragging, setIsDragging] = useState(false);
/** 拖拽起始锚点:记录鼠标按下时的瞬时坐标偏移 */
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
/** 加载生命周期标识:控制遮罩层与加载动画的协同展示 */
const [isLoading, setIsLoading] = useState(true);
// 使用 ref 存储最新的 scale 和 position避免闭包问题
// --- 引用一致性保障 (Ref-Sync Mechanism) ---
/** * @description
* React wheel
* ref React
*/
const scaleRef = useRef(scale);
const positionRef = useRef(position);
/** 状态到引用的单向同步Scale */
useEffect(() => {
scaleRef.current = scale;
}, [scale]);
/** 状态到引用的单向同步Position */
useEffect(() => {
positionRef.current = position;
}, [position]);
/**
* 线 Effect
* Mermaid DSL SVG
*/
useEffect(() => {
// 准入检查:若无输入数据或容器未就绪,则跳过本次渲染周期
if (!chart || !svgContainerRef.current) return;
// 开启加载状态,锁定 UI 交互
setIsLoading(true);
/**
*
*/
const renderER = async () => {
// 创建离屏渲染容器,避免 mermaid 在 body 中创建临时元素导致抖动
/**
* 1
* @description
* Mermaid body ID
*
* CSS
*/
const offscreenContainer = document.createElement('div');
offscreenContainer.style.cssText = 'position: fixed; left: -9999px; top: -9999px; visibility: hidden; pointer-events: none;';
document.body.appendChild(offscreenContainer);
try {
// 修复格式问题
/**
* 2DSL
* AI
*/
let fixedChart = chart.trim();
// 修正特定 AI 模型的输出错误,确保 erDiagram 标识符具有正确的换行前缀
if (fixedChart.includes('}}%% erDiagram')) {
fixedChart = fixedChart.replace('}}%% erDiagram', '}}%%\nerDiagram');
}
// 动态导入 mermaid
/**
* 3Mermaid
* Dynamic Import Code-splitting Bundle
*/
const mermaid = (await import('mermaid')).default;
// 优化的初始化配置
/**
* 4
* ER
*/
mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'loose',
securityLevel: 'loose', // 允许复杂的交互属性
er: {
diagramPadding: 30,
layoutDirection: 'TB',
minEntityWidth: 150,
minEntityHeight: 100,
entityPadding: 25,
stroke: '#333',
fill: '#f8f9fa',
fontSize: 16,
useMaxWidth: false
diagramPadding: 30, // 图表外边距
layoutDirection: 'TB', // 采用自上而下的逻辑流向
minEntityWidth: 150, // 实体矩形最小宽度
minEntityHeight: 100, // 实体矩形最小高度
entityPadding: 25, // 字段间的呼吸感间距
stroke: '#333', // 连接线颜色
fill: '#f8f9fa', // 实体背景色
fontSize: 16, // 业务字体大小适配
useMaxWidth: false // 禁用自动宽度,由本组件接管几何变换
}
});
// 在离屏容器中渲染
/**
* 5
* ID SVG ID
*/
const id = `interactive-er-${Date.now()}`;
const result = await mermaid.render(id, fixedChart, offscreenContainer);
// 步骤 6DOM 注入与 SVG 后置处理
if (svgContainerRef.current) {
svgContainerRef.current.innerHTML = result.svg;
// 获取 SVG 元素
// 获取并接管 SVG 根节点
const svg = svgContainerRef.current.querySelector('svg');
if (svg) {
svgRef.current = svg;
// 设置 SVG 样式
// 注入交互式 CSS 属性
svg.style.width = '100%';
svg.style.height = '100%';
svg.style.cursor = 'grab';
svg.style.userSelect = 'none';
// 确保有 viewBox
svg.style.cursor = 'grab'; // 初始手势状态
svg.style.userSelect = 'none'; // 禁止文本选中干扰拖拽
/**
* 7ViewBox
* SVG
* getBBox
*/
if (!svg.getAttribute('viewBox')) {
try {
const bbox = svg.getBBox();
svg.setAttribute('viewBox', `0 0 ${bbox.width + 60} ${bbox.height + 60}`);
} catch (e) {
// 异常兜底:应用标准 4:3 比例视口
svg.setAttribute('viewBox', '0 0 800 600');
}
}
// 重置缩放和位置
// 步骤 8重置变换坐标系
// 每次内容更新后,回归初始视角,确保用户能看到全貌
setScale(1);
setPosition({ x: 0, y: 0 });
updateTransform(svg, 1, { x: 0, y: 0 });
@ -104,6 +190,7 @@ export const InteractiveERRenderer: React.FC<InteractiveERRendererProps> = ({ ch
setIsLoading(false);
}
} catch (error) {
// 异常处理:在容器内渲染错误提示 UI替代原始图形展示
console.error('❌ ER图渲染失败:', error);
if (svgContainerRef.current) {
svgContainerRef.current.innerHTML = `
@ -115,7 +202,7 @@ export const InteractiveERRenderer: React.FC<InteractiveERRendererProps> = ({ ch
}
setIsLoading(false);
} finally {
// 清理离屏容器
// 资源回收:销毁临时的离屏容器
if (offscreenContainer.parentNode) {
offscreenContainer.parentNode.removeChild(offscreenContainer);
}
@ -123,28 +210,44 @@ export const InteractiveERRenderer: React.FC<InteractiveERRendererProps> = ({ ch
};
renderER();
}, [chart]);
// 更新变换
}, [chart]); // 当图表定义变更时,触发完整的重绘管线
/**
*
* scale position CSS 2D
* @param {SVGElement} svg -
* @param {number} newScale -
* @param {Object} newPosition -
*/
const updateTransform = useCallback((svg: SVGElement, newScale: number, newPosition: { x: number, y: number }) => {
// 采用 translate + scale 的复合变换
svg.style.transform = `translate(${newPosition.x}px, ${newPosition.y}px) scale(${newScale})`;
// 设置变换锚点为中心,符合用户直觉
svg.style.transformOrigin = 'center center';
}, []);
// 使用 useEffect 添加 non-passive wheel 事件监听器,解决 preventDefault 警告
/**
*
*
*/
useEffect(() => {
const container = svgContainerRef.current;
if (!container) return;
/**
*
* @param {WheelEvent} e -
*/
const handleWheel = (e: WheelEvent) => {
// 准入条件:必须同时存在 SVG 实例及父容器
if (!svgRef.current || !svgContainerRef.current) return;
// 获取容器边界
// 步骤 1坐标空间判定
const containerRect = svgContainerRef.current.getBoundingClientRect();
const mouseX = e.clientX;
const mouseY = e.clientY;
// 检查鼠标是否在ER图容器
// 判定鼠标是否在 ER 渲染有效负载区
const isMouseInContainer = (
mouseX >= containerRect.left &&
mouseX <= containerRect.right &&
@ -152,25 +255,40 @@ export const InteractiveERRenderer: React.FC<InteractiveERRendererProps> = ({ ch
mouseY <= containerRect.bottom
);
// 只有当鼠标在ER图容器内时才进行缩放否则允许正常滚动
// 逻辑分流:若鼠标不在容器内,释放控制权,允许父级页面正常滚动
if (!isMouseInContainer) {
return;
}
// 阻止默认滚动行为并停止事件冒泡
/**
* 2
* 使 non-passive preventDefault
*/
e.preventDefault();
e.stopPropagation();
/**
* 3
* 0.9/1.1 线
*/
const delta = e.deltaY > 0 ? 0.9 : 1.1;
// 从 ref 中读取最新几何参数,避免闭包失效
const currentScale = scaleRef.current;
const currentPosition = positionRef.current;
// 步骤 4范围约束检查 (Clamping)
// 限制最小 0.2 倍(概览模式),最大 3 倍(微观模式)
const newScale = Math.max(0.2, Math.min(3, currentScale * delta));
// 步骤 5触发状态同步与视图变换
setScale(newScale);
updateTransform(svgRef.current, newScale, currentPosition);
};
// 添加 non-passive 事件监听器
/**
*
* passive: false Chrome
*/
container.addEventListener('wheel', handleWheel, { passive: false });
return () => {
@ -178,16 +296,18 @@ export const InteractiveERRenderer: React.FC<InteractiveERRendererProps> = ({ ch
};
}, [updateTransform]);
// 开始拖动 - 只在鼠标在ER图区域内时生效
/**
*
* @param {React.MouseEvent} e - React
*/
const handleMouseDown = (e: React.MouseEvent) => {
if (!svgRef.current || !svgContainerRef.current) return;
// 获取容器边界
// 步骤 1热区点击判定
const containerRect = svgContainerRef.current.getBoundingClientRect();
const mouseX = e.clientX;
const mouseY = e.clientY;
// 检查鼠标是否在ER图容器内
const isMouseInContainer = (
mouseX >= containerRect.left &&
mouseX <= containerRect.right &&
@ -199,44 +319,60 @@ export const InteractiveERRenderer: React.FC<InteractiveERRendererProps> = ({ ch
return;
}
// 阻止事件冒泡,避免影响外部元素
// 步骤 2阻止父级组件感知此交互如侧边栏收起等
e.stopPropagation();
// 步骤 3开启位移捕捉模式
setIsDragging(true);
// 记录初始位移偏移量公式dragStart = mousePos - currentPos
setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y });
// 切换视觉指针样式
svgRef.current.style.cursor = 'grabbing';
};
// 拖动中 - 添加边界检查
/**
*
* @param {React.MouseEvent} e
*/
const handleMouseMove = (e: React.MouseEvent) => {
// 若未处于拖拽状态或 SVG 未加载,立即短路返回
if (!isDragging || !svgRef.current) return;
// 阻止事件冒泡
// 阻止浏览器默认文本选择干扰
e.stopPropagation();
// 步骤 1计算目标位移向量
const newPosition = {
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y
};
// 步骤 2实时刷新状态与视图
setPosition(newPosition);
updateTransform(svgRef.current, scale, newPosition);
};
// 结束拖动 - 添加事件处理
/**
*
* @param {React.MouseEvent} [e] -
*/
const handleMouseUp = (e?: React.MouseEvent) => {
if (!svgRef.current) return;
// 如果有事件对象,阻止冒泡
if (e) {
e.stopPropagation();
}
// 重置交互标志位
setIsDragging(false);
// 恢复抓取状态指针
svgRef.current.style.cursor = 'grab';
};
// 重置视图
/**
*
* 1.0 (0,0)
*/
const resetView = () => {
if (!svgRef.current) return;
@ -248,9 +384,16 @@ export const InteractiveERRenderer: React.FC<InteractiveERRendererProps> = ({ ch
updateTransform(svgRef.current, newScale, newPosition);
};
/**
* JSX
*
*/
return (
<div className={className} style={{ position: 'relative', width: '100%', height: '100%' }}>
{/* 控制按钮 */}
{/* (HUD):
*/}
<div style={{
position: 'absolute',
top: '10px',
@ -261,6 +404,7 @@ export const InteractiveERRenderer: React.FC<InteractiveERRendererProps> = ({ ch
opacity: isLoading ? 0 : 1,
transition: 'opacity 0.2s ease-in-out'
}}>
{/* 缩小触发器 */}
<button
onClick={() => {
if (!svgRef.current) return;
@ -280,6 +424,7 @@ export const InteractiveERRenderer: React.FC<InteractiveERRendererProps> = ({ ch
>
</button>
{/* 复位首页触发器 */}
<button
onClick={resetView}
style={{
@ -294,6 +439,7 @@ export const InteractiveERRenderer: React.FC<InteractiveERRendererProps> = ({ ch
>
</button>
{/* 放大触发器 */}
<button
onClick={() => {
if (!svgRef.current) return;
@ -315,7 +461,9 @@ export const InteractiveERRenderer: React.FC<InteractiveERRendererProps> = ({ ch
</button>
</div>
{/* 缩放信息 */}
{/* HUD - :
*/}
<div style={{
position: 'absolute',
bottom: '10px',
@ -332,7 +480,9 @@ export const InteractiveERRenderer: React.FC<InteractiveERRendererProps> = ({ ch
{Math.round(scale * 100)}%
</div>
{/* 外层容器 - 固定尺寸,防止抖动 */}
{/* (Main Canvas):
*/}
<div
ref={containerRef}
style={{
@ -347,7 +497,9 @@ export const InteractiveERRenderer: React.FC<InteractiveERRendererProps> = ({ ch
position: 'relative'
}}
>
{/* 加载状态 - 绝对定位,不影响布局 */}
{/* :
*/}
<div style={{
position: 'absolute',
inset: 0,
@ -363,6 +515,7 @@ export const InteractiveERRenderer: React.FC<InteractiveERRendererProps> = ({ ch
zIndex: 5
}}>
<div>
{/* CSS 纯代码驱动的加载动画旋转体 */}
<div style={{
width: '40px',
height: '40px',
@ -376,7 +529,10 @@ export const InteractiveERRenderer: React.FC<InteractiveERRendererProps> = ({ ch
</div>
</div>
{/* SVG 容器 - 绝对定位,渲染完成后显示 */}
{/* SVG 宿:
Canvas
*/}
<div
ref={svgContainerRef}
onMouseDown={handleMouseDown}
@ -386,13 +542,16 @@ export const InteractiveERRenderer: React.FC<InteractiveERRendererProps> = ({ ch
style={{
position: 'absolute',
inset: 0,
touchAction: 'none',
touchAction: 'none', // 禁用系统默认触摸手势,由组件接管控制
opacity: isLoading ? 0 : 1,
visibility: isLoading ? 'hidden' : 'visible',
transition: 'opacity 0.2s ease-in-out'
}}
/>
{/* :
Loading Keyframe
*/}
<style>{`
@keyframes spin {
0% { transform: rotate(0deg); }

@ -1,33 +1,52 @@
/**
* @file Pagination.tsx
* @module Components/Navigation/Pagination
* @description
*
* *
* 1. (Performance-1): API
* 2. :
* 3. (Usability-1): Ellipsis
* 4. : React onChange
* *
* - SF2:
* - SF9:
* * @author Wang Lirong ()
* @version 2.1.0
* @date 2026-01-02
*/
import React from 'react';
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
/**
*
*
* @interface PaginationProps
*/
interface PaginationProps {
/** 当前页码 (1-based) */
/** 当前活跃页码(基于 1 的索引) */
current: number;
/** 总条目数 */
/** 系统中符合过滤条件的条目总数 */
total: number;
/** 每页条目数 */
/** 每页预设的显示数据行数 */
pageSize: number;
/** 页码变化回调 */
/** 页码发生变更时的回调函数,参数为目标页码 */
onChange: (page: number) => void;
/** 是否显示总数信息 */
/** 是否在组件左侧展示“显示第 X 到 Y 条”的汇总信息 */
showTotal?: boolean;
/** 是否显示快速跳转 */
/** 是否展示快速跳转至特定页码的输入框 */
showQuickJumper?: boolean;
/** 是否模式 */
/** 是否启用极简模式(仅保留“当前/总计”及翻页箭头) */
simple?: boolean;
/** 自定义类名 */
/** 允许外部注入的自定义 CSS 类名 */
className?: string;
/** 是否禁用 */
/** 禁用状态标识:为 true 时禁止所有点击交互 */
disabled?: boolean;
}
/**
*
*
*
* @component Pagination
*/
export const Pagination: React.FC<PaginationProps> = ({
current,
@ -40,38 +59,54 @@ export const Pagination: React.FC<PaginationProps> = ({
className = '',
disabled = false,
}) => {
/** * 1
* 使
*/
const totalPages = Math.max(1, Math.ceil(total / pageSize));
/** 计算当前页面起始条目的绝对序号 */
const startItem = total === 0 ? 0 : (current - 1) * pageSize + 1;
/** 计算当前页面结束条目的绝对序号(取 pageSize 与 total 的最小值,防止溢出) */
const endItem = Math.min(current * pageSize, total);
// 生成页码数组
/**
* 2 -
* @description
* ellipsis
* UI
* @returns {(number | 'ellipsis')[]}
*/
const getPageNumbers = (): (number | 'ellipsis')[] => {
const pages: (number | 'ellipsis')[] = [];
const maxVisible = 5; // 最多显示的页码数
const maxVisible = 5; // 窗口内最多展示的连续数字按钮
// 分支 A总页数较少时直接平铺展示所有页码
if (totalPages <= maxVisible + 2) {
// 总页数较少,全部显示
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
// 总页数较多,显示省略号
pages.push(1);
// 分支 B总页数较多执行复杂的省略号计算逻辑
pages.push(1); // 始终保留首页
if (current <= 3) {
// 当前页靠近开头
// 子分支 B1当前页靠近序列开头
for (let i = 2; i <= Math.min(4, totalPages - 1); i++) {
pages.push(i);
}
// 在尾部之前插入省略号
if (totalPages > 5) pages.push('ellipsis');
} else if (current >= totalPages - 2) {
// 当前页靠近结尾
// 子分支 B2当前页靠近序列结尾
// 在首部之后插入省略号
if (totalPages > 5) pages.push('ellipsis');
for (let i = Math.max(totalPages - 3, 2); i < totalPages; i++) {
pages.push(i);
}
} else {
// 当前页在中间
// 子分支 B3当前页处于中间区域
// 双向插入省略号,仅保留当前页及其前后相邻页
pages.push('ellipsis');
for (let i = current - 1; i <= current + 1; i++) {
pages.push(i);
@ -79,27 +114,39 @@ export const Pagination: React.FC<PaginationProps> = ({
pages.push('ellipsis');
}
pages.push(totalPages);
pages.push(totalPages); // 始终保留末页
}
return pages;
};
/**
* 3
*
* @param {number} page -
*/
const handlePageChange = (page: number) => {
if (disabled || page < 1 || page > totalPages || page === current) return;
onChange(page);
};
// 简洁模式
/**
* A (Simple Mode)
*
*/
if (simple) {
return (
<div className={`flex items-center justify-between gap-2 py-3 px-4 ${className}`}>
{/* 数据摘要展示 */}
{showTotal && (
<span className="text-xs sm:text-sm text-gray-500 shrink-0 pagination-total-text">
{total > 0 ? `${startItem}-${endItem} / ${total}` : '暂无数据'}
</span>
)}
{/* 简洁导航控件组 */}
<div className="flex items-center gap-1 sm:gap-2">
{/* 上一页触发器 */}
<button
onClick={() => handlePageChange(current - 1)}
disabled={disabled || current <= 1}
@ -108,9 +155,13 @@ export const Pagination: React.FC<PaginationProps> = ({
>
<ChevronLeft size={16} />
</button>
{/* 当前进度的文本描述2 / 10 */}
<span className="text-sm font-medium text-gray-700 px-2 min-w-[4rem] text-center">
{current} / {totalPages}
</span>
{/* 下一页触发器 */}
<button
onClick={() => handlePageChange(current + 1)}
disabled={disabled || current >= totalPages}
@ -124,10 +175,14 @@ export const Pagination: React.FC<PaginationProps> = ({
);
}
// 完整模式
/**
* B (Standard Mode)
*
*/
return (
<div className={`flex flex-col sm:flex-row items-center justify-between gap-3 py-3 px-4 ${className}`}>
{/* 总数信息 - 右对齐 */}
{/* 步骤 4.1:总数详细统计信息 (Total Summary) */}
{showTotal && (
<div className="text-xs sm:text-sm text-gray-500 order-last sm:order-first pagination-total-text">
{total > 0 ? (
@ -142,9 +197,10 @@ export const Pagination: React.FC<PaginationProps> = ({
</div>
)}
{/* 分页控件 */}
{/* 步骤 4.2:分页控制按钮群 (Pagination Control Group) */}
<div className="flex items-center gap-1">
{/* 首页 */}
{/* 首页快捷键:在移动端自动隐藏以节省空间 */}
<button
onClick={() => handlePageChange(1)}
disabled={disabled || current <= 1}
@ -154,7 +210,7 @@ export const Pagination: React.FC<PaginationProps> = ({
<ChevronsLeft size={14} />
</button>
{/* 上一页 */}
{/* 上一页按钮 */}
<button
onClick={() => handlePageChange(current - 1)}
disabled={disabled || current <= 1}
@ -164,9 +220,10 @@ export const Pagination: React.FC<PaginationProps> = ({
<ChevronLeft size={16} />
</button>
{/* 页码按钮 */}
{/* 核心:动态页码渲染区域 */}
<div className="flex items-center gap-1">
{getPageNumbers().map((page, index) =>
// 渲染省略号占位符
page === 'ellipsis' ? (
<span
key={`ellipsis-${index}`}
@ -175,12 +232,13 @@ export const Pagination: React.FC<PaginationProps> = ({
···
</span>
) : (
// 渲染具体的页码数字按钮
<button
key={page}
onClick={() => handlePageChange(page)}
disabled={disabled}
className={`inline-flex items-center justify-center min-w-[2rem] h-8 px-2 rounded-md text-sm font-medium transition-colors ${page === current
? 'bg-primary text-white border border-primary'
? 'bg-primary text-white border border-primary' // 活跃页应用品牌主题色
: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 hover:border-gray-400'
} disabled:opacity-50 disabled:cursor-not-allowed`}
aria-label={`${page}`}
@ -192,7 +250,7 @@ export const Pagination: React.FC<PaginationProps> = ({
)}
</div>
{/* 下一页 */}
{/* 下一页按钮 */}
<button
onClick={() => handlePageChange(current + 1)}
disabled={disabled || current >= totalPages}
@ -202,7 +260,7 @@ export const Pagination: React.FC<PaginationProps> = ({
<ChevronRight size={16} />
</button>
{/* 末页 */}
{/* 末页快捷键 */}
<button
onClick={() => handlePageChange(totalPages)}
disabled={disabled || current >= totalPages}
@ -213,7 +271,9 @@ export const Pagination: React.FC<PaginationProps> = ({
</button>
</div>
{/* 快速跳转 */}
{/* 4.3 (Quick Jumper)
*/}
{showQuickJumper && totalPages > 5 && (
<div className="hidden md:flex items-center gap-2 text-sm text-gray-600">
<span></span>
@ -223,10 +283,13 @@ export const Pagination: React.FC<PaginationProps> = ({
max={totalPages}
className="w-14 h-8 px-2 border border-gray-300 rounded-md text-center text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary"
onKeyDown={(e) => {
// 监听回车键,执行跳转逻辑
if (e.key === 'Enter') {
const value = parseInt((e.target as HTMLInputElement).value);
if (!isNaN(value)) {
// 执行带边界约束的页码跳转
handlePageChange(Math.min(Math.max(1, value), totalPages));
// 执行完成后清空输入框,优化交互流
(e.target as HTMLInputElement).value = '';
}
}
@ -240,4 +303,4 @@ export const Pagination: React.FC<PaginationProps> = ({
);
};
export default Pagination;
export default Pagination;

@ -1,20 +1,48 @@
/**
* @file PanelToggleButton.tsx
* @module Components/Layout/Panel-Toggle
* @description
* Workspace
* *
* 1. (Usability-1):
* 2. (A11y): WCAG 2.1 AA 44x44px
* 3. : Tooltip
* *
* - Requirement 6.1:
* - Requirement 6.2:
* - Requirement 6.4/6.5:
* * @author Wang Lirong ()
* @version 1.3.0
* @date 2026-01-02
*/
import React from 'react';
import { PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react';
/**
*
* @interface PanelToggleButtonProps
* @description
*/
interface PanelToggleButtonProps {
/** 标识当前受控面板是否处于“物理展开”状态 */
isOpen: boolean;
/** 触发面板状态反转的业务回调函数,通常由上层状态机驱动 */
onToggle: () => void;
/** *
* - 'left':
* - 'right': AI
*/
position: 'left' | 'right';
/** 可选的自定义提示文本,若缺省则由组件根据语义自动生成 */
title?: string;
}
/**
* PanelToggleButton -
*
*
* WCAG 2.1 AA (44x44px)
*
* Requirements: 6.1, 6.2, 6.4, 6.5
* @component PanelToggleButton
* @description
* React
* Tailwind CSS 500%
*/
export const PanelToggleButton: React.FC<PanelToggleButtonProps> = ({
isOpen,
@ -22,35 +50,70 @@ export const PanelToggleButton: React.FC<PanelToggleButtonProps> = ({
position,
title
}) => {
// 根据位置和状态选择合适的图标
/**
*
* @description Lucide
* *
* 1. PanelLeftClose
* 2. PanelLeftOpen
* 3.
* @returns {JSX.Element} SVG
*/
const getIcon = () => {
// 处理左侧锚点逻辑
if (position === 'left') {
return isOpen ? <PanelLeftClose size={18} /> : <PanelLeftOpen size={18} />;
} else {
}
// 处理右侧锚点逻辑(镜像翻转)
else {
return isOpen ? <PanelRightClose size={18} /> : <PanelRightOpen size={18} />;
}
};
// 生成默认标题
/**
*
* @description
* @returns {string}
*/
const getDefaultTitle = () => {
if (position === 'left') {
// 对应左侧:通常挂载数据库 Schema 浏览器
return isOpen ? '最小化数据库面板' : '展开数据库面板';
} else {
// 对应右侧:通常挂载 Agent 会话历史列表
return isOpen ? '最小化会话列表' : '展开会话列表';
}
};
/**
*
* button 访Tab-Index
*/
return (
<button
/** 执行由父级注入的布局重算回调 */
onClick={onToggle}
/** *
* - panel-toggle-button:
* - box-border p-2.5: padding
* - hover:bg-gray-100:
*/
className="panel-toggle-button box-border p-2.5 hover:bg-gray-100 rounded-md text-gray-500 hover:text-gray-800 transition-colors"
/** 渲染优先级:用户自定义标题 > 自动生成语义化标题 */
title={title || getDefaultTitle()}
/** 声明按钮类型为非提交型,防止在表单环境内被误触 */
type="button"
/** *
* WCAG 2.1 AA
* 44x44px Click Target Size
* 使
*/
style={{
minWidth: '44px',
minHeight: '44px'
}}
>
{/* 渲染由图标路由算法分发的图形节点 */}
{getIcon()}
</button>
);

@ -1,3 +1,22 @@
/**
* @file ProjectWizard.tsx
* @module Components/Workflows/Project-Creation-Wizard
* @description
*
* * *
* 1. (SF2): INITIALIZING -> SCHEMA -> DDL -> DEPLOY 线
* 2. (SF3): AI
* 3. (UX Optimization): AI
* 4. : localStorage
* * *
* - Ref React 退
* - Footer Render Prop
* - ER Schema
* * @author Wang Lirong ()
* @version 3.2.0
* @date 2026-01-02
*/
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { ProjectDTO, CreationStageEnum, ProjectStatusEnum } from '../types';
import { getProjectDetail, generateDDL, deployProject, regenerateER } from '../api/project';
@ -5,7 +24,11 @@ import { Button, ProgressBar } from './UI';
import { Loader2, CheckCircle2, AlertTriangle, FileJson, Code2, Play, RefreshCw } from 'lucide-react';
import { InteractiveERRenderer } from './InteractiveERRenderer';
// 定义阶段顺序,用于防止状态回退
/**
*
* @constant STAGE_ORDER
* @description
*/
const STAGE_ORDER = [
CreationStageEnum.INITIALIZING,
CreationStageEnum.GENERATING_SCHEMA,
@ -16,85 +39,133 @@ const STAGE_ORDER = [
CreationStageEnum.COMPLETED
];
/**
*
* @function getStageIndex
*/
const getStageIndex = (stage: CreationStageEnum | undefined | null): number => {
if (!stage) return -1;
return STAGE_ORDER.indexOf(stage);
};
/**
*
* @interface ProjectWizardProps
*/
interface ProjectWizardProps {
/** 目标项目的全局唯一识别码 */
projectId: string | number;
/** 全部阶段完成后触发的成功回调 */
onComplete: () => void;
/** 用户中途主动关闭向导的回调 */
onClose: () => void;
/** 只读模式标识:用于查看已完成项目的历史配置 */
viewOnly?: boolean;
/** 动态底部渲染函数:允许向导根据当前阶段向父级模态框“投影”操作按钮 */
renderFooter?: (footer: React.ReactNode) => void;
}
/**
*
* @function formatDisplayContent
* @description
* AI
* 1. JSON
* 2. \n, \"
*/
const formatDisplayContent = (content: any) => {
if (!content) return '';
let str = content;
// 如果是对象,先转为 JSON 字符串
// 类型分支:处理原生 JSON 对象
if (typeof content !== 'string') {
str = JSON.stringify(content, null, 2);
} else {
// 如果是字符串,尝试解析为 JSON 对象再转回字符串(为了格式化)
// 类型分支:尝试对 JSON 字符串执行二次美化
try {
const parsed = JSON.parse(content);
str = JSON.stringify(parsed, null, 2);
} catch (e) {
// 解析失败,说明是普通字符串(如 DDL保持原样
// 容错:解析失败则判定为普通 DDL/SQL 文本,维持现状
}
}
// 后处理:将字面量 \n 转换为实际换行符,将 \" 转换为 ",以提升可读性
// 注意:这可能会导致生成的文本不再是有效的 JSON但更适合人类阅读
// 后处理:将技术转义符映射为物理表现字符
return str.replace(/\\n/g, '\n').replace(/\\"/g, '"');
};
// 从 schema_definition 对象中提取 schema 文本
/**
* Schema
* @function extractSchemaText
*/
const extractSchemaText = (schemaDefinition: any): string => {
if (!schemaDefinition) return '';
// 如果是对象且有 schema 字段,提取它
// 策略判定:优先提取字段中包含的 schema 核心代码块
if (typeof schemaDefinition === 'object' && schemaDefinition.schema) {
return formatDisplayContent(schemaDefinition.schema);
}
// 否则格式化整个内容
// 兜底策略:格式化全量元数据内容
return formatDisplayContent(schemaDefinition);
};
/**
* @component ProjectWizard
* @description
* useEffect
*/
export const ProjectWizard: React.FC<ProjectWizardProps> = ({ projectId, onComplete, onClose, viewOnly = false, renderFooter }) => {
// --- 核心业务状态 ---
const [project, setProject] = useState<ProjectDTO | null>(null);
const [error, setError] = useState<string | null>(null);
// Edit states
// --- 编辑态缓冲区 (Draft Buffer) ---
/** 存储用户在 Schema 编辑区输入的文本,支持在确认前进行多轮微调 */
const [editedSchema, setEditedSchema] = useState<string>('');
/** 存储 AI 生成的 DDL 语句快照,作为最终部署前的审查底稿 */
const [editedDDL, setEditedDDL] = useState<string>('');
/** 存储向导执行过程中的额外增量需求 */
const [requirements] = useState<string>('');
/** 控制完成阶段视图下的 Tab 切换(概览/Schema/DDL */
const [activeTab, setActiveTab] = useState('概览');
// ER 图重新生成状态
// --- 异步渲染状态监控 ---
/** 标识 ER 图是否正处于 AI 重新生成的处理队列中 */
const [isRegeneratingER, setIsRegeneratingER] = useState(false);
/** 缓存本地已渲染的 ER 图代码,用于执行 DOM 局部刷新 */
const [localERCode, setLocalERCode] = useState<string>('');
/** 记录上一次成功的 ER 图快照,用于检测后端推送的数据是否产生实质变化 */
const previousERCodeRef = useRef<string>('');
// 轮询控制状态
// --- 策略控制状态 ---
/** 控制 HTTP 轮询器的激活状态,在需要用户交互或操作已完成时进入休眠 */
const [shouldPoll, setShouldPoll] = useState(true);
// Progress animation state
// --- 视觉表现层状态 (UX Progress Engine) ---
/** * @description UX
* AI
* visualProgress
*/
const [visualProgress, setVisualProgress] = useState(0);
/** 持久化引用快照:用于在 Effect 销毁时将当前进度离线保存 */
const progressSnapshotRef = useRef<{ stage: CreationStageEnum | null; progress: number }>({
stage: null,
progress: 0,
});
/** 恢复引用快照:记录从 localStorage 加载回来的历史进度 */
const restoredSnapshotRef = useRef<{ stage: CreationStageEnum | null; progress: number } | null>(null);
// Track the latest stage index to prevent regression
/** * @description
*
*/
const latestStageRef = useRef<number>(-1);
// Polling
/**
* Effect
*
*/
useEffect(() => {
let intervalId: NodeJS.Timeout | null = null;
@ -102,28 +173,37 @@ export const ProjectWizard: React.FC<ProjectWizardProps> = ({ projectId, onCompl
try {
const data = await getProjectDetail(projectId);
// Prevent stage regression (e.g. polling returns old status after optimistic update)
/**
* 退
* STAGE_ORDER
*/
const currentStageIndex = getStageIndex(data.creation_stage);
if (currentStageIndex < latestStageRef.current) {
return;
}
// 更新最新阶段索引记录
latestStageRef.current = currentStageIndex;
setProject(data);
// Initialize edit states if empty - 在任何需要显示的阶段都初始化数据
// Schema: 在 SCHEMA_GENERATED, GENERATING_DDL, DDL_GENERATED 等阶段都需要
/**
*
*
*/
if (!editedSchema && data.schema_definition) {
setEditedSchema(extractSchemaText(data.schema_definition));
}
// DDL: 在 DDL_GENERATED, EXECUTING_DDL, COMPLETED 等阶段需要
if (!editedDDL && data.ddl_statement) {
setEditedDDL(formatDisplayContent(data.ddl_statement));
}
// 在需要用户操作的阶段停止轮询
/**
*
*
* 1SCHEMA_GENERATED/DDL_GENERATED
* 2ACTIVE
*/
const stage = data.creation_stage;
// 在用户需要操作的阶段停止轮询(除非正在重新生成 ER 图)
if (!isRegeneratingER && (
stage === CreationStageEnum.SCHEMA_GENERATED ||
stage === CreationStageEnum.DDL_GENERATED ||
@ -138,17 +218,21 @@ export const ProjectWizard: React.FC<ProjectWizardProps> = ({ projectId, onCompl
};
fetchProject();
// 执行异步轮询逻辑
if (!viewOnly && shouldPoll) {
// 重新生成 ER 图时使用较低的轮询频率5秒其他情况使用正常频率3秒
// 策略分支:常规任务 3s 一次,高频图形重绘 5s 一次以减轻服务器压力
const pollInterval = isRegeneratingER ? 5000 : 3000;
intervalId = setInterval(fetchProject, pollInterval);
}
// 清理逻辑:防止内存泄漏
return () => {
if (intervalId) clearInterval(intervalId);
};
}, [projectId, viewOnly, shouldPoll, isRegeneratingER]);
/** 状态快照实时同步 Effect */
useEffect(() => {
progressSnapshotRef.current = {
stage: (project?.creation_stage ?? null) as CreationStageEnum | null,
@ -156,6 +240,9 @@ export const ProjectWizard: React.FC<ProjectWizardProps> = ({ projectId, onCompl
};
}, [project?.creation_stage, visualProgress]);
/** * 线 Effect
* UX
*/
useEffect(() => {
if (viewOnly) return;
@ -166,6 +253,8 @@ export const ProjectWizard: React.FC<ProjectWizardProps> = ({ projectId, onCompl
const parsed = JSON.parse(raw) as { stage?: string; progress?: number };
const stage = (parsed.stage ?? null) as CreationStageEnum | null;
const progress = Number(parsed.progress);
// 校验历史数据的有效性
if (Number.isFinite(progress) && progress > 0) {
const clamped = Math.max(0, Math.min(100, progress));
restoredSnapshotRef.current = { stage, progress: clamped };
@ -173,9 +262,10 @@ export const ProjectWizard: React.FC<ProjectWizardProps> = ({ projectId, onCompl
}
}
} catch {
// ignore
// 静默处理存储异常
}
// 销毁前执行序列化存储
return () => {
try {
const snapshot = progressSnapshotRef.current;
@ -187,21 +277,27 @@ export const ProjectWizard: React.FC<ProjectWizardProps> = ({ projectId, onCompl
};
}, [projectId, viewOnly]);
// Progress Bar Animation Logic
/**
* (Visual Progress Engine)
* @description
* Asymptotic Growth ()
*
* $$ \Delta P = \max(0.1, (P_{target} - P_{current}) \times 0.05) $$
*/
useEffect(() => {
if (!project || viewOnly) return;
const stage = project.creation_stage || CreationStageEnum.INITIALIZING;
let target = 0;
// Determine target based on stage
// 分支判定:根据当前物理阶段设置视觉目标阈值
switch (stage) {
case CreationStageEnum.INITIALIZING:
case CreationStageEnum.GENERATING_SCHEMA:
target = 90; // Aim for 90% while generating
target = 90; // 在后端生成期间,最高停留在 90%
break;
case CreationStageEnum.SCHEMA_GENERATED:
target = 100; // Completed this step
target = 100; // 后端反馈生成完毕,视觉立即拉满
break;
case CreationStageEnum.GENERATING_DDL:
target = 90;
@ -210,7 +306,7 @@ export const ProjectWizard: React.FC<ProjectWizardProps> = ({ projectId, onCompl
target = 100;
break;
case CreationStageEnum.EXECUTING_DDL:
target = 95;
target = 95; // 物理执行阶段,由于涉及网络 IO 波动,最大设定为 95%
break;
case CreationStageEnum.COMPLETED:
target = 100;
@ -219,15 +315,14 @@ export const ProjectWizard: React.FC<ProjectWizardProps> = ({ projectId, onCompl
target = 0;
}
// 如果是同一阶段从弹窗关闭后恢复,尽量从上次的进度继续增长(不从 0 重来)
// 断点续传优化:若当前阶段与缓存一致,则从缓存点继续动画
const restored = restoredSnapshotRef.current;
if (restored && restored.stage === stage && visualProgress < restored.progress) {
setVisualProgress(restored.progress);
return; // 避免立即启动 timer
return;
}
// 对于"已完成"类型的阶段非生成中如果进度为0且没有恢复快照直接显示100%
// 这是为了处理刚打开弹窗时的情况
// 完成类阶段的即时响应逻辑
const isCompletedStage = stage === CreationStageEnum.SCHEMA_GENERATED ||
stage === CreationStageEnum.DDL_GENERATED ||
stage === CreationStageEnum.COMPLETED;
@ -237,29 +332,26 @@ export const ProjectWizard: React.FC<ProjectWizardProps> = ({ projectId, onCompl
return;
}
// 如果已经达到目标,不启动 timer
if (visualProgress >= target) return;
/** 核心定时器:驱动进度条平滑增长 */
const timer = setInterval(() => {
setVisualProgress(prev => {
if (prev >= target) return prev;
// Calculate increment: smaller as we get closer to target
const remaining = target - prev;
const increment = Math.max(0.1, remaining * 0.05); // 5% of remaining distance
const increment = Math.max(0.1, remaining * 0.05);
return Math.min(target, prev + increment);
});
}, 100); // Update every 100ms
}, 100);
return () => clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [project?.creation_stage, viewOnly]);
// Reset progress when entering a new "generating" stage
/** 进度重置策略 Effect */
useEffect(() => {
const currentStage = project?.creation_stage || CreationStageEnum.INITIALIZING;
// 当跨入新的“生成类”阶段时,重置物理进度条
if (currentStage === CreationStageEnum.GENERATING_SCHEMA ||
currentStage === CreationStageEnum.GENERATING_DDL ||
currentStage === CreationStageEnum.EXECUTING_DDL) {
@ -268,6 +360,7 @@ export const ProjectWizard: React.FC<ProjectWizardProps> = ({ projectId, onCompl
setVisualProgress(0);
}
// 流程完结,清理存储占用的资源
if (currentStage === CreationStageEnum.COMPLETED) {
restoredSnapshotRef.current = null;
if (!viewOnly) {
@ -280,47 +373,53 @@ export const ProjectWizard: React.FC<ProjectWizardProps> = ({ projectId, onCompl
}
}, [project?.creation_stage]);
/**
* Schema
* DDL
*/
const handleConfirmSchema = useCallback(async () => {
if (!project) return;
try {
// 清除恢复快照确保进度从0开始
restoredSnapshotRef.current = null;
setShouldPoll(true); // 重新开启轮询
setShouldPoll(true);
// 乐观更新:立即切换到生成 DDL 状态,显示进度条
// 注意:先更新 project 状态,再重置进度,避免进度条动画逻辑的干扰
/**
* (Optimistic UI Update):
* UI
*/
const nextStage = CreationStageEnum.GENERATING_DDL;
latestStageRef.current = getStageIndex(nextStage);
setProject(prev => prev ? { ...prev, creation_stage: nextStage } : null);
setVisualProgress(0); // Reset progress for next stage
setVisualProgress(0);
// 发起异步业务请求
await generateDDL(project.project_id, editedSchema, requirements);
// State update will happen on next poll
} catch (err) {
console.error("Failed to confirm schema:", err);
setError("确认 Schema 失败。");
}
}, [project, editedSchema, requirements]);
/**
* DDL
* SF1
*/
const handleConfirmDDL = useCallback(async () => {
if (!project) return;
try {
// 清除恢复快照确保进度从0开始
restoredSnapshotRef.current = null;
// 关键修改 1: 部署期间暂停轮询,防止状态跳变
// 关键步骤:在部署这一敏感事务期间暂停背景轮询,防止乐观状态被服务器旧缓存覆盖
setShouldPoll(false);
// 乐观更新:立即切换到执行 DDL 状态,显示进度条
const nextStage = CreationStageEnum.EXECUTING_DDL;
latestStageRef.current = getStageIndex(nextStage);
setProject(prev => prev ? { ...prev, creation_stage: nextStage } : null);
setVisualProgress(0); // Reset progress for next stage
setVisualProgress(0);
// 关键修改 2: 获取返回值并直接更新状态
// 执行物理部署
const updatedProject = await deployProject(project.project_id, editedDDL);
if (updatedProject) {
@ -331,29 +430,30 @@ export const ProjectWizard: React.FC<ProjectWizardProps> = ({ projectId, onCompl
}
}
// 部署完成后再恢复轮询
setShouldPoll(true);
} catch (err) {
console.error("Failed to deploy project:", err);
setError("部署项目失败。");
setShouldPoll(true); // 出错时恢复轮询
setShouldPoll(true);
}
}, [project, editedDDL]);
// 重新生成 ER 图
/**
* ER
* SF5
*/
const handleRegenerateER = useCallback(async () => {
if (!project || !editedSchema.trim()) return;
// 记录当前 ER 图代码,用于检测更新
// 保存前一个状态的副本
previousERCodeRef.current = localERCode;
setIsRegeneratingER(true);
setError(null);
setShouldPoll(true); // 开启轮询等待 ER 图更新
setShouldPoll(true);
try {
await regenerateER(project.project_id, editedSchema);
// ER 图会在后台生成,通过轮询获取最新数据
} catch (err) {
console.error("Failed to regenerate ER:", err);
setError("重新生成 ER 图失败。");
@ -361,25 +461,29 @@ export const ProjectWizard: React.FC<ProjectWizardProps> = ({ projectId, onCompl
}
}, [project, editedSchema, localERCode]);
// 当 project 更新时,同步更新本地 ER 图代码
/**
* ER Effect
* DSL
*/
useEffect(() => {
const newERCode = project?.er_diagram_code;
if (!newERCode || newERCode === localERCode) return;
// 如果正在重新生成 ER 图,检测 ER 图是否真的更新了
// 若新代码已抵达且与旧代码不符,则判定重绘任务完成
if (isRegeneratingER && newERCode !== previousERCodeRef.current) {
setIsRegeneratingER(false);
setShouldPoll(false);
}
// 更新本地 ER 图代码
setLocalERCode(newERCode);
// 更新 ref
previousERCodeRef.current = newERCode;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [project?.er_diagram_code]);
// 根据当前阶段更新 footer 内容
/**
* Effect
* @description
* Modal
*/
useEffect(() => {
if (!renderFooter || !project) {
renderFooter?.(null);
@ -390,6 +494,7 @@ export const ProjectWizard: React.FC<ProjectWizardProps> = ({ projectId, onCompl
switch (stage) {
case CreationStageEnum.SCHEMA_GENERATED:
// 场景 ASchema 已生成,提供修改与确认两个维度的动作
renderFooter(
<div className="flex justify-between items-center w-full">
<Button
@ -410,6 +515,7 @@ export const ProjectWizard: React.FC<ProjectWizardProps> = ({ projectId, onCompl
);
break;
case CreationStageEnum.DDL_GENERATED:
// 场景 BDDL 已准备就绪,仅提供最终部署确认
renderFooter(
<>
<Button variant="default" onClick={onClose}></Button>
@ -421,6 +527,7 @@ export const ProjectWizard: React.FC<ProjectWizardProps> = ({ projectId, onCompl
);
break;
case CreationStageEnum.COMPLETED:
// 场景 C流程完结提供应用入口
if (!viewOnly) {
renderFooter(
<Button onClick={onComplete} className="bg-blue-600 hover:bg-blue-700 text-white">
@ -435,17 +542,20 @@ export const ProjectWizard: React.FC<ProjectWizardProps> = ({ projectId, onCompl
renderFooter(null);
break;
}
// 注意:这里故意省略部分依赖项以避免无限循环
// renderFooter, onClose, onComplete 是 props 函数,可能每次渲染都是新引用
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [project?.creation_stage, viewOnly, isRegeneratingER, handleRegenerateER, handleConfirmSchema, handleConfirmDDL]);
// --- 加载守卫 ---
if (!project) return <div className="p-8 flex justify-center"><Loader2 className="animate-spin" /></div>;
/**
*
*
*/
const renderStageContent = () => {
const stage = viewOnly ? CreationStageEnum.COMPLETED : (project.creation_stage || CreationStageEnum.INITIALIZING);
switch (stage) {
/** 生成中:显示全局动画与虚拟进度 */
case CreationStageEnum.INITIALIZING:
case CreationStageEnum.GENERATING_SCHEMA:
return (
@ -459,6 +569,7 @@ export const ProjectWizard: React.FC<ProjectWizardProps> = ({ projectId, onCompl
</div>
);
/** 审计态:左右分布展示编辑器与可视化图形 */
case CreationStageEnum.SCHEMA_GENERATED:
return (
<div className="flex flex-col">
@ -470,8 +581,9 @@ export const ProjectWizard: React.FC<ProjectWizardProps> = ({ projectId, onCompl
<span className="text-sm text-gray-500"> Schema"修改" ER </span>
</div>
{/* 编辑与预览分屏布局 */}
<div className="flex gap-4 h-[500px]">
{/* 左侧Schema 编辑区 */}
{/* 源码编辑区:支持手动干预 AI 逻辑偏差 */}
<div className="flex-1 min-w-0 border rounded-md overflow-hidden">
<textarea
className="w-full h-full p-4 font-mono text-sm resize-none focus:outline-none overflow-y-auto"
@ -480,7 +592,7 @@ export const ProjectWizard: React.FC<ProjectWizardProps> = ({ projectId, onCompl
/>
</div>
{/* 右侧ER 图预览区 */}
{/* 物理可视化预览区:基于 InteractiveERRenderer */}
<div className="flex-1 min-w-0 border rounded-md overflow-hidden bg-gray-50 relative">
{isRegeneratingER && (
<div className="absolute inset-0 bg-white/80 flex items-center justify-center z-10">
@ -520,6 +632,7 @@ export const ProjectWizard: React.FC<ProjectWizardProps> = ({ projectId, onCompl
</div>
);
/** DDL 审计态:展示最终生成的 SQL 脚本 */
case CreationStageEnum.DDL_GENERATED:
return (
<div className="flex flex-col">
@ -551,6 +664,7 @@ export const ProjectWizard: React.FC<ProjectWizardProps> = ({ projectId, onCompl
</div>
);
/** 完成态:展示全量概览与项目配置清单 */
case CreationStageEnum.COMPLETED:
return (
<div className="flex flex-col">
@ -562,7 +676,7 @@ export const ProjectWizard: React.FC<ProjectWizardProps> = ({ projectId, onCompl
</div>
)}
{/* Tabs */}
{/* 标签切换系统 */}
<div className="border-b border-gray-200 shrink-0">
<nav className="-mb-px flex space-x-8">
{['概览', 'Schema', 'DDL'].map((tab) => (
@ -581,6 +695,7 @@ export const ProjectWizard: React.FC<ProjectWizardProps> = ({ projectId, onCompl
</div>
<div className="h-[500px] w-full mt-6">
{/* Tab: 可视化图表 */}
{activeTab === '概览' && project?.er_diagram_code && project.er_diagram_code.trim() && (
<div className="h-full border rounded-lg p-4 flex flex-col" style={{ overflow: 'hidden' }}>
<h4 className="font-medium mb-4 text-gray-700 shrink-0">ER </h4>
@ -588,18 +703,7 @@ export const ProjectWizard: React.FC<ProjectWizardProps> = ({ projectId, onCompl
</div>
)}
{activeTab === '概览' && (!project?.er_diagram_code || !project.er_diagram_code.trim()) && (
<div className="h-full border rounded-lg p-4 flex flex-col" style={{ overflow: 'hidden' }}>
<h4 className="font-medium mb-4 text-gray-700 shrink-0">ER </h4>
<div className="flex-1 flex items-center justify-center text-gray-500">
<div className="text-center">
<p className="mb-2">ER </p>
<p className="text-sm"> ER </p>
</div>
</div>
</div>
)}
{/* Tab: Schema 源码内容 */}
{activeTab === 'Schema' && (
<div className="h-full border rounded-md bg-gray-50 overflow-y-auto">
<div
@ -636,13 +740,18 @@ export const ProjectWizard: React.FC<ProjectWizardProps> = ({ projectId, onCompl
}
};
/** *
*
*/
return (
<div className="bg-white rounded-lg shadow-sm p-6 max-w-4xl mx-auto w-full flex flex-col">
{/* 头部摘要信息区 */}
<div className="mb-6 border-b pb-4 shrink-0">
<h2 className="text-2xl font-bold text-gray-800">{project.project_name}</h2>
<p className="text-gray-500">{project.description}</p>
</div>
{/* 错误提示浮窗 */}
{error && (
<div className="bg-red-50 text-red-600 p-4 rounded-md mb-6 flex items-center gap-2 shrink-0">
<AlertTriangle className="w-5 h-5" />
@ -650,9 +759,10 @@ export const ProjectWizard: React.FC<ProjectWizardProps> = ({ projectId, onCompl
</div>
)}
{/* 动态工作区 */}
<div className="flex-1 min-h-0">
{renderStageContent()}
</div>
</div>
);
};
};

@ -1,68 +1,114 @@
/**
* @file Sidebar.tsx
* @module Components/Layout/Global-Sidebar
* @description
*
* *
* 1. (RBAC): (Admin/User)
* 2. :
* 3. :
* 4. : (Context-Awareness)
* * @author Wang Lirong ()
* @version 2.4.0
* @date 2026-01-02
*/
import React, { useEffect, useRef, useState } from 'react';
import { LayoutDashboard, BarChart2, Users, Book, Bell, X, PanelLeftClose, PanelLeftOpen, User as UserIcon, Settings, LogOut, Database, Bot } from 'lucide-react';
import { UserRole } from '../types'; // 修复: 移除 .ts 后缀
import { UserRole } from '../types';
/**
*
*
* @interface SidebarProps
* @description
*/
interface SidebarProps {
/** 当前登录用户的角色(管理员或普通用户) */
/** 当前登录用户的角色标识(决定菜单权限矩阵 */
role: UserRole;
/** 当前登录用户信息(用于底部展示头像/名称/角色) */
/** 当前登录用户的档案数据摘要(头像、名称、角色展示 */
user?: { name: string; role: UserRole; avatar_url?: string | null } | null;
/** 当前选择的项目工作区(用于显示“当前工作区”入口) */
/** 全局选中的活跃项目上下文,用于显示“当前工作区”状态 */
selectedProject?: { id: string; name: string } | null;
/** 当前激活的页面 ID用于高亮显示菜单项 */
/** 当前活跃页面的 ID用于驱动导航项的激活态高亮显示 */
activePage: string;
/** *
* @param page - ID
/** *
* @param {string} page -
*/
onNavigate: (page: string) => void;
/** 移动端菜单是否打开 */
/** 移动端环境下的展示状态开关(由父级布局容器控制) */
isOpen?: boolean;
/** 关闭移动端菜单的回调 */
/** 触发移动端侧边栏关闭逻辑的回调函数 */
onClose?: () => void;
/** 退出登录回调(用于底部用户菜单) */
/** 退出登录并清理安全凭证的业务回调 */
onLogout?: () => void;
}
/**
*
* *
* @component Sidebar
* @description
*
* Side Effect CSS (Tailwind)
*/
export const Sidebar: React.FC<SidebarProps> = ({ role, user, selectedProject, activePage, onNavigate, isOpen, onClose, onLogout }) => {
/** *
* 256px (w-64) 64px (w-16)
*/
const [isCollapsed, setIsCollapsed] = useState(false);
/** 底部个人信息面板的下拉菜单可见性状态 */
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
/** 引用底部用户菜单容器,用于执行“点击外部自动关闭”的 DOM 监听逻辑 */
const userMenuRef = useRef<HTMLDivElement>(null);
/**
* A
* localStorage
*/
useEffect(() => {
try {
const raw = localStorage.getItem('app.sidebarCollapsed');
setIsCollapsed(raw === '1');
} catch {
// ignore
// 容错:若 Storage 访问受限(如隐私模式),维持默认展开状态
}
}, []);
/**
* B
* 线
*/
useEffect(() => {
try {
localStorage.setItem('app.sidebarCollapsed', isCollapsed ? '1' : '0');
} catch {
// ignore
// 静默处理非关键性存储失败
}
}, [isCollapsed]);
/**
* C
* Dropdown
*/
useEffect(() => {
/**
*
* @param {MouseEvent} event -
*/
const handleClickOutside = (event: MouseEvent) => {
if (userMenuRef.current && !userMenuRef.current.contains(event.target as Node)) {
setIsUserMenuOpen(false);
}
};
// 在 document 层面委托监听,确保能捕获所有点击流
document.addEventListener('mousedown', handleClickOutside);
// 卸载钩子:注销监听器,防止内存泄漏
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// 根据角色定义菜单项配置
/**
*
* UserRole
*
*/
const menuItems = role === UserRole.ADMIN ? [
{ id: 'admin_users', label: '用户管理', icon: <Users size={18} /> },
{ id: 'admin_announcements', label: '公告管理', icon: <Bell size={18} /> },
@ -77,15 +123,22 @@ export const Sidebar: React.FC<SidebarProps> = ({ role, user, selectedProject, a
return (
<>
{/* 移动端遮罩层 - z-40 ensures it covers the header (z-20) */}
{/* (Overlay Mask)
- z-40 Header
- onClose
*/}
{isOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 md:hidden"
className="fixed inset-0 bg-black/50 z-40 md:hidden animate-in fade-in duration-300"
onClick={onClose}
/>
)}
{/* 侧边栏容器 - z-50 ensures it's on top of everything */}
{/* (Sidebar Main Container)
- transition-[transform,width]:
- z-50:
- md:static: 使 sticky
*/}
<div className={`
fixed inset-y-0 left-0 w-64 bg-white border-r border-gray-200 h-screen h-[100dvh] flex flex-col
transition-[transform,width] duration-300 ease-in-out motion-reduce:transition-none z-50
@ -93,47 +146,54 @@ export const Sidebar: React.FC<SidebarProps> = ({ role, user, selectedProject, a
${isOpen ? 'translate-x-0' : '-translate-x-full'}
${isCollapsed ? 'md:w-16' : 'md:w-64'}
`}>
{/* :
1. (p-6 -> p-4)
2. 使 scale-125
3. overflow-hidden
{/* Logo (Header & Branding)
*/}
<div className="p-4 flex items-center justify-between border-b border-gray-100 relative">
{/* 桌面端折叠时:不显示任何图标/Logo仅保留展开按钮 */}
{/* 非折叠状态下展示品牌图形与文字 */}
{!isCollapsed && (
<div className="flex items-center gap-3 min-w-0">
<div className="w-8 h-8 flex items-center justify-center overflow-hidden">
{/* scale-125: 采用放大裁剪策略提升图标视觉冲击力 */}
<img
src="/database-logo.svg"
alt="AutoDB Logo"
className="w-full h-full object-contain scale-125 transform"
className="w-full h-full object-contain scale-125 transform transition-transform duration-500 hover:rotate-6"
/>
</div>
<h1 className="font-bold text-gray-800 text-lg tracking-tight truncate">AutoDB</h1>
</div>
)}
{/* 侧边栏交互控制按钮群组 */}
<div className="flex items-center gap-1 shrink-0">
{/* 桌面端最小化/展开按钮 */}
{/* 桌面端特有:折叠状态切换器 */}
<button
onClick={() => setIsCollapsed(v => !v)}
className={`hidden md:inline-flex p-1 text-gray-500 hover:bg-gray-100 rounded-md items-center justify-center ${isCollapsed ? 'md:mx-auto md:absolute md:left-1/2 md:-translate-x-1/2 md:top-1/2 md:-translate-y-1/2' : ''}`}
title={isCollapsed ? '展开侧边栏' : '最小化侧边栏'}
type="button"
>
{/* 动态切换展开/折叠图标语义 */}
{isCollapsed ? <PanelLeftOpen size={20} /> : <PanelLeftClose size={20} />}
</button>
{/* 移动端关闭按钮 */}
{/* 移动端特有:明确的关闭动作出口 */}
<button onClick={onClose} className="md:hidden p-1 text-gray-500 hover:bg-gray-100 rounded-md" type="button">
<X size={20} />
</button>
</div>
</div>
{/* 菜单列表区域 */}
<div className="flex-1 py-4 px-3 space-y-1">
{/* 当前工作区(仅普通用户显示) */}
{/* (Main Navigation Links)
flex-1
*/}
<div className="flex-1 py-4 px-3 space-y-1 overflow-y-auto custom-scrollbar">
{/* (Dynamic Context Slot)
AI
*/}
{role !== UserRole.ADMIN && (
<button
onClick={() => {
@ -144,9 +204,9 @@ export const Sidebar: React.FC<SidebarProps> = ({ role, user, selectedProject, a
}
onClose?.();
}}
title={selectedProject ? `当前工作区:${selectedProject.name}` : '未选择工作区(去项目概览选择)'}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-medium transition-colors ${activePage === 'workspace'
? 'bg-blue-50 text-primary'
title={selectedProject ? `当前工作区:${selectedProject.name}` : '未选择工作区'}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-medium transition-all duration-200 ${activePage === 'workspace'
? 'bg-blue-50 text-primary shadow-sm ring-1 ring-blue-100'
: selectedProject
? 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
: 'text-gray-400 hover:bg-gray-50'
@ -156,16 +216,17 @@ export const Sidebar: React.FC<SidebarProps> = ({ role, user, selectedProject, a
<span className="shrink-0"><Database size={18} /></span>
<span className={`${isCollapsed ? 'md:hidden' : ''} min-w-0 flex-1 text-left`}
>
<div className="truncate"></div>
<div className="truncate font-semibold"></div>
{selectedProject ? (
<div className="text-xs text-gray-500 truncate">{selectedProject.name}</div>
<div className="text-[10px] text-gray-500 truncate leading-tight">{selectedProject.name}</div>
) : (
<div className="text-xs text-gray-400 truncate"></div>
<div className="text-[10px] text-gray-400 truncate leading-tight"></div>
)}
</span>
</button>
)}
{/* 映射并渲染核心菜单阵列 */}
{menuItems.map((item) => (
<button
key={item.id}
@ -174,8 +235,8 @@ export const Sidebar: React.FC<SidebarProps> = ({ role, user, selectedProject, a
onClose?.();
}}
title={item.label}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-medium transition-colors ${activePage === item.id
? 'bg-blue-50 text-primary'
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-medium transition-all duration-200 ${activePage === item.id
? 'bg-blue-50 text-primary shadow-sm ring-1 ring-blue-100'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
} ${isCollapsed ? 'md:justify-center md:gap-0 md:px-0 md:h-11 md:w-11 md:mx-auto' : ''}`}
>
@ -185,14 +246,18 @@ export const Sidebar: React.FC<SidebarProps> = ({ role, user, selectedProject, a
))}
</div>
{/* 底部:当前用户信息(头像/名称/角色) + 菜单(个人设置/退出登录) */}
{/* (Bottom Account Section)
*/}
<div className="border-t border-gray-100 p-3 relative" ref={userMenuRef}>
{/* 触发器:点击展现个人中心菜单 */}
<button
type="button"
onClick={() => setIsUserMenuOpen(v => !v)}
title={isCollapsed ? `${user?.name || '当前用户'}(点击展开菜单)` : undefined}
className={`w-full flex items-center gap-3 rounded-lg hover:bg-gray-50 transition-colors ${isCollapsed ? 'md:justify-center md:gap-0 md:px-0 md:h-11 md:w-11 md:mx-auto' : 'px-2 py-2'}`}
>
{/* 头像渲染容器:支持外部图片与通用图标兜底 */}
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center text-primary border border-blue-200 overflow-hidden shrink-0">
{user?.avatar_url ? (
<img src={user.avatar_url} alt={user?.name || 'avatar'} className="w-full h-full object-cover" />
@ -200,35 +265,43 @@ export const Sidebar: React.FC<SidebarProps> = ({ role, user, selectedProject, a
<UserIcon size={20} />
)}
</div>
{/* 身份描述:仅在非折叠状态下渲染 */}
<div className={`${isCollapsed ? 'md:hidden' : ''} min-w-0 flex-1 text-left`}
>
<div className="text-sm font-medium text-gray-700 truncate">{user?.name || '当前用户'}</div>
<div className="text-xs text-gray-500">{user?.role === UserRole.ADMIN ? '管理员' : '普通用户'}</div>
<div className="text-xs text-gray-500 font-normal">{user?.role === UserRole.ADMIN ? '系统管理员' : '普通用户'}</div>
</div>
</button>
{/* (User Action Floating Menu)
-
- animate-in
*/}
{isUserMenuOpen && (
<div
className={`${isCollapsed ? 'md:absolute md:left-full md:bottom-3 md:ml-2 md:w-48' : 'absolute left-0 bottom-full mb-2 w-full'} bg-white rounded-lg shadow-lg border border-gray-100 py-1 animate-in fade-in zoom-in-95 duration-100 origin-top z-50`}
className={`${isCollapsed ? 'md:absolute md:left-full md:bottom-3 md:ml-2 md:w-48' : 'absolute left-0 bottom-full mb-2 w-full'} bg-white rounded-lg shadow-lg border border-gray-100 py-1 animate-in fade-in zoom-in-95 duration-150 origin-bottom z-50`}
>
{/* 设置项入口 */}
<button
onClick={() => {
setIsUserMenuOpen(false);
onNavigate('profile');
onClose?.();
}}
className="w-full text-left px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
className="w-full text-left px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2 transition-colors"
type="button"
>
<Settings size={16} />
</button>
{/* 分割线:视觉分区 */}
<div className="border-t border-gray-100 my-1"></div>
{/* 退出登录:具备警示语义的颜色提示 */}
<button
onClick={() => {
setIsUserMenuOpen(false);
onLogout?.();
}}
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 transition-colors"
type="button"
>
<LogOut size={16} /> 退

@ -1,48 +1,62 @@
/**
* @file UI.tsx
* @module Components/Foundation/Design-System
* @description UI
* (Atomic Design)
* * *
* 1. (Usability-1):
* 2. : Tailwind 500% SF12
* 3. : 线 (Event Bus) API UI
* 4. : Backdrop-blur ()
* * *
* - Requirement 1.1:
* - Requirement 7.1:
* * @author Wang Lirong ()
* @version 2.6.0
* @date 2026-01-02
*/
import React, { JSX, ReactNode, useState, useEffect } from 'react';
import { Check, X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-react';
// --- Button Component ---
// =========================================================
// 1. Button Component - 交互触发器
// =========================================================
/**
*
* @interface ButtonProps
* @extends {React.ButtonHTMLAttributes<HTMLButtonElement>} button
* @extends {React.ButtonHTMLAttributes<HTMLButtonElement>}
*/
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
/**
*
* - 'primary':
* - 'default':
* - 'dashed': 线
* - 'text':
* - 'danger':
* @default 'default'
*
* - primary: DDL
* - default:
* - dashed:
* - text:
* - danger:
*/
variant?: 'primary' | 'default' | 'dashed' | 'text' | 'danger';
/**
*
*/
/** 允许在文本左侧插入 Lucide 图标节点 */
icon?: ReactNode;
}
/**
*
* * hover, focus, disabled Tailwind
* * WCAG 2.1 AA (44x44px)
*
* @param children
* @param variant
* @param className
* @param icon
* @param {ButtonProps} props -
* @returns {JSX.Element}
* @component Button
* @description
*
* WCAG 2.1 AA 44px
*/
export const Button: React.FC<ButtonProps> = ({ children, variant = 'default', className = '', icon, ...props }) => {
// 基础样式:布局、内边距、字体、圆角、阴影及过渡效果
// 确保最小高度 44px 符合 WCAG 2.1 AA 标准
/** *
* - inline-flex:
* - transition-all: GPU
* - focus:ring-2:
*/
const baseStyles = "inline-flex items-center justify-center px-3 sm:px-4 py-2 sm:py-2.5 min-h-[44px] text-sm font-medium transition-all duration-200 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-1 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap";
// 不同变体的样式映射
/** 样式变体映射矩阵 */
const variants = {
primary: "bg-primary text-white hover:bg-primary-hover border border-transparent focus:ring-blue-500",
default: "bg-white text-gray-700 border border-gray-300 hover:text-primary hover:border-primary focus:ring-gray-200",
@ -53,38 +67,35 @@ export const Button: React.FC<ButtonProps> = ({ children, variant = 'default', c
return (
<button className={`${baseStyles} ${variants[variant]} ${className}`} {...props}>
{/* 图标渲染逻辑:当存在子元素时,自动追加右边距以维持间距平衡 */}
{icon && <span className={children ? "mr-1.5 sm:mr-2" : ""}>{icon}</span>}
{children}
</button>
);
};
// --- Input Component ---
// =========================================================
// 2. Input Component - 结构化输入
// =========================================================
/**
*
* @interface InputProps
* @extends {React.InputHTMLAttributes<HTMLInputElement>} input
*/
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
/**
*
*/
/** 顶部语义化标签内容 */
label?: string;
}
/**
*
* * Label focus
* * 使 16px iOS
*
* @param label
* @param className
* @param {InputProps} props -
* @returns {JSX.Element} Label Input
* @component Input
* @description
*
* iOS 16px
*/
export const Input: React.FC<InputProps> = ({ label, className = '', ...props }): JSX.Element => (
<div className="flex flex-col gap-1.5">
{/* 条件渲染:展示字段名称及必填红星标识 */}
{label && (
<label className="text-sm font-medium text-gray-700">
{label}
@ -98,66 +109,42 @@ export const Input: React.FC<InputProps> = ({ label, className = '', ...props })
</div>
);
// --- Select Component ---
// =========================================================
// 3. Select Component - 维度选择器
// =========================================================
/**
*
*
*/
export interface SelectOption {
/** 业务后端预期的原始值 */
value: string;
/** 用户端展示的语义化标签 */
label: string;
/** 禁用特定选项的布尔锁 */
disabled?: boolean;
}
/**
*
* @interface SelectProps
*/
interface SelectProps {
/**
*
*/
label?: string;
/**
*
*/
options: SelectOption[];
/**
*
*/
value: string;
/**
*
*/
onChange: (value: string) => void;
/**
*
* - 'default':
* - 'minimal':
* @default 'default'
*/
variant?: 'default' | 'minimal';
/**
*
*/
className?: string;
/**
*
*/
placeholder?: string;
/**
*
*/
disabled?: boolean;
/**
*
*/
required?: boolean;
}
/**
*
* *
* *
* @component Select
* @description
*
* select
*/
export const Select: React.FC<SelectProps> = ({
label,
@ -170,12 +157,21 @@ export const Select: React.FC<SelectProps> = ({
disabled = false,
required = false
}) => {
// 下拉面板的状态锁
const [isOpen, setIsOpen] = useState(false);
/** *
* Fixed overflow
*/
const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0, width: 0 });
// DOM 节点物理引用
const selectRef = React.useRef<HTMLDivElement>(null);
const buttonRef = React.useRef<HTMLButtonElement>(null);
// 点击外部关闭下拉菜单
/**
*
* UX
*/
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (selectRef.current && !selectRef.current.contains(event.target as Node)) {
@ -186,17 +182,23 @@ export const Select: React.FC<SelectProps> = ({
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// 获取当前选中项的标签
// 查找当前选中态的显示文本,实现回显逻辑
const selectedOption = options.find(opt => opt.value === value);
const displayText = selectedOption?.label || placeholder;
/**
*
* @description
* getBoundingClientRect()
*/
const handleToggle = () => {
if (disabled) return;
if (!isOpen && buttonRef.current) {
// 对于 minimal 变体,找到最近的带边框的父容器
/** * Minimal
*
*/
let targetElement: HTMLElement | null = buttonRef.current;
if (variant === 'minimal') {
// 向上查找带边框的父容器
let parent = buttonRef.current.parentElement;
while (parent && parent !== document.body) {
const style = window.getComputedStyle(parent);
@ -207,6 +209,8 @@ export const Select: React.FC<SelectProps> = ({
parent = parent.parentElement;
}
}
// 物理坐标重算
const rect = targetElement!.getBoundingClientRect();
setMenuPosition({
top: rect.bottom + 4,
@ -217,17 +221,20 @@ export const Select: React.FC<SelectProps> = ({
setIsOpen(!isOpen);
};
/** 派发值变更事件并关闭视图 */
const handleSelect = (optionValue: string) => {
onChange(optionValue);
setIsOpen(false);
};
/** 基于变体参数生成的样式字符串 */
const triggerStyles = variant === 'default'
? `px-3 py-2.5 min-h-[44px] bg-white border border-gray-300 rounded-lg text-base sm:text-sm shadow-sm ${isOpen ? 'border-primary ring-2 ring-blue-100' : 'hover:border-gray-400'}`
: `bg-transparent text-sm font-medium text-gray-800 py-1 ${isOpen ? 'text-primary' : ''}`;
return (
<div className="flex flex-col gap-1.5" ref={selectRef}>
{/* Label 区域 */}
{label && (
<label className="text-sm font-medium text-gray-700">
{label}
@ -235,7 +242,7 @@ export const Select: React.FC<SelectProps> = ({
</label>
)}
<div className={`relative ${className}`}>
{/* 触发 */}
{/* 触发按钮组件:集成了 rotate 图标动效 */}
<button
ref={buttonRef}
type="button"
@ -256,7 +263,7 @@ export const Select: React.FC<SelectProps> = ({
</svg>
</button>
{/* 下拉菜单 */}
{/* 下拉面板:采用 Fixed 布局及 Z-Index 策略确保不被层级覆盖 */}
{isOpen && (
<div
className="fixed bg-white border border-gray-200 rounded-lg shadow-md overflow-hidden animate-in fade-in zoom-in-95 duration-150"
@ -267,6 +274,7 @@ export const Select: React.FC<SelectProps> = ({
minWidth: menuPosition.width
}}
>
{/* 内部滚动区:最大高度限制为 5 个标准列表项的高度 */}
<div className="overflow-y-auto py-1" style={{ maxHeight: 'calc(5 * 44px)' }}>
{options.map((option) => (
<div
@ -281,6 +289,7 @@ export const Select: React.FC<SelectProps> = ({
`}
>
<span className="whitespace-nowrap">{option.label}</span>
{/* 选中态视觉回显:蓝勾图标 */}
{option.value === value && (
<Check size={16} className="text-primary shrink-0" />
)}
@ -294,61 +303,61 @@ export const Select: React.FC<SelectProps> = ({
);
};
// --- Card Component ---
// =========================================================
// 4. Card Component - 信息分屏器
// =========================================================
/**
*
*/
interface CardProps {
/** 卡片主体内容 */
children: ReactNode;
/** 卡片左上角标题(可选) */
title?: ReactNode;
/** 卡片右上角额外操作区(可选),例如按钮或标签 */
extra?: ReactNode;
/** 自定义类名 */
className?: string;
}
/**
*
* *
* *
* * @param {CardProps} props -
* @returns {JSX.Element}
* @component Card
* @description
*
*
*/
export const Card: React.FC<CardProps> = ({ children, title, extra, className = '' }: CardProps): JSX.Element => (
<div className={`bg-white rounded-xl border border-gray-200 shadow-sm transition-all hover:shadow-md ${className}`}>
{/* 顶部标题栏区域 */}
{(title || extra) && (
<div className="px-4 sm:px-6 py-3 sm:py-4 border-b border-gray-100 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 bg-gray-50/30 rounded-t-xl">
<div className="font-semibold text-gray-800 text-sm sm:text-base min-w-0 truncate max-w-full">{title}</div>
{/* 渲染右上角扩展内容(如:更多按钮、状态标签) */}
<div className="shrink-0">{extra}</div>
</div>
)}
{/* 内容插槽区域 */}
<div className="p-4 sm:p-6">{children}</div>
</div>
);
// --- Tag Component ---
// =========================================================
// 5. Tag Component - 语义化标签
// =========================================================
/**
*
*/
interface TagProps {
/** *
* @default 'blue'
*/
color?: 'blue' | 'green' | 'red' | 'orange' | 'gray';
/** 标签内容 */
children: ReactNode;
}
/**
*
* *
*
* @param {TagProps} props -
* @returns {JSX.Element}
* @component Tag
* @description
*
* - green: /
* - blue: /
* - red: /
* - orange: /
*/
export const Tag: React.FC<TagProps> = ({ color = 'blue', children }) => {
const colors = {
@ -365,49 +374,43 @@ export const Tag: React.FC<TagProps> = ({ color = 'blue', children }) => {
);
};
// --- ProgressBar Component ---
// =========================================================
// 6. ProgressBar Component - 进度反馈系统
// =========================================================
/**
*
* *
*
* @param {Object} props -
* @param {number} props.progress - (0-100)
* @returns {JSX.Element}
* @component ProgressBar
* @description
* 线 AI Schema DDL
*/
export const ProgressBar: React.FC<{ progress: number }> = ({ progress }) => {
return (
<div className="w-full bg-gray-100 rounded-full h-2 overflow-hidden shadow-inner">
{/* 内层填充条:利用 transition 实现物理惯性动画感 */}
<div
className="bg-primary h-full rounded-full transition-all duration-700 ease-out shadow-sm relative overflow-hidden"
style={{ width: `${Math.max(0, Math.min(100, progress))}%` }}
>
{/* 动态呼吸动效,增强“处理中”的视觉暗示 */}
<div className="absolute inset-0 bg-white/20 animate-pulse"></div>
</div>
</div>
);
};
// --- Steps Component ---
// =========================================================
// 7. Steps Component - 流程导航器
// =========================================================
/**
*
*/
interface StepItem {
/** 步骤标题 */
title: string;
/** 步骤描述可选当前UI未显示 */
description?: string;
}
/**
*
* *
*
* @param {Object} props -
* @param {StepItem[]} props.steps -
* @param {number} props.current - (0-based)
* @returns {JSX.Element}
* @component Steps
* @description
* ProjectWizard
*/
export const Steps: React.FC<{ steps: StepItem[]; current: number }> = ({ steps, current }) => {
return (
@ -418,21 +421,22 @@ export const Steps: React.FC<{ steps: StepItem[]; current: number }> = ({ steps,
return (
<div key={index} className="flex flex-col items-center relative flex-1 group">
{/* 连接线:除了第一个节点外,每个节点左侧都有连接线 */}
{/* 逻辑:渲染节点间的物理连接线。若当前节点已达标,则线段高亮。 */}
{index !== 0 && (
<div className="absolute top-5 right-[50%] w-full h-[2px] -z-10">
<div className={`h-full transition-colors duration-500 ${index <= current ? 'bg-primary' : 'bg-gray-200'}`}></div>
</div>
)}
{/* 步骤图标:完成态显示对号,进行/未进行态显示数字 */}
{/* 节点图标:完成态展示 Check 图标,否则展示索引数字。 */}
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold transition-all duration-500 z-10 border-2 shadow-sm ${isCompleted ? 'bg-primary border-primary text-white' :
isCurrent ? 'bg-white border-primary text-primary ring-4 ring-blue-50 scale-110' :
'bg-white border-gray-200 text-gray-400'
}`}>
{isCompleted ? <Check size={18} strokeWidth={3} /> : index + 1}
</div>
{/* 步骤标题 */}
{/* 节点描述文本 */}
<div className={`mt-3 text-sm font-medium transition-colors duration-300 ${isCurrent ? 'text-primary' :
isCompleted ? 'text-gray-800' :
'text-gray-400'
@ -446,42 +450,41 @@ export const Steps: React.FC<{ steps: StepItem[]; current: number }> = ({ steps,
);
};
// --- Modal Component ---
// =========================================================
// 8. Modal Component - 高级模态对话框
// =========================================================
/**
*
*/
interface ModalProps {
/** 是否显示模态框 */
isOpen: boolean;
/** 关闭模态框的回调函数 */
onClose: () => void;
/** 模态框标题 */
title: string;
/** 模态框内容 */
children: ReactNode;
/** 底部操作区内容(可选) */
footer?: ReactNode;
/** *
* @default 'max-w-md'
*/
maxWidth?: string;
}
/**
*
* *
* *
*
* @param {ModalProps} props -
* @returns {JSX.Element | null} isOpen false null
* @component Modal
* @description
*
* (backdrop-blur-sm) 200ms /退
*/
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, footer, maxWidth = 'max-w-md' }) => {
// 渲染守卫
if (!isOpen) return null;
return (
/**
*
* - bg-gray-900/60:
* - overflow-y-auto:
*/
<div className="fixed inset-0 z-50 flex items-start justify-center bg-gray-900/60 backdrop-blur-sm p-2 sm:p-4 transition-opacity duration-300 overflow-y-auto">
{/* 模态框主体:应用 zoom-in 动画效果 */}
<div className={`bg-white rounded-xl shadow-2xl w-full ${maxWidth} animate-in fade-in zoom-in-95 duration-200 flex flex-col my-auto shrink-0`}>
{/* Header */}
{/* 头部标题区域 */}
<div className="px-4 sm:px-8 py-4 sm:py-5 border-b border-gray-100 flex justify-between items-center shrink-0 bg-white gap-2 min-h-[64px] rounded-t-xl">
<h3 className="text-base sm:text-lg font-bold text-gray-800 tracking-tight truncate flex-1 min-w-0 flex items-center">{title}</h3>
<button
@ -492,11 +495,13 @@ export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children,
<X size={14} />
</button>
</div>
{/* Content */}
{/* 主内容插槽区 */}
<div className="p-4 sm:p-8 bg-white flex-1 min-h-0 overflow-hidden">
{children}
</div>
{/* Footer */}
{/* 底部操作区:通过 flex-wrap 适配小屏设备下的按钮堆叠 */}
{footer && (
<div className="px-4 sm:px-8 py-4 sm:py-5 bg-gray-50 border-t border-gray-100 flex flex-wrap justify-end gap-2 sm:gap-3 shrink-0 rounded-b-xl">
{footer}
@ -507,21 +512,43 @@ export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children,
);
};
// --- Toast/Message Component System ---
// =========================================================
// 9. Toast/Message System - 异步消息通知中心
// =========================================================
/**
*
*/
export type ToastType = 'success' | 'error' | 'info' | 'warning';
/**
*
*/
export interface ToastData {
/** 唯一的 UUID用于精准移除 DOM 节点 */
id: string;
type: ToastType;
content: string;
/** 自动销毁时长,单位毫秒 */
duration?: number;
}
// 简单的 Event Bus用于在 React 组件树之外(如 API Client触发 UI 更新
/**
* 线 (Observer Pattern)
* @class MessageBus
* @description
* React Axios UI
*
*/
class MessageBus {
// 观察者回调列表
private listeners: ((toast: ToastData) => void)[] = [];
/**
*
* @param {Function} listener -
* @returns {Function}
*/
subscribe(listener: (toast: ToastData) => void) {
this.listeners.push(listener);
return () => {
@ -529,22 +556,30 @@ class MessageBus {
};
}
/**
* 广
* @param {ToastType} type -
* @param {string} content -
* @param {number} [duration=3000] -
*/
emit(type: ToastType, content: string, duration = 3000) {
const toast: ToastData = {
id: Math.random().toString(36).substr(2, 9),
id: Math.random().toString(36).substr(2, 9), // 快速生成唯一随机 ID
type,
content,
duration
};
// 顺序触发所有已注册的活跃订阅者
this.listeners.forEach(l => l(toast));
}
}
/** 单例消息总线实例 */
const messageBus = new MessageBus();
/**
*
* : message.success("操作成功")
* (Public API)
* message.success("Success!")
*/
export const message = {
success: (content: string, duration?: number) => messageBus.emit('success', content, duration),
@ -554,14 +589,20 @@ export const message = {
};
/**
*
* App
*
*
* @component ToastContainer
* @description
* Root messageBus toasts
*/
export const ToastContainer: React.FC = () => {
// 管理内存中的活跃消息队列
const [toasts, setToasts] = useState<ToastData[]>([]);
useEffect(() => {
/**
*
* 广 (setTimeout)
*/
return messageBus.subscribe((toast) => {
setToasts(prev => [...prev, toast]);
if (toast.duration && toast.duration > 0) {
@ -572,33 +613,44 @@ export const ToastContainer: React.FC = () => {
});
}, []);
/**
*
* @param {string} id - ID
*/
const removeToast = (id: string) => {
setToasts(prev => prev.filter(t => t.id !== id));
};
return (
/**
*
* fixed inset-0 + flex items-center:
*/
<div className="fixed inset-0 z-[100] pointer-events-none flex items-center justify-center">
<div className="flex flex-col gap-3 max-w-md w-full mx-4">
{toasts.map(toast => (
<div
key={toast.id}
/** pointer-events-auto: 允许用户点击关闭按钮 */
className={`pointer-events-auto w-full p-4 rounded-lg shadow-xl border transform transition-all duration-300 animate-in zoom-in-95 fade-in bg-white flex items-center gap-3 min-h-[56px]
${toast.type === 'success' ? 'border-green-200 bg-green-50/90 backdrop-blur-sm' :
toast.type === 'error' ? 'border-red-200 bg-red-50/90 backdrop-blur-sm' :
toast.type === 'warning' ? 'border-orange-200 bg-orange-50/90 backdrop-blur-sm' : 'border-blue-200 bg-blue-50/90 backdrop-blur-sm'}`}
>
{/* Icon */}
{/* 状态图标:通过 type 属性分发 Lucide 语义图标 */}
<div className="shrink-0 flex items-center justify-center">
{toast.type === 'success' && <CheckCircle size={20} className="text-green-600" />}
{toast.type === 'error' && <AlertCircle size={20} className="text-red-600" />}
{toast.type === 'warning' && <AlertTriangle size={20} className="text-orange-600" />}
{toast.type === 'info' && <Info size={20} className="text-blue-600" />}
</div>
{/* Content */}
{/* 核心消息载荷区域:支持长文本自动折行 (break-words) */}
<div className="flex-1 text-sm text-gray-800 font-medium break-words leading-relaxed min-w-0 flex items-center">
{toast.content}
</div>
{/* Close */}
{/* 手动关闭触发器 */}
<button
onClick={() => removeToast(toast.id)}
className="text-gray-400 hover:text-gray-600 transition-colors shrink-0 rounded-full hover:bg-gray-100 w-6 h-6 flex items-center justify-center ml-2"
@ -612,5 +664,6 @@ export const ToastContainer: React.FC = () => {
</div>
);
};
// 导出 ConfirmDialog 组件
/** 统一导出业务关联组件 */
export { ConfirmDialog } from './ConfirmDialog';

@ -1,53 +1,87 @@
/**
* @file useViewportWidth.ts
* @module Hooks/Responsive-Layout
* @description
*
* *
* 1. xs/sm/md/lg/xl
* 2. (Debounce) resize
* 3. orientationchange
* *
* - Requirement 1.1:
* * @author Wang Lirong ()
* @version 2.2.0
* @date 2026-01-02
*/
import { useState, useEffect } from 'react';
/**
* Hook
*
*
*
*
* Requirements: 1.1
*
* @typedef {('xs' | 'sm' | 'md' | 'lg' | 'xl')} BreakpointId
* @description CSS Bootstrap/Tailwind
*/
export type BreakpointId = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
/**
*
* @interface ViewportWidthInfo
* @description
*/
export interface ViewportWidthInfo {
/** 当前视口宽度(像素) */
/** 当前视口的实时物理宽度(单位:像素 px */
width: number;
/** 当前视口高度(像素) */
/** 当前视口的实时物理高度(单位:像素 px */
height: number;
/** 当前断点标识 */
/** 当前所属的响应式断点标识,用于组件侧的条件分支逻辑 */
breakpoint: BreakpointId;
/** 断点检查函数 */
/** *
*
*/
is: {
xs: boolean; // < 480px
sm: boolean; // 480px - 768px
md: boolean; // 768px - 1024px
lg: boolean; // 1024px - 1440px
xl: boolean; // > 1440px
mobile: boolean; // < 768px
tablet: boolean; // 768px - 1024px
desktop: boolean; // > 1024px
/** 极小屏幕:通常指小屏手机 (width < 480px) */
xs: boolean;
/** 小屏幕:大屏手机或竖屏平板 (480px - 768px) */
sm: boolean;
/** 中等屏幕:横屏平板 (768px - 1024px) */
md: boolean;
/** 大屏幕:笔记本或小尺寸显示器 (1024px - 1440px) */
lg: boolean;
/** 超大屏幕2K/4K 专业显示器 (width > 1440px) */
xl: boolean;
/** 业务定义:移动端视图(合并 xs 与 sm */
mobile: boolean;
/** 业务定义:平板端视图(对应 md 档) */
tablet: boolean;
/** 业务定义:桌面端视图(对应 lg 及以上) */
desktop: boolean;
};
}
/**
*
*
*
* @constant BREAKPOINTS
* @description 稿 UI 线
*/
export const BREAKPOINTS = {
xs: 0, // 0px - 479px
sm: 480, // 480px - 767px
md: 768, // 768px - 1023px
lg: 1024, // 1024px - 1439px
xl: 1440, // 1440px+
/** 超小屏幕起始像素 */
xs: 0,
/** 小屏幕阈值 (480px) */
sm: 480,
/** 中等屏幕阈值 (768px) */
md: 768,
/** 大屏幕阈值 (1024px) */
lg: 1024,
/** 超大屏幕阈值 (1440px) */
xl: 1440,
} as const;
/**
*
*
* @param width
* @returns
*
*
* @function getBreakpointId
* @param {number} width -
* @returns {BreakpointId} ID
*/
function getBreakpointId(width: number): BreakpointId {
if (width < BREAKPOINTS.sm) return 'xs';
@ -58,34 +92,41 @@ function getBreakpointId(width: number): BreakpointId {
}
/**
*
*
* @param width
* @param breakpoint
* @returns
*
*
* @function createBreakpointChecks
* @param {number} width -
* @param {BreakpointId} breakpoint - ID
*/
function createBreakpointChecks(width: number, breakpoint: BreakpointId) {
return {
// 精确档位判定
xs: breakpoint === 'xs',
sm: breakpoint === 'sm',
md: breakpoint === 'md',
lg: breakpoint === 'lg',
xl: breakpoint === 'xl',
mobile: width < BREAKPOINTS.md, // < 768px
tablet: width >= BREAKPOINTS.md && width < BREAKPOINTS.lg, // 768px - 1023px
desktop: width >= BREAKPOINTS.lg, // >= 1024px
// 聚合业务档位判定
mobile: width < BREAKPOINTS.md, // 判定依据:宽度小于 768px 被视为广义移动端
tablet: width >= BREAKPOINTS.md && width < BREAKPOINTS.lg, // 判定依据768px 至 1023px
desktop: width >= BREAKPOINTS.lg, // 判定依据1024px 以上进入桌面级交互模式
};
}
/**
*
*
* @returns ViewportWidthInfo
*
* window 访
* @function calculateViewportInfo
* @returns {ViewportWidthInfo}
*/
function calculateViewportInfo(): ViewportWidthInfo {
// 获取浏览器视口的实时几何尺寸
const width = window.innerWidth;
const height = window.innerHeight;
// 识别当前宽度所属的档位
const breakpoint = getBreakpointId(width);
// 生成语义化布尔对象
const is = createBreakpointChecks(width, breakpoint);
return {
@ -97,27 +138,36 @@ function calculateViewportInfo(): ViewportWidthInfo {
}
/**
* Hook
*
*
*
*
* @param debounceMs 100ms
* @returns ViewportWidthInfo
* Hook
* @export @function useViewportWidth
* @description
*
* (Debounce) React
* (Reflow)
* * @param {number} [debounceMs=100] -
* @returns {ViewportWidthInfo}
*/
export function useViewportWidth(debounceMs: number = 100): ViewportWidthInfo {
/**
* 1
* 使 (SSR)
*/
const [viewportInfo, setViewportInfo] = useState<ViewportWidthInfo>(() => calculateViewportInfo());
useEffect(() => {
// 引用定时器 ID用于防抖逻辑的清理
let timeoutId: NodeJS.Timeout | null = null;
/** 窗口缩放事件的回调封装 */
const updateViewportInfo = () => {
// 清除之前的防抖定时器
// 步骤 2防抖逻辑介入。
// 若在 debounceMs 毫秒内再次触发 resize则撤销之前的更新任务。
if (timeoutId) {
clearTimeout(timeoutId);
}
// 设置新的防抖定时器
// 步骤 3设定延时更新任务。
// 仅在浏览器停止缩放动作后指定的毫秒数,才正式计算视口信息。
timeoutId = setTimeout(() => {
const newViewportInfo = calculateViewportInfo();
setViewportInfo(newViewportInfo);
@ -125,34 +175,35 @@ export function useViewportWidth(debounceMs: number = 100): ViewportWidthInfo {
}, debounceMs);
};
// 监听窗口大小变化
/**
* 4
* resize orientationchange /
*/
window.addEventListener('resize', updateViewportInfo);
// 监听方向变化(移动设备)
window.addEventListener('orientationchange', updateViewportInfo);
// 步骤 5Effect 清理阶段。
// 销毁监听器并清除未执行的定时器,防止内存泄漏。
return () => {
window.removeEventListener('resize', updateViewportInfo);
window.removeEventListener('orientationchange', updateViewportInfo);
// 清理防抖定时器
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [debounceMs]);
}, [debounceMs]); // 依赖项:仅当用户改变防抖策略时重新绑定
return viewportInfo;
}
/**
* Hook
*
*
*
*
* @param debounceMs 100ms
* @returns
* Hook
* @export @function useViewportWidthOnly
* @description
* Canvas
* @param {number} [debounceMs=100]
* @returns {number}
*/
export function useViewportWidthOnly(debounceMs: number = 100): number {
const { width } = useViewportWidth(debounceMs);
@ -160,24 +211,27 @@ export function useViewportWidthOnly(debounceMs: number = 100): number {
}
/**
* Hook
*
*
*
* @param breakpoint
* @param debounceMs 100ms
* @returns
* Hook (Conditional Matching)
* @export @function useBreakpointMatch
* @description
*
* @param {(BreakpointId | BreakpointId[] | 'mobile' | 'tablet' | 'desktop')} breakpoint -
* @param {number} [debounceMs=100]
* @returns {boolean}
*/
export function useBreakpointMatch(
breakpoint: BreakpointId | BreakpointId[] | 'mobile' | 'tablet' | 'desktop',
debounceMs: number = 100
): boolean {
// 从核心 Hook 中提取语义判定对象
const { is } = useViewportWidth(debounceMs);
// 分支逻辑:处理断点数组(如:['xs', 'sm'] 代表“是否为移动端”)
if (Array.isArray(breakpoint)) {
return breakpoint.some(bp => is[bp]);
}
// 默认逻辑:单一档位匹配
return is[breakpoint as keyof typeof is];
}

@ -1,96 +1,173 @@
/**
* @file AdminPanel.tsx
* @module Pages/Administration/User-Management
* @description -
* AutoDB
* * *
* 1. (SF11):
* 2. (Security-4): /
* 3. :
* 4. : mapToUser DTO
* * *
* - Requirement 11.1:
* - Requirement 11.2: //
* * @author Wang Lirong ()
* @version 2.2.0
* @date 2026-01-02
*/
import React, { useState, useEffect } from 'react';
import { User, UserRole, UserStatus, AdminUserListItem } from '../types.ts';
import { Card, Button, Tag, Modal, Input, message, Select } from '../components/UI.tsx';
import { Search, Ban, CheckCircle, Users, Eye, Database, Edit3, Loader2, Filter, AlertTriangle } from 'lucide-react';
import { adminApi } from '../api/admin.ts';
/**
*
* @interface AdminPanelProps
* @property {Function} onViewUser - /
*/
interface AdminPanelProps {
onViewUser: (user: User) => void;
}
/**
* -
* * API (GET /users, PATCH /status, PATCH /quota)
* @component AdminPanel
* @description
* React
*
*/
export const AdminPanel: React.FC<AdminPanelProps> = ({ onViewUser }) => {
// --- 状态管理 ---
// --- 核心数据集状态 (Data States) ---
/** 存储当前分页下的用户列表快照 */
const [users, setUsers] = useState<AdminUserListItem[]>([]);
/** 页面级全局加载锁 */
const [isLoading, setIsLoading] = useState(false);
// 过滤与分页
// --- 过滤、搜索与分页控制 (Context States) ---
/** 检索关键字缓冲区 */
const [searchTerm, setSearchTerm] = useState('');
/** 状态漏斗:支持全量、正常、挂起、封禁四维度过滤 */
const [statusFilter, setStatusFilter] = useState<'all' | 'normal' | 'suspended' | 'banned'>('all');
/** 分页器:当前激活页码 */
const [page, setPage] = useState(1);
/** 后端符合过滤条件的总条目数,用于驱动分页 UI 计算 */
const [total, setTotal] = useState(0);
/** 业务规范:固定单页容量为 10 条 */
const pageSize = 10;
// 配额编辑状态
// --- 资源配额治理状态 (Quota Management) ---
/** 当前正在进行配额编辑的目标用户引用 */
const [editingQuotaUser, setEditingQuotaUser] = useState<AdminUserListItem | null>(null);
/** 配额输入框实时值 */
const [newQuota, setNewQuota] = useState('');
/** 针对配额输入的即时校验错误信息 */
const [quotaError, setQuotaError] = useState('');
/** 提交配额变更时的异步状态锁 */
const [isSavingQuota, setIsSavingQuota] = useState(false);
// 状态修改 (封禁/解封) 弹窗状态
// --- 账号安全干预状态 (Status & Risk Control) ---
/** 封禁/解封事务弹窗的显隐状态 */
const [isStatusModalOpen, setIsStatusModalOpen] = useState(false);
/** 状态变更事务的目标用户快照 */
const [statusTargetUser, setStatusTargetUser] = useState<AdminUserListItem | null>(null);
const [targetStatusAction, setTargetStatusAction] = useState<'normal' | 'banned'>('normal'); // 目标状态
/** 目标操作倾向normal 代表恢复正常/解封banned 代表强制封禁 */
const [targetStatusAction, setTargetStatusAction] = useState<'normal' | 'banned'>('normal');
/** 审计理由字段:记录操作原因以备日后安全审计 */
const [statusReason, setStatusReason] = useState('');
/** 状态变更请求的异步处理锁 */
const [isSavingStatus, setIsSavingStatus] = useState(false);
// --- 数据获取 ---
/**
*
* @async @function fetchUsers
* @description
* SF11
* 1.
* 2. adminApi.getUsers
* 3. ID
* 4.
*/
const fetchUsers = async () => {
setIsLoading(true);
try {
// 发起异步请求,透传分页与搜索权重参数
const res = await adminApi.getUsers(page, pageSize, searchTerm || undefined, statusFilter);
// 需求:用户列表按 id 升序排列
/**
* UI
*
*/
const sortedItems = res.items.sort((a, b) => a.user_id - b.user_id);
setUsers(sortedItems);
setTotal(res.total);
} catch (error) {
// 异常路径:记录日志并可通过全局错误拦截器抛出 UI 提示
console.error("Fetch users failed", error);
} finally {
// 释放加载锁,恢复 UI 交互
setIsLoading(false);
}
};
// 监听筛选条件变化
/**
*
*
*/
useEffect(() => {
fetchUsers();
}, [page, statusFilter]);
/**
*
* @param {React.KeyboardEvent} e -
*/
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
// 监听 Enter 键,执行非实时搜索以降低 API 峰值负载
if (e.key === 'Enter') {
setPage(1);
setPage(1); // 搜索条件变化时,强制重置至第一页
fetchUsers();
}
};
// --- 业务逻辑处理 ---
// --- 业务事务处理集 (Business Transaction Handlers) ---
/**
*
*
* @param {AdminUserListItem} user -
* @param {'normal' | 'banned'} action -
*/
const handleOpenStatusModal = (user: AdminUserListItem, action: 'normal' | 'banned') => {
setStatusTargetUser(user);
setTargetStatusAction(action);
setStatusReason(''); // 重置原因输入
setStatusReason(''); // 事务初始化:强制清空审计原因缓冲区
setIsStatusModalOpen(true);
};
/**
* (//)
*
* @async @function handleConfirmStatusChange
* @description
*
* 1.
* 2. API
* 3. UI Optimistic Update
* 4.
*/
const handleConfirmStatusChange = async () => {
if (!statusTargetUser) return;
// 根据动作方向计算语义化描述
const targetStatus = targetStatusAction;
const actionText = targetStatus === 'normal' ?
(statusTargetUser.status === 'banned' ? '解封' : '恢复正常') :
'封禁';
// 简单的必填校验
/**
*
*
*/
if (!statusReason.trim()) {
message.error(`请输入${actionText}原因`);
return;
@ -98,45 +175,58 @@ export const AdminPanel: React.FC<AdminPanelProps> = ({ onViewUser }) => {
setIsSavingStatus(true);
try {
// 调用管理端专用更新接口
await adminApi.updateUserStatus(statusTargetUser.user_id, {
status: targetStatus,
reason: statusReason
});
// 业务层反馈
message.success(`用户已${actionText}`);
// 乐观更新 UI
/**
* UI
*
*/
setUsers(prev => prev.map(u => u.user_id === statusTargetUser.user_id ? { ...u, status: targetStatus } : u));
// 关闭弹窗
// 事务完成:销毁所有临时上下文
setIsStatusModalOpen(false);
setStatusTargetUser(null);
} catch (error) {
console.error(error);
console.error("Status update failed", error);
} finally {
setIsSavingStatus(false);
}
};
/**
*
*
* @param {AdminUserListItem} user -
*/
const handleOpenQuotaModal = (user: AdminUserListItem) => {
setEditingQuotaUser(user);
setNewQuota(user.max_databases.toString());
setQuotaError('');
setQuotaError(''); // 重置错误信号
};
/**
*
*
* @async @function handleSaveQuota
* @description
*
* Max: 1000
*/
const handleSaveQuota = async () => {
if (!editingQuotaUser) return;
const quota = parseInt(newQuota);
// 数据合法性预检
if (isNaN(quota) || quota < 0) {
setQuotaError('额度不能为负数');
return;
}
// 系统级策略保护:单用户最大数据库数不得超过 1000
if (quota > 1000) {
setQuotaError('额度值超出系统上限 (1000)');
return;
@ -144,41 +234,57 @@ export const AdminPanel: React.FC<AdminPanelProps> = ({ onViewUser }) => {
setIsSavingQuota(true);
try {
// 执行配额写入操作
await adminApi.updateUserQuota(editingQuotaUser.user_id, quota);
message.success('额度更新成功');
// 同步本地状态,刷新“项目数/额度”列的展示
setUsers(prev => prev.map(u => u.user_id === editingQuotaUser.user_id ? { ...u, max_databases: quota } : u));
setEditingQuotaUser(null);
setEditingQuotaUser(null); // 事务关闭
} catch (error) {
console.error(error);
console.error("Quota update failed", error);
} finally {
setIsSavingQuota(false);
}
};
// 辅助:将 AdminUserListItem 转换为 User 对象以适配 UserProfile 组件
/**
* (Adapter Pattern)
* @function mapToUser
* @description
* AdminUserListItem User
* user_idid
* @param {AdminUserListItem} item - DTO
* @returns {User} User
*/
const mapToUser = (item: AdminUserListItem): User => ({
id: item.user_id.toString(),
username: item.username,
email: item.email,
role: UserRole.USER, // 列表默认视为普通用户,详情页可细化
role: UserRole.USER, // 管理员视角下默认作为普通用户处理
status: item.status === 'normal' ? UserStatus.NORMAL : UserStatus.BANNED,
// 时间戳本地化处理:针对从未登录的用户进行语义化兜底
lastLogin: item.last_login_at ? new Date(item.last_login_at).toLocaleString() : '从未登录',
projectQuota: item.max_databases
});
/**
*
*/
return (
<div className="p-8 max-w-7xl mx-auto h-full overflow-y-auto">
{/* 顶部标题与操作栏 */}
{/* 步骤 1顶部状态区 - 包含标题及响应式检索组件 */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
<h2 className="text-2xl font-bold text-gray-800 flex items-center gap-3">
<div className="p-2 bg-blue-100 text-blue-600 rounded-lg shrink-0">
<Users size={20} />
</div>
</h2>
<div className="flex gap-2 w-full md:w-auto">
{/* 状态筛选 */}
{/* 过滤器组:状态筛选下行菜单 */}
<div className="flex items-center gap-2 bg-white px-3 rounded-lg border border-gray-300 shadow-sm h-[38px] hover:border-gray-400 transition-colors">
<Filter size={16} className="text-gray-400" />
<Select
@ -195,7 +301,7 @@ export const AdminPanel: React.FC<AdminPanelProps> = ({ onViewUser }) => {
/>
</div>
{/* 搜索 */}
{/* 搜索控制:全局关键字搜索 */}
<div className="relative flex-1 md:w-64">
<input
type="text"
@ -210,63 +316,72 @@ export const AdminPanel: React.FC<AdminPanelProps> = ({ onViewUser }) => {
</div>
</div>
{/* 用户列表表格 */}
{/* 步骤 2数据展示区 - 响应式表格设计 */}
<Card className="overflow-hidden p-0 min-h-[400px]">
{isLoading ? (
/** 加载态:采用 Loader 动画替代空列表 */
<div className="flex justify-center items-center h-64">
<Loader2 className="animate-spin text-primary" size={32} />
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm min-w-[1000px]">
{/* 表头定义:声明各列业务语义 */}
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-4 font-medium text-gray-600 whitespace-nowrap">ID</th>
<th className="px-6 py-4 font-medium text-gray-600 whitespace-nowrap"></th>
<th className="px-6 py-4 font-medium text-gray-600 whitespace-nowrap"></th>
<th className="px-6 py-4 font-medium text-gray-600 whitespace-nowrap"></th>
<th className="px-6 py-4 font-medium text-gray-600 whitespace-nowrap"></th>
<th className="px-6 py-4 font-medium text-gray-600 whitespace-nowrap">/</th>
<th className="px-6 py-4 font-medium text-gray-600 whitespace-nowrap"></th>
<th className="px-6 py-4 font-medium text-gray-600 text-right whitespace-nowrap"></th>
<th className="px-6 py-4 font-medium text-gray-600 whitespace-nowrap">访</th>
<th className="px-6 py-4 font-medium text-gray-600 text-right whitespace-nowrap"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{users.map(user => (
<tr key={user.user_id} className="hover:bg-gray-50/50">
<tr key={user.user_id} className="hover:bg-gray-50/50 transition-colors">
<td className="px-6 py-4 text-gray-500 font-mono whitespace-nowrap">{user.user_id}</td>
<td className="px-6 py-4 font-medium text-gray-800 whitespace-nowrap">{user.username}</td>
<td className="px-6 py-4 text-gray-600 whitespace-nowrap">{user.email}</td>
<td className="px-6 py-4 whitespace-nowrap">
{/* 状态标签:通过色彩语义强化风险识别 */}
<Tag color={user.status === 'normal' ? 'green' : user.status === 'banned' ? 'red' : 'orange'}>
{user.status === 'normal' ? '正常' : user.status === 'banned' ? '封禁' : '异常'}
{user.status === 'normal' ? '正常' : user.status === 'banned' ? '封禁' : '异常活跃'}
</Tag>
</td>
{/* 额度列 */}
<td className="px-6 py-4 whitespace-nowrap">
{/* 额度交互区:点击触发向导修改 */}
<div
className="flex items-center gap-2 group cursor-pointer"
onClick={() => handleOpenQuotaModal(user)}
title="点击修改配额"
title="点击调整资源上限"
>
<span className="font-mono">{user.project_count} / {user.max_databases}</span>
<span className="font-mono text-blue-600 bg-blue-50 px-2 py-0.5 rounded">
{user.project_count} / {user.max_databases}
</span>
<Edit3 size={12} className="text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</td>
<td className="px-6 py-4 text-gray-500 whitespace-nowrap">
{user.last_login_at ? new Date(user.last_login_at).toLocaleString() : '-'}
</td>
<td className="px-6 py-4 text-right whitespace-nowrap">
<div className="flex justify-end items-center gap-2">
{/* 动作 A查看全维视图 */}
<Button
variant="text"
className="h-8 px-2 text-gray-500 hover:text-primary"
onClick={() => onViewUser(mapToUser(user))}
title="查看详情"
title="查看全量档案"
>
<Eye size={16} />
</Button>
{/* 正常用户:显示封禁按钮 */}
{/* 策略逻辑:针对不同状态渲染差异化的干预按钮 */}
{user.status === 'normal' && (
<Button
variant="danger"
@ -274,11 +389,10 @@ export const AdminPanel: React.FC<AdminPanelProps> = ({ onViewUser }) => {
onClick={() => handleOpenStatusModal(user, 'banned')}
icon={<Ban size={12} />}
>
</Button>
)}
{/* 已封禁用户:显示解封按钮 */}
{user.status === 'banned' && (
<Button
variant="default"
@ -286,13 +400,12 @@ export const AdminPanel: React.FC<AdminPanelProps> = ({ onViewUser }) => {
onClick={() => handleOpenStatusModal(user, 'normal')}
icon={<CheckCircle size={12} />}
>
</Button>
)}
{/* 异常用户:显示恢复和封禁两个按钮 */}
{user.status === 'suspended' && (
<>
<div className="flex gap-1">
<Button
variant="default"
className="h-8 px-3 text-xs border-green-200 text-green-600 hover:bg-green-50"
@ -309,15 +422,21 @@ export const AdminPanel: React.FC<AdminPanelProps> = ({ onViewUser }) => {
>
</Button>
</>
</div>
)}
</div>
</td>
</tr>
))}
{users.length === 0 && (
{/* 空态兜底 */}
{users.length === 0 && !isLoading && (
<tr>
<td colSpan={7} className="text-center py-12 text-gray-500"></td>
<td colSpan={7} className="text-center py-12 text-gray-500">
<div className="flex flex-col items-center">
<AlertTriangle size={32} className="text-gray-300 mb-2" />
</div>
</td>
</tr>
)}
</tbody>
@ -325,11 +444,11 @@ export const AdminPanel: React.FC<AdminPanelProps> = ({ onViewUser }) => {
</div>
)}
{/* 分页器 */}
{/* 步骤 3数据辅助层 - 分页控制 */}
{total > 0 && (
<div className="flex justify-between items-center px-6 py-4 border-t border-gray-100 bg-gray-50/50">
<span className="text-xs text-gray-500">
{total} {page}
<span className="text-xs text-gray-500 font-medium">
{total} (Page: {page})
</span>
<div className="flex gap-2">
<Button variant="default" className="h-8 px-3 text-xs" disabled={page <= 1} onClick={() => setPage(p => p - 1)}></Button>
@ -339,73 +458,74 @@ export const AdminPanel: React.FC<AdminPanelProps> = ({ onViewUser }) => {
)}
</Card>
{/* 配额修改模态框 */}
{/* 事务模块 A配额调整对话框 */}
<Modal
isOpen={!!editingQuotaUser}
onClose={() => setEditingQuotaUser(null)}
title="修改用户数据库额度"
title="物理资源限制参数调整"
maxWidth="max-w-sm"
footer={
<>
<div className="flex justify-end gap-2">
<Button onClick={() => setEditingQuotaUser(null)}></Button>
<Button variant="primary" onClick={handleSaveQuota} disabled={isSavingQuota}>
{isSavingQuota ? '保存中...' : '保存更改'}
{isSavingQuota ? '正在同步云端...' : '更新配置'}
</Button>
</>
</div>
}
>
<div className="space-y-4">
<div className="bg-blue-50 p-3 rounded-lg flex items-start gap-3">
<div className="bg-blue-50 p-3 rounded-lg flex items-start gap-3 border border-blue-100">
<Database className="text-primary shrink-0 mt-0.5" size={18} />
<div className="text-sm text-blue-900">
<p> <strong>{editingQuotaUser?.username}</strong> </p>
<div className="text-xs text-blue-900 leading-relaxed">
<p> <strong>{editingQuotaUser?.username}</strong> </p>
<p className="mt-1 opacity-70"></p>
</div>
</div>
<div>
<Input
label="新额度上限"
label="最大项目允许数 (Max Databases)"
type="number"
value={newQuota}
onChange={(e) => setNewQuota(e.target.value)}
placeholder="请输入整数"
placeholder="请输入配额整数"
min={0}
required
/>
{quotaError && <p className="text-red-500 text-xs mt-1">{quotaError}</p>}
{quotaError && <p className="text-red-500 text-[11px] mt-1 font-medium">{quotaError}</p>}
</div>
</div>
</Modal>
{/* 状态修改 (封禁/解封) 模态框 */}
{/* 事务模块 B账户干预确认对话框 (Security Critical) */}
<Modal
isOpen={isStatusModalOpen}
onClose={() => setIsStatusModalOpen(false)}
title={
targetStatusAction === 'normal' ?
(statusTargetUser?.status === 'banned' ? '解封用户账号' : '恢复用户为正常状态') :
'封禁用户账号'
(statusTargetUser?.status === 'banned' ? '撤销账户封禁' : '恢复正常服务') :
'执行账户封禁审计'
}
maxWidth="max-w-md"
footer={
<>
<Button onClick={() => setIsStatusModalOpen(false)}></Button>
<div className="flex justify-end gap-2">
<Button onClick={() => setIsStatusModalOpen(false)}></Button>
<Button
variant={targetStatusAction === 'banned' ? 'danger' : 'primary'}
onClick={handleConfirmStatusChange}
disabled={isSavingStatus}
>
{isSavingStatus ? '处理中...' : (
{isSavingStatus ? '正在下发指令...' : (
targetStatusAction === 'normal' ?
(statusTargetUser?.status === 'banned' ? '确认解封' : '确认恢复正常') :
'确认封禁'
(statusTargetUser?.status === 'banned' ? '确认撤销' : '确认恢复') :
'立即执行封禁'
)}
</Button>
</>
</div>
}
>
<div className="space-y-4">
{/* 警告提示 */}
{/* 风险告知面板 */}
<div className={`p-4 rounded-lg flex items-start gap-3 border ${targetStatusAction === 'banned' ? 'bg-red-50 border-red-100 text-red-800' :
statusTargetUser?.status === 'banned' ? 'bg-green-50 border-green-100 text-green-800' :
'bg-blue-50 border-blue-100 text-blue-800'
@ -413,39 +533,36 @@ export const AdminPanel: React.FC<AdminPanelProps> = ({ onViewUser }) => {
<AlertTriangle size={18} className="mt-0.5 shrink-0" />
<div className="text-sm">
<p className="font-bold mb-1">
{targetStatusAction === 'banned' ? '高风险操作' :
statusTargetUser?.status === 'banned' ? '解封操作' :
'恢复正常状态'}
</p>
<p>
<strong>{statusTargetUser?.username}</strong>
{targetStatusAction === 'banned' ? '封禁' :
statusTargetUser?.status === 'banned' ? '解封' :
'恢复正常'}
{targetStatusAction === 'banned' ? '封禁后该用户将无法登录系统或使用任何API服务。' :
statusTargetUser?.status === 'banned' ? '解封后用户将恢复正常权限。' :
'恢复后用户将变为正常状态,移除异常标记。'}
{targetStatusAction === 'banned' ? '安全红线警示' :
statusTargetUser?.status === 'banned' ? '服务恢复确认' :
'状态正常化处理'}
</p>
<div className="opacity-90 leading-relaxed text-xs">
<strong>{statusTargetUser?.username}</strong>
{targetStatusAction === 'banned' ?
'封禁后,该用户的所有活跃 Agent 任务将立即挂起API 访问令牌将永久失效。' :
'操作后,用户将重新获得系统登录及 Agent 协作权限。'}
</div>
</div>
</div>
{/* 原因输入 */}
{/* 强制审计原因录入 */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">
{targetStatusAction === 'banned' ? '封禁原因' :
statusTargetUser?.status === 'banned' ? '解封原因' :
'恢复原因'} <span className="text-red-500">*</span>
<label className="text-sm font-semibold text-gray-700 flex justify-between">
<span> (Audit Reason)</span>
<span className="text-red-500 font-normal"></span>
</label>
<textarea
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary outline-none transition-colors min-h-[100px] resize-none"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary outline-none transition-all min-h-[100px] resize-none placeholder:text-gray-300"
placeholder={
targetStatusAction === 'banned' ? "请输入违规行为或封禁理由..." :
statusTargetUser?.status === 'banned' ? "请输入解封理由或申诉处理结果..." :
"请输入恢复正常的原因(如:确认为误判、用户行为已恢复正常等)..."
targetStatusAction === 'banned' ? "请详细描述违规证据或封禁理由..." :
"请输入恢复服务的原因(如:误判处理、风险解除等)..."
}
value={statusReason}
onChange={(e) => setStatusReason(e.target.value)}
/>
<p className="text-[10px] text-gray-400 italic"></p>
</div>
</div>
</Modal>

@ -1,3 +1,21 @@
/**
* @file AdminAIModels.tsx
* @module Pages/Administration/AI-Infrastructure
* @description AI
* LLMs
* * *
* 1. : FinetunedSF12
* 2. (Connectivity Audit): API Response Time
* 3. : API Key
* 4. : Options
* * *
* - adminApi (Wang Lirong )
* - Lucide-react
* * @author Wang Lirong ()
* @version 2.3.0
* @date 2026-01-02
*/
import React, { useState, useEffect } from 'react';
import { AIModelConfigResponse, AIModelConfigDetailResponse, AIModelConfigCreate, AIModelConfigUpdate } from '../types.ts';
import { Card, Button, Tag, Modal, Input, message, Select } from '../components/UI.tsx';
@ -5,48 +23,66 @@ import { Bot, Plus, Edit3, Trash2, Loader2, RefreshCw, Eye, EyeOff, Zap, CheckCi
import { adminApi } from '../api/admin.ts';
/**
* AI
* AI
* @component AdminAIModels
* @description
* React AI CRUD
* URL Regex Validation
*/
export const AdminAIModels: React.FC = () => {
// --- 状态管理 ---
// --- 1. 数据驱动状态 (Data-Driven States) ---
/** 存储当前全量在线的模型配置快照 */
const [models, setModels] = useState<AIModelConfigResponse[]>([]);
/** 局部加载锁:用于处理列表刷新时的视觉反馈 */
const [isLoading, setIsLoading] = useState(false);
/** 分页控制状态机 */
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const pageSize = 10;
// 创建/编辑模态框状态
// --- 2. 事务流控制状态 (Workflow States) ---
/** 配置模态框显隐开关 */
const [isModalOpen, setIsModalOpen] = useState(false);
/** 存储当前正处于编辑生命周期的详情对象。若为 null 则标识为“创建”模式 */
const [editingModel, setEditingModel] = useState<AIModelConfigDetailResponse | null>(null);
/** 提交配置至后端时的异步状态锁 */
const [isSaving, setIsSaving] = useState(false);
// 表单数据
// --- 3. 表单载荷与校验状态 (Form & Validation) ---
/** 严格对应 AIModelConfigCreate 定义的表单缓冲区 */
const [formData, setFormData] = useState<AIModelConfigCreate>({
model_name: '',
api_url: '',
model_id: '',
api_key: '',
model_type: 'general_llm'
model_type: 'general_llm' // 缺省默认为通用大模型类型
});
/** API 密钥可视化开关 */
const [showApiKey, setShowApiKey] = useState(false);
// 表单校验错误
/** 针对特定字段的深度验证错误信息采集器 */
const [formErrors, setFormErrors] = useState<{ api_url?: string }>({});
// 测试连接状态
// --- 4. 基础设施健康度检测状态 (Infrastructure Healthcheck) ---
/** 标识是否正在执行 API 握手测试 */
const [isTesting, setIsTesting] = useState(false);
/** 存储连接测试后的多维反馈结果,包含时延及错误信息 */
const [testResult, setTestResult] = useState<{
success: boolean;
message: string;
response_time_ms?: number;
} | null>(null);
// 删除确认
// --- 5. 危险操作缓冲状态 (Risk Control) ---
/** 待执行物理删除的目标对象引用 */
const [deleteTarget, setDeleteTarget] = useState<AIModelConfigResponse | null>(null);
/** 删除过程中的异步加载标识 */
const [isDeleting, setIsDeleting] = useState(false);
// --- 数据获取 ---
/**
*
* @async @function fetchModels
* @description
* API
*/
const fetchModels = async () => {
setIsLoading(true);
try {
@ -61,11 +97,20 @@ export const AdminAIModels: React.FC = () => {
}
};
/**
*
*/
useEffect(() => {
fetchModels();
}, [page]);
// --- URL 校验 ---
/**
* URL
* @description
* API Endpoint HTTP/HTTPS
* @param {string} url - URL
* @returns {string|undefined}
*/
const validateUrl = (url: string): string | undefined => {
const trimmed = url.trim();
if (!trimmed) return '请输入 API 地址';
@ -80,15 +125,22 @@ export const AdminAIModels: React.FC = () => {
return undefined;
};
// --- URL 输入变化 ---
/**
* API
*
*/
const handleApiUrlChange = (value: string) => {
const error = validateUrl(value);
setFormData(prev => ({ ...prev, api_url: value }));
setTestResult(null);
setTestResult(null); // 地址变更后,历史测试结果失效
setFormErrors(prev => ({ ...prev, api_url: error }));
};
// --- 业务逻辑 ---
// --- 事务交互逻辑集 (Interaction Logic) ---
/**
*
*/
const handleOpenCreateModal = () => {
setEditingModel(null);
setFormData({
@ -104,10 +156,15 @@ export const AdminAIModels: React.FC = () => {
setIsModalOpen(true);
};
/**
*
*
*/
const handleOpenEditModal = async (model: AIModelConfigResponse) => {
try {
const detail = await adminApi.getAIModelDetail(model.config_id);
setEditingModel(detail);
// 初始化表单,注意 api_key 默认设为空,采取“不输入即不修改”的策略
setFormData({
model_name: detail.model_name,
api_url: detail.api_url,
@ -125,22 +182,33 @@ export const AdminAIModels: React.FC = () => {
}
};
// --- 测试连接 ---
/**
* API
* @async @function handleTestConnection
* @description
* Agent Ping
*
* 1. URL
* 2. API Key
* 3.
*/
const handleTestConnection = async () => {
// 步骤 1合规性预检
const urlError = validateUrl(formData.api_url);
if (urlError) {
setFormErrors({ api_url: urlError });
return;
}
// 编辑模式下,如果使用已保存的密钥且没有输入新密钥,则使用 config_id 进行测试
const hasNewApiKey = formData.api_key.trim().length > 0;
// 步骤 2安全边界校验
if (!editingModel && !hasNewApiKey) {
message.error('请先输入 API 密钥再测试连接');
return;
}
// 密钥格式正则:匹配 sk- (OpenAI), ms- (DashScope), ak- (Baidu) 等标准
if (hasNewApiKey && !/^(sk-|ms-|ak-)[A-Za-z0-9\-]{10,}/i.test(formData.api_key.trim())) {
message.error('API 密钥格式不正确,需以 sk-/ms-/ak- 开头');
return;
@ -155,22 +223,27 @@ export const AdminAIModels: React.FC = () => {
setTestResult(null);
try {
let res;
/**
*
* A
* B
*/
if (editingModel && !hasNewApiKey) {
// 编辑模式下使用已保存的密钥测试
res = await adminApi.testAIModelConnection({
api_url: formData.api_url,
model_id: formData.model_id,
config_id: editingModel.config_id // 传递 config_id 让后端使用已保存的密钥
config_id: editingModel.config_id
});
} else {
// 新建模式或输入了新密钥
res = await adminApi.testAIModelConnection({
api_url: formData.api_url,
api_key: formData.api_key,
model_id: formData.model_id
});
}
setTestResult(res);
// 实时反馈时延性能数据
if (res.success) {
message.success(`连接成功!响应时间: ${res.response_time_ms}ms`);
} else {
@ -185,10 +258,16 @@ export const AdminAIModels: React.FC = () => {
}
};
// --- 保存配置 ---
/**
*
* @async @function handleSave
* @description
* handleTestConnection
*/
const handleSave = async () => {
setFormErrors({});
// 基础非空校验
if (!formData.model_name.trim()) {
message.error('请输入模型名称');
return;
@ -205,13 +284,11 @@ export const AdminAIModels: React.FC = () => {
return;
}
// 新建模式必须输入密钥
if (!editingModel && !formData.api_key.trim()) {
message.error('请输入 API 密钥');
return;
}
// 如果输入了新密钥,校验格式
if (formData.api_key.trim()) {
const key = formData.api_key.trim();
if (!/^(sk-|ms-|ak-)[A-Za-z0-9\-]{10,}/i.test(key)) {
@ -220,6 +297,10 @@ export const AdminAIModels: React.FC = () => {
}
}
/**
*
*
*/
if (!testResult || !testResult.success) {
message.error('请先点击"测试连接"并确保通过后再保存');
return;
@ -228,6 +309,7 @@ export const AdminAIModels: React.FC = () => {
setIsSaving(true);
try {
if (editingModel) {
// 构造更新数据载荷,实现 API Key 的增量更新
const updateData: AIModelConfigUpdate = {
model_name: formData.model_name,
api_url: formData.api_url,
@ -240,11 +322,12 @@ export const AdminAIModels: React.FC = () => {
await adminApi.updateAIModel(editingModel.config_id, updateData);
message.success('模型配置已更新');
} else {
// 新建配置全量提交
await adminApi.createAIModel(formData);
message.success('模型配置已创建');
}
setIsModalOpen(false);
fetchModels();
fetchModels(); // 乐观刷新列表
} catch (error: any) {
console.error("Save model failed", error);
message.error(error?.message || '保存失败');
@ -253,7 +336,10 @@ export const AdminAIModels: React.FC = () => {
}
};
// --- 删除配置 ---
/**
*
* API
*/
const handleDelete = async () => {
if (!deleteTarget) return;
@ -271,15 +357,18 @@ export const AdminAIModels: React.FC = () => {
}
};
/**
*
*/
return (
<div className="p-8 max-w-7xl mx-auto h-full overflow-y-auto">
{/* 顶部标题与操作栏 */}
{/* 步骤 1头部品牌与动作栏 */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
<h2 className="text-2xl font-bold text-gray-800 flex items-center gap-3">
<div className="p-2 bg-blue-100 text-blue-600 rounded-lg shrink-0">
<Bot size={20} />
</div>
</h2>
<div className="flex gap-2">
@ -297,12 +386,14 @@ export const AdminAIModels: React.FC = () => {
onClick={handleOpenCreateModal}
icon={<Plus size={14} />}
>
</Button>
</div>
</div>
{/* 模型列表 */}
{/* 2
table-fixed truncate API
*/}
<Card className="overflow-hidden p-0 min-h-[400px]">
{isLoading ? (
<div className="flex justify-center items-center h-64">
@ -313,22 +404,23 @@ export const AdminAIModels: React.FC = () => {
<table className="w-full text-left text-sm table-fixed">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-4 py-4 font-medium text-gray-600 whitespace-nowrap w-[15%]"></th>
<th className="px-4 py-4 font-medium text-gray-600 whitespace-nowrap w-[18%]"> ID</th>
<th className="px-4 py-4 font-medium text-gray-600 whitespace-nowrap w-[10%]"></th>
<th className="px-4 py-4 font-medium text-gray-600 whitespace-nowrap w-[27%]">API </th>
<th className="px-4 py-4 font-medium text-gray-600 whitespace-nowrap w-[18%]"></th>
<th className="px-4 py-4 font-medium text-gray-600 text-right whitespace-nowrap w-[12%]"></th>
<th className="px-4 py-4 font-medium text-gray-600 whitespace-nowrap w-[15%]"></th>
<th className="px-4 py-4 font-medium text-gray-600 whitespace-nowrap w-[18%]"></th>
<th className="px-4 py-4 font-medium text-gray-600 whitespace-nowrap w-[10%]"></th>
<th className="px-4 py-4 font-medium text-gray-600 whitespace-nowrap w-[27%]">Endpoint </th>
<th className="px-4 py-4 font-medium text-gray-600 whitespace-nowrap w-[18%]"></th>
<th className="px-4 py-4 font-medium text-gray-600 text-right whitespace-nowrap w-[12%]"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{models.map(model => (
<tr key={model.config_id} className="hover:bg-gray-50/50">
<tr key={model.config_id} className="hover:bg-gray-50/50 transition-colors">
<td className="px-4 py-4 truncate" title={model.model_name}>
<span className="font-medium text-gray-800">{model.model_name}</span>
</td>
<td className="px-4 py-4 text-gray-600 font-mono text-xs truncate" title={model.model_id}>{model.model_id}</td>
<td className="px-4 py-4 whitespace-nowrap">
{/* 类型标签分发:区分本地自研模型与外部公有云 LLM */}
<Tag color={model.model_type === 'local_finetune' ? 'blue' : 'orange'}>
{model.model_type === 'local_finetune' ? '本地微调' : '通用LLM'}
</Tag>
@ -345,7 +437,7 @@ export const AdminAIModels: React.FC = () => {
variant="text"
className="h-8 px-2 text-gray-500 hover:text-primary"
onClick={() => handleOpenEditModal(model)}
title="编辑"
title="修正配置"
>
<Edit3 size={14} />
</Button>
@ -353,7 +445,7 @@ export const AdminAIModels: React.FC = () => {
variant="text"
className="h-8 px-2 text-gray-500 hover:text-red-500"
onClick={() => setDeleteTarget(model)}
title="除"
title="物理移除"
>
<Trash2 size={14} />
</Button>
@ -361,10 +453,11 @@ export const AdminAIModels: React.FC = () => {
</td>
</tr>
))}
{/* 空态兜底描述 */}
{models.length === 0 && (
<tr>
<td colSpan={6} className="text-center py-12 text-gray-500">
"添加模型"
<td colSpan={6} className="text-center py-12 text-gray-400">
AI
</td>
</tr>
)}
@ -373,11 +466,11 @@ export const AdminAIModels: React.FC = () => {
</div>
)}
{/* 分页器 */}
{/* 分页导航器 */}
{total > 0 && (
<div className="flex justify-between items-center px-6 py-4 border-t border-gray-100 bg-gray-50/50">
<span className="text-xs text-gray-500">
{total} {page}
: {total} | : {page}
</span>
<div className="flex gap-2">
<Button variant="default" className="h-8 px-3 text-xs" disabled={page <= 1} onClick={() => setPage(p => p - 1)}></Button>
@ -387,25 +480,25 @@ export const AdminAIModels: React.FC = () => {
)}
</Card>
{/* 创建/编辑模态框 */}
{/* 事务模块 A配置编辑与测试对话框 */}
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title={editingModel ? '编辑模型配置' : '添加模型配置'}
title={editingModel ? '修正推理引擎配置' : '注册新推理引擎'}
maxWidth="max-w-lg"
footer={
<>
<Button onClick={() => setIsModalOpen(false)}></Button>
<div className="flex justify-end gap-2">
<Button onClick={() => setIsModalOpen(false)}></Button>
<Button variant="primary" onClick={handleSave} disabled={isSaving || !testResult?.success}>
{isSaving ? '保存中...' : '保存'}
{isSaving ? '正在执行物理持久化...' : '确认入库'}
</Button>
</>
</div>
}
>
<div className="space-y-4">
<Input
label="模型名称"
placeholder="如: GPT-4, Qwen-32B"
label="模型展示名称"
placeholder="例如: GPT-4-o (湖南大学定制版)"
value={formData.model_name}
onChange={(e) => {
setFormData(prev => ({ ...prev, model_name: e.target.value }));
@ -416,20 +509,20 @@ export const AdminAIModels: React.FC = () => {
<div>
<Input
label="API 地址"
placeholder="https://api.example.com/v1/chat/completions"
label="API Endpoint (反向代理地址)"
placeholder="https://openai.proxy.com/v1/chat/completions"
value={formData.api_url}
onChange={(e) => handleApiUrlChange(e.target.value)}
required
/>
{formErrors.api_url && (
<p className="text-xs text-red-500 mt-1">{formErrors.api_url}</p>
<p className="text-xs text-red-500 mt-1 font-medium">{formErrors.api_url}</p>
)}
</div>
<Input
label="模型 ID"
placeholder="如: gpt-4, qwen-coder-32b"
label="后端模型标识 (Model ID)"
placeholder="如: gpt-4, llama-3-70b-instruct"
value={formData.model_id}
onChange={(e) => {
setFormData(prev => ({ ...prev, model_id: e.target.value }));
@ -440,9 +533,9 @@ export const AdminAIModels: React.FC = () => {
<div className="relative">
<Input
label={editingModel ? "API 密钥 (留空则使用已保存的密钥)" : "API 密钥"}
label={editingModel ? "API 访问令牌 (留空则沿用旧令牌)" : "API 访问令牌"}
type={showApiKey ? "text" : "password"}
placeholder={editingModel ? "输入新密钥或留空使用已保存的" : "sk-xxxxx"}
placeholder={editingModel ? "输入新令牌或保持空白" : "密钥通常以 sk- 开头"}
value={formData.api_key}
onChange={(e) => {
setFormData(prev => ({ ...prev, api_key: e.target.value }));
@ -450,44 +543,59 @@ export const AdminAIModels: React.FC = () => {
}}
required={!editingModel}
/>
{/* 密钥可视性切换器 */}
<button
type="button"
className="absolute right-3 top-8 text-gray-400 hover:text-gray-600"
className="absolute right-3 top-8 text-gray-400 hover:text-gray-600 transition-colors"
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
{editingModel && !formData.api_key.trim() && (
<div className="mt-1">
<span className="text-xs text-blue-600 bg-blue-50 px-2 py-0.5 rounded">使</span>
<span className="text-[10px] text-blue-600 bg-blue-50 px-2 py-0.5 rounded border border-blue-100">
[] 使
</span>
</div>
)}
</div>
{/* 测试连接按钮 */}
<div className="flex items-center gap-3">
<Button
variant="default"
className="h-9 px-4 flex items-center gap-2"
onClick={handleTestConnection}
disabled={isTesting}
icon={<Zap size={14} />}
>
{isTesting ? '测试中...' : '测试连接'}
</Button>
{/* 关键组件:连接测试反馈区 */}
<div className="bg-gray-50/50 p-4 rounded-xl border border-gray-100 flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider"></span>
<Button
variant="default"
className="h-8 px-4 flex items-center gap-2 bg-white"
onClick={handleTestConnection}
disabled={isTesting}
icon={<Zap size={14} className={isTesting ? "animate-pulse" : ""} />}
>
{isTesting ? '正在嗅探链路...' : '执行连通性测试'}
</Button>
</div>
{testResult && (
<div className={`flex items-center gap-2 text-sm ${testResult.success ? 'text-green-600' : 'text-red-500'}`}>
{testResult.success ? <CheckCircle size={16} /> : <XCircle size={16} />}
<span>{testResult.message}</span>
{typeof testResult.response_time_ms === 'number' && testResult.success && (
<span className="text-gray-500 text-xs">({testResult.response_time_ms}ms)</span>
)}
<div className={`flex items-start gap-2 p-2 rounded-lg text-sm ${testResult.success ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-600'}`}>
<div className="mt-0.5 shrink-0">
{testResult.success ? <CheckCircle size={16} /> : <XCircle size={16} />}
</div>
<div className="flex-1">
<p className="font-bold">{testResult.success ? '测试成功' : '握手失败'}</p>
<p className="text-xs opacity-80">{testResult.message}</p>
{typeof testResult.response_time_ms === 'number' && testResult.success && (
<div className="mt-1 inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 bg-green-100 rounded">
<RefreshCw size={10} className="animate-spin-slow" />
: {testResult.response_time_ms}ms
</div>
)}
</div>
</div>
)}
</div>
<Select
label="模型类型"
label="模型应用策略 (引擎分流)"
className="w-full"
value={formData.model_type}
onChange={(val) => {
@ -496,33 +604,39 @@ export const AdminAIModels: React.FC = () => {
}}
required
options={[
{ value: 'general_llm', label: '通用 LLM' },
{ value: 'local_finetune', label: '本地微调模型' }
{ value: 'general_llm', label: '通用 LLM (标准推理)' },
{ value: 'local_finetune', label: '本地微调模型 (高精度语义映射)' }
]}
/>
</div>
</Modal>
{/* 删除确认模态框 */}
{/* 事务模块 B销毁确认对话框 */}
<Modal
isOpen={!!deleteTarget}
onClose={() => setDeleteTarget(null)}
title="确认删除"
title="高危:资源销毁确认"
maxWidth="max-w-sm"
footer={
<>
<Button onClick={() => setDeleteTarget(null)}></Button>
<div className="flex gap-2">
<Button onClick={() => setDeleteTarget(null)}></Button>
<Button variant="danger" onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? '删除中...' : '确认删除'}
{isDeleting ? '正在执行销毁...' : '确认注销配置'}
</Button>
</>
</div>
}
>
<div className="text-gray-600">
<p> <strong>{deleteTarget?.model_name}</strong> </p>
<p className="text-sm text-red-500 mt-2"></p>
<div className="space-y-3">
<div className="p-3 bg-red-50 rounded-lg flex items-start gap-3 border border-red-100">
<Trash2 className="text-red-600 shrink-0 mt-0.5" size={18} />
<div className="text-xs text-red-900 leading-relaxed">
<strong>{deleteTarget?.model_name}</strong>
</div>
</div>
<p className="text-[11px] text-gray-400 italic"></p>
</div>
</Modal>
</div>
);
};
};

@ -1,3 +1,22 @@
/**
* @file AdminAnnouncements.tsx
* @module Pages/Administration/Announcement-Governance
* @description
* AutoDB
*
* * *
* 1. : 稿 (Draft) (Published)
* 2. : status
* 3. : Map ID
* 4. :
* * *
* - Requirement 10.1:
* - Requirement 10.2:
* * @author Wang Lirong ()
* @version 1.5.0
* @date 2026-01-02
*/
import React, { useState, useEffect } from 'react';
import { Announcement } from '../types.ts';
import { Card, Button, Tag, Input, Modal, message, ConfirmDialog, Select } from '../components/UI.tsx';
@ -5,76 +24,119 @@ import { Pagination } from '../components/Pagination.tsx';
import { Plus, Edit, Trash, Bell, Loader2 } from 'lucide-react';
import { announcementApi, CreateAnnouncementParams, UpdateAnnouncementParams } from '../api/announcement.ts';
// 分页配置
/**
*
* @constant PAGE_SIZE -
*/
const PAGE_SIZE = 10;
/**
* @component AdminAnnouncements
* @description
* React
*
*/
export const AdminAnnouncements: React.FC = () => {
// --- 状态管理 ---
// --- 1. 数据集状态管理 (Data Sets) ---
/** 存储当前视图缓存的公告实体阵列 */
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
/** 局部加载锁:用于处理列表刷新及并发请求时的视觉反馈 */
const [isLoading, setIsLoading] = useState(false);
/** 异步保存锁:防止在提交表单时由于二次点击产生的网络幂等性问题 */
const [isSaving, setIsSaving] = useState(false);
/** 编辑模态框可见性开关 */
const [isModalOpen, setIsModalOpen] = useState(false);
// 分页状态
// --- 2. 分页状态机 (Pagination Control) ---
/** 当前激活的物理页码 */
const [currentPage, setCurrentPage] = useState(1);
/** 总记录计数:用于驱动分页器的内部逻辑(如省略号计算) */
const [totalAnnouncements, setTotalAnnouncements] = useState(0);
// 当前编辑的公告 ID (null 为新增)
// --- 3. 事务上下文状态 (Transaction Context) ---
/** 标识当前正在操作的公告 ID。若为 null 则表示处于“新公告创建”事务中 */
const [editingId, setEditingId] = useState<number | null>(null);
// 表单状态
// --- 4. 表单响应式字段 (Form Fields) ---
/** 公告标题缓存区 */
const [title, setTitle] = useState('');
/** 公告正文内容缓存区(支持多行输入) */
const [content, setContent] = useState('');
/** *
* - published:
* - draft:
*/
const [status, setStatus] = useState<'published' | 'draft'>('draft');
// 确认删除对话框状态
// --- 5. 安全风险确认状态 (Deletion Safeguard) ---
/** 销毁确认弹窗的显示状态 */
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
/** 物理删除的目标 ID 引用 */
const [announcementToDelete, setAnnouncementToDelete] = useState<number | null>(null);
// --- 数据获取 ---
/**
* (Data Fetching & Cleaning)
* @async @function fetchAnnouncements
* @description
*
* Promise.all
*/
const fetchAnnouncements = async (page: number = currentPage) => {
setIsLoading(true);
try {
// 管理员通常需要看到所有状态的公告
// 由于后端接口目前通过 status 筛选,这里并发请求 draft 和 published 两种状态并合并
// 实际生产中建议后端提供一个不带 status 过滤的 "all" 选项或专门的管理员列表接口
/** * A稿
*
*/
const [publishedRes, draftRes] = await Promise.all([
announcementApi.getList(page, PAGE_SIZE, 'published'),
announcementApi.getList(page, PAGE_SIZE, 'draft')
]);
// 合并并按 announcement_id 去重,再按创建时间倒序
/** * B
* 1. Merging: DTO
* 2. Deduplication: Map ID
* 3. Sorting: created_at
*/
const merged = [...publishedRes.items, ...draftRes.items];
const uniqueById = Array.from(
new Map(merged.map(a => [a.announcement_id, a])).values()
).sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
setAnnouncements(uniqueById);
// 设置总数(这里简化处理,实际应该从后端获取准确的总数)
/** 步骤 C更新分页元数据 */
setTotalAnnouncements(publishedRes.total + draftRes.total);
} catch (error) {
// 异常捕获:在此处可集成全局 Sentry 记录
console.error("Failed to fetch announcements", error);
} finally {
// 释放视觉锁
setIsLoading(false);
}
};
/**
*
*/
useEffect(() => {
fetchAnnouncements();
}, []);
// --- 交互处理 ---
// --- 交互业务逻辑集 (User Interaction Logic) ---
/**
*
* @param {Announcement} [announcement] -
*/
const handleOpenModal = (announcement?: Announcement) => {
if (announcement) {
// 编辑模式
/** 场景:修正现有公告信息 */
setEditingId(announcement.announcement_id);
setTitle(announcement.title);
setContent(announcement.content);
// 确保 status 是符合类型的
setStatus(announcement.status === 'published' ? 'published' : 'draft');
} else {
// 新增模式
/** 场景:撰写新公告 */
setEditingId(null);
setTitle('');
setContent('');
@ -83,22 +145,32 @@ export const AdminAnnouncements: React.FC = () => {
setIsModalOpen(true);
};
/**
*
* @async @function handleSave
* @description
* /
* -> (Update/Create) -> ->
*/
const handleSave = async () => {
// 步骤 1合法性初审必填项拦截
if (!title || !content) return;
setIsSaving(true);
try {
if (editingId) {
// 更新现有公告
/** 场景 A更新现有资源的元数据 */
const updateData: UpdateAnnouncementParams = { title, content, status };
await announcementApi.update(editingId, updateData);
} else {
// 创建新公告
/** 场景 B触发新资源的物理生成 */
const createData: CreateAnnouncementParams = { title, content, status };
await announcementApi.create(createData);
}
// 刷新列表并关闭模态框
/** 步骤 2数据最终一致性刷新 */
await fetchAnnouncements(currentPage);
/** 步骤 3事务完结清理交互上下文 */
setIsModalOpen(false);
} catch (error) {
console.error("Failed to save announcement", error);
@ -108,19 +180,32 @@ export const AdminAnnouncements: React.FC = () => {
}
};
/**
*
* @param {number} id -
*/
const handleDelete = async (id: number) => {
setAnnouncementToDelete(id);
setIsDeleteConfirmOpen(true);
};
/**
*
* @async @function confirmDelete
* @description
*
* (Optimistic UI Update)
*/
const confirmDelete = async () => {
if (!announcementToDelete) return;
try {
await announcementApi.delete(announcementToDelete);
// 乐观更新 UI
/** 性能优化:无需重新拉取列表,直接从本地内存过滤掉已销毁项 */
setAnnouncements(prev => prev.filter(a => a.announcement_id !== announcementToDelete));
setTotalAnnouncements(prev => prev - 1);
message.success("公告已删除");
} catch (error) {
console.error("Delete failed", error);
@ -128,73 +213,94 @@ export const AdminAnnouncements: React.FC = () => {
}
};
// 分页处理
/**
*
* @param {number} page -
*/
const handlePageChange = (page: number) => {
setCurrentPage(page);
fetchAnnouncements(page);
};
return (
/** 主容器布局:采用 Flex-Col 结构,适配 100% 视口高度并强制隐藏多余溢出 */
<div className="p-8 max-w-7xl mx-auto h-full flex flex-col overflow-hidden">
{/* 顶部操作栏 */}
{/* 1 (Action Bar)
*/}
<div className="flex justify-between items-center mb-8 flex-shrink-0">
<h2 className="text-2xl font-bold text-gray-800 flex items-center gap-3">
<div className="p-2 bg-blue-100 text-blue-600 rounded-lg shrink-0">
<Bell size={20} />
</div>
</h2>
<Button variant="primary" icon={<Plus size={16} />} onClick={() => handleOpenModal()}>
</Button>
</div>
{/* 公告列表 */}
<div className="flex-1 overflow-y-auto min-h-0">
{/* 2 (List View)
使 flex-1 overflow-y-auto
*/}
<div className="flex-1 overflow-y-auto min-h-0 custom-scrollbar">
{isLoading ? (
/** 加载骨架:采用居中 Loader 动画 */
<div className="flex justify-center items-center h-64">
<Loader2 className="animate-spin text-primary" size={32} />
</div>
) : (
<div className="space-y-4 pb-4">
{announcements.map(item => (
/** 公告卡片:集成阴影过渡与响应式内容布局 */
<Card key={item.announcement_id} className="hover:shadow-md transition-shadow">
<div className="flex justify-between items-start">
<div className="flex-1 pr-4">
{/* 公告元数据区:包含标题、状态标签及时间戳 */}
<div className="flex items-center gap-3 mb-2">
<h3 className="font-bold text-gray-800 text-lg">{item.title}</h3>
{/* 语义化标签分发:绿色对应发布,橙色对应草稿 */}
<Tag color={item.status === 'published' ? 'green' : 'orange'}>
{item.status === 'published' ? '已发布' : '草稿'}
</Tag>
<span className="text-sm text-gray-400">
<span className="text-sm text-gray-400 font-mono">
{new Date(item.created_at).toLocaleDateString()}
</span>
</div>
<p className="text-gray-600 text-sm line-clamp-2">{item.content}</p>
{/* 核心内容预览:支持两行文本截断 (line-clamp-2) */}
<p className="text-gray-600 text-sm line-clamp-2 leading-relaxed">{item.content}</p>
</div>
{/* 动作按钮群组:提供编辑与删除入口 */}
<div className="flex gap-2 shrink-0">
<Button variant="text" onClick={() => handleOpenModal(item)}>
<Edit size={16} className="text-gray-500 hover:text-primary" />
<Button variant="text" onClick={() => handleOpenModal(item)} title="编辑内容">
<Edit size={16} className="text-gray-500 hover:text-primary transition-colors" />
</Button>
<Button variant="text" onClick={() => handleDelete(item.announcement_id)}>
<Trash size={16} className="text-gray-500 hover:text-red-500" />
<Button variant="text" onClick={() => handleDelete(item.announcement_id)} title="注销公告">
<Trash size={16} className="text-gray-500 hover:text-red-500 transition-colors" />
</Button>
</div>
</div>
</Card>
))}
{/* 场景兜底:空状态展示,采用虚线边框样式 */}
{announcements.length === 0 && (
<div className="text-center py-12 text-gray-500 bg-white rounded-lg border border-dashed border-gray-300">
<Bell size={32} className="mx-auto mb-2 opacity-20" />
</div>
)}
</div>
)}
</div>
{/* 分页控件 - 固定在底部,始终显示 */}
{/* 3 (Pagination Area)
*/}
{totalAnnouncements > 0 && (
<div className="pagination-container flex-shrink-0">
<div className="pagination-container border-t border-gray-100 mt-4 pt-4 flex-shrink-0">
<div className="pagination-wrapper">
<Pagination
current={currentPage}
@ -202,66 +308,75 @@ export const AdminAnnouncements: React.FC = () => {
pageSize={PAGE_SIZE}
onChange={handlePageChange}
showTotal={true}
// 响应式自适应:在小屏幕 (width < 640) 下自动切换至 Simple 渲染模式
simple={window.innerWidth < 640}
/>
</div>
</div>
)}
{/* 编辑/新增模态框 */}
{/* 事务模块 A编辑/新增表单模态框 */}
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title={editingId ? "编辑公告" : "发布新公告"}
title={editingId ? "修改现有公告" : "撰写新公告"}
footer={
<>
<Button onClick={() => setIsModalOpen(false)}></Button>
<div className="flex justify-end gap-3">
<Button onClick={() => setIsModalOpen(false)}></Button>
<Button variant="primary" onClick={handleSave} disabled={isSaving}>
{isSaving ? '保存中...' : '保存'}
{isSaving ? '正在同步至云端...' : '保存更改'}
</Button>
</>
</div>
}
>
<div className="space-y-4">
<div className="space-y-5">
{/* 公告标题录入单元 */}
<Input
label="公告标题"
placeholder="请输入标题"
placeholder="请输入具有概括性的标题"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
{/* 状态维度选择器 */}
<Select
label="状态"
label="发布策略状态"
className="w-full"
value={status}
onChange={(val) => setStatus(val as any)}
required
options={[
{ value: 'draft', label: '存为草稿' },
{ value: 'published', label: '立即发布' }
{ value: 'draft', label: '存为草稿 (暂不对外可见)' },
{ value: 'published', label: '立即发布 (同步全网用户)' }
]}
/>
{/* 正文录入单元:手写 textarea 样式以实现高度自适应 */}
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700"><span className="text-red-500 ml-1">*</span></label>
<label className="text-sm font-medium text-gray-700"><span className="text-red-500 ml-1">*</span></label>
<textarea
className="px-3 py-2.5 bg-white border border-gray-300 rounded-lg text-sm focus:border-primary focus:ring-2 focus:ring-blue-100 h-32 resize-none outline-none transition-all shadow-sm"
placeholder="请输入公告内容..."
className="px-3 py-2.5 bg-white border border-gray-300 rounded-lg text-sm focus:border-primary focus:ring-2 focus:ring-blue-100 h-40 resize-none outline-none transition-all shadow-sm placeholder:text-gray-300"
placeholder="请详细描述通知事项、操作指引或维护周期..."
value={content}
onChange={(e) => setContent(e.target.value)}
></textarea>
<p className="text-[11px] text-gray-400 italic"> Dashboard</p>
</div>
</div>
</Modal>
{/* 删除公告确认对话框 */}
{/* B (Confirm Dialog)
Usability-2
*/}
<ConfirmDialog
isOpen={isDeleteConfirmOpen}
onClose={() => setIsDeleteConfirmOpen(false)}
onConfirm={confirmDelete}
title="删除公告"
message="确定要删除这条公告吗?删除后无法恢复。"
confirmText="删除"
cancelText="取消"
title="高危:资源注销确认"
message="确定要彻底移除这条公告吗?注销后,所有关联用户的通知流将同步中断且不可回溯。"
confirmText="立即执行删除"
cancelText="放弃并返回"
isDangerous={true}
showWarningIcon={true}
/>

@ -1,70 +1,155 @@
/**
* TypeScript interfaces for Project Overview Optimization feature
* Requirements: 4.2, 4.3, 4.4, 4.6
* @file project-overview.ts
* @module Types/Project-Overview-Feature
* @description
*
* *
* 1. DTOProjectData
* 2. ProjectCard, StatusBadge Props
* 3.
* *
* - Requirement 4.2:
* - Requirement 4.3:
* - Requirement 4.4:
* - Requirement 4.6:
* * @author Wang Lirong ()
* @version 1.2.0
* @date 2026-01-02
*/
// Core project data structure for project overview
/**
*
* @interface ProjectData
* @description
* Dashboard FCP
*/
export interface ProjectData {
/** 数据库项目在全球系统中的唯一主键序列号 */
project_id: number;
/** 数据库项目展示名称(受 6 字符截断算法约束) */
project_name: string;
/** *
* Schema Agent
*/
description: string;
/** *
* - initializing: DDL
* - active: NL2SQL
* - inactive:
*/
project_status: 'initializing' | 'active' | 'inactive';
updated_at: string; // ISO 8601 format
/** 记录项目的最后变更时间戳(遵循 ISO 8601 标准格式,便于前端时区转换) */
updated_at: string;
/** *
* DDL Dialect
*/
db_type: 'mysql' | 'postgresql' | 'sqlite';
}
// Project card component props
/**
*
* @interface ProjectCardProps
* @description ProjectCard
*/
export interface ProjectCardProps {
/** 当前待渲染的项目核心数据实体 */
project: ProjectData;
/** 点击卡片主体区域进入工作台 (Workspace) 的路由跳转回调 */
onCardClick: (projectId: number) => void;
/** 触发项目配置微调(如修改描述、重命名)的编辑回调 */
onEdit: (projectId: number) => void;
/** 触发带安全令牌校验的删除确认流程回调 */
onDelete: (projectId: number) => void;
}
// Database icon component props
/**
*
* @interface DatabaseIconProps
*/
export interface DatabaseIconProps {
/** 需要渲染的数据库引擎枚举值 */
dbType: 'mysql' | 'postgresql' | 'sqlite';
/** 图标渲染尺寸单位px采用响应式默认值 */
size?: number;
/** 扩展 CSS 类名,用于支持外部样式注入 */
className?: string;
}
// Project details modal component props
/**
*
* @interface ProjectDetailsModalProps
* @description
*/
export interface ProjectDetailsModalProps {
/** 当前选中的项目对象。若为 null 则表示模态框处于关闭或重置状态。 */
project: ProjectData | null;
/** 控制模态框显隐状态的布尔开关 */
isOpen: boolean;
/** 模态框关闭时的清理回调(负责重置局部状态) */
onClose: () => void;
}
// Action buttons component props
/**
*
* @interface ActionButtonsProps
*/
export interface ActionButtonsProps {
/** 目标项目识别码 */
projectId: number;
/** 编辑逻辑触发函数 */
onEdit: (projectId: number) => void;
/** 物理删除逻辑触发函数 */
onDelete: (projectId: number) => void;
}
// Project status badge props
/**
*
* @interface ProjectStatusBadgeProps
*/
export interface ProjectStatusBadgeProps {
/** 实时状态决定标签的着色方案Green/Blue/Gray */
status: 'initializing' | 'active' | 'inactive';
/** 容器扩展类名 */
className?: string;
}
// Date formatter utility props
/**
*
* @interface DateFormatterProps
*/
export interface DateFormatterProps {
/** 待格式化的 ISO 时间戳字符串 */
date: string;
/** 样式类名,常用于对 Today/Yesterday 进行特殊高亮处理 */
className?: string;
}
// Text truncation utility props
/**
*
* @interface TextTruncationProps
* @description Tooltip -
*/
export interface TextTruncationProps {
/** 原始全量文本内容 */
text: string;
/** 强制截断的字符长度临界值 */
maxLength: number;
/** 是否在悬停时展示全量内容提示框,默认为 true */
showTooltip?: boolean;
}
// Project overview grid props
/**
*
* @interface ProjectOverviewGridProps
* @description
*/
export interface ProjectOverviewGridProps {
/** 聚合渲染的项目数据列表 */
projects: ProjectData[];
/** 点击项目事件向下透传 */
onCardClick: (projectId: number) => void;
/** 编辑事件向下透传 */
onEdit: (projectId: number) => void;
/** 删除事件向下透传 */
onDelete: (projectId: number) => void;
}

@ -1,22 +1,45 @@
// src/frontend/src/utils/errorHandling.ts
/**
* @file errorHandling.ts
* @module Utils/Global-Error-Management
* @description
*
* *
* 1. Reliability-3: AI SQL
* 2. Usability-1: 500
* 3. Security-2 & 3: 401/403 线 [cite: 178, 549, 550]
* *
* - ErrorHandler: BusinessCode
* - HttpErrorHandler: HTTP
* - NetworkErrorHandler:
* * @author Wang Lirong ()
* @version 2.5.0
* @date 2026-01-02
*/
import { BusinessError } from '../types';
import { message } from '../components/UI.tsx';
/**
*
* : 7.1, 7.2, 7.3, 7.4
*
* @interface ErrorHandlingConfig
*/
// 错误处理配置
export interface ErrorHandlingConfig {
/** 是否将技术性错误信息过滤为用户友好的语义描述 */
showUserFriendlyMessages: boolean;
/** 是否在控制台或远端审计系统中记录错误详情 */
logErrors: boolean;
/** 是否开启基于 API Client 策略的自动化重试 */
enableRetry: boolean;
/** 网络级错误触发重试的最大尝试次数 */
maxRetries: number;
/** 初始重试延迟基数(毫秒) */
retryDelay: number;
}
// 默认错误处理配置
/**
*
* 线
*/
export const defaultErrorConfig: ErrorHandlingConfig = {
showUserFriendlyMessages: true,
logErrors: true,
@ -25,19 +48,30 @@ export const defaultErrorConfig: ErrorHandlingConfig = {
retryDelay: 1000,
};
// 错误分类处理器
/**
* (Business Logic Error Processor)
* 200 UI
*/
export class ErrorHandler {
/** 处理器当前实例的私有配置快照 */
private config: ErrorHandlingConfig;
/**
*
* @param {Partial<ErrorHandlingConfig>} [config={}] -
*/
constructor(config: Partial<ErrorHandlingConfig> = {}) {
this.config = { ...defaultErrorConfig, ...config };
}
/**
*
* 7.1: APImessage
*
* 7.1: message
* @method handleBusinessError
* @param {BusinessError} error -
*/
handleBusinessError(error: BusinessError): void {
// 步骤 1审计记录。若配置开启则将完整堆栈与详情记录至日志系统。
if (this.config.logErrors) {
console.error('Business Error:', {
code: error.code,
@ -47,41 +81,50 @@ export class ErrorHandler {
});
}
// 步骤 2类型断言分发。根据不同的错误码区间路由至特定的 UI 提示逻辑。
switch (true) {
// 区间 10000-19999前端/后端表单验证异常
case error.isValidationError():
this.handleValidationError(error);
break;
// 区间 20000-29999通用业务逻辑冲突余额不足、重复创建
case error.isBusinessError():
this.handleLogicError(error);
break;
// 区间 30000-39999项目级权限越权尝试
case error.isPermissionError():
this.handlePermissionError(error);
break;
// HTTP 状态码映射转换错误
case error.isHttpError():
this.handleHttpError(error);
break;
// 区 lot 50000+:后端致命异常
case error.isSystemError():
this.handleSystemError(error);
break;
default:
// 后向兼容处理:处理未定义码段的兜底逻辑
this.handleUnknownError(error);
}
}
/**
* (10000-19999)
* (10000-19999)
* Schema Agent [cite: 341]
* @private
*/
private handleValidationError(error: BusinessError): void {
if (this.config.showUserFriendlyMessages) {
// 显示具体的验证错误信息
// 步骤:向用户弹出具体的验证失败原因(如“项目名长度不符合规范”)
message.error(error.message || '输入数据验证失败');
// 如果有详细的字段错误信息,可以进一步处理
// 步骤:针对复杂对象(如 DDL 参数),执行细粒度的字段级解析
if (error.details && typeof error.details === 'object') {
this.handleFieldErrors(error.details);
}
@ -89,53 +132,61 @@ export class ErrorHandler {
}
/**
* (20000-29999)
* (20000-29999)
*
* @private
*/
private handleLogicError(error: BusinessError): void {
if (this.config.showUserFriendlyMessages) {
// 直接展示业务层透传的错误文本,保持语义的一致性
message.error(error.message || '操作失败,请检查操作条件');
}
}
/**
* (30000-39999)
* 7.4:
* (30000-39999)
* 7.4: UI 访 [cite: 329]
* @private
*/
private handlePermissionError(error: BusinessError): void {
if (this.config.showUserFriendlyMessages) {
message.error(error.message || '权限不足,无法执行此操作');
}
// 可以触发权限相关的全局事件
// 步骤:通过 DOM 消息总线触发权限相关的全局通知逻辑
this.emitPermissionError(error);
}
/**
* (50000+)
* (50000+)
* Python
* @private
*/
private handleSystemError(error: BusinessError): void {
if (this.config.showUserFriendlyMessages) {
message.error('系统错误,请稍后重试');
}
// 系统错误需要特别记录
// 步骤:此类错误被标记为高优先级审计,通常需要运维人员介入。
if (this.config.logErrors) {
console.error('System Error - Requires attention:', error);
}
}
/**
* HTTP (400-599)
* HTTP
* @private
*/
private handleHttpError(error: BusinessError): void {
if (this.config.showUserFriendlyMessages) {
// 显示后端返回的具体错误信息,而不是通用的系统错误
// 避免使用模糊的“请求失败”,尽可能从 response body 中提取原始反馈。
message.error(error.message || '请求失败');
}
}
/**
*
*
* @private
*/
private handleUnknownError(error: BusinessError): void {
if (this.config.showUserFriendlyMessages) {
@ -144,11 +195,14 @@ export class ErrorHandler {
}
/**
*
*
* FastAPI ValidationError (Pydantic )
* @private
* @param {any} details -
*/
private handleFieldErrors(details: any): void {
if (Array.isArray(details)) {
// FastAPI 风格的验证错误
// 解析典型的 loc 路径及 msg 信息例如body.project_name 过短
details.forEach((fieldError: any) => {
if (fieldError.msg && fieldError.loc) {
const field = Array.isArray(fieldError.loc) ? fieldError.loc.join('.') : fieldError.loc;
@ -156,7 +210,7 @@ export class ErrorHandler {
}
});
} else if (typeof details === 'object') {
// 自定义字段错误格式
// 处理自定义的键值对错误格式
Object.entries(details).forEach(([field, error]) => {
console.warn(`Field ${field}: ${error}`);
});
@ -164,10 +218,11 @@ export class ErrorHandler {
}
/**
*
*
* Header Sidebar UI
* @private
*/
private emitPermissionError(error: BusinessError): void {
// 可以通过事件系统通知其他组件
window.dispatchEvent(new CustomEvent('permission-error', {
detail: { error }
}));
@ -175,22 +230,27 @@ export class ErrorHandler {
}
/**
* HTTP
* 7.2:
* 7.3:
* HTTP (Transport Layer Error Handler)
* 7.2:
* 7.3: 401
*/
export class HttpErrorHandler {
private config: ErrorHandlingConfig;
/** 记录最近一次错误消息,用于简单的去抖动处理 */
private lastErrorMessage: string = '';
/** 记录最近一次错误发生的时间戳 */
private lastErrorTime: number = 0;
private readonly ERROR_DEBOUNCE_TIME = 3000; // 3秒内相同错误只显示一次
/** 定义错误气泡的消隐间隔3秒防止在高频请求下的 UI 提示堆叠 */
private readonly ERROR_DEBOUNCE_TIME = 3000;
constructor(config: Partial<ErrorHandlingConfig> = {}) {
this.config = { ...defaultErrorConfig, ...config };
}
/**
*
*
*
* @private
*/
private shouldShowError(errorMessage: string): boolean {
const now = Date.now();
@ -203,7 +263,11 @@ export class HttpErrorHandler {
}
/**
* HTTP
* HTTP
* RFC 7231
* @method handleHttpError
* @param {number} status - HTTP
* @param {any} [data] -
*/
handleHttpError(status: number, data?: any): void {
if (this.config.logErrors) {
@ -212,22 +276,27 @@ export class HttpErrorHandler {
switch (status) {
case 401:
// 鉴权失败Token 过期或非法
this.handleAuthenticationError(data);
break;
case 403:
// 禁止访问:当前用户无权访问目标资源
this.handleForbiddenError(data);
break;
case 404:
// 路由不存在或数据库项目记录已销毁
this.handleNotFoundError(data);
break;
case 408:
// 请求超时:后端 Agent 响应耗时超过 30s 预设阈值 [cite: 55-60]
this.handleTimeoutError(data);
break;
case 429:
// 请求频率限制:触发系统防火墙限流策略
this.handleRateLimitError(data);
break;
@ -235,20 +304,23 @@ export class HttpErrorHandler {
case 502:
case 503:
case 504:
// 服务端集群异常处理
this.handleServerError(status, data);
break;
default:
// 未定义的 HTTP 网络异常
this.handleGenericHttpError(status, data);
}
}
/**
* (401)
* 7.3:
* (401 Unauthorized)
* 7.3: [cite: 326-327, 463]
* @private
*/
private handleAuthenticationError(data?: any): void {
// 清除本地认证信息
private handleAuthenticationError(_data?: any): void {
// 步骤 1物理销毁本地所有敏感持久化信息确保 JWT Token 不被滥用
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user_info');
@ -258,10 +330,10 @@ export class HttpErrorHandler {
message.error(errorMessage);
}
// 触发全局认证失效事件
// 步骤 2广播全局事件由 WorkspaceWrapper 等组件捕获并停止心跳轮询
window.dispatchEvent(new CustomEvent('auth-expired'));
// 可以自动跳转到登录页
// 步骤 3延时执行强制重定向逻辑为用户预留阅读提示的时间。
setTimeout(() => {
if (window.location.pathname !== '/login') {
window.location.href = '/login';
@ -270,7 +342,8 @@ export class HttpErrorHandler {
}
/**
* (403)
* (403 Forbidden)
* @private
*/
private handleForbiddenError(data?: any): void {
const message_text = this.extractErrorMessage(data) || '权限不足,无法访问此资源';
@ -281,7 +354,8 @@ export class HttpErrorHandler {
}
/**
* (404)
* (404 Not Found)
* @private
*/
private handleNotFoundError(data?: any): void {
const message_text = this.extractErrorMessage(data) || '请求的资源不存在';
@ -292,9 +366,11 @@ export class HttpErrorHandler {
}
/**
* (408)
* (408 Request Timeout)
* Performance1
* @private
*/
private handleTimeoutError(data?: any): void {
private handleTimeoutError(_data?: any): void {
const errorMessage = '请求超时,请检查网络连接后重试';
if (this.config.showUserFriendlyMessages && this.shouldShowError(errorMessage)) {
message.error(errorMessage);
@ -302,9 +378,10 @@ export class HttpErrorHandler {
}
/**
* (429)
* (429 Too Many Requests)
* @private
*/
private handleRateLimitError(data?: any): void {
private handleRateLimitError(_data?: any): void {
const errorMessage = '请求过于频繁,请稍后再试';
if (this.config.showUserFriendlyMessages && this.shouldShowError(errorMessage)) {
message.error(errorMessage);
@ -312,10 +389,12 @@ export class HttpErrorHandler {
}
/**
* (5xx)
* (5xx Server Errors)
* @private
*/
private handleServerError(status: number, data?: any): void {
private handleServerError(status: number, _data?: any): void {
if (this.config.showUserFriendlyMessages) {
// 区分 503 维护状态与通用的 500 内部崩溃
const message_text = status === 503
? '服务暂时不可用,请稍后重试'
: '服务器内部错误,请稍后重试';
@ -327,9 +406,10 @@ export class HttpErrorHandler {
}
/**
* HTTP
* HTTP
* @private
*/
private handleGenericHttpError(status: number, data?: any): void {
private handleGenericHttpError(_status: number, data?: any): void {
const message_text = this.extractErrorMessage(data) || '网络请求失败,请检查网络连接';
if (this.config.showUserFriendlyMessages && this.shouldShowError(message_text)) {
@ -338,27 +418,32 @@ export class HttpErrorHandler {
}
/**
*
*
* UnifiedResponse, FastAPI Detail, Error
* @private
* @param {any} data -
* @returns {string | null}
*/
private extractErrorMessage(data?: any): string | null {
if (!data) return null;
// UnifiedResponse 格式
// 格式 1系统预设的 UnifiedResponse 包装
if (data.message) {
return data.message;
}
// FastAPI 验证错误格式
// 格式 2FastAPI 自动生成的验证细节
if (data.detail) {
if (typeof data.detail === 'string') {
return data.detail;
}
if (Array.isArray(data.detail) && data.detail.length > 0) {
// 提取 Pydantic 抛出的第一条验证失败消息
return data.detail[0]?.msg || data.detail[0] || null;
}
}
// 其他格式
// 格式 3标准 JSON 错误响应
if (data.error) {
return typeof data.error === 'string' ? data.error : data.error.message;
}
@ -368,8 +453,8 @@ export class HttpErrorHandler {
}
/**
*
* 7.2:
* (Connectivity Layer Error Handler)
* 7.2: DNS
*/
export class NetworkErrorHandler {
private config: ErrorHandlingConfig;
@ -379,7 +464,9 @@ export class NetworkErrorHandler {
}
/**
*
*
* @method handleNetworkError
* @param {any} error -
*/
handleNetworkError(error: any): void {
if (this.config.logErrors) {
@ -388,7 +475,7 @@ export class NetworkErrorHandler {
let message_text = '网络连接失败,请检查网络设置';
// 根据错误类型提供更具体的提示
// 步骤:根据 Axios 返回的错误枚举,提供极其精确的故障排查指引
if (error.code === 'NETWORK_ERROR') {
message_text = '网络连接中断,请检查网络连接';
} else if (error.code === 'TIMEOUT') {
@ -403,14 +490,16 @@ export class NetworkErrorHandler {
}
/**
*
* Navigator 线
* @returns {boolean}
*/
checkNetworkStatus(): boolean {
return navigator.onLine;
}
/**
*
*
* Usability-1
*/
setupNetworkStatusListener(): void {
window.addEventListener('online', () => {
@ -428,14 +517,19 @@ export class NetworkErrorHandler {
}
/**
*
*
* (Unified Central Error Handler)
* (Facade Pattern)
*
*/
export class GlobalErrorHandler {
private businessErrorHandler: ErrorHandler;
private httpErrorHandler: HttpErrorHandler;
private networkErrorHandler: NetworkErrorHandler;
/**
*
* @param {Partial<ErrorHandlingConfig>} [config={}]
*/
constructor(config: Partial<ErrorHandlingConfig> = {}) {
this.businessErrorHandler = new ErrorHandler(config);
this.httpErrorHandler = new HttpErrorHandler(config);
@ -443,22 +537,29 @@ export class GlobalErrorHandler {
}
/**
*
*
* error
* @method handleError
* @param {any} error -
*/
handleError(error: any): void {
// 优先级 1由 responseInterceptor 识别并抛出的业务逻辑错误
if (error instanceof BusinessError) {
this.businessErrorHandler.handleBusinessError(error);
} else if (error.response) {
// HTTP 错误
}
// 优先级 2由 Axios 识别的 HTTP 响应层错误 (status code >= 400)
else if (error.response) {
this.httpErrorHandler.handleHttpError(
error.response.status,
error.response.data
);
} else if (error.request) {
// 网络错误
}
// 优先级 3请求已发起但未收到响应如 DNS 失败、连接超时)
else if (error.request) {
this.networkErrorHandler.handleNetworkError(error);
} else {
// 其他错误
}
// 优先级 4代码执行期异常或其他未知逻辑错误
else {
console.error('Unknown Error:', error);
if (error.message) {
message.error(error.message);
@ -467,17 +568,20 @@ export class GlobalErrorHandler {
}
/**
*
*
* window 线
*/
initialize(): void {
// 设置网络状态监听
// 设置浏览器网络状态感知
this.networkErrorHandler.setupNetworkStatusListener();
// 设置全局错误捕获
// 监听同步代码执行中的未捕获异常
window.addEventListener('error', (event) => {
console.error('Global Error:', event.error);
});
// 监听异步 Promise 流中遗漏的 catch 异常
// 确保任何 API 调用失败都能被正确处理 [cite: 543]
window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled Promise Rejection:', event.reason);
this.handleError(event.reason);
@ -485,9 +589,18 @@ export class GlobalErrorHandler {
}
}
// 创建全局错误处理器实例
/**
*
* Web
*/
export const globalErrorHandler = new GlobalErrorHandler();
// 导出便捷函数
/**
* 便
*/
export const handleError = (error: any) => globalErrorHandler.handleError(error);
/**
*
*/
export const initializeErrorHandling = () => globalErrorHandler.initialize();

@ -1,127 +1,154 @@
/**
* @file globalZoomLevel.ts
* @module Utils/UI-Adaptation
* @description
* (Usability-1)
* *
* 1. 500%
* 2. MacBook Retina DPR (Device Pixel Ratio)
* 3. (Scale Factor) ER
* *
* - SF5:
* - Usability-1:
* * @author Wang Lirong ()
* @version 2.1.0
* @date 2026-01-02
*/
import { useState, useEffect } from 'react';
/**
*
*
* 300%400% 500%
* 使
*
* Requirements: 6.1, 6.2, 6.3
*
* @interface GlobalZoomLevelInfo
* @description
*/
export interface GlobalZoomLevelInfo {
/** 当前缩放级别(百分比,如 100, 150, 200, 300, 400, 500 */
/** 当前用户设置的浏览器缩放级别(百分比数值,范围通常在 50 至 500 之间) */
zoomLevel: number;
/** 是否为高缩放模式(>200% */
/** 布尔标识:是否处于高缩放模式(>200%),用于触发移动端样式降级 */
isHighZoom: boolean;
/** 是否为极高缩放模式(>300% */
/** 布尔标识:是否处于极高缩放模式(>300%),用于隐藏非核心装饰元素 */
isExtremeZoom: boolean;
/** 原始设备像素比 */
/** 原始设备像素比,反映物理像素与逻辑像素的比例关系 */
devicePixelRatio: number;
/** 当前视口宽度 */
/** 当前视口的物理逻辑宽度单位px */
viewportWidth: number;
/** 当前视口高度 */
/** 当前视口的物理逻辑高度单位px */
viewportHeight: number;
/** 建议的全局缩放因子 (0.7-1.0) */
/** *
* CSS transform: scale()
*/
globalScaleFactor: number;
/** 缩放阈值检测 */
/** 细化缩放阈值检测状态,供业务组件进行条件渲染判定 */
thresholds: {
/** 是否跨越 300% 阈值 */
above300: boolean;
/** 是否跨越 400% 阈值 */
above400: boolean;
/** 是否跨越 500% 极高阈值 */
above500: boolean;
};
}
/**
*
*
*
* scaleFactor = 100 / zoomLevel * adjustmentFactor
* adjustmentFactor
*
*
* - 500%: ~0.35 (65%)
* - 400%: ~0.45 (55%)
* - 300%: ~0.55 (45%)
* - 250%: ~0.65 (35%)
* - 200%: ~0.75 (25%)
* - 150%: ~0.85 (15%)
* - <125%: 1.0 ()
*
* @param zoomLevel
* @returns (0.35-1.0)
*
* *
* 400% UI
* 0.35 1.0
* *
* scaleFactor = clamp(min, max, (100 / zoomLevel) ^ power)
* power = 0.7 线
* * @function calculateGlobalScaleFactor
* @param {number} zoomLevel -
* @returns {number}
*/
export function calculateGlobalScaleFactor(zoomLevel: number): number {
// 低于125%缩放时不进行抵消
// 步骤 1定义感知阈值。低于 125% 的缩放被视为标准视觉环境,不执行任何补偿。
if (zoomLevel <= 125) {
return 1.0;
}
// 计算反向缩放因子
// 使用公式scaleFactor = (100 / zoomLevel) ^ power
// power < 1 表示部分抵消power = 1 表示完全抵消
const power = 0.7; // 抵消约70%的缩放效果
/**
* 2
* power < 1 (0.7) 70% 30%
*/
const power = 0.7;
const rawFactor = Math.pow(100 / zoomLevel, power);
// 限制最小值为0.35最大值为1.0
/**
* 3 (Clamping)
* 0.35 500% UI
*/
const clampedFactor = Math.max(0.35, Math.min(1.0, rawFactor));
// 四舍五入到两位小数
// 步骤 4执行数值修约防止浮点数计算误差导致 CSS 渲染抖动。
return Math.round(clampedFactor * 100) / 100;
}
/**
*
*
*
* 1. 使 window.devicePixelRatio
* 2.
* 3. DPR Retina
* 4. 500%
*
* *
* 1. window.devicePixelRatio
* 2. screen.width DPR Retina
* 3. 400%+
* 4. 150, 175, 200
* * @function calculateGlobalZoomLevel
* @returns {GlobalZoomLevelInfo}
*/
function calculateGlobalZoomLevel(): GlobalZoomLevelInfo {
// 获取环境原始数据
const devicePixelRatio = window.devicePixelRatio || 1;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// 基础缩放级别计算
// 在标准显示器上devicePixelRatio 通常为 1
// 在高分辨率显示器(如 Retina基础 DPR 可能为 2 或更高
// 检测基础 DPR未缩放时的 DPR
// 通过屏幕尺寸和视口尺寸的关系来推断
// 步骤 1基础 DPR (Base Device Pixel Ratio) 估算
// 逻辑:在 4K 或 MacBook 等高分屏上,默认 DPR 为 2此时 100% 缩放表现为 pixelRatio=2
const screenWidth = window.screen.width;
const screenHeight = window.screen.height;
// 估算基础 DPR考虑常见的高分辨率屏幕
let baseDPR = 1;
/**
*
* A>= 2.5K DPR 2
*/
if (screenWidth >= 2560 || screenHeight >= 1440) {
baseDPR = 2; // 可能是高分辨率屏幕
} else if (screenWidth >= 1920 && devicePixelRatio >= 1.5) {
baseDPR = 2;
}
/**
* B1080P 1
*/
else if (screenWidth >= 1920 && devicePixelRatio >= 1.5) {
baseDPR = devicePixelRatio > 2 ? 2 : 1;
}
// 计算相对于基础 DPR 的缩放级别
// 步骤 2计算相对缩放比例当前 DPR / 硬件基础 DPR
const relativeZoom = devicePixelRatio / baseDPR;
// 将缩放级别转换为百分比并四舍五入到常见值
// 将比例转换为百分比整数
let zoomLevel = Math.round(relativeZoom * 100);
// 校正到常见的缩放级别(包括高缩放级别)
/**
* 3Quantization
*
*
*/
const commonZoomLevels = [50, 67, 75, 80, 90, 100, 110, 125, 150, 175, 200, 250, 300, 400, 500];
const closest = commonZoomLevels.reduce((prev, curr) =>
Math.abs(curr - zoomLevel) < Math.abs(prev - zoomLevel) ? curr : prev
);
// 如果计算结果接近常见缩放级别,使用常见值
// 若计算偏差在 15% 容差范围内,则执行自动吸附
if (Math.abs(closest - zoomLevel) <= 15) {
zoomLevel = closest;
}
// 额外的视口尺寸检查(增强版)
// 如果视口非常小,可能是高缩放级别
/**
* 4
*
* 600px
*/
if (viewportWidth < 600 && zoomLevel < 300) {
// 基于视口宽度推断可能的缩放级别
// 建立视口宽度与最小预期缩放的映射关系
if (viewportWidth < 150) zoomLevel = Math.max(zoomLevel, 500);
else if (viewportWidth < 200) zoomLevel = Math.max(zoomLevel, 400);
else if (viewportWidth < 300) zoomLevel = Math.max(zoomLevel, 300);
@ -129,18 +156,20 @@ function calculateGlobalZoomLevel(): GlobalZoomLevelInfo {
else if (viewportWidth < 500) zoomLevel = Math.max(zoomLevel, 200);
}
// 计算各种状态标志
// 步骤 5状态标志合成与阈值计算
const isHighZoom = zoomLevel > 200;
const isExtremeZoom = zoomLevel > 300;
// 调用前文定义的幂函数计算补偿因子
const globalScaleFactor = calculateGlobalScaleFactor(zoomLevel);
// 缩放阈值检测
// 步骤 6生成复合状态阈值对象
const thresholds = {
above300: zoomLevel > 300,
above400: zoomLevel > 400,
above500: zoomLevel > 500,
};
// 最终封装返回完整的视口上下文报告
return {
zoomLevel,
isHighZoom,
@ -154,54 +183,68 @@ function calculateGlobalZoomLevel(): GlobalZoomLevelInfo {
}
/**
* Hook
*
* Hook 300%400% 500%
* 使
*
* @returns GlobalZoomLevelInfo
* Hook (Custom Hook)
* @export @function useGlobalZoomLevel
* @description React
* Hook
* 1.
* 2. 'resize'
* 3. matchMedia API
* 4. API 1000ms
* * @returns {GlobalZoomLevelInfo}
*/
export function useGlobalZoomLevel(): GlobalZoomLevelInfo {
// 步骤 1初始化组件状态。通过惰性初始化函数获取初次渲染时的视口数据。
const [zoomInfo, setZoomInfo] = useState<GlobalZoomLevelInfo>(() => calculateGlobalZoomLevel());
useEffect(() => {
/** 状态更新闭包函数 */
const updateZoomLevel = () => {
const newZoomInfo = calculateGlobalZoomLevel();
setZoomInfo(newZoomInfo);
};
// 监听窗口大小变化
// 监听器注册 A监听窗口尺寸重置事件通常伴随缩放操作
window.addEventListener('resize', updateZoomLevel);
// 监听设备像素比变化(缩放变化)
// 使用 matchMedia 监听缩放变化(如果可用)
/**
* B DPI
* matchMedia
* resize DPI
*/
let mediaQueryList: MediaQueryList | null = null;
let handleMediaChange: (() => void) | null = null;
if (typeof window.matchMedia === 'function') {
try {
// 创建一个监控单位逻辑像素的媒体查询实例
mediaQueryList = window.matchMedia('(resolution: 1dppx)');
handleMediaChange = () => {
// 延迟一点执行,确保 devicePixelRatio 已更新
// 步骤:执行 10ms 微任务延迟,确保底层 devicePixelRatio 状态完成硬更新
setTimeout(updateZoomLevel, 10);
};
// 现代浏览器支持 addEventListener
// 步骤:根据浏览器规范选择正确的事件绑定方式
if (mediaQueryList.addEventListener) {
mediaQueryList.addEventListener('change', handleMediaChange);
} else {
// 兼容旧浏览器(已废弃的方法)
// 针对旧版 Safari 或 Edge 的向后兼容处理
(mediaQueryList as any).addListener(handleMediaChange);
}
} catch (error) {
// 忽略 matchMedia 错误,使用定期检查作为备用
// 异常处理:环境不支持 matchMedia 时,退回至常规事件流
console.warn('matchMedia not supported, using fallback polling');
}
}
// 定期检查(作为备用方案)
/**
* Heartbeat
* 1000 Resize
*
*/
const intervalId = setInterval(updateZoomLevel, 1000);
// 钩子清理阶段:撤销所有活动的资源引用,防止内存泄漏。
return () => {
window.removeEventListener('resize', updateZoomLevel);
@ -210,18 +253,20 @@ export function useGlobalZoomLevel(): GlobalZoomLevelInfo {
if (mediaQueryList.removeEventListener) {
mediaQueryList.removeEventListener('change', handleMediaChange);
} else {
// 兼容旧浏览器(已废弃的方法)
// 兼容性清理逻辑
(mediaQueryList as any).removeListener(handleMediaChange);
}
} catch (error) {
// 忽略清理错误
// 抑制清理期间的静默异常
}
}
// 销毁心跳定时器
clearInterval(intervalId);
};
}, []);
// 返回暴露给业务组件的只读状态快照
return zoomInfo;
}

@ -1,68 +1,103 @@
/**
* Utility functions for Project Overview Optimization feature
* Requirements: 3.1, 3.5, 7.1, 8.1, 8.5
* @file project-overview.ts
* @module Utils/UI-Optimization-Engine
* @description
* UI
* *
* 1. Usability-1
* 2. Canvas
* 3. HH:mm Format3
* 4. Tooltip
* * @author Wang Lirong ()
* @version 2.3.1 (Hotfix: Unused parameter resolution)
* @date 2026-01-02
*/
import { PROJECT_NAME_MAX_LENGTH } from '../constants/project-overview';
/**
* Truncate text with ellipsis if it exceeds the maximum length
* Requirement 3.1: Project name truncation at 6 characters
*
* 3.1:
* *
*
* * @function truncateText
* @param {string} text -
* @param {number} [maxLength=PROJECT_NAME_MAX_LENGTH] -
* @returns {string}
*/
export function truncateText(text: string, maxLength: number = PROJECT_NAME_MAX_LENGTH): string {
// 分支判定:内容未达阈值,直接返回原样引用以节省处理开销
if (text.length <= maxLength) {
return text;
}
// 截取前 maxLength 位字符并拼接半角省略号
return text.substring(0, maxLength) + '...';
}
/**
* Truncate project name specifically at 6 characters with ellipsis
* Requirement 3.1: Project name truncation at 6 characters with ellipsis
*
* 3.1: 6
* * @function truncateProjectName
* @param {string} projectName -
*/
export function truncateProjectName(projectName: string): string {
return truncateText(projectName, PROJECT_NAME_MAX_LENGTH);
}
/**
* Truncate description based on available space
* Requirement 7.1: Description truncation logic based on available space
*
* 7.1:
* *
* 1.
* 2. characterWidth
* * @function truncateDescription
* @param {string} description -
* @param {number} availableWidth - px
* @param {number} [characterWidth=8] -
*/
export function truncateDescription(description: string, availableWidth: number, characterWidth: number = 8): string {
// 空值守卫
if (!description) {
return '';
}
// Calculate approximate characters that can fit in available space
// 步骤 1基于可用像素宽度计算理论最大容纳字符数
const maxCharacters = Math.floor(availableWidth / characterWidth);
// 步骤 2容量检查
if (description.length <= maxCharacters) {
return description;
}
// Truncate at word boundary if possible
// 步骤 3尝试执行“词汇友好型”截断
const truncated = description.substring(0, maxCharacters);
const lastSpaceIndex = truncated.lastIndexOf(' ');
// If we can break at a word boundary and it's not too short, do so
/**
* 70%
*
*/
if (lastSpaceIndex > maxCharacters * 0.7) {
return truncated.substring(0, lastSpaceIndex) + '...';
}
// Otherwise, truncate at character boundary
// 兜底策略:若无合适词界,执行字符级硬截断
return truncated + '...';
}
/**
* Check if text needs truncation
*
* Tooltip
* * @function needsTruncation
*/
export function needsTruncation(text: string, maxLength: number = PROJECT_NAME_MAX_LENGTH): boolean {
return text.length > maxLength;
}
/**
* Check if description needs truncation based on available space
* Requirement 7.1: Check if description exceeds available card space
*
* 7.1:
* * @function descriptionNeedsTruncation
*/
export function descriptionNeedsTruncation(description: string, availableWidth: number, characterWidth: number = 8): boolean {
if (!description) {
@ -74,56 +109,73 @@ export function descriptionNeedsTruncation(description: string, availableWidth:
}
/**
* Measure text width (utility for text measurement)
* Requirement 3.5: Add utility functions for text measurement and truncation
* Enhanced with error handling and fallbacks
*
* 3.5:
* *
* Canvas API Off-screen SSR
* * @function measureTextWidth
* @param {string} text -
* @param {number} [fontSize=14] -
* @param {string} [fontFamily='Arial'] -
* @returns {number} (px)
*/
export function measureTextWidth(text: string, fontSize: number = 14, fontFamily: string = 'Arial'): number {
if (!text || typeof text !== 'string') {
return 0;
}
// Try to create a temporary canvas element to measure text
// 步骤 1探测宿主环境兼容性确保 SSR 或测试脚本不抛出异常
try {
// Check if we're in a browser environment
if (typeof document === 'undefined' || typeof window === 'undefined') {
// Fallback for server-side rendering or test environments
// 容错分支:基于统计学常数 0.6 估算平均宽度因子
return text.length * (fontSize * 0.6);
}
// 步骤 2初始化离屏 Canvas 测绘上下文
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (context) {
// 注入目标样式参数
context.font = `${fontSize}px ${fontFamily}`;
// 执行原生 TextMetrics 测量
const metrics = context.measureText(text);
return metrics.width;
}
} catch (error) {
// 异常拦截:当 Canvas 指纹保护等隐私设置导致 API 受限时,执行备选预估方案
console.warn('Canvas text measurement failed, using fallback:', error);
}
// Fallback: estimate based on character count and average character width
// This is a rough approximation for testing purposes
// 步骤 3通用兜底策略
return text.length * (fontSize * 0.6);
}
/**
* Truncate text to fit within a specific pixel width
* Requirement 3.5: Add utility functions for text measurement and truncation
*
* 3.5:
* *
* Binary Search O(n) O(log n)
* * @function truncateToWidth
* @param {string} text -
* @param {number} maxWidth - (px)
*/
export function truncateToWidth(text: string, maxWidth: number, fontSize: number = 14, fontFamily: string = 'Arial'): string {
if (!text) {
return '';
}
// 先检查全量渲染是否符合空间约束
const fullWidth = measureTextWidth(text, fontSize, fontFamily);
if (fullWidth <= maxWidth) {
return text;
}
// Binary search to find the optimal truncation point
/**
*
*
*/
let left = 0;
let right = text.length;
let bestFit = '';
@ -131,12 +183,15 @@ export function truncateToWidth(text: string, maxWidth: number, fontSize: number
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const candidate = text.substring(0, mid) + '...';
// 执行 Canvas 实时测距
const candidateWidth = measureTextWidth(candidate, fontSize, fontFamily);
if (candidateWidth <= maxWidth) {
// 当前长度可行,尝试向右探测更大的容量
bestFit = candidate;
left = mid + 1;
} else {
// 当前长度溢出,向左收缩查找空间
right = mid - 1;
}
}
@ -145,31 +200,39 @@ export function truncateToWidth(text: string, maxWidth: number, fontSize: number
}
/**
* Format date for display
* Requirement 8.1: "YYYY-MM-DD HH:mm" format
* Requirement 8.5: "今天 HH:mm" for today's dates
*
* 8.1: YYYY-MM-DD HH:mm
* 8.5: Today
* * @function formatProjectDate
* @param {string} dateString - ISO 8601
* @returns {string}
*/
export function formatProjectDate(dateString: string): string {
try {
const date = new Date(dateString);
// Check if date is invalid
// 类型安全性校验:拦截解析失败的 Date 对象
if (isNaN(date.getTime())) {
return 'Invalid Date';
}
// 步骤 1获取基准时间。计算当前“今日 0 点”的时间戳。
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const dateOnly = new Date(date.getFullYear(), date.getMonth(), date.getDate());
// Check if the date is today
// 步骤 2判定是否为“今日”发生的业务活动
if (dateOnly.getTime() === today.getTime()) {
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
// 增强 UX展示为“今天 HH:mm”
return `今天 ${hours}:${minutes}`;
}
// Format as YYYY-MM-DD HH:mm
/**
* 3
* ISO 8601
*/
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
@ -178,14 +241,18 @@ export function formatProjectDate(dateString: string): string {
return `${year}-${month}-${day} ${hours}:${minutes}`;
} catch (error) {
// 异常恢复逻辑
return 'Invalid Date';
}
}
/**
* Calculate tooltip position to avoid viewport edges
* Requirement 2.4: Tooltip positioning
* Enhanced with error handling and fallbacks
* Tooltip
* 2.4: UI Viewport Collision Detection
* *
* 1. Top/Bottom/Left/Right
* 2.
* * @function calculateTooltipPosition
*/
export function calculateTooltipPosition(
cardElement: HTMLElement,
@ -193,31 +260,36 @@ export function calculateTooltipPosition(
preferredPlacement: 'top' | 'bottom' | 'left' | 'right' = 'top'
): { x: number; y: number; placement: 'top' | 'bottom' | 'left' | 'right' } {
try {
// Validate inputs
// 参数验证:确保 DOM 节点已完成渲染且可被测量
if (!cardElement || !tooltipElement) {
console.warn('Invalid elements provided to calculateTooltipPosition');
return { x: 0, y: 0, placement: 'top' };
}
// 调用浏览器原生 API 获取几何属性矩阵
const cardRect = cardElement.getBoundingClientRect();
const tooltipRect = tooltipElement.getBoundingClientRect();
// Validate rectangles
// 几何矩阵存在性检查
if (!cardRect || !tooltipRect) {
console.warn('Could not get element rectangles for tooltip positioning');
return { x: 0, y: 0, placement: 'top' };
}
// 建立视口边界快照
const viewport = {
width: window.innerWidth || 1024, // Fallback viewport width
height: window.innerHeight || 768, // Fallback viewport height
width: window.innerWidth || 1024,
height: window.innerHeight || 768,
};
let x = 0;
let y = 0;
let placement = preferredPlacement;
// Calculate position based on preferred placement
/**
* 1
* / 8px
*/
switch (preferredPlacement) {
case 'top':
x = cardRect.left + cardRect.width / 2 - tooltipRect.width / 2;
@ -236,13 +308,16 @@ export function calculateTooltipPosition(
y = cardRect.top + cardRect.height / 2 - tooltipRect.height / 2;
break;
default:
// Fallback to top placement
// 兜底逻辑:默认上方居中
x = cardRect.left + cardRect.width / 2 - tooltipRect.width / 2;
y = cardRect.top - tooltipRect.height - 8;
placement = 'top';
}
// Adjust if tooltip would go outside viewport
/**
* 2Collision Detection
* X/Y Tooltip
*/
if (x < 8) {
x = 8;
if (placement === 'left') placement = 'right';
@ -260,29 +335,33 @@ export function calculateTooltipPosition(
if (placement === 'bottom') placement = 'top';
}
// Ensure coordinates are valid numbers
// 最终数值清洗:确保返回的是合法的非负像素值
x = isNaN(x) ? 0 : Math.max(0, x);
y = isNaN(y) ? 0 : Math.max(0, y);
return { x, y, placement };
} catch (error) {
// 全局防崩溃兜底方案
console.error('Error calculating tooltip position:', error);
// Return safe fallback position
return { x: 0, y: 0, placement: 'top' };
}
}
/**
* Calculate initial tooltip position from mouse event
* Requirement 2.4: Tooltip positioning
* Enhanced with error handling and fallbacks
*
* MouseEnter Tooltip
* * *
* _mouseEvent 线 TypeScript
* API cardElement
* * * @function calculateTooltipPositionFromEvent
* @param {HTMLElement} cardElement - DOM
* @param {React.MouseEvent} _mouseEvent - React
*/
export function calculateTooltipPositionFromEvent(
cardElement: HTMLElement,
mouseEvent: React.MouseEvent<HTMLDivElement>
_mouseEvent: React.MouseEvent<HTMLDivElement>
): { x: number; y: number; placement: 'top' | 'bottom' | 'left' | 'right' } {
try {
// Validate inputs
if (!cardElement) {
console.warn('Invalid card element provided to calculateTooltipPositionFromEvent');
return { x: 0, y: 0, placement: 'top' };
@ -290,30 +369,29 @@ export function calculateTooltipPositionFromEvent(
const cardRect = cardElement.getBoundingClientRect();
// Validate rectangle
if (!cardRect) {
console.warn('Could not get card rectangle for tooltip positioning');
return { x: 0, y: 0, placement: 'top' };
}
// Default position above the card
// 设置默认中心悬停坐标。逻辑实现上,我们当前优先保证 Tooltip 的居中对称美感。
const x = cardRect.left + cardRect.width / 2;
const y = cardRect.top - 8;
// Ensure coordinates are valid numbers
const safeX = isNaN(x) ? 0 : Math.max(0, x);
const safeY = isNaN(y) ? 0 : Math.max(0, y);
return { x: safeX, y: safeY, placement: 'top' };
} catch (error) {
console.error('Error calculating tooltip position from event:', error);
// Return safe fallback position
return { x: 0, y: 0, placement: 'top' };
}
}
/**
* Get Chinese label for project status
*
*
* @param {'initializing' | 'active' | 'inactive'} status -
*/
export function getProjectStatusLabel(status: 'initializing' | 'active' | 'inactive'): string {
const statusLabels = {
@ -325,14 +403,15 @@ export function getProjectStatusLabel(status: 'initializing' | 'active' | 'inact
}
/**
* Check if a project status is valid
*
*/
export function isValidProjectStatus(status: string): status is 'initializing' | 'active' | 'inactive' {
return ['initializing', 'active', 'inactive'].includes(status);
}
/**
* Get status badge color class
*
* BEM CSS Badge
*/
export function getStatusBadgeColorClass(status: 'initializing' | 'active' | 'inactive'): string {
switch (status) {

Loading…
Cancel
Save