feat(web): components update
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
306
web/src/components/Charts/AreaLineChart.tsx
Normal file
306
web/src/components/Charts/AreaLineChart.tsx
Normal 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
|
||||
295
web/src/components/Charts/BarChart.tsx
Normal file
295
web/src/components/Charts/BarChart.tsx
Normal 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
|
||||
200
web/src/components/Charts/GraphNetworkChart.tsx
Normal file
200
web/src/components/Charts/GraphNetworkChart.tsx
Normal 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
|
||||
260
web/src/components/Charts/LineChart.tsx
Normal file
260
web/src/components/Charts/LineChart.tsx
Normal 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
|
||||
193
web/src/components/Charts/PieChart.tsx
Normal file
193
web/src/components/Charts/PieChart.tsx
Normal 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
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
70
web/src/components/Layout/PageHeader.tsx
Normal file
70
web/src/components/Layout/PageHeader.tsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
27
web/src/components/RbButton/index.module.css
Normal file
27
web/src/components/RbButton/index.module.css
Normal 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;
|
||||
}
|
||||
22
web/src/components/RbButton/index.tsx
Normal file
22
web/src/components/RbButton/index.tsx
Normal 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
|
||||
@@ -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>
|
||||
|
||||
76
web/src/components/RbCard/index.tsx
Normal file
76
web/src/components/RbCard/index.tsx
Normal 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
|
||||
21
web/src/components/RbDescriptions/index.module.css
Normal file
21
web/src/components/RbDescriptions/index.module.css
Normal 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;
|
||||
}
|
||||
14
web/src/components/RbDescriptions/index.tsx
Normal file
14
web/src/components/RbDescriptions/index.tsx
Normal 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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
3
web/src/components/RbSlider/index.module.css
Normal file
3
web/src/components/RbSlider/index.module.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.ant-slider-horizontal .ant-slider-rail {
|
||||
width: calc(100% - 6px);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user