Compare commits
No commits in common. "3337285135abec2e55bad8e02f2674d02e9f4a80" and "4097e719a9410d692500452c31030f061bcfe7bb" have entirely different histories.
3337285135
...
4097e719a9
|
|
@ -39,5 +39,3 @@ backend/src/main/resources/application-local.yml
|
|||
/backend/m2repo_local/
|
||||
/backend/src/test/
|
||||
/backend/target/
|
||||
/.claude
|
||||
/web-fe/
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ lerna-debug.log*
|
|||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.tsbuildinfo
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@
|
|||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.10",
|
||||
"@types/react": "^18.2.55",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
|
|
@ -1581,16 +1580,6 @@
|
|||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.13.2",
|
||||
"resolved": "https://registry.npmmirror.com/@types/node/-/node-24.13.2.tgz",
|
||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pako": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/@types/pako/-/pako-2.0.4.tgz",
|
||||
|
|
@ -4677,13 +4666,6 @@
|
|||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unified": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/unified/-/unified-11.0.5.tgz",
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@
|
|||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.10",
|
||||
"@types/react": "^18.2.55",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ConfigProvider, theme, App as AntdApp } from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import enUS from "antd/locale/en_US";
|
||||
|
|
@ -6,8 +6,10 @@ import { useTranslation } from "react-i18next";
|
|||
import AppRoutes from "./routes";
|
||||
import { getOpenPlatformConfig } from "./api";
|
||||
import { useThemeStore } from "./store/themeStore";
|
||||
import type { SysPlatformConfig } from "./types";
|
||||
|
||||
export default function App() {
|
||||
const [config, setConfig] = useState<SysPlatformConfig | null>(null);
|
||||
const { colorPrimary, themeMode, initTheme } = useThemeStore();
|
||||
const { i18n } = useTranslation();
|
||||
const antdLocale = useMemo(() => (i18n.language === "en-US" ? enUS : zhCN), [i18n.language]);
|
||||
|
|
@ -17,6 +19,7 @@ export default function App() {
|
|||
const fetchConfig = async () => {
|
||||
try {
|
||||
const data = await getOpenPlatformConfig();
|
||||
setConfig(data);
|
||||
if (data.projectName) {
|
||||
document.title = data.projectName;
|
||||
}
|
||||
|
|
@ -45,24 +48,12 @@ export default function App() {
|
|||
algorithm: themeMode === 'tech' ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: colorPrimary,
|
||||
borderRadius: 4,
|
||||
colorBgLayout: '#f5f6fa',
|
||||
borderRadius: 10,
|
||||
colorBgLayout: 'transparent',
|
||||
},
|
||||
components: {
|
||||
Card: {
|
||||
borderRadiusLG: 4
|
||||
},
|
||||
Button: {
|
||||
borderRadius: 4
|
||||
},
|
||||
Input: {
|
||||
borderRadius: 4
|
||||
},
|
||||
Select: {
|
||||
borderRadius: 4
|
||||
},
|
||||
Modal: {
|
||||
borderRadiusLG: 4
|
||||
borderRadiusLG: 16
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 143 KiB |
|
|
@ -0,0 +1,110 @@
|
|||
import { Layout, Menu, Space, Button } from "antd";
|
||||
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchMyMenuTree } from "../api";
|
||||
import { PermissionNode } from "../types";
|
||||
import { LogoutOutlined, HomeOutlined, SettingOutlined, UserOutlined, SafetyOutlined, ClusterOutlined, BookOutlined, DesktopOutlined, ShopOutlined, InfoCircleOutlined, ApartmentOutlined } from "@ant-design/icons";
|
||||
|
||||
const iconMap: Record<string, any> = {
|
||||
'home': <HomeOutlined />,
|
||||
'tenant': <ShopOutlined />,
|
||||
'org': <ApartmentOutlined />,
|
||||
'user': <UserOutlined />,
|
||||
'role': <SafetyOutlined />,
|
||||
'permission': <ClusterOutlined />,
|
||||
'dict': <BookOutlined />,
|
||||
'device': <DesktopOutlined />,
|
||||
'setting': <SettingOutlined />,
|
||||
'logs': <InfoCircleOutlined />
|
||||
};
|
||||
|
||||
export default function AppLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [menuTree, setMenuTree] = useState<PermissionNode[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadMenu = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const tree = await fetchMyMenuTree();
|
||||
setMenuTree(tree || []);
|
||||
} catch (e) {
|
||||
console.error("Failed to load menu tree", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadMenu();
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem("accessToken");
|
||||
localStorage.removeItem("refreshToken");
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
const renderMenuItems = (nodes: PermissionNode[]): any[] => {
|
||||
return nodes
|
||||
.filter(node => node.isVisible !== 0 && node.status !== 0)
|
||||
.map(node => {
|
||||
const icon = node.icon ? iconMap[node.icon] || <SettingOutlined /> : undefined;
|
||||
|
||||
if (node.children && node.children.length > 0) {
|
||||
return {
|
||||
key: node.path || `parent-${node.permId}`,
|
||||
icon: icon,
|
||||
label: node.name,
|
||||
children: renderMenuItems(node.children)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: node.path,
|
||||
icon: icon,
|
||||
label: node.path ? <Link to={node.path}>{node.name}</Link> : node.name
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
{ key: "/", label: <Link to="/">总览</Link>, icon: <HomeOutlined /> },
|
||||
...renderMenuItems(menuTree),
|
||||
{
|
||||
key: "logout",
|
||||
label: <span onClick={handleLogout}>退出登录</span>,
|
||||
icon: <LogoutOutlined />,
|
||||
danger: true,
|
||||
style: { marginTop: 'auto' }
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: "100vh" }}>
|
||||
<Layout.Sider collapsible width={220}>
|
||||
<div style={{ padding: '16px', display: 'flex', alignItems: 'center', gap: '8px', overflow: 'hidden' }}>
|
||||
<img src="/logo.svg" alt="logo" style={{ width: 28, height: 28, flexShrink: 0 }} />
|
||||
<span style={{ color: "#fff", fontWeight: 700, fontSize: '18px', whiteSpace: 'nowrap' }}>MeetingAI</span>
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
items={menuItems}
|
||||
style={{ height: 'calc(100% - 64px)', display: 'flex', flexDirection: 'column' }}
|
||||
/>
|
||||
</Layout.Sider>
|
||||
<Layout>
|
||||
<Layout.Header style={{ background: "#fff", padding: '0 24px', display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
|
||||
<Space>
|
||||
<Button type="text" icon={<LogoutOutlined />} onClick={handleLogout}>退出</Button>
|
||||
</Space>
|
||||
</Layout.Header>
|
||||
<Layout.Content style={{ padding: 24, background: '#f0f2f5', overflowY: 'auto' }}>
|
||||
<Outlet />
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,15 +1,15 @@
|
|||
import { ColorPicker, Space, Drawer, Segmented, Typography } from 'antd';
|
||||
import { FormatPainterOutlined } from '@ant-design/icons';
|
||||
import { ColorPicker, Space, Drawer, Segmented, Typography, Button } from 'antd';
|
||||
import { FormatPainterOutlined, LayoutOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState } from 'react';
|
||||
import { useThemeStore, type ThemeMode } from '@/store/themeStore';
|
||||
import { useThemeStore, type ThemeMode, type LayoutMode } from '@/store/themeStore';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function ThemeSelector() {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { colorPrimary, themeMode, setColorPrimary, setThemeMode } = useThemeStore();
|
||||
const { colorPrimary, themeMode, layoutMode, setColorPrimary, setThemeMode, setLayoutMode } = useThemeStore();
|
||||
|
||||
const themeOptions = [
|
||||
{ label: t('theme.default', 'Default'), value: 'default' },
|
||||
|
|
@ -17,6 +17,11 @@ export default function ThemeSelector() {
|
|||
{ label: t('theme.tech', 'Tech'), value: 'tech' },
|
||||
];
|
||||
|
||||
const layoutOptions = [
|
||||
{ label: t('theme.layoutSide', 'Side Menu'), value: 'side' },
|
||||
{ label: t('theme.layoutTop', 'Top Menu'), value: 'top' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="theme-selector-container">
|
||||
<Space align="center" style={{ cursor: 'pointer', padding: '0 8px' }} onClick={() => setOpen(true)}>
|
||||
|
|
@ -55,6 +60,19 @@ export default function ThemeSelector() {
|
|||
onChange={(value) => setThemeMode(value as ThemeMode)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<LayoutOutlined style={{ color: 'var(--app-primary-color)' }} />
|
||||
<Text strong>{t('theme.layout', 'Navigation Mode')}</Text>
|
||||
</div>
|
||||
<Segmented
|
||||
block
|
||||
options={layoutOptions}
|
||||
value={layoutMode}
|
||||
onChange={(value) => setLayoutMode(value as LayoutMode)}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
</Drawer>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,362 +0,0 @@
|
|||
.meeting-create-drawer-root .ant-drawer-content-wrapper {
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.meeting-create-drawer .ant-drawer-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meeting-create-drawer__skeleton {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.meeting-create-drawer__header {
|
||||
flex-shrink: 0;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.meeting-create-drawer__title-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
background: #f9fafe;
|
||||
color: #333;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.meeting-create-drawer__title.ant-typography {
|
||||
margin: 0 !important;
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.meeting-create-drawer__subtitle.ant-typography {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: #9095a1;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.meeting-create-drawer__type-switch.ant-radio-group {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.meeting-create-drawer__type-switch .ant-radio-button-wrapper {
|
||||
height: 32px;
|
||||
min-width: 112px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-inline: 14px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.meeting-create-drawer__type-switch .anticon {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.meeting-create-drawer__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.meeting-create-form {
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.meeting-create-form .ant-form-item {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.meeting-create-form .ant-form-item-label {
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.meeting-create-form .ant-form-item-label > label {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.meeting-create-form .ant-input:not(textarea),
|
||||
.meeting-create-form .ant-picker,
|
||||
.meeting-create-form .ant-select-selector,
|
||||
.meeting-create-form .ant-btn {
|
||||
min-height: 32px !important;
|
||||
border-radius: 4px !important;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.meeting-create-form .ant-select-selector {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meeting-create-form .ant-select-multiple .ant-select-selector {
|
||||
height: auto !important;
|
||||
min-height: 32px !important;
|
||||
}
|
||||
|
||||
.meeting-create-form textarea.ant-input {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.meeting-create-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.meeting-create-section-title__bar {
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 1px;
|
||||
background: #3c70f5;
|
||||
}
|
||||
|
||||
.meeting-create-section-title .ant-typography {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.meeting-create-section-divider {
|
||||
margin: 24px 0;
|
||||
border-top: 1px solid #e6e6e6;
|
||||
}
|
||||
|
||||
.meeting-create-template-grid {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.meeting-create-template-grid .ant-col {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.meeting-create-template-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
padding: 0 16px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.meeting-create-template-card:hover {
|
||||
border-color: #b7cdfd;
|
||||
background: #f9fafe;
|
||||
}
|
||||
|
||||
.meeting-create-template-card.is-selected {
|
||||
border-color: #3c70f5;
|
||||
background: #eef4ff;
|
||||
}
|
||||
|
||||
.meeting-create-template-card__name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meeting-create-template-card.is-selected .meeting-create-template-card__name {
|
||||
color: #3c70f5;
|
||||
}
|
||||
|
||||
.meeting-create-template-card__check {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
right: -1px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0 4px 0 4px;
|
||||
background: #3c70f5;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.meeting-create-radio-line {
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.meeting-create-advanced.ant-collapse {
|
||||
overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.meeting-create-advanced .ant-collapse-header {
|
||||
min-height: 48px;
|
||||
align-items: center !important;
|
||||
padding: 8px 14px !important;
|
||||
}
|
||||
|
||||
.meeting-create-advanced .ant-collapse-content-box {
|
||||
padding: 12px 14px 14px !important;
|
||||
}
|
||||
|
||||
.meeting-create-advanced__label {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meeting-create-advanced__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #333;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.meeting-create-advanced__icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-right: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
background: #eef4ff;
|
||||
color: #3c70f5;
|
||||
}
|
||||
|
||||
.meeting-create-advanced__body {
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed #e6e6e6;
|
||||
}
|
||||
|
||||
.meeting-create-upload.ant-upload-wrapper .ant-upload-drag {
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.meeting-create-upload .ant-upload {
|
||||
padding: 28px 16px;
|
||||
}
|
||||
|
||||
.meeting-create-upload .ant-upload-drag-icon {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.meeting-create-upload .ant-upload-drag-icon .anticon {
|
||||
color: #3c70f5;
|
||||
font-size: 44px;
|
||||
}
|
||||
|
||||
.meeting-create-upload .ant-upload-text {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.meeting-create-upload .ant-upload-hint {
|
||||
margin-top: 8px;
|
||||
color: #9095a1;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.meeting-create-upload__progress {
|
||||
width: min(420px, 80%);
|
||||
margin: 24px auto 0;
|
||||
}
|
||||
|
||||
.meeting-create-upload__progress > div:last-child {
|
||||
margin-top: 8px;
|
||||
color: #3c70f5;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.meeting-create-upload__file.ant-tag {
|
||||
max-width: 90%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.meeting-create-upload__file span:last-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
margin-left: 4px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meeting-create-drawer__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 12px 24px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.meeting-create-drawer__footer .ant-btn {
|
||||
min-width: 112px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.meeting-create-drawer-root .ant-drawer-content-wrapper {
|
||||
width: 100vw !important;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.meeting-create-drawer__header,
|
||||
.meeting-create-drawer__body,
|
||||
.meeting-create-drawer__footer {
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.meeting-create-drawer__type-switch {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.meeting-create-drawer__type-switch .ant-radio-button-wrapper {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -47,7 +47,6 @@ import {
|
|||
} from "../../api/business/meeting";
|
||||
import { getPromptPage, type PromptTemplateVO } from "../../api/business/prompt";
|
||||
import type { SysUser } from "../../types";
|
||||
import "./MeetingCreateDrawer.css";
|
||||
|
||||
const { Dragger } = Upload;
|
||||
const { Option } = Select;
|
||||
|
|
@ -196,19 +195,15 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
|
|||
|
||||
const nextConfig = createConfigRes.data.data || DEFAULT_CREATE_CONFIG;
|
||||
const nextType = resolveAvailableCreateType(initialType, nextConfig);
|
||||
const promptRecords = promptRes.data?.data?.records || [];
|
||||
const asrRecords = asrRes.data?.data?.records || [];
|
||||
const llmRecords = llmRes.data?.data?.records || [];
|
||||
const hotwordRecords = hotwordRes.data?.data?.records || [];
|
||||
const activePrompts = promptRecords.filter((item: PromptTemplateVO) => item.status === 1);
|
||||
const activePrompts = promptRes.data.data.records.filter((item: PromptTemplateVO) => item.status === 1);
|
||||
|
||||
setCreateConfig(nextConfig);
|
||||
setConfigLoaded(true);
|
||||
setType(nextType);
|
||||
setAsrModels(asrRecords.filter((item: AiModelVO) => item.status === 1));
|
||||
setLlmModels(llmRecords.filter((item: AiModelVO) => item.status === 1));
|
||||
setAsrModels(asrRes.data.data.records.filter((item: AiModelVO) => item.status === 1));
|
||||
setLlmModels(llmRes.data.data.records.filter((item: AiModelVO) => item.status === 1));
|
||||
setPrompts(activePrompts);
|
||||
setHotwordList(hotwordRecords.filter((item: HotWordVO) => item.status === 1));
|
||||
setHotwordList(hotwordRes.data.data.records.filter((item: HotWordVO) => item.status === 1));
|
||||
setHotWordGroups((hotWordGroupRes.data.data || []).filter((item: HotWordGroupVO) => item.status === 1));
|
||||
setUserList(users || []);
|
||||
|
||||
|
|
@ -392,22 +387,20 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
|
|||
|
||||
return (
|
||||
<Drawer
|
||||
rootClassName="meeting-create-drawer-root"
|
||||
className="meeting-create-drawer"
|
||||
title={null}
|
||||
open={open}
|
||||
onClose={onCancel}
|
||||
width="min(960px, calc(100vw - 48px))"
|
||||
width={960}
|
||||
forceRender
|
||||
destroyOnClose={false}
|
||||
placement="right"
|
||||
closable={false}
|
||||
footer={
|
||||
configLoaded ? (
|
||||
<div className="meeting-create-drawer__footer">
|
||||
<Space size={12}>
|
||||
<Button onClick={onCancel}>取消</Button>
|
||||
<Button type="primary" onClick={handleOk} loading={submitting}>
|
||||
<div style={{ textAlign: "right", padding: "16px 32px" }}>
|
||||
<Space size={16}>
|
||||
<Button onClick={onCancel} size="large" style={{ borderRadius: 8, minWidth: 120 }}>取消</Button>
|
||||
<Button type="primary" onClick={handleOk} loading={submitting} size="large" style={{ borderRadius: 8, minWidth: 140, fontWeight: 500 }}>
|
||||
{type === "upload" ? (audioUrl ? "完成并分析" : "上传并分析") : "创建并进入识别"}
|
||||
</Button>
|
||||
</Space>
|
||||
|
|
@ -421,120 +414,117 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
|
|||
}}
|
||||
>
|
||||
{!configLoaded ? (
|
||||
<div className="meeting-create-drawer__skeleton">
|
||||
<div style={{ padding: "40px" }}>
|
||||
<Skeleton active paragraph={{ rows: 12 }} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="meeting-create-drawer__header">
|
||||
<Row justify="space-between" align="middle" gutter={[16, 12]}>
|
||||
<Col flex="auto">
|
||||
<Space size={12}>
|
||||
<div className="meeting-create-drawer__title-icon">
|
||||
<div style={{ background: "var(--app-bg-surface)", padding: "24px 32px", borderBottom: "1px solid var(--app-border-color)" }}>
|
||||
<Row justify="space-between" align="middle">
|
||||
<Col>
|
||||
<Space size={16}>
|
||||
<div style={{ width: 48, height: 48, borderRadius: 12, background: "var(--app-bg-surface-strong)", color: "var(--app-text-main)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 24, border: "1px solid var(--app-border-color)" }}>
|
||||
{type === "upload" ? <CloudUploadOutlined /> : <AudioOutlined />}
|
||||
</div>
|
||||
<div>
|
||||
<Title level={4} className="meeting-create-drawer__title">{type === "upload" ? "上传录音发起分析" : "创建实时会议"}</Title>
|
||||
<Text type="secondary" className="meeting-create-drawer__subtitle">{type === "upload" ? "上传已有音频文件并由 AI 进行转写与总结分析" : "实时采集语音并进行流式转写与实时纪要生成"}</Text>
|
||||
<Title level={4} style={{ margin: 0, fontWeight: 600 }}>{type === "upload" ? "上传录音发起分析" : "创建实时会议"}</Title>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>{type === "upload" ? "上传已有音频文件并由 AI 进行转写与总结分析" : "实时采集语音并进行流式转写与实时纪要生成"}</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col flex="none">
|
||||
<Radio.Group value={type} onChange={e => setType(e.target.value)} optionType="button" buttonStyle="solid" className="meeting-create-drawer__type-switch">
|
||||
<Col>
|
||||
<Radio.Group value={type} onChange={e => setType(e.target.value)} optionType="button" buttonStyle="solid" size="large">
|
||||
{createConfig.offlineEnabled && (
|
||||
<Radio.Button value="upload"><CloudUploadOutlined /> 上传录音</Radio.Button>
|
||||
<Radio.Button value="upload" style={{ padding: "0 24px" }}><CloudUploadOutlined style={{ marginRight: 6 }} /> 上传录音</Radio.Button>
|
||||
)}
|
||||
{createConfig.realtimeEnabled && (
|
||||
<Radio.Button value="realtime"><AudioOutlined /> 实时会议</Radio.Button>
|
||||
<Radio.Button value="realtime" style={{ padding: "0 24px" }}><AudioOutlined style={{ marginRight: 6 }} /> 实时会议</Radio.Button>
|
||||
)}
|
||||
</Radio.Group>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<div className="meeting-create-drawer__body">
|
||||
<Form form={form} layout="vertical" disabled={loading} className="meeting-create-form">
|
||||
<div className="meeting-create-section-title">
|
||||
<span className="meeting-create-section-title__bar" />
|
||||
<Title level={5}>基础信息</Title>
|
||||
<div style={{ padding: "32px 40px", flex: 1, overflowY: "auto", background: "var(--app-bg-layout)" }}>
|
||||
<Form form={form} layout="vertical" disabled={loading}>
|
||||
<div style={{ marginBottom: 24, display: "flex", alignItems: "center" }}>
|
||||
<div style={{ width: 4, height: 16, background: "#1890ff", borderRadius: 2, marginRight: 8 }} />
|
||||
<Title level={5} style={{ margin: 0 }}>基础信息</Title>
|
||||
</div>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} md={12}>
|
||||
<Row gutter={32}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="title" label="会议标题" rules={[{ required: true }]}>
|
||||
<Input placeholder="输入会议标题" />
|
||||
<Input placeholder="输入会议标题" size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="meetingTime" label="会议时间" rules={[{ required: true }]}>
|
||||
<DatePicker showTime style={{ width: "100%" }} />
|
||||
<DatePicker showTime style={{ width: "100%" }} size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} md={12}>
|
||||
<Row gutter={32}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="participants" label="参会人员">
|
||||
<Select mode="multiple" placeholder="选择参会人员" showSearch optionFilterProp="children">
|
||||
<Select mode="multiple" placeholder="选择参会人员" showSearch optionFilterProp="children" size="large">
|
||||
{userList.map(u => (<Option key={u.userId} value={u.userId}><Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space></Option>))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="hostUserId" label="会议主持人">
|
||||
<Select allowClear placeholder="默认为创建人" showSearch optionFilterProp="children">
|
||||
<Select allowClear placeholder="默认为创建人" showSearch optionFilterProp="children" size="large">
|
||||
{userList.map(u => (<Option key={u.userId} value={u.userId}><Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space></Option>))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={24}>
|
||||
<Row gutter={32}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="tags" label="会议标签">
|
||||
<Select mode="tags" placeholder="输入标签" />
|
||||
<Select mode="tags" placeholder="输入标签" size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div className="meeting-create-section-divider" />
|
||||
<div style={{ margin: "32px 0", borderTop: "1px solid var(--app-border-color)" }} />
|
||||
|
||||
<div className="meeting-create-section-title">
|
||||
<span className="meeting-create-section-title__bar" />
|
||||
<Title level={5}>模型与纪要配置</Title>
|
||||
<div style={{ marginBottom: 24, display: "flex", alignItems: "center" }}>
|
||||
<div style={{ width: 4, height: 16, background: "#1890ff", borderRadius: 2, marginRight: 8 }} />
|
||||
<Title level={5} style={{ margin: 0 }}>模型与纪要配置</Title>
|
||||
</div>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} md={12}>
|
||||
<Row gutter={32}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="asrModelId" label="语音识别模型 (ASR)" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择 ASR 模型">{asrModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select>
|
||||
<Select placeholder="选择 ASR 模型" size="large">{asrModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="summaryModelId" label="总结模型 (LLM)" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择总结模型">{llmModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select>
|
||||
<Select placeholder="选择总结模型" size="large">{llmModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item name="promptId" label="总结模板" rules={[{ required: true }]}>
|
||||
{prompts.length > 15 ? (
|
||||
<Select placeholder="请选择总结模板" showSearch optionFilterProp="children">
|
||||
<Select placeholder="请选择总结模板" showSearch optionFilterProp="children" size="large">
|
||||
{prompts.map(p => <Option key={p.id} value={p.id}>{p.templateName}</Option>)}
|
||||
</Select>
|
||||
) : (
|
||||
<div className="meeting-create-template-grid">
|
||||
<div style={{ padding: "2px" }}>
|
||||
<Row gutter={[12, 12]}>
|
||||
{prompts.map(p => {
|
||||
const isSelected = watchedPromptId === p.id;
|
||||
return (
|
||||
<Col xs={24} sm={12} md={8} key={p.id}>
|
||||
<div
|
||||
onClick={() => form.setFieldsValue({ promptId: p.id })}
|
||||
className={isSelected ? "meeting-create-template-card is-selected" : "meeting-create-template-card"}
|
||||
>
|
||||
<div className="meeting-create-template-card__name">{p.templateName}</div>
|
||||
{isSelected && <div className="meeting-create-template-card__check"><CheckOutlined /></div>}
|
||||
<Col span={8} key={p.id}>
|
||||
<div onClick={() => form.setFieldsValue({ promptId: p.id })} style={{ padding: "12px 16px", borderRadius: 8, border: `1px solid ${isSelected ? "#1890ff" : "var(--app-border-color)"}`, background: isSelected ? "#e6f7ff" : "var(--app-bg-surface)", cursor: "pointer", position: "relative", transition: "all 0.2s", display: "flex", alignItems: "center", height: "100%" }}>
|
||||
<div style={{ fontSize: "14px", color: isSelected ? "#1890ff" : "var(--app-text-main)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: isSelected ? 500 : 400 }}>{p.templateName}</div>
|
||||
{isSelected && <div style={{ position: "absolute", top: -1, right: -1, width: 20, height: 20, background: "#1890ff", borderRadius: "0 8px 0 8px", display: "flex", alignItems: "center", justifyContent: "center" }}><CheckOutlined style={{ color: "#fff", fontSize: 12 }} /></div>}
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
|
|
@ -544,15 +534,15 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
|
|||
)}
|
||||
</Form.Item>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} md={12}>
|
||||
<Row gutter={32}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="hotWordGroupId" label="热词组" tooltip={selectedPrompt?.hotWordGroupName ? `默认跟随模板:${selectedPrompt.hotWordGroupName}` : "模板未绑定热词组时可手动选择"} extra={watchedHotWordGroupId != null ? "创建会议时会优先使用这里选中的热词组" : undefined}>
|
||||
<Select placeholder={selectedPrompt?.hotWordGroupId ? "默认已带出模板热词组,可按需修改" : "请选择热词组"} options={[{ label: "不使用热词组", value: 0 }, ...hotWordGroups.map((item) => ({ label: `${item.groupName} (${item.hotWordCount}/200)`, value: item.id }))]} />
|
||||
<Select placeholder={selectedPrompt?.hotWordGroupId ? "默认已带出模板热词组,可按需修改" : "请选择热词组"} size="large" options={[{ label: "不使用热词组", value: 0 }, ...hotWordGroups.map((item) => ({ label: `${item.groupName} (${item.hotWordCount}/200)`, value: item.id }))]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="summaryDetailLevel" label="总结详细程度" rules={[{ required: true }]}>
|
||||
<Radio.Group className="meeting-create-radio-line">
|
||||
<Radio.Group size="large">
|
||||
<Radio value="DETAILED">详细</Radio>
|
||||
<Radio value="STANDARD">标准</Radio>
|
||||
<Radio value="BRIEF">简洁</Radio>
|
||||
|
|
@ -564,37 +554,37 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
|
|||
<Collapse
|
||||
ghost
|
||||
expandIconPosition="end"
|
||||
className="meeting-create-advanced"
|
||||
style={{ marginBottom: 24, background: "var(--app-bg-surface)", border: "1px solid var(--app-border-color)", borderRadius: 8, overflow: "hidden" }}
|
||||
items={[
|
||||
{
|
||||
key: "advanced",
|
||||
forceRender: true,
|
||||
label: (
|
||||
<div className="meeting-create-advanced__label">
|
||||
<div className="meeting-create-advanced__title">
|
||||
<div className="meeting-create-advanced__icon">
|
||||
<SettingOutlined />
|
||||
<div style={{ display: "flex", alignItems: "center", width: "100%", height: "32px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", fontWeight: 600, color: "var(--app-text-main)", fontSize: 15 }}>
|
||||
<div style={{ width: 32, height: 32, borderRadius: 8, background: "#f0f5ff", color: "#1677ff", display: "flex", alignItems: "center", justifyContent: "center", marginRight: 12 }}>
|
||||
<SettingOutlined style={{ fontSize: 16 }} />
|
||||
</div>
|
||||
高级设置
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div className="meeting-create-advanced__body">
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} md={8}>
|
||||
<div style={{ paddingTop: 8, borderTop: "1px dashed var(--app-border-color)" }}>
|
||||
<Row gutter={32}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="useSpkId" label={<span>说话人区分 <Tooltip title="自动识别录音中的不同发言者并进行角色分离"><QuestionCircleOutlined /></Tooltip></span>} valuePropName="checked" getValueProps={(v) => ({ checked: !!v })} normalize={(v) => (v ? 1 : 0)}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="enableTextRefine" label={<span>文本修正 <Tooltip title="自动识别并修正语音转写中的口语词、语气助词等"><QuestionCircleOutlined /></Tooltip></span>} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
{type === "realtime" && (
|
||||
<>
|
||||
<Col xs={24} md={8}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="saveAudio" label="保存录音" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
|
@ -621,10 +611,10 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
|
|||
|
||||
{type === "upload" && (
|
||||
<>
|
||||
<div className="meeting-create-section-divider" />
|
||||
<div className="meeting-create-section-title">
|
||||
<span className="meeting-create-section-title__bar" />
|
||||
<Title level={5}>上传录音文件</Title>
|
||||
<div style={{ margin: "32px 0", borderTop: "1px solid var(--app-border-color)" }} />
|
||||
<div style={{ marginBottom: 24, display: "flex", alignItems: "center" }}>
|
||||
<div style={{ width: 4, height: 16, background: "#1890ff", borderRadius: 2, marginRight: 8 }} />
|
||||
<Title level={5} style={{ margin: 0 }}>上传录音文件</Title>
|
||||
</div>
|
||||
<Dragger
|
||||
accept=".mp3,.wav,.m4a"
|
||||
|
|
@ -633,22 +623,22 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
|
|||
customRequest={customUpload}
|
||||
onChange={info => setFileList(info.fileList.slice(-1))}
|
||||
maxCount={1}
|
||||
className="meeting-create-upload"
|
||||
style={{ borderRadius: 12, padding: "32px 0", background: "var(--app-bg-surface)", border: "1px dashed var(--app-border-color)" }}
|
||||
>
|
||||
<div>
|
||||
<p className="ant-upload-drag-icon"><CloudUploadOutlined /></p>
|
||||
<p className="ant-upload-text">点击或拖拽录音文件到此处</p>
|
||||
<p className="ant-upload-hint">支持 mp3、wav、m4a 等格式,大小不超过 {createConfig.offlineAudioMaxSizeMb}MB</p>
|
||||
<p className="ant-upload-drag-icon" style={{ marginBottom: 16 }}><CloudUploadOutlined style={{ fontSize: 56, color: "#1890ff" }} /></p>
|
||||
<p className="ant-upload-text" style={{ fontSize: 18, fontWeight: 500, color: "var(--app-text-main)" }}>点击或拖拽录音文件到此处</p>
|
||||
<p className="ant-upload-hint" style={{ fontSize: 14, marginTop: 12, color: "var(--app-text-secondary)" }}>支持 mp3、wav、m4a 等格式,大小不超过 {createConfig.offlineAudioMaxSizeMb}MB</p>
|
||||
{uploadProgress > 0 && uploadProgress < 100 && (
|
||||
<div className="meeting-create-upload__progress">
|
||||
<div style={{ width: "60%", margin: "32px auto 0" }}>
|
||||
<Progress percent={uploadProgress} size="small" />
|
||||
<div>文件上传中,请稍候...</div>
|
||||
<div style={{ fontSize: 13, color: "#1890ff", marginTop: 8 }}>文件上传中,请稍候...</div>
|
||||
</div>
|
||||
)}
|
||||
{audioUrl && (
|
||||
<Tag color="processing" className="meeting-create-upload__file">
|
||||
<Tag color="processing" style={{ marginTop: 24, padding: "6px 16px", fontSize: 14, borderRadius: 6, maxWidth: "90%", display: "inline-flex", alignItems: "center" }}>
|
||||
<span>已上传:</span>
|
||||
<span>{audioUrl.split("/").pop()}</span>
|
||||
<span style={{ marginLeft: 4, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{audioUrl.split("/").pop()}</span>
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,259 @@
|
|||
/* 帮助面板样式 */
|
||||
.action-help-panel .ant-drawer-header {
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.help-panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.help-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.help-panel-header-text {
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
/* 操作详情样式 */
|
||||
.help-action-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.help-action-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.help-action-icon {
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.help-action-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.help-action-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.help-action-badge {
|
||||
align-self: flex-start;
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* 帮助区块样式 */
|
||||
.help-section {
|
||||
padding: 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid #1677ff;
|
||||
}
|
||||
|
||||
.help-section-warning {
|
||||
background: #fff7e6;
|
||||
border-left-color: #faad14;
|
||||
}
|
||||
|
||||
.help-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.help-section-content {
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.help-section-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.help-section-list li {
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.help-section-list li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.help-section-steps {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
counter-reset: step-counter;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.help-section-steps li {
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
margin-bottom: 12px;
|
||||
padding-left: 12px;
|
||||
position: relative;
|
||||
counter-increment: step-counter;
|
||||
}
|
||||
|
||||
.help-section-steps li:before {
|
||||
content: counter(step-counter);
|
||||
position: absolute;
|
||||
left: -20px;
|
||||
top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #1677ff;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.help-section-steps li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.help-shortcut {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.help-shortcut kbd {
|
||||
display: inline-block;
|
||||
padding: 6px 12px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%);
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
|
||||
font-size: 12px;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 操作列表样式 */
|
||||
.help-actions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.help-action-item {
|
||||
padding: 12px;
|
||||
background: white;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.help-action-item:hover {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 2px 8px rgba(22, 119, 255, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.help-action-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.help-action-item-icon {
|
||||
font-size: 16px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.help-action-item-title {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.help-action-item-shortcut {
|
||||
padding: 2px 6px;
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.help-action-item-desc {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
/* 折叠面板自定义样式 */
|
||||
.action-help-panel .ant-collapse-ghost > .ant-collapse-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.action-help-panel .ant-collapse-ghost > .ant-collapse-item > .ant-collapse-header {
|
||||
padding: 12px 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.action-help-panel .ant-collapse-ghost > .ant-collapse-item > .ant-collapse-content {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.action-help-panel .ant-drawer-content-wrapper {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.help-action-header {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.help-section {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Drawer, Collapse, Badge, Tag, Empty } from 'antd'
|
||||
import {
|
||||
QuestionCircleOutlined,
|
||||
BulbOutlined,
|
||||
WarningOutlined,
|
||||
InfoCircleOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import './ActionHelpPanel.css'
|
||||
|
||||
const { Panel } = Collapse
|
||||
|
||||
/**
|
||||
* 操作帮助面板组件
|
||||
* 在页面侧边显示当前操作的详细说明和帮助信息
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.visible - 是否显示面板
|
||||
* @param {Function} props.onClose - 关闭回调
|
||||
* @param {Object} props.currentAction - 当前操作信息
|
||||
* @param {Array} props.allActions - 所有可用操作列表
|
||||
* @param {string} props.placement - 面板位置
|
||||
* @param {Function} props.onActionSelect - 选择操作的回调
|
||||
*/
|
||||
function ActionHelpPanel({
|
||||
visible = false,
|
||||
onClose,
|
||||
currentAction = null,
|
||||
allActions = [],
|
||||
placement = 'right',
|
||||
onActionSelect,
|
||||
}) {
|
||||
const [activeKey, setActiveKey] = useState(['current'])
|
||||
|
||||
// 当 currentAction 变化时,自动展开"当前操作"面板
|
||||
useEffect(() => {
|
||||
if (currentAction && visible) {
|
||||
setActiveKey(['current'])
|
||||
}
|
||||
}, [currentAction, visible])
|
||||
|
||||
// 渲染当前操作详情
|
||||
const renderCurrentAction = () => {
|
||||
if (!currentAction) {
|
||||
return (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="将鼠标悬停在按钮上查看帮助"
|
||||
style={{ padding: '40px 0' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="help-action-detail">
|
||||
{/* 操作标题 */}
|
||||
<div className="help-action-header">
|
||||
<div className="help-action-icon">{currentAction.icon}</div>
|
||||
<div className="help-action-info">
|
||||
<h3 className="help-action-title">{currentAction.title}</h3>
|
||||
{currentAction.badge && (
|
||||
<Tag color={currentAction.badge.color} className="help-action-badge">
|
||||
{currentAction.badge.text}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作描述 */}
|
||||
{currentAction.description && (
|
||||
<div className="help-section">
|
||||
<div className="help-section-title">
|
||||
<InfoCircleOutlined /> 功能说明
|
||||
</div>
|
||||
<div className="help-section-content">{currentAction.description}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 使用场景 */}
|
||||
{currentAction.scenarios && currentAction.scenarios.length > 0 && (
|
||||
<div className="help-section">
|
||||
<div className="help-section-title">
|
||||
<BulbOutlined /> 使用场景
|
||||
</div>
|
||||
<ul className="help-section-list">
|
||||
{currentAction.scenarios.map((scenario, index) => (
|
||||
<li key={index}>{scenario}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作步骤 */}
|
||||
{currentAction.steps && currentAction.steps.length > 0 && (
|
||||
<div className="help-section">
|
||||
<div className="help-section-title">
|
||||
<ThunderboltOutlined /> 操作步骤
|
||||
</div>
|
||||
<ol className="help-section-steps">
|
||||
{currentAction.steps.map((step, index) => (
|
||||
<li key={index}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 注意事项 */}
|
||||
{currentAction.warnings && currentAction.warnings.length > 0 && (
|
||||
<div className="help-section help-section-warning">
|
||||
<div className="help-section-title">
|
||||
<WarningOutlined /> 注意事项
|
||||
</div>
|
||||
<ul className="help-section-list">
|
||||
{currentAction.warnings.map((warning, index) => (
|
||||
<li key={index}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 快捷键 */}
|
||||
{currentAction.shortcut && (
|
||||
<div className="help-section">
|
||||
<div className="help-section-title">⌨️ 快捷键</div>
|
||||
<div className="help-shortcut">
|
||||
<kbd>{currentAction.shortcut}</kbd>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 权限要求 */}
|
||||
{currentAction.permission && (
|
||||
<div className="help-section">
|
||||
<div className="help-section-title">🔐 权限要求</div>
|
||||
<div className="help-section-content">
|
||||
<Tag color="blue">{currentAction.permission}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染所有操作列表
|
||||
const renderAllActions = () => {
|
||||
if (allActions.length === 0) {
|
||||
return <Empty description="暂无操作" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="help-actions-list">
|
||||
{allActions.map((action, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="help-action-item"
|
||||
onClick={() => {
|
||||
if (onActionSelect) {
|
||||
onActionSelect(action)
|
||||
setActiveKey(['current'])
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="help-action-item-header">
|
||||
<span className="help-action-item-icon">{action.icon}</span>
|
||||
<span className="help-action-item-title">{action.title}</span>
|
||||
{action.shortcut && (
|
||||
<kbd className="help-action-item-shortcut">{action.shortcut}</kbd>
|
||||
)}
|
||||
</div>
|
||||
<div className="help-action-item-desc">{action.description}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={
|
||||
<div className="help-panel-title">
|
||||
<QuestionCircleOutlined style={{ marginRight: 8 }} />
|
||||
操作帮助
|
||||
{currentAction && <Badge status="processing" text="实时帮助" />}
|
||||
</div>
|
||||
}
|
||||
placement={placement}
|
||||
width={420}
|
||||
open={visible}
|
||||
onClose={onClose}
|
||||
className="action-help-panel"
|
||||
>
|
||||
<Collapse
|
||||
activeKey={activeKey}
|
||||
onChange={setActiveKey}
|
||||
ghost
|
||||
expandIconPosition="end"
|
||||
>
|
||||
<Panel
|
||||
header={
|
||||
<div className="help-panel-header">
|
||||
<span className="help-panel-header-text">当前操作</span>
|
||||
{currentAction && (
|
||||
<Badge
|
||||
count="实时"
|
||||
style={{
|
||||
backgroundColor: '#52c41a',
|
||||
fontSize: 10,
|
||||
height: 18,
|
||||
lineHeight: '18px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
key="current"
|
||||
>
|
||||
{renderCurrentAction()}
|
||||
</Panel>
|
||||
|
||||
<Panel header="所有可用操作" key="all">
|
||||
{renderAllActions()}
|
||||
</Panel>
|
||||
</Collapse>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActionHelpPanel
|
||||
|
|
@ -2,257 +2,21 @@
|
|||
margin-top: auto;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
padding: 8px 0;
|
||||
background: var(--app-surface-color, #fff);
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px 16px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.app-pagination-total {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
color: var(--app-text-main, #333);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-pagination-controls {
|
||||
flex: 1 1 auto;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.app-pagination-container .ant-pagination {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 32px;
|
||||
flex: 1 1 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin: 0 !important;
|
||||
overflow: visible;
|
||||
flex-wrap: nowrap;
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.app-pagination-container .app-global-pagination.ant-pagination {
|
||||
width: auto;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border-top: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.app-pagination-container .ant-pagination .ant-pagination-options {
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
margin-inline-start: 8px !important;
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.app-pagination-container .ant-pagination .ant-pagination-options-quick-jumper {
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-pagination-container .ant-select {
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-pagination-container .ant-select-selector {
|
||||
height: 32px !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.app-pagination-container .ant-select-selection-item {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
line-height: 30px !important;
|
||||
}
|
||||
|
||||
.app-pagination-quick-jumper {
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: stretch;
|
||||
align-self: center;
|
||||
gap: 6px;
|
||||
color: var(--app-text-main, #333);
|
||||
font-size: 14px;
|
||||
line-height: 32px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-pagination-quick-jumper__label {
|
||||
box-sizing: border-box;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 32px;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.app-pagination-quick-jumper input {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
flex: 0 0 48px;
|
||||
width: 48px;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
color: var(--app-text-main, #333);
|
||||
font-size: 14px;
|
||||
line-height: 30px;
|
||||
text-align: center;
|
||||
background-color: var(--app-surface-color, #fff);
|
||||
border: 1px solid var(--app-border-color, #d9d9d9);
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.app-pagination-quick-jumper input:hover {
|
||||
border-color: var(--app-primary-color, #1677ff);
|
||||
}
|
||||
|
||||
.app-pagination-quick-jumper input:focus {
|
||||
border-color: var(--app-primary-color, #1677ff);
|
||||
box-shadow: 0 0 0 2px rgba(var(--app-primary-rgb, 22, 119, 255), 0.12);
|
||||
}
|
||||
|
||||
.app-pagination-quick-jumper input:disabled {
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
cursor: not-allowed;
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
border-color: var(--app-border-color, #d9d9d9);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.app-pagination-container .ant-pagination-total-text {
|
||||
display: none;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.app-pagination-container .ant-select-selection-search-input {
|
||||
caret-color: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-pagination-container--compact {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.app-pagination-container--compact .app-pagination-total {
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
.app-pagination-container--compact .app-pagination-controls {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.app-pagination-container--compact .ant-pagination {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.app-pagination-container--narrow {
|
||||
height: auto;
|
||||
min-height: 50px;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-pagination-container--narrow .app-pagination-controls {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.app-pagination-container--narrow .ant-pagination {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.app-pagination-container--simple {
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.app-pagination-container--simple .app-pagination-total {
|
||||
flex: 1 1 auto;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.app-pagination-container--simple .app-pagination-controls {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
min-width: max-content;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.app-pagination-container--simple .ant-pagination {
|
||||
justify-content: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-pagination-container {
|
||||
height: auto;
|
||||
min-height: 50px;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.app-pagination-container .ant-pagination {
|
||||
width: auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.app-pagination-controls {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.app-pagination-container--simple {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-pagination-container--simple .app-pagination-controls {
|
||||
width: auto;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.app-pagination-container--simple .ant-pagination {
|
||||
justify-content: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,140 +1,32 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Pagination, type PaginationProps } from 'antd';
|
||||
import React from 'react';
|
||||
import { Pagination, PaginationProps } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getMinPageSize, getPageSizeOptions, type PaginationVariant } from '@/utils/pagination';
|
||||
import './index.css';
|
||||
|
||||
export interface AppPaginationProps extends PaginationProps {
|
||||
total: number;
|
||||
variant?: PaginationVariant;
|
||||
}
|
||||
|
||||
export default function AppPagination(props: AppPaginationProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
className,
|
||||
showQuickJumper,
|
||||
showSizeChanger,
|
||||
showTotal,
|
||||
simple,
|
||||
total,
|
||||
variant = 'table',
|
||||
pageSizeOptions,
|
||||
onChange,
|
||||
...restProps
|
||||
} = props;
|
||||
const [jumperValue, setJumperValue] = useState('');
|
||||
const [layoutMode, setLayoutMode] = useState<'regular' | 'compact' | 'narrow'>('regular');
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const { className, showSizeChanger, ...restProps } = props;
|
||||
const mergedClassName = ['app-global-pagination', className].filter(Boolean).join(' ');
|
||||
const mergedShowSizeChanger =
|
||||
showSizeChanger === undefined || showSizeChanger === true
|
||||
? { showSearch: false }
|
||||
: showSizeChanger;
|
||||
const mergedPageSizeOptions = pageSizeOptions ?? getPageSizeOptions(variant);
|
||||
const defaultPageSize = getMinPageSize(mergedPageSizeOptions);
|
||||
const current = Number(restProps.current ?? restProps.defaultCurrent ?? 1);
|
||||
const pageSize = Number(restProps.pageSize ?? restProps.defaultPageSize ?? defaultPageSize);
|
||||
const rangeStart = total > 0 ? (current - 1) * pageSize + 1 : 0;
|
||||
const rangeEnd = total > 0 ? Math.min(current * pageSize, total) : 0;
|
||||
const totalContent = showTotal ? showTotal(total, [rangeStart, rangeEnd]) : t('common.total', { total });
|
||||
const pageCount = useMemo(() => Math.max(1, Math.ceil(total / pageSize)), [pageSize, total]);
|
||||
const shouldShowQuickJumper = showQuickJumper !== false && !simple;
|
||||
const isDisabled = Boolean(restProps.disabled);
|
||||
const containerClassName = [
|
||||
'app-pagination-container',
|
||||
`app-pagination-container--${layoutMode}`,
|
||||
simple && 'app-pagination-container--simple',
|
||||
shouldShowQuickJumper && 'app-pagination-container--with-jumper',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
useEffect(() => {
|
||||
setJumperValue('');
|
||||
}, [current, pageSize, total]);
|
||||
|
||||
useEffect(() => {
|
||||
if (simple) {
|
||||
setLayoutMode('regular');
|
||||
return;
|
||||
}
|
||||
|
||||
const node = containerRef.current;
|
||||
if (!node || typeof ResizeObserver === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateLayoutMode = (width: number) => {
|
||||
if (width < 520) {
|
||||
setLayoutMode('narrow');
|
||||
} else if (width < 760) {
|
||||
setLayoutMode('compact');
|
||||
} else {
|
||||
setLayoutMode('regular');
|
||||
}
|
||||
};
|
||||
|
||||
updateLayoutMode(node.getBoundingClientRect().width);
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
updateLayoutMode(entry.contentRect.width);
|
||||
});
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [simple]);
|
||||
|
||||
const handleJump = () => {
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
if (!jumperValue.trim()) {
|
||||
return;
|
||||
}
|
||||
const targetPage = Number(jumperValue);
|
||||
if (!Number.isFinite(targetPage)) {
|
||||
setJumperValue('');
|
||||
return;
|
||||
}
|
||||
const nextPage = Math.min(Math.max(Math.trunc(targetPage), 1), pageCount);
|
||||
if (nextPage !== current) {
|
||||
onChange?.(nextPage, pageSize);
|
||||
}
|
||||
setJumperValue('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={containerClassName}>
|
||||
<div className="app-pagination-total">{totalContent}</div>
|
||||
<div className="app-pagination-controls">
|
||||
<Pagination
|
||||
className={mergedClassName}
|
||||
showSizeChanger={mergedShowSizeChanger}
|
||||
showQuickJumper={false}
|
||||
simple={simple}
|
||||
defaultPageSize={defaultPageSize}
|
||||
pageSizeOptions={mergedPageSizeOptions}
|
||||
size="default"
|
||||
total={total}
|
||||
onChange={onChange}
|
||||
{...restProps}
|
||||
/>
|
||||
{shouldShowQuickJumper ? (
|
||||
<span className="app-pagination-quick-jumper">
|
||||
<span className="app-pagination-quick-jumper__label">跳至</span>
|
||||
<input
|
||||
aria-label="跳转页码"
|
||||
disabled={isDisabled}
|
||||
value={jumperValue}
|
||||
onChange={(event) => setJumperValue(event.target.value.replace(/[^\d]/g, ''))}
|
||||
onBlur={handleJump}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleJump();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="app-pagination-quick-jumper__label">页</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="app-pagination-container">
|
||||
<Pagination
|
||||
className={mergedClassName}
|
||||
showSizeChanger={mergedShowSizeChanger}
|
||||
showQuickJumper
|
||||
showTotal={(total) => t('common.total', { total })}
|
||||
pageSizeOptions={['8','10', '20', '50', '100']}
|
||||
size="default"
|
||||
{...restProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,304 @@
|
|||
/* 底部提示栏基础样式 */
|
||||
.bottom-hint-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
padding: 12px 24px;
|
||||
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 主题样式 */
|
||||
.bottom-hint-bar-light {
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.bottom-hint-bar-dark {
|
||||
background: #001529;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.bottom-hint-bar-gradient {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* 容器布局 */
|
||||
.hint-bar-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 左侧区域 */
|
||||
.hint-bar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hint-bar-icon {
|
||||
font-size: 24px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.bottom-hint-bar-light .hint-bar-icon {
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.hint-bar-title-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.hint-bar-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.bottom-hint-bar-light .hint-bar-title {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.hint-bar-badge {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* 中间区域 */
|
||||
.hint-bar-center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hint-bar-description,
|
||||
.hint-bar-quick-tip,
|
||||
.hint-bar-warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.bottom-hint-bar-light .hint-bar-description,
|
||||
.bottom-hint-bar-light .hint-bar-quick-tip {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.hint-info-icon {
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.bottom-hint-bar-light .hint-info-icon {
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.hint-tip-icon {
|
||||
font-size: 14px;
|
||||
color: #fadb14;
|
||||
}
|
||||
|
||||
.hint-warning-icon {
|
||||
font-size: 14px;
|
||||
color: #ff7a45;
|
||||
}
|
||||
|
||||
.bottom-hint-bar-light .hint-bar-warning {
|
||||
color: #d46b08;
|
||||
}
|
||||
|
||||
/* 右侧区域 */
|
||||
.hint-bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hint-bar-shortcut {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.shortcut-label {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.shortcut-kbd {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
color: inherit;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.bottom-hint-bar-light .shortcut-kbd {
|
||||
background: #f0f0f0;
|
||||
border-color: #d9d9d9;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.hint-bar-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.hint-bar-close:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.bottom-hint-bar-light .hint-bar-close {
|
||||
background: #f0f0f0;
|
||||
border-color: #d9d9d9;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.bottom-hint-bar-light .hint-bar-close:hover {
|
||||
background: #e0e0e0;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
/* 进度指示条 */
|
||||
.hint-bar-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hint-bar-progress::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
animation: progressWave 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.bottom-hint-bar-light .hint-bar-progress {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.bottom-hint-bar-light .hint-bar-progress::after {
|
||||
background: #1677ff;
|
||||
}
|
||||
|
||||
@keyframes progressWave {
|
||||
0%, 100% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 1024px) {
|
||||
.hint-bar-container {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hint-bar-center {
|
||||
flex-basis: 100%;
|
||||
order: 3;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hint-bar-description,
|
||||
.hint-bar-quick-tip,
|
||||
.hint-bar-warning {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.bottom-hint-bar {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.hint-bar-left {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hint-bar-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.hint-bar-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.hint-bar-right {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.shortcut-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hint-bar-close {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.hint-bar-quick-tip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hint-bar-warning {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import { Tag } from 'antd'
|
||||
import {
|
||||
InfoCircleOutlined,
|
||||
BulbOutlined,
|
||||
WarningOutlined,
|
||||
CloseOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import './BottomHintBar.css'
|
||||
|
||||
/**
|
||||
* 底部固定提示栏组件
|
||||
* 在页面底部显示当前悬停按钮的实时说明
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.visible - 是否显示提示栏
|
||||
* @param {Object} props.hintInfo - 当前提示信息
|
||||
* @param {Function} props.onClose - 关闭回调
|
||||
* @param {string} props.theme - 主题:light, dark, gradient
|
||||
*/
|
||||
function BottomHintBar({ visible = false, hintInfo = null, onClose, theme = 'gradient' }) {
|
||||
if (!visible || !hintInfo) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bottom-hint-bar bottom-hint-bar-${theme}`}
|
||||
onMouseEnter={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="hint-bar-container">
|
||||
{/* 左侧:图标和标题 */}
|
||||
<div className="hint-bar-left">
|
||||
<div className="hint-bar-icon">{hintInfo.icon}</div>
|
||||
<div className="hint-bar-title-section">
|
||||
<h4 className="hint-bar-title">{hintInfo.title}</h4>
|
||||
{hintInfo.badge && (
|
||||
<Tag color={hintInfo.badge.color} className="hint-bar-badge">
|
||||
{hintInfo.badge.text}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中间:主要信息 */}
|
||||
<div className="hint-bar-center">
|
||||
{/* 描述 */}
|
||||
{hintInfo.description && (
|
||||
<div className="hint-bar-description">
|
||||
<InfoCircleOutlined className="hint-info-icon" />
|
||||
<span>{hintInfo.description}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 快速提示 */}
|
||||
{hintInfo.quickTip && (
|
||||
<div className="hint-bar-quick-tip">
|
||||
<BulbOutlined className="hint-tip-icon" />
|
||||
<span>{hintInfo.quickTip}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 警告 */}
|
||||
{hintInfo.warning && (
|
||||
<div className="hint-bar-warning">
|
||||
<WarningOutlined className="hint-warning-icon" />
|
||||
<span>{hintInfo.warning}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧:快捷键和关闭 */}
|
||||
<div className="hint-bar-right">
|
||||
{hintInfo.shortcut && (
|
||||
<div className="hint-bar-shortcut">
|
||||
<span className="shortcut-label">快捷键</span>
|
||||
<kbd className="shortcut-kbd">{hintInfo.shortcut}</kbd>
|
||||
</div>
|
||||
)}
|
||||
{onClose && (
|
||||
<button className="hint-bar-close" onClick={onClose}>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度指示条 */}
|
||||
<div className="hint-bar-progress" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BottomHintBar
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
/* 按钮带引导 - 简洁现代设计 */
|
||||
.button-with-guide {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 帮助图标按钮 - 简洁扁平设计 */
|
||||
.guide-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: rgba(0, 0, 0, 0.35);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.guide-icon-btn:hover {
|
||||
background: rgba(22, 119, 255, 0.06);
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.guide-icon-btn:active {
|
||||
background: rgba(22, 119, 255, 0.12);
|
||||
}
|
||||
|
||||
/* 引导弹窗样式 */
|
||||
.button-guide-modal .ant-modal-header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.button-guide-modal .ant-modal-body {
|
||||
padding: 24px;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.guide-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.guide-modal-icon {
|
||||
font-size: 24px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.guide-modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.guide-modal-badge {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
/* 引导区块样式 */
|
||||
.guide-section {
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid #1677ff;
|
||||
}
|
||||
|
||||
.guide-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.guide-section-warning {
|
||||
background: #fff7e6;
|
||||
border-left-color: #faad14;
|
||||
}
|
||||
|
||||
.guide-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.guide-section-icon {
|
||||
font-size: 16px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.guide-section-warning .guide-section-icon {
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.guide-section-content {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.guide-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.guide-list li {
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.guide-list li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 步骤样式 */
|
||||
.guide-steps {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.guide-steps .ant-steps-item-title {
|
||||
font-size: 13px !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.guide-steps .ant-steps-item-description {
|
||||
font-size: 13px !important;
|
||||
line-height: 1.6 !important;
|
||||
color: rgba(0, 0, 0, 0.65) !important;
|
||||
}
|
||||
|
||||
/* 引导底部 */
|
||||
.guide-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.guide-footer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.guide-footer-label {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.guide-footer-kbd {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%);
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
|
||||
font-size: 11px;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.button-guide-modal {
|
||||
max-width: calc(100% - 32px);
|
||||
}
|
||||
|
||||
.button-guide-modal .ant-modal-body {
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
.guide-footer {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
import { useState } from 'react'
|
||||
import { Button, Modal, Steps, Tag } from 'antd'
|
||||
import {
|
||||
QuestionCircleOutlined,
|
||||
BulbOutlined,
|
||||
WarningOutlined,
|
||||
CheckCircleOutlined,
|
||||
InfoCircleOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import './ButtonWithGuide.css'
|
||||
|
||||
/**
|
||||
* 带引导的按钮组件 - 简洁现代设计
|
||||
* 在按钮旁边显示一个简洁的帮助图标,点击后显示详细引导
|
||||
*/
|
||||
function ButtonWithGuide({
|
||||
label,
|
||||
icon,
|
||||
type = 'default',
|
||||
danger = false,
|
||||
disabled = false,
|
||||
onClick,
|
||||
guide,
|
||||
size = 'middle',
|
||||
...restProps
|
||||
}) {
|
||||
const [showGuideModal, setShowGuideModal] = useState(false)
|
||||
|
||||
const handleGuideClick = (e) => {
|
||||
e.stopPropagation()
|
||||
if (guide) {
|
||||
setShowGuideModal(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="button-with-guide">
|
||||
<Button
|
||||
type={type}
|
||||
icon={icon}
|
||||
danger={danger}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size={size}
|
||||
{...restProps}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
{guide && !disabled && (
|
||||
<button className="guide-icon-btn" onClick={handleGuideClick} title="查看帮助">
|
||||
<QuestionCircleOutlined />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 引导弹窗 */}
|
||||
{guide && (
|
||||
<Modal
|
||||
title={
|
||||
<div className="guide-modal-header">
|
||||
<span className="guide-modal-icon">{guide.icon || icon}</span>
|
||||
<span className="guide-modal-title">{guide.title}</span>
|
||||
{guide.badge && (
|
||||
<Tag color={guide.badge.color} className="guide-modal-badge">
|
||||
{guide.badge.text}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
open={showGuideModal}
|
||||
onCancel={() => setShowGuideModal(false)}
|
||||
footer={[
|
||||
<Button key="close" type="primary" onClick={() => setShowGuideModal(false)}>
|
||||
知道了
|
||||
</Button>,
|
||||
]}
|
||||
width={600}
|
||||
className="button-guide-modal"
|
||||
>
|
||||
{/* 功能描述 */}
|
||||
{guide.description && (
|
||||
<div className="guide-section">
|
||||
<div className="guide-section-title">
|
||||
<InfoCircleOutlined className="guide-section-icon" />
|
||||
功能说明
|
||||
</div>
|
||||
<p className="guide-section-content">{guide.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 使用步骤 */}
|
||||
{guide.steps && guide.steps.length > 0 && (
|
||||
<div className="guide-section">
|
||||
<div className="guide-section-title">
|
||||
<CheckCircleOutlined className="guide-section-icon" />
|
||||
操作步骤
|
||||
</div>
|
||||
<Steps
|
||||
direction="vertical"
|
||||
current={-1}
|
||||
items={guide.steps.map((step, index) => ({
|
||||
title: `步骤 ${index + 1}`,
|
||||
description: step,
|
||||
status: 'wait',
|
||||
}))}
|
||||
className="guide-steps"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 使用场景 */}
|
||||
{guide.scenarios && guide.scenarios.length > 0 && (
|
||||
<div className="guide-section">
|
||||
<div className="guide-section-title">
|
||||
<BulbOutlined className="guide-section-icon" />
|
||||
适用场景
|
||||
</div>
|
||||
<ul className="guide-list">
|
||||
{guide.scenarios.map((scenario, index) => (
|
||||
<li key={index}>{scenario}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 注意事项 */}
|
||||
{guide.warnings && guide.warnings.length > 0 && (
|
||||
<div className="guide-section guide-section-warning">
|
||||
<div className="guide-section-title">
|
||||
<WarningOutlined className="guide-section-icon" />
|
||||
注意事项
|
||||
</div>
|
||||
<ul className="guide-list">
|
||||
{guide.warnings.map((warning, index) => (
|
||||
<li key={index}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 快捷键和权限 */}
|
||||
{(guide.shortcut || guide.permission) && (
|
||||
<div className="guide-footer">
|
||||
{guide.shortcut && (
|
||||
<div className="guide-footer-item">
|
||||
<span className="guide-footer-label">快捷键:</span>
|
||||
<kbd className="guide-footer-kbd">{guide.shortcut}</kbd>
|
||||
</div>
|
||||
)}
|
||||
{guide.permission && (
|
||||
<div className="guide-footer-item">
|
||||
<span className="guide-footer-label">权限要求:</span>
|
||||
<Tag color="blue">{guide.permission}</Tag>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ButtonWithGuide
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
.button-guide-badge-wrapper {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 引导徽章样式 - 改为放在右上角外部 */
|
||||
.button-guide-badge-wrapper .ant-badge {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.button-guide-badge-wrapper .ant-badge-count {
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 引导徽章样式 */
|
||||
.guide-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
background: #1677ff;
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
animation: pulseBadge 2s ease-in-out infinite;
|
||||
box-shadow: 0 2px 8px rgba(22, 119, 255, 0.4);
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
.guide-badge:hover {
|
||||
animation: none;
|
||||
transform: scale(1.2);
|
||||
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.6);
|
||||
}
|
||||
|
||||
.guide-badge-new {
|
||||
background: linear-gradient(135deg, #52c41a 0%, #73d13d 100%);
|
||||
box-shadow: 0 2px 8px rgba(82, 196, 26, 0.4);
|
||||
}
|
||||
|
||||
.guide-badge-new:hover {
|
||||
box-shadow: 0 4px 12px rgba(82, 196, 26, 0.6);
|
||||
}
|
||||
|
||||
.guide-badge-help {
|
||||
background: linear-gradient(135deg, #1677ff 0%, #4096ff 100%);
|
||||
box-shadow: 0 2px 8px rgba(22, 119, 255, 0.4);
|
||||
}
|
||||
|
||||
.guide-badge-help:hover {
|
||||
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.6);
|
||||
}
|
||||
|
||||
.guide-badge-warn {
|
||||
background: linear-gradient(135deg, #faad14 0%, #ffc53d 100%);
|
||||
box-shadow: 0 2px 8px rgba(250, 173, 20, 0.4);
|
||||
}
|
||||
|
||||
.guide-badge-warn:hover {
|
||||
box-shadow: 0 4px 12px rgba(250, 173, 20, 0.6);
|
||||
}
|
||||
|
||||
@keyframes pulseBadge {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
/* 引导弹窗样式 */
|
||||
.button-guide-modal .ant-modal-header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.button-guide-modal .ant-modal-body {
|
||||
padding: 24px;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.guide-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.guide-modal-icon {
|
||||
font-size: 24px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.guide-modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.guide-modal-badge {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
/* 引导区块样式 */
|
||||
.guide-section {
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid #1677ff;
|
||||
}
|
||||
|
||||
.guide-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.guide-section-warning {
|
||||
background: #fff7e6;
|
||||
border-left-color: #faad14;
|
||||
}
|
||||
|
||||
.guide-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.guide-section-icon {
|
||||
font-size: 16px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.guide-section-warning .guide-section-icon {
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.guide-section-content {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.guide-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.guide-list li {
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.guide-list li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 步骤样式 */
|
||||
.guide-steps {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.guide-steps .ant-steps-item-title {
|
||||
font-size: 13px !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.guide-steps .ant-steps-item-description {
|
||||
font-size: 13px !important;
|
||||
line-height: 1.6 !important;
|
||||
color: rgba(0, 0, 0, 0.65) !important;
|
||||
}
|
||||
|
||||
/* 引导底部 */
|
||||
.guide-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.guide-footer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.guide-footer-label {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.guide-footer-kbd {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%);
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
|
||||
font-size: 11px;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.button-guide-modal {
|
||||
max-width: calc(100% - 32px);
|
||||
}
|
||||
|
||||
.button-guide-modal .ant-modal-body {
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
.guide-footer {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
import { useState } from 'react'
|
||||
import { Button, Badge, Modal, Steps, Tag, Divider } from 'antd'
|
||||
import {
|
||||
QuestionCircleOutlined,
|
||||
BulbOutlined,
|
||||
WarningOutlined,
|
||||
CheckCircleOutlined,
|
||||
InfoCircleOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import './ButtonWithGuideBadge.css'
|
||||
|
||||
/**
|
||||
* 智能引导徽章按钮组件
|
||||
* 为新功能或复杂按钮添加脉冲动画的徽章,点击后显示详细引导
|
||||
* @param {Object} props
|
||||
* @param {string} props.label - 按钮文本
|
||||
* @param {ReactNode} props.icon - 按钮图标
|
||||
* @param {string} props.type - 按钮类型
|
||||
* @param {boolean} props.danger - 危险按钮
|
||||
* @param {boolean} props.disabled - 禁用状态
|
||||
* @param {Function} props.onClick - 点击回调
|
||||
* @param {Object} props.guide - 引导配置
|
||||
* @param {boolean} props.showBadge - 是否显示徽章
|
||||
* @param {string} props.badgeType - 徽章类型:new, help, warn
|
||||
* @param {string} props.size - 按钮大小
|
||||
*/
|
||||
function ButtonWithGuideBadge({
|
||||
label,
|
||||
icon,
|
||||
type = 'default',
|
||||
danger = false,
|
||||
disabled = false,
|
||||
onClick,
|
||||
guide,
|
||||
showBadge = true,
|
||||
badgeType = 'help',
|
||||
size = 'middle',
|
||||
...restProps
|
||||
}) {
|
||||
const [showGuideModal, setShowGuideModal] = useState(false)
|
||||
|
||||
const handleBadgeClick = (e) => {
|
||||
e.stopPropagation()
|
||||
if (guide) {
|
||||
setShowGuideModal(true)
|
||||
}
|
||||
}
|
||||
|
||||
const getBadgeConfig = () => {
|
||||
const configs = {
|
||||
new: {
|
||||
text: 'NEW',
|
||||
color: '#52c41a',
|
||||
icon: <InfoCircleOutlined />,
|
||||
},
|
||||
help: {
|
||||
text: '?',
|
||||
color: '#1677ff',
|
||||
icon: <QuestionCircleOutlined />,
|
||||
},
|
||||
warn: {
|
||||
text: '!',
|
||||
color: '#faad14',
|
||||
icon: <WarningOutlined />,
|
||||
},
|
||||
}
|
||||
return configs[badgeType] || configs.help
|
||||
}
|
||||
|
||||
const badgeConfig = getBadgeConfig()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="button-guide-badge-wrapper">
|
||||
{showBadge && guide && !disabled ? (
|
||||
<Badge
|
||||
count={
|
||||
<div
|
||||
className={`guide-badge guide-badge-${badgeType}`}
|
||||
onClick={handleBadgeClick}
|
||||
>
|
||||
{badgeConfig.icon}
|
||||
</div>
|
||||
}
|
||||
offset={[-5, 5]}
|
||||
>
|
||||
<Button
|
||||
type={type}
|
||||
icon={icon}
|
||||
danger={danger}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size={size}
|
||||
{...restProps}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
type={type}
|
||||
icon={icon}
|
||||
danger={danger}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size={size}
|
||||
{...restProps}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 引导弹窗 */}
|
||||
{guide && (
|
||||
<Modal
|
||||
title={
|
||||
<div className="guide-modal-header">
|
||||
<span className="guide-modal-icon">{guide.icon || icon}</span>
|
||||
<span className="guide-modal-title">{guide.title}</span>
|
||||
{guide.badge && (
|
||||
<Tag color={guide.badge.color} className="guide-modal-badge">
|
||||
{guide.badge.text}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
open={showGuideModal}
|
||||
onCancel={() => setShowGuideModal(false)}
|
||||
footer={[
|
||||
<Button key="close" type="primary" onClick={() => setShowGuideModal(false)}>
|
||||
知道了
|
||||
</Button>,
|
||||
]}
|
||||
width={600}
|
||||
className="button-guide-modal"
|
||||
>
|
||||
{/* 功能描述 */}
|
||||
{guide.description && (
|
||||
<div className="guide-section">
|
||||
<div className="guide-section-title">
|
||||
<InfoCircleOutlined className="guide-section-icon" />
|
||||
功能说明
|
||||
</div>
|
||||
<p className="guide-section-content">{guide.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 使用步骤 */}
|
||||
{guide.steps && guide.steps.length > 0 && (
|
||||
<div className="guide-section">
|
||||
<div className="guide-section-title">
|
||||
<CheckCircleOutlined className="guide-section-icon" />
|
||||
操作步骤
|
||||
</div>
|
||||
<Steps
|
||||
direction="vertical"
|
||||
current={-1}
|
||||
items={guide.steps.map((step, index) => ({
|
||||
title: `步骤 ${index + 1}`,
|
||||
description: step,
|
||||
status: 'wait',
|
||||
}))}
|
||||
className="guide-steps"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 使用场景 */}
|
||||
{guide.scenarios && guide.scenarios.length > 0 && (
|
||||
<div className="guide-section">
|
||||
<div className="guide-section-title">
|
||||
<BulbOutlined className="guide-section-icon" />
|
||||
适用场景
|
||||
</div>
|
||||
<ul className="guide-list">
|
||||
{guide.scenarios.map((scenario, index) => (
|
||||
<li key={index}>{scenario}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 注意事项 */}
|
||||
{guide.warnings && guide.warnings.length > 0 && (
|
||||
<div className="guide-section guide-section-warning">
|
||||
<div className="guide-section-title">
|
||||
<WarningOutlined className="guide-section-icon" />
|
||||
注意事项
|
||||
</div>
|
||||
<ul className="guide-list">
|
||||
{guide.warnings.map((warning, index) => (
|
||||
<li key={index}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 快捷键和权限 */}
|
||||
{(guide.shortcut || guide.permission) && (
|
||||
<div className="guide-footer">
|
||||
{guide.shortcut && (
|
||||
<div className="guide-footer-item">
|
||||
<span className="guide-footer-label">快捷键:</span>
|
||||
<kbd className="guide-footer-kbd">{guide.shortcut}</kbd>
|
||||
</div>
|
||||
)}
|
||||
{guide.permission && (
|
||||
<div className="guide-footer-item">
|
||||
<span className="guide-footer-label">权限要求:</span>
|
||||
<Tag color="blue">{guide.permission}</Tag>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ButtonWithGuideBadge
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
.button-hover-card-wrapper {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 悬浮卡片 */
|
||||
.hover-info-card {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
transform: translateY(-50%);
|
||||
opacity: 0;
|
||||
animation: slideInRight 0.3s ease forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hover-info-card-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-50%) translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.hover-info-card-content {
|
||||
width: 340px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 12px 28px rgba(0, 0, 0, 0.12),
|
||||
0 6px 12px rgba(0, 0, 0, 0.08),
|
||||
0 0 2px rgba(0, 0, 0, 0.04);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hover-info-card-content .ant-card-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* 卡片头部 */
|
||||
.hover-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.hover-card-title-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hover-card-icon {
|
||||
font-size: 20px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.hover-card-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.hover-card-badge {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* 卡片描述 */
|
||||
.hover-card-description {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
/* 卡片区块 */
|
||||
.hover-card-section {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid #1677ff;
|
||||
}
|
||||
|
||||
.hover-card-warning {
|
||||
background: #fff7e6;
|
||||
border-left-color: #faad14;
|
||||
}
|
||||
|
||||
.hover-card-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
font-size: 12px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.hover-card-warning .section-icon {
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.hover-card-list {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.hover-card-list li {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.hover-card-list li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 卡片底部 */
|
||||
.hover-card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.footer-label {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.footer-kbd {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%);
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
|
||||
font-size: 11px;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.hover-info-card-content {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.hover-info-card {
|
||||
left: 50% !important;
|
||||
transform: translateX(-50%) translateY(-50%);
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-50%) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(-50%) scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
import { useState, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Button, Card, Tag } from 'antd'
|
||||
import {
|
||||
BulbOutlined,
|
||||
WarningOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import './ButtonWithHoverCard.css'
|
||||
|
||||
/**
|
||||
* 悬浮展开卡片按钮组件
|
||||
* 鼠标悬停时,在按钮旁边展开一个精美的信息卡片
|
||||
* @param {Object} props
|
||||
* @param {string} props.label - 按钮文本
|
||||
* @param {ReactNode} props.icon - 按钮图标
|
||||
* @param {string} props.type - 按钮类型
|
||||
* @param {boolean} props.danger - 危险按钮
|
||||
* @param {boolean} props.disabled - 禁用状态
|
||||
* @param {Function} props.onClick - 点击回调
|
||||
* @param {Object} props.cardInfo - 卡片信息配置
|
||||
* @param {string} props.size - 按钮大小
|
||||
*/
|
||||
function ButtonWithHoverCard({
|
||||
label,
|
||||
icon,
|
||||
type = 'default',
|
||||
danger = false,
|
||||
disabled = false,
|
||||
onClick,
|
||||
cardInfo,
|
||||
size = 'middle',
|
||||
...restProps
|
||||
}) {
|
||||
const [showCard, setShowCard] = useState(false)
|
||||
const [cardPosition, setCardPosition] = useState({ top: 0, left: 0 })
|
||||
const wrapperRef = useRef(null)
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!cardInfo || disabled) return
|
||||
|
||||
if (wrapperRef.current) {
|
||||
const rect = wrapperRef.current.getBoundingClientRect()
|
||||
setCardPosition({
|
||||
top: rect.top + rect.height / 2,
|
||||
left: rect.right + 12,
|
||||
})
|
||||
}
|
||||
setShowCard(true)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setShowCard(false)
|
||||
}
|
||||
|
||||
// 渲染悬浮卡片
|
||||
const renderCard = () => {
|
||||
if (!showCard || !cardInfo) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`hover-info-card ${showCard ? 'hover-info-card-visible' : ''}`}
|
||||
style={{
|
||||
top: cardPosition.top,
|
||||
left: cardPosition.left,
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
size="small"
|
||||
variant="borderless"
|
||||
className="hover-info-card-content"
|
||||
>
|
||||
{/* 标题区 */}
|
||||
<div className="hover-card-header">
|
||||
<div className="hover-card-title-wrapper">
|
||||
{cardInfo.icon && (
|
||||
<span className="hover-card-icon">{cardInfo.icon}</span>
|
||||
)}
|
||||
<h4 className="hover-card-title">{cardInfo.title}</h4>
|
||||
</div>
|
||||
{cardInfo.badge && (
|
||||
<Tag color={cardInfo.badge.color} className="hover-card-badge">
|
||||
{cardInfo.badge.text}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
{cardInfo.description && (
|
||||
<div className="hover-card-section">
|
||||
<p className="hover-card-description">{cardInfo.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 使用场景 */}
|
||||
{cardInfo.scenarios && cardInfo.scenarios.length > 0 && (
|
||||
<div className="hover-card-section">
|
||||
<div className="hover-card-section-title">
|
||||
<BulbOutlined className="section-icon" />
|
||||
使用场景
|
||||
</div>
|
||||
<ul className="hover-card-list">
|
||||
{cardInfo.scenarios.slice(0, 2).map((scenario, index) => (
|
||||
<li key={index}>{scenario}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 快速提示 */}
|
||||
{cardInfo.quickTips && cardInfo.quickTips.length > 0 && (
|
||||
<div className="hover-card-section">
|
||||
<div className="hover-card-section-title">
|
||||
<ThunderboltOutlined className="section-icon" />
|
||||
快速提示
|
||||
</div>
|
||||
<ul className="hover-card-list">
|
||||
{cardInfo.quickTips.map((tip, index) => (
|
||||
<li key={index}>{tip}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 注意事项 */}
|
||||
{cardInfo.warnings && cardInfo.warnings.length > 0 && (
|
||||
<div className="hover-card-section hover-card-warning">
|
||||
<div className="hover-card-section-title">
|
||||
<WarningOutlined className="section-icon" />
|
||||
注意
|
||||
</div>
|
||||
<ul className="hover-card-list">
|
||||
{cardInfo.warnings.slice(0, 2).map((warning, index) => (
|
||||
<li key={index}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 快捷键 */}
|
||||
{cardInfo.shortcut && (
|
||||
<div className="hover-card-footer">
|
||||
<span className="footer-label">快捷键</span>
|
||||
<kbd className="footer-kbd">{cardInfo.shortcut}</kbd>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="button-hover-card-wrapper"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<Button
|
||||
type={type}
|
||||
icon={icon}
|
||||
danger={danger}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size={size}
|
||||
{...restProps}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 使用 Portal 渲染悬浮卡片到 body */}
|
||||
{typeof document !== 'undefined' && createPortal(renderCard(), document.body)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ButtonWithHoverCard
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
/* 按钮包裹容器 */
|
||||
.button-with-tip-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.button-with-tip {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 提示指示器 */
|
||||
.button-tip-indicator {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
cursor: help;
|
||||
transition: all 0.3s ease;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.button-with-tip-wrapper:hover .button-tip-indicator {
|
||||
color: #1677ff;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* 脉冲动画 */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 提示框样式 */
|
||||
.button-tip-overlay {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.button-tip-overlay .ant-tooltip-inner {
|
||||
padding: 12px 16px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.button-tip-overlay .ant-tooltip-arrow {
|
||||
--antd-arrow-background-color: #667eea;
|
||||
}
|
||||
|
||||
.button-tip-overlay .ant-tooltip-arrow-content {
|
||||
background: #667eea;
|
||||
}
|
||||
|
||||
/* 提示内容布局 */
|
||||
.button-tip-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.button-tip-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.button-tip-description {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.button-tip-shortcut {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.tip-label {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.tip-kbd {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.button-tip-notes {
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.tip-notes-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tip-notes-list {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.tip-notes-list li {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tip-notes-list li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 不同主题的提示框 */
|
||||
.tip-theme-success.button-tip-overlay .ant-tooltip-inner {
|
||||
background: linear-gradient(135deg, #56ab2f 0%, #a8e063 100%);
|
||||
}
|
||||
|
||||
.tip-theme-warning.button-tip-overlay .ant-tooltip-inner {
|
||||
background: linear-gradient(135deg, #f7971e 0%, #ffd200 100%);
|
||||
}
|
||||
|
||||
.tip-theme-danger.button-tip-overlay .ant-tooltip-inner {
|
||||
background: linear-gradient(135deg, #eb3349 0%, #f45c43 100%);
|
||||
}
|
||||
|
||||
.tip-theme-info.button-tip-overlay .ant-tooltip-inner {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.button-tip-overlay {
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.button-tip-indicator {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import { Button, Tooltip } from 'antd'
|
||||
import { QuestionCircleOutlined } from '@ant-design/icons'
|
||||
import './ButtonWithTip.css'
|
||||
|
||||
/**
|
||||
* 带有增强提示的按钮组件
|
||||
* @param {Object} props
|
||||
* @param {string} props.label - 按钮文本
|
||||
* @param {ReactNode} props.icon - 按钮图标
|
||||
* @param {string} props.type - 按钮类型
|
||||
* @param {boolean} props.danger - 危险按钮
|
||||
* @param {boolean} props.disabled - 禁用状态
|
||||
* @param {Function} props.onClick - 点击回调
|
||||
* @param {Object} props.tip - 提示配置
|
||||
* @param {string} props.tip.title - 提示标题
|
||||
* @param {string} props.tip.description - 详细描述
|
||||
* @param {string} props.tip.shortcut - 快捷键提示
|
||||
* @param {Array} props.tip.notes - 注意事项列表
|
||||
* @param {string} props.tip.placement - 提示位置
|
||||
* @param {boolean} props.showTipIcon - 是否显示提示图标
|
||||
* @param {string} props.size - 按钮大小
|
||||
*/
|
||||
function ButtonWithTip({
|
||||
label,
|
||||
icon,
|
||||
type = 'default',
|
||||
danger = false,
|
||||
disabled = false,
|
||||
onClick,
|
||||
tip,
|
||||
showTipIcon = true,
|
||||
size = 'middle',
|
||||
...restProps
|
||||
}) {
|
||||
// 如果没有提示配置,直接返回普通按钮
|
||||
if (!tip) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
icon={icon}
|
||||
danger={danger}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size={size}
|
||||
{...restProps}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// 构建提示内容
|
||||
const tooltipContent = (
|
||||
<div className="button-tip-content">
|
||||
{tip.title && <div className="button-tip-title">{tip.title}</div>}
|
||||
{tip.description && <div className="button-tip-description">{tip.description}</div>}
|
||||
{tip.shortcut && (
|
||||
<div className="button-tip-shortcut">
|
||||
<span className="tip-label">快捷键:</span>
|
||||
<kbd className="tip-kbd">{tip.shortcut}</kbd>
|
||||
</div>
|
||||
)}
|
||||
{tip.notes && tip.notes.length > 0 && (
|
||||
<div className="button-tip-notes">
|
||||
<div className="tip-notes-title">注意事项:</div>
|
||||
<ul className="tip-notes-list">
|
||||
{tip.notes.map((note, index) => (
|
||||
<li key={index}>{note}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={tooltipContent}
|
||||
placement={tip.placement || 'top'}
|
||||
classNames={{ root: 'button-tip-overlay' }}
|
||||
mouseEnterDelay={0.3}
|
||||
arrow={{ pointAtCenter: true }}
|
||||
>
|
||||
<div className="button-with-tip-wrapper">
|
||||
<Button
|
||||
type={type}
|
||||
icon={icon}
|
||||
danger={danger}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size={size}
|
||||
className="button-with-tip"
|
||||
{...restProps}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
{showTipIcon && !disabled && (
|
||||
<QuestionCircleOutlined className="button-tip-indicator" />
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default ButtonWithTip
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
/* 图表面板 */
|
||||
.chart-panel {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chart-panel:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chart-panel-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
margin-bottom: 12px;
|
||||
padding-left: 8px;
|
||||
border-left: 3px solid #1677ff;
|
||||
}
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import * as echarts from 'echarts'
|
||||
import './ChartPanel.css'
|
||||
|
||||
/**
|
||||
* 图表面板组件
|
||||
* @param {Object} props
|
||||
* @param {string} props.type - 图表类型: 'line' | 'bar' | 'pie' | 'ring'
|
||||
* @param {string} props.title - 图表标题
|
||||
* @param {Object} props.data - 图表数据
|
||||
* @param {number} props.height - 图表高度,默认 200px
|
||||
* @param {Object} props.option - 自定义 ECharts 配置
|
||||
* @param {string} props.className - 自定义类名
|
||||
*/
|
||||
function ChartPanel({ type = 'line', title, data, height = 200, option = {}, className = '' }) {
|
||||
const chartRef = useRef(null)
|
||||
const chartInstance = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartRef.current || !data) return
|
||||
|
||||
// 使用 setTimeout 确保 DOM 完全渲染
|
||||
const timer = setTimeout(() => {
|
||||
// 初始化图表
|
||||
if (!chartInstance.current) {
|
||||
chartInstance.current = echarts.init(chartRef.current)
|
||||
}
|
||||
|
||||
// 根据类型生成配置
|
||||
const chartOption = getChartOption(type, data, option)
|
||||
chartInstance.current.setOption(chartOption, true)
|
||||
}, 0)
|
||||
|
||||
// 窗口大小改变时重绘(使用 passive 选项)
|
||||
const handleResize = () => {
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.resize()
|
||||
}
|
||||
}
|
||||
|
||||
// 添加被动事件监听器
|
||||
window.addEventListener('resize', handleResize, { passive: true })
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [type, data, option])
|
||||
|
||||
// 组件卸载时销毁图表
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
chartInstance.current?.dispose()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={`chart-panel ${className}`}>
|
||||
{title && <div className="chart-panel-title">{title}</div>}
|
||||
<div ref={chartRef} style={{ width: '100%', height: `${height}px` }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据图表类型生成 ECharts 配置
|
||||
*/
|
||||
function getChartOption(type, data, customOption) {
|
||||
const baseOption = {
|
||||
grid: {
|
||||
left: '10%',
|
||||
right: '5%',
|
||||
top: '15%',
|
||||
bottom: '15%',
|
||||
},
|
||||
tooltip: {
|
||||
trigger: type === 'pie' || type === 'ring' ? 'item' : 'axis',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
borderColor: '#e8e8e8',
|
||||
borderWidth: 1,
|
||||
textStyle: {
|
||||
color: '#333',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'line':
|
||||
return {
|
||||
...baseOption,
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.xAxis || [],
|
||||
boundaryGap: false,
|
||||
axisLine: { lineStyle: { color: '#e8e8e8' } },
|
||||
axisLabel: { color: '#8c8c8c', fontSize: 11 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { lineStyle: { color: '#e8e8e8' } },
|
||||
axisLabel: { color: '#8c8c8c', fontSize: 11 },
|
||||
splitLine: { lineStyle: { color: '#f0f0f0' } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
data: data.series || [],
|
||||
smooth: true,
|
||||
lineStyle: { width: 2, color: '#1677ff' },
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(22, 119, 255, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(22, 119, 255, 0.05)' },
|
||||
]),
|
||||
},
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
itemStyle: { color: '#1677ff' },
|
||||
},
|
||||
],
|
||||
...customOption,
|
||||
}
|
||||
|
||||
case 'bar':
|
||||
return {
|
||||
...baseOption,
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.xAxis || [],
|
||||
axisLine: { lineStyle: { color: '#e8e8e8' } },
|
||||
axisLabel: { color: '#8c8c8c', fontSize: 11 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { lineStyle: { color: '#e8e8e8' } },
|
||||
axisLabel: { color: '#8c8c8c', fontSize: 11 },
|
||||
splitLine: { lineStyle: { color: '#f0f0f0' } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: data.series || [],
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#4096ff' },
|
||||
{ offset: 1, color: '#1677ff' },
|
||||
]),
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
},
|
||||
barWidth: '50%',
|
||||
},
|
||||
],
|
||||
...customOption,
|
||||
}
|
||||
|
||||
case 'pie':
|
||||
case 'ring':
|
||||
return {
|
||||
...baseOption,
|
||||
grid: undefined,
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
right: '10%',
|
||||
top: 'center',
|
||||
textStyle: { color: '#8c8c8c', fontSize: 12 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: type === 'ring' ? ['40%', '65%'] : '65%',
|
||||
center: ['40%', '50%'],
|
||||
data: data.series || [],
|
||||
label: {
|
||||
fontSize: 11,
|
||||
color: '#8c8c8c',
|
||||
},
|
||||
labelLine: {
|
||||
lineStyle: { color: '#d9d9d9' },
|
||||
},
|
||||
itemStyle: {
|
||||
borderRadius: 4,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2,
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.3)',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
...customOption,
|
||||
}
|
||||
|
||||
default:
|
||||
return { ...baseOption, ...customOption }
|
||||
}
|
||||
}
|
||||
|
||||
export default ChartPanel
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
import { Modal } from 'antd'
|
||||
import { ExclamationCircleOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||
|
||||
/**
|
||||
* 标准确认对话框组件
|
||||
* @param {Object} options - 对话框配置
|
||||
* @param {string} options.title - 标题
|
||||
* @param {string|ReactNode} options.content - 内容
|
||||
* @param {string} options.okText - 确认按钮文字
|
||||
* @param {string} options.cancelText - 取消按钮文字
|
||||
* @param {string} options.type - 类型: 'warning', 'danger', 'info'
|
||||
* @param {Function} options.onOk - 确认回调
|
||||
* @param {Function} options.onCancel - 取消回调
|
||||
*/
|
||||
const ConfirmDialog = {
|
||||
/**
|
||||
* 显示删除确认对话框(单个项目)
|
||||
*/
|
||||
delete: ({ title = '确认删除', itemName, itemInfo, onOk, onCancel }) => {
|
||||
Modal.confirm({
|
||||
title,
|
||||
content: (
|
||||
<div>
|
||||
<p>您确定要删除以下项目吗?</p>
|
||||
<div style={{ marginTop: 12, padding: 12, background: '#f5f5f5', borderRadius: 6 }}>
|
||||
<p style={{ margin: 0, fontWeight: 500 }}>{itemName}</p>
|
||||
{itemInfo && (
|
||||
<p style={{ margin: '4px 0 0 0', fontSize: 13, color: '#666' }}>{itemInfo}</p>
|
||||
)}
|
||||
</div>
|
||||
<p style={{ marginTop: 12, color: '#ff4d4f', fontSize: 13 }}>
|
||||
此操作不可恢复,请谨慎操作!
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
okText: '确认删除',
|
||||
cancelText: '取消',
|
||||
okType: 'danger',
|
||||
centered: true,
|
||||
icon: <DeleteOutlined style={{ color: '#ff4d4f' }} />,
|
||||
onOk,
|
||||
onCancel,
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 显示批量删除确认对话框
|
||||
*/
|
||||
batchDelete: ({ count, items, onOk, onCancel }) => {
|
||||
Modal.confirm({
|
||||
title: '批量删除确认',
|
||||
content: (
|
||||
<div>
|
||||
<p>您确定要删除选中的 {count} 个项目吗?</p>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: 12,
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 6,
|
||||
maxHeight: 200,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
padding: '6px 0',
|
||||
borderBottom: index < items.length - 1 ? '1px solid #e8e8e8' : 'none',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 500 }}>{item.name}</span>
|
||||
{item.info && (
|
||||
<span style={{ marginLeft: 12, fontSize: 13, color: '#666' }}>
|
||||
({item.info})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p style={{ marginTop: 12, color: '#ff4d4f', fontSize: 13 }}>
|
||||
此操作不可恢复,请谨慎操作!
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
okText: '确认删除',
|
||||
cancelText: '取消',
|
||||
okType: 'danger',
|
||||
centered: true,
|
||||
icon: <DeleteOutlined style={{ color: '#ff4d4f' }} />,
|
||||
onOk,
|
||||
onCancel,
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 显示警告确认对话框
|
||||
*/
|
||||
warning: ({ title, content, okText = '确定', cancelText = '取消', onOk, onCancel }) => {
|
||||
Modal.confirm({
|
||||
title,
|
||||
content,
|
||||
okText,
|
||||
cancelText,
|
||||
centered: true,
|
||||
icon: <ExclamationCircleOutlined style={{ color: '#faad14' }} />,
|
||||
onOk,
|
||||
onCancel,
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 显示通用确认对话框
|
||||
*/
|
||||
confirm: ({
|
||||
title,
|
||||
content,
|
||||
okText = '确定',
|
||||
cancelText = '取消',
|
||||
okType = 'primary',
|
||||
onOk,
|
||||
onCancel,
|
||||
}) => {
|
||||
Modal.confirm({
|
||||
title,
|
||||
content,
|
||||
okText,
|
||||
cancelText,
|
||||
okType,
|
||||
centered: true,
|
||||
onOk,
|
||||
onCancel,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default ConfirmDialog
|
||||
|
|
@ -1,244 +0,0 @@
|
|||
.data-list-panel {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
padding: 12px 16px 10px;
|
||||
border-radius: 4px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.data-list-panel--auto {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.data-list-panel,
|
||||
.data-list-panel .ant-btn,
|
||||
.data-list-panel .ant-input,
|
||||
.data-list-panel .ant-input-affix-wrapper,
|
||||
.data-list-panel .ant-select,
|
||||
.data-list-panel .ant-select-selector,
|
||||
.data-list-panel .ant-table {
|
||||
font-family: "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.data-list-panel .ant-btn {
|
||||
height: 32px;
|
||||
border-radius: 4px !important;
|
||||
box-shadow: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.data-list-panel .ant-btn .ant-btn-icon,
|
||||
.data-list-panel .ant-input-prefix,
|
||||
.data-list-panel .ant-input-prefix .anticon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.data-list-panel .ant-input,
|
||||
.data-list-panel .ant-input-affix-wrapper,
|
||||
.data-list-panel .ant-select-selector {
|
||||
height: 32px !important;
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
.data-list-panel .ant-input-affix-wrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.data-list-panel .ant-input-affix-wrapper .ant-input {
|
||||
height: 30px !important;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.data-list-panel .ant-input-prefix {
|
||||
height: 100%;
|
||||
margin-inline-end: 6px;
|
||||
}
|
||||
|
||||
.data-list-panel .ant-select-selector {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.data-list-panel__toolbar {
|
||||
flex-shrink: 0;
|
||||
flex-wrap: nowrap;
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
margin-bottom: 14px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.data-list-panel__left-actions {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.data-list-panel__right-actions {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.data-list-panel__toolbar .ant-btn {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.data-list-panel__toolbar .ant-btn:not(.ant-btn-icon-only) {
|
||||
padding-inline: 14px;
|
||||
}
|
||||
|
||||
.data-list-panel__toolbar .ant-btn .ant-btn-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.data-list-panel__right-actions .ant-space {
|
||||
min-width: 0;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.data-list-panel__table-container {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.data-list-panel--auto .data-list-panel__table-container,
|
||||
.data-list-panel--auto .data-list-panel__table-area {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.data-list-panel__table-area {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.data-list-panel__table-area .app-page__table-wrap,
|
||||
.data-list-panel__table-area .list-table-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.data-list-panel__table-area .ant-table-thead > tr > th {
|
||||
height: 45px;
|
||||
color: #000;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
background: #fafafa !important;
|
||||
}
|
||||
|
||||
.data-list-panel__table-area .ant-table-tbody > tr > td {
|
||||
height: 47px;
|
||||
color: #000;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.data-list-panel__table-area .ant-table-tbody > tr:not(.row-selected):not(.ant-table-row-selected):not(.dict-type-row-selected):hover > td,
|
||||
.data-list-panel__table-area .ant-table-tbody > tr:not(.row-selected):not(.ant-table-row-selected):not(.dict-type-row-selected) > td.ant-table-cell-row-hover {
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
.data-list-panel__table-area .ant-table-cell {
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.data-list-panel__table-area .ant-table-content,
|
||||
.data-list-panel__table-area .ant-table-body {
|
||||
overflow-x: auto !important;
|
||||
}
|
||||
|
||||
.data-list-panel__table-area .ant-tag {
|
||||
margin-inline-end: 0;
|
||||
border-radius: 4px;
|
||||
font-family: "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif;
|
||||
}
|
||||
|
||||
.data-list-panel__footer {
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.data-list-panel__footer .app-pagination-container {
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
border-radius: 0;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.data-list-panel__footer .app-pagination-container .ant-pagination {
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.data-list-panel__footer .app-pagination-container .ant-pagination-options {
|
||||
margin-inline-start: 8px;
|
||||
}
|
||||
|
||||
.data-list-panel__footer .app-pagination-container,
|
||||
.data-list-panel__footer .app-pagination-container .ant-pagination,
|
||||
.data-list-panel__footer .app-pagination-total {
|
||||
color: #333;
|
||||
font-family: "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.data-list-panel {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.data-list-panel__toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.data-list-panel__left-actions,
|
||||
.data-list-panel__right-actions {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.data-list-panel__right-actions,
|
||||
.data-list-panel__right-actions .ant-space,
|
||||
.data-list-panel__right-actions .ant-input-affix-wrapper,
|
||||
.data-list-panel__right-actions .ant-select {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import type { ReactNode } from "react";
|
||||
import "./DataListPanel.css";
|
||||
|
||||
interface DataListPanelProps {
|
||||
leftActions?: ReactNode;
|
||||
rightActions?: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
layout?: "fixed" | "auto";
|
||||
className?: string;
|
||||
toolbarClassName?: string;
|
||||
}
|
||||
|
||||
export default function DataListPanel({
|
||||
leftActions,
|
||||
rightActions,
|
||||
children,
|
||||
footer,
|
||||
layout = "fixed",
|
||||
className = "",
|
||||
toolbarClassName = "",
|
||||
}: DataListPanelProps) {
|
||||
const classes = ["data-list-panel", `data-list-panel--${layout}`, className].filter(Boolean).join(" ");
|
||||
const toolbarClasses = ["data-list-panel__toolbar", toolbarClassName].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
{(leftActions || rightActions) ? (
|
||||
<div className={toolbarClasses}>
|
||||
<div className="data-list-panel__left-actions">{leftActions}</div>
|
||||
<div className="data-list-panel__right-actions">{rightActions}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="data-list-panel__table-container">
|
||||
<div className="data-list-panel__table-area">
|
||||
<div className="app-page__table-wrap">{children}</div>
|
||||
</div>
|
||||
{footer ? <div className="data-list-panel__footer">{footer}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
/* 详情抽屉容器 */
|
||||
.detail-drawer-content {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 顶部信息区域 - 固定不滚动 */
|
||||
.detail-drawer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-drawer-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.detail-drawer-close-button {
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.detail-drawer-close-button:hover {
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.detail-drawer-header-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detail-drawer-title-icon {
|
||||
font-size: 18px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.detail-drawer-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.detail-drawer-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-drawer-header-right {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* 可滚动内容区域 */
|
||||
.detail-drawer-scrollable-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* 标签页区域 */
|
||||
.detail-drawer-tabs {
|
||||
background: #ffffff;
|
||||
padding: 0;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.detail-drawer-tabs :global(.ant-tabs) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.detail-drawer-tabs :global(.ant-tabs-content-holder) {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.detail-drawer-tabs :global(.ant-tabs-nav) {
|
||||
padding: 0;
|
||||
margin: 0 0 16px 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.detail-drawer-tabs :global(.ant-tabs-nav::before) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.detail-drawer-tabs :global(.ant-tabs-tab) {
|
||||
padding: 12px 0;
|
||||
margin: 0 32px 0 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-drawer-tabs :global(.ant-tabs-tab:first-child) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.detail-drawer-tabs :global(.ant-tabs-tab-active .ant-tabs-tab-btn) {
|
||||
color: #d946ef;
|
||||
}
|
||||
|
||||
.detail-drawer-tabs :global(.ant-tabs-ink-bar) {
|
||||
background: #d946ef;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.detail-drawer-tab-content {
|
||||
padding: 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import { Drawer, Button, Space, Tabs } from "antd";
|
||||
import { CloseOutlined } from "@ant-design/icons";
|
||||
import type { ReactNode } from "react";
|
||||
import "./DetailDrawer.css";
|
||||
|
||||
type DrawerTitle = {
|
||||
text: string;
|
||||
badge?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
};
|
||||
|
||||
type HeaderAction = {
|
||||
key: string;
|
||||
label: string;
|
||||
type?: "default" | "primary" | "dashed" | "link" | "text";
|
||||
icon?: ReactNode;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
type DrawerTab = {
|
||||
key: string;
|
||||
label: ReactNode;
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
interface DetailDrawerProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
title?: DrawerTitle;
|
||||
headerActions?: HeaderAction[];
|
||||
width?: number;
|
||||
children?: ReactNode;
|
||||
tabs?: DrawerTab[];
|
||||
}
|
||||
|
||||
function DetailDrawer({
|
||||
visible,
|
||||
onClose,
|
||||
title,
|
||||
headerActions = [],
|
||||
width = 1080,
|
||||
children,
|
||||
tabs,
|
||||
}: DetailDrawerProps) {
|
||||
return (
|
||||
<Drawer
|
||||
title={null}
|
||||
placement="right"
|
||||
width={width}
|
||||
onClose={onClose}
|
||||
open={visible}
|
||||
closable={false}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
<div className="detail-drawer-content">
|
||||
<div className="detail-drawer-header">
|
||||
<div className="detail-drawer-header-left">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={onClose}
|
||||
className="detail-drawer-close-button"
|
||||
/>
|
||||
<div className="detail-drawer-header-info">
|
||||
{title?.icon && <span className="detail-drawer-title-icon">{title.icon}</span>}
|
||||
<h2 className="detail-drawer-title">{title?.text}</h2>
|
||||
{title?.badge && <span className="detail-drawer-badge">{title.badge}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-drawer-header-right">
|
||||
<Space size="middle">
|
||||
{headerActions.map((action) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
type={action.type || "default"}
|
||||
icon={action.icon}
|
||||
danger={action.danger}
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-drawer-scrollable-content">
|
||||
{children}
|
||||
|
||||
{tabs && tabs.length > 0 && (
|
||||
<div className="detail-drawer-tabs">
|
||||
<Tabs
|
||||
defaultActiveKey={tabs[0].key}
|
||||
type="line"
|
||||
size="large"
|
||||
items={tabs.map((tab) => ({
|
||||
key: tab.key,
|
||||
label: tab.label,
|
||||
children: <div className="detail-drawer-tab-content">{tab.content}</div>,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default DetailDrawer;
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
/* 扩展信息面板容器 */
|
||||
.extend-info-panel {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 垂直布局(默认) */
|
||||
.extend-info-panel-vertical {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 水平布局 */
|
||||
.extend-info-panel-horizontal {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 信息区块 */
|
||||
.extend-info-section {
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 水平布局时区块自适应宽度 */
|
||||
.extend-info-panel-horizontal .extend-info-section {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.extend-info-section:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 区块头部 */
|
||||
.extend-info-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%);
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.extend-info-section-header:hover {
|
||||
background: linear-gradient(135deg, #f0f4ff 0%, #e8f0ff 100%);
|
||||
}
|
||||
|
||||
.extend-info-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.extend-info-section-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.extend-info-section-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #8c8c8c;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.extend-info-section-toggle:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
/* 区块内容 */
|
||||
.extend-info-section-content {
|
||||
padding: 16px 20px;
|
||||
animation: expandContent 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes expandContent {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { useState } from 'react'
|
||||
import { UpOutlined, DownOutlined } from '@ant-design/icons'
|
||||
import './ExtendInfoPanel.css'
|
||||
|
||||
/**
|
||||
* 扩展信息面板组件
|
||||
* @param {Object} props
|
||||
* @param {Array} props.sections - 信息区块配置数组
|
||||
* @param {string} props.sections[].key - 区块唯一键
|
||||
* @param {string} props.sections[].title - 区块标题
|
||||
* @param {ReactNode} props.sections[].icon - 标题图标
|
||||
* @param {ReactNode} props.sections[].content - 区块内容
|
||||
* @param {boolean} props.sections[].defaultCollapsed - 默认是否折叠
|
||||
* @param {boolean} props.sections[].hideTitleBar - 是否隐藏该区块的标题栏(默认 false)
|
||||
* @param {string} props.layout - 布局方式:'vertical'(垂直堆叠)| 'horizontal'(水平排列)
|
||||
* @param {string} props.className - 自定义类名
|
||||
*/
|
||||
function ExtendInfoPanel({ sections = [], layout = 'vertical', className = '' }) {
|
||||
const [collapsedSections, setCollapsedSections] = useState(() => {
|
||||
const initial = {}
|
||||
sections.forEach((section) => {
|
||||
if (section.defaultCollapsed) {
|
||||
initial[section.key] = true
|
||||
}
|
||||
})
|
||||
return initial
|
||||
})
|
||||
|
||||
const toggleSection = (key) => {
|
||||
setCollapsedSections((prev) => ({
|
||||
...prev,
|
||||
[key]: !prev[key],
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`extend-info-panel extend-info-panel-${layout} ${className}`}>
|
||||
{sections.map((section) => {
|
||||
const isCollapsed = collapsedSections[section.key]
|
||||
const hideTitleBar = section.hideTitleBar === true
|
||||
|
||||
return (
|
||||
<div key={section.key} className="extend-info-section">
|
||||
{/* 区块头部 - 可配置隐藏 */}
|
||||
{!hideTitleBar && (
|
||||
<div className="extend-info-section-header" onClick={() => toggleSection(section.key)}>
|
||||
<div className="extend-info-section-title">
|
||||
{section.icon && <span className="extend-info-section-icon">{section.icon}</span>}
|
||||
<span>{section.title}</span>
|
||||
</div>
|
||||
<button className="extend-info-section-toggle" type="button">
|
||||
{isCollapsed ? <DownOutlined /> : <UpOutlined />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 区块内容 - 如果隐藏标题栏则总是显示,否则根据折叠状态 */}
|
||||
{(hideTitleBar || !isCollapsed) && (
|
||||
<div className="extend-info-section-content">{section.content}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExtendInfoPanel
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
.form-drawer-root .ant-drawer-content-wrapper {
|
||||
max-width: var(--app-form-drawer-max-width, calc(100vw - 48px));
|
||||
}
|
||||
|
||||
.form-drawer .ant-drawer-header {
|
||||
min-height: 64px;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--app-border-color, #f0f0f0);
|
||||
}
|
||||
|
||||
.form-drawer .ant-drawer-header-title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.form-drawer .ant-drawer-title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.form-drawer .ant-drawer-body {
|
||||
background: var(--app-surface-color, #ffffff);
|
||||
}
|
||||
|
||||
.form-drawer .ant-drawer-footer {
|
||||
border-top: 1px solid var(--app-border-color, #f0f0f0);
|
||||
background: var(--app-surface-color, #ffffff);
|
||||
}
|
||||
|
||||
.form-drawer__title {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-drawer__title-icon {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
color: var(--app-primary-color, #1677ff);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.form-drawer__title-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.form-drawer__title-text {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: var(--app-text-main, rgba(0, 0, 0, 0.88));
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.form-drawer__subtitle {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
margin-top: 2px;
|
||||
color: var(--app-text-secondary, rgba(0, 0, 0, 0.45));
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.form-drawer__body {
|
||||
min-height: 100%;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.form-drawer__body--compact {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.form-drawer__body--spacious {
|
||||
padding: 24px 32px;
|
||||
}
|
||||
|
||||
.form-drawer__body .ant-form-vertical .ant-form-item {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.form-drawer__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 24px;
|
||||
}
|
||||
|
||||
.form-drawer__footer-extra {
|
||||
min-width: 0;
|
||||
color: var(--app-text-secondary, rgba(0, 0, 0, 0.45));
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.form-drawer__footer-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.form-drawer__footer-actions .ant-btn {
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-drawer-root .ant-drawer-content-wrapper {
|
||||
width: 100vw !important;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.form-drawer .ant-drawer-header {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.form-drawer__body,
|
||||
.form-drawer__body--compact,
|
||||
.form-drawer__body--spacious {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.form-drawer__footer {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { Button, Drawer } from "antd";
|
||||
import type { ButtonProps, DrawerProps } from "antd";
|
||||
import "./FormDrawer.css";
|
||||
|
||||
type FormDrawerSize = "sm" | "md" | "lg" | "xl" | "wide";
|
||||
type FormDrawerBodyDensity = "compact" | "default" | "spacious";
|
||||
|
||||
const FORM_DRAWER_WIDTH = "var(--app-form-drawer-width, 600px)";
|
||||
|
||||
const drawerWidthMap: Record<FormDrawerSize, number | string> = {
|
||||
sm: FORM_DRAWER_WIDTH,
|
||||
md: FORM_DRAWER_WIDTH,
|
||||
lg: FORM_DRAWER_WIDTH,
|
||||
xl: FORM_DRAWER_WIDTH,
|
||||
wide: FORM_DRAWER_WIDTH,
|
||||
};
|
||||
|
||||
interface FormDrawerProps
|
||||
extends Omit<DrawerProps, "children" | "footer" | "onClose" | "open" | "size" | "title" | "width"> {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
size?: FormDrawerSize;
|
||||
width?: number | string;
|
||||
bodyDensity?: FormDrawerBodyDensity;
|
||||
bodyClassName?: string;
|
||||
bodyStyle?: CSSProperties;
|
||||
footer?: ReactNode;
|
||||
footerExtra?: ReactNode;
|
||||
hideFooter?: boolean;
|
||||
cancelText?: ReactNode;
|
||||
okText?: ReactNode;
|
||||
okIcon?: ReactNode;
|
||||
okLoading?: boolean;
|
||||
okDisabled?: boolean;
|
||||
onOk?: () => void;
|
||||
cancelButtonProps?: ButtonProps;
|
||||
okButtonProps?: ButtonProps;
|
||||
}
|
||||
|
||||
function joinClassNames(...classes: Array<string | undefined | false>) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export default function FormDrawer({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
children,
|
||||
size = "md",
|
||||
width,
|
||||
bodyDensity = "default",
|
||||
bodyClassName,
|
||||
bodyStyle,
|
||||
footer,
|
||||
footerExtra,
|
||||
hideFooter = false,
|
||||
cancelText = "取消",
|
||||
okText = "保存",
|
||||
okIcon,
|
||||
okLoading = false,
|
||||
okDisabled = false,
|
||||
onOk,
|
||||
cancelButtonProps,
|
||||
okButtonProps,
|
||||
rootClassName,
|
||||
className,
|
||||
styles,
|
||||
placement = "right",
|
||||
maskClosable = false,
|
||||
destroyOnHidden = true,
|
||||
...drawerProps
|
||||
}: FormDrawerProps) {
|
||||
const { children: okButtonChildren, onClick: okButtonOnClick, ...restOkButtonProps } = okButtonProps ?? {};
|
||||
const { body, footer: footerStyle, header, ...restStyles } = styles ?? {};
|
||||
const resolvedWidth = width ?? drawerWidthMap[size];
|
||||
const resolvedTitle = (
|
||||
<div className="form-drawer__title">
|
||||
{icon ? <span className="form-drawer__title-icon">{icon}</span> : null}
|
||||
<span className="form-drawer__title-main">
|
||||
<span className="form-drawer__title-text">{title}</span>
|
||||
{subtitle ? <span className="form-drawer__subtitle">{subtitle}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
const defaultFooter = (
|
||||
<div className="form-drawer__footer">
|
||||
{footerExtra ? <div className="form-drawer__footer-extra">{footerExtra}</div> : null}
|
||||
<div className="form-drawer__footer-actions">
|
||||
<Button onClick={onClose} {...cancelButtonProps}>
|
||||
{cancelButtonProps?.children ?? cancelText}
|
||||
</Button>
|
||||
{onOk || okButtonProps ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={okIcon}
|
||||
loading={okLoading}
|
||||
disabled={okDisabled}
|
||||
onClick={onOk ?? okButtonOnClick}
|
||||
{...restOkButtonProps}
|
||||
>
|
||||
{okButtonChildren ?? okText}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const resolvedFooter = hideFooter ? null : footer ?? defaultFooter;
|
||||
const bodyClasses = joinClassNames(
|
||||
"form-drawer__body",
|
||||
bodyDensity !== "default" && `form-drawer__body--${bodyDensity}`,
|
||||
bodyClassName,
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={resolvedTitle}
|
||||
width={resolvedWidth}
|
||||
placement={placement}
|
||||
maskClosable={maskClosable}
|
||||
destroyOnHidden={destroyOnHidden}
|
||||
rootClassName={joinClassNames("form-drawer-root", rootClassName)}
|
||||
className={joinClassNames("form-drawer", className)}
|
||||
footer={resolvedFooter}
|
||||
styles={{
|
||||
...restStyles,
|
||||
header: {
|
||||
...header,
|
||||
},
|
||||
body: {
|
||||
padding: 0,
|
||||
...body,
|
||||
},
|
||||
footer: {
|
||||
padding: 0,
|
||||
...footerStyle,
|
||||
},
|
||||
}}
|
||||
{...drawerProps}
|
||||
>
|
||||
<div className={bodyClasses} style={bodyStyle}>
|
||||
{children}
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export type { FormDrawerProps, FormDrawerSize, FormDrawerBodyDensity };
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/* 信息面板 */
|
||||
.info-panel {
|
||||
padding: 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
/* 信息区域容器 */
|
||||
.info-panel > :global(.ant-row) {
|
||||
padding: 24px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.info-panel-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.info-panel-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* 添加左侧装饰条 */
|
||||
.info-panel-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
background: linear-gradient(180deg, #1677ff 0%, #4096ff 100%);
|
||||
border-radius: 2px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.info-panel-item:hover {
|
||||
background: linear-gradient(90deg, #f0f7ff 0%, transparent 100%);
|
||||
padding-left: 10px;
|
||||
padding-right: 16px;
|
||||
margin-left: -12px;
|
||||
margin-right: -16px;
|
||||
border-radius: 8px;
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.info-panel-item:hover::before {
|
||||
width: 3px;
|
||||
height: 60%;
|
||||
}
|
||||
|
||||
.info-panel-label {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.info-panel-value {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 操作按钮区 */
|
||||
.info-panel-actions {
|
||||
padding: 24px 32px;
|
||||
background: linear-gradient(to bottom, #fafafa 0%, #f5f5f5 100%);
|
||||
border-top: 2px solid #e8e8e8;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 操作区域顶部装饰线 */
|
||||
.info-panel-actions::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, #1677ff 0%, transparent 50%, #1677ff 100%);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { Row, Col, Space, Button } from 'antd'
|
||||
import './InfoPanel.css'
|
||||
|
||||
/**
|
||||
* 信息展示面板组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.data - 数据源
|
||||
* @param {Array} props.fields - 字段配置数组
|
||||
* @param {Array} props.actions - 操作按钮配置(可选)
|
||||
* @param {Array} props.gutter - Grid间距配置
|
||||
*/
|
||||
function InfoPanel({ data, fields = [], actions = [], gutter = [24, 16] }) {
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="info-panel">
|
||||
<Row gutter={gutter}>
|
||||
{fields.map((field) => {
|
||||
const value = data[field.key]
|
||||
const displayValue = field.render ? field.render(value, data) : value
|
||||
|
||||
return (
|
||||
<Col key={field.key} span={field.span || 6}>
|
||||
<div className="info-panel-item">
|
||||
<div className="info-panel-label">{field.label}</div>
|
||||
<div className="info-panel-value">{displayValue}</div>
|
||||
</div>
|
||||
</Col>
|
||||
)
|
||||
})}
|
||||
</Row>
|
||||
|
||||
{/* 可选的操作按钮区 */}
|
||||
{actions && actions.length > 0 && (
|
||||
<div className="info-panel-actions">
|
||||
<Space size="middle">
|
||||
{actions.map((action) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
type={action.type || 'default'}
|
||||
icon={action.icon}
|
||||
disabled={action.disabled}
|
||||
danger={action.danger}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InfoPanel
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
.list-action-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
padding: 16px;
|
||||
background: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.list-action-bar-left,
|
||||
.list-action-bar-right {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 搜索和筛选组合 */
|
||||
.list-action-bar-right :global(.ant-space-compact) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.list-action-bar-right :global(.ant-space-compact .ant-input-search) {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.list-action-bar-right :global(.ant-space-compact > .ant-btn) {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
/* 批量操作区域样式 */
|
||||
.selection-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-color-secondary);
|
||||
border: 1px solid var(--link-color);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.selection-count {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.selection-count strong {
|
||||
color: var(--link-color);
|
||||
font-weight: 600;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.all-pages-tag {
|
||||
color: var(--link-color);
|
||||
font-weight: 500;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.select-all-link,
|
||||
.clear-selection-link {
|
||||
color: var(--link-color);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.select-all-link:hover,
|
||||
.clear-selection-link:hover {
|
||||
background: rgba(22, 119, 255, 0.1);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.list-action-bar {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.list-action-bar-left,
|
||||
.list-action-bar-right {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
import { Button, Input, Space, Popover } from 'antd'
|
||||
import { ReloadOutlined, FilterOutlined } from '@ant-design/icons'
|
||||
import './ListActionBar.css'
|
||||
|
||||
const { Search } = Input
|
||||
|
||||
/**
|
||||
* 列表操作栏组件
|
||||
* @param {Object} props
|
||||
* @param {Array} props.actions - 左侧操作按钮配置数组
|
||||
* @param {Array} props.batchActions - 批量操作按钮配置数组(仅在有选中项时显示)
|
||||
* @param {Object} props.selectionInfo - 选中信息 { count: 选中数量, total: 总数量, isAllPagesSelected: 是否跨页全选 }
|
||||
* @param {Function} props.onSelectAllPages - 选择所有页回调
|
||||
* @param {Function} props.onClearSelection - 清除选择回调
|
||||
* @param {Object} props.search - 搜索配置
|
||||
* @param {Object} props.filter - 高级筛选配置(可选)
|
||||
* @param {boolean} props.showRefresh - 是否显示刷新按钮
|
||||
* @param {Function} props.onRefresh - 刷新回调
|
||||
*/
|
||||
function ListActionBar({
|
||||
actions = [],
|
||||
batchActions = [],
|
||||
selectionInfo,
|
||||
onSelectAllPages,
|
||||
onClearSelection,
|
||||
search,
|
||||
filter,
|
||||
showRefresh = false,
|
||||
onRefresh,
|
||||
}) {
|
||||
// 是否有选中项
|
||||
const hasSelection = selectionInfo && selectionInfo.count > 0
|
||||
return (
|
||||
<div className="list-action-bar">
|
||||
{/* 左侧操作按钮区 */}
|
||||
<div className="list-action-bar-left">
|
||||
{/* 常规操作按钮(无选中时显示) */}
|
||||
{!hasSelection && actions.map((action) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
type={action.type || 'default'}
|
||||
icon={action.icon}
|
||||
disabled={action.disabled}
|
||||
danger={action.danger}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{/* 批量操作区域(有选中时显示) */}
|
||||
{hasSelection && (
|
||||
<Space>
|
||||
{/* 选中信息 */}
|
||||
<div className="selection-info">
|
||||
<span className="selection-count">
|
||||
已选择 <strong>{selectionInfo.count}</strong> 项
|
||||
{selectionInfo.isAllPagesSelected && (
|
||||
<span className="all-pages-tag">(全部页)</span>
|
||||
)}
|
||||
</span>
|
||||
{!selectionInfo.isAllPagesSelected && selectionInfo.total > selectionInfo.count && (
|
||||
<a onClick={onSelectAllPages} className="select-all-link">
|
||||
选择全部 {selectionInfo.total} 项
|
||||
</a>
|
||||
)}
|
||||
<a onClick={onClearSelection} className="clear-selection-link">
|
||||
清除
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* 批量操作按钮 */}
|
||||
{batchActions.map((action) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
type={action.type || 'default'}
|
||||
icon={action.icon}
|
||||
disabled={action.disabled}
|
||||
danger={action.danger}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧搜索筛选区 */}
|
||||
<div className="list-action-bar-right">
|
||||
<Space.Compact>
|
||||
<Search
|
||||
placeholder={search?.placeholder || '请输入搜索关键词'}
|
||||
allowClear
|
||||
style={{ width: search?.width || 280 }}
|
||||
onSearch={search?.onSearch}
|
||||
onChange={(e) => search?.onChange?.(e.target.value)}
|
||||
value={search?.value}
|
||||
/>
|
||||
{filter && (
|
||||
<Popover
|
||||
content={filter.content}
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<FilterOutlined />
|
||||
<span>{filter.title || '高级筛选'}</span>
|
||||
</div>
|
||||
}
|
||||
trigger="click"
|
||||
open={filter.visible}
|
||||
onOpenChange={filter.onVisibleChange}
|
||||
placement="bottomRight"
|
||||
overlayClassName="filter-popover"
|
||||
>
|
||||
<Button
|
||||
icon={<FilterOutlined />}
|
||||
type={filter.isActive ? 'primary' : 'default'}
|
||||
>
|
||||
{filter.selectedLabel || '筛选'}
|
||||
</Button>
|
||||
</Popover>
|
||||
)}
|
||||
</Space.Compact>
|
||||
{showRefresh && (
|
||||
<Button icon={<ReloadOutlined />} onClick={onRefresh}>
|
||||
刷新
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ListActionBar
|
||||
|
|
@ -1,23 +1,16 @@
|
|||
/* 列表表格容器 */
|
||||
.list-table-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
background: var(--app-surface-color, #fff);
|
||||
}
|
||||
|
||||
.list-table-container .ant-table-wrapper {
|
||||
background: var(--app-surface-color, #fff);
|
||||
}
|
||||
|
||||
/* 行选中样式 */
|
||||
.list-table-container .row-selected > td {
|
||||
background-color: var(--item-hover-bg) !important;
|
||||
.list-table-container .row-selected {
|
||||
background-color: var(--item-hover-bg);
|
||||
}
|
||||
|
||||
.list-table-container .row-selected:hover > td {
|
||||
|
|
@ -32,7 +25,7 @@
|
|||
}
|
||||
|
||||
.selection-count {
|
||||
color: var(--app-text-secondary, #9095a1);
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
|
@ -55,33 +48,6 @@
|
|||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.list-table-container .ant-table {
|
||||
color: var(--app-text-main, #333);
|
||||
}
|
||||
|
||||
.list-table-container .ant-table-thead > tr > th {
|
||||
background: var(--app-bg-surface-soft, #fafafa) !important;
|
||||
color: var(--app-text-main, #333);
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--app-border-color, #f0f0f0);
|
||||
}
|
||||
|
||||
.list-table-container .ant-table-tbody > tr > td {
|
||||
border-bottom: 1px solid var(--app-border-color, #f0f0f0);
|
||||
}
|
||||
|
||||
.list-table-container .ant-table-tbody > tr:not(.row-selected):not(.ant-table-row-selected):hover > td {
|
||||
background: var(--app-surface-color, #fff) !important;
|
||||
}
|
||||
|
||||
.list-table-container .ant-table-tbody > tr:not(.row-selected):not(.ant-table-row-selected) > td.ant-table-cell-row-hover {
|
||||
background: var(--app-surface-color, #fff) !important;
|
||||
}
|
||||
|
||||
.list-table-container .ant-table-tbody > tr.row-selected > td.ant-table-cell-row-hover {
|
||||
background-color: var(--item-hover-bg) !important;
|
||||
}
|
||||
|
||||
.list-table-container .list-table-table--y-scroll.ant-table-wrapper,
|
||||
.list-table-container .list-table-table--y-scroll.ant-table-wrapper .ant-spin-nested-loading,
|
||||
.list-table-container .list-table-table--y-scroll.ant-table-wrapper .ant-spin-container,
|
||||
|
|
@ -113,64 +79,3 @@
|
|||
max-height: var(--list-table-scroll-y) !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
.list-table-container .list-table-table--y-scroll.ant-table-wrapper .ant-table-sticky-scroll {
|
||||
display: none !important;
|
||||
height: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.list-table-container .list-table-table--empty.list-table-table--y-scroll.ant-table-wrapper .ant-table-body {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.list-table-container .list-table-table--empty.ant-table-wrapper .ant-table-placeholder {
|
||||
display: none !important;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.list-table-container .list-table-table--empty.ant-table-wrapper .ant-table-placeholder > td {
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
position: static !important;
|
||||
}
|
||||
|
||||
.list-table-container .list-table-table--empty.ant-table-wrapper .ant-table-placeholder .ant-table-cell {
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.list-table-empty-overlay {
|
||||
position: absolute;
|
||||
top: 47px;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
background: var(--app-surface-color, #fff);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.list-table-empty-overlay .ant-empty {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.list-table-container .ant-table-wrapper,
|
||||
.list-table-container .ant-spin-nested-loading,
|
||||
.list-table-container .ant-spin-container,
|
||||
.list-table-container .ant-table,
|
||||
.list-table-container .ant-table-container {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list-table-container .ant-table-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from "react";
|
||||
import { Empty, Table } from "antd";
|
||||
import { Table } from "antd";
|
||||
import type { TablePaginationConfig, TableProps } from "antd";
|
||||
import "./ListTable.css";
|
||||
import i18n from "../../../i18n";
|
||||
|
|
@ -20,7 +20,6 @@ export type ListTableProps<T extends Record<string, any>> = {
|
|||
selectedRow?: T | null;
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
locale?: TableProps<T>["locale"];
|
||||
onChange?: TableProps<T>["onChange"];
|
||||
};
|
||||
|
||||
|
|
@ -44,7 +43,6 @@ function ListTable<T extends Record<string, any>>({
|
|||
selectedRow,
|
||||
loading = false,
|
||||
className = "",
|
||||
locale,
|
||||
onChange,
|
||||
}: ListTableProps<T>) {
|
||||
const mergedScroll = React.useMemo(() => {
|
||||
|
|
@ -55,13 +53,6 @@ function ListTable<T extends Record<string, any>>({
|
|||
}, [scroll]);
|
||||
|
||||
const hasVerticalScroll = mergedScroll.y !== undefined;
|
||||
const isEmptyTable = !loading && dataSource.length === 0;
|
||||
const tableClassName = [
|
||||
hasVerticalScroll ? "list-table-table--y-scroll" : undefined,
|
||||
isEmptyTable ? "list-table-table--empty" : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const rowSelection: TableProps<T>["rowSelection"] = onSelectionChange
|
||||
? {
|
||||
selectedRowKeys,
|
||||
|
|
@ -131,19 +122,11 @@ function ListTable<T extends Record<string, any>>({
|
|||
["--list-table-scroll-y" as string]: typeof mergedScroll.y === "number" ? `${mergedScroll.y}px` : mergedScroll.y,
|
||||
} as React.CSSProperties)
|
||||
: undefined;
|
||||
const emptyText = locale?.emptyText ?? (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" />
|
||||
);
|
||||
const emptyContent = typeof emptyText === "function" ? emptyText() : emptyText;
|
||||
const tableLocale = {
|
||||
...locale,
|
||||
emptyText: null,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`list-table-container ${className}`} style={wrapperStyle}>
|
||||
<Table
|
||||
className={tableClassName || undefined}
|
||||
className={hasVerticalScroll ? "list-table-table--y-scroll" : undefined}
|
||||
size="middle"
|
||||
rowSelection={rowSelection}
|
||||
columns={columns}
|
||||
|
|
@ -152,14 +135,12 @@ function ListTable<T extends Record<string, any>>({
|
|||
pagination={mergedPagination}
|
||||
scroll={mergedScroll}
|
||||
loading={loading}
|
||||
locale={tableLocale}
|
||||
onChange={onChange}
|
||||
onRow={(record) => ({
|
||||
onClick: () => onRowClick?.(record),
|
||||
className: selectedRow?.[rowKey] === record[rowKey] ? "row-selected" : "",
|
||||
})}
|
||||
/>
|
||||
{isEmptyTable ? <div className="list-table-empty-overlay">{emptyContent}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,154 @@
|
|||
.app-header {
|
||||
background: var(--header-bg);
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
height: 64px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* 左侧区域 */
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Logo 区域 */
|
||||
.header-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 168px;
|
||||
transition: width 0.2s;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-color-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.trigger:hover {
|
||||
color: #1677ff;
|
||||
background: rgba(22, 119, 255, 0.08);
|
||||
}
|
||||
|
||||
/* 右侧区域 */
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Icon Buttons */
|
||||
.header-icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--text-color-secondary);
|
||||
transition: all 0.2s;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.header-icon-btn:hover {
|
||||
background-color: var(--item-hover-bg);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* 通知面板样式 */
|
||||
.header-notification-popover .ant-popover-inner-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.notification-popover {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.popover-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.popover-header .title {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
padding: 12px 16px !important;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.notification-item:hover {
|
||||
background: var(--item-hover-bg);
|
||||
}
|
||||
|
||||
.notification-item.unread {
|
||||
background: #e6f7ff;
|
||||
}
|
||||
|
||||
/* Dark mode adjustment for unread */
|
||||
body.dark .notification-item.unread {
|
||||
background: #111d2c;
|
||||
}
|
||||
|
||||
.notification-item.unread:hover {
|
||||
background: #bae7ff;
|
||||
}
|
||||
|
||||
body.dark .notification-item.unread:hover {
|
||||
background: #112a45;
|
||||
}
|
||||
|
||||
.content-text {
|
||||
font-size: 13px;
|
||||
color: var(--text-color-secondary);
|
||||
margin-top: 4px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.8;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.popover-footer {
|
||||
padding: 8px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Layout, Badge, Avatar, Dropdown, Space, Popover, List, Tabs, Button, Empty, Typography, Segmented, Tooltip } from 'antd'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
BellOutlined,
|
||||
ProjectOutlined,
|
||||
TeamOutlined,
|
||||
NotificationOutlined,
|
||||
MoonOutlined,
|
||||
SunOutlined,
|
||||
GlobalOutlined
|
||||
} from '@ant-design/icons'
|
||||
import useUserStore from '@/stores/userStore'
|
||||
import useNotificationStore from '@/stores/notificationStore'
|
||||
import useThemeStore from '@/stores/themeStore'
|
||||
import { getNotifications, getUnreadCount, markAsRead, markAllAsRead } from '@/api/notification'
|
||||
import Toast from '@/components/Toast/Toast'
|
||||
import './AppHeader.css'
|
||||
|
||||
const { Header } = Layout
|
||||
const { Text } = Typography
|
||||
|
||||
function AppHeader({ collapsed, onToggle, showLogo = true }) {
|
||||
const navigate = useNavigate()
|
||||
const { user } = useUserStore()
|
||||
const { unreadCount, fetchUnreadCount, decrementUnreadCount, resetUnreadCount } = useNotificationStore()
|
||||
const { isDarkMode, toggleTheme } = useThemeStore()
|
||||
|
||||
const [notifications, setNotifications] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [popoverVisible, setPopoverVisible] = useState(false)
|
||||
const [lang, setLang] = useState('zh')
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchUnreadCount()
|
||||
const timer = setInterval(fetchUnreadCount, 120000)
|
||||
return () => clearInterval(timer)
|
||||
}
|
||||
}, [user])
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getNotifications({ page: 1, page_size: 5 })
|
||||
setNotifications(res.data || [])
|
||||
} catch (error) {
|
||||
console.error('Fetch notifications error:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMarkRead = async (id) => {
|
||||
try {
|
||||
await markAsRead(id)
|
||||
setNotifications(notifications.map(n => n.id === id ? { ...n, is_read: true } : n))
|
||||
decrementUnreadCount()
|
||||
} catch (error) {
|
||||
console.error('Mark read error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMarkAllRead = async () => {
|
||||
try {
|
||||
await markAllAsRead()
|
||||
setNotifications(notifications.map(n => ({ ...n, is_read: true })))
|
||||
resetUnreadCount()
|
||||
Toast.success('操作成功', '所有通知已标记为已读')
|
||||
} catch (error) {
|
||||
console.error('Mark all read error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNotificationClick = (n) => {
|
||||
if (!n.is_read) {
|
||||
handleMarkRead(n.id)
|
||||
}
|
||||
if (n.link) {
|
||||
navigate(n.link)
|
||||
setPopoverVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getCategoryIcon = (category) => {
|
||||
switch (category) {
|
||||
case 'project': return <ProjectOutlined style={{ color: '#1890ff' }} />
|
||||
case 'collaboration': return <TeamOutlined style={{ color: '#52c41a' }} />
|
||||
default: return <NotificationOutlined style={{ color: '#faad14' }} />
|
||||
}
|
||||
}
|
||||
|
||||
const notificationContent = (
|
||||
<div className="notification-popover">
|
||||
<div className="popover-header">
|
||||
<span className="title">消息通知</span>
|
||||
{unreadCount > 0 && (
|
||||
<Button type="link" size="small" onClick={handleMarkAllRead}>
|
||||
全部已读
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<List
|
||||
className="notification-list"
|
||||
loading={loading}
|
||||
itemLayout="horizontal"
|
||||
dataSource={notifications}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无新消息" /> }}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
className={`notification-item ${!item.is_read ? 'unread' : ''}`}
|
||||
onClick={() => handleNotificationClick(item)}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={<Avatar icon={getCategoryIcon(item.category)} />}
|
||||
title={<Text strong={!item.is_read}>{item.title}</Text>}
|
||||
description={
|
||||
<div>
|
||||
<div className="content-text">{item.content}</div>
|
||||
<div className="time">{new Date(item.created_at).toLocaleString('zh-CN')}</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
<div className="popover-footer">
|
||||
<Button type="link" block onClick={() => { navigate('/notifications'); setPopoverVisible(false); }}>
|
||||
查看全部消息
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Header className="app-header" style={{ paddingLeft: showLogo ? 24 : 0 }}>
|
||||
{/* 左侧:Logo + 折叠按钮 */}
|
||||
{showLogo && (
|
||||
<div className="header-left">
|
||||
{/* Logo 区域 */}
|
||||
<div className="header-logo">
|
||||
<img src="/favicon.svg" alt="logo" style={{ width: 32, height: 32, marginRight: 8 }} />
|
||||
<h2 style={{ margin: 0, color: '#1677ff', fontWeight: 'bold' }}>NexDocus</h2>
|
||||
</div>
|
||||
|
||||
{/* 折叠按钮 */}
|
||||
<div className="trigger" onClick={onToggle}>
|
||||
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!showLogo && <div />} {/* Spacer if left is empty */}
|
||||
|
||||
{/* 右侧:功能按钮 */}
|
||||
<div className="header-right">
|
||||
<Space size={16} className="header-actions">
|
||||
|
||||
{/* 1. 主题切换 */}
|
||||
<div className="header-icon-btn" title="切换主题" onClick={toggleTheme}>
|
||||
{isDarkMode ? <SunOutlined style={{ fontSize: 18 }} /> : <MoonOutlined style={{ fontSize: 18 }} />}
|
||||
</div>
|
||||
|
||||
{/* 2. 语言切换 */}
|
||||
<Segmented
|
||||
value={lang}
|
||||
onChange={setLang}
|
||||
options={[
|
||||
{ label: '中', value: 'zh' },
|
||||
{ label: 'EN', value: 'en' },
|
||||
]}
|
||||
style={{ fontWeight: 500 }}
|
||||
/>
|
||||
|
||||
{/* 3. 消息通知 */}
|
||||
<Popover
|
||||
content={notificationContent}
|
||||
trigger="click"
|
||||
open={popoverVisible}
|
||||
onOpenChange={(visible) => {
|
||||
setPopoverVisible(visible)
|
||||
if (visible) {
|
||||
fetchNotifications()
|
||||
}
|
||||
}}
|
||||
placement="bottomRight"
|
||||
overlayClassName="header-notification-popover"
|
||||
>
|
||||
<div className="header-icon-btn" title="消息中心">
|
||||
<Badge count={unreadCount} size="small" offset={[2, -2]}>
|
||||
<BellOutlined style={{ fontSize: 18 }} />
|
||||
</Badge>
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
</Space>
|
||||
</div>
|
||||
</Header>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppHeader
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
.app-sider {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background: #fafafa;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.app-sider::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.app-sider::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.app-sider::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* 菜单样式 */
|
||||
.sider-menu {
|
||||
border-right: none;
|
||||
padding-top: 8px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
/* 收起状态下的图标放大 */
|
||||
:global(.ant-layout-sider-collapsed) .sider-menu :global(.ant-menu-item) {
|
||||
padding: 0 !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 56px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
/* 收起状态下的 SubMenu 样式 */
|
||||
:global(.ant-layout-sider-collapsed) .sider-menu :global(.ant-menu-submenu) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
:global(.ant-layout-sider-collapsed) .sider-menu :global(.ant-menu-submenu-title) {
|
||||
padding: 0 !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 56px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
:global(.ant-layout-sider-collapsed) .sider-menu :global(.anticon) {
|
||||
font-size: 24px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 收起状态下的 Tooltip */
|
||||
:global(.ant-layout-sider-collapsed) .sider-menu :global(.ant-menu-item-icon) {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
:global(.ant-layout-sider-collapsed) .sider-menu :global(.ant-menu-submenu-title) :global(.anticon) {
|
||||
font-size: 24px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 菜单项徽章 */
|
||||
.menu-item-with-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.menu-badge {
|
||||
font-size: 10px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
border-radius: 9px;
|
||||
padding: 0 6px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.badge-hot :global(.ant-badge-count) {
|
||||
background: #ff4d4f;
|
||||
}
|
||||
|
||||
.badge-new :global(.ant-badge-count) {
|
||||
background: #52c41a;
|
||||
}
|
||||
|
||||
/* 收起状态下隐藏徽章 */
|
||||
:global(.ant-layout-sider-collapsed) .menu-badge {
|
||||
display: none;
|
||||
}
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
DashboardOutlined,
|
||||
DesktopOutlined,
|
||||
GlobalOutlined,
|
||||
CloudServerOutlined,
|
||||
UserOutlined,
|
||||
AppstoreOutlined,
|
||||
SettingOutlined,
|
||||
BlockOutlined,
|
||||
FolderOutlined,
|
||||
FileTextOutlined,
|
||||
SafetyOutlined,
|
||||
TeamOutlined,
|
||||
ProjectOutlined,
|
||||
RocketOutlined,
|
||||
ReadOutlined,
|
||||
BookOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { App } from 'antd'
|
||||
import { getUserMenus } from '@/api/menu'
|
||||
import useUserStore from '@/stores/userStore'
|
||||
import ModernSidebar from '../ModernSidebar/ModernSidebar'
|
||||
|
||||
// 图标映射
|
||||
const iconMap = {
|
||||
DashboardOutlined: <DashboardOutlined />,
|
||||
DesktopOutlined: <DesktopOutlined />,
|
||||
GlobalOutlined: <GlobalOutlined />,
|
||||
CloudServerOutlined: <CloudServerOutlined />,
|
||||
UserOutlined: <UserOutlined />,
|
||||
AppstoreOutlined: <AppstoreOutlined />,
|
||||
SettingOutlined: <SettingOutlined />,
|
||||
BlockOutlined: <BlockOutlined />,
|
||||
FolderOutlined: <FolderOutlined />,
|
||||
FileTextOutlined: <FileTextOutlined />,
|
||||
SafetyOutlined: <SafetyOutlined />,
|
||||
TeamOutlined: <TeamOutlined />,
|
||||
ProjectOutlined: <ProjectOutlined />,
|
||||
ReadOutlined: <ReadOutlined />,
|
||||
BookOutlined: <BookOutlined />,
|
||||
}
|
||||
|
||||
function AppSider({ collapsed, onToggle }) {
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { user, logout } = useUserStore()
|
||||
const [menuGroups, setMenuGroups] = useState([])
|
||||
|
||||
// 加载菜单数据
|
||||
useEffect(() => {
|
||||
loadMenus()
|
||||
}, [])
|
||||
|
||||
const loadMenus = async () => {
|
||||
try {
|
||||
const res = await getUserMenus()
|
||||
if (res.data) {
|
||||
// 过滤菜单:只显示 type=1 (目录) 和 type=2 (菜单)
|
||||
const validMenus = res.data.filter(item => [1, 2].includes(item.menu_type))
|
||||
transformMenuData(validMenus)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load menus error:', error)
|
||||
message.error('加载菜单失败')
|
||||
}
|
||||
}
|
||||
|
||||
const transformMenuData = (data) => {
|
||||
const groups = []
|
||||
|
||||
// 默认组 (用于存放一级菜单即是叶子节点的情况)
|
||||
const defaultGroup = {
|
||||
title: '', // 空标题或 '通用'
|
||||
items: []
|
||||
}
|
||||
|
||||
data.forEach(item => {
|
||||
// 检查是否有子菜单
|
||||
const validChildren = item.children ? item.children.filter(child => [1, 2].includes(child.menu_type)) : []
|
||||
|
||||
if (validChildren.length > 0) {
|
||||
// 一级菜单作为组标题
|
||||
const groupItems = validChildren.map(child => {
|
||||
const icon = typeof child.icon === 'string' ? (iconMap[child.icon] || <AppstoreOutlined />) : child.icon
|
||||
return {
|
||||
key: child.menu_code,
|
||||
label: child.menu_name,
|
||||
icon: icon,
|
||||
path: child.path
|
||||
}
|
||||
})
|
||||
|
||||
groups.push({
|
||||
title: item.menu_name, // e.g. "系统管理"
|
||||
items: groupItems
|
||||
})
|
||||
} else {
|
||||
// 一级菜单是叶子节点,放入默认组
|
||||
const icon = typeof item.icon === 'string' ? (iconMap[item.icon] || <AppstoreOutlined />) : item.icon
|
||||
defaultGroup.items.push({
|
||||
key: item.menu_code,
|
||||
label: item.menu_name,
|
||||
icon: icon,
|
||||
path: item.path
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 如果默认组有内容,放在最前面
|
||||
if (defaultGroup.items.length > 0) {
|
||||
groups.unshift(defaultGroup)
|
||||
}
|
||||
|
||||
setMenuGroups(groups)
|
||||
}
|
||||
|
||||
const handleNavigate = (key, item) => {
|
||||
if (item.path) {
|
||||
navigate(item.path)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const handleProfileClick = () => {
|
||||
navigate('/profile')
|
||||
}
|
||||
|
||||
// 获取当前激活的 key
|
||||
// 简单匹配 path
|
||||
const getActiveKey = () => {
|
||||
const path = location.pathname
|
||||
// 遍历所有 items 找匹配
|
||||
for (const group of menuGroups) {
|
||||
for (const item of group.items) {
|
||||
if (item.path === path) return item.key
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const logoNode = (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, paddingLeft: 8 }}>
|
||||
<img src="/favicon.svg" alt="logo" style={{ width: 32, height: 32 }} />
|
||||
{!collapsed && (
|
||||
<span style={{ fontSize: 18, fontWeight: 'bold', color: 'var(--text-color)' }}>NexDocus</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
// 获取用户头像URL
|
||||
const getUserAvatarUrl = () => {
|
||||
if (!user?.avatar) return null
|
||||
// avatar 字段存储的是相对路径,如:2/avatar/xxx.jpg
|
||||
// 需要转换为 API 端点: /api/v1/auth/avatar/{user_id}/{filename}
|
||||
// 如果已经是 http 开头(第三方),则直接返回
|
||||
if (user.avatar.startsWith('http')) return user.avatar
|
||||
|
||||
const parts = user.avatar.split('/')
|
||||
if (parts.length >= 3) {
|
||||
const userId = parts[0]
|
||||
const filename = parts[2]
|
||||
return `/api/v1/auth/avatar/${userId}/${filename}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const userObj = user ? {
|
||||
name: user.nickname || user.username,
|
||||
role: user.role_name || 'Admin',
|
||||
avatar: getUserAvatarUrl()
|
||||
} : null
|
||||
|
||||
return (
|
||||
<ModernSidebar
|
||||
logo={logoNode}
|
||||
menuGroups={menuGroups}
|
||||
activeKey={getActiveKey()}
|
||||
onNavigate={handleNavigate}
|
||||
user={userObj}
|
||||
onLogout={handleLogout}
|
||||
onProfileClick={handleProfileClick}
|
||||
collapsed={collapsed}
|
||||
onCollapse={onToggle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppSider
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
.main-layout {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: row; /* Changed to row for Sider-Left layout */
|
||||
background: var(--bg-color-secondary);
|
||||
}
|
||||
|
||||
.main-content-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
height: 100vh;
|
||||
background: var(--bg-color-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
background: var(--bg-color-secondary);
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { useState } from 'react'
|
||||
import { Layout } from 'antd'
|
||||
import AppSider from './AppSider'
|
||||
import AppHeader from './AppHeader'
|
||||
import './MainLayout.css'
|
||||
|
||||
const { Content } = Layout
|
||||
|
||||
function MainLayout({ children }) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
setCollapsed(!collapsed)
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout className="main-layout" hasSider>
|
||||
<AppSider collapsed={collapsed} onToggle={toggleCollapsed} />
|
||||
<Layout className="main-content-wrapper">
|
||||
<AppHeader collapsed={collapsed} onToggle={toggleCollapsed} showLogo={false} />
|
||||
<Content className="main-content">
|
||||
<div className="content-wrapper">
|
||||
{children}
|
||||
</div>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default MainLayout
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export { default } from './MainLayout'
|
||||
export { default as MainLayout } from './MainLayout'
|
||||
export { default as AppSider } from './AppSider'
|
||||
export { default as AppHeader } from './AppHeader'
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
.modern-sidebar {
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
background: var(--sider-bg) !important;
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modern-sidebar .ant-layout-sider-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.modern-sidebar-header {
|
||||
padding: 24px 20px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Collapse Trigger */
|
||||
.collapse-trigger {
|
||||
position: absolute;
|
||||
right: -12px;
|
||||
top: 32px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
color: var(--text-color-secondary);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.collapse-trigger:hover {
|
||||
color: #1677ff;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Menu Area */
|
||||
.modern-sidebar-menu {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.modern-sidebar-menu::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.modern-sidebar-menu::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Menu Group */
|
||||
.menu-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
font-size: 12px;
|
||||
color: var(--text-color-secondary);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
padding-left: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Menu Item */
|
||||
.modern-sidebar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 12px; /* Rounded corners */
|
||||
transition: all 0.2s;
|
||||
color: var(--text-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modern-sidebar-item:hover {
|
||||
background-color: var(--item-hover-bg);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.modern-sidebar-item.active {
|
||||
background-color: #2563eb; /* Royal Blue */
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.25);
|
||||
}
|
||||
|
||||
.item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.item-label {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.item-arrow {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Collapsed State */
|
||||
.modern-sidebar-item.collapsed {
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.modern-sidebar-footer {
|
||||
padding: 16px;
|
||||
flex-shrink: 0;
|
||||
background: var(--sider-bg);
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
padding-left: 12px;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.footer-link:hover {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.footer-link.collapsed {
|
||||
justify-content: center;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
/* User Card */
|
||||
.user-card {
|
||||
background-color: var(--bg-color-secondary); /* Light gray background */
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 12px;
|
||||
color: var(--text-color-secondary);
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background-color: var(--border-color);
|
||||
color: #ef4444; /* Red for logout */
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Layout, Avatar, Tooltip, Button } from 'antd';
|
||||
import {
|
||||
MenuUnfoldOutlined,
|
||||
MenuFoldOutlined,
|
||||
LogoutOutlined,
|
||||
QuestionCircleOutlined,
|
||||
RightOutlined,
|
||||
LeftOutlined
|
||||
} from '@ant-design/icons';
|
||||
import './ModernSidebar.css';
|
||||
|
||||
const { Sider } = Layout;
|
||||
|
||||
const ModernSidebar = ({
|
||||
logo,
|
||||
menuGroups = [],
|
||||
activeKey,
|
||||
onNavigate,
|
||||
user,
|
||||
onLogout,
|
||||
onProfileClick,
|
||||
collapsed,
|
||||
onCollapse,
|
||||
width = 260,
|
||||
collapsedWidth = 80,
|
||||
className = '',
|
||||
style = {}
|
||||
}) => {
|
||||
|
||||
const handleItemClick = (item) => {
|
||||
if (onNavigate) {
|
||||
onNavigate(item.key, item);
|
||||
}
|
||||
};
|
||||
|
||||
const renderMenuItem = (item) => {
|
||||
const isActive = activeKey === item.key;
|
||||
|
||||
// 如果是折叠状态,只显示图标,并使用Tooltip
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Tooltip title={item.label} placement="right" key={item.key}>
|
||||
<div
|
||||
className={`modern-sidebar-item collapsed ${isActive ? 'active' : ''}`}
|
||||
onClick={() => handleItemClick(item)}
|
||||
>
|
||||
<div className="item-icon">{item.icon}</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
className={`modern-sidebar-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => handleItemClick(item)}
|
||||
>
|
||||
<div className="item-content">
|
||||
<div className="item-icon">{item.icon}</div>
|
||||
<span className="item-label">{item.label}</span>
|
||||
</div>
|
||||
{isActive && <RightOutlined className="item-arrow" />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sider
|
||||
width={width}
|
||||
collapsed={collapsed}
|
||||
collapsedWidth={collapsedWidth}
|
||||
trigger={null}
|
||||
className={`modern-sidebar ${className}`}
|
||||
theme="light"
|
||||
style={style}
|
||||
>
|
||||
{/* 顶部 Logo 区域 */}
|
||||
<div className="modern-sidebar-header">
|
||||
<div className="logo-container">
|
||||
{logo}
|
||||
</div>
|
||||
{/* 折叠按钮 - 悬浮在边缘 */}
|
||||
<div
|
||||
className="collapse-trigger"
|
||||
onClick={() => onCollapse && onCollapse(!collapsed)}
|
||||
>
|
||||
{collapsed ? <RightOutlined /> : <LeftOutlined />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 菜单列表区域 */}
|
||||
<div className="modern-sidebar-menu">
|
||||
{menuGroups.map((group, index) => (
|
||||
<div key={index} className="menu-group">
|
||||
{!collapsed && group.title && (
|
||||
<div className="group-title">{group.title}</div>
|
||||
)}
|
||||
<div className="group-items">
|
||||
{group.items.map(item => renderMenuItem(item))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 底部区域 */}
|
||||
<div className="modern-sidebar-footer">
|
||||
{/* 帮助支持 */}
|
||||
{!collapsed && (
|
||||
<div className="footer-link">
|
||||
<QuestionCircleOutlined />
|
||||
<span>帮助支持</span>
|
||||
</div>
|
||||
)}
|
||||
{collapsed && (
|
||||
<div className="footer-link collapsed">
|
||||
<QuestionCircleOutlined />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 用户卡片 */}
|
||||
<div className="user-card">
|
||||
<div
|
||||
className="user-info"
|
||||
onClick={onProfileClick}
|
||||
style={{ cursor: onProfileClick ? 'pointer' : 'default' }}
|
||||
>
|
||||
<Avatar
|
||||
size={collapsed ? 32 : 40}
|
||||
src={user?.avatar}
|
||||
style={{ backgroundColor: '#1677ff' }}
|
||||
>
|
||||
{user?.name?.[0]?.toUpperCase() || 'U'}
|
||||
</Avatar>
|
||||
{!collapsed && (
|
||||
<div className="user-details">
|
||||
<div className="user-name">{user?.name || 'User'}</div>
|
||||
<div className="user-role">{user?.role || 'Member'}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="logout-btn" onClick={onLogout} title="退出登录">
|
||||
<LogoutOutlined />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Sider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModernSidebar;
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
.pdf-viewer-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: #f5f5f5;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pdf-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pdf-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.pdf-content .react-pdf__Document {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pdf-content .react-pdf__Page {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
margin-bottom: 20px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.pdf-content .react-pdf__Page canvas {
|
||||
max-width: 100%;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.pdf-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pdf-error {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
color: #f5222d;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
import { useState, useMemo } from 'react'
|
||||
import { Document, Page, pdfjs } from 'react-pdf'
|
||||
import { Button, Space, InputNumber, Spin, App } from 'antd'
|
||||
import {
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
ZoomInOutlined,
|
||||
ZoomOutOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import 'react-pdf/dist/Page/AnnotationLayer.css'
|
||||
import 'react-pdf/dist/Page/TextLayer.css'
|
||||
import './PDFViewer.css'
|
||||
|
||||
// 配置 PDF.js worker - 使用本地文件
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf-worker/pdf.worker.min.mjs'
|
||||
|
||||
function PDFViewer({ url, filename }) {
|
||||
const { message } = App.useApp();
|
||||
const [numPages, setNumPages] = useState(null)
|
||||
const [pageNumber, setPageNumber] = useState(1)
|
||||
const [scale, setScale] = useState(1.0)
|
||||
|
||||
// 使用 useMemo 避免不必要的重新加载
|
||||
const fileConfig = useMemo(() => ({ url }), [url])
|
||||
|
||||
const onDocumentLoadSuccess = ({ numPages }) => {
|
||||
setNumPages(numPages)
|
||||
setPageNumber(1)
|
||||
}
|
||||
|
||||
const onDocumentLoadError = (error) => {
|
||||
message.error('PDF文件加载失败')
|
||||
}
|
||||
|
||||
const goToPrevPage = () => {
|
||||
setPageNumber((prev) => Math.max(prev - 1, 1))
|
||||
}
|
||||
|
||||
const goToNextPage = () => {
|
||||
setPageNumber((prev) => Math.min(prev + 1, numPages))
|
||||
}
|
||||
|
||||
const zoomIn = () => {
|
||||
setScale((prev) => Math.min(prev + 0.2, 3.0))
|
||||
}
|
||||
|
||||
const zoomOut = () => {
|
||||
setScale((prev) => Math.max(prev - 0.2, 0.5))
|
||||
}
|
||||
|
||||
const handlePageChange = (value) => {
|
||||
if (value >= 1 && value <= numPages) {
|
||||
setPageNumber(value)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pdf-viewer-container">
|
||||
{/* 工具栏 */}
|
||||
<div className="pdf-toolbar">
|
||||
<Space>
|
||||
<Button
|
||||
icon={<LeftOutlined />}
|
||||
onClick={goToPrevPage}
|
||||
disabled={pageNumber <= 1}
|
||||
size="small"
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Space.Compact>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={numPages || 1}
|
||||
value={pageNumber}
|
||||
onChange={handlePageChange}
|
||||
size="small"
|
||||
style={{ width: 60 }}
|
||||
/>
|
||||
<Button size="small" disabled>
|
||||
/ {numPages || 0}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<Button
|
||||
icon={<RightOutlined />}
|
||||
onClick={goToNextPage}
|
||||
disabled={pageNumber >= numPages}
|
||||
size="small"
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Space>
|
||||
<Button icon={<ZoomOutOutlined />} onClick={zoomOut} size="small">
|
||||
缩小
|
||||
</Button>
|
||||
<span style={{ minWidth: 50, textAlign: 'center' }}>
|
||||
{Math.round(scale * 100)}%
|
||||
</span>
|
||||
<Button icon={<ZoomInOutlined />} onClick={zoomIn} size="small">
|
||||
放大
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* PDF内容区 */}
|
||||
<div className="pdf-content">
|
||||
<Document
|
||||
file={fileConfig}
|
||||
onLoadSuccess={onDocumentLoadSuccess}
|
||||
onLoadError={onDocumentLoadError}
|
||||
loading={
|
||||
<div className="pdf-loading">
|
||||
<Spin size="large" />
|
||||
<div style={{ marginTop: 16 }}>正在加载PDF...</div>
|
||||
</div>
|
||||
}
|
||||
error={<div className="pdf-error">PDF加载失败,请稍后重试</div>}
|
||||
>
|
||||
<Page
|
||||
pageNumber={pageNumber}
|
||||
scale={scale}
|
||||
renderTextLayer={true}
|
||||
renderAnnotationLayer={true}
|
||||
loading={
|
||||
<div className="pdf-loading">
|
||||
<Spin size="large" />
|
||||
<div style={{ marginTop: 16 }}>正在渲染页面...</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Document>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PDFViewer
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
.virtual-pdf-viewer-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg-color-secondary);
|
||||
}
|
||||
|
||||
.pdf-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: var(--card-bg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
z-index: 10;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.pdf-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pdf-virtual-list {
|
||||
background: var(--bg-color-secondary);
|
||||
}
|
||||
|
||||
.pdf-page-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
background: var(--bg-color-secondary);
|
||||
}
|
||||
|
||||
.pdf-page-wrapper canvas {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
background: white;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pdf-page-number {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-color);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pdf-page-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 600px;
|
||||
gap: 8px;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pdf-page-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 600px;
|
||||
background: var(--item-hover-bg);
|
||||
border: 1px dashed var(--border-color);
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pdf-page-skeleton {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
min-height: 800px;
|
||||
}
|
||||
|
||||
.pdf-page-error {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.pdf-page-error p {
|
||||
color: #ff4d4f;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pdf-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.pdf-error {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
color: #ff4d4f;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 文本层样式优化 */
|
||||
.react-pdf__Page__textContent {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* 注释层样式优化 */
|
||||
.react-pdf__Page__annotations {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Document容器样式 - 确保不限制高度 */
|
||||
.react-pdf__Document {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
|
@ -0,0 +1,268 @@
|
|||
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
|
||||
import { Document, Page, pdfjs } from 'react-pdf'
|
||||
import { Button, Space, InputNumber, App, Spin } from 'antd'
|
||||
import {
|
||||
ZoomInOutlined,
|
||||
ZoomOutOutlined,
|
||||
VerticalAlignTopOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import 'react-pdf/dist/Page/AnnotationLayer.css'
|
||||
import 'react-pdf/dist/Page/TextLayer.css'
|
||||
import './VirtualPDFViewer.css'
|
||||
|
||||
// 配置 PDF.js worker
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf-worker/pdf.worker.min.mjs'
|
||||
|
||||
function VirtualPDFViewer({ url, filename }) {
|
||||
const { message } = App.useApp();
|
||||
const [numPages, setNumPages] = useState(null)
|
||||
const [scale, setScale] = useState(1.0)
|
||||
const [pdfOriginalSize, setPdfOriginalSize] = useState({ width: 595, height: 842 }) // 默认 A4
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [visiblePages, setVisiblePages] = useState(new Set([1]))
|
||||
const containerRef = useRef(null)
|
||||
const pageRefs = useRef({})
|
||||
|
||||
// 使用 useMemo 避免不必要的重新加载
|
||||
const fileConfig = useMemo(() => ({ url }), [url])
|
||||
|
||||
// 离线部署环境不依赖 unpkg 等外部静态资源。
|
||||
const pdfOptions = useMemo(() => undefined, [])
|
||||
|
||||
// 根据 PDF 实际宽高和缩放比例计算页面高度
|
||||
const pageHeight = useMemo(() => {
|
||||
// 计算内容高度:缩放后的 PDF 高度 + 上下 padding (40px) + 页码文字区域 (20px)
|
||||
return Math.ceil(pdfOriginalSize.height * scale) + 60
|
||||
}, [scale, pdfOriginalSize.height])
|
||||
|
||||
const onDocumentLoadError = (error) => {
|
||||
console.error('[PDF] Document load error:', error)
|
||||
message.error('PDF文件加载失败')
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Handle scroll to update visible pages
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!containerRef.current || !numPages) return
|
||||
|
||||
const container = containerRef.current
|
||||
const scrollTop = container.scrollTop
|
||||
const containerHeight = container.clientHeight
|
||||
|
||||
// Calculate which pages are visible
|
||||
// Add small tolerance (1px) to handle browser scroll precision issues
|
||||
const pageIndex = scrollTop / pageHeight
|
||||
let firstVisiblePage = Math.max(1, Math.ceil(pageIndex + 0.001))
|
||||
|
||||
// Special case: if scrolled to bottom, show last page
|
||||
const isAtBottom = scrollTop + containerHeight >= container.scrollHeight - 1
|
||||
if (isAtBottom) {
|
||||
firstVisiblePage = numPages
|
||||
}
|
||||
|
||||
const lastVisiblePage = Math.min(numPages, Math.ceil((scrollTop + containerHeight) / pageHeight))
|
||||
|
||||
|
||||
|
||||
// Add buffer pages (2 before and 2 after)
|
||||
const newVisiblePages = new Set()
|
||||
for (let i = Math.max(1, firstVisiblePage - 2); i <= Math.min(numPages, lastVisiblePage + 2); i++) {
|
||||
newVisiblePages.add(i)
|
||||
}
|
||||
|
||||
setVisiblePages(newVisiblePages)
|
||||
setCurrentPage(firstVisiblePage)
|
||||
}, [numPages, pageHeight])
|
||||
|
||||
const onDocumentLoadSuccess = useCallback(async (pdf) => {
|
||||
setNumPages(pdf.numPages)
|
||||
|
||||
try {
|
||||
// 获取第一页的原始尺寸,用于计算初始缩放
|
||||
const page = await pdf.getPage(1)
|
||||
const viewport = page.getViewport({ scale: 1.0 })
|
||||
const { width, height } = viewport
|
||||
setPdfOriginalSize({ width, height })
|
||||
|
||||
// 自动适应宽度:仅当 PDF 宽度超过容器时才进行缩放
|
||||
if (containerRef.current) {
|
||||
const containerWidth = containerRef.current.clientWidth - 40 // 减去左右内边距
|
||||
if (width > containerWidth) {
|
||||
const autoScale = Math.floor((containerWidth / width) * 10) / 10 // 保留一位小数
|
||||
setScale(Math.min(Math.max(autoScale, 0.5), 1.0)) // 限制缩放比例,最高不超 1.0
|
||||
} else {
|
||||
setScale(1.0) // 宽度足够则保持 100%
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error calculating initial scale:', err)
|
||||
}
|
||||
|
||||
// Initially show first 3 pages
|
||||
setVisiblePages(new Set([1, 2, 3]))
|
||||
|
||||
// Trigger scroll calculation after a short delay to ensure DOM is ready
|
||||
setTimeout(() => {
|
||||
handleScroll()
|
||||
}, 200)
|
||||
}, [handleScroll])
|
||||
|
||||
// Attach scroll listener
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
container.addEventListener('scroll', handleScroll)
|
||||
return () => container.removeEventListener('scroll', handleScroll)
|
||||
}, [handleScroll, numPages, pageHeight])
|
||||
|
||||
const zoomIn = () => {
|
||||
setScale((prev) => Math.min(prev + 0.2, 3.0))
|
||||
}
|
||||
|
||||
const zoomOut = () => {
|
||||
setScale((prev) => Math.max(prev - 0.2, 0.5))
|
||||
}
|
||||
|
||||
const handlePageChange = (value) => {
|
||||
if (value >= 1 && value <= numPages && containerRef.current) {
|
||||
const scrollTop = (value - 1) * pageHeight
|
||||
const container = containerRef.current
|
||||
container.scrollTo({ top: scrollTop, behavior: 'auto' })
|
||||
|
||||
// Manually trigger handleScroll after scrolling to ensure page number updates
|
||||
setTimeout(() => {
|
||||
handleScroll()
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToTop = () => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="virtual-pdf-viewer-container">
|
||||
{/* 工具栏 */}
|
||||
<div className="pdf-toolbar">
|
||||
<Space>
|
||||
<Button
|
||||
icon={<LeftOutlined />}
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
size="small"
|
||||
/>
|
||||
<Space.Compact>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={numPages || 1}
|
||||
value={currentPage}
|
||||
onChange={handlePageChange}
|
||||
size="small"
|
||||
style={{ width: 60 }}
|
||||
/>
|
||||
<Button size="small" disabled>
|
||||
/ {numPages || 0}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<Button
|
||||
icon={<RightOutlined />}
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage >= (numPages || 0)}
|
||||
size="small"
|
||||
/>
|
||||
<Button
|
||||
icon={<VerticalAlignTopOutlined />}
|
||||
onClick={scrollToTop}
|
||||
size="small"
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
回到顶部
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Space>
|
||||
<Button icon={<ZoomOutOutlined />} onClick={zoomOut} size="small">
|
||||
缩小
|
||||
</Button>
|
||||
<span style={{ minWidth: 50, textAlign: 'center' }}>
|
||||
{Math.round(scale * 100)}%
|
||||
</span>
|
||||
<Button icon={<ZoomInOutlined />} onClick={zoomIn} size="small">
|
||||
放大
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* PDF内容区 - 自定义虚拟滚动 */}
|
||||
<div className="pdf-content" ref={containerRef}>
|
||||
<Document
|
||||
file={fileConfig}
|
||||
onLoadSuccess={onDocumentLoadSuccess}
|
||||
onLoadError={onDocumentLoadError}
|
||||
loading={
|
||||
<div className="pdf-loading">
|
||||
<Spin size="large" />
|
||||
<div style={{ marginTop: 16 }}>正在加载PDF...</div>
|
||||
</div>
|
||||
}
|
||||
error={<div className="pdf-error">PDF加载失败,请稍后重试</div>}
|
||||
options={pdfOptions}
|
||||
>
|
||||
{numPages && (
|
||||
<div style={{ height: numPages * pageHeight, position: 'relative' }}>
|
||||
{Array.from({ length: numPages }, (_, index) => {
|
||||
const pageNumber = index + 1
|
||||
const isVisible = visiblePages.has(pageNumber)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={pageNumber}
|
||||
ref={el => pageRefs.current[pageNumber] = el}
|
||||
className="pdf-page-wrapper"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: index * pageHeight,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: pageHeight,
|
||||
}}
|
||||
>
|
||||
{isVisible ? (
|
||||
<>
|
||||
<Page
|
||||
pageNumber={pageNumber}
|
||||
scale={scale}
|
||||
renderTextLayer={true}
|
||||
renderAnnotationLayer={true}
|
||||
loading={
|
||||
<div className="pdf-page-loading">
|
||||
<Spin size="small" />
|
||||
<div>加载第 {pageNumber} 页...</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="pdf-page-number">第 {pageNumber} 页</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="pdf-page-placeholder">
|
||||
<div>第 {pageNumber} 页</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Document>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VirtualPDFViewer
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
.page-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
gap: 0;
|
||||
background: var(--app-bg-layout, #f5f6fa);
|
||||
}
|
||||
|
||||
.page-container.page-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-container__header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding: 16px 16px 0;
|
||||
border: 1px solid var(--app-border-color, #e6e6e6);
|
||||
border-bottom: none;
|
||||
border-radius: 4px 4px 0 0;
|
||||
background: var(--app-surface-color, #fff);
|
||||
}
|
||||
|
||||
.page-container__header--actions-only {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.page-container__title-wrap {
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.page-container__title.ant-typography {
|
||||
margin: 0 !important;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
line-height: 28px;
|
||||
color: var(--app-text-main, #333333);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page-container__title.ant-typography::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -8px;
|
||||
top: 6px;
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
background: var(--app-primary-color, #3c70f5);
|
||||
}
|
||||
|
||||
.page-container__subtitle.ant-typography {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
padding-bottom: 16px;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--app-text-secondary, #9095a1);
|
||||
}
|
||||
|
||||
.page-container__header-extra {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-container__toolbar {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: 0 16px 8px;
|
||||
border-left: 1px solid var(--app-border-color, #e6e6e6);
|
||||
border-right: 1px solid var(--app-border-color, #e6e6e6);
|
||||
background: var(--app-surface-color, #fff);
|
||||
}
|
||||
|
||||
.page-container__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 16px 12px;
|
||||
border: 1px solid var(--app-border-color, #e6e6e6);
|
||||
border-top: none;
|
||||
border-radius: 0 0 4px 4px;
|
||||
background: var(--app-surface-color, #fff);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-container.page-container {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import React from 'react';
|
||||
import { Typography } from 'antd';
|
||||
import { Space, Typography } from 'antd';
|
||||
import type { ReactNode } from 'react';
|
||||
import './PageContainer.css';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
|
|
@ -24,37 +23,76 @@ const PageContainer: React.FC<PageContainerProps> = ({
|
|||
className = '',
|
||||
style
|
||||
}) => {
|
||||
const hasTitle = title !== null && title !== undefined && title !== false;
|
||||
const hasSubtitle = subtitle !== null && subtitle !== undefined && subtitle !== false;
|
||||
const hasHeader = hasTitle || hasSubtitle || Boolean(headerExtra);
|
||||
|
||||
return (
|
||||
<div className={`page-container ${className}`.trim()} style={style}>
|
||||
{hasHeader && (
|
||||
<div className={`page-container__header ${!hasTitle && !hasSubtitle ? 'page-container__header--actions-only' : ''}`.trim()}>
|
||||
{(hasTitle || hasSubtitle) && (
|
||||
<div className="page-container__title-wrap">
|
||||
{hasTitle && (
|
||||
<Title level={4} className="page-container__title">
|
||||
{title}
|
||||
</Title>
|
||||
)}
|
||||
{hasSubtitle && (
|
||||
<Text type="secondary" className="page-container__subtitle">
|
||||
<div
|
||||
className={`page-container ${className}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
gap: 16,
|
||||
...style
|
||||
}}
|
||||
>
|
||||
{(title || headerExtra) && (
|
||||
<div
|
||||
className="page-container__header"
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
flexWrap: 'wrap',
|
||||
gap: 16,
|
||||
paddingBottom: 12,
|
||||
borderBottom: '1px solid var(--app-border-color)'
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 200 }}>
|
||||
<Title level={4} style={{ margin: 0, fontWeight: 600 }}>
|
||||
{title}
|
||||
</Title>
|
||||
{subtitle && (
|
||||
<Text type="secondary" style={{ display: 'block', fontSize: 14, marginTop: 4 }}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
{headerExtra && (
|
||||
<div className="page-container__header-extra" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{headerExtra}
|
||||
</div>
|
||||
)}
|
||||
{headerExtra && <div className="page-container__header-extra">{headerExtra}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolbar && <div className="page-container__toolbar">{toolbar}</div>}
|
||||
{toolbar && (
|
||||
<div
|
||||
className="page-container__toolbar"
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12
|
||||
}}
|
||||
>
|
||||
{toolbar}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="page-container__body">{children}</div>
|
||||
<div
|
||||
className="page-container__body"
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageContainer;
|
||||
export default PageContainer;
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
.page-header-standard {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 12px;
|
||||
padding: 24px 28px;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-header-standard::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -10%;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.page-header-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
.page-header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: #ffffff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.page-header-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.page-header-title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin: 0;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.page-header-description {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.page-header-extra {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header-standard {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header-extra {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||
import './PageHeader.css'
|
||||
|
||||
function PageHeader({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
showBack = false,
|
||||
onBack,
|
||||
extra
|
||||
}) {
|
||||
return (
|
||||
<div className="page-header-standard">
|
||||
<div className="page-header-main">
|
||||
{showBack && (
|
||||
<button className="back-button" onClick={onBack}>
|
||||
<ArrowLeftOutlined />
|
||||
</button>
|
||||
)}
|
||||
<div className="page-header-content">
|
||||
{icon && <div className="page-header-icon">{icon}</div>}
|
||||
<div className="page-header-text">
|
||||
<h1 className="page-header-title">{title}</h1>
|
||||
{description && (
|
||||
<p className="page-header-description">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{extra && <div className="page-header-extra">{extra}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageHeader
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { Typography } from 'antd';
|
||||
import React from 'react';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
extra?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PageHeader: React.FC<PageHeaderProps> = ({ title, subtitle, extra, className = '' }) => {
|
||||
return (
|
||||
<div
|
||||
className={`page-header ${className}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 24,
|
||||
flexWrap: 'wrap',
|
||||
gap: 16
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 200 }}>
|
||||
<Title level={4} style={{ margin: '0 0 8px 0', fontWeight: 600 }}>
|
||||
{title}
|
||||
</Title>
|
||||
{subtitle && (
|
||||
<Text type="secondary" style={{ display: 'block', fontSize: 14 }}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{extra && <div className="page-header-extra" style={{ display: 'flex', alignItems: 'center' }}>{extra}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageHeader;
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
.page-title-bar {
|
||||
background: linear-gradient(135deg, #e0e7ff 0%, #f3e8ff 100%);
|
||||
border-radius: 12px;
|
||||
padding: 16px 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
|
||||
.page-title-bar::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -5%;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: rgba(139, 92, 246, 0.05);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.title-bar-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title-bar-left {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.title-badge {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
color: #7c3aed;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.title-bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.title-actions button {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.title-actions button.primary {
|
||||
background: #7c3aed;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.title-actions button.primary:hover {
|
||||
background: #6d28d9;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.25);
|
||||
}
|
||||
|
||||
.title-actions button.secondary {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
color: #7c3aed;
|
||||
border: 1px solid rgba(139, 92, 246, 0.2);
|
||||
}
|
||||
|
||||
.title-actions button.secondary:hover {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
border: 1px solid rgba(139, 92, 246, 0.2);
|
||||
color: #7c3aed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.toggle-button:hover {
|
||||
background: rgba(139, 92, 246, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 扩展内容区域 */
|
||||
.title-bar-expanded-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(139, 92, 246, 0.1);
|
||||
animation: expandContent 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes expandContent {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式适配 */
|
||||
@media (max-width: 768px) {
|
||||
.title-bar-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.title-bar-right {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.title-actions {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.title-actions button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { useState } from 'react'
|
||||
import { UpOutlined, DownOutlined } from '@ant-design/icons'
|
||||
import './PageTitleBar.css'
|
||||
|
||||
function PageTitleBar({
|
||||
title,
|
||||
badge,
|
||||
description,
|
||||
actions,
|
||||
showToggle = false,
|
||||
onToggle,
|
||||
defaultExpanded = false,
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded)
|
||||
|
||||
const handleToggle = () => {
|
||||
const newExpanded = !expanded
|
||||
setExpanded(newExpanded)
|
||||
if (onToggle) {
|
||||
onToggle(newExpanded)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-title-bar">
|
||||
<div className="title-bar-content">
|
||||
<div className="title-bar-left">
|
||||
<div className="title-row">
|
||||
<div className="title-group">
|
||||
<h1 className="page-title">{title}</h1>
|
||||
{badge && <span className="title-badge">{badge}</span>}
|
||||
</div>
|
||||
{description && <p className="page-description">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="title-bar-right">
|
||||
{actions && <div className="title-actions">{actions}</div>}
|
||||
{showToggle && (
|
||||
<button
|
||||
className="toggle-button"
|
||||
onClick={handleToggle}
|
||||
title={expanded ? '收起信息面板' : '展开信息面板'}
|
||||
>
|
||||
{expanded ? <UpOutlined /> : <DownOutlined />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageTitleBar
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { Navigate } from 'react-router-dom'
|
||||
import useUserStore from '@/stores/userStore'
|
||||
|
||||
function ProtectedRoute({ children }) {
|
||||
const token = localStorage.getItem('access_token')
|
||||
|
||||
if (!token) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
export default ProtectedRoute
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
.section-card {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--app-border-color, #e6e6e6);
|
||||
border-radius: 4px;
|
||||
background-color: var(--app-surface-color, #fff);
|
||||
background-image: url("../../../assets/home/mask.png");
|
||||
background-position: right top;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.section-card--auto {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.section-card__header {
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section-card__title-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.section-card__title {
|
||||
margin: 0;
|
||||
padding-bottom: 8px;
|
||||
color: var(--app-text-main, #333);
|
||||
font-family: "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
letter-spacing: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-card__title::before {
|
||||
content: "";
|
||||
flex: 0 0 auto;
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
border-radius: 1px;
|
||||
background: var(--app-primary-color, #3c70f5);
|
||||
}
|
||||
|
||||
.section-card__description {
|
||||
padding: 0 0 16px 12px;
|
||||
color: var(--app-text-secondary, #9095a1);
|
||||
font-family: "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
letter-spacing: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.section-card__extra {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section-card__tabs {
|
||||
z-index: 1;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.section-card__tabs:has(+ .section-card__content .data-list-panel) {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.section-card__tabs > .ant-tabs {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.section-card__tabs > .ant-tabs > .ant-tabs-nav {
|
||||
margin: 0 !important;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.section-card__tabs > .ant-tabs > .ant-tabs-nav::before {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.section-card__tabs .ant-tabs-nav-list {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.section-card__tabs .ant-tabs-content-holder,
|
||||
.section-card__tabs .ant-tabs-ink-bar {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.section-card__tabs .ant-tabs-tab {
|
||||
margin-left: 0 !important;
|
||||
padding: 10px 16px !important;
|
||||
border: 0 solid transparent !important;
|
||||
border-radius: 0 !important;
|
||||
background-color: rgba(249, 250, 254, 0) !important;
|
||||
transition: background-color 0.16s ease !important;
|
||||
}
|
||||
|
||||
.section-card__tabs .ant-tabs-tab:hover {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.section-card__tabs .ant-tabs-tab.ant-tabs-tab-active,
|
||||
.section-card__tabs .ant-tabs-tab.ant-tabs-tab-active:hover,
|
||||
.section-card__tabs .ant-tabs-tab.ant-tabs-tab-active:focus,
|
||||
.section-card__tabs .ant-tabs-tab.ant-tabs-tab-active:active {
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
background-color: var(--app-bg-surface-soft, #e9eef8) !important;
|
||||
}
|
||||
|
||||
.section-card__tabs .ant-tabs-tab-btn {
|
||||
color: var(--app-text-main, #333);
|
||||
font-family: "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
letter-spacing: 0;
|
||||
transition: color 0.16s ease !important;
|
||||
}
|
||||
|
||||
.section-card__tabs .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn {
|
||||
color: var(--app-primary-color, #1677ff) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-card__content {
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--app-bg-surface-soft, #f9fafe);
|
||||
}
|
||||
|
||||
.section-card--auto .section-card__content {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.section-card {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.section-card__header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-card__extra {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import type { CSSProperties, ReactNode } from "react";
|
||||
import "./SectionCard.css";
|
||||
|
||||
interface SectionCardProps {
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
extra?: ReactNode;
|
||||
tabs?: ReactNode;
|
||||
children: ReactNode;
|
||||
layout?: "fixed" | "auto";
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export default function SectionCard({
|
||||
title,
|
||||
description,
|
||||
extra,
|
||||
tabs,
|
||||
children,
|
||||
layout = "fixed",
|
||||
className = "",
|
||||
contentClassName = "",
|
||||
style,
|
||||
}: SectionCardProps) {
|
||||
const hasHeader = Boolean(title) || Boolean(description) || Boolean(extra);
|
||||
const classes = ["section-card", `section-card--${layout}`, className].filter(Boolean).join(" ");
|
||||
const contentClasses = ["section-card__content", contentClassName].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<section className={classes} style={style}>
|
||||
{hasHeader ? (
|
||||
<div className="section-card__header">
|
||||
<div className="section-card__title-wrap">
|
||||
{title ? <h2 className="section-card__title">{title}</h2> : null}
|
||||
{description ? <div className="section-card__description">{description}</div> : null}
|
||||
</div>
|
||||
{extra ? <div className="section-card__extra">{extra}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{tabs ? <div className="section-card__tabs">{tabs}</div> : null}
|
||||
<div className={contentClasses}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
.selection-alert-container {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.selection-alert-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.selection-alert-content span {
|
||||
flex: 1;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.selection-alert-content strong {
|
||||
color: #1677ff;
|
||||
font-weight: 600;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.selection-alert-content a {
|
||||
color: #1677ff;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.selection-alert-content a:hover {
|
||||
background: rgba(22, 119, 255, 0.08);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 响应式处理 */
|
||||
@media (max-width: 768px) {
|
||||
.selection-alert-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.selection-alert-content a {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { Alert } from 'antd'
|
||||
import './SelectionAlert.css'
|
||||
|
||||
/**
|
||||
* 全选提示条组件
|
||||
* @param {Object} props
|
||||
* @param {number} props.currentPageCount - 当前页选中数量
|
||||
* @param {number} props.totalCount - 总数据量
|
||||
* @param {boolean} props.isAllPagesSelected - 是否已选择所有页
|
||||
* @param {Function} props.onSelectAllPages - 选择所有页的回调
|
||||
* @param {Function} props.onClearSelection - 清除选择的回调
|
||||
*/
|
||||
function SelectionAlert({
|
||||
currentPageCount,
|
||||
totalCount,
|
||||
isAllPagesSelected,
|
||||
onSelectAllPages,
|
||||
onClearSelection,
|
||||
}) {
|
||||
// 如果没有选中任何项,不显示
|
||||
if (currentPageCount === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 如果已选择所有页
|
||||
if (isAllPagesSelected) {
|
||||
return (
|
||||
<div className="selection-alert-container">
|
||||
<Alert
|
||||
message={
|
||||
<div className="selection-alert-content">
|
||||
<span>
|
||||
已选择全部 <strong>{totalCount}</strong> 条数据
|
||||
</span>
|
||||
<a onClick={onClearSelection}>清除选择</a>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
closable={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 如果只选择了当前页,且总数大于当前页
|
||||
if (currentPageCount > 0 && totalCount > currentPageCount) {
|
||||
return (
|
||||
<div className="selection-alert-container">
|
||||
<Alert
|
||||
message={
|
||||
<div className="selection-alert-content">
|
||||
<span>
|
||||
已选择当前页 <strong>{currentPageCount}</strong> 条数据
|
||||
</span>
|
||||
<a onClick={onSelectAllPages}>
|
||||
选择全部 {totalCount} 条数据
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
type="warning"
|
||||
showIcon
|
||||
closable={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 只选择了部分数据,且总数等于当前页(单页情况)
|
||||
return (
|
||||
<div className="selection-alert-container">
|
||||
<Alert
|
||||
message={
|
||||
<div className="selection-alert-content">
|
||||
<span>
|
||||
已选择 <strong>{currentPageCount}</strong> 条数据
|
||||
</span>
|
||||
<a onClick={onClearSelection}>清除选择</a>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
closable={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectionAlert
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
/* 侧边信息面板容器 */
|
||||
.side-info-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 信息区块 */
|
||||
.side-info-section {
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.side-info-section:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 区块头部 */
|
||||
.side-info-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%);
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.side-info-section-header:hover {
|
||||
background: linear-gradient(135deg, #f0f4ff 0%, #e8f0ff 100%);
|
||||
}
|
||||
|
||||
.side-info-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.side-info-section-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.side-info-section-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #8c8c8c;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.side-info-section-toggle:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
/* 区块内容 */
|
||||
.side-info-section-content {
|
||||
padding: 16px 20px;
|
||||
animation: expandContent 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes expandContent {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { useState } from 'react'
|
||||
import { UpOutlined, DownOutlined } from '@ant-design/icons'
|
||||
import './SideInfoPanel.css'
|
||||
|
||||
/**
|
||||
* 侧边信息面板组件
|
||||
* @param {Object} props
|
||||
* @param {Array} props.sections - 信息区块配置数组
|
||||
* @param {string} props.className - 自定义类名
|
||||
*/
|
||||
function SideInfoPanel({ sections = [], className = '' }) {
|
||||
const [collapsedSections, setCollapsedSections] = useState(() => {
|
||||
const initial = {}
|
||||
sections.forEach((section) => {
|
||||
if (section.defaultCollapsed) {
|
||||
initial[section.key] = true
|
||||
}
|
||||
})
|
||||
return initial
|
||||
})
|
||||
|
||||
const toggleSection = (key) => {
|
||||
setCollapsedSections((prev) => ({
|
||||
...prev,
|
||||
[key]: !prev[key],
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`side-info-panel ${className}`}>
|
||||
{sections.map((section) => {
|
||||
const isCollapsed = collapsedSections[section.key]
|
||||
|
||||
return (
|
||||
<div key={section.key} className="side-info-section">
|
||||
{/* 区块头部 */}
|
||||
<div className="side-info-section-header" onClick={() => toggleSection(section.key)}>
|
||||
<div className="side-info-section-title">
|
||||
{section.icon && <span className="side-info-section-icon">{section.icon}</span>}
|
||||
<span>{section.title}</span>
|
||||
</div>
|
||||
<button className="side-info-section-toggle" type="button">
|
||||
{isCollapsed ? <DownOutlined /> : <UpOutlined />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 区块内容 */}
|
||||
{!isCollapsed && (
|
||||
<div className="side-info-section-content">{section.content}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SideInfoPanel
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
/* 分栏布局容器 */
|
||||
.split-layout {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* 横向布局(左右分栏) */
|
||||
.split-layout-horizontal {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
/* 纵向布局(上下分栏) */
|
||||
.split-layout-vertical {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.split-layout-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 扩展信息区 */
|
||||
.split-layout-extend {
|
||||
flex-shrink: 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
/* 右侧扩展区(横向布局) */
|
||||
.split-layout-extend-right {
|
||||
height: 693px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
/* 顶部扩展区(纵向布局) */
|
||||
.split-layout-extend-top {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 滚动条样式(横向布局右侧扩展区) */
|
||||
.split-layout-extend-right::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.split-layout-extend-right::-webkit-scrollbar-track {
|
||||
background: #f5f5f5;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.split-layout-extend-right::-webkit-scrollbar-thumb {
|
||||
background: #d9d9d9;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.split-layout-extend-right::-webkit-scrollbar-thumb:hover {
|
||||
background: #bfbfbf;
|
||||
}
|
||||
|
||||
/* 响应式:小屏幕时隐藏右侧扩展区 */
|
||||
@media (max-width: 1200px) {
|
||||
.split-layout-extend-right {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import './SplitLayout.css'
|
||||
|
||||
/**
|
||||
* 主内容区布局组件
|
||||
* @param {Object} props
|
||||
* @param {string} props.direction - 布局方向:'horizontal'(左右)| 'vertical'(上下)
|
||||
* @param {ReactNode} props.mainContent - 主内容区
|
||||
* @param {ReactNode} props.extendContent - 扩展内容区
|
||||
* @param {number} props.extendSize - 扩展区尺寸(horizontal 模式下为宽度,px)
|
||||
* @param {number} props.gap - 主内容与扩展区间距(px)
|
||||
* @param {boolean} props.showExtend - 是否显示扩展区
|
||||
* @param {string} props.extendPosition - 扩展区位置(horizontal: 'right', vertical: 'top')
|
||||
* @param {string} props.className - 自定义类名
|
||||
*
|
||||
* @deprecated 旧参数(向后兼容):leftContent, rightContent, rightWidth, showRight
|
||||
*/
|
||||
function SplitLayout({
|
||||
// 新 API
|
||||
direction = 'horizontal',
|
||||
mainContent,
|
||||
extendContent,
|
||||
extendSize = 360,
|
||||
gap = 16,
|
||||
showExtend = true,
|
||||
extendPosition,
|
||||
className = '',
|
||||
// 旧 API(向后兼容)
|
||||
leftContent,
|
||||
rightContent,
|
||||
rightWidth,
|
||||
showRight,
|
||||
}) {
|
||||
// 向后兼容:如果使用旧 API,转换为新 API
|
||||
const actualMainContent = mainContent || leftContent
|
||||
const actualExtendContent = extendContent || rightContent
|
||||
const actualExtendSize = extendSize !== 360 ? extendSize : (rightWidth || 360)
|
||||
const actualShowExtend = showExtend !== undefined ? showExtend : (showRight !== undefined ? showRight : true)
|
||||
const actualDirection = direction
|
||||
const actualExtendPosition = extendPosition || (actualDirection === 'horizontal' ? 'right' : 'top')
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`split-layout split-layout-${actualDirection} ${className}`}
|
||||
style={{ gap: `${gap}px` }}
|
||||
>
|
||||
{/* 纵向布局且扩展区在顶部时,先渲染扩展区 */}
|
||||
{actualDirection === 'vertical' && actualExtendPosition === 'top' && actualShowExtend && actualExtendContent && (
|
||||
<div className="split-layout-extend split-layout-extend-top">
|
||||
{actualExtendContent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 主内容区 */}
|
||||
<div className="split-layout-main">{actualMainContent}</div>
|
||||
|
||||
{/* 横向布局时,扩展区在右侧 */}
|
||||
{actualDirection === 'horizontal' && actualShowExtend && actualExtendContent && (
|
||||
<div
|
||||
className="split-layout-extend split-layout-extend-right"
|
||||
style={{ width: `${actualExtendSize}px` }}
|
||||
>
|
||||
{actualExtendContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SplitLayout
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
/* 统计卡片 */
|
||||
.stat-card {
|
||||
padding: 16px;
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
border-color: #d9d9d9;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* 一列布局(默认) */
|
||||
.stat-card-column {
|
||||
/* 继承默认样式 */
|
||||
}
|
||||
|
||||
/* 两列布局 */
|
||||
.stat-card.stat-card-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-card.stat-card-row .stat-card-header {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stat-card.stat-card-row .stat-card-body {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 卡片头部 */
|
||||
.stat-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stat-card-title {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-card-icon {
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 卡片内容 */
|
||||
.stat-card-body {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stat-card-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-card-suffix {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
margin-left: 4px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* 趋势指示器 */
|
||||
.stat-card-trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.stat-card-trend.trend-up {
|
||||
color: #52c41a;
|
||||
background: #f6ffed;
|
||||
}
|
||||
|
||||
.stat-card-trend.trend-down {
|
||||
color: #ff4d4f;
|
||||
background: #fff1f0;
|
||||
}
|
||||
|
||||
.stat-card-trend svg {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons'
|
||||
import './StatCard.css'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export interface StatCardProps {
|
||||
title: string
|
||||
value: number | string
|
||||
icon?: ReactNode
|
||||
color?: string
|
||||
trend?: { value: number; direction: 'up' | 'down' }
|
||||
suffix?: string
|
||||
layout?: 'column' | 'row'
|
||||
gridColumn?: string
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计卡片组件
|
||||
*/
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
icon,
|
||||
color = 'blue',
|
||||
trend,
|
||||
suffix = '',
|
||||
layout = 'column',
|
||||
gridColumn,
|
||||
className = '',
|
||||
onClick,
|
||||
style: customStyle = {},
|
||||
}: StatCardProps) {
|
||||
const colorMap: Record<string, string> = {
|
||||
blue: '#1677ff',
|
||||
green: '#52c41a',
|
||||
orange: '#faad14',
|
||||
red: '#ff4d4f',
|
||||
purple: '#722ed1',
|
||||
gray: '#8c8c8c',
|
||||
}
|
||||
|
||||
const themeColor = colorMap[color] || color
|
||||
|
||||
const style = {
|
||||
...(gridColumn ? { gridColumn } : {}),
|
||||
...customStyle,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`stat-card stat-card-${layout} ${className}`} style={style} onClick={onClick}>
|
||||
<div className="stat-card-header">
|
||||
<span className="stat-card-title">{title}</span>
|
||||
{icon && (
|
||||
<span className="stat-card-icon" style={{ color: themeColor }} aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="stat-card-body">
|
||||
<div className="stat-card-value tabular-nums" style={{ color: themeColor }}>
|
||||
{value}
|
||||
{suffix && <span className="stat-card-suffix">{suffix}</span>}
|
||||
</div>
|
||||
|
||||
{trend && (
|
||||
<div
|
||||
className={`stat-card-trend ${trend.direction === 'up' ? 'trend-up' : 'trend-down'} tabular-nums`}
|
||||
aria-label={`${trend.direction === 'up' ? 'Increase' : 'Decrease'} of ${trend.value}%`}
|
||||
>
|
||||
{trend.direction === 'up' ? <ArrowUpOutlined aria-hidden="true" /> : <ArrowDownOutlined aria-hidden="true" />}
|
||||
<span>{Math.abs(trend.value)}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatCard
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import { notification } from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
InfoCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
notification.config({
|
||||
placement: "topRight",
|
||||
top: 24,
|
||||
duration: 3,
|
||||
maxCount: 3,
|
||||
});
|
||||
|
||||
const Toast = {
|
||||
success: (message: string, description = "", duration = 3) => {
|
||||
notification.success({
|
||||
message,
|
||||
description,
|
||||
duration,
|
||||
icon: <CheckCircleOutlined style={{ color: "#52c41a" }} />,
|
||||
style: {
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
error: (message: string, description = "", duration = 3) => {
|
||||
notification.error({
|
||||
message,
|
||||
description,
|
||||
duration,
|
||||
icon: <CloseCircleOutlined style={{ color: "#ff4d4f" }} />,
|
||||
style: {
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
warning: (message: string, description = "", duration = 3) => {
|
||||
notification.warning({
|
||||
message,
|
||||
description,
|
||||
duration,
|
||||
icon: <ExclamationCircleOutlined style={{ color: "#faad14" }} />,
|
||||
style: {
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
info: (message: string, description = "", duration = 3) => {
|
||||
notification.info({
|
||||
message,
|
||||
description,
|
||||
duration,
|
||||
icon: <InfoCircleOutlined style={{ color: "#1677ff" }} />,
|
||||
style: {
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
custom: (config: any) => {
|
||||
notification.open({
|
||||
...config,
|
||||
style: {
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
|
||||
...config.style,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default Toast;
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
/* 树形筛选面板 */
|
||||
.tree-filter-panel {
|
||||
width: 320px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 已选择的筛选条件 */
|
||||
.tree-filter-selected {
|
||||
min-height: 40px;
|
||||
padding: 12px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 6px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
}
|
||||
|
||||
.tree-filter-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tree-filter-label {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tree-filter-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.tree-filter-placeholder span {
|
||||
color: #8c8c8c;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 树形选择器容器 */
|
||||
.tree-filter-container {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tree-filter-header {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.tree-filter-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import { Tree, Tag, Divider, Button, Space } from 'antd'
|
||||
import { useState, useEffect } from 'react'
|
||||
import './TreeFilterPanel.css'
|
||||
|
||||
/**
|
||||
* 树形筛选面板组件
|
||||
* @param {Object} props
|
||||
* @param {Array} props.treeData - 树形数据
|
||||
* @param {string} props.selectedKey - 当前选中的节点ID
|
||||
* @param {string} props.tempSelectedKey - 临时选中的节点ID(确认前)
|
||||
* @param {string} props.treeTitle - 树标题
|
||||
* @param {Function} props.onSelect - 选择变化回调
|
||||
* @param {Function} props.onConfirm - 确认筛选
|
||||
* @param {Function} props.onClear - 清除筛选
|
||||
* @param {string} props.placeholder - 占位提示文本
|
||||
*/
|
||||
function TreeFilterPanel({
|
||||
treeData,
|
||||
selectedKey,
|
||||
tempSelectedKey,
|
||||
treeTitle = '分组筛选',
|
||||
onSelect,
|
||||
onConfirm,
|
||||
onClear,
|
||||
placeholder = '请选择分组进行筛选',
|
||||
}) {
|
||||
// 获取所有节点的key用于默认展开
|
||||
const getAllKeys = (nodes) => {
|
||||
let keys = []
|
||||
const traverse = (node) => {
|
||||
keys.push(node.key)
|
||||
if (node.children) {
|
||||
node.children.forEach(traverse)
|
||||
}
|
||||
}
|
||||
nodes.forEach(traverse)
|
||||
return keys
|
||||
}
|
||||
|
||||
const [expandedKeys, setExpandedKeys] = useState([])
|
||||
|
||||
// 初始化时展开所有节点
|
||||
useEffect(() => {
|
||||
if (treeData && treeData.length > 0) {
|
||||
setExpandedKeys(getAllKeys(treeData))
|
||||
}
|
||||
}, [treeData])
|
||||
|
||||
// 查找节点名称
|
||||
const findNodeName = (nodes, id) => {
|
||||
for (const node of nodes) {
|
||||
if (node.key === id) return node.title
|
||||
if (node.children) {
|
||||
const found = findNodeName(node.children, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const handleTreeSelect = (selectedKeys) => {
|
||||
const key = selectedKeys[0] || null
|
||||
onSelect?.(key)
|
||||
}
|
||||
|
||||
const handleExpand = (keys) => {
|
||||
setExpandedKeys(keys)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tree-filter-panel">
|
||||
{/* 已选择的筛选条件 */}
|
||||
<div className="tree-filter-selected">
|
||||
{tempSelectedKey ? (
|
||||
<div className="tree-filter-tag">
|
||||
<span className="tree-filter-label">已选择分组:</span>
|
||||
<Tag color="blue" closable onClose={() => onSelect?.(null)}>
|
||||
{findNodeName(treeData, tempSelectedKey)}
|
||||
</Tag>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tree-filter-placeholder">
|
||||
<span>{placeholder}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
{/* 树形选择器 */}
|
||||
<div className="tree-filter-container">
|
||||
<div className="tree-filter-header">{treeTitle}</div>
|
||||
<Tree
|
||||
treeData={treeData}
|
||||
expandedKeys={expandedKeys}
|
||||
onExpand={handleExpand}
|
||||
onSelect={handleTreeSelect}
|
||||
selectedKeys={tempSelectedKey ? [tempSelectedKey] : []}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="tree-filter-actions">
|
||||
<Space>
|
||||
<Button size="small" onClick={onClear}>
|
||||
清除筛选
|
||||
</Button>
|
||||
<Button size="small" type="primary" onClick={onConfirm}>
|
||||
确定
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TreeFilterPanel
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { fetchDictItemsByTypeCode } from '../api/dict';
|
||||
import type { SysDictItem } from '../types';
|
||||
import { SysDictItem } from '../types';
|
||||
|
||||
const dictCache: Record<string, SysDictItem[]> = {};
|
||||
const pendingRequests: Record<string, Promise<SysDictItem[]>[]> = {};
|
||||
|
||||
export function useDict(typeCode: string) {
|
||||
const [items, setItems] = useState<SysDictItem[]>(dictCache[typeCode] || []);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@
|
|||
linear-gradient(90deg, rgba(255, 255, 255, 0.34) 1px, transparent 1px);
|
||||
--app-bg-overlay-size: 36px 36px;
|
||||
--app-bg-card: rgba(255, 255, 255, 0.74);
|
||||
--app-bg-layout: #f5f6fa;
|
||||
--app-text-main: #1f2937;
|
||||
--app-text-secondary: #66758f;
|
||||
--app-border-color: rgba(103, 126, 189, 0.12);
|
||||
|
|
@ -20,19 +19,7 @@
|
|||
--app-bg-surface: rgba(255, 255, 255, 0.68);
|
||||
--app-bg-surface-soft: rgba(255, 255, 255, 0.56);
|
||||
--app-bg-surface-strong: rgba(255, 255, 255, 0.82);
|
||||
--app-surface-color: rgba(255, 255, 255, 0.82);
|
||||
--app-text-muted: #66758f;
|
||||
--item-hover-bg: rgba(22, 119, 255, 0.08);
|
||||
--text-color-secondary: #66758f;
|
||||
--link-color: #1677ff;
|
||||
--list-table-scroll-y: 100%;
|
||||
--meeting-status-border: rgba(22, 119, 255, 0.2);
|
||||
--meeting-status-bg: rgba(22, 119, 255, 0.08);
|
||||
--meeting-status-color: #1677ff;
|
||||
--meeting-source-color: #3b82f6;
|
||||
--meeting-progress-color: #1677ff;
|
||||
--app-form-drawer-width: 600px;
|
||||
--app-form-drawer-max-width: calc(100vw - 48px);
|
||||
}
|
||||
|
||||
:root[data-theme="minimal"] {
|
||||
|
|
@ -43,7 +30,6 @@
|
|||
linear-gradient(rgba(255, 255, 255, 0.24) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.24) 1px, transparent 1px);
|
||||
--app-bg-card: rgba(255, 255, 255, 0.82);
|
||||
--app-bg-layout: #f5f6fa;
|
||||
--app-text-main: #111827;
|
||||
--app-text-secondary: #5b6474;
|
||||
--app-border-color: rgba(148, 163, 184, 0.16);
|
||||
|
|
@ -52,17 +38,7 @@
|
|||
--app-bg-surface: rgba(255, 255, 255, 0.74);
|
||||
--app-bg-surface-soft: rgba(255, 255, 255, 0.62);
|
||||
--app-bg-surface-strong: rgba(255, 255, 255, 0.86);
|
||||
--app-surface-color: rgba(255, 255, 255, 0.86);
|
||||
--app-text-muted: #5b6474;
|
||||
--item-hover-bg: rgba(22, 119, 255, 0.08);
|
||||
--text-color-secondary: #5b6474;
|
||||
--link-color: #1677ff;
|
||||
--list-table-scroll-y: 100%;
|
||||
--meeting-status-border: rgba(22, 119, 255, 0.2);
|
||||
--meeting-status-bg: rgba(22, 119, 255, 0.08);
|
||||
--meeting-status-color: #1677ff;
|
||||
--meeting-source-color: #3b82f6;
|
||||
--meeting-progress-color: #1677ff;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] {
|
||||
|
|
@ -74,7 +50,6 @@
|
|||
linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px);
|
||||
--app-bg-card: rgba(13, 23, 39, 0.62);
|
||||
--app-bg-layout: #08101c;
|
||||
--app-text-main: #e2e8f0;
|
||||
--app-text-secondary: rgba(190, 206, 229, 0.74);
|
||||
--app-border-color: rgba(88, 151, 255, 0.18);
|
||||
|
|
@ -83,17 +58,7 @@
|
|||
--app-bg-surface: rgba(10, 21, 37, 0.78);
|
||||
--app-bg-surface-soft: rgba(10, 21, 37, 0.72);
|
||||
--app-bg-surface-strong: rgba(8, 17, 31, 0.88);
|
||||
--app-surface-color: rgba(8, 17, 31, 0.88);
|
||||
--app-text-muted: rgba(190, 206, 229, 0.74);
|
||||
--item-hover-bg: rgba(88, 151, 255, 0.18);
|
||||
--text-color-secondary: rgba(190, 206, 229, 0.74);
|
||||
--link-color: #60a5fa;
|
||||
--list-table-scroll-y: 100%;
|
||||
--meeting-status-border: rgba(96, 165, 250, 0.28);
|
||||
--meeting-status-bg: rgba(96, 165, 250, 0.14);
|
||||
--meeting-status-color: #60a5fa;
|
||||
--meeting-source-color: #60a5fa;
|
||||
--meeting-progress-color: #60a5fa;
|
||||
}
|
||||
|
||||
html {
|
||||
|
|
@ -296,53 +261,16 @@ body::after {
|
|||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.app-page__content-toolbar {
|
||||
flex-shrink: 0;
|
||||
min-height: 40px;
|
||||
margin-bottom: 14px;
|
||||
.app-page__page-actions {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.app-page__content-toolbar-actions,
|
||||
.app-page__content-toolbar-filters {
|
||||
min-width: 0;
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-page__content-toolbar-actions {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.app-page__content-toolbar-filters {
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
margin: -8px 0 16px;
|
||||
}
|
||||
|
||||
.app-page__content-toolbar .ant-btn {
|
||||
height: 32px;
|
||||
border-radius: 4px !important;
|
||||
box-shadow: none;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.app-page__content-toolbar .ant-btn:not(.ant-btn-icon-only) {
|
||||
padding-inline: 14px;
|
||||
}
|
||||
|
||||
.app-page__content-toolbar .ant-input,
|
||||
.app-page__content-toolbar .ant-input-affix-wrapper,
|
||||
.app-page__content-toolbar .ant-select-selector,
|
||||
.app-page__content-toolbar .ant-picker,
|
||||
.app-page__content-toolbar .ant-input-number {
|
||||
height: 32px !important;
|
||||
border-radius: 4px !important;
|
||||
.app-page__page-actions .ant-btn {
|
||||
min-width: 96px;
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
|
||||
.app-page__toolbar .ant-input,
|
||||
|
|
@ -365,13 +293,6 @@ body::after {
|
|||
padding: 8px 4px 4px;
|
||||
}
|
||||
|
||||
.ant-drawer:has(.app-page__drawer-footer) .ant-drawer-content-wrapper,
|
||||
.ant-drawer:has(.screen-saver-drawer__footer) .ant-drawer-content-wrapper,
|
||||
.meeting-create-drawer-root .ant-drawer-content-wrapper {
|
||||
width: min(var(--app-form-drawer-width), var(--app-form-drawer-max-width)) !important;
|
||||
max-width: var(--app-form-drawer-max-width);
|
||||
}
|
||||
|
||||
.app-page__split {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
|
@ -889,11 +810,6 @@ body::after {
|
|||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:root {
|
||||
--app-form-drawer-width: 100vw;
|
||||
--app-form-drawer-max-width: 100vw;
|
||||
}
|
||||
|
||||
body::after {
|
||||
inset: 10px;
|
||||
border-radius: 18px;
|
||||
|
|
@ -913,85 +829,52 @@ body::after {
|
|||
.app-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.app-page__content-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-page__content-toolbar-actions,
|
||||
.app-page__content-toolbar-filters,
|
||||
.app-page__content-toolbar-filters .ant-space {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-page__content-toolbar-filters {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Global Pagination Style */
|
||||
.app-page__table-wrap .ant-table-wrapper,
|
||||
.orgs-table-shell .ant-table-wrapper {
|
||||
.ant-table-wrapper {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app-page__table-wrap .ant-table-wrapper .ant-spin-nested-loading,
|
||||
.orgs-table-shell .ant-table-wrapper .ant-spin-nested-loading {
|
||||
.ant-table-wrapper .ant-spin-nested-loading {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app-page__table-wrap .ant-table-wrapper .ant-spin-container,
|
||||
.orgs-table-shell .ant-table-wrapper .ant-spin-container {
|
||||
.ant-table-wrapper .ant-spin-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-page__table-wrap .ant-table-wrapper .ant-table,
|
||||
.orgs-table-shell .ant-table-wrapper .ant-table {
|
||||
.ant-table-wrapper .ant-table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-page__table-wrap .ant-table-wrapper .ant-table-container,
|
||||
.orgs-table-shell .ant-table-wrapper .ant-table-container {
|
||||
.ant-table-wrapper .ant-table-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-page__table-wrap .ant-table-wrapper .ant-table-content,
|
||||
.app-page__table-wrap .ant-table-wrapper .ant-table-body,
|
||||
.orgs-table-shell .ant-table-wrapper .ant-table-content,
|
||||
.orgs-table-shell .ant-table-wrapper .ant-table-body {
|
||||
.ant-table-wrapper .ant-table-content,
|
||||
.ant-table-wrapper .ant-table-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-page__table-wrap .ant-table-wrapper .ant-table-header,
|
||||
.orgs-table-shell .ant-table-wrapper .ant-table-header {
|
||||
.ant-table-wrapper .ant-table-header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-page__table-wrap .ant-table-wrapper .ant-table-body,
|
||||
.orgs-table-shell .ant-table-wrapper .ant-table-body {
|
||||
.ant-table-wrapper .ant-table-body {
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
.app-page__table-wrap .ant-table-tbody > tr:not(.row-selected):not(.ant-table-row-selected):not(.dict-type-row-selected):hover > td,
|
||||
.app-page__table-wrap .ant-table-tbody > tr:not(.row-selected):not(.ant-table-row-selected):not(.dict-type-row-selected) > td.ant-table-cell-row-hover,
|
||||
.app-page__panel-card .ant-table-tbody > tr:not(.row-selected):not(.ant-table-row-selected):not(.dict-type-row-selected):hover > td,
|
||||
.app-page__panel-card .ant-table-tbody > tr:not(.row-selected):not(.ant-table-row-selected):not(.dict-type-row-selected) > td.ant-table-cell-row-hover {
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
.ant-table-wrapper .ant-table-pagination.ant-pagination.app-global-pagination,
|
||||
.app-global-pagination.ant-pagination {
|
||||
margin: auto 0 0 0 !important;
|
||||
|
|
@ -1017,44 +900,11 @@ body::after {
|
|||
.app-global-pagination.ant-pagination .ant-pagination-options,
|
||||
.ant-table-wrapper .ant-table-pagination.ant-pagination.app-global-pagination .ant-pagination-options {
|
||||
margin-inline-start: 12px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-global-pagination.ant-pagination .ant-pagination-item-active,
|
||||
.app-global-pagination.ant-pagination .ant-pagination-item-1.ant-pagination-item-disabled,
|
||||
.ant-table-wrapper .ant-table-pagination.ant-pagination.app-global-pagination .ant-pagination-item-active,
|
||||
.ant-table-wrapper .ant-table-pagination.ant-pagination.app-global-pagination .ant-pagination-item-1.ant-pagination-item-disabled {
|
||||
color: #1677ff !important;
|
||||
background: #fff !important;
|
||||
border: 1px solid #1677ff !important;
|
||||
}
|
||||
|
||||
.app-global-pagination.ant-pagination .ant-pagination-item-active a,
|
||||
.app-global-pagination.ant-pagination .ant-pagination-item-1.ant-pagination-item-disabled a,
|
||||
.ant-table-wrapper .ant-table-pagination.ant-pagination.app-global-pagination .ant-pagination-item-active a,
|
||||
.ant-table-wrapper .ant-table-pagination.ant-pagination.app-global-pagination .ant-pagination-item-1.ant-pagination-item-disabled a {
|
||||
color: #1677ff !important;
|
||||
background: transparent !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.app-global-pagination.ant-pagination .ant-pagination-options-quick-jumper,
|
||||
.ant-table-wrapper .ant-table-pagination.ant-pagination.app-global-pagination .ant-pagination-options-quick-jumper {
|
||||
margin-inline-start: 12px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 32px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-global-pagination.ant-pagination .ant-pagination-options-quick-jumper input,
|
||||
.ant-table-wrapper .ant-table-pagination.ant-pagination.app-global-pagination .ant-pagination-options-quick-jumper input {
|
||||
height: 32px;
|
||||
line-height: 30px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.app-global-pagination.ant-pagination .ant-select-selection-search-input,
|
||||
|
|
@ -1322,119 +1172,3 @@ body::after {
|
|||
0% { opacity: 0.4; }
|
||||
100% { opacity: 1; text-shadow: 0 0 8px rgba(22, 119, 255, 0.4); }
|
||||
}
|
||||
|
||||
/* web-fe baseline overrides */
|
||||
:root,
|
||||
:root[data-theme="minimal"],
|
||||
:root[data-theme="tech"] {
|
||||
--app-bg-main: #f5f6fa;
|
||||
--app-bg-overlay: none;
|
||||
--app-bg-card: #fff;
|
||||
--app-bg-layout: #f5f6fa;
|
||||
--app-text-main: #333333;
|
||||
--app-text-secondary: #9095a1;
|
||||
--app-border-color: #e6e6e6;
|
||||
--app-shadow: none;
|
||||
--app-bg-page: #f5f6fa;
|
||||
--app-bg-surface: #fff;
|
||||
--app-bg-surface-soft: #fafafa;
|
||||
--app-bg-surface-strong: #fff;
|
||||
--app-surface-color: #fff;
|
||||
--app-text-muted: #9095a1;
|
||||
--text-color-secondary: #9095a1;
|
||||
--link-color: #1677ff;
|
||||
--list-table-scroll-y: 100%;
|
||||
--meeting-status-border: rgba(22, 119, 255, 0.18);
|
||||
--meeting-status-bg: rgba(22, 119, 255, 0.08);
|
||||
--meeting-status-color: #1677ff;
|
||||
--meeting-source-color: #3b82f6;
|
||||
--meeting-progress-color: #1677ff;
|
||||
--app-form-drawer-width: 600px;
|
||||
--app-form-drawer-max-width: calc(100vw - 48px);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Microsoft YaHei UI", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
|
||||
}
|
||||
|
||||
body::before,
|
||||
body::after,
|
||||
#root::before,
|
||||
#root::after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ant-layout-sider {
|
||||
background: #fff !important;
|
||||
border-right: 1px solid #f5f6fa;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.route-page-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 320px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ant-menu-light {
|
||||
background: transparent !important;
|
||||
color: #333 !important;
|
||||
}
|
||||
|
||||
.ant-menu-light .ant-menu-item a {
|
||||
color: #333 !important;
|
||||
}
|
||||
|
||||
.app-page__filter-card,
|
||||
.app-page__content-card,
|
||||
.app-page__panel-card {
|
||||
border: none;
|
||||
border-radius: 4px !important;
|
||||
box-shadow: none;
|
||||
background: #fff;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.app-page__filter-card {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.app-page__toolbar .ant-input,
|
||||
.app-page__toolbar .ant-input-affix-wrapper,
|
||||
.app-page__toolbar .ant-select-selector,
|
||||
.app-page__toolbar .ant-picker,
|
||||
.app-page__toolbar .ant-input-number,
|
||||
.app-page__toolbar .ant-btn,
|
||||
.ant-btn,
|
||||
.ant-input,
|
||||
.ant-input-affix-wrapper,
|
||||
.ant-select-selector,
|
||||
.ant-picker {
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
.app-page__empty-state {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.ant-table-wrapper .ant-table-pagination.ant-pagination.app-global-pagination,
|
||||
.app-global-pagination.ant-pagination {
|
||||
padding: 8px 0 0;
|
||||
background: #fff;
|
||||
border-top: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,271 +0,0 @@
|
|||
.main-layout {
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.main-shell {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
height: 64px;
|
||||
padding: 0 24px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.app-header-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-header-logo img {
|
||||
height: 36px;
|
||||
width: auto;
|
||||
max-width: 160px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-header-logo-title {
|
||||
color: #1f2a37;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.main-sider {
|
||||
position: relative;
|
||||
height: calc(100vh - 64px);
|
||||
overflow: visible;
|
||||
border-right: 1px solid #f5f6fa;
|
||||
border-top: 2px solid #f5f6fa;
|
||||
background: #fff !important;
|
||||
transition:
|
||||
flex-basis 0.24s ease,
|
||||
max-width 0.24s ease,
|
||||
min-width 0.24s ease,
|
||||
width 0.24s ease;
|
||||
will-change: width;
|
||||
}
|
||||
|
||||
.main-sider .ant-layout-sider-children {
|
||||
height: calc(100vh - 64px);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.main-sider .ant-layout-sider-children::-webkit-scrollbar,
|
||||
.main-menu::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.main-sider .ant-layout-sider-children::-webkit-scrollbar-track,
|
||||
.main-menu::-webkit-scrollbar-track {
|
||||
background: #f8f8f8 !important;
|
||||
}
|
||||
|
||||
.main-sider .ant-layout-sider-children::-webkit-scrollbar-thumb,
|
||||
.main-menu::-webkit-scrollbar-thumb {
|
||||
background: #f8f8f8 !important;
|
||||
border-radius: 3px !important;
|
||||
}
|
||||
|
||||
.main-sider .ant-layout-sider-children::-webkit-scrollbar-thumb:hover,
|
||||
.main-menu::-webkit-scrollbar-thumb:hover {
|
||||
background: #e0e0e0 !important;
|
||||
}
|
||||
|
||||
.main-sider .ant-layout-sider-trigger {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main-sider .ant-layout-sider-zero-width-trigger {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main-sider .ant-menu {
|
||||
border-inline-end: none !important;
|
||||
transition:
|
||||
width 0.24s ease,
|
||||
min-width 0.24s ease,
|
||||
max-width 0.24s ease;
|
||||
}
|
||||
|
||||
.main-sider .ant-menu-title-content,
|
||||
.main-sider .ant-menu-submenu-arrow {
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
transform 0.18s ease,
|
||||
color 0.18s ease;
|
||||
}
|
||||
|
||||
.main-sider--collapsed .ant-menu-title-content,
|
||||
.main-sider--collapsed .ant-menu-submenu-arrow {
|
||||
opacity: 0;
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
|
||||
.main-sider .ant-menu-item,
|
||||
.main-sider .ant-menu-submenu-title {
|
||||
width: calc(100% - 8px);
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
margin-inline: 4px;
|
||||
margin-block: 4px;
|
||||
border-radius: 8px;
|
||||
transition:
|
||||
background-color 0.18s ease,
|
||||
color 0.18s ease,
|
||||
padding 0.24s ease,
|
||||
margin 0.24s ease;
|
||||
}
|
||||
|
||||
.main-sider--collapsed .ant-menu-item,
|
||||
.main-sider--collapsed .ant-menu-submenu-title {
|
||||
width: 56px;
|
||||
margin-inline: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.main-sider--collapsed .ant-menu-item .ant-menu-item-icon,
|
||||
.main-sider--collapsed .ant-menu-submenu-title .ant-menu-item-icon {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.main-sider--collapsed .ant-menu-inline-collapsed > .ant-menu-item,
|
||||
.main-sider--collapsed .ant-menu-inline-collapsed > .ant-menu-submenu > .ant-menu-submenu-title {
|
||||
padding-inline: 20px !important;
|
||||
}
|
||||
|
||||
.main-sider--collapsed .ant-menu-inline-collapsed > .ant-menu-item .ant-menu-item-icon,
|
||||
.main-sider--collapsed .ant-menu-inline-collapsed > .ant-menu-submenu > .ant-menu-submenu-title .ant-menu-item-icon {
|
||||
line-height: 44px;
|
||||
}
|
||||
|
||||
.main-sider--collapsed .ant-menu-inline-collapsed {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.main-sider--collapsed .main-menu {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.main-sider .collapsedCenter {
|
||||
position: absolute;
|
||||
right: -17px;
|
||||
top: 18px;
|
||||
cursor: pointer;
|
||||
z-index: 100;
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
border-radius: 50%;
|
||||
text-align: center;
|
||||
line-height: 35px;
|
||||
border: 1px solid #f0f0f0;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05);
|
||||
background: #fff;
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
transform 0.24s ease;
|
||||
}
|
||||
|
||||
.main-sider .collapsedCenter:hover {
|
||||
border-color: rgba(24, 144, 255, 0.4);
|
||||
box-shadow: 0 4px 8px rgba(24, 144, 255, 0.4);
|
||||
}
|
||||
|
||||
.main-sider--collapsed .collapsedCenter {
|
||||
transform: translateX(1px);
|
||||
}
|
||||
|
||||
.main-sider .collapsedCenter .trigger {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
color: #666;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
transition:
|
||||
color 0.18s ease,
|
||||
background-color 0.18s ease,
|
||||
transform 0.24s ease;
|
||||
}
|
||||
|
||||
.main-sider .collapsedCenter .trigger.ant-btn {
|
||||
border-radius: 50% !important;
|
||||
}
|
||||
|
||||
.main-sider .collapsedCenter .trigger .anticon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.main-sider .collapsedCenter .trigger .anticon svg {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.main-sider--collapsed .collapsedCenter .trigger .anticon svg {
|
||||
transform: translateX(1px);
|
||||
}
|
||||
|
||||
.main-sider .collapsedCenter .trigger:hover {
|
||||
color: #1890ff;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.main-menu {
|
||||
height: calc(100% - 10px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
border-inline-end: none !important;
|
||||
padding-top: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.main-content-layout {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
position: relative;
|
||||
height: calc(100vh - 64px);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import * as AntIcons from "@ant-design/icons";
|
||||
import {
|
||||
BellOutlined,
|
||||
ApartmentOutlined,
|
||||
|
|
@ -5,6 +6,7 @@ import {
|
|||
DashboardOutlined,
|
||||
DesktopOutlined,
|
||||
GlobalOutlined,
|
||||
LayoutOutlined,
|
||||
LogoutOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
|
|
@ -25,21 +27,11 @@ import { useAuth } from "@/hooks/useAuth";
|
|||
import { usePermission } from "@/hooks/usePermission";
|
||||
import type { PlatformRuntime, SysPermission, SysPlatformConfig } from "@/types";
|
||||
import ThemeSelector from "@/components/ThemeSelector/ThemeSelector";
|
||||
import "./AppLayout.css";
|
||||
import { useThemeStore } from "@/store/themeStore";
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
const { Header, Sider, Content, Footer } = Layout;
|
||||
|
||||
const iconMap: Record<string, ReactNode> = {
|
||||
ApartmentOutlined: <ApartmentOutlined />,
|
||||
BookOutlined: <BookOutlined />,
|
||||
DashboardOutlined: <DashboardOutlined />,
|
||||
DesktopOutlined: <DesktopOutlined />,
|
||||
SafetyCertificateOutlined: <SafetyCertificateOutlined />,
|
||||
SettingOutlined: <SettingOutlined />,
|
||||
ShopOutlined: <ShopOutlined />,
|
||||
TeamOutlined: <TeamOutlined />,
|
||||
UserOutlined: <UserOutlined />,
|
||||
VideoCameraOutlined: <VideoCameraOutlined />,
|
||||
dashboard: <DashboardOutlined />,
|
||||
meeting: <VideoCameraOutlined />,
|
||||
user: <UserOutlined />,
|
||||
|
|
@ -55,7 +47,9 @@ const iconMap: Record<string, ReactNode> = {
|
|||
function resolveMenuIcon(icon?: string): ReactNode {
|
||||
if (!icon) return <SettingOutlined />;
|
||||
const aliasIcon = iconMap[icon];
|
||||
return aliasIcon ?? <SettingOutlined />;
|
||||
if (aliasIcon) return aliasIcon;
|
||||
const IconComponent = (AntIcons as unknown as Record<string, React.ComponentType>)[icon];
|
||||
return IconComponent ? <IconComponent /> : <SettingOutlined />;
|
||||
}
|
||||
|
||||
type PermissionMenuNode = SysPermission & {
|
||||
|
|
@ -81,7 +75,7 @@ function findActiveMenu(nodes: PermissionMenuNode[], path: string, parentKeys: s
|
|||
for (const node of nodes) {
|
||||
const key = getMenuKey(node);
|
||||
|
||||
if (node.path === path || (node.path && node.path !== "/" && path.startsWith(`${node.path}/`))) {
|
||||
if (node.path === path) {
|
||||
return { key, parentKeys };
|
||||
}
|
||||
|
||||
|
|
@ -135,6 +129,7 @@ export default function AppLayout() {
|
|||
const navigate = useNavigate();
|
||||
const { logout } = useAuth();
|
||||
const { load: loadPermissions } = usePermission();
|
||||
const { layoutMode } = useThemeStore();
|
||||
|
||||
const fetchInitialData = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -329,6 +324,16 @@ export default function AppLayout() {
|
|||
}
|
||||
];
|
||||
|
||||
let profile: { isPlatformAdmin?: boolean } = {};
|
||||
try {
|
||||
const stored = sessionStorage.getItem("userProfile");
|
||||
if (stored) {
|
||||
profile = JSON.parse(stored) || {};
|
||||
}
|
||||
} catch {
|
||||
profile = {};
|
||||
}
|
||||
|
||||
items.push({ type: "divider", key: "divider" });
|
||||
items.push({
|
||||
key: "logout",
|
||||
|
|
@ -375,56 +380,155 @@ export default function AppLayout() {
|
|||
</Space>
|
||||
);
|
||||
|
||||
const renderLogo = () => (
|
||||
<div className="app-header-logo">
|
||||
<img src={platformConfig?.logoUrl || "/logo.svg"} alt={platformConfig?.projectName || "logo"} />
|
||||
<span className="app-header-logo-title">{platformConfig?.projectName || "智听云"}</span>
|
||||
const renderLogo = (isTop: boolean = false) => (
|
||||
<div
|
||||
style={{
|
||||
height: 64,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: isTop ? "0 24px 0 0" : "0 16px",
|
||||
gap: "12px",
|
||||
borderBottom: isTop ? "none" : "1px solid var(--app-border-color)",
|
||||
flexShrink: 0
|
||||
}}
|
||||
>
|
||||
<img src={platformConfig?.logoUrl || "/logo.svg"} alt="logo" style={{ width: 32, height: 32, objectFit: "contain" }} />
|
||||
{(!collapsed || isTop) && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "18px",
|
||||
fontWeight: 700,
|
||||
color: "var(--app-primary-color)",
|
||||
letterSpacing: "0.5px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap"
|
||||
}}
|
||||
>
|
||||
{platformConfig?.projectName || "UnisBase"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout className="main-layout">
|
||||
<Header className="main-header">
|
||||
{renderLogo()}
|
||||
<div className="header-right">{headerRightTools}</div>
|
||||
</Header>
|
||||
|
||||
<Layout className="main-shell">
|
||||
<Layout style={{ height: "100vh", overflow: "hidden" }}>
|
||||
{layoutMode === 'side' && (
|
||||
<Sider
|
||||
trigger={null}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
className={`main-sider app-sider ${collapsed ? "main-sider--collapsed" : ""}`}
|
||||
theme="light"
|
||||
width={180}
|
||||
className="app-sider"
|
||||
style={{ boxShadow: "var(--app-shadow)", zIndex: 10 }}
|
||||
>
|
||||
<div className="collapsedCenter">
|
||||
{renderLogo(false)}
|
||||
<div style={{ flex: 1, overflowY: "auto", overflowX: "hidden" }}>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={selectedMenuKeys}
|
||||
openKeys={openKeys}
|
||||
onOpenChange={setOpenKeys}
|
||||
items={menuItems}
|
||||
style={{ borderRight: 0, marginTop: 16 }}
|
||||
/>
|
||||
</div>
|
||||
</Sider>
|
||||
)}
|
||||
|
||||
<Layout style={{ flex: 1, minWidth: 0 }}>
|
||||
<Header
|
||||
style={{
|
||||
background: "var(--app-bg-card)",
|
||||
padding: "0 24px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
boxShadow: "var(--app-shadow)",
|
||||
zIndex: 9,
|
||||
height: 64,
|
||||
flexShrink: 0,
|
||||
width: "100%"
|
||||
}}
|
||||
>
|
||||
{layoutMode === 'side' ? (
|
||||
<Button
|
||||
type="text"
|
||||
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
shape="circle"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="trigger"
|
||||
aria-label={collapsed ? "展开菜单" : "收起菜单"}
|
||||
style={{ fontSize: "16px", width: 64, height: 64, color: "var(--app-text-main)", marginLeft: -24 }}
|
||||
/>
|
||||
) : (
|
||||
renderLogo(true)
|
||||
)}
|
||||
|
||||
{layoutMode === 'top' ? (
|
||||
<div style={{ flex: 1, minWidth: 0, padding: "0 24px" }}>
|
||||
<Menu
|
||||
mode="horizontal"
|
||||
selectedKeys={selectedMenuKeys}
|
||||
items={menuItems}
|
||||
style={{
|
||||
borderBottom: 0,
|
||||
lineHeight: '62px',
|
||||
background: 'transparent',
|
||||
color: 'var(--app-text-main)'
|
||||
}}
|
||||
theme={document.documentElement.getAttribute('data-theme') === 'tech' ? 'dark' : 'light'}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ flex: 1, display: "flex", alignItems: "center", paddingLeft: 12 }} />
|
||||
)}
|
||||
|
||||
{headerRightTools}
|
||||
</Header>
|
||||
|
||||
<Content
|
||||
style={{
|
||||
margin: "24px 24px 12px",
|
||||
padding: 24,
|
||||
background: "var(--app-bg-card)",
|
||||
borderRadius: "8px",
|
||||
boxShadow: "var(--app-shadow)",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flex: 1,
|
||||
minHeight: 0
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</Content>
|
||||
|
||||
<Footer
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
padding: "0 24px 16px",
|
||||
background: "transparent"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
minHeight: 44,
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--app-border-color)",
|
||||
background: "var(--app-bg-card)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 16,
|
||||
padding: "10px 16px",
|
||||
color: "var(--app-text-main)",
|
||||
fontSize: 13,
|
||||
textAlign: "center",
|
||||
backdropFilter: "blur(10px)"
|
||||
}}
|
||||
>
|
||||
{platformConfig?.icpInfo ? <span>{platformConfig.icpInfo}</span> : null}
|
||||
{platformConfig?.icpInfo && platformConfig?.copyrightInfo ? <span aria-hidden="true" style={{ color: "#d0d5dd" }}>|</span> : null}
|
||||
{platformConfig?.copyrightInfo ? <span>{platformConfig.copyrightInfo}</span> : null}
|
||||
</div>
|
||||
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={selectedMenuKeys}
|
||||
openKeys={collapsed ? [] : openKeys}
|
||||
onOpenChange={setOpenKeys}
|
||||
inlineCollapsed={collapsed}
|
||||
items={menuItems}
|
||||
className="main-menu"
|
||||
/>
|
||||
</Sider>
|
||||
|
||||
<Layout className="main-content-layout">
|
||||
<Content className="main-content">
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Footer>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,15 +1,7 @@
|
|||
.permissions-page {
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.permissions-page > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.permissions-content-card {
|
||||
|
|
@ -17,9 +9,9 @@
|
|||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 4px !important;
|
||||
border: 1px solid #e6e6e6 !important;
|
||||
box-shadow: none !important;
|
||||
border-radius: 18px !important;
|
||||
border: 1px solid rgba(226, 232, 240, 0.8) !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04) !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
|
@ -81,7 +73,7 @@
|
|||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #e2e8f0;
|
||||
border-radius: 3px;
|
||||
border-radius: 10px;
|
||||
&:hover {
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,39 +1,17 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Col, Drawer, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Table, Tag, Tooltip, Typography, App } from 'antd';
|
||||
import { Button, Card, Col, Drawer, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Table, Tag, Tooltip, Typography, App } from 'antd';
|
||||
import * as AntIcons from "@ant-design/icons";
|
||||
import { DndContext, PointerSensor, useSensor, useSensors, DragEndEvent } from '@dnd-kit/core';
|
||||
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
|
||||
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import {
|
||||
ApartmentOutlined,
|
||||
BookOutlined,
|
||||
CheckSquareOutlined,
|
||||
ClusterOutlined,
|
||||
DashboardOutlined,
|
||||
DeleteOutlined,
|
||||
DesktopOutlined,
|
||||
EditOutlined,
|
||||
FolderOutlined,
|
||||
HolderOutlined,
|
||||
InfoCircleOutlined,
|
||||
MenuOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
SearchOutlined,
|
||||
SettingOutlined,
|
||||
ShopOutlined,
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
VideoCameraOutlined
|
||||
} from "@ant-design/icons";
|
||||
import { CheckSquareOutlined, ClusterOutlined, DeleteOutlined, EditOutlined, FolderOutlined, InfoCircleOutlined, MenuOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, HolderOutlined } from "@ant-design/icons";
|
||||
import { createPermission, deletePermission, listMyPermissions, updatePermission } from "@/api";
|
||||
import { useDict } from "@/hooks/useDict";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import DataListPanel from "@/components/shared/DataListPanel";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import type { SysPermission } from "@/types";
|
||||
import "./index.less";
|
||||
|
||||
|
|
@ -53,19 +31,6 @@ const legacyIconAliases: Record<string, string> = {
|
|||
setting: "SettingOutlined"
|
||||
};
|
||||
|
||||
const menuIconComponents: Record<string, React.ComponentType<any>> = {
|
||||
ApartmentOutlined,
|
||||
BookOutlined,
|
||||
DashboardOutlined,
|
||||
DesktopOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
SettingOutlined,
|
||||
ShopOutlined,
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
VideoCameraOutlined
|
||||
};
|
||||
|
||||
interface RowProps extends React.HTMLAttributes<HTMLTableRowElement> {
|
||||
'data-row-key': string;
|
||||
}
|
||||
|
|
@ -114,12 +79,15 @@ const DragHandle = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const menuIconOptions = Object.keys(menuIconComponents).sort((left, right) => left.localeCompare(right));
|
||||
const menuIconOptions = Object.keys(AntIcons)
|
||||
.filter((key) => /(?:Outlined|Filled|TwoTone)$/.test(key))
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
|
||||
function renderSelectableIcon(iconName?: string) {
|
||||
const resolvedName = iconName ? (legacyIconAliases[iconName] || iconName) : undefined;
|
||||
if (!resolvedName) return null;
|
||||
const IconComponent = menuIconComponents[resolvedName];
|
||||
const iconsMap = AntIcons as unknown as Record<string, React.ComponentType<{ className?: string; style?: React.CSSProperties }>>;
|
||||
const IconComponent = iconsMap[resolvedName];
|
||||
return IconComponent ? <IconComponent aria-hidden="true" /> : null;
|
||||
}
|
||||
|
||||
|
|
@ -451,21 +419,18 @@ export default function Permissions() {
|
|||
];
|
||||
|
||||
return (
|
||||
<PageContainer title={null} className="permissions-page">
|
||||
<SectionCard
|
||||
title={t("permissions.title")}
|
||||
description={t("permissions.subtitle")}
|
||||
>
|
||||
<DataListPanel
|
||||
leftActions={
|
||||
can("sys:permission:create") ? (
|
||||
<Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate}>
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
rightActions={
|
||||
<Space wrap>
|
||||
<PageContainer
|
||||
title={t("permissions.title")}
|
||||
subtitle={t("permissions.subtitle")}
|
||||
headerExtra={
|
||||
can("sys:permission:create") && (
|
||||
<Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate}>
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
toolbar={
|
||||
<>
|
||||
<Input
|
||||
placeholder={t("permissions.permName")}
|
||||
value={query.name}
|
||||
|
|
@ -498,9 +463,9 @@ export default function Permissions() {
|
|||
<Button icon={<ReloadOutlined aria-hidden="true" />} onClick={() => setQuery({ name: "", code: "", permType: "" })}>
|
||||
{t("common.reset")}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<DndContext sensors={sensors} modifiers={[restrictToVerticalAxis]} onDragEnd={onDragEnd}>
|
||||
<SortableContext items={flatVisualKeys} strategy={verticalListSortingStrategy}>
|
||||
<Table
|
||||
|
|
@ -521,8 +486,6 @@ export default function Permissions() {
|
|||
/>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</DataListPanel>
|
||||
</SectionCard>
|
||||
|
||||
<Drawer
|
||||
title={<Space><ClusterOutlined aria-hidden="true" /><span>{editing ? t("permissions.drawerTitleEdit") : t("permissions.drawerTitleCreate")}</span></Space>}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,14 @@
|
|||
.roles-page-v2 {
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.roles-page-v2 > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.roles-layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.roles-layout__row {
|
||||
|
|
@ -35,30 +28,11 @@
|
|||
.roles-detail-card {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border-radius: 4px !important;
|
||||
border-radius: 18px !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: none !important;
|
||||
border: 1px solid #e6e6e6 !important;
|
||||
overflow: hidden;
|
||||
background: #ffffff !important;
|
||||
}
|
||||
|
||||
.roles-side-card > .ant-card-head,
|
||||
.roles-detail-card > .ant-card-head {
|
||||
flex-shrink: 0;
|
||||
min-height: 55px;
|
||||
border-bottom: 1px solid #eef1f6 !important;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.roles-side-card > .ant-card-head .ant-card-head-title,
|
||||
.roles-detail-card > .ant-card-head .ant-card-head-title {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.roles-detail-card > .ant-card-head .ant-card-extra {
|
||||
padding: 12px 0;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04) !important;
|
||||
border: 1px solid rgba(226, 232, 240, 0.8) !important;
|
||||
}
|
||||
|
||||
.roles-side-card .ant-card-body,
|
||||
|
|
@ -67,7 +41,7 @@
|
|||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px !important;
|
||||
padding: 16px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
|
@ -75,22 +49,22 @@
|
|||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.role-search-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.role-search-bar .ant-input-affix-wrapper {
|
||||
flex: 1;
|
||||
border-radius: 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.role-search-bar .ant-btn {
|
||||
border-radius: 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.role-list-container-v3 {
|
||||
|
|
@ -98,8 +72,8 @@
|
|||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 0;
|
||||
margin-right: 0;
|
||||
padding-right: 8px;
|
||||
margin-right: -8px;
|
||||
|
||||
/* Modern scrollbar */
|
||||
&::-webkit-scrollbar {
|
||||
|
|
@ -110,7 +84,7 @@
|
|||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #e2e8f0;
|
||||
border-radius: 3px;
|
||||
border-radius: 10px;
|
||||
&:hover {
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
|
@ -121,24 +95,26 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid #f1f5f9;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
border-color: #c8d8ff;
|
||||
background: #f9fafe;
|
||||
border-color: #cbd5e1;
|
||||
background: #f8fafc;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #9cb8ff;
|
||||
background: #f3f7ff;
|
||||
border-color: #bfdbfe;
|
||||
background: #eff6ff;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
|
|
@ -147,16 +123,16 @@
|
|||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: #3c70f5;
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.role-name {
|
||||
color: #2f5edb;
|
||||
color: #1e3a8a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.role-item-symbol {
|
||||
background: #3c70f5;
|
||||
background: #3b82f6;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
|
@ -165,9 +141,9 @@
|
|||
.role-item-symbol {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
background: #eef4ff;
|
||||
color: #3c70f5;
|
||||
border-radius: 10px;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -231,7 +207,7 @@
|
|||
|
||||
.role-list-pagination {
|
||||
flex-shrink: 0;
|
||||
padding-top: 8px;
|
||||
padding-top: 16px;
|
||||
margin-top: auto;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
display: flex;
|
||||
|
|
@ -248,9 +224,9 @@
|
|||
.role-detail-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
background: #eef4ff;
|
||||
color: #3c70f5;
|
||||
border-radius: 12px;
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -287,8 +263,7 @@
|
|||
min-height: 0;
|
||||
|
||||
.ant-tabs-nav {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 8px !important;
|
||||
margin-bottom: 16px !important;
|
||||
&::before {
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
|
@ -297,7 +272,7 @@
|
|||
.ant-tabs-content-holder {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
|
|
@ -307,45 +282,20 @@
|
|||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #e2e8f0;
|
||||
border-radius: 3px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-content,
|
||||
.ant-tabs-tabpane {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.role-detail-pane {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #d8dfeb;
|
||||
border-radius: 3px;
|
||||
}
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.permission-tree-wrapper {
|
||||
min-height: 100%;
|
||||
padding: 12px;
|
||||
background: #ffffff;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e6e6e6;
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.role-members-toolbar {
|
||||
|
|
@ -371,8 +321,8 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #ffffff;
|
||||
border-radius: 4px;
|
||||
border: 1px dashed #cccccc;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
border-radius: 16px;
|
||||
border: 1px dashed rgba(148, 163, 184, 0.5);
|
||||
margin: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,11 +34,9 @@ import {
|
|||
} from "@/api";
|
||||
import { useDict } from "@/hooks/useDict";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import DataListPanel from "@/components/shared/DataListPanel";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
import { getStandardPagination } from "@/utils/pagination";
|
||||
import type { SysOrg, SysPermission, SysRole, SysTenant, SysUser } from "@/types";
|
||||
import type { RoleDataScope, SysOrg, SysPermission, SysRole, SysTenant, SysUser } from "@/types";
|
||||
import "./index.less";
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
|
@ -424,215 +422,200 @@ export default function Roles() {
|
|||
const saveLabel = "同步保存";
|
||||
|
||||
return (
|
||||
<PageContainer title={null} className="roles-page-v2">
|
||||
<SectionCard
|
||||
title="角色管理"
|
||||
description="维护角色基础信息、功能权限、数据权限与成员绑定。"
|
||||
>
|
||||
<DataListPanel
|
||||
className="roles-data-panel"
|
||||
toolbarClassName="roles-data-panel__toolbar"
|
||||
leftActions={
|
||||
can("sys:role:create") ? (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新增角色
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div className="roles-layout">
|
||||
<Row gutter={8} className="roles-layout__row">
|
||||
<Col span={7} className="roles-layout__side">
|
||||
<Card
|
||||
title={<Space><ApartmentOutlined /><span>{"角色列表"}</span></Space>}
|
||||
bordered={false}
|
||||
className="app-page__panel-card roles-side-card"
|
||||
>
|
||||
<div className="role-search-panel">
|
||||
{isPlatformMode && (
|
||||
<Select
|
||||
placeholder="按租户筛选"
|
||||
style={{ width: "100%" }}
|
||||
allowClear
|
||||
suffixIcon={<FilterOutlined />}
|
||||
value={filterTenantId}
|
||||
onChange={(value) => { setFilterTenantId(normalizeNumber(value)); setRolePage((prev) => ({ ...prev, current: 1 })); }}
|
||||
options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))}
|
||||
/>
|
||||
)}
|
||||
<div className="role-search-bar">
|
||||
<Input placeholder="输入角色名称或编码搜索" prefix={<SearchOutlined style={{ color: "#94a3b8" }} />} value={searchText} onChange={(event) => setSearchText(event.target.value)} allowClear />
|
||||
<Button type="default" onClick={() => { setSearchText(""); setFilterTenantId(undefined); setRolePage((prev) => ({ ...prev, current: 1 })); }}>{"重置"}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="app-page roles-page-v2">
|
||||
<PageHeader title="角色管理" subtitle="维护角色基础信息、功能权限、数据权限与成员绑定" />
|
||||
|
||||
<div className="role-list-container-v3">
|
||||
<List
|
||||
loading={loading}
|
||||
dataSource={data}
|
||||
pagination={false}
|
||||
locale={{ emptyText: <Empty description="暂无角色数据" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
|
||||
renderItem={(item) => (
|
||||
<div key={item.roleId} className={`role-item-card-v3 ${selectedRole?.roleId === item.roleId ? "active" : ""}`} onClick={() => void selectRole(item)}>
|
||||
<div className="role-item-symbol" aria-hidden="true">
|
||||
<SafetyCertificateOutlined />
|
||||
</div>
|
||||
<div className="role-item-main">
|
||||
<div className="role-item-name-row">
|
||||
<Text strong className="role-name">{item.roleName}</Text>
|
||||
{isPlatformMode && <Tag color="blue" style={{ fontSize: 10, scale: "0.8", margin: "0 0 0 4px", borderRadius: "10px" }}>{item.tenantId === 0 ? "平台租户" : tenants.find((tenant) => tenant.id === item.tenantId)?.tenantName || `租户:${item.tenantId}`}</Tag>}
|
||||
{item.status === 0 && <Tag color="error" style={{ fontSize: 10, scale: "0.8", margin: 0 }}>{"停用"}</Tag>}
|
||||
</div>
|
||||
<Text type="secondary" className="role-code">{item.roleCode}</Text>
|
||||
</div>
|
||||
{selectedRole?.roleId === item.roleId ? (
|
||||
<div className="role-item-selected-mark" aria-hidden="true">
|
||||
<CheckCircleFilled />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="role-item-actions">
|
||||
<Space size={4}>
|
||||
<Tooltip title="编辑">
|
||||
<Button type="text" size="small" icon={<EditOutlined />} onClick={(event) => openEditBasic(event, item)} />
|
||||
</Tooltip>
|
||||
{item.roleCode !== "ADMIN" && (
|
||||
<Popconfirm title="确定删除该角色吗?" okText="确定" cancelText="取消" onConfirm={(event) => void handleRemove(event!, item.roleId)}>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={(event) => event.stopPropagation()} />
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="role-list-pagination">
|
||||
<Pagination
|
||||
{...getStandardPagination(rolePage.total, rolePage.current, rolePage.size, handleRolePageChange, { size: "small", showSizeChanger: true, pageSizeOptions: ["10", "20", "50"] })}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<div className="app-page__page-actions">
|
||||
{can("sys:role:create") && <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>{"新增角色"}</Button>}
|
||||
</div>
|
||||
|
||||
<Col span={17} className="roles-layout__detail">
|
||||
{selectedRole ? (
|
||||
<Card
|
||||
className="app-page__panel-card roles-detail-card"
|
||||
bordered={false}
|
||||
title={<div className="role-detail-header"><div className="role-detail-icon"><SafetyCertificateOutlined /></div><div className="role-detail-heading"><div className="role-detail-title">{selectedRole.roleName}</div><Text type="secondary" className="role-detail-code">{selectedRole.roleCode}</Text></div></div>}
|
||||
extra={<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handlePrimarySave} disabled={saveDisabled} style={{ borderRadius: "6px" }}>{saveLabel}</Button>}
|
||||
>
|
||||
<Tabs activeKey={activeTab} onChange={(key) => setActiveTab(key as RoleTabKey)} className="role-detail-tabs">
|
||||
<Tabs.TabPane tab={<Space><KeyOutlined />{"功能权限"}</Space>} key="permissions">
|
||||
<div className="role-detail-pane">
|
||||
<div className="permission-tree-wrapper">
|
||||
<Tree
|
||||
checkable
|
||||
selectable={false}
|
||||
checkStrictly={false}
|
||||
treeData={permissionTreeData}
|
||||
checkedKeys={selectedPermIds}
|
||||
onCheck={(keys, info) => {
|
||||
const checked = Array.isArray(keys) ? keys : keys.checked;
|
||||
const halfChecked = info.halfCheckedKeys || [];
|
||||
setSelectedPermIds(checked.map((key) => Number(key)));
|
||||
setHalfCheckedIds(halfChecked.map((key) => Number(key)));
|
||||
setPermissionsDirty(true);
|
||||
}}
|
||||
defaultExpandAll
|
||||
/>
|
||||
</div>
|
||||
<div className="roles-layout">
|
||||
<Row gutter={24} className="roles-layout__row">
|
||||
<Col span={7} className="roles-layout__side">
|
||||
<Card title={<Space><ApartmentOutlined /><span>{"角色列表"}</span></Space>} bordered={false} className="app-page__panel-card roles-side-card">
|
||||
<div className="role-search-panel">
|
||||
{isPlatformMode && (
|
||||
<Select
|
||||
placeholder="按租户筛选"
|
||||
style={{ width: "100%" }}
|
||||
allowClear
|
||||
suffixIcon={<FilterOutlined />}
|
||||
value={filterTenantId}
|
||||
onChange={(value) => { setFilterTenantId(normalizeNumber(value)); setRolePage((prev) => ({ ...prev, current: 1 })); }}
|
||||
options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))}
|
||||
/>
|
||||
)}
|
||||
<div className="role-search-bar">
|
||||
<Input placeholder="输入角色名称或编码搜索" prefix={<SearchOutlined style={{ color: "#94a3b8" }} />} value={searchText} onChange={(event) => setSearchText(event.target.value)} allowClear />
|
||||
<Button type="default" onClick={() => { setSearchText(""); setFilterTenantId(undefined); setRolePage((prev) => ({ ...prev, current: 1 })); }}>{"重置"}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="role-list-container-v3">
|
||||
<List
|
||||
loading={loading}
|
||||
dataSource={data}
|
||||
pagination={false}
|
||||
locale={{ emptyText: <Empty description="暂无角色数据" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
|
||||
renderItem={(item) => (
|
||||
<div key={item.roleId} className={`role-item-card-v3 ${selectedRole?.roleId === item.roleId ? "active" : ""}`} onClick={() => void selectRole(item)}>
|
||||
<div className="role-item-symbol" aria-hidden="true">
|
||||
<SafetyCertificateOutlined />
|
||||
</div>
|
||||
<div className="role-item-main">
|
||||
<div className="role-item-name-row">
|
||||
<Text strong className="role-name">{item.roleName}</Text>
|
||||
{isPlatformMode && <Tag color="blue" style={{ fontSize: 10, scale: "0.8", margin: "0 0 0 4px", borderRadius: "10px" }}>{item.tenantId === 0 ? "平台租户" : tenants.find((tenant) => tenant.id === item.tenantId)?.tenantName || `租户:${item.tenantId}`}</Tag>}
|
||||
{item.status === 0 && <Tag color="error" style={{ fontSize: 10, scale: "0.8", margin: 0 }}>{"停用"}</Tag>}
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={<Space><ApartmentOutlined />{"数据权限"}</Space>} key="dataScope">
|
||||
<div className="role-detail-pane">
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Radio.Group
|
||||
value={dataScopeType}
|
||||
onChange={(event) => {
|
||||
setDataScopeType(event.target.value);
|
||||
setDataScopeDirty(true);
|
||||
}}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
{DATA_SCOPE_OPTIONS.map((item) => (
|
||||
<Radio.Button key={item.value} value={item.value}>{item.label}</Radio.Button>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16, color: "#64748b" }}>{getDataScopeDescription(dataScopeType)}</div>
|
||||
{dataScopeType === "CUSTOM" ? (
|
||||
<div className="permission-tree-wrapper">
|
||||
<Tree
|
||||
checkable
|
||||
selectable={false}
|
||||
treeData={scopeOrgTree}
|
||||
checkedKeys={scopeOrgIds}
|
||||
onCheck={(keys) => {
|
||||
const checked = Array.isArray(keys) ? keys : keys.checked;
|
||||
setScopeOrgIds(checked.map((key) => Number(key)));
|
||||
setDataScopeDirty(true);
|
||||
}}
|
||||
defaultExpandAll
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="当前范围不需要选择部门" />
|
||||
<Text type="secondary" className="role-code">{item.roleCode}</Text>
|
||||
</div>
|
||||
{selectedRole?.roleId === item.roleId ? (
|
||||
<div className="role-item-selected-mark" aria-hidden="true">
|
||||
<CheckCircleFilled />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="role-item-actions">
|
||||
<Space size={4}>
|
||||
<Tooltip title="编辑">
|
||||
<Button type="text" size="small" icon={<EditOutlined />} onClick={(event) => openEditBasic(event, item)} />
|
||||
</Tooltip>
|
||||
{item.roleCode !== "ADMIN" && (
|
||||
<Popconfirm title="确定删除该角色吗?" okText="确定" cancelText="取消" onConfirm={(event) => void handleRemove(event!, item.roleId)}>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={(event) => event.stopPropagation()} />
|
||||
</Popconfirm>
|
||||
)}
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={<Space><TeamOutlined />{`成员管理 (${roleUsers.length})`}</Space>} key="users">
|
||||
<div className="role-detail-pane">
|
||||
<div className="role-members-toolbar">
|
||||
<Title level={5} style={{ margin: 0 }}>{"已绑定用户"}</Title>
|
||||
<Button type="primary" ghost icon={<UserAddOutlined />} onClick={openUserModal} disabled={!can("sys:role:update")}>{"绑定用户"}</Button>
|
||||
</div>
|
||||
<Table
|
||||
rowKey="userId"
|
||||
size="small"
|
||||
loading={loadingUsers}
|
||||
dataSource={roleUsers}
|
||||
pagination={{ ...getStandardPagination(roleUsers.length, 1, 10, undefined, { size: "small", showSizeChanger: false }), current: undefined }}
|
||||
columns={[
|
||||
{
|
||||
title: "用户信息",
|
||||
render: (_: unknown, user: SysUser) => (
|
||||
<Space>
|
||||
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: "#f0f2f5", color: "#8c8c8c" }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{user.displayName}</div>
|
||||
<div style={{ fontSize: 11, color: "#bfbfbf" }}>@{user.username}</div>
|
||||
</div>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{ title: "手机号", dataIndex: "phone", className: "tabular-nums" },
|
||||
{ title: "状态", dataIndex: "status", width: 80, render: (status: number) => <Tag color={status === 1 ? "green" : "red"}>{status === 1 ? "启用" : "停用"}</Tag> },
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 80,
|
||||
render: (_: unknown, user: SysUser) => (
|
||||
<Popconfirm title="确定解除该用户绑定吗?" okText="确定" cancelText="取消" onConfirm={() => void handleUnbindUser(user.userId)} disabled={!can("sys:role:update")}>
|
||||
<Button type="text" danger size="small" icon={<DeleteOutlined />} disabled={!can("sys:role:update")} />
|
||||
</Popconfirm>
|
||||
)
|
||||
}
|
||||
]}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="role-list-pagination">
|
||||
<Pagination
|
||||
{...getStandardPagination(rolePage.total, rolePage.current, rolePage.size, handleRolePageChange, { size: "small", showSizeChanger: true, pageSizeOptions: ["10", "20", "50"] })}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={17} className="roles-layout__detail">
|
||||
{selectedRole ? (
|
||||
<Card
|
||||
className="app-page__panel-card roles-detail-card"
|
||||
bordered={false}
|
||||
title={<div className="role-detail-header"><div className="role-detail-icon"><SafetyCertificateOutlined /></div><div className="role-detail-heading"><div className="role-detail-title">{selectedRole.roleName}</div><Text type="secondary" className="role-detail-code">{selectedRole.roleCode}</Text></div></div>}
|
||||
extra={<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handlePrimarySave} disabled={saveDisabled} style={{ borderRadius: "6px" }}>{saveLabel}</Button>}
|
||||
>
|
||||
<Tabs activeKey={activeTab} onChange={(key) => setActiveTab(key as RoleTabKey)} className="role-detail-tabs">
|
||||
<Tabs.TabPane tab={<Space><KeyOutlined />{"功能权限"}</Space>} key="permissions">
|
||||
<div className="role-detail-pane">
|
||||
<div className="permission-tree-wrapper">
|
||||
<Tree
|
||||
checkable
|
||||
selectable={false}
|
||||
checkStrictly={false}
|
||||
treeData={permissionTreeData}
|
||||
checkedKeys={selectedPermIds}
|
||||
onCheck={(keys, info) => {
|
||||
const checked = Array.isArray(keys) ? keys : keys.checked;
|
||||
const halfChecked = info.halfCheckedKeys || [];
|
||||
setSelectedPermIds(checked.map((key) => Number(key)));
|
||||
setHalfCheckedIds(halfChecked.map((key) => Number(key)));
|
||||
setPermissionsDirty(true);
|
||||
}}
|
||||
defaultExpandAll
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={<Space><ApartmentOutlined />{"数据权限"}</Space>} key="dataScope">
|
||||
<div className="role-detail-pane">
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Radio.Group
|
||||
value={dataScopeType}
|
||||
onChange={(event) => {
|
||||
setDataScopeType(event.target.value);
|
||||
setDataScopeDirty(true);
|
||||
}}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
{DATA_SCOPE_OPTIONS.map((item) => (
|
||||
<Radio.Button key={item.value} value={item.value}>{item.label}</Radio.Button>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16, color: "#64748b" }}>{getDataScopeDescription(dataScopeType)}</div>
|
||||
{dataScopeType === "CUSTOM" ? (
|
||||
<div className="permission-tree-wrapper">
|
||||
<Tree
|
||||
checkable
|
||||
selectable={false}
|
||||
treeData={scopeOrgTree}
|
||||
checkedKeys={scopeOrgIds}
|
||||
onCheck={(keys) => {
|
||||
const checked = Array.isArray(keys) ? keys : keys.checked;
|
||||
setScopeOrgIds(checked.map((key) => Number(key)));
|
||||
setDataScopeDirty(true);
|
||||
}}
|
||||
defaultExpandAll
|
||||
/>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="app-page__empty-state"><Empty description="请选择左侧角色查看详情" /></div>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</DataListPanel>
|
||||
</SectionCard>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="当前范围不需要选择部门" />
|
||||
)}
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={<Space><TeamOutlined />{`成员管理 (${roleUsers.length})`}</Space>} key="users">
|
||||
<div className="role-detail-pane">
|
||||
<div className="role-members-toolbar">
|
||||
<Title level={5} style={{ margin: 0 }}>{"已绑定用户"}</Title>
|
||||
<Button type="primary" ghost icon={<UserAddOutlined />} onClick={openUserModal} disabled={!can("sys:role:update")}>{"绑定用户"}</Button>
|
||||
</div>
|
||||
<Table
|
||||
rowKey="userId"
|
||||
size="small"
|
||||
loading={loadingUsers}
|
||||
dataSource={roleUsers}
|
||||
pagination={{ ...getStandardPagination(roleUsers.length, 1, 10, undefined, { size: "small", showSizeChanger: false }), current: undefined }}
|
||||
columns={[
|
||||
{
|
||||
title: "用户信息",
|
||||
render: (_: unknown, user: SysUser) => (
|
||||
<Space>
|
||||
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: "#f0f2f5", color: "#8c8c8c" }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{user.displayName}</div>
|
||||
<div style={{ fontSize: 11, color: "#bfbfbf" }}>@{user.username}</div>
|
||||
</div>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{ title: "手机号", dataIndex: "phone", className: "tabular-nums" },
|
||||
{ title: "状态", dataIndex: "status", width: 80, render: (status: number) => <Tag color={status === 1 ? "green" : "red"}>{status === 1 ? "启用" : "停用"}</Tag> },
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 80,
|
||||
render: (_: unknown, user: SysUser) => (
|
||||
<Popconfirm title="确定解除该用户绑定吗?" okText="确定" cancelText="取消" onConfirm={() => void handleUnbindUser(user.userId)} disabled={!can("sys:role:update")}>
|
||||
<Button type="text" danger size="small" icon={<DeleteOutlined />} disabled={!can("sys:role:update")} />
|
||||
</Popconfirm>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="app-page__empty-state"><Empty description="请选择左侧角色查看详情" /></div>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<Modal title="绑定用户到角色" open={userModalOpen} onCancel={() => setUserModalOpen(false)} onOk={() => void handleAddUsers()} okText="确定" cancelText="取消" width={650} destroyOnClose>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
|
|
@ -660,6 +643,6 @@ export default function Roles() {
|
|||
</Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
</PageContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,5 @@
|
|||
.users-page {
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.users-page > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.users-header {
|
||||
|
|
@ -24,8 +14,8 @@
|
|||
}
|
||||
|
||||
.users-table-card {
|
||||
border-radius: 4px;
|
||||
box-shadow: none;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.users-table-toolbar {
|
||||
|
|
@ -42,8 +32,8 @@
|
|||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 18px 24px 14px;
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
|
||||
background: linear-gradient(180deg, rgba(248, 250, 252, 0.95) 0%, rgba(255, 255, 255, 0.75) 100%);
|
||||
}
|
||||
|
||||
.users-content-header__count {
|
||||
|
|
@ -76,63 +66,6 @@
|
|||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.user-affiliation-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-affiliation-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #d6e4ff;
|
||||
border-radius: 4px;
|
||||
background: #f5f9ff;
|
||||
color: #315efb;
|
||||
font-size: 12px;
|
||||
line-height: 22px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.user-affiliation-tag--tenant {
|
||||
border-color: #b7ebd5;
|
||||
background: #f1fffa;
|
||||
color: #08936a;
|
||||
}
|
||||
|
||||
.user-affiliation-tag--org {
|
||||
border-color: #d6e4ff;
|
||||
background: #f5f9ff;
|
||||
color: #315efb;
|
||||
}
|
||||
|
||||
.user-affiliation-tag .anticon {
|
||||
flex: 0 0 auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-affiliation-tag--tenant .anticon {
|
||||
color: #13a878;
|
||||
}
|
||||
|
||||
.user-affiliation-tag--org .anticon {
|
||||
color: #6b8cff;
|
||||
}
|
||||
|
||||
.user-affiliation-tag__text {
|
||||
min-width: 0;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-phone {
|
||||
color: #8c8c8c;
|
||||
font-size: 13px;
|
||||
|
|
@ -141,7 +74,6 @@
|
|||
.user-drawer-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
@ -157,24 +89,6 @@
|
|||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.user-membership-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.user-membership-title {
|
||||
margin-top: 8px !important;
|
||||
margin-bottom: 16px !important;
|
||||
}
|
||||
|
||||
.user-membership-title--required::before {
|
||||
content: "*";
|
||||
display: inline-block;
|
||||
margin-inline-end: 4px;
|
||||
color: #ff4d4f;
|
||||
font-family: SimSun, sans-serif;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Custom alignment for Row/Col in Form */
|
||||
.user-form .ant-row {
|
||||
margin-left: -8px !important;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
TreeSelect,
|
||||
|
|
@ -51,13 +52,11 @@ import {
|
|||
import {fetchPublicPasswordPolicy, type PasswordPolicyPublic} from "@/api/auth";
|
||||
import { useDict } from "@/hooks/useDict";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import DataListPanel from "@/components/shared/DataListPanel";
|
||||
import AppPagination from "@/components/shared/AppPagination";
|
||||
import ListTable from "@/components/shared/ListTable/ListTable";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import { LOGIN_NAME_PATTERN, sanitizeLoginName } from "@/utils/loginName";
|
||||
import {buildPasswordPolicyValidator, buildPolicyHints} from "@/utils/password";
|
||||
import { getStandardPagination } from "@/utils/pagination";
|
||||
import type { SysOrg, SysRole, SysTenant, SysUser } from "@/types";
|
||||
import "./index.less";
|
||||
|
||||
|
|
@ -65,13 +64,9 @@ const { Title, Text } = Typography;
|
|||
|
||||
type OrgTreeNode = { value: number; title: string; children: OrgTreeNode[] };
|
||||
|
||||
type Membership = { tenantId?: number; orgId?: number; orgName?: string; orgPath?: string; orgFullName?: string };
|
||||
type Membership = { tenantId?: number; orgId?: number; orgName?: string };
|
||||
type ResetPasswordValues = { newPassword: string; confirmPassword: string };
|
||||
|
||||
const getMembershipOrgName = (membership: Membership) => {
|
||||
return membership.orgFullName || membership.orgPath || membership.orgName || "";
|
||||
};
|
||||
|
||||
function buildOrgTree(list: SysOrg[]): OrgTreeNode[] {
|
||||
const map = new Map<number, OrgTreeNode>();
|
||||
const roots: OrgTreeNode[] = [];
|
||||
|
|
@ -266,6 +261,8 @@ export default function Users() {
|
|||
);
|
||||
}, [data, searchText]);
|
||||
|
||||
const activeFilterLabel = filterTenantId ? tenantMap[filterTenantId] || `Tenant ${filterTenantId}` : "全部租户";
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setRoleSelectOpen(false);
|
||||
|
|
@ -401,26 +398,16 @@ export default function Users() {
|
|||
? [{
|
||||
title: t("users.tenant"),
|
||||
key: "tenant",
|
||||
width: 260,
|
||||
render: (_: any, record: SysUser) => {
|
||||
if (record.memberships && record.memberships.length > 0) {
|
||||
const tenantNames = Array.from(
|
||||
new Set(
|
||||
record.memberships
|
||||
.map((membership: Membership) => tenantMap[membership.tenantId || 0] || (membership.tenantId ? `Tenant ${membership.tenantId}` : ""))
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
return (
|
||||
<div className="user-affiliation-list">
|
||||
{tenantNames.map((name, index) => (
|
||||
<Tooltip key={`${name}-${index}`} title={name}>
|
||||
<span className="user-affiliation-tag user-affiliation-tag--tenant">
|
||||
<ShopOutlined aria-hidden="true" />
|
||||
<span className="user-affiliation-tag__text">{name}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<div className="flex flex-col gap-1">
|
||||
{record.memberships.slice(0, 2).map((membership: any) => (
|
||||
<Tag key={membership.tenantId} color="blue" style={{ margin: 0, padding: "0 4px", fontSize: 11 }}>
|
||||
{tenantMap[membership.tenantId] || `Tenant ${membership.tenantId}`}
|
||||
</Tag>
|
||||
))}
|
||||
{record.memberships.length > 2 && <Text type="secondary" style={{ fontSize: 11 }}>+{record.memberships.length - 2} more</Text>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -431,22 +418,17 @@ export default function Users() {
|
|||
{
|
||||
title: t("users.orgNode"),
|
||||
key: "org",
|
||||
width: 340,
|
||||
render: (_: any, record: SysUser) => {
|
||||
if (record.memberships && record.memberships.length > 0) {
|
||||
const orgNames = Array.from(
|
||||
new Set(record.memberships.map((membership: Membership) => getMembershipOrgName(membership).trim()).filter(Boolean))
|
||||
);
|
||||
const orgNames = record.memberships.map((membership: any) => membership.orgName).filter(Boolean);
|
||||
if (orgNames.length > 0) {
|
||||
return (
|
||||
<div className="user-affiliation-list">
|
||||
<div className="flex flex-col gap-1">
|
||||
{orgNames.map((name: string, index: number) => (
|
||||
<Tooltip key={`${name}-${index}`} title={name}>
|
||||
<span className="user-affiliation-tag user-affiliation-tag--org">
|
||||
<ApartmentOutlined aria-hidden="true" />
|
||||
<span className="user-affiliation-tag__text">{name}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Space key={index} size={4} style={{ fontSize: 13, color: "#555" }}>
|
||||
<ApartmentOutlined />
|
||||
<span>{name}</span>
|
||||
</Space>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -494,21 +476,14 @@ export default function Users() {
|
|||
];
|
||||
|
||||
return (
|
||||
<PageContainer title={null} className="users-page">
|
||||
<SectionCard
|
||||
title={t("users.title")}
|
||||
description={t("users.subtitle")}
|
||||
>
|
||||
<DataListPanel
|
||||
leftActions={
|
||||
can("sys:user:create") ? (
|
||||
<Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate}>
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
rightActions={
|
||||
<Space size={8} wrap>
|
||||
<div className="app-page users-page">
|
||||
<PageHeader title={t("users.title")} subtitle={t("users.subtitle")}/>
|
||||
|
||||
<Card className="users-table-card app-page__filter-card" styles={{body: {padding: "16px"}}}>
|
||||
<div className="users-table-toolbar">
|
||||
<Space size="middle" wrap className="app-page__toolbar"
|
||||
style={{justifyContent: "space-between", width: "100%"}}>
|
||||
<Space size="middle" wrap className="app-page__toolbar">
|
||||
{isPlatformMode &&
|
||||
<Select placeholder={t("users.tenantFilter")} style={{width: 200}} allowClear value={filterTenantId}
|
||||
onChange={setFilterTenantId}
|
||||
|
|
@ -523,31 +498,30 @@ export default function Users() {
|
|||
onClick={handleSearch}>{t("common.search")}</Button>
|
||||
<Button onClick={handleResetSearch}>{t("common.reset")}</Button>
|
||||
</Space>
|
||||
}
|
||||
footer={
|
||||
<AppPagination
|
||||
current={current}
|
||||
pageSize={pageSize}
|
||||
total={filteredData.length}
|
||||
onChange={(page, size) => {
|
||||
setCurrent(page);
|
||||
setPageSize(size);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ListTable
|
||||
rowKey="userId"
|
||||
columns={columns}
|
||||
dataSource={filteredData.slice((current - 1) * pageSize, current * pageSize)}
|
||||
loading={loading}
|
||||
scroll={{ y: "100%" }}
|
||||
pagination={false}
|
||||
/>
|
||||
</DataListPanel>
|
||||
</SectionCard>
|
||||
{can("sys:user:create") && <Button type="primary" icon={<PlusOutlined aria-hidden="true"/>}
|
||||
onClick={openCreate}>{t("common.create")}</Button>}
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Drawer title={<div className="user-drawer-title"><UserOutlined aria-hidden="true"/><span>{editing ? t("users.drawerTitleEdit") : t("users.drawerTitleCreate")}</span>
|
||||
<Card className="app-page__content-card flex-1 flex flex-col overflow-hidden"
|
||||
styles={{body: {padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden"}}}>
|
||||
<Table
|
||||
rowKey="userId"
|
||||
columns={columns}
|
||||
dataSource={filteredData}
|
||||
loading={loading}
|
||||
size="middle"
|
||||
scroll={{y: "calc(100vh - 430px)"}}
|
||||
pagination={getStandardPagination(filteredData.length, current, pageSize, (page, size) => {
|
||||
setCurrent(page);
|
||||
setPageSize(size);
|
||||
})}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Drawer title={<div className="user-drawer-title"><UserOutlined className="mr-2"
|
||||
aria-hidden="true"/>{editing ? t("users.drawerTitleEdit") : t("users.drawerTitleCreate")}
|
||||
</div>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520} destroyOnClose
|
||||
footer={<div className="app-page__drawer-footer"><Button
|
||||
onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary"
|
||||
|
|
@ -586,63 +560,6 @@ export default function Users() {
|
|||
<Button icon={<UploadOutlined/>} loading={avatarUploading}
|
||||
style={{marginBottom: 16}}>{t("profile.uploadAvatar")}</Button>
|
||||
</Upload>
|
||||
{isPlatformMode && (
|
||||
<div className="user-membership-section">
|
||||
<Title level={5} className="user-membership-title user-membership-title--required">{t("usersExt.membershipsTitle")}</Title>
|
||||
<Form.List
|
||||
name="memberships"
|
||||
rules={[
|
||||
{
|
||||
validator: async (_, value?: Membership[]) => {
|
||||
if (value?.some((membership) => membership?.tenantId)) {
|
||||
return;
|
||||
}
|
||||
throw new Error(t("usersExt.membershipRequired"));
|
||||
}
|
||||
}
|
||||
]}
|
||||
>
|
||||
{(fields, { add, remove }, { errors }) => (
|
||||
<>
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<Card key={key} size="small" className="mb-3" styles={{ body: { padding: "12px" } }} title={t("usersExt.membershipTitle", { index: name + 1 })} extra={fields.length > 1 && <Button type="text" danger icon={<MinusCircleOutlined />} onClick={() => remove(name)} />}>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item {...restField} label={t("users.tenant")} name={[name, "tenantId"]} rules={[{ required: true, message: t("usersExt.membershipRequired") }]}>
|
||||
<Select options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))} placeholder={t("usersExt.selectTenant")} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<MembershipOrgSelect fieldProps={{ ...restField }} name={name} tenantId={form.getFieldValue(["memberships", name, "tenantId"])} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
<Form.ErrorList errors={errors} />
|
||||
<Button type="dashed" onClick={() => add()} block
|
||||
icon={<PlusOutlined/>}>{t("usersExt.addMembership")}</Button>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</div>
|
||||
)}
|
||||
{!isPlatformMode && (
|
||||
<Form.Item
|
||||
label={t("users.orgNode")}
|
||||
name="orgId"
|
||||
rules={[{ required: true, message: t("usersExt.selectOrgPlaceholder") }]}
|
||||
>
|
||||
<TreeSelect placeholder={t("usersExt.selectOrgPlaceholder")} allowClear treeData={orgTreeData}/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item label={t("users.roles")} name="roleIds"
|
||||
rules={[{required: true, message: t("users.roles")}]}><Select mode="multiple"
|
||||
placeholder={t("users.roles")}
|
||||
options={roleOptions}
|
||||
open={roleSelectOpen}
|
||||
onDropdownVisibleChange={setRoleSelectOpen}
|
||||
onChange={() => setRoleSelectOpen(false)}
|
||||
optionFilterProp={isPlatformMode ? "searchText" : "label"}/></Form.Item>
|
||||
{!editing ? (
|
||||
<Form.Item
|
||||
label={t("users.password")}
|
||||
|
|
@ -679,12 +596,49 @@ export default function Users() {
|
|||
<Input.Password placeholder={t("usersExt.confirmPasswordPlaceholder")} autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<Form.Item label={t("users.roles")} name="roleIds"
|
||||
rules={[{required: true, message: t("users.roles")}]}><Select mode="multiple"
|
||||
placeholder={t("users.roles")}
|
||||
options={roleOptions}
|
||||
open={roleSelectOpen}
|
||||
onDropdownVisibleChange={setRoleSelectOpen}
|
||||
onChange={() => setRoleSelectOpen(false)}
|
||||
optionFilterProp={isPlatformMode ? "searchText" : "label"}/></Form.Item>
|
||||
{!isPlatformMode && <Form.Item label={t("users.orgNode")} name="orgId"><TreeSelect
|
||||
placeholder={t("usersExt.selectOrgPlaceholder")} allowClear treeData={orgTreeData}/></Form.Item>}
|
||||
<Row gutter={16}>
|
||||
<Col span={12}><Form.Item label={t("common.status")} name="status" initialValue={1}><Select
|
||||
options={statusDict.map((item) => ({label: item.itemLabel, value: Number(item.itemValue)}))}/></Form.Item></Col>
|
||||
{isPlatformMode &&
|
||||
<Col span={12}><Form.Item label={t("users.platformAdmin")} name="isPlatformAdmin" valuePropName="checked"><Switch/></Form.Item></Col>}
|
||||
</Row>
|
||||
{isPlatformMode && (
|
||||
<>
|
||||
<Title level={5} style={{ marginTop: 24, marginBottom: 16 }}>{t("usersExt.membershipsTitle")}</Title>
|
||||
<Form.List name="memberships">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<Card key={key} size="small" className="mb-3" styles={{ body: { padding: "12px" } }} title={t("usersExt.membershipTitle", { index: name + 1 })} extra={fields.length > 1 && <Button type="text" danger icon={<MinusCircleOutlined />} onClick={() => remove(name)} />}>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item {...restField} label={t("users.tenant")} name={[name, "tenantId"]} rules={[{ required: true, message: t("usersExt.membershipRequired") }]}>
|
||||
<Select options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))} placeholder={t("usersExt.selectTenant")} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<MembershipOrgSelect fieldProps={{ ...restField }} name={name} tenantId={form.getFieldValue(["memberships", name, "tenantId"])} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add()} block
|
||||
icon={<PlusOutlined/>}>{t("usersExt.addMembership")}</Button>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Drawer>
|
||||
|
||||
|
|
@ -752,6 +706,6 @@ export default function Users() {
|
|||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
.auth-shell {
|
||||
min-height: 100vh;
|
||||
padding: 32px 24px;
|
||||
background: #f5f6fa;
|
||||
background: radial-gradient(circle at top left, rgba(22, 119, 255, 0.16), transparent 32%),
|
||||
radial-gradient(circle at bottom right, rgba(59, 130, 246, 0.12), transparent 28%),
|
||||
linear-gradient(180deg, #eef4fb 0%, #f7f9fc 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -12,17 +14,19 @@
|
|||
min-height: 680px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 0.92fr) minmax(420px, 1fr);
|
||||
border-radius: 4px;
|
||||
border-radius: 28px;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e6e6e6;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.12);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.auth-shell__aside {
|
||||
padding: 56px 48px;
|
||||
color: #333333;
|
||||
background: #f9fafe;
|
||||
border-right: 1px solid #e6e6e6;
|
||||
color: #f8fbff;
|
||||
background: radial-gradient(circle at top left, rgba(12, 74, 110, 0.4), transparent 50%),
|
||||
linear-gradient(165deg, #0b1b36 0%, #0c4a6e 60%, #0369a1 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
|
@ -31,7 +35,13 @@
|
|||
}
|
||||
|
||||
.auth-shell__aside::after {
|
||||
content: none;
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: auto -80px -120px auto;
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0) 68%);
|
||||
}
|
||||
|
||||
.auth-shell__aside-top {
|
||||
|
|
@ -47,26 +57,28 @@
|
|||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e6e6e6;
|
||||
color: #3c70f5;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-shell__aside-extra {
|
||||
padding: 16px 20px;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.auth-shell__aside-extra-title {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
color: #333333;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
@ -74,13 +86,13 @@
|
|||
.auth-shell__aside-extra-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: #606775;
|
||||
color: rgba(226, 232, 240, 0.9);
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.auth-shell__aside-extra-list li::marker {
|
||||
color: #3c70f5;
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
.auth-shell__aside-copy {
|
||||
|
|
@ -91,15 +103,15 @@
|
|||
|
||||
.auth-shell__aside-title.ant-typography {
|
||||
margin: 0 0 16px;
|
||||
color: #333333;
|
||||
font-size: 32px;
|
||||
color: #ffffff;
|
||||
font-size: 36px;
|
||||
line-height: 1.18;
|
||||
letter-spacing: 0;
|
||||
letter-spacing: -0.8px;
|
||||
}
|
||||
|
||||
.auth-shell__aside-description.ant-typography {
|
||||
margin: 0;
|
||||
color: #606775;
|
||||
color: rgba(226, 232, 240, 0.9);
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
|
@ -117,18 +129,18 @@
|
|||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.auth-shell__highlight-item .anticon {
|
||||
margin-top: 4px;
|
||||
color: #3c70f5;
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
.auth-shell__highlight-item .ant-typography {
|
||||
color: #333333;
|
||||
color: rgba(241, 245, 249, 0.94);
|
||||
}
|
||||
|
||||
.auth-shell__main {
|
||||
|
|
@ -148,10 +160,10 @@
|
|||
|
||||
.auth-shell__panel-title.ant-typography {
|
||||
margin: 0 0 8px;
|
||||
color: #333333;
|
||||
font-size: 28px;
|
||||
color: #10233b;
|
||||
font-size: 30px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.auth-shell__panel-subtitle.ant-typography {
|
||||
|
|
@ -168,7 +180,7 @@
|
|||
.auth-shell__panel-content .ant-input-affix-wrapper,
|
||||
.auth-shell__panel-content .ant-input,
|
||||
.auth-shell__panel-content .ant-btn {
|
||||
border-radius: 4px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.auth-shell__panel-content .ant-input-affix-wrapper,
|
||||
|
|
@ -184,7 +196,7 @@
|
|||
.auth-shell__panel-footer {
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e6e6e6;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.18);
|
||||
}
|
||||
|
||||
.auth-form__inline {
|
||||
|
|
@ -219,9 +231,9 @@
|
|||
.auth-form__policy-hints {
|
||||
margin: -4px 0 20px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 4px;
|
||||
background: #f9fafe;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 14px;
|
||||
background: rgba(241, 245, 249, 0.78);
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
}
|
||||
|
||||
.auth-form__policy-hints-title {
|
||||
|
|
@ -250,11 +262,6 @@
|
|||
height: auto;
|
||||
}
|
||||
|
||||
.auth-form__notice {
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.auth-shell {
|
||||
padding: 20px;
|
||||
|
|
@ -268,8 +275,6 @@
|
|||
.auth-shell__aside {
|
||||
padding: 36px 28px 28px;
|
||||
gap: 24px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
}
|
||||
|
||||
.auth-shell__main {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
background: #f5f6fa;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
/* Left Hero Section */
|
||||
.login-left {
|
||||
flex: 1.1;
|
||||
background: #f9fafe;
|
||||
background: linear-gradient(140deg, #f0f5ff 0%, #eef5ff 40%, #f7fbff 100%);
|
||||
padding: 56px 64px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -25,13 +26,14 @@
|
|||
.brand-logo-img {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
filter: drop-shadow(0 8px 16px rgba(45, 107, 255, 0.24));
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #2f3a4f;
|
||||
letter-spacing: 0;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.login-hero {
|
||||
|
|
@ -40,12 +42,12 @@
|
|||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 36px;
|
||||
font-size: 42px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: #333;
|
||||
color: #1d2b3a;
|
||||
margin-bottom: 24px;
|
||||
letter-spacing: 0;
|
||||
letter-spacing: -0.5px;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
|
|
@ -57,7 +59,7 @@
|
|||
.hero-desc {
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
color: #596275;
|
||||
color: #687489;
|
||||
max-width: 440px;
|
||||
}
|
||||
|
||||
|
|
@ -77,6 +79,7 @@
|
|||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Right Form Section */
|
||||
.login-right {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
|
@ -98,9 +101,9 @@
|
|||
.login-header h2 {
|
||||
font-size: 28px !important;
|
||||
font-weight: 700 !important;
|
||||
color: #333 !important;
|
||||
color: #1f2a37 !important;
|
||||
margin-bottom: 8px !important;
|
||||
letter-spacing: 0;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.login-header span {
|
||||
|
|
@ -114,7 +117,7 @@
|
|||
|
||||
.login-form .ant-input-affix-wrapper-lg {
|
||||
padding: 10px 16px;
|
||||
border-radius: 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.captcha-wrapper {
|
||||
|
|
@ -127,7 +130,7 @@
|
|||
width: 120px;
|
||||
height: 46px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -163,7 +166,7 @@
|
|||
height: 48px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
|
|
@ -171,14 +174,14 @@
|
|||
text-align: center;
|
||||
padding: 16px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.tabular-nums {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1024px) {
|
||||
.login-left {
|
||||
padding: 48px;
|
||||
|
|
@ -198,9 +201,8 @@
|
|||
.login-container {
|
||||
background: #ffffff;
|
||||
padding: 48px 32px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
box-shadow: none;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ export default function ResetPassword() {
|
|||
type="info"
|
||||
message="密码更新后将直接进入系统首页"
|
||||
description="新密码提交成功后立即生效,旧密码会同时失效。"
|
||||
className="auth-form__notice"
|
||||
style={{marginBottom: 20, borderRadius: 14}}
|
||||
/>
|
||||
|
||||
<Form form={form} layout="vertical" onFinish={onFinish} requiredMark={false} className="auth-form">
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
.role-permission-page {
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.role-permission-page > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.role-permission-layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.role-permission-toolbar {
|
||||
margin: 0 8px 8px;
|
||||
}
|
||||
|
||||
.role-permission-layout > .ant-col {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.role-permission-layout .full-height-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.role-permission-layout .full-height-card .ant-card-body {
|
||||
min-height: 0;
|
||||
}
|
||||
|
|
@ -17,11 +17,10 @@ import { useEffect, useMemo, useState } from "react";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { ClusterOutlined, KeyOutlined, SafetyCertificateOutlined, SaveOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { listPermissions, listRolePermissions, listRoles, saveRolePermissions } from "@/api";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import { getStandardPagination } from "@/utils/pagination";
|
||||
import type { SysPermission, SysRole } from "@/types";
|
||||
import "./index.less";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
|
|
@ -176,26 +175,22 @@ export default function RolePermissionBinding() {
|
|||
};
|
||||
|
||||
return (
|
||||
<PageContainer title={null} className="role-permission-page">
|
||||
<SectionCard
|
||||
title={t("rolePerm.title")}
|
||||
description={t("rolePerm.subtitle")}
|
||||
>
|
||||
<div className="app-page__content-toolbar role-permission-toolbar">
|
||||
<div className="app-page__content-toolbar-actions">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined aria-hidden="true" />}
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={!selectedRoleId || (selectedRole?.roleCode === "TENANT_ADMIN" && !isPlatformMode)}
|
||||
>
|
||||
{saving ? t("common.loading") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="app-page__content-toolbar-filters" />
|
||||
</div>
|
||||
<Row gutter={16} className="role-permission-layout">
|
||||
<PageContainer
|
||||
title={t("rolePerm.title")}
|
||||
subtitle={t("rolePerm.subtitle")}
|
||||
headerExtra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined aria-hidden="true" />}
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={!selectedRoleId || (selectedRole?.roleCode === "TENANT_ADMIN" && !isPlatformMode)}
|
||||
>
|
||||
{saving ? t("common.loading") : t("common.save")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Row gutter={24} style={{ height: "calc(100vh - 180px)" }}>
|
||||
<Col xs={24} lg={10} style={{ height: "100%" }}>
|
||||
<Card title={<Space><SafetyCertificateOutlined aria-hidden="true" /><span>{t("rolePerm.roleList")}</span></Space>} className="app-page__panel-card full-height-card">
|
||||
<div className="mb-4">
|
||||
|
|
@ -280,7 +275,6 @@ export default function RolePermissionBinding() {
|
|||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</SectionCard>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
.user-role-page {
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.user-role-page > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.user-role-layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-role-toolbar {
|
||||
margin: 0 8px 8px;
|
||||
}
|
||||
|
||||
.user-role-layout > .ant-col {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.user-role-layout .full-height-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.user-role-layout .full-height-card .ant-card-body {
|
||||
min-height: 0;
|
||||
}
|
||||
|
|
@ -17,10 +17,9 @@ import { useTranslation } from "react-i18next";
|
|||
import { listRoles, listUserRoles, listUsers, saveUserRoles } from "@/api";
|
||||
import { SaveOutlined, SearchOutlined, TeamOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import type { SysRole, SysUser } from "@/types";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import { getStandardPagination } from "@/utils/pagination";
|
||||
import "./index.less";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
|
|
@ -102,20 +101,16 @@ export default function UserRoleBinding() {
|
|||
};
|
||||
|
||||
return (
|
||||
<PageContainer title={null} className="user-role-page">
|
||||
<SectionCard
|
||||
title={t("userRole.title")}
|
||||
description={t("userRole.subtitle")}
|
||||
>
|
||||
<div className="app-page__content-toolbar user-role-toolbar">
|
||||
<div className="app-page__content-toolbar-actions">
|
||||
<Button type="primary" icon={<SaveOutlined aria-hidden="true" />} onClick={handleSave} loading={saving} disabled={!selectedUserId}>
|
||||
{saving ? t("common.loading") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="app-page__content-toolbar-filters" />
|
||||
</div>
|
||||
<Row gutter={16} className="user-role-layout">
|
||||
<PageContainer
|
||||
title={t("userRole.title")}
|
||||
subtitle={t("userRole.subtitle")}
|
||||
headerExtra={
|
||||
<Button type="primary" icon={<SaveOutlined aria-hidden="true" />} onClick={handleSave} loading={saving} disabled={!selectedUserId}>
|
||||
{saving ? t("common.loading") : t("common.save")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Row gutter={24} style={{ height: "calc(100vh - 180px)" }}>
|
||||
<Col xs={24} lg={12} style={{ height: "100%" }}>
|
||||
<Card title={<Space><UserOutlined aria-hidden="true" /><span>{t("userRole.userList")}</span></Space>} className="app-page__panel-card full-height-card">
|
||||
<div className="mb-4">
|
||||
|
|
@ -202,7 +197,6 @@ export default function UserRoleBinding() {
|
|||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</SectionCard>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
.ai-models-page {
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.ai-models-page > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ai-models-page .section-card__tabs {
|
||||
margin-bottom: 0;
|
||||
padding: 8px 8px 0;
|
||||
border-radius: 4px 4px 0 0;
|
||||
background-color: #f9fafe;
|
||||
}
|
||||
|
||||
.ai-models-page .section-card__content {
|
||||
border-radius: 0 0 4px 4px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.ai-models-search {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.ai-models-data-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ai-models-data-panel .data-list-panel__table-area {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.ai-models-search,
|
||||
.ai-models-data-panel .data-list-panel__right-actions .ant-input-affix-wrapper {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AutoComplete, Button, Col, Divider, Drawer, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Switch, Table, Tabs, Tag, Tooltip, Typography, App } from 'antd';
|
||||
import { AutoComplete, Button, Card, Col, Divider, Drawer, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Switch, Table, Tabs, Tag, Tooltip, Typography, App } from 'antd';
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import DataListPanel from "@/components/shared/DataListPanel";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
|
|
@ -28,7 +26,6 @@ import {
|
|||
} from "../../api/business/aimodel";
|
||||
import {getMeetingCreateConfig, type MeetingCreateConfig} from "../../api/business/meeting";
|
||||
import AppPagination from "../../components/shared/AppPagination";
|
||||
import "./AiModels.css";
|
||||
|
||||
const { Option } = Select;
|
||||
const { Title } = Typography;
|
||||
|
|
@ -73,6 +70,7 @@ const AiModels: React.FC = () => {
|
|||
const [fetchLoading, setFetchLoading] = useState(false);
|
||||
const [connectivityLoading, setConnectivityLoading] = useState(false);
|
||||
const [remoteModels, setRemoteModels] = useState<string[]>([]);
|
||||
const [speakerModels, setSpeakerModels] = useState<string[]>([]);
|
||||
const [createConfig, setCreateConfig] = useState<MeetingCreateConfig>(DEFAULT_CREATE_CONFIG);
|
||||
const modelNameAutoFilledRef = useRef(false);
|
||||
const localProfileLoadedRef = useRef(false);
|
||||
|
|
@ -150,6 +148,7 @@ const AiModels: React.FC = () => {
|
|||
|
||||
const openDrawer = (record?: AiModelVO) => {
|
||||
setRemoteModels([]);
|
||||
setSpeakerModels([]);
|
||||
modelNameAutoFilledRef.current = false;
|
||||
localProfileLoadedRef.current = false;
|
||||
|
||||
|
|
@ -179,6 +178,7 @@ const AiModels: React.FC = () => {
|
|||
setRemoteModels([record.modelCode]);
|
||||
}
|
||||
if (speakerModel) {
|
||||
setSpeakerModels([String(speakerModel)]);
|
||||
}
|
||||
} else {
|
||||
setEditingId(null);
|
||||
|
|
@ -252,7 +252,9 @@ const AiModels: React.FC = () => {
|
|||
|
||||
const applyLocalProfile = (profile: AiLocalProfileVO, baseUrl: string) => {
|
||||
const nextRemoteModels = Array.isArray(profile.asrModels) ? profile.asrModels : [];
|
||||
const nextSpeakerModels = Array.isArray(profile.speakerModels) ? profile.speakerModels : [];
|
||||
setRemoteModels(nextRemoteModels);
|
||||
setSpeakerModels(nextSpeakerModels);
|
||||
|
||||
const nextValues: Record<string, unknown> = {};
|
||||
if (profile.activeAsrModel) {
|
||||
|
|
@ -447,66 +449,56 @@ const AiModels: React.FC = () => {
|
|||
|
||||
return (
|
||||
<PageContainer
|
||||
title={null}
|
||||
className="ai-models-page"
|
||||
title="AI 模型配置"
|
||||
subtitle="管理ASR语音识别和LLM大语言模型"
|
||||
headerExtra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openDrawer()}>
|
||||
新增模型
|
||||
</Button>
|
||||
}
|
||||
toolbar={
|
||||
<Input
|
||||
placeholder="搜索模型名称"
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
onPressEnter={(event) => setSearchName((event.target as HTMLInputElement).value)}
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SectionCard
|
||||
title="AI 模型配置"
|
||||
description="管理 ASR 语音识别和 LLM 大语言模型。"
|
||||
tabs={
|
||||
<Tabs
|
||||
activeKey={activeType}
|
||||
onChange={(key) => {
|
||||
setActiveType(key as ModelType);
|
||||
setCurrent(1);
|
||||
}}
|
||||
items={[
|
||||
{ key: "ASR", label: "ASR 模型" },
|
||||
{ key: "LLM", label: "LLM 模型" },
|
||||
]}
|
||||
size="middle"
|
||||
type="card"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DataListPanel
|
||||
className="ai-models-data-panel"
|
||||
leftActions={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openDrawer()}>
|
||||
新增模型
|
||||
</Button>
|
||||
}
|
||||
rightActions={
|
||||
<Input
|
||||
placeholder="搜索模型名称"
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
onPressEnter={(event) => setSearchName((event.target as HTMLInputElement).value)}
|
||||
className="ai-models-search"
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<AppPagination
|
||||
current={current}
|
||||
pageSize={size}
|
||||
total={total}
|
||||
onChange={(page, pageSize) => {
|
||||
setCurrent(page);
|
||||
setSize(pageSize);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
scroll={{ x: "max-content", y: "100%" }}
|
||||
pagination={false}
|
||||
/>
|
||||
</DataListPanel>
|
||||
</SectionCard>
|
||||
<Tabs
|
||||
activeKey={activeType}
|
||||
onChange={(key) => {
|
||||
setActiveType(key as ModelType);
|
||||
setCurrent(1);
|
||||
}}
|
||||
items={[
|
||||
{ key: "ASR", label: "ASR 模型" },
|
||||
{ key: "LLM", label: "LLM 模型" },
|
||||
]}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Card className="app-page__content-card" style={{ flex: 1, minHeight: 0 }} styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' } }}>
|
||||
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
scroll={{ x: "max-content", y: "100%" }}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
<AppPagination
|
||||
current={current}
|
||||
pageSize={size}
|
||||
total={total}
|
||||
onChange={(page, pageSize) => {
|
||||
setCurrent(page);
|
||||
setSize(pageSize);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
width={600}
|
||||
|
|
@ -726,12 +718,12 @@ const AiModels: React.FC = () => {
|
|||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="temperature" label="温度">
|
||||
<Form.Item name="temperature" label="Temperature">
|
||||
<InputNumber min={0} max={2} step={0.1} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="topP" label="Top P(核采样)">
|
||||
<Form.Item name="topP" label="Top P">
|
||||
<InputNumber min={0} max={1} step={0.1} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
.client-management-page {
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.client-management-page > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.client-summary-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.client-summary-chip {
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
background: #f9fafe;
|
||||
color: #333;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-summary-chip .anticon {
|
||||
color: #3c70f5;
|
||||
}
|
||||
|
||||
.client-summary-chip strong {
|
||||
color: #1677ff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.client-summary-chips,
|
||||
.client-summary-chip {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
import {
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Drawer,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
|
|
@ -11,23 +13,21 @@ import {
|
|||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Upload
|
||||
} from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { CloudUploadOutlined, DeleteOutlined, DownloadOutlined, EditOutlined, LaptopOutlined, MobileOutlined, PlusOutlined, ReloadOutlined, RocketOutlined, SearchOutlined, UploadOutlined } from "@ant-design/icons";
|
||||
import { CheckCircleOutlined, CloudUploadOutlined, DeleteOutlined, DownloadOutlined, EditOutlined, LaptopOutlined, MobileOutlined, PlusOutlined, ReloadOutlined, RocketOutlined, SearchOutlined, UploadOutlined, WindowsOutlined } from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import DataListPanel from "@/components/shared/DataListPanel";
|
||||
import ListTable from "@/components/shared/ListTable/ListTable";
|
||||
import AppPagination from "@/components/shared/AppPagination";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import { createClientDownload, deleteClientDownload, listClientDownloads, type ClientDownloadDTO, type ClientDownloadVO, updateClientDownload, uploadClientPackage } from "@/api/business/client";
|
||||
import { fetchDictItemsByTypeCode } from "@/api/dict";
|
||||
import { useDict } from "@/hooks/useDict";
|
||||
import type { SysDictItem } from "@/types";
|
||||
import "./ClientManagement.css";
|
||||
|
||||
const { Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
|
@ -406,76 +406,108 @@ export default function ClientManagement() {
|
|||
];
|
||||
|
||||
return (
|
||||
<PageContainer title={null} className="client-management-page">
|
||||
<SectionCard
|
||||
title="客户端管理"
|
||||
description="发布平台由数据字典 client_platform 驱动,并按父子分组展示。"
|
||||
>
|
||||
<DataListPanel
|
||||
leftActions={
|
||||
<Space wrap>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
发布新版
|
||||
</Button>
|
||||
<div className="client-summary-chips" aria-label="客户端发布统计">
|
||||
<span className="client-summary-chip"><CloudUploadOutlined aria-hidden="true" /><span>发布总数</span><strong>{stats.total}</strong></span>
|
||||
<span className="client-summary-chip"><LaptopOutlined aria-hidden="true" /><span>已启用</span><strong>{stats.enabled}</strong></span>
|
||||
<span className="client-summary-chip"><MobileOutlined aria-hidden="true" /><span>最新版本</span><strong>{stats.latest}</strong></span>
|
||||
<span className="client-summary-chip"><RocketOutlined aria-hidden="true" /><span>平台分组</span><strong>{stats.groups}</strong></span>
|
||||
<PageContainer
|
||||
title="客户端管理"
|
||||
subtitle="发布平台由数据字典 client_platform 驱动,并按父子分组展示。"
|
||||
headerExtra={
|
||||
<Space size={12}>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading || groupLoading || platformLoading} style={{ borderRadius: 8, height: 36, fontWeight: 500 }}>刷新</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate} style={{ borderRadius: 8, height: 36, fontWeight: 500 }}>发布新版</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Row gutter={24} style={{ marginBottom: 24 }}>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless" style={{ borderRadius: 16, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface-strong)', backdropFilter: 'blur(12px)', boxShadow: '0 4px 20px rgba(0,0,0,0.02)' }} styles={{ body: { padding: '20px 24px' } }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ color: 'var(--app-text-secondary)', fontSize: 13, fontWeight: 500 }}>发布总数</span>
|
||||
<span style={{ color: 'var(--app-text-main)', fontSize: 32, fontWeight: 800, fontFamily: 'system-ui, -apple-system, sans-serif' }}>{stats.total}</span>
|
||||
</div>
|
||||
</Space>
|
||||
}
|
||||
rightActions={
|
||||
<Space wrap>
|
||||
<Input
|
||||
placeholder="搜索平台、版本、系统要求或下载地址"
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
style={{ width: 300 }}
|
||||
value={searchValue}
|
||||
onChange={(event) => setSearchValue(event.target.value)}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 140 }}
|
||||
value={statusFilter}
|
||||
options={STATUS_FILTER_OPTIONS as unknown as { label: string; value: string }[]}
|
||||
onChange={(value) => setStatusFilter(value as typeof statusFilter)}
|
||||
/>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: 160 }}
|
||||
value={activeTab}
|
||||
options={platformTypeOptions}
|
||||
onChange={setActiveTab}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading || groupLoading || platformLoading}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
footer={
|
||||
<AppPagination
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={filteredRecords.length}
|
||||
onChange={(nextPage, nextSize) => {
|
||||
setPage(nextPage);
|
||||
setPageSize(nextSize);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ListTable<ClientDownloadVO>
|
||||
<div style={{ width: 52, height: 52, borderRadius: 16, background: 'linear-gradient(135deg, rgba(22, 119, 255, 0.1), rgba(54, 207, 201, 0.1))', border: '1px solid rgba(22, 119, 255, 0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<CloudUploadOutlined style={{ color: "#1677ff", fontSize: 24 }} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless" style={{ borderRadius: 16, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface-strong)', backdropFilter: 'blur(12px)', boxShadow: '0 4px 20px rgba(0,0,0,0.02)' }} styles={{ body: { padding: '20px 24px' } }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ color: 'var(--app-text-secondary)', fontSize: 13, fontWeight: 500 }}>已启用</span>
|
||||
<span style={{ color: 'var(--app-text-main)', fontSize: 32, fontWeight: 800, fontFamily: 'system-ui, -apple-system, sans-serif' }}>{stats.enabled}</span>
|
||||
</div>
|
||||
<div style={{ width: 52, height: 52, borderRadius: 16, background: 'linear-gradient(135deg, rgba(82, 196, 26, 0.1), rgba(183, 235, 143, 0.1))', border: '1px solid rgba(82, 196, 26, 0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<LaptopOutlined style={{ color: "#52c41a", fontSize: 24 }} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless" style={{ borderRadius: 16, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface-strong)', backdropFilter: 'blur(12px)', boxShadow: '0 4px 20px rgba(0,0,0,0.02)' }} styles={{ body: { padding: '20px 24px' } }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ color: 'var(--app-text-secondary)', fontSize: 13, fontWeight: 500 }}>最新版本</span>
|
||||
<span style={{ color: 'var(--app-text-main)', fontSize: 32, fontWeight: 800, fontFamily: 'system-ui, -apple-system, sans-serif' }}>{stats.latest}</span>
|
||||
</div>
|
||||
<div style={{ width: 52, height: 52, borderRadius: 16, background: 'linear-gradient(135deg, rgba(114, 46, 209, 0.1), rgba(179, 127, 235, 0.1))', border: '1px solid rgba(114, 46, 209, 0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<MobileOutlined style={{ color: "#722ed1", fontSize: 24 }} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless" style={{ borderRadius: 16, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface-strong)', backdropFilter: 'blur(12px)', boxShadow: '0 4px 20px rgba(0,0,0,0.02)' }} styles={{ body: { padding: '20px 24px' } }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ color: 'var(--app-text-secondary)', fontSize: 13, fontWeight: 500 }}>平台分组</span>
|
||||
<span style={{ color: 'var(--app-text-main)', fontSize: 32, fontWeight: 800, fontFamily: 'system-ui, -apple-system, sans-serif' }}>{stats.groups}</span>
|
||||
</div>
|
||||
<div style={{ width: 52, height: 52, borderRadius: 16, background: 'linear-gradient(135deg, rgba(250, 140, 22, 0.1), rgba(255, 213, 145, 0.1))', border: '1px solid rgba(250, 140, 22, 0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<LaptopOutlined style={{ color: "#fa8c16", fontSize: 24 }} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card className="app-page__filter-card mb-4" styles={{ body: { padding: 16 } }}>
|
||||
<Space wrap style={{width: "100%"}}>
|
||||
<Input placeholder="搜索平台、版本、系统要求或下载地址" prefix={<SearchOutlined/>} allowClear
|
||||
style={{width: 320}} value={searchValue} onChange={(event) => setSearchValue(event.target.value)}/>
|
||||
<Select style={{width: 150}} value={statusFilter}
|
||||
options={STATUS_FILTER_OPTIONS as unknown as { label: string; value: string }[]}
|
||||
onChange={(value) => setStatusFilter(value as typeof statusFilter)}/>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{width: 180}}
|
||||
value={activeTab}
|
||||
options={platformTypeOptions}
|
||||
onChange={setActiveTab}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card className="app-page__content-card flex-1 flex flex-col overflow-hidden" styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}>
|
||||
<div className="app-page__table-wrap" style={{padding: "0 24px", overflow: "hidden"}}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={pagedRecords}
|
||||
loading={loading || groupLoading || platformLoading}
|
||||
locale={platformGroups.length === 0 ? { emptyText: <Empty description="未配置 client_platform 字典项" /> } : undefined}
|
||||
scroll={{ x: 1100, y: "100%" }}
|
||||
pagination={false}
|
||||
/>
|
||||
</DataListPanel>
|
||||
</SectionCard>
|
||||
</div>
|
||||
<AppPagination
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={filteredRecords.length}
|
||||
onChange={(nextPage, nextSize) => { setPage(nextPage); setPageSize(nextSize); }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Drawer title={editing ? "编辑客户端版本" : "新增客户端版本"} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={680} destroyOnHidden forceRender footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>取消</Button><Button type="primary" icon={<UploadOutlined />} loading={saving} onClick={() => void handleSubmit()}>保存</Button></div>}>
|
||||
<Form form={form} layout="vertical">
|
||||
|
|
|
|||
|
|
@ -1,145 +0,0 @@
|
|||
.external-app-page {
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.external-app-page > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.external-app-summary-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.external-app-summary-chip {
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
background: #f9fafe;
|
||||
color: #333;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.external-app-summary-chip .anticon {
|
||||
color: #3c70f5;
|
||||
}
|
||||
|
||||
.external-app-summary-chip strong {
|
||||
color: #1677ff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.external-app-icon-form-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.external-app-icon-form-item .ant-upload {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.external-app-icon-picker {
|
||||
width: 128px;
|
||||
min-height: 128px;
|
||||
padding: 12px 10px;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: #667085;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
background: #f8fafc;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 6px;
|
||||
transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.external-app-icon-picker:hover,
|
||||
.external-app-icon-picker:focus-visible {
|
||||
color: #1677ff;
|
||||
background: #f0f7ff;
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.08);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.external-app-icon-picker:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.external-app-icon-picker__preview {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
color: #94a3b8;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.external-app-icon-picker__preview > .anticon {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.external-app-icon-picker__preview .ant-avatar {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.external-app-icon-picker__text {
|
||||
color: #1f2937;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.external-app-icon-picker__hint {
|
||||
max-width: 100%;
|
||||
color: #98a2b3;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.external-app-icon-picker.has-image {
|
||||
background: #fff;
|
||||
border-style: solid;
|
||||
border-color: #d9e4f5;
|
||||
}
|
||||
|
||||
.external-app-icon-picker.has-image .external-app-icon-picker__preview {
|
||||
border-color: transparent;
|
||||
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.external-app-summary-chips,
|
||||
.external-app-summary-chip {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.external-app-icon-picker {
|
||||
width: 100%;
|
||||
min-height: 116px;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,11 @@
|
|||
import { App, Avatar, Button, Col, Drawer, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Switch, Tag, Typography, Upload } from "antd";
|
||||
import { App, Avatar, Button, Card, Col, Drawer, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Switch, Table, Tag, Typography, Upload } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { AppstoreOutlined, DeleteOutlined, EditOutlined, GlobalOutlined, LinkOutlined, PictureOutlined, PlusOutlined, ReloadOutlined, RobotOutlined, SaveOutlined, SearchOutlined, UploadOutlined } from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import DataListPanel from "@/components/shared/DataListPanel";
|
||||
import ListTable from "@/components/shared/ListTable/ListTable";
|
||||
import AppPagination from "@/components/shared/AppPagination";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import { createExternalApp, deleteExternalApp, listExternalApps, type ExternalAppDTO, type ExternalAppVO, updateExternalApp, uploadExternalAppApk, uploadExternalAppIcon } from "@/api/business/externalApp";
|
||||
import "./ExternalAppManagement.css";
|
||||
|
||||
const { Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
|
@ -55,7 +52,6 @@ function compactObject<T extends Record<string, unknown>>(value: T): T {
|
|||
export default function ExternalAppManagement() {
|
||||
const { message } = App.useApp();
|
||||
const [form] = Form.useForm<ExternalAppFormValues>();
|
||||
const watchedIconUrl = Form.useWatch("iconUrl", form);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
|
@ -295,57 +291,92 @@ export default function ExternalAppManagement() {
|
|||
];
|
||||
|
||||
return (
|
||||
<PageContainer title={null} className="external-app-page">
|
||||
<SectionCard
|
||||
title="外部应用管理"
|
||||
description="统一维护首页九宫格与抽屉入口中的原生应用、Web 服务和应用图标资源。"
|
||||
>
|
||||
<DataListPanel
|
||||
leftActions={
|
||||
<Space wrap>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新增应用
|
||||
</Button>
|
||||
<div className="external-app-summary-chips" aria-label="外部应用统计">
|
||||
<span className="external-app-summary-chip"><AppstoreOutlined aria-hidden="true" /><span>应用总数</span><strong>{stats.total}</strong></span>
|
||||
<span className="external-app-summary-chip"><RobotOutlined aria-hidden="true" /><span>原生应用</span><strong>{stats.native}</strong></span>
|
||||
<span className="external-app-summary-chip"><GlobalOutlined aria-hidden="true" /><span>Web 应用</span><strong>{stats.web}</strong></span>
|
||||
<span className="external-app-summary-chip"><PictureOutlined aria-hidden="true" /><span>已启用</span><strong>{stats.enabled}</strong></span>
|
||||
<PageContainer
|
||||
title="外部应用管理"
|
||||
subtitle="统一维护首页九宫格与抽屉入口中的原生应用、Web 服务和应用图标资源。"
|
||||
headerExtra={
|
||||
<Space size={12}>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading} style={{ borderRadius: 8, height: 36, fontWeight: 500 }}>刷新</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate} style={{ borderRadius: 8, height: 36, fontWeight: 500 }}>新增应用</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Row gutter={24} style={{ marginBottom: 24 }}>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless" style={{ borderRadius: 16, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface-strong)', backdropFilter: 'blur(12px)', boxShadow: '0 4px 20px rgba(0,0,0,0.02)' }} styles={{ body: { padding: '20px 24px' } }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ color: 'var(--app-text-secondary)', fontSize: 13, fontWeight: 500 }}>应用总数</span>
|
||||
<span style={{ color: 'var(--app-text-main)', fontSize: 32, fontWeight: 800, fontFamily: 'system-ui, -apple-system, sans-serif' }}>{stats.total}</span>
|
||||
</div>
|
||||
</Space>
|
||||
}
|
||||
rightActions={
|
||||
<Space wrap>
|
||||
<Input placeholder="搜索名称、描述、包名或入口地址" prefix={<SearchOutlined />} allowClear style={{ width: 300 }} value={searchValue} onChange={(event) => setSearchValue(event.target.value)} />
|
||||
<Select value={appTypeFilter} style={{ width: 130 }} onChange={(value) => setAppTypeFilter(value)} options={[{ label: "全部类型", value: "all" }, { label: "原生应用", value: "native" }, { label: "Web 应用", value: "web" }]} />
|
||||
<Select value={statusFilter} style={{ width: 130 }} onChange={(value) => setStatusFilter(value as typeof statusFilter)} options={STATUS_OPTIONS as unknown as { label: string; value: string }[]} />
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
footer={
|
||||
<AppPagination
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={filteredRecords.length}
|
||||
onChange={(nextPage, nextSize) => {
|
||||
setPage(nextPage);
|
||||
setPageSize(nextSize);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ListTable<ExternalAppVO>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={pagedRecords}
|
||||
loading={loading}
|
||||
scroll={{ x: "max-content", y: "100%" }}
|
||||
pagination={false}
|
||||
/>
|
||||
</DataListPanel>
|
||||
</SectionCard>
|
||||
<div style={{ width: 52, height: 52, borderRadius: 16, background: 'linear-gradient(135deg, rgba(22, 119, 255, 0.1), rgba(54, 207, 201, 0.1))', border: '1px solid rgba(22, 119, 255, 0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<AppstoreOutlined style={{ color: "#1677ff", fontSize: 24 }} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless" style={{ borderRadius: 16, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface-strong)', backdropFilter: 'blur(12px)', boxShadow: '0 4px 20px rgba(0,0,0,0.02)' }} styles={{ body: { padding: '20px 24px' } }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ color: 'var(--app-text-secondary)', fontSize: 13, fontWeight: 500 }}>原生应用</span>
|
||||
<span style={{ color: 'var(--app-text-main)', fontSize: 32, fontWeight: 800, fontFamily: 'system-ui, -apple-system, sans-serif' }}>{stats.native}</span>
|
||||
</div>
|
||||
<div style={{ width: 52, height: 52, borderRadius: 16, background: 'linear-gradient(135deg, rgba(82, 196, 26, 0.1), rgba(183, 235, 143, 0.1))', border: '1px solid rgba(82, 196, 26, 0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<RobotOutlined style={{ color: "#52c41a", fontSize: 24 }} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless" style={{ borderRadius: 16, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface-strong)', backdropFilter: 'blur(12px)', boxShadow: '0 4px 20px rgba(0,0,0,0.02)' }} styles={{ body: { padding: '20px 24px' } }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ color: 'var(--app-text-secondary)', fontSize: 13, fontWeight: 500 }}>Web 应用</span>
|
||||
<span style={{ color: 'var(--app-text-main)', fontSize: 32, fontWeight: 800, fontFamily: 'system-ui, -apple-system, sans-serif' }}>{stats.web}</span>
|
||||
</div>
|
||||
<div style={{ width: 52, height: 52, borderRadius: 16, background: 'linear-gradient(135deg, rgba(114, 46, 209, 0.1), rgba(179, 127, 235, 0.1))', border: '1px solid rgba(114, 46, 209, 0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<GlobalOutlined style={{ color: "#722ed1", fontSize: 24 }} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless" style={{ borderRadius: 16, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface-strong)', backdropFilter: 'blur(12px)', boxShadow: '0 4px 20px rgba(0,0,0,0.02)' }} styles={{ body: { padding: '20px 24px' } }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ color: 'var(--app-text-secondary)', fontSize: 13, fontWeight: 500 }}>已启用</span>
|
||||
<span style={{ color: 'var(--app-text-main)', fontSize: 32, fontWeight: 800, fontFamily: 'system-ui, -apple-system, sans-serif' }}>{stats.enabled}</span>
|
||||
</div>
|
||||
<div style={{ width: 52, height: 52, borderRadius: 16, background: 'linear-gradient(135deg, rgba(250, 140, 22, 0.1), rgba(255, 213, 145, 0.1))', border: '1px solid rgba(250, 140, 22, 0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<PictureOutlined style={{ color: "#fa8c16", fontSize: 24 }} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card className="app-page__filter-card mb-4" styles={{ body: { padding: 16 } }}>
|
||||
<Space wrap style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<Space wrap>
|
||||
<Input placeholder="搜索名称、描述、包名或入口地址" prefix={<SearchOutlined />} allowClear style={{ width: 320 }} value={searchValue} onChange={(event) => setSearchValue(event.target.value)} />
|
||||
<Select value={appTypeFilter} style={{ width: 140 }} onChange={(value) => setAppTypeFilter(value)} options={[{ label: "全部类型", value: "all" }, { label: "原生应用", value: "native" }, { label: "Web 应用", value: "web" }]} />
|
||||
<Select value={statusFilter} style={{ width: 140 }} onChange={(value) => setStatusFilter(value as typeof statusFilter)} options={STATUS_OPTIONS as unknown as { label: string; value: string }[]} />
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card className="app-page__content-card flex-1 flex flex-col overflow-hidden" styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}>
|
||||
<div className="app-page__table-wrap" style={{ padding: "0 24px", overflow: "auto" }}>
|
||||
<Table rowKey="id" columns={columns} dataSource={pagedRecords} loading={loading} scroll={{ x: "max-content", y: "100%" }} pagination={false} />
|
||||
</div>
|
||||
<AppPagination
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={filteredRecords.length}
|
||||
onChange={(nextPage, nextSize) => { setPage(nextPage); setPageSize(nextSize); }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Drawer title={editing ? "编辑外部应用" : "新增外部应用"} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={700} destroyOnHidden forceRender footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>取消</Button><Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => void handleSubmit()}>保存</Button></div>}>
|
||||
<Form form={form} layout="vertical">
|
||||
|
|
@ -354,36 +385,14 @@ export default function ExternalAppManagement() {
|
|||
<Col span={8}><Form.Item name="appType" label="应用类型" rules={[{ required: true, message: "请选择应用类型" }]}><Select options={[{ label: "原生应用", value: "native" }, { label: "Web 应用", value: "web" }]} /></Form.Item></Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item name="iconUrl" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="应用图标" className="external-app-icon-form-item">
|
||||
<Upload
|
||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||
showUploadList={false}
|
||||
beforeUpload={(file) => { void handleUploadIcon(file as File); return Upload.LIST_IGNORE; }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={watchedIconUrl ? "external-app-icon-picker has-image" : "external-app-icon-picker"}
|
||||
disabled={uploadingIcon}
|
||||
aria-label="上传应用图标"
|
||||
>
|
||||
<span className="external-app-icon-picker__preview">
|
||||
{watchedIconUrl ? (
|
||||
<Avatar shape="square" size={72} src={watchedIconUrl} icon={<PictureOutlined />} />
|
||||
) : (
|
||||
<PictureOutlined />
|
||||
)}
|
||||
</span>
|
||||
<span className="external-app-icon-picker__text">
|
||||
{uploadingIcon ? "上传中..." : watchedIconUrl ? "更换图标" : "点击上传图标"}
|
||||
</span>
|
||||
<span className="external-app-icon-picker__hint">PNG / JPG / WebP / SVG</span>
|
||||
</button>
|
||||
<Space align="start" className="mb-4">
|
||||
<Upload showUploadList={false} beforeUpload={(file) => { void handleUploadIcon(file as File); return Upload.LIST_IGNORE; }}>
|
||||
<Avatar shape="square" size={72} src={form.getFieldValue("iconUrl")} icon={<PictureOutlined />} style={{ cursor: "pointer", border: "1px dashed var(--app-border-color)", background: "var(--app-bg-card)" }} />
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Form.Item name="iconUrl" label="图标地址"><Input placeholder="上传图标后自动回填,或手动填写" /></Form.Item>
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
<Form.Item noStyle shouldUpdate={(prev, curr) => prev.appType !== curr.appType}>
|
||||
{({ getFieldValue }) => getFieldValue("appType") === "native" ? (
|
||||
|
|
|
|||
|
|
@ -1,343 +0,0 @@
|
|||
.hotwords-page {
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.hotwords-page > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.hotwords-layout {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hotwords-group-panel.ant-card {
|
||||
width: 300px;
|
||||
min-width: 240px;
|
||||
max-width: 320px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e6e6e6 !important;
|
||||
border-radius: 4px !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.hotwords-group-panel .ant-card-head {
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.hotwords-group-panel .ant-card-head-title {
|
||||
color: #333;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hotwords-group-panel .ant-card-extra .ant-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.hotwords-group-panel .ant-card-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hotwords-group-panel__filters {
|
||||
flex-shrink: 0;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.hotwords-group-panel__filters .ant-input-search,
|
||||
.hotwords-group-panel__filters .ant-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hotwords-group-panel__filter-stack {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hotwords-group-panel__list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.hotwords-group-panel__list .ant-list {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.hotwords-group-item.ant-list-item {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
min-height: 68px;
|
||||
padding: 10px 12px 10px 14px !important;
|
||||
border-bottom: 1px solid #f0f0f0 !important;
|
||||
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.hotwords-group-item.ant-list-item:hover {
|
||||
background: #f9fafe;
|
||||
}
|
||||
|
||||
.hotwords-group-item.is-selected {
|
||||
background: #f3f6ff;
|
||||
}
|
||||
|
||||
.hotwords-group-item.is-selected::before {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
bottom: 10px;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
border-radius: 0 2px 2px 0;
|
||||
background: #3c70f5;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.hotwords-group-item.is-selected .hotwords-group-item__title {
|
||||
color: #3c70f5;
|
||||
}
|
||||
|
||||
.hotwords-group-item .ant-list-item-meta {
|
||||
min-width: 0;
|
||||
margin-block-end: 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.hotwords-group-item .ant-list-item-action {
|
||||
margin-inline-start: 8px;
|
||||
}
|
||||
|
||||
.hotwords-group-item .ant-list-item-action > li {
|
||||
padding-inline: 0 4px;
|
||||
}
|
||||
|
||||
.hotwords-group-item .ant-list-item-action .ant-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.hotwords-group-item .ant-list-item-meta-title,
|
||||
.hotwords-group-item .ant-list-item-meta-description {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hotwords-group-item__title {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
color: #333;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hotwords-group-item__desc {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.hotwords-group-item__desc .ant-tag {
|
||||
margin: 0;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.hotwords-group-item__desc > span:last-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hotwords-group-panel .app-pagination-container {
|
||||
flex-shrink: 0;
|
||||
height: auto;
|
||||
min-height: 48px;
|
||||
padding: 8px 12px;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
border-radius: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hotwords-group-panel .app-pagination-total {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hotwords-group-panel .app-pagination-controls {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
min-width: max-content;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.hotwords-group-panel .app-pagination-container .ant-pagination {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
min-width: max-content;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.hotwords-group-panel .hotwords-group-pagination.ant-pagination-simple {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.hotwords-group-panel .hotwords-group-pagination.ant-pagination-simple .ant-pagination-prev,
|
||||
.hotwords-group-panel .hotwords-group-pagination.ant-pagination-simple .ant-pagination-next {
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
line-height: 22px;
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
.hotwords-group-panel .hotwords-group-pagination.ant-pagination-simple .ant-pagination-simple-pager {
|
||||
height: 24px;
|
||||
margin-inline: 2px;
|
||||
color: #333;
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.hotwords-group-panel .hotwords-group-pagination.ant-pagination-simple .ant-pagination-simple-pager input {
|
||||
width: 28px;
|
||||
height: 24px;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hotwords-list-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hotwords-list-panel .data-list-panel__left-actions {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.hotwords-list-panel .data-list-panel__table-area {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hotwords-pinyin-tag.ant-tag {
|
||||
max-width: 132px;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hotwords-search__word {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.hotwords-search__category {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.hotwords-modal-form {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.hotwords-drawer-form.hotwords-modal-form {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.hotwords-drawer-form .ant-form-item-label > label {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hotwords-drawer-form .ant-input,
|
||||
.hotwords-drawer-form .ant-input-number,
|
||||
.hotwords-drawer-form .ant-select-selector,
|
||||
.hotwords-drawer-form .ant-input-affix-wrapper,
|
||||
.hotwords-drawer-form textarea.ant-input {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.hotwords-drawer-form textarea.ant-input {
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.hotwords-weight-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hotwords-table.ant-table-wrapper,
|
||||
.hotwords-table .ant-spin-nested-loading,
|
||||
.hotwords-table .ant-spin-container,
|
||||
.hotwords-table .ant-table,
|
||||
.hotwords-table .ant-table-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hotwords-table .ant-table-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
.hotwords-table .ant-table-thead > tr > th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hotwords-table .ant-table-tbody > tr > td {
|
||||
height: 65px;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.hotwords-layout {
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.hotwords-group-panel.ant-card {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
min-height: 360px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hotwords-list-panel .data-list-panel__left-actions,
|
||||
.hotwords-list-panel .data-list-panel__right-actions,
|
||||
.hotwords-list-panel .data-list-panel__right-actions .ant-space,
|
||||
.hotwords-list-panel .data-list-panel__right-actions .ant-input-affix-wrapper,
|
||||
.hotwords-list-panel .data-list-panel__right-actions .ant-select {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
|
@ -19,15 +19,11 @@ import {
|
|||
Typography,
|
||||
} from "antd";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import DataListPanel from "@/components/shared/DataListPanel";
|
||||
import FormDrawer from "@/components/shared/FormDrawer";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SaveOutlined,
|
||||
SearchOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
|
@ -49,7 +45,6 @@ import {
|
|||
type HotWordGroupVO,
|
||||
} from "../../api/business/hotwordGroup";
|
||||
import AppPagination from "../../components/shared/AppPagination";
|
||||
import "./HotWords.css";
|
||||
|
||||
const { Option } = Select;
|
||||
const { Text } = Typography;
|
||||
|
|
@ -141,8 +136,8 @@ const HotWords: React.FC = () => {
|
|||
tenantId: isPlatformAdmin ? activeTenantId : undefined,
|
||||
});
|
||||
if (res.data?.data) {
|
||||
setData(res.data.data.records || []);
|
||||
setTotal(res.data.data.total || 0);
|
||||
setData(res.data.data.records);
|
||||
setTotal(res.data.data.total);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -207,9 +202,9 @@ const HotWords: React.FC = () => {
|
|||
await loadGroupPage();
|
||||
};
|
||||
|
||||
const handleSubmit = async (formValues?: HotWordFormValues) => {
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = formValues ?? await form.validateFields();
|
||||
const values = await form.validateFields();
|
||||
setSubmitLoading(true);
|
||||
const payload = {
|
||||
...values,
|
||||
|
|
@ -310,7 +305,6 @@ const HotWords: React.FC = () => {
|
|||
title: "热词原文",
|
||||
dataIndex: "word",
|
||||
key: "word",
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (text: string) => <Text strong ellipsis={{ tooltip: text }}>{text}</Text>,
|
||||
},
|
||||
|
|
@ -318,21 +312,18 @@ const HotWords: React.FC = () => {
|
|||
title: "拼音",
|
||||
dataIndex: "pinyinList",
|
||||
key: "pinyinList",
|
||||
width: 160,
|
||||
render: (list: string[]) => list?.[0] ? <Tag className="hotwords-pinyin-tag">{list[0]}</Tag> : <Text type="secondary">-</Text>,
|
||||
render: (list: string[]) => list?.[0] ? <Tag style={{ borderRadius: 4 }}>{list[0]}</Tag> : <Text type="secondary">-</Text>,
|
||||
},
|
||||
{
|
||||
title: "分类",
|
||||
dataIndex: "category",
|
||||
key: "category",
|
||||
width: 140,
|
||||
render: (value: string) => categories.find((item) => item.itemValue === value)?.itemLabel || value || "-",
|
||||
},
|
||||
{
|
||||
title: "热词组",
|
||||
dataIndex: "hotWordGroupId",
|
||||
key: "hotWordGroupId",
|
||||
width: 160,
|
||||
render: (value?: number, record?: HotWordVO) => {
|
||||
const name = record?.hotWordGroupName || (value ? groupNameMap[value] : undefined);
|
||||
return name ? <Tag color="blue">{name}</Tag> : <Text type="secondary">未分组</Text>;
|
||||
|
|
@ -342,21 +333,17 @@ const HotWords: React.FC = () => {
|
|||
title: "权重",
|
||||
dataIndex: "weight",
|
||||
key: "weight",
|
||||
width: 90,
|
||||
render: (value: number) => <Tag color="orange">{value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
width: 100,
|
||||
render: (value: number) => value === 1 ? <Badge status="success" text="启用" /> : <Badge status="default" text="禁用" />,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 140,
|
||||
fixed: "right" as const,
|
||||
render: (_: unknown, record: HotWordVO) => (
|
||||
<Space size="middle">
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleOpenModal(record)}>
|
||||
|
|
@ -379,182 +366,177 @@ const HotWords: React.FC = () => {
|
|||
|
||||
return (
|
||||
<PageContainer
|
||||
title={null}
|
||||
className="hotwords-page"
|
||||
title="热词管理"
|
||||
subtitle="管理ASR识别引擎的热词库,提升特定场景下的识别准确率"
|
||||
>
|
||||
<SectionCard
|
||||
title="热词管理"
|
||||
description="管理 ASR 识别热词与热词组,提升特定场景下的转写识别准确率。"
|
||||
>
|
||||
<div className="hotwords-layout">
|
||||
<Card
|
||||
className="hotwords-group-panel"
|
||||
title="热词组"
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => openGroupEditor()}
|
||||
size="small"
|
||||
aria-label="新增热词组"
|
||||
<div style={{ display: 'flex', gap: '16px', flex: 1, minHeight: 0 }}>
|
||||
{/* Left Panel: Hotword Groups */}
|
||||
<Card
|
||||
className="app-page__content-card"
|
||||
title="热词组"
|
||||
style={{ width: '20%', display: 'flex', flexDirection: 'column', minWidth: 240, maxWidth: 300 }}
|
||||
styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", minHeight: 0 } }}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => openGroupEditor()}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div style={{ padding: "16px 16px 12px", borderBottom: "1px solid var(--ant-color-border-secondary, #f0f0f0)" }}>
|
||||
<Space direction="vertical" size={12} style={{ width: "100%" }}>
|
||||
<Input.Search
|
||||
placeholder="搜索热词组名称"
|
||||
allowClear
|
||||
value={groupSearchInput}
|
||||
onChange={(e) => setGroupSearchInput(e.target.value)}
|
||||
onSearch={(value) => {
|
||||
setGroupSearchInput(value);
|
||||
setGroupSearchName(value.trim());
|
||||
setGroupCurrent(1);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="hotwords-group-panel__filters">
|
||||
<Space direction="vertical" size={12} className="hotwords-group-panel__filter-stack">
|
||||
<Input.Search
|
||||
placeholder="搜索热词组名称"
|
||||
allowClear
|
||||
value={groupSearchInput}
|
||||
onChange={(e) => setGroupSearchInput(e.target.value)}
|
||||
onSearch={(value) => {
|
||||
setGroupSearchInput(value);
|
||||
setGroupSearchName(value.trim());
|
||||
setGroupCurrent(1);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
placeholder="按状态筛选"
|
||||
allowClear
|
||||
value={groupSearchStatus}
|
||||
options={[
|
||||
{ label: "启用", value: 1 },
|
||||
{ label: "禁用", value: 0 },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setGroupSearchStatus(value);
|
||||
setGroupCurrent(1);
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
<div className="hotwords-group-panel__list">
|
||||
<List
|
||||
loading={groupLoading}
|
||||
dataSource={groupListData}
|
||||
renderItem={(item) => {
|
||||
const isSelected = searchGroupId === item.id;
|
||||
const actions = [];
|
||||
if (item.id) {
|
||||
actions.push(
|
||||
<Select
|
||||
placeholder="按状态筛选"
|
||||
allowClear
|
||||
value={groupSearchStatus}
|
||||
style={{ width: "100%" }}
|
||||
options={[
|
||||
{ label: "启用", value: 1 },
|
||||
{ label: "禁用", value: 0 },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setGroupSearchStatus(value);
|
||||
setGroupCurrent(1);
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
|
||||
<List
|
||||
loading={groupLoading}
|
||||
dataSource={groupListData}
|
||||
renderItem={(item) => {
|
||||
const isSelected = searchGroupId === item.id;
|
||||
const actions = [];
|
||||
if (item.id) {
|
||||
actions.push(
|
||||
<Button
|
||||
key={`edit-${item.id}`}
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={(e) => openGroupEditor(item, e)}
|
||||
size="small"
|
||||
/>
|
||||
);
|
||||
actions.push(
|
||||
<Popconfirm
|
||||
key={`delete-${item.id}`}
|
||||
title="确定删除这个热词组吗?"
|
||||
description="删除前必须先解除模板引用并清空组内热词。"
|
||||
onConfirm={(e) => handleDeleteGroup(item.id, e)}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
key={`edit-${item.id}`}
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={(e) => openGroupEditor(item, e)}
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
size="small"
|
||||
/>
|
||||
);
|
||||
actions.push(
|
||||
<Popconfirm
|
||||
key={`delete-${item.id}`}
|
||||
title="确定删除这个热词组吗?"
|
||||
description="删除前必须先解除模板引用并清空组内热词。"
|
||||
onConfirm={(e) => handleDeleteGroup(item.id, e)}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
size="small"
|
||||
/>
|
||||
</Popconfirm>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
onClick={() => handleSelectGroup(item)}
|
||||
className={isSelected ? "hotwords-group-item is-selected" : "hotwords-group-item"}
|
||||
actions={actions}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<Text strong className="hotwords-group-item__title">
|
||||
{item.groupName}
|
||||
</Text>
|
||||
}
|
||||
description={
|
||||
item.id
|
||||
? (
|
||||
<span className="hotwords-group-item__desc">
|
||||
<Tag color={item.hotWordCount >= 200 ? "red" : item.status === 1 ? "processing" : "default"}>
|
||||
{item.hotWordCount}/200
|
||||
</Tag>
|
||||
<span>{item.remark || "暂无备注"}</span>
|
||||
</span>
|
||||
)
|
||||
: "查看所有热词"
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
</Popconfirm>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<AppPagination
|
||||
className="hotwords-group-pagination"
|
||||
simple
|
||||
showSizeChanger={false}
|
||||
showQuickJumper={false}
|
||||
current={groupCurrent}
|
||||
pageSize={groupSize}
|
||||
total={groupTotal}
|
||||
onChange={(page, pageSize) => {
|
||||
setGroupCurrent(page);
|
||||
setGroupSize(pageSize);
|
||||
}
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
onClick={() => handleSelectGroup(item)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '12px 24px',
|
||||
background: isSelected ? 'var(--ant-color-primary-bg, #e6f4ff)' : 'transparent',
|
||||
borderBottom: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
|
||||
transition: 'background 0.3s'
|
||||
}}
|
||||
actions={actions}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<Text strong style={{ color: isSelected ? 'var(--ant-color-primary, #1677ff)' : 'inherit' }}>
|
||||
{item.groupName}
|
||||
</Text>
|
||||
}
|
||||
description={
|
||||
item.id
|
||||
? (
|
||||
<>
|
||||
<Tag color={item.hotWordCount >= 200 ? "red" : item.status === 1 ? "processing" : "default"}>
|
||||
{item.hotWordCount}/200
|
||||
</Tag>
|
||||
{item.remark}
|
||||
</>
|
||||
)
|
||||
: '查看所有热词'
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
<AppPagination
|
||||
current={groupCurrent}
|
||||
pageSize={groupSize}
|
||||
total={groupTotal}
|
||||
onChange={(page, pageSize) => {
|
||||
setGroupCurrent(page);
|
||||
setGroupSize(pageSize);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<DataListPanel
|
||||
className="hotwords-list-panel"
|
||||
leftActions={<Text strong>{hotWordGroupTitle}</Text>}
|
||||
rightActions={
|
||||
<Space wrap size="small">
|
||||
<Input
|
||||
placeholder="搜索热词原文"
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
value={searchWord}
|
||||
onChange={(e) => setSearchWord(e.target.value)}
|
||||
onPressEnter={() => { setCurrent(1); void fetchData(); }}
|
||||
className="hotwords-search__word"
|
||||
/>
|
||||
<Select
|
||||
placeholder="筛选分类"
|
||||
allowClear
|
||||
value={searchCategory || undefined}
|
||||
onChange={(value) => {
|
||||
setSearchCategory(value as string);
|
||||
setCurrent(1);
|
||||
void fetchData();
|
||||
}}
|
||||
className="hotwords-search__category"
|
||||
options={categories.map((c) => ({ label: c.itemLabel, value: c.itemValue }))}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenModal()}>
|
||||
新增热词
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => { setCurrent(1); void fetchData(); void loadGroupPage(); }} title="刷新" aria-label="刷新" />
|
||||
</Space>
|
||||
}
|
||||
footer={
|
||||
<AppPagination
|
||||
current={current}
|
||||
pageSize={size}
|
||||
total={total}
|
||||
onChange={(page, pageSize) => {
|
||||
setCurrent(page);
|
||||
setSize(pageSize);
|
||||
}}
|
||||
{/* Right Panel: Hotwords */}
|
||||
<Card
|
||||
className="app-page__content-card"
|
||||
title={hotWordGroupTitle}
|
||||
extra={
|
||||
<Space wrap size="small">
|
||||
<Input
|
||||
placeholder="搜索热词原文"
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
value={searchWord}
|
||||
onChange={(e) => setSearchWord(e.target.value)}
|
||||
onPressEnter={() => { setCurrent(1); void fetchData(); }}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
placeholder="筛选分类"
|
||||
allowClear
|
||||
value={searchCategory || undefined}
|
||||
onChange={(value) => {
|
||||
setSearchCategory(value as string);
|
||||
setCurrent(1);
|
||||
void fetchData();
|
||||
}}
|
||||
style={{ width: 120 }}
|
||||
options={categories.map((c) => ({ label: c.itemLabel, value: c.itemValue }))}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenModal()}>
|
||||
新增热词
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => { setCurrent(1); void fetchData(); void loadGroupPage(); }}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, overflow: 'hidden' }}
|
||||
styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}
|
||||
>
|
||||
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "16px 24px 0" }}>
|
||||
<Table
|
||||
className="hotwords-table"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
|
|
@ -562,31 +544,31 @@ const HotWords: React.FC = () => {
|
|||
scroll={{ x: "max-content", y: "100%" }}
|
||||
pagination={false}
|
||||
/>
|
||||
</DataListPanel>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
<AppPagination
|
||||
current={current}
|
||||
pageSize={size}
|
||||
total={total}
|
||||
onChange={(page, pageSize) => {
|
||||
setCurrent(page);
|
||||
setSize(pageSize);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<FormDrawer
|
||||
<Modal
|
||||
title={editingId ? "编辑热词" : "新增热词"}
|
||||
subtitle={editingId ? "调整热词分类、分组、权重和启用状态" : "补充识别热词,提升会议转写命中率"}
|
||||
open={modalVisible}
|
||||
onClose={() => setModalVisible(false)}
|
||||
size="md"
|
||||
bodyDensity="compact"
|
||||
okText="确定"
|
||||
okIcon={<SaveOutlined />}
|
||||
okLoading={submitLoading}
|
||||
okButtonProps={{ htmlType: "submit", form: "hotword-form" }}
|
||||
onOk={() => void handleSubmit()}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
confirmLoading={submitLoading}
|
||||
width={560}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form
|
||||
id="hotword-form"
|
||||
form={form}
|
||||
layout="vertical"
|
||||
className="hotwords-modal-form hotwords-drawer-form"
|
||||
onFinish={(values) => void handleSubmit(values)}
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="word" label="热词原文" rules={[{ required: true, message: "请输入热词原文" }]}>
|
||||
<Input placeholder="输入识别关键词" maxLength={80} onBlur={handleWordBlur} />
|
||||
<Input placeholder="输入识别关键词" onBlur={handleWordBlur} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="pinyin" hidden>
|
||||
|
|
@ -594,7 +576,7 @@ const HotWords: React.FC = () => {
|
|||
</Form.Item>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="category" label="热词分类">
|
||||
<Select placeholder="请选择分类" allowClear>
|
||||
{categories.map((item) => (
|
||||
|
|
@ -605,7 +587,7 @@ const HotWords: React.FC = () => {
|
|||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="hotWordGroupId" label="所属热词组">
|
||||
<Select placeholder="请选择热词组" allowClear options={groupOptions.map((item) => ({ label: `${item.groupName} (${item.hotWordCount}/200)`, value: item.id }))} />
|
||||
</Form.Item>
|
||||
|
|
@ -613,16 +595,16 @@ const HotWords: React.FC = () => {
|
|||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="weight"
|
||||
label="识别权重 (1-5)"
|
||||
tooltip="权重越高,识别引擎越倾向于将其识别为该热词"
|
||||
>
|
||||
<InputNumber min={1} max={5} precision={1} step={0.1} className="hotwords-weight-input" />
|
||||
<InputNumber min={1} max={5} precision={1} step={0.1} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="status" label="使用状态">
|
||||
<Select>
|
||||
<Option value={1}>启用</Option>
|
||||
|
|
@ -633,10 +615,10 @@ const HotWords: React.FC = () => {
|
|||
</Row>
|
||||
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea autoSize={{ minRows: 3, maxRows: 6 }} maxLength={255} showCount placeholder="记录热词来源或适用场景" />
|
||||
<Input.TextArea rows={2} placeholder="记录热词来源或适用场景" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</FormDrawer>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={editingGroupId ? "编辑热词组" : "新增热词组"}
|
||||
|
|
@ -646,7 +628,7 @@ const HotWords: React.FC = () => {
|
|||
confirmLoading={groupSubmitLoading}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={groupForm} layout="vertical" className="hotwords-modal-form">
|
||||
<Form form={groupForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="groupName" label="热词组名称" rules={[{ required: true, message: "请输入热词组名称" }]}>
|
||||
<Input placeholder="例如:项目术语、客户名单" maxLength={100} />
|
||||
</Form.Item>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue