feat(web): components update

This commit is contained in:
zhaoying
2026-03-07 12:18:11 +08:00
parent 4c18f9e858
commit 0b3b241436
44 changed files with 1881 additions and 345 deletions

View File

@@ -21,7 +21,6 @@ import { useTranslation } from 'react-i18next';
import { lightTheme } from './styles/antdThemeConfig.ts'
import router from './routes';
import { useI18n } from '@/store/locale'
import LayoutBg from '@/components/Layout/LayoutBg'
import dayjs from 'dayjs'
import 'dayjs/locale/en'
import 'dayjs/locale/zh-cn'
@@ -61,7 +60,6 @@ function App() {
theme={lightTheme}
>
<AntdApp>
<LayoutBg />
<Suspense fallback={<Spin fullscreen></Spin>}>
<RouterProvider
router={router}

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-02 15:01:59
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-02 15:46:05
* @Last Modified time: 2026-02-11 10:53:27
*/
/**
@@ -15,7 +15,7 @@
*/
import { type FC, type ReactNode, useEffect } from 'react';
import { type RadioGroupProps } from 'antd';
import { type RadioGroupProps, Flex } from 'antd';
import clsx from 'clsx'
// Button checkbox component props
@@ -57,12 +57,13 @@ const ButtonCheckbox: FC<ButtonCheckboxProps> = ({
}
return (
<div
className={clsx("rb:flex rb:items-center rb:border rb:rounded-lg rb:px-2 rb:text-[12px] rb:h-6 rb:cursor-pointer rb:hover:bg-[#F0F3F8]", {
<Flex
align="center"
className={clsx("rb:border rb:rounded-lg rb:px-2! rb:text-[12px] rb:h-6 rb:cursor-pointer", {
// Checked state: blue background and border
"rb:bg-[rgba(21,94,239,0.06)] rb:border-[#155EEF] rb:text-[#155EEF]": checked,
"rb:bg-[#FAFAFA] rb:border-[#171719]": checked,
// Unchecked state: gray border and dark text
"rb:border-[#DFE4ED] rb:text-[#212332]": !checked,
"rb:border-[#EBEBEB] rb:text-[#212332] rb:hover:bg-[#F0F3F8]": !checked,
})}
onClick={handleChange}
>
@@ -71,7 +72,7 @@ const ButtonCheckbox: FC<ButtonCheckboxProps> = ({
{/* Display checked icon when checked */}
{checkedIcon && checked && <img src={checkedIcon} className="rb:w-4 rb:h-4 rb:mr-1" />}
{children}
</div>
</Flex>
);
};

View File

@@ -0,0 +1,306 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-10 13:36:03
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-25 13:51:52
*/
/*
* AreaLineChart Component
*
* A reusable area line chart component built with ECharts that displays time-series data
* with gradient-filled areas under the lines. Supports multiple data series with
* customizable colors and responsive behavior.
*
* Features:
* - Multiple line series with gradient area fills
* - Gradient line colors (white to color to white)
* - Customizable x-axis key for flexible data structures
* - Date-based x-axis with formatted labels (DD/MM)
* - Responsive resizing using ResizeObserver
* - Interactive tooltips on hover
* - Customizable grid layout and colors
* - Legend at the bottom for series identification
* - Empty state when no data is available
* - Smooth rendering with requestAnimationFrame
*/
import { type FC, useEffect, useRef, useMemo } from 'react'
import ReactEcharts from 'echarts-for-react';
import * as echarts from 'echarts';
import { formatDateTime } from '@/utils/format';
import Empty from '@/components/Empty'
/** Base configuration for all line series */
const SeriesConfig = {
type: 'line',
stack: 'Total',
symbol: 'circle',
symbolSize: 5,
showSymbol: true,
label: {
show: false,
position: 'top'
},
emphasis: {
focus: 'series'
},
}
/** Default color palette for area line series */
const Colors = ['#155EEF', '#FFB048', '#4DA8FF']
/**
* Data structure for chart data points
* Flexible structure allowing any string key with string or number values
*
* @interface ChartData
* @property {string | number} [key: string] - Dynamic properties for x-axis and data series
*/
export interface ChartData {
[key: string]: string | number;
}
/**
* Props for the AreaLineChart component
*
* @interface AreaLineChartProps
* @property {string} xAxisKey - Key name in chartData to use for x-axis values
* @property {ChartData[]} chartData - Array of data points with dynamic properties
* @property {Record<string, string>} seriesList - Map of data keys to display names
* @property {string} [className] - Additional CSS classes for the container
* @property {number} [height] - Height of the chart in pixels
* @property {string[]} [colors] - Custom color array for line series and gradients
* @property {any} [grid] - ECharts grid configuration for chart positioning
*/
interface AreaLineChartProps {
xAxisKey: string;
chartData: ChartData[];
seriesList: Record<string, string>;
className?: string;
height?: number;
colors?: string[];
grid?: any;
lineStyle?: any;
showLegend?: boolean;
smooth?: boolean;
}
/**
* AreaLineChart Component
*
* Renders a multi-series area line chart with gradient fills.
* The area gradient goes from the series color at the top to white at the bottom.
* The line gradient goes from white to the series color and back to white.
* Automatically resizes when container dimensions change.
*
* @param {AreaLineChartProps} props - Component props
* @returns {JSX.Element} Rendered area line chart or empty state
*
* @example
* ```tsx
* <AreaLineChart
* xAxisKey="date"
* chartData={[
* { date: '2024-01-01', revenue: 1000, profit: 200 },
* { date: '2024-01-02', revenue: 1500, profit: 300 }
* ]}
* seriesList={{ revenue: 'Revenue', profit: 'Profit' }}
* height={300}
* />
* ```
*/
const AreaLineChart: FC<AreaLineChartProps> = ({
xAxisKey,
chartData,
seriesList,
height,
colors = Colors,
grid = {
top: 7,
left: 4,
right: 16,
bottom: 32,
containLabel: true
},
lineStyle,
showLegend = true,
smooth = true
}) => {
/** Reference to the ECharts instance for programmatic control */
const chartRef = useRef<ReactEcharts>(null);
/** Flag to prevent multiple simultaneous resize operations */
const resizeScheduledRef = useRef(false)
/**
* Generate series configuration for each data series with gradient effects
* Creates area fills with vertical gradients (color to white)
* and line colors with horizontal gradients (white to color to white)
*
* @returns {Array} Array of ECharts series configurations with gradient styles
*/
const getSeries = () => {
return Object.entries(seriesList).map(([key, name], index) => ({
...SeriesConfig,
name: name,
data: chartData.map(vo => vo[key as keyof ChartData]),
areaStyle: {
opacity: 0.8,
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: colors[index]
},
{
offset: 1,
color: '#FFFFFF'
}
])
},
lineStyle: lineStyle || {
width: 3,
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{
offset: 0,
color: '#FFFFFF'
},
{
offset: 0.8,
color: colors[index]
},
{
offset: 1,
color: '#FFFFFF'
}
])
},
smooth
}))
}
/**
* Memoized legend data to prevent unnecessary recalculations
* Formats series list for display in chart legend
*/
const formatSeriesList = useMemo(() => {
return Object.entries(seriesList).map(([_key, name]) => ({
...SeriesConfig,
name: name,
}))
}, [seriesList])
/**
* Set up responsive behavior using ResizeObserver
* Resizes chart when parent container dimensions change
*/
useEffect(() => {
const handleResize = () => {
if (chartRef.current && !resizeScheduledRef.current) {
resizeScheduledRef.current = true
requestAnimationFrame(() => {
chartRef.current?.getEchartsInstance().resize();
resizeScheduledRef.current = false
});
}
}
const resizeObserver = new ResizeObserver(handleResize)
const chartElement = chartRef.current?.getEchartsInstance().getDom().parentElement
if (chartElement) {
resizeObserver.observe(chartElement)
}
return () => {
resizeObserver.disconnect()
}
}, [chartData])
return (
<div style={{ height: `${height}px` }}>
{chartData && chartData.length > 0
? <ReactEcharts
ref={chartRef}
option={{
color: colors,
tooltip: {
trigger: 'axis',
extraCssText: 'box-shadow: 0px 2px 6px 0px rgba(33,35,50,0.16); border-radius: 8px;',
axisPointer: {
type: 'line',
crossStyle: {
color: '#5F6266',
},
lineStyle: {
color: '#5F6266',
},
label: {
show: false
}
},
},
legend: {
show: showLegend,
data: formatSeriesList,
textStyle: {
color: '#5B6167',
fontFamily: 'PingFangSC, PingFang SC',
lineHeight: 16,
},
itemGap: 8,
padding: 0,
itemWidth: 26,
itemHeight: 10,
bottom: 0,
lineStyle: {
width: 3,
},
},
grid: grid,
xAxis: {
type: 'category',
data: chartData.map(item => formatDateTime(item[xAxisKey], 'DD/MM')),
boundaryGap: false,
axisLabel: {
color: '#5B6167',
fontFamily: 'PingFangSC, PingFang SC',
lineHeight: 17,
},
axisLine: {
show: false,
lineStyle: {
color: '#EBEBEB',
}
},
splitLine: {
show: false,
},
axisTick: {
show: false
}
},
yAxis: {
type: 'value',
axisLabel: {
color: '#A8A9AA',
fontFamily: 'PingFangSC, PingFang SC',
align: 'right',
lineHeight: 17,
},
axisLine: {
lineStyle: {
color: '#EBEBEB',
}
},
},
series: getSeries()
}}
style={{ height: `${height}px`, width: '100%', minWidth: '100%', boxSizing: 'border-box' }}
opts={{ renderer: 'canvas' }}
notMerge={true}
lazyUpdate={true}
/>
: <Empty size={120} className="rb:h-full!" />
}
</div>
)
}
export default AreaLineChart

View File

@@ -0,0 +1,295 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-10 13:36:03
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-25 13:49:04
*/
/*
* BarChart Component
*
* A reusable area line chart component built with ECharts that displays time-series data
* with gradient-filled areas under the lines. Supports multiple data series with
* customizable colors and responsive behavior.
*
* Features:
* - Multiple line series with gradient area fills
* - Gradient line colors (white to color to white)
* - Customizable x-axis key for flexible data structures
* - Date-based x-axis with formatted labels (DD/MM)
* - Responsive resizing using ResizeObserver
* - Interactive tooltips on hover
* - Customizable grid layout and colors
* - Legend at the bottom for series identification
* - Empty state when no data is available
* - Smooth rendering with requestAnimationFrame
*/
import { type FC, useEffect, useRef, useMemo } from 'react'
import ReactEcharts from 'echarts-for-react';
import * as echarts from 'echarts';
import { formatDateTime } from '@/utils/format';
import Empty from '@/components/Empty'
/** Base configuration for all line series */
const SeriesConfig = {
type: 'bar',
stack: 'Total',
symbol: 'circle',
symbolSize: 5,
showSymbol: true,
label: {
show: false,
position: 'top'
},
emphasis: {
focus: 'series'
},
showBackground: true,
}
/** Default color palette for area line series */
const Colors = ['#155EEF', '#FFB048', '#4DA8FF']
/**
* Data structure for chart data points
* Flexible structure allowing any string key with string or number values
*
* @interface ChartData
* @property {string | number} [key: string] - Dynamic properties for x-axis and data series
*/
export interface ChartData {
[key: string]: string | number;
}
/**
* Props for the BarChart component
*
* @interface BarChartProps
* @property {string} xAxisKey - Key name in chartData to use for x-axis values
* @property {ChartData[]} chartData - Array of data points with dynamic properties
* @property {Record<string, string>} seriesList - Map of data keys to display names
* @property {string} [className] - Additional CSS classes for the container
* @property {number} [height] - Height of the chart in pixels
* @property {string[]} [colors] - Custom color array for line series and gradients
* @property {any} [grid] - ECharts grid configuration for chart positioning
*/
interface BarChartProps {
xAxisKey: string;
chartData: ChartData[];
seriesList: Record<string, string>;
className?: string;
height?: number;
colors?: string[];
grid?: any;
itemStyle?: any;
showLegend?: boolean;
showBackground?: boolean;
}
/**
* BarChart Component
*
* Renders a multi-series area line chart with gradient fills.
* The area gradient goes from the series color at the top to white at the bottom.
* The line gradient goes from white to the series color and back to white.
* Automatically resizes when container dimensions change.
*
* @param {BarChartProps} props - Component props
* @returns {JSX.Element} Rendered area line chart or empty state
*
* @example
* ```tsx
* <BarChart
* xAxisKey="date"
* chartData={[
* { date: '2024-01-01', revenue: 1000, profit: 200 },
* { date: '2024-01-02', revenue: 1500, profit: 300 }
* ]}
* seriesList={{ revenue: 'Revenue', profit: 'Profit' }}
* height={300}
* />
* ```
*/
const BarChart: FC<BarChartProps> = ({
xAxisKey,
chartData,
seriesList,
height,
colors = Colors,
grid = {
top: 7,
left: 4,
right: 16,
bottom: 32,
containLabel: true
},
itemStyle,
showLegend = true,
showBackground = true,
}) => {
/** Reference to the ECharts instance for programmatic control */
const chartRef = useRef<ReactEcharts>(null);
/** Flag to prevent multiple simultaneous resize operations */
const resizeScheduledRef = useRef(false)
/**
* Generate series configuration for each data series with gradient effects
* Creates area fills with vertical gradients (color to white)
* and line colors with horizontal gradients (white to color to white)
*
* @returns {Array} Array of ECharts series configurations with gradient styles
*/
const getSeries = () => {
return Object.entries(seriesList).map(([key, name], index) => ({
...SeriesConfig,
name: name,
data: chartData.map(vo => vo[key as keyof ChartData]),
barWidth: 16,
itemStyle: itemStyle || {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: colors[index]
},
{
offset: 1,
color: '#FFFFFF'
}
]),
},
emphasis: {
itemStyle: {
}
},
barGap: '-100%',
showBackground: showBackground,
}))
}
/**
* Memoized legend data to prevent unnecessary recalculations
* Formats series list for display in chart legend
*/
const formatSeriesList = useMemo(() => {
return Object.entries(seriesList).map(([_key, name]) => ({
...SeriesConfig,
name: name,
}))
}, [seriesList])
/**
* Set up responsive behavior using ResizeObserver
* Resizes chart when parent container dimensions change
*/
useEffect(() => {
const handleResize = () => {
if (chartRef.current && !resizeScheduledRef.current) {
resizeScheduledRef.current = true
requestAnimationFrame(() => {
chartRef.current?.getEchartsInstance().resize();
resizeScheduledRef.current = false
});
}
}
const resizeObserver = new ResizeObserver(handleResize)
const chartElement = chartRef.current?.getEchartsInstance().getDom().parentElement
if (chartElement) {
resizeObserver.observe(chartElement)
}
return () => {
resizeObserver.disconnect()
}
}, [chartData])
return (
<div style={{ height: `${height}px` }}>
{chartData && chartData.length > 0
? <ReactEcharts
ref={chartRef}
option={{
color: colors,
tooltip: {
trigger: 'axis',
extraCssText: 'box-shadow: 0px 2px 6px 0px rgba(33,35,50,0.16); border-radius: 8px;',
axisPointer: {
type: 'line',
crossStyle: {
color: '#5F6266',
},
lineStyle: {
color: '#5F6266',
},
label: {
show: false
}
},
},
legend: {
show: showLegend,
data: formatSeriesList,
textStyle: {
color: '#5B6167',
fontFamily: 'PingFangSC, PingFang SC',
lineHeight: 16,
},
itemGap: 8,
padding: 0,
itemWidth: 26,
itemHeight: 10,
bottom: 0,
itemStyle: {
width: 3,
},
},
grid: grid,
xAxis: {
type: 'category',
data: chartData.map(item => formatDateTime(item[xAxisKey], 'DD/MM')),
boundaryGap: false,
axisLabel: {
color: '#5B6167',
fontFamily: 'PingFangSC, PingFang SC',
lineHeight: 17,
},
axisLine: {
show: false,
itemStyle: {
color: '#EBEBEB',
}
},
splitLine: {
show: false,
},
axisTick: {
show: false
}
},
yAxis: {
type: 'value',
axisLabel: {
color: '#A8A9AA',
fontFamily: 'PingFangSC, PingFang SC',
align: 'right',
lineHeight: 17,
},
axisLine: {
itemStyle: {
color: '#EBEBEB',
}
},
},
series: getSeries()
}}
style={{ height: `${height}px`, width: '100%', minWidth: '100%', boxSizing: 'border-box' }}
opts={{ renderer: 'canvas' }}
notMerge={true}
lazyUpdate={true}
/>
: <Empty size={120} className="rb:h-full!" />
}
</div>
)
}
export default BarChart

View File

@@ -0,0 +1,200 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-10 14:06:09
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-10 14:06:09
*/
/**
* GraphNetworkChart Component
*
* A force-directed graph visualization component built with ECharts.
* Displays nodes and edges in an interactive network diagram with physics-based layout.
* Supports zooming, panning, dragging nodes, and click interactions.
*/
import { type FC, useEffect, useRef, type SetStateAction, type Dispatch } from 'react'
import ReactEcharts from 'echarts-for-react';
import PageEmpty from '@/components/Empty/PageEmpty'
// Default color palette for node categories
const Colors = ['#171719', '#155EEF', '#9C6FFF', '#FF8A4C']
/**
* Node interface representing a graph node/vertex
*/
export interface Node {
id: string; // Unique identifier for the node
label: string; // Display label for the node
category: number; // Category index for grouping and coloring
symbolSize: number; // Size of the node symbol in pixels
name: string; // Node name (used in ECharts)
itemStyle: {
color: string; // Custom color for this node
}
caption: string; // Additional description or caption
[key: string]: any; // Allow additional custom properties
}
/**
* Edge interface representing a connection between two nodes
*/
export interface Edge {
id: string; // Unique identifier for the edge
source: string; // Source node ID
target: string; // Target node ID
type: string; // Type/category of the relationship
caption: string; // Description of the relationship
value: number; // Numeric value associated with the edge
weight: number; // Weight/strength of the connection
}
/**
* Props for the GraphNetworkChart component
*/
interface GraphNetworkChartProps {
nodes: Node[]; // Array of nodes to display in the graph
links: Edge[]; // Array of edges connecting the nodes
categories: { name: string }[]; // Category definitions for node grouping
colors?: string[]; // Optional custom color palette (defaults to Colors)
onNodeClick: Dispatch<SetStateAction<Node | null>>; // Callback when a node is clicked
}
const GraphNetworkChart: FC<GraphNetworkChartProps> = ({
nodes,
links,
categories,
colors = Colors,
onNodeClick,
}) => {
// Reference to the ECharts instance for programmatic control
const chartRef = useRef<ReactEcharts>(null);
// Flag to prevent multiple simultaneous resize operations (debouncing)
const resizeScheduledRef = useRef(false)
/**
* Effect: Handle responsive chart resizing
*
* Uses ResizeObserver to detect container size changes and resize the chart accordingly.
* Implements requestAnimationFrame for smooth, debounced resize operations.
* Re-runs when nodes change to ensure proper sizing with new data.
*/
useEffect(() => {
const handleResize = () => {
if (chartRef.current && !resizeScheduledRef.current) {
resizeScheduledRef.current = true
// Use requestAnimationFrame for smooth, optimized resize
requestAnimationFrame(() => {
chartRef.current?.getEchartsInstance().resize();
resizeScheduledRef.current = false
});
}
}
// Observe the chart container for size changes
const resizeObserver = new ResizeObserver(handleResize)
const chartElement = chartRef.current?.getEchartsInstance().getDom().parentElement
if (chartElement) {
resizeObserver.observe(chartElement)
}
// Cleanup: disconnect observer when component unmounts
return () => {
resizeObserver.disconnect()
}
}, [nodes])
return (
<div className="rb:w-full rb:h-full">
{/* Render chart only if nodes exist, otherwise show empty state */}
{nodes && nodes.length > 0
? <ReactEcharts
ref={chartRef}
option={{
// Color palette for node categories
colors: colors,
// Disable default tooltip (custom interaction via onNodeClick)
tooltip: {
show: false
},
// Hide legend (categories not displayed in legend)
legend: {
show: false,
bottom: 12,
},
series: [
{
type: 'graph', // Graph/network chart type
layout: 'force', // Force-directed layout algorithm
data: nodes || [], // Node data
links: links || [], // Edge data
categories: categories, // Category definitions
roam: true, // Enable zoom and pan interactions
// Dynamic zoom level based on node count for better initial view
zoom: nodes.length < 50 ? 3 : nodes.length < 100 ? 2 : 1,
// Node label configuration
label: {
show: true, // Display labels
position: 'right', // Position label to the right of node
formatter: '{b}', // Use node name as label text
},
// Edge styling
lineStyle: {
color: '#5B6167', // Gray color for edges
curveness: 0.3 // Slight curve for better visibility
},
// Force-directed layout physics configuration
force: {
repulsion: 100, // Repulsion force between nodes
edgeLength: 80, // Ideal distance between connected nodes
gravity: 0.3, // Gravity pulling nodes to center
layoutAnimation: true, // Animate layout changes
preventOverlap: true, // Prevent nodes from overlapping
edgeSymbol: ['none', 'arrow'], // Arrow on target end of edge
edgeSymbolSize: [4, 10], // Size of edge symbols
initLayout: 'force' // Use force-directed for initial layout
},
selectedMode: 'single', // Allow selecting one node at a time
draggable: true, // Enable dragging nodes
animationDurationUpdate: 0, // Disable animation on data update for performance
// Styling for selected nodes
select: {
itemStyle: {
borderWidth: 2, // Thicker border when selected
borderColor: '#ffffff', // White border for contrast
shadowBlur: 10, // Glow effect on selection
}
}
}
]
}}
style={{ height: '100%', width: '100%' }}
notMerge={false} // Merge options instead of replacing (better performance)
lazyUpdate={true} // Batch updates for better performance
// Event handlers
onEvents={{
click: (params: { dataType: string; data: Node; name: string }) => {
// Only trigger callback for node clicks (not edges or background)
if (params.dataType === 'node') {
onNodeClick(params.data)
}
}
}}
/>
: <PageEmpty />
}
</div>
)
}
export default GraphNetworkChart

View File

@@ -0,0 +1,260 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-10 13:35:55
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-10 13:35:55
*/
/*
* LineChart Component
*
* A reusable line chart component built with ECharts for displaying time-series data
* with multiple data series. Supports customizable colors, responsive behavior,
* and interactive tooltips.
*
* Features:
* - Multiple line series with different colors
* - Date-based x-axis with formatted labels (DD/MM)
* - Responsive resizing using ResizeObserver
* - Interactive tooltips on hover
* - Customizable grid layout and colors
* - Legend at the bottom for series identification
* - Empty state when no data is available
* - Smooth rendering with requestAnimationFrame
*/
import { type FC, useEffect, useRef, useMemo } from 'react'
import ReactEcharts from 'echarts-for-react';
import { formatDateTime } from '@/utils/format';
import Empty from '@/components/Empty'
/** Base configuration for all line series */
const SeriesConfig = {
type: 'line',
stack: 'Total',
symbol: 'circle',
symbolSize: 5,
showSymbol: true,
label: {
show: false,
position: 'top'
},
emphasis: {
focus: 'series'
},
}
/** Default color palette for line series */
const Colors = ['#171719', '#155EEF', '#FF5D34']
/**
* Data structure for chart data points
*
* @interface ChartData
* @property {string | number} date - Date value for x-axis (timestamp or date string)
* @property {string | number} [key: string] - Dynamic properties for different data series
*/
export interface ChartData {
date: string | number;
[key: string]: string | number;
}
/**
* Props for the LineChart component
*
* @interface LineChartProps
* @property {ChartData[]} chartData - Array of data points with date and series values
* @property {Record<string, string>} seriesList - Map of data keys to display names
* @property {string} [className] - Additional CSS classes for the container
* @property {number} [height] - Height of the chart in pixels
* @property {string[]} [colors] - Custom color array for line series
* @property {any} [grid] - ECharts grid configuration for chart positioning
*/
interface LineChartProps {
chartData: ChartData[];
seriesList: Record<string, string>;
className?: string;
height?: number;
colors?: string[];
grid?: any;
}
/**
* LineChart Component
*
* Renders a multi-series line chart with date-based x-axis.
* Automatically resizes when container dimensions change.
*
* @param {LineChartProps} props - Component props
* @returns {JSX.Element} Rendered line chart or empty state
*
* @example
* ```tsx
* <LineChart
* chartData={[
* { date: '2024-01-01', users: 100, sessions: 200 },
* { date: '2024-01-02', users: 150, sessions: 250 }
* ]}
* seriesList={{ users: 'Active Users', sessions: 'Sessions' }}
* height={300}
* />
* ```
*/
const LineChart: FC<LineChartProps> = ({
chartData,
seriesList,
height,
colors = Colors,
grid = {
top: 7,
right: 16,
}
}) => {
/** Reference to the ECharts instance for programmatic control */
const chartRef = useRef<ReactEcharts>(null);
/** Flag to prevent multiple simultaneous resize operations */
const resizeScheduledRef = useRef(false)
/**
* Generate series configuration for each data series
* Maps seriesList keys to chart series with corresponding data and colors
*
* @returns {Array} Array of ECharts series configurations
*/
const getSeries = () => {
return Object.entries(seriesList).map(([key, name], index) => ({
...SeriesConfig,
name: name,
data: chartData.map(vo => vo[key as keyof ChartData]),
lineStyle: {
width: 2,
color: colors[index]
},
}))
}
/**
* Memoized legend data to prevent unnecessary recalculations
* Formats series list for display in chart legend
*/
const formatSeriesList = useMemo(() => {
return Object.entries(seriesList).map(([_key, name]) => ({
...SeriesConfig,
name: name,
}))
}, [seriesList])
/**
* Set up responsive behavior using ResizeObserver
* Resizes chart when parent container dimensions change
*/
useEffect(() => {
const handleResize = () => {
if (chartRef.current && !resizeScheduledRef.current) {
resizeScheduledRef.current = true
requestAnimationFrame(() => {
chartRef.current?.getEchartsInstance().resize();
resizeScheduledRef.current = false
});
}
}
const resizeObserver = new ResizeObserver(handleResize)
const chartElement = chartRef.current?.getEchartsInstance().getDom().parentElement
if (chartElement) {
resizeObserver.observe(chartElement)
}
return () => {
resizeObserver.disconnect()
}
}, [chartData])
return (
<div style={{ height: `${height}px` }}>
{chartData && chartData.length > 0
? <ReactEcharts
ref={chartRef}
option={{
color: colors,
tooltip: {
trigger: 'axis',
extraCssText: 'box-shadow: 0px 2px 6px 0px rgba(33,35,50,0.16); border-radius: 8px;',
axisPointer: {
type: 'line',
crossStyle: {
color: '#5F6266',
},
lineStyle: {
color: '#5F6266',
},
label: {
show: false
}
},
},
legend: {
data: formatSeriesList,
textStyle: {
color: '#5B6167',
fontFamily: 'PingFangSC, PingFang SC',
lineHeight: 16,
},
itemGap: 8,
padding: 0,
itemWidth: 26,
itemHeight: 10,
bottom: 0,
lineStyle: {
width: 3,
},
},
grid: grid,
xAxis: {
type: 'category',
data: chartData.map(item => formatDateTime(item.date, 'DD/MM')),
boundaryGap: false,
axisLabel: {
color: '#5B6167',
fontFamily: 'PingFangSC, PingFang SC',
lineHeight: 17,
},
axisLine: {
show: false,
lineStyle: {
color: '#EBEBEB',
}
},
splitLine: {
show: false,
},
axisTick: {
show: false
}
},
yAxis: {
type: 'value',
axisLabel: {
color: '#A8A9AA',
fontFamily: 'PingFangSC, PingFang SC',
align: 'right',
lineHeight: 17,
},
axisLine: {
lineStyle: {
color: '#EBEBEB',
}
},
},
series: getSeries()
}}
style={{ height: '100%', width: '100%', minWidth: '100%', boxSizing: 'border-box' }}
opts={{ renderer: 'canvas' }}
notMerge={true}
lazyUpdate={true}
/>
: <Empty size={120} className="rb:h-full!" />
}
</div>
)
}
export default LineChart

View File

@@ -0,0 +1,193 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-10 13:35:45
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-10 13:35:45
*/
/*
* PieChart Component
*
* A reusable pie chart component built with ECharts that displays data distribution
* in a donut chart format with customizable colors and responsive behavior.
*
* Features:
* - Donut-style pie chart with percentage labels
* - Customizable color palette
* - Responsive resizing using ResizeObserver
* - Hover tooltips showing percentage values
* - Legend at the bottom with horizontal layout
* - Empty state when no data is available
* - Shadow effects for better visual depth
*/
import { type FC, useEffect, useRef } from 'react'
import ReactEcharts from 'echarts-for-react';
import Empty from '@/components/Empty'
/** Default color palette for pie chart segments */
const Colors = ['#171719', '#155EEF', '#4DA8FF', '#9C6FFF', '#ABEBFF', '#DFE4ED']
/**
* Data structure for each pie chart segment
*
* @interface ChartData
* @property {string} name - Label for the segment (displayed in legend)
* @property {number} value - Numeric value for the segment (determines size)
*/
export interface ChartData {
name: string;
value: number;
}
/**
* Props for the PieChart component
*
* @interface PieChartProps
* @property {ChartData[]} chartData - Array of data points to display in the chart
* @property {number} [height=260] - Height of the chart in pixels
* @property {string[]} [colors] - Custom color array for chart segments (defaults to Colors)
*/
interface PieChartProps {
chartData: ChartData[];
height?: number;
colors?: string[];
}
/**
* PieChart Component
*
* Renders a donut-style pie chart with percentage labels and legend.
* Automatically resizes when container dimensions change.
*
* @param {PieChartProps} props - Component props
* @returns {JSX.Element} Rendered pie chart or empty state
*
* @example
* ```tsx
* <PieChart
* chartData={[
* { name: 'Category A', value: 30 },
* { name: 'Category B', value: 70 }
* ]}
* height={300}
* />
* ```
*/
const PieChart: FC<PieChartProps> = ({
chartData,
height = 260,
colors = Colors,
}) => {
/** Reference to the ECharts instance for programmatic control */
const chartRef = useRef<ReactEcharts>(null);
/** Flag to prevent multiple simultaneous resize operations */
const resizeScheduledRef = useRef(false)
/**
* Set up responsive behavior using ResizeObserver
* Resizes chart when parent container dimensions change
*/
useEffect(() => {
const handleResize = () => {
if (chartRef.current && !resizeScheduledRef.current) {
resizeScheduledRef.current = true
// Use requestAnimationFrame for smooth resize performance
requestAnimationFrame(() => {
chartRef.current?.getEchartsInstance().resize();
resizeScheduledRef.current = false
});
}
}
const resizeObserver = new ResizeObserver(handleResize)
const chartElement = chartRef.current?.getEchartsInstance().getDom().parentElement
if (chartElement) {
resizeObserver.observe(chartElement)
}
// Cleanup: disconnect observer when component unmounts
return () => {
resizeObserver.disconnect()
}
}, [chartData])
return (
<div style={{ height: `${height}px` }}>
{chartData && chartData.length > 0
? <ReactEcharts
ref={chartRef}
option={{
color: colors,
tooltip: {
trigger: 'item',
textStyle: {
color: '#5B6167',
fontSize: 12,
width: 27,
height: 16,
},
formatter: '{d}%',
padding: [8, 5],
backgroundColor: '#FFFFFF',
borderColor: '#DFE4ED',
extraCssText: 'width: 36px; height: 36px; box-shadow: 0px 2px 4px 0px rgba(33,35,50,0.12);border-radius: 36px;'
},
legend: {
bottom: 0,
padding: 0,
itemWidth: 12,
itemHeight: 12,
borderRadius: 2,
orient: 'horizontal',
itemGap: 48,
textStyle: {
color: '#5B6167',
fontFamily: 'PingFangSC, PingFang SC',
lineHeight: 16,
}
},
series: [
{
type: 'pie',
radius: ['60%', '100%'],
avoidLabelOverlap: false,
percentPrecision: 0,
padAngle: 1,
width: 182,
height: 182,
left: 'center',
top: 24,
itemStyle: {
borderRadius: 2,
shadowBlur: 4,
shadowOffsetX: 0,
shadowOffsetY: 2,
shadowColor: 'rgba(0,0,0,0.25)',
},
label: {
fontWeight: 'bold',
color: '#171719',
formatter: '{d}%',
fontFamily: 'MiSans-Demibold',
},
labelLine: {
lineStyle: {
color: '#DFE4ED'
}
},
data: chartData
}
]
}}
style={{ height: `${height}px`, width: '100%', minWidth: '100%', boxSizing: 'border-box' }}
opts={{ renderer: 'canvas' }}
notMerge={true}
lazyUpdate={true}
/>
: <Empty size={120} className="rb:h-full!" />
}
</div>
)
}
export default PieChart

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2025-12-10 16:46:17
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-06 21:05:52
* @Last Modified time: 2026-02-25 19:04:55
*/
import { type FC, useRef, useEffect } from 'react'
import clsx from 'clsx'
@@ -85,26 +85,29 @@ const ChatContent: FC<ChatContentProps> = ({
: <>
{/* Top label (such as timestamp, username, etc.) */}
{labelPosition === 'top' &&
<div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-4 rb:font-regular">
<div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-4 rb:font-regular rb:px-1">
{labelFormat(item)}
</div>
}
{/* Message bubble */}
<div className={clsx('rb:border rb:text-left rb:rounded-lg rb:mt-1.5 rb:leading-4.5 rb:p-[10px_12px_2px_12px] rb:inline-block rb:max-w-130 rb:wrap-break-word', contentClassNames, {
<div className={clsx('rb:text-left rb:rounded-lg rb:leading-5 rb:p-[10px_12px_2px_12px] rb:inline-block rb:max-w-130 rb:wrap-break-word rb:relative', contentClassNames, {
// Error message style (content is null and not assistant message)
'rb:border-[rgba(255,93,52,0.30)] rb:bg-[rgba(255,93,52,0.08)] rb:text-[#FF5D34]': errorDesc && item.role === 'assistant' && item.content === null && !renderRuntime,
'rb:bg-[rgba(255,93,52,0.08)] rb:text-[#FF5D34]': (item.status && item.status !== 'completed') || (errorDesc && item.role === 'assistant' && item.content === null && !renderRuntime),
// Assistant message style
'rb:bg-[rgba(21,94,239,0.08)] rb:border-[rgba(21,94,239,0.30)]': item.role === 'user',
'rb:bg-[#E3EBFD]': item.role === 'user',
// User message style
'rb:bg-[#FFFFFF] rb:border-[#EBEBEB]': item.role === 'assistant' && (item.content || item.content === '' || typeof renderRuntime === 'function'),
'rb:bg-[#F6F6F6] rb:text-[#212332]': item.role === 'assistant' && (item.content || item.content === '' || typeof renderRuntime === 'function'),
'rb:mt-1.5': labelPosition === 'top',
'rb:mb-1.5': labelPosition === 'bottom',
})}>
{item.status && <div className="rb:size-5 rb:bg-cover rb:bg-[url('@/assets/images/conversation/exclamation_circle.svg')] rb:absolute rb:-left-7"></div>}
{item.subContent && renderRuntime && renderRuntime(item, index)}
{/* Render message content using Markdown component */}
<Markdown content={renderRuntime ? item.content ?? '' : item.content ?? errorDesc ?? ''} />
</div>
{/* Bottom label (such as timestamp, username, etc.) */}
{labelPosition === 'bottom' &&
<div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-4 rb:font-regular rb:mt-2">
<div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-4 rb:font-regular">
{labelFormat(item)}
</div>
}

View File

@@ -4,12 +4,10 @@
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-06 13:36:20
*/
import { type FC, useEffect, useMemo } from 'react'
import { type FC, useEffect, useMemo, useState } from 'react'
import { Flex, Input, Form } from 'antd'
import clsx from 'clsx'
import SendIcon from '@/assets/images/conversation/send.svg'
import SendDisabledIcon from '@/assets/images/conversation/sendDisabled.svg'
import LoadingIcon from '@/assets/images/conversation/loading.svg'
import type { ChatInputProps } from './types'
/**
@@ -28,6 +26,7 @@ const ChatInput: FC<ChatInputProps> = ({
}) => {
const [form] = Form.useForm()
const values = Form.useWatch([], form)
const [isFocus, setIsFocus] = useState(false)
// Monitor form value changes to control send button state
// Clear form when external message is empty
@@ -69,9 +68,18 @@ const ChatInput: FC<ChatInputProps> = ({
onSend(values.message)
}
const handleFocus = () => {
setIsFocus(true)
}
const handleBlur = () => {
setIsFocus(false)
}
return (
<div className={`rb:absolute rb:bottom-3 rb:left-0 rb:right-0 rb:w-full ${className}`}>
<Flex vertical justify="space-between" className="rb:border rb:border-[#DFE4ED] rb:rounded-xl rb:min-h-30">
<Flex vertical justify="space-between" className={clsx("rb-border rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.1)] rb:rounded-xl rb:min-h-30", {
' rb:border-[#171719]!': isFocus
})}>
{previewFileList.length > 0 && <div className="rb:overflow-x-auto rb:max-w-full"><Flex gap={14} className="rb:mx-3! rb:mt-3! rb:w-max!">
{previewFileList.map((file) => {
if (file.type.includes('image')) {
@@ -108,7 +116,12 @@ const ChatInput: FC<ChatInputProps> = ({
)
}
return (
<div key={file.url || file.uid} className="rb:w-45 rb:text-[12px] rb:gap-2.5 rb:flex rb:items-center rb:group rb:relative rb:rounded-lg rb:bg-[#F0F3F8] rb:py-2 rb:px-2.5">
<Flex
key={file.url || file.uid}
align="center"
gap={10}
className="rb:w-45 rb:text-[12px] rb:group rb:relative rb:rounded-lg rb:bg-[#F0F3F8] rb:py-2! rb:px-2.5!"
>
{(file.type.includes('doc') || file.type.includes('docx') || file.type.includes('word') || file.type.includes('wordprocessingml.document')) && <div
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/word_disabled.svg')] rb:hover:bg-[url('@/assets/images/conversation/word.svg')]"
></div>}
@@ -126,7 +139,7 @@ const ChatInput: FC<ChatInputProps> = ({
className="rb:hidden rb:group-hover:block rb:absolute rb:-right-1 rb:-top-1 rb:size-3.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/delete.svg')] rb:hover:bg-[url('@/assets/images/conversation/delete_hover.svg')]"
onClick={() => handleDelete(file)}
></div>
</div>
</Flex>
)
})}
</Flex></div>}
@@ -145,23 +158,25 @@ const ChatInput: FC<ChatInputProps> = ({
handleSend();
}
}}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</Form.Item>
</Form>
{/* Bottom action area */}
<Flex align="center" justify="space-between" className="rb:m-[0_10px_10px_10px]!">
<Flex align="center" justify="space-between" className="rb:mx-2.5! rb:mt-0! rb:mb-2.5!">
{/* Child component content (such as buttons) */}
<div className="rb:flex-1">{children}</div>
<div className="rb:flex rb:items-center">
<Flex align="center">
{/* Send button - display different icons based on state */}
{loading
? <img src={LoadingIcon} className="rb:w-5.5 rb:h-5.5 rb:cursor-pointer" />
? <div className="rb:size-7 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/loading.svg')]"></div>
: !values || !values?.message || values?.message?.trim() === ''
? <img src={SendDisabledIcon} className="rb:w-5.5 rb:h-5.5 rb:cursor-pointer" />
: <img src={SendIcon} className="rb:w-5.5 rb:h-5.5 rb:cursor-pointer" onClick={handleSend} />
? <div className="rb:size-7 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/sendDisabled.svg')]"></div>
: <div className="rb:size-7 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/send.svg')]" onClick={handleSend}></div>
}
</div>
</Flex>
</Flex>
</Flex>
</div>

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-02 15:02:17
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-02 15:46:29
* @Last Modified time: 2026-03-04 14:43:42
*/
/**
* CustomSelect - A select component that fetches options from an API
@@ -13,7 +13,7 @@
* @component
*/
import { useEffect, useState, useMemo, type FC, type Key } from 'react';
import { useEffect, useState, useMemo, type FC } from 'react';
import { Select } from 'antd';
import type { SelectProps, DefaultOptionType } from 'antd/es/select';
import { useTranslation } from 'react-i18next';
@@ -22,7 +22,7 @@ import { request } from '@/utils/request';
// Generic option type for API response data
interface OptionType {
[key: string]: Key | string | number;
[key: string]: any;
}
// API response structure
@@ -104,7 +104,7 @@ const CustomSelect: FC<CustomSelectProps> = ({
{/* Render options from API data */}
{displayOptions.map((option) => (
<Select.Option key={option[valueKey]} value={option[valueKey]}>
{String(option[labelKey])}
{option[labelKey]}
</Select.Option>
))}
</Select>

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-02 15:04:18
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-02 15:49:01
* @Last Modified time: 2026-02-09 13:50:04
*/
/**
* PageEmpty Component
@@ -22,7 +22,7 @@ import Empty from './index'
/**
* @param size - Icon size in pixels - single number or [width, height] array (default: [240, 210])
*/
const PageEmpty: FC<{ size?: number | number[] }> = ({ size = [240, 210] }) => {
const PageEmpty: FC<{ size?: number | number[]; className?: string; }> = ({ size = [240, 210], className = '' }) => {
const { t } = useTranslation()
return (
<Empty
@@ -30,7 +30,7 @@ const PageEmpty: FC<{ size?: number | number[] }> = ({ size = [240, 210] }) => {
title={t('empty.pageEmpty')}
subTitle={t('empty.pageEmptyDesc')}
size={size}
className="rb:h-full"
className={`rb:h-full ${className}`}
/>
)
}

View File

@@ -22,7 +22,7 @@ import Empty from './index'
/**
* @param size - Icon size in pixels - single number or [width, height] array (default: [240, 210])
*/
const PageLoading: FC<{ size?: number | number[] }> = ({ size = [240, 210] }) => {
const PageLoading: FC<{ size?: number | number[]; className?: string; }> = ({ size = [240, 210], className = '' }) => {
const { t } = useTranslation()
return (
<Empty
@@ -30,7 +30,7 @@ const PageLoading: FC<{ size?: number | number[] }> = ({ size = [240, 210] }) =>
title={t('empty.loadingEmpty')}
subTitle={t('empty.loadingEmptyDesc')}
size={size}
className="rb:h-full"
className={`rb:h-full ${className}`}
/>
)
}

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-02 15:03:25
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-25 11:14:25
* @Last Modified time: 2026-03-04 14:02:53
*/
/**
* Empty Component
@@ -15,6 +15,7 @@
import { type FC, type ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import { Flex } from 'antd';
import emptyIcon from '@/assets/images/empty/empty.svg';
@@ -48,14 +49,19 @@ const Empty: FC<EmptyProps> = ({
// Use custom subtitle or default translation if subtitle is needed
const curSubTitle = isNeedSubTitle ? (subTitle || t('empty.tableEmpty')) : null;
return (
<div className={`rb:flex rb:items-center rb:justify-center rb:flex-col ${className}`}>
<Flex
align="center"
justify="center"
vertical
className={className}
>
{/* Empty state icon */}
<img src={url || emptyIcon} alt="404" style={{ width: `${width}px`, height: `${height}px` }} />
{/* Optional title */}
{title && <div className="rb:mt-2 rb:leading-5">{title}</div>}
{title && <div className="rb:mt-2 rb:leading-5 rb:text-[#212332]">{title}</div>}
{/* Optional subtitle with conditional styling */}
{curSubTitle && <div className={`rb:mt-[${url ? 8 : 5}px] rb:leading-4 rb:text-[12px] rb:text-[#A8A9AA]`}>{curSubTitle}</div>}
</div>
{curSubTitle && <div className={`rb:mt-[${url ? 8 : 5}px] rb:leading-4 rb:text-[12px] rb:text-[#5B6167]`}>{curSubTitle}</div>}
</Flex>
);
}
export default Empty;

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-02 15:06:24
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-02 15:50:49
* @Last Modified time: 2026-02-25 14:52:52
*/
/**
* SwitchFormItem Component
@@ -13,7 +13,7 @@
* @component
*/
import { Switch, Form } from "antd";
import { Switch, Form, Flex } from "antd";
import type { FC, ReactNode } from "react";
import LabelWrapper from './LabelWrapper'
@@ -43,10 +43,14 @@ const SwitchFormItem: FC<SwitchFormItemProps> = ({
disabled
}) => {
return (
<div className={`${className} rb:flex rb:items-center rb:justify-between`}>
<Flex
align="center"
justify="space-between"
className={className}
>
{/* Label and description section */}
<LabelWrapper title={title}>
{desc && <DescWrapper desc={desc} className="rb:mt-2" />}
{desc && <DescWrapper desc={desc} className="rb:mt-1" />}
</LabelWrapper>
{/* Switch control */}
<Form.Item
@@ -56,7 +60,7 @@ const SwitchFormItem: FC<SwitchFormItemProps> = ({
>
<Switch disabled={disabled} size={size} />
</Form.Item>
</div>
</Flex>
)
}

View File

@@ -15,7 +15,7 @@
*/
import { forwardRef, useImperativeHandle, useState, useRef } from 'react';
import { Button, Space } from 'antd';
import { Button, Flex, Space } from 'antd';
import { UnlockOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
@@ -79,49 +79,67 @@ const UserInfoModal = forwardRef<UserInfoModalRef>((_props, ref) => {
footer={null}
>
{/* Basic Information Section */}
<div className="rb:text-[#5B6167] rb:font-medium">{t('header.basicInfo')}</div>
{/* Username */}
<div className="rb:flex rb:justify-between rb:text-[#5B6167] rb:text-[14px] rb:leading-5 rb:mb-3 rb:mt-3">
<span className="rb:whitespace-nowrap">{t('user.username')}</span>
<span className="rb:text-[#212332]">{user.username}</span>
</div>
{/* Email */}
<div className="rb:flex rb:justify-between rb:text-[#5B6167] rb:text-[14px] rb:leading-5 rb:mb-3">
<span className="rb:whitespace-nowrap">{t('user.email')}</span>
<Space size={8} className="rb:text-[#212332]">
{user.email}
<div
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/editBorder.svg')] rb:hover:bg-[url('@/assets/images/editBg.svg')]"
onClick={handleEditEmail}
></div>
</Space>
</div>
{/* Role */}
<div className="rb:flex rb:justify-between rb:text-[#5B6167] rb:text-[14px] rb:leading-5 rb:mb-3">
<span className="rb:whitespace-nowrap">{t('user.role')}</span>
<span className="rb:text-[#212332]">{user.is_superuser ? t('user.superuser') : t('user.normalUser')}</span>
</div>
{/* Created Date */}
<div className="rb:flex rb:justify-between rb:text-[#5B6167] rb:text-[14px] rb:leading-5 rb:mb-3">
<span className="rb:whitespace-nowrap">{t('user.createdAt')}</span>
<span className="rb:text-[#212332]">{formatDateTime(user.created_at, 'YYYY-MM-DD HH:mm:ss')}</span>
</div>
<Flex vertical gap={12}>
<div className="rb:text-[#5B6167] rb:font-medium">{t('header.basicInfo')}</div>
{/* Username */}
<Flex
justify="space-between"
className="rb:text-[#5B6167] rb:leading-5"
>
<span className="rb:whitespace-nowrap">{t('user.username')}</span>
<span className="rb:text-[#212332]">{user.username}</span>
</Flex>
{/* Email */}
<Flex
justify="space-between"
className="rb:text-[#5B6167] rb:leading-5"
>
<span className="rb:whitespace-nowrap">{t('user.email')}</span>
<Space size={8} className="rb:text-[#212332]">
{user.email}
<div
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/editBorder.svg')] rb:hover:bg-[url('@/assets/images/editBg.svg')]"
onClick={handleEditEmail}
></div>
</Space>
</Flex>
{/* Role */}
<Flex
justify="space-between"
className="rb:text-[#5B6167] rb:leading-5"
>
<span className="rb:whitespace-nowrap">{t('user.role')}</span>
<span className="rb:text-[#212332]">{user.is_superuser ? t('user.superuser') : t('user.normalUser')}</span>
</Flex>
{/* Created Date */}
<Flex
justify="space-between"
className="rb:text-[#5B6167] rb:leading-5"
>
<span className="rb:whitespace-nowrap">{t('user.createdAt')}</span>
<span className="rb:text-[#212332]">{formatDateTime(user.created_at, 'YYYY-MM-DD HH:mm:ss')}</span>
</Flex>
</Flex>
{/* Security Settings Section */}
<div className="rb:text-[#5B6167] rb:font-medium rb:mt-6">{t('header.securitySettings')}</div>
<div className="rb:text-[#5B6167] rb:font-medium rb:mt-6 rb:mb-3">{t('header.securitySettings')}</div>
{/* Password Change Card */}
<div className="rb:mt-3 rb:bg-[#F0F3F8] rb:p-[10px_12px] rb:rounded-md rb:flex rb:items-center rb:justify-between rb:gap-2">
<div className="rb:flex rb:items-center rb:gap-3">
<Flex
align="center"
justify="space-between"
gap={8}
className="rb:bg-[#F0F3F8] rb:px-3! rb:py-2.5! rb:rounded-md"
>
<Flex align="center" gap={12}>
<UnlockOutlined className="rb:text-[24px]" />
<div>
<div className="rb:leading-5">{t('header.changePassword')}</div>
<div className="rb:text-[#5B6167] rb:text-[12px] rb:mt-1 rb:leading-4">{t('header.changePasswordDesc')}</div>
</div>
</div>
</Flex>
<Button onClick={() => resetPasswordModalRef.current?.handleOpen(user)}>{t('common.change')}</Button>
</div>
</Flex>
<ResetPasswordModal
ref={resetPasswordModalRef}

View File

@@ -1,13 +1,11 @@
.header {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 46px 16px 21px;
padding: 16px 24px 16px 20px;
height: 64px;
border-bottom: 1px solid #EAECEE;
color: #212332;
z-index: 0;
line-height: 32px;
font-size: 14px;
}
.title {
@@ -17,4 +15,14 @@
}
.header :global(.ant-breadcrumb) {
line-height: 31px;
}
.header :global(.ant-breadcrumb .ant-breadcrumb-item a) {
height: inherit;
color: #5B6167;
font-weight: normal;
}
.header :global(.ant-breadcrumb .ant-breadcrumb-separator:nth-last-child(2)),
.header :global(.ant-breadcrumb .ant-breadcrumb-item:last-child a) {
color: #212332;
font-weight: 600;
}

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-02 15:07:49
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-02 15:07:49
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-05 13:43:59
*/
/**
* AppHeader Component
@@ -121,7 +121,7 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
* - Disables navigation for the last breadcrumb item
*/
const formatBreadcrumbNames = () => {
return breadcrumbs.map((menu, index) => {
return breadcrumbs.filter(item => item.type !== 'group').map((menu, index) => {
const item: any = {
title: menu.i18nKey ? t(menu.i18nKey) : menu.label,
};
@@ -150,14 +150,14 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
return (
<Header className={styles.header}>
{/* Breadcrumb navigation */}
<Breadcrumb separator=">" items={formatBreadcrumbNames() as BreadcrumbProps['items']} />
<Breadcrumb separator="<" items={formatBreadcrumbNames() as BreadcrumbProps['items']} className="rb:font-medium!" />
{/* User info dropdown menu */}
<Dropdown
menu={{
items: userMenuItems
}}
>
<div className="rb:cursor-pointer">{user.username}</div>
<div className="rb:cursor-pointer rb:font-medium">{user.username}</div>
</Dropdown>
<SettingModal
ref={settingModalRef}

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-02 15:11:02
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-02 15:11:02
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 18:43:42
*/
/**
* AuthLayout Component
@@ -61,7 +61,7 @@ const AuthLayout: FC = () => {
{/* Header with breadcrumbs and user menu */}
<AppHeader />
{/* Main content area - renders child routes */}
<Content style={{ padding: '16px 17px 24px 16px', zIndex: 0 }}>
<Content style={{ padding: '0 12px 20px 12px', zIndex: 0 }}>
<Outlet />
</Content>
</Layout>

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-02 15:11:43
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-02 15:11:43
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-05 14:57:08
*/
/**
* AuthSpaceLayout Component
@@ -63,7 +63,7 @@ const AuthSpaceLayout: FC = () => {
{/* Header with breadcrumbs and user menu configured for space mode */}
<AppHeader source="space" />
{/* Main content area for knowledge base pages - renders child routes */}
<Content style={{ padding: '16px 17px 24px 16px', zIndex: 0, height: 'calc(100vh - 64px)', overflowY: 'auto' }}>
<Content style={{ padding: '0 12px 12px 12px', zIndex: 0, height: 'calc(100vh - 64px)', overflowY: 'auto' }}>
<Outlet />
</Content>
</Layout>

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-02 15:12:42
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-02 15:12:42
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-24 15:07:38
*/
/**
* BasicLayout Component
@@ -19,6 +19,7 @@
import { Outlet } from 'react-router-dom';
import { useEffect, type FC } from 'react';
import { Layout } from 'antd';
import { useUser } from '@/store/user';
@@ -36,10 +37,10 @@ const BasicLayout: FC = () => {
}, [getUserInfo, getStorageType]);
return (
<div className="rb:relative rb:h-full rb:w-full">
<Layout className="rb:min-h-screen!">
{/* Render child routes without additional UI */}
<Outlet />
</div>
</Layout>
)
};

View File

@@ -1,41 +0,0 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-02 15:13:20
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-02 15:13:20
*/
/**
* LayoutBg Component
*
* A decorative background component that displays styled background elements.
* Provides visual aesthetics with positioned decorative shapes.
*
* @component
*/
import { type FC } from 'react';
import clsx from 'clsx';
import styles from './layout.module.css';
/**
* Background layout component with decorative elements.
* Renders a fixed full-screen background with styled shapes.
*/
const LayoutBg: FC = () => {
return (
<div className="rb:fixed rb:top-0 rb:right-0 rb:left-0 rb:bottom-0 rb:bg-[#FBFDFF]">
{/* Top section with decorative background shapes */}
<div className={clsx('rb:h-60', styles.bgTop)}>
{/* Left decorative element 1 */}
<div className={clsx(styles.left1)}></div>
{/* Left decorative element 2 */}
<div className={clsx(styles.left2)}></div>
{/* Right decorative element */}
<div className={clsx(styles.right1)}></div>
</div>
</div>
)
};
export default LayoutBg;

View File

@@ -0,0 +1,70 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-10 11:08:27
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-24 15:25:14
*/
/*
* PageHeader Component
*
* A reusable page header component that provides a consistent layout for page titles,
* subtitles, and action buttons across the application.
*
* Features:
* - Displays a main title and optional subtitle
* - Supports custom action buttons or extra content on the right side
* - Fixed height (64px) with responsive layout
* - Text overflow handling with ellipsis
* - Consistent spacing and styling using Tailwind CSS
*/
import { type FC, type ReactNode } from 'react';
import { Layout, Flex } from 'antd';
import clsx from 'clsx';
const { Header } = Layout;
interface ConfigHeaderProps {
avatarUrl?: string | null;
avatarText?: string;
avatarClassName?: string;
title?: ReactNode | string;
operation?: ReactNode;
extra?: ReactNode;
centerContent?: ReactNode;
}
const PageHeader: FC<ConfigHeaderProps> = ({
avatarUrl,
avatarText,
avatarClassName,
title,
operation,
extra,
centerContent
}) => {
return (
// Main header container: full width, 64px height, flex layout with space between
<Header className="rb:w-full rb:h-16 rb:flex rb:items-center rb:justify-between rb:gap-6 rb:px-4! rb:bg-[#FFFFFF]!">
<Flex align="center" gap={8}>
{avatarUrl
? <img src={avatarUrl} alt={avatarUrl} className="rb:size-8 rb:rounded-lg rb:mr-2" />
: avatarText
? <Flex align="center" justify="center" className={clsx(avatarClassName, "rb:size-8 rb:rounded-lg rb:text-[24px] rb:text-[#ffffff] rb:bg-[#155EEF] rb:mr-2")}>{avatarText}</Flex> : null
}
{/* Left section: Title and subtitle */}
<div>
{/* Main title: 18px font, semibold, [#212332] color, single line with ellipsis */}
<div className="rb:leading-5 rb:text-[14px] rb:text-[#212332] rb:font-medium rb:wrap-break-word rb:line-clamp-1">{title}</div>
</div>
{operation}
</Flex>
{centerContent}
{/* Right section: Extra content (buttons, filters, etc.) */}
<Flex align="center" gap={12}>
{extra}
</Flex>
</Header>
);
};
export default PageHeader;

View File

@@ -1,37 +0,0 @@
.bg-top {
width: 100%;
height: 240px;
background: linear-gradient(to bottom, rgba(229, 242, 254, 1), rgba(251, 253, 255, 1));
opacity: 0.8;
}
.left1 {
width: 748px;
height: 354px;
background: radial-gradient(circle, rgba(138, 239, 255, 1) 100%, rgba(232, 244, 255, 0.71) 100%);
opacity: 0.24;
filter: blur(50px);
position: absolute;
left: -374px;
top: -187px;
}
.left2 {
width: 558px;
height: 504px;
background: radial-gradient(circle, #155EEF 0%, rgba(232, 244, 255, 0) 100%);
opacity: 0.22399999999999998;
filter: blur(50px);
position: absolute;
left: -279px;
top: -252px;
}
.right1 {
width: 470px;
height: 474px;
background: radial-gradient(circle, rgba(21, 94, 239, 1) 0%, rgba(232, 244, 255, 0) 100%);
opacity: 0.32;
filter: blur(50px);
position: absolute;
right: -235px;
top: -237px;
}

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-02 15:16:10
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-02 15:16:10
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-25 14:02:17
*/
/**
* RbButton Component
@@ -14,19 +14,12 @@
*/
import { memo } from 'react'
import type { FC, ReactNode } from 'react'
import { Button } from 'antd'
import type { FC } from 'react'
import { Button, type ButtonProps } from 'antd'
/** Props interface for RbButton component */
interface RbButtonProps {
node: {
children: ReactNode;
};
children: string[]
}
/** Button component for rendering buttons in markdown */
const RbButton: FC<RbButtonProps> = (props) => {
const RbButton: FC<ButtonProps> = (props) => {
const { children } = props;
return (

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-02 15:18:19
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 15:44:42
* @Last Modified time: 2026-02-09 13:51:01
*/
/**
* PageScrollList Component
@@ -17,7 +17,7 @@
*/
import React, { useEffect, useState, useRef, forwardRef, useImperativeHandle } from 'react';
import { List } from 'antd';
import { Row, Col } from 'antd';
import InfiniteScroll from 'react-infinite-scroll-component';
import { request } from '@/utils/request';
@@ -58,6 +58,8 @@ interface PageScrollListProps<T, Q = Record<string, unknown>> {
needLoading?: boolean;
}
const heightClass = 'rb:h-[calc(100vh-124px)]!';
/** Infinite scroll list component with pagination support */
const PageScrollList = forwardRef(<T, Q = Record<string, unknown>>({
renderItem,
@@ -136,29 +138,29 @@ const PageScrollList = forwardRef(<T, Q = Record<string, unknown>>({
<div
ref={scrollRef}
id="scrollableDiv"
className={`rb:overflow-y-auto rb:overflow-x-hidden rb:h-[calc(100vh-148px)] ${className}`}
className={`rb:overflow-y-auto rb:overflow-x-hidden ${heightClass} ${className}`}
>
<InfiniteScroll
dataLength={data.length}
next={loadMoreData}
hasMore={hasMore}
loader={loading && needLoading ? <PageLoading /> : false}
loader={loading && needLoading ? <PageLoading className={heightClass} /> : false}
// endMessage={<Divider plain>It is all, nothing more 🤐</Divider>}
scrollableTarget="scrollableDiv"
className='rb:h-full!'
>
{/* Render grid list or empty state */}
{data.length > 0 ? (
<List
grid={{ gutter: 16, column: column }}
dataSource={data}
renderItem={(item) => (
<List.Item>
<Row
gutter={[16, 16]}
>
{data.map((item, index) => (
<Col key={(item as any).id || index} span={24/column}>
{renderItem(item)}
</List.Item>
)}
/>
) : !loading ? <PageEmpty /> : null}
</Col>
))}
</Row>
) : !loading ? <PageEmpty className={heightClass} /> : null}
</InfiniteScroll>
</div>
</>

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-02 15:19:30
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-02 15:19:30
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-04 10:11:37
*/
/**
* RadioGroupCard Component
@@ -95,22 +95,23 @@ const RadioGroupCard: FC<RadioCardProps> = ({
})}>
{/* Render each option as a selectable card */}
{options.map(option => (
<div key={String(option.value)} className={clsx("rb:relative rb:border rb:rounded-lg rb:w-full rb:p-[20px_12px] rb:text-center rb:cursor-pointer", {
'rb:bg-[rgba(21,94,239,0.06)] rb:border-[#155EEF]': option.value === value,
'rb:border-[#EBEBEB] rb:bg-[#ffffff]': option.value !== value,
<div key={String(option.value)} className={clsx("rb:relative rb:border rb:rounded-lg rb:w-full rb:text-center rb:cursor-pointer", {
'rb:border rb:border-[#171719]!': option.value === value,
'rb:border-[#EBEBEB] rb:bg-white': option.value !== value,
'rb:opacity-[0.75]': option.disabled,
'rb:flex rb:items-center rb:text-left rb:gap-4': block,
'rb:py-5 rb:px-3 rb:leading-5.5': !block,
'rb:flex rb:items-center rb:text-left rb:gap-4 rb:py-3 rb:px-4 rb:leading-4': block,
})} onClick={() => handleChange(option)}>
{option.recommend && <div className="rb:absolute rb:right-0 rb:top-0 rb:bg-[#FF5D34] rb:rounded-[0px_7px_0px_8px] rb:text-[12px] rb:text-white rb:font-regular rb:leading-4 rb:p-[4px_8px]">{t('common.recommend')}</div>}
{option.recommend && <div className="rb:absolute rb:right-0 rb:top-0 rb:bg-[#FF5D34] rb:rounded-[0px_7px_0px_8px] rb:text-[12px] rb:text-white rb:font-regular rb:leading-4 rb:py-1 rb:px-2">{t('common.recommend')}</div>}
{/* Use custom render or default card layout */}
{itemRender ? itemRender(option) : (
<>
{option.icon && <img src={option.icon} className={clsx("rb:w-10 rb:h-10", {
{option.icon && <img src={option.icon} className={clsx("rb:size-10", {
'rb:m-[0_auto] rb:mb-3': !block,
})} />}
<div>
<div className="rb:text-[14px] rb:font-medium">{option.label}</div>
<div className="rb:mt-1.5 rb:text-[#5B6167] rb:text-[12px] rb:font-regular">{option.labelDesc}</div>
<div className="rb:font-medium rb:text-[#212332]">{option.label}</div>
<div className="rb:mt-2 rb:text-[#5B6167] rb:text-[12px] rb:font-regular">{option.labelDesc}</div>
</div>
</>
)}

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-02 15:19:59
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-02 15:19:59
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-11 15:02:24
*/
/**
* RbAlert Component
@@ -14,6 +14,7 @@
*/
import { type FC, type ReactNode } from 'react'
import { Flex } from 'antd';
/** Props interface for RbAlert component */
interface RbAlertProps {
@@ -38,10 +39,10 @@ const colors = {
/** Custom alert component with color themes and optional icon */
const RbAlert: FC<RbAlertProps> = ({ color = 'blue', icon, className, children }) => {
return (
<div className={`${colors[color]} ${className} rb:p-[6px_9px] rb:flex rb:items-center rb:text-[12px] rb:font-regular rb:leading-4 rb:border rb:rounded-md`}>
<Flex align="center" className={`${colors[color]} ${className} rb:px-2.25! rb:py-1.5! rb:text-[12px] rb:font-regular rb:leading-4 rb:border rb:rounded-md`}>
{icon && <span className="rb:text-[16px] rb:mr-2.25">{icon}</span>}
{children}
</div>
</Flex>
)
}
export default RbAlert

View File

@@ -0,0 +1,27 @@
.rbButton {
border-radius: 8px;
}
.rbButton:global(:not(.ant-btn-disabled):hover) {
opacity: 0.7;
}
.rbButton:global(.ant-btn-color-dangerous.ant-btn-variant-outlined),
.rbButton:global(.ant-btn-color-dangerous.ant-btn-variant-dashed) {
border-color: #EBEBEB;
color: #171719;
}
.rbButton:global(.ant-btn-color-dangerous.ant-btn-variant-outlined:not(:disabled):not(.ant-btn-disabled):hover),
.rbButton:global(.ant-btn-color-dangerous.ant-btn-variant-dashed:not(:disabled):not(.ant-btn-disabled):hover) {
border-color: #FF5D34;
background-color: rgba(255, 93, 52, 0.06);
color: #FF5D34;
opacity: 1 !important;
}
.rbButton:global(.ant-btn-color-primary.ant-btn-background-ghost:not(:disabled):not(.ant-btn-disabled):hover) {
color: #171719;
border-color: #171719;
}
.rbButton:global(.ant-btn-color-primary.ant-btn-background-ghost:not(:disabled):not(.ant-btn-disabled):active) {
color: #171719;
border-color: #171719;
background-color: #FAFAFA;
}

View File

@@ -0,0 +1,22 @@
import { type FC } from 'react'
import clsx from 'clsx'
import { Button, type ButtonProps } from 'antd'
import styles from './index.module.css'
const RbButton: FC<ButtonProps> = ({
children,
className,
...props
}) => {
return (
<Button
className={clsx(styles.rbButton, className, "rb:hover:shadow-[0px_2px_8px_0px_rgba(23,23,25,0.16)]")}
{...props}
>
{children}
</Button>
)
}
export default RbButton

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-02 15:21:14
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-04 13:49:05
* @Last Modified time: 2026-02-25 16:20:39
*/
/**
* RbCard Component
@@ -27,6 +27,7 @@ interface RbCardProps {
headerClassName?: string;
/** Card title (string, ReactNode, or function) */
title?: string | ReactNode | (() => ReactNode);
titleClassName?: string;
/** Subtitle text displayed below title */
subTitle?: string | ReactNode;
/** Extra content displayed in header (top-right) */
@@ -51,13 +52,14 @@ interface RbCardProps {
className?: string;
/** Click handler */
onClick?: () => void;
variant?: 'borderL';
variant?: 'borderL' | 'borderless' | 'outlined';
}
/** Custom card component with flexible styling and header options */
const RbCard: FC<RbCardProps> = ({
headerClassName,
title,
titleClassName,
subTitle,
extra,
children,
@@ -66,10 +68,10 @@ const RbCard: FC<RbCardProps> = ({
bodyPadding,
bodyClassName: bodyClassNames,
headerType = 'border',
bgColor = '#FBFDFF',
bgColor = '#FFFFFF',
height = 'auto',
className,
variant,
variant = 'borderless',
...props
}) => {
/** Calculate body padding based on header type and avatar presence */
@@ -78,7 +80,7 @@ const RbCard: FC<RbCardProps> = ({
: headerType === 'borderL'
? 'rb:p-[0_16px_12px_16px]!'
: avatarUrl || avatar
? 'rb:p-[16px_20px_16px_16px]!'
? 'rb:p-4!'
: (headerType === 'borderless')
? 'rb:p-[0_20px_16px_16px]!'
: (headerType === 'border' && !avatarUrl && !avatar) || headerType === 'borderBL'
@@ -88,15 +90,15 @@ const RbCard: FC<RbCardProps> = ({
if (variant === 'borderL') {
return (
<div
className="rb:p-[12px_16px] rb:rounded-lg rb:shadow-[inset_4px_0px_0px_0px_#155EEF] rb:border rb:border-[#DFE4ED]"
className="rb:p-[12px_16px] rb:rounded-lg rb:shadow-[inset_4px_0px_0px_0px_#155EEF] rb-border"
>
<Flex justify="space-between" className={`rb:mb-3! ${headerClassName || ''}`}>
<Flex vertical gap={4}>
<div className="rb:font-medium rb:leading-5.5">
{typeof title === 'function' ? title() : title ?
<div className="rb:flex rb:items-center">
<Flex align="center">
{avatarUrl
? <img src={avatarUrl} className="rb:mr-3.25 rb:w-12 rb:h-12 rb:rounded-lg" />
? <img src={avatarUrl} alt={avatarUrl} className="rb:mr-3.25 rb:size-12 rb:rounded-lg" />
: avatar ? avatar : null
}
<div className={
@@ -107,10 +109,10 @@ const RbCard: FC<RbCardProps> = ({
}
)
}>
<div className="rb:w-full rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{title}</div>
<div className={`rb:w-full rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap ${titleClassName}`}>{title}</div>
{subTitle && <div className="rb:text-[#5B6167] rb:text-[12px]">{subTitle}</div>}
</div>
</div> : null
</Flex> : null
}
</div>
{subTitle && <div className="rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-4">{subTitle}</div>}
@@ -125,12 +127,13 @@ const RbCard: FC<RbCardProps> = ({
}
return (
<Card
variant={variant}
{...props}
title={typeof title === 'function' ? title() : title ?
<div className="rb:flex rb:items-center rb:gap-2">
<Flex align="center" gap={12}>
{/* Avatar image or custom avatar component */}
{avatarUrl
? <img src={avatarUrl} className="rb:mr-3.25 rb:w-12 rb:h-12 rb:rounded-lg" />
? <img src={avatarUrl} alt={avatarUrl} className="rb:mr-3.25 rb:size-12 rb:rounded-lg" />
: avatar ? avatar : null
}
<div className={
@@ -142,11 +145,11 @@ const RbCard: FC<RbCardProps> = ({
)
}>
{/* Title with tooltip for overflow text */}
<Tooltip title={title}><div className="rb:w-full rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{title}</div></Tooltip>
<Tooltip title={title}><div className={`rb:w-full rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap ${titleClassName}`}>{title}</div></Tooltip>
{/* Optional subtitle */}
{subTitle && <div className="rb:text-[#5B6167] rb:text-[12px]">{subTitle}</div>}
</div>
</div> : null
</Flex> : null
}
extra={extra}
classNames={{
@@ -154,11 +157,11 @@ const RbCard: FC<RbCardProps> = ({
'rb:font-medium',
{
/** Borderless header style */
'rb:border-[0]! rb:text-[16px] rb:p-[0_16px]!': headerType === 'borderless',
'rb:border-[0]! rb:text-[16px] rb:p-[0_16px]! rb:min-h-10!': headerType === 'borderless',
/** Header with avatar */
'rb:border-[0]! rb:text-[16px] rb:p-[16px_16px_0_16px]!': avatarUrl || avatar,
/** Standard border header */
'rb:text-[18px] rb:p-[0]! rb:m-[0_20px]!': headerType === 'border' && !avatarUrl && !avatar,
'rb:text-[18px] rb:p-[0]! rb:m-[0_20px]! rb:border-b-[0.5px]!': headerType === 'border' && !avatarUrl && !avatar,
/** Border bottom-left style */
"rb:m-[0_16px]! rb:p-[0]! rb:relative rb:before:content-[''] rb:before:w-[4px] rb:before:h-[16px] rb:before:bg-[#5B6167] rb:before:absolute rb:before:top-[50%] rb:before:left-[-16px] rb:before:translate-y-[-50%] rb:before:bg-[#5B6167]! rb:before:h-[16px]!": headerType === 'borderBL',
/** Border left style */
@@ -170,9 +173,13 @@ const RbCard: FC<RbCardProps> = ({
}}
style={{
background: bgColor,
height: height
height: height,
border: variant === 'outlined' ? '1px solid #EBEBEB' : 'none'
}}
className={`rb:hover:shadow-[0px_2px_4px_0px_rgba(0,0,0,0.15)] ${className}`}
className={clsx({
'rb:shadow-none!': variant === 'borderless' || variant === 'outlined',
'rb:hover:shadow-[0px_2px_8px_0px_rgba(23,23,25,0.16)]!': variant !== 'borderless' && variant !== 'outlined'
}, className)}
>
{children}
</Card>

View File

@@ -0,0 +1,76 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-02 15:21:14
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-24 14:59:53
*/
/**
* RbCard Component
*
* A customizable card component that extends Ant Design's Card with:
* - Multiple header styles (border, borderless, borderBL, borderL)
* - Avatar support with image or custom component
* - Flexible padding and styling options
* - Tooltip support for long titles
* - Hover effects
*
* @component
*/
import { type FC, type ReactNode } from 'react'
import { Card, Tooltip, Flex, type CardProps } from 'antd';
import clsx from 'clsx';
/** Props interface for RbCard component */
interface RbCardProps extends CardProps {
children?: ReactNode;
/** Custom avatar component */
avatarText?: string;
avatarClassName?: string;
/** Avatar image URL */
avatarUrl?: string | null;
/** Click handler */
onClick?: () => void;
footer?: ReactNode;
}
/** Custom card component with flexible styling and header options */
const RbCard: FC<RbCardProps> = ({
title,
children,
avatarText,
avatarClassName,
avatarUrl,
footer,
...props
}) => {
return (
<Card
variant="borderless"
{...props}
title={<Flex align="center" gap={12}>
{avatarUrl
? <img src={avatarUrl} alt={avatarUrl} className="rb:size-12 rb:rounded-lg" />
: avatarText
? <Flex align="center" justify="center" className={clsx(avatarClassName, "rb:size-11 rb:rounded-lg rb:text-[24px] rb:text-[#ffffff] rb:bg-[#155EEF]")}>{avatarText}</Flex> : null
}
<Tooltip title={title}>
<div className="rb:flex-1 rb:leading-5.5 rb:min-w-0 rb:whitespace-break-spaces rb:wrap-break-word rb:line-clamp-2">
{title}
</div>
</Tooltip>
</Flex>}
classNames={{
header: 'rb:text-[16px] rb:p-[16px_16px_8px_16px]! rb:border-0!',
body: 'rb:p-4! rb:bg-white!',
}}
className="rb:hover:shadow-[0px_2px_8px_0px_rgba(23,23,25,0.16)]! rb:group"
>
{children}
{footer ? <div className="rb:mt-6">{footer}</div> : null}
</Card>
)
}
export default RbCard

View File

@@ -0,0 +1,21 @@
.rbDescriptions:global(.ant-descriptions.ant-descriptions-bordered>.ant-descriptions-view),
.rbDescriptions:global(.ant-descriptions.ant-descriptions-bordered>.ant-descriptions-view .ant-descriptions-row) {
border: none;
}
.rbDescriptions:global(.ant-descriptions.ant-descriptions-bordered>.ant-descriptions-view .ant-descriptions-row>.ant-descriptions-item-label),
.rbDescriptions:global(.ant-descriptions.ant-descriptions-bordered>.ant-descriptions-view .ant-descriptions-row>.ant-descriptions-item-content) {
border-inline-end: none;
}
.rbDescriptions:global(.ant-descriptions.ant-descriptions-bordered>.ant-descriptions-view .ant-descriptions-row>.ant-descriptions-item-label) {
color: #5B6167;
background: transparent;
}
.rbDescriptions:global(.ant-descriptions.ant-descriptions-bordered>.ant-descriptions-view .ant-descriptions-row>.ant-descriptions-item-label),
.rbDescriptions:global(.ant-descriptions.ant-descriptions-bordered>.ant-descriptions-view .ant-descriptions-row>.ant-descriptions-item-content) {
padding: 0;
line-height: 20px;
}
.rbDescriptions:global(.ant-descriptions.ant-descriptions-bordered>.ant-descriptions-view .ant-descriptions-row:not(:last-child)>.ant-descriptions-item-label),
.rbDescriptions:global(.ant-descriptions.ant-descriptions-bordered>.ant-descriptions-view .ant-descriptions-row:not(:last-child)>.ant-descriptions-item-content) {
padding-bottom: 8px;
}

View File

@@ -0,0 +1,14 @@
import { type FC } from 'react'
import { Descriptions, type DescriptionsProps } from 'antd'
import styles from './index.module.css'
const RbDescriptions: FC<DescriptionsProps> = ({
items,
}) => {
return (
<Descriptions bordered column={1} className={styles.rbDescriptions} items={items} />
)
}
export default RbDescriptions

View File

@@ -19,7 +19,7 @@
*/
import { type FC, useState, useEffect } from 'react'
import { Button, Drawer, Space } from 'antd';
import { Button, Drawer, Space, Flex } from 'antd';
import type { DrawerProps } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
@@ -78,9 +78,9 @@ const RbDrawer: FC<DrawerProps> =({
{...props}
>
{/* Full-height flex container for content */}
<div className='rb:flex rb:flex-col rb:h-full'>
<Flex vertical className='rb:h-full'>
{children}
</div>
</Flex>
</Drawer>
)
}

View File

@@ -0,0 +1,3 @@
.ant-slider-horizontal .ant-slider-rail {
width: calc(100% - 6px);
}

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-02 15:23:39
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-02 15:23:39
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-11 11:10:40
*/
/**
* RbSlider Component
@@ -15,32 +15,59 @@
* @component
*/
import { type FC, useEffect } from 'react';
import { Slider, type SliderSingleProps } from 'antd';
import { type FC, useEffect, useState } from 'react';
import { Slider, type SliderSingleProps, Flex, InputNumber, type InputNumberProps } from 'antd';
/** Props interface for RbSlider component */
interface RbSliderProps extends SliderSingleProps {
/** Callback fired when value changes (for side effects) */
onValueChange?: (value: number | null | undefined) => void;
/** Callback fired when value changes (for side effects) */
onChange?: (value: SliderSingleProps['value']) => void;
isInput?: boolean;
size?: 'small' | 'default';
className?: string;
}
/** Custom slider component with value display */
const RbSlider: FC<RbSliderProps> = ({
value,
min = 0,
max,
onValueChange,
step = 1,
onChange,
step = 0.01,
size = 'default' ,
isInput = false,
className = '',
...rest
}) => {
const [curValue, setCurValue] = useState<SliderSingleProps['value']>(0)
useEffect(() => {
setCurValue(value)
}, [value])
/** Listen to value changes and trigger side effects via onValueChange callback */
useEffect(() => {
if (onValueChange) {
onValueChange(value);
onValueChange(curValue);
}
}, [value, onValueChange]);
}, [curValue, onValueChange]);
const handleInputChange: InputNumberProps['onChange'] = (newValue) => {
onChange?.(newValue as number | undefined);
setCurValue(newValue as number | undefined)
};
const handleSliderChange: SliderSingleProps['onChange'] = (newValue) => {
onChange?.(newValue);
setCurValue(newValue)
};
return (
<div className="rb:flex rb:items-center rb:justify-between rb:gap-2 rb:rounded-[5px]">
<Flex
align="center"
justify="space-between"
gap={12}
className={`rb:rounded-[5px] ${className}`}
>
{/* Slider with fixed width */}
<Slider
style={{
@@ -48,12 +75,29 @@ const RbSlider: FC<RbSliderProps> = ({
width: '384px'
}}
{...rest}
min={min}
max={max}
step={step}
value={value}
value={curValue}
onChange={handleSliderChange}
classNames={size === 'small' ? {
rail: 'rb:w-[calc(100%-6px)]!'
} : undefined}
className={size === 'small' ? `${size} rb:flex-1` : undefined}
/>
{/* Display current value or minimum value */}
<div className="rb:text-[14px] rb:text-[#155EEF] rb:leading-5">{value || min}</div>
</div>
{isInput
? <InputNumber
min={min}
max={max}
step={step as number}
value={curValue}
onChange={handleInputChange}
className="rb:w-20!"
/>
: <div className="rb:text-[14px] rb:text-[#155EEF] rb:leading-5">{curValue || min}</div>
}
</Flex>
);
};

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-02 15:24:23
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-02 15:24:23
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-10 14:59:39
*/
/**
* SearchInput Component
@@ -21,8 +21,6 @@ import { useState, type FC, useCallback, useRef } from 'react';
import { Input, type InputProps } from 'antd';
import { useTranslation } from 'react-i18next';
import searchIcon from '@/assets/images/search.svg'
/** Props interface for SearchInput component */
interface SearchInputProps {
/** Placeholder text */
@@ -112,7 +110,7 @@ const SearchInput: FC<SearchInputProps> = ({
return (
<Input
allowClear
prefix={<img src={searchIcon} alt="search" className="rb:w-4 rb:h-4 rb:mr-1" />}
prefix={<div className="rb:size-4 rb:bg-[url('@/assets/images/search.svg')] rb:mr-1"></div>}
placeholder={placeholder || t('user.searchPlaceholder')}
value={value}
onChange={handleChange}

View File

@@ -1,10 +1,10 @@
.sider {
border-right: 1px solid #EAECEE;
/* border-right: 1px solid #EAECEE; */
max-height: 100vh;
}
.title {
height: 64px;
padding: 24px 12px 12px 12px;
padding: 24px 10px 12px 12px;
font-family: Gilroy, Gilroy;
font-weight: 800;
font-size: 16px;
@@ -13,7 +13,7 @@
text-align: left;
font-style: normal;
display: flex;
border-bottom: 1px solid #EAECEE;
/* border-bottom: 1px solid #EAECEE; */
justify-content: space-between;
gap: 12px;
}
@@ -27,8 +27,12 @@
height: 24px;
margin-right: 8px;
}
.menuIcon {
cursor: pointer;
width: 28px;
height: 28px;
.sider :global(.ant-menu .ant-menu-item-group-title) {
padding-left: 20px;
padding-top: 10px;
padding-bottom: 4px;
font-weight: 500;
}
.sider :global(.ant-menu .ant-menu-item-divider) {
margin: 20px 20px 10px 20px;
}

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-02 15:25:31
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-04 13:49:16
* @Last Modified time: 2026-02-24 11:32:15
*/
/**
* SiderMenu Component
@@ -19,7 +19,7 @@
*/
import { useState, useEffect, type FC } from 'react';
import { Menu as AntMenu, Layout } from 'antd';
import { Menu as AntMenu, Layout, Flex } from 'antd';
import { UserOutlined } from '@ant-design/icons';
import type { MenuProps } from 'antd';
import { useNavigate, useLocation } from 'react-router-dom';
@@ -29,47 +29,49 @@ import clsx from 'clsx';
import { useMenu, type MenuItem } from '@/store/menu';
import styles from './index.module.css'
import logo from '@/assets/images/logo.png'
import menuFold from '@/assets/images/menuFold.png'
import menuUnfold from '@/assets/images/menuUnfold.png'
import { useUser } from '@/store/user';
import logout from '@/assets/images/logout.svg'
// Import SVG files
import dashboardIcon from '@/assets/images/menu/dashboard.svg';
import dashboardActiveIcon from '@/assets/images/menu/dashboard_active.svg';
import dashboardIcon from '@/assets/images/menuNew/dashboard.svg';
import dashboardActiveIcon from '@/assets/images/menuNew/dashboard_active.svg';
import modelIcon from '@/assets/images/menu/model.svg';
import modelActiveIcon from '@/assets/images/menu/model_active.svg';
import memoryIcon from '@/assets/images/menu/memory.svg';
import memoryActiveIcon from '@/assets/images/menu/memory_active.svg';
import spaceIcon from '@/assets/images/menu/space.svg';
import spaceActiveIcon from '@/assets/images/menu/space_active.svg';
import userIcon from '@/assets/images/menu/user.svg';
import userActiveIcon from '@/assets/images/menu/user_active.svg';
import userMemoryIcon from '@/assets/images/menu/userMemory.svg';
import userMemoryActiveIcon from '@/assets/images/menu/userMemory_active.svg';
import applicationIcon from '@/assets/images/menu/application.svg';
import applicationActiveIcon from '@/assets/images/menu/application_active.svg';
import knowledgeIcon from '@/assets/images/menu/knowledge.svg';
import knowledgeActiveIcon from '@/assets/images/menu/knowledge_active.svg';
import memoryConversationIcon from '@/assets/images/menu/memoryConversation.svg';
import memoryConversationActiveIcon from '@/assets/images/menu/memoryConversation_active.svg';
import memberIcon from '@/assets/images/menu/member.svg';
import memberActiveIcon from '@/assets/images/menu/member_active.svg';
import applicationIcon from '@/assets/images/menuNew/application.svg';
import applicationActiveIcon from '@/assets/images/menuNew/application_active.svg';
import knowledgeIcon from '@/assets/images/menuNew/knowledge.svg';
import knowledgeActiveIcon from '@/assets/images/menuNew/knowledge_active.svg';
import memoryIcon from '@/assets/images/menuNew/memory.svg';
import memoryActiveIcon from '@/assets/images/menuNew/memory_active.svg';
import userMemoryIcon from '@/assets/images/menuNew/userMemory.svg';
import userMemoryActiveIcon from '@/assets/images/menuNew/userMemory_active.svg';
import memoryConversationIcon from '@/assets/images/menuNew/memoryConversation.svg';
import memoryConversationActiveIcon from '@/assets/images/menuNew/memoryConversation_active.svg';
import apiKeyIcon from '@/assets/images/menuNew/apiKey.svg';
import apiKeyActiveIcon from '@/assets/images/menuNew/apiKey_active.svg';
import memberIcon from '@/assets/images/menuNew/member.svg';
import memberActiveIcon from '@/assets/images/menuNew/member_active.svg';
import ontologyIcon from '@/assets/images/menuNew/ontology.svg'
import ontologyActiveIcon from '@/assets/images/menuNew/ontology_active.svg'
import spaceConfigIcon from '@/assets/images/menuNew/spaceConfig.svg'
import spaceConfigActiveIcon from '@/assets/images/menuNew/spaceConfig_active.svg'
import promptIcon from '@/assets/images/menuNew/prompt.svg'
import promptActiveIcon from '@/assets/images/menuNew/prompt_active.svg'
import toolIcon from '@/assets/images/menu/tool.png';
import toolActiveIcon from '@/assets/images/menu/tool_active.png';
import apiKeyIcon from '@/assets/images/menu/apiKey.png';
import apiKeyActiveIcon from '@/assets/images/menu/apiKey_active.png';
import pricingIcon from '@/assets/images/menu/pricing.svg'
import pricingActiveIcon from '@/assets/images/menu/pricing_active.svg'
import spaceConfigIcon from '@/assets/images/menu/spaceConfig.svg'
import spaceConfigActiveIcon from '@/assets/images/menu/spaceConfig_active.svg'
import ontologyIcon from '@/assets/images/menu/ontology.svg'
import ontologyActiveIcon from '@/assets/images/menu/ontology_active.svg'
import promptIcon from '@/assets/images/menu/prompt.svg'
import promptActiveIcon from '@/assets/images/menu/prompt_active.svg'
import skillsIcon from '@/assets/images/menu/skills.svg'
import skillsActiveIcon from '@/assets/images/menu/skills_active.svg'
/** Icon path mapping table for menu items (normal and active states) */
const iconPathMap: Record<string, string> = {
'dashboard': dashboardIcon,
@@ -145,43 +147,57 @@ const Menu: FC<{
/** Convert custom menu format to Ant Design Menu items format */
const generateMenuItems = (menuList: MenuItem[]): MenuProps['items'] => {
const items: MenuProps['items'] = [];
const filteredMenus = menuList.filter(menu => menu.display);
return menuList.filter(menu => menu.display).map((menu) => {
filteredMenus.forEach((menu, index) => {
const iconKey = selectedKeys.includes(menu.path || '') ? `${menu.code}Active` : menu.code;
const iconSrc = iconPathMap[iconKey as keyof typeof iconPathMap];
const subs = (menu.subs || []).filter(sub => sub.display);
/** Leaf node - menu item without children */
if (!subs || subs.length === 0) {
if (!menu.path) return null;
return {
key: menu.path,
title: menu.i18nKey ? t(menu.i18nKey) : menu.label,
label: (
<span data-menu-id={menu.path}>
{menu.i18nKey ? t(menu.i18nKey) : menu.label}
</span>
),
if (menu.path) {
items.push({
key: menu.path,
title: menu.i18nKey ? t(menu.i18nKey) : menu.label,
label: (
<span data-menu-id={menu.path}>
{menu.i18nKey ? t(menu.i18nKey) : menu.label}
</span>
),
icon: iconSrc ? <img
src={iconSrc}
className="rb:w-4 rb:h-4"
alt={iconSrc}
/> : null,
});
}
} else {
/** Node with submenu - menu item with children */
const menuLabel = collapsed && menu.type === 'group'? '': menu.i18nKey ? t(menu.i18nKey) : menu.label;
const children = generateMenuItems(subs) || [];
items.push({
key: `submenu-${menu.id}`,
...(menu.type === 'group' ? { type: 'group' as const } : {}),
title: menuLabel,
label: menuLabel,
icon: iconSrc ? <img
src={iconSrc}
className="rb:w-4 rb:h-4 rb:mr-2"
/> : null,
};
className="rb:w-4 rb:h-4"
alt={iconSrc}
/> : <UserOutlined/>,
children,
});
}
/** Node with submenu - menu item with children */
const menuLabel = menu.i18nKey ? t(menu.i18nKey) : menu.label;
return {
key: `submenu-${menu.id}`,
title: menuLabel,
label: menuLabel,
icon: iconSrc ? <img
src={iconSrc}
className="rb:w-4 rb:h-4 rb:mr-2"
/> : <UserOutlined/>,
children: generateMenuItems(subs),
};
}).filter(Boolean);
/** Add divider after group items (except the last one) */
if (menu.type === 'group' && index < filteredMenus.length - 1) {
items.push({ type: 'divider', key: `divider-${menu.id}` });
}
});
return items;
};
/** Generate menu items from configuration */
@@ -252,20 +268,26 @@ const Menu: FC<{
'rb:flex rb:items-center rb:text-[14px]! rb:py-2!': !collapsed && source === 'space' && user.current_workspace_name,
})}>
{!collapsed && source === 'space' && user.current_workspace_name
? <div className="rb:w-43.75 rb:text-center">
<div className="rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{user.current_workspace_name}</div>
<span className="rb:text-[12px] rb:text-[#5B6167] rb:leading-4 rb:font-regular">
{t(`space.${storageType}`)}
</span>
</div>
: !collapsed
? <div className="rb:flex">
<img src={logo} className={styles.logo} />
{t('title')}
? <Flex gap={9}>
<Flex align="center" justify="center" className="rb:size-10 rb:rounded-xl rb:bg-[#171719] rb:text-white rb:text-[18px] rb:font-medium">{user.current_workspace_name[0]}</Flex>
<div className="rb:w-32">
<div className="rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap rb:font-medium rb:text-[16px] rb:leading-5.5">{user.current_workspace_name}</div>
<span className="rb:text-[14px] rb:text-[#5B6167] rb:leading-5 rb:font-regular">
{t(`space.${storageType}`)}
</span>
</div>
</Flex>
: !collapsed
? <Flex>
<img src={logo} className={styles.logo}
alt={logo} />
{t('title')}
</Flex>
: null
}
<img src={collapsed ? menuUnfold : menuFold} className={styles.menuIcon} onClick={toggleSider} />
<div className={clsx("rb:cursor-pointer rb:size-5 rb:bg-cover rb:bg-[url('@/assets/images/menuNew/menuFold.svg')]", {
'rb:rotate-180': collapsed
})} onClick={toggleSider}></div>
</div>
{/* Main navigation menu */}
<AntMenu
@@ -276,18 +298,21 @@ const Menu: FC<{
onClick={handleMenuClick}
items={menuItems}
inlineCollapsed={collapsed}
inlineIndent={13}
inlineIndent={10}
className="rb:max-h-[calc(100vh-136px)] rb:overflow-y-auto"
/>
{/* Return to space button for superusers */}
{user?.is_superuser && source === 'space' &&
<div
<Flex
gap={8}
align="center"
justify="start"
onClick={goToSpace}
className="rb:pl-6.25 rb:flex rb:items-center rb:justify-start rb:absolute rb:bottom-8 rb:w-full rb:text-[12px] rb:text-[#5B6167] rb:hover:text-[#212332] rb:leading-4 rb:font-regular rb:text-center rb:mt-6 rb:cursor-pointer"
className="rb-border-t rb:pt-5! rb:pb-2.5! rb:absolute rb:bottom-2.5 rb:right-5 rb:left-5 rb:text-[13px] rb:text-[#5B6167] rb:hover:text-[#212332] rb:leading-4.5 rb:font-regular rb:text-center rb:mt-2.25 rb:cursor-pointer"
>
<img src={logout} className="rb:w-4 rb:h-4 rb:mr-4" />
<div className="rb:cursor-pointer rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/logout.svg')]"></div>
{collapsed ? null : t('common.returnToSpace')}
</div>
</Flex>
}
</Sider>
);

View File

@@ -107,7 +107,7 @@ const SliderInput: FC<SliderInputProps> = ({
<div className={`rb:w-full ${className}`}>
{/* Optional label */}
{label && (
<div className="rb:text-sm rb:font-medium rb:text-gray-700">
<div className="rb:text-sm rb:font-medium rb:text-[#475467]">
{label}
</div>
)}

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-02 15:29:42
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-02 15:29:42
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-11 11:14:29
*/
/**
* StatusTag Component
@@ -14,7 +14,7 @@
*/
import { type FC } from 'react'
import { Tag } from 'antd';
import { Tag, Flex } from 'antd';
import clsx from 'clsx';
/** Props interface for StatusTag component */
@@ -40,13 +40,12 @@ const StatusTag: FC<StatusTagProps> = ({
status,
text
}) => {
console.log('status', status)
return (
<Tag style={{ backgroundColor: '#fff', borderColor: '#DFE4ED' }}>
<span className='rb:flex rb:items-center rb:text-[#5B6167] rb:pl-px rb:pr-px'>
<Flex align="center" className='rb:text-[#5B6167] rb:pl-px rb:pr-px'>
<span className={clsx(' rb:w-1.25 rb:h-1.25 rb:rounded-full rb:mr-1', Colors[status])}></span>
{ text }
</span>
</Flex>
</Tag>
)
}

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-02 15:29:46
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-02 15:32:11
* @Last Modified time: 2026-02-25 15:06:40
*/
/**
* RbTable Component
@@ -199,7 +199,7 @@ const RbTable = forwardRef<TableRef, TableComponentProps>(({
if (scrollY !== undefined) {
config.y = scrollY;
} else if (isScroll) {
config.y = 'calc(100vh - 280px)';
config.y = 'calc(100vh - 240px)';
}
return Object.keys(config).length > 0 ? config : undefined;
@@ -214,7 +214,6 @@ const RbTable = forwardRef<TableRef, TableComponentProps>(({
dataSource={data}
pagination={paginationConfig}
rowSelection={rowSelection}
rowClassName="rb:text-[#5B6167]"
locale={{ emptyText: <Empty size={emptySize} subTitle={emptyText} /> }}
scroll={getScrollConfig()}
tableLayout="auto"

View File

@@ -243,7 +243,7 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
<div className="rb:mb-6 rb:w-full">
<Dragger {...uploadProps} style={{ height: '270px' }}>
<div className="rb:flex rb:justify-center rb:flex-col rb:items-center">
<img className="rb:w-12 rb:h-12" src={CloudUploadOutlined} />
<div className="rb:size-12 rb:bg-cover rb:bg-[url('@/assets/images/CloudUploadOutlined.png')]"></div>
{(!isAutoUpload || !hasProgress && (!fileList || !fileList.length)) &&
<>
<div className="rb:text-base rb:text-[14px] rb:font-medium rb:flex rb:items-center rb:mt-2 rb:leading-5">

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-02 15:30:52
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-02 15:57:03
* @Last Modified time: 2026-02-10 15:01:42
*/
/**
* UploadImages Component
@@ -238,7 +238,7 @@ const UploadImages = forwardRef<UploadImagesRef, UploadImagesProps>(({
{...uploadProps}
>
{fileList.length < maxCount && (
<img src={PlusIcon} className="rb:size-7" />
<div className="rb:size-7 rb:bg-cover rb:bg-[url('@/assets/images/plus.svg')]"></div>
)}
</Upload>
{previewImage && (