Compare commits

...

14 Commits

Author SHA1 Message Date
puz 3337285135 feat: 界面优化 2026-07-02 09:30:48 +08:00
puz b0a3dedb99 feat:前端整体调整 2026-07-02 08:36:49 +08:00
puz 90b1f60955 Merge branch 'dev_na' of http://111.10.202.195:3000/chenh/imeeting into dev_ymcg 2026-07-01 22:02:04 +08:00
puz 0a33ff8e56 feat:页面调整2 2026-07-01 22:00:19 +08:00
puz 4bc3f65a10 Merge branch 'dev_na' of http://111.10.202.195:3000/chenh/imeeting into dev_ymcg
# Conflicts:
#	frontend/src/pages/business/MeetingDetail.tsx
2026-07-01 11:06:49 +08:00
puz cfb5aeff71 feat:调整优化 2026-07-01 11:01:38 +08:00
puz 92c8b126f5 Merge branch 'dev_na' of http://111.10.202.195:3000/chenh/imeeting into dev_ymcg 2026-07-01 09:01:44 +08:00
puz 1ab6e37fe1 feat:首页调整 2026-06-30 15:25:51 +08:00
puz d36beec479 Merge branch 'dev_na' of http://111.10.202.195:3000/chenh/imeeting into dev_ymcg 2026-06-30 13:40:30 +08:00
puz d5525496ea feat:前端项目页面重构 2026-06-30 13:37:33 +08:00
puz 6c970536b2 feat: 积分、算力、模型页面重构 2026-06-29 10:17:30 +08:00
puz 08d9024401 Merge remote-tracking branch 'origin/dev_na' into dev_ymcg 2026-06-29 10:14:13 +08:00
puz 255e268138 Merge branch 'dev_na' of http://111.10.202.195:3000/chenh/imeeting into dev_ymcg
同步 dev_na 分支的最新更新,确保 dev_ymcg 包含远程修复的功能模块和代码变更,避免后续冲突。
2026-06-26 14:14:38 +08:00
puz 21d356bb58 初次重构 2026-06-26 14:13:40 +08:00
150 changed files with 9836 additions and 11125 deletions

2
.gitignore vendored
View File

@ -39,3 +39,5 @@ backend/src/main/resources/application-local.yml
/backend/m2repo_local/
/backend/src/test/
/backend/target/
/.claude
/web-fe/

1
frontend/.gitignore vendored
View File

@ -10,6 +10,7 @@ lerna-debug.log*
node_modules
dist
dist-ssr
*.tsbuildinfo
*.local
# Editor directories and files

View File

@ -27,6 +27,7 @@
"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",
@ -1580,6 +1581,16 @@
"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",
@ -4666,6 +4677,13 @@
"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",

View File

@ -28,6 +28,7 @@
"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",

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo } 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,10 +6,8 @@ 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]);
@ -19,7 +17,6 @@ export default function App() {
const fetchConfig = async () => {
try {
const data = await getOpenPlatformConfig();
setConfig(data);
if (data.projectName) {
document.title = data.projectName;
}
@ -48,12 +45,24 @@ export default function App() {
algorithm: themeMode === 'tech' ? theme.darkAlgorithm : theme.defaultAlgorithm,
token: {
colorPrimary: colorPrimary,
borderRadius: 10,
colorBgLayout: 'transparent',
borderRadius: 4,
colorBgLayout: '#f5f6fa',
},
components: {
Card: {
borderRadiusLG: 16
borderRadiusLG: 4
},
Button: {
borderRadius: 4
},
Input: {
borderRadius: 4
},
Select: {
borderRadius: 4
},
Modal: {
borderRadiusLG: 4
}
}
}}

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

View File

@ -1,110 +0,0 @@
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>
);
}

View File

@ -1,15 +1,15 @@
import { ColorPicker, Space, Drawer, Segmented, Typography, Button } from 'antd';
import { FormatPainterOutlined, LayoutOutlined } from '@ant-design/icons';
import { ColorPicker, Space, Drawer, Segmented, Typography } from 'antd';
import { FormatPainterOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import { useThemeStore, type ThemeMode, type LayoutMode } from '@/store/themeStore';
import { useThemeStore, type ThemeMode } from '@/store/themeStore';
const { Text } = Typography;
export default function ThemeSelector() {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const { colorPrimary, themeMode, layoutMode, setColorPrimary, setThemeMode, setLayoutMode } = useThemeStore();
const { colorPrimary, themeMode, setColorPrimary, setThemeMode } = useThemeStore();
const themeOptions = [
{ label: t('theme.default', 'Default'), value: 'default' },
@ -17,11 +17,6 @@ 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)}>
@ -60,19 +55,6 @@ 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>

View File

@ -0,0 +1,362 @@
.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;
}
}

View File

@ -47,6 +47,7 @@ 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;
@ -195,15 +196,19 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
const nextConfig = createConfigRes.data.data || DEFAULT_CREATE_CONFIG;
const nextType = resolveAvailableCreateType(initialType, nextConfig);
const activePrompts = promptRes.data.data.records.filter((item: PromptTemplateVO) => item.status === 1);
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);
setCreateConfig(nextConfig);
setConfigLoaded(true);
setType(nextType);
setAsrModels(asrRes.data.data.records.filter((item: AiModelVO) => item.status === 1));
setLlmModels(llmRes.data.data.records.filter((item: AiModelVO) => item.status === 1));
setAsrModels(asrRecords.filter((item: AiModelVO) => item.status === 1));
setLlmModels(llmRecords.filter((item: AiModelVO) => item.status === 1));
setPrompts(activePrompts);
setHotwordList(hotwordRes.data.data.records.filter((item: HotWordVO) => item.status === 1));
setHotwordList(hotwordRecords.filter((item: HotWordVO) => item.status === 1));
setHotWordGroups((hotWordGroupRes.data.data || []).filter((item: HotWordGroupVO) => item.status === 1));
setUserList(users || []);
@ -387,20 +392,22 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
return (
<Drawer
rootClassName="meeting-create-drawer-root"
className="meeting-create-drawer"
title={null}
open={open}
onClose={onCancel}
width={960}
width="min(960px, calc(100vw - 48px))"
forceRender
destroyOnClose={false}
placement="right"
closable={false}
footer={
configLoaded ? (
<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 }}>
<div className="meeting-create-drawer__footer">
<Space size={12}>
<Button onClick={onCancel}></Button>
<Button type="primary" onClick={handleOk} loading={submitting}>
{type === "upload" ? (audioUrl ? "完成并分析" : "上传并分析") : "创建并进入识别"}
</Button>
</Space>
@ -414,117 +421,120 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
}}
>
{!configLoaded ? (
<div style={{ padding: "40px" }}>
<div className="meeting-create-drawer__skeleton">
<Skeleton active paragraph={{ rows: 12 }} />
</div>
) : (
<>
<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)" }}>
<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">
{type === "upload" ? <CloudUploadOutlined /> : <AudioOutlined />}
</div>
<div>
<Title level={4} style={{ margin: 0, fontWeight: 600 }}>{type === "upload" ? "上传录音发起分析" : "创建实时会议"}</Title>
<Text type="secondary" style={{ fontSize: 13 }}>{type === "upload" ? "上传已有音频文件并由 AI 进行转写与总结分析" : "实时采集语音并进行流式转写与实时纪要生成"}</Text>
<Title level={4} className="meeting-create-drawer__title">{type === "upload" ? "上传录音发起分析" : "创建实时会议"}</Title>
<Text type="secondary" className="meeting-create-drawer__subtitle">{type === "upload" ? "上传已有音频文件并由 AI 进行转写与总结分析" : "实时采集语音并进行流式转写与实时纪要生成"}</Text>
</div>
</Space>
</Col>
<Col>
<Radio.Group value={type} onChange={e => setType(e.target.value)} optionType="button" buttonStyle="solid" size="large">
<Col flex="none">
<Radio.Group value={type} onChange={e => setType(e.target.value)} optionType="button" buttonStyle="solid" className="meeting-create-drawer__type-switch">
{createConfig.offlineEnabled && (
<Radio.Button value="upload" style={{ padding: "0 24px" }}><CloudUploadOutlined style={{ marginRight: 6 }} /> </Radio.Button>
<Radio.Button value="upload"><CloudUploadOutlined /> </Radio.Button>
)}
{createConfig.realtimeEnabled && (
<Radio.Button value="realtime" style={{ padding: "0 24px" }}><AudioOutlined style={{ marginRight: 6 }} /> </Radio.Button>
<Radio.Button value="realtime"><AudioOutlined /> </Radio.Button>
)}
</Radio.Group>
</Col>
</Row>
</div>
<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 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>
<Row gutter={32}>
<Col span={12}>
<Row gutter={24}>
<Col xs={24} md={12}>
<Form.Item name="title" label="会议标题" rules={[{ required: true }]}>
<Input placeholder="输入会议标题" size="large" />
<Input placeholder="输入会议标题" />
</Form.Item>
</Col>
<Col span={12}>
<Col xs={24} md={12}>
<Form.Item name="meetingTime" label="会议时间" rules={[{ required: true }]}>
<DatePicker showTime style={{ width: "100%" }} size="large" />
<DatePicker showTime style={{ width: "100%" }} />
</Form.Item>
</Col>
</Row>
<Row gutter={32}>
<Col span={12}>
<Row gutter={24}>
<Col xs={24} md={12}>
<Form.Item name="participants" label="参会人员">
<Select mode="multiple" placeholder="选择参会人员" showSearch optionFilterProp="children" size="large">
<Select mode="multiple" placeholder="选择参会人员" showSearch optionFilterProp="children">
{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 span={12}>
<Col xs={24} md={12}>
<Form.Item name="hostUserId" label="会议主持人">
<Select allowClear placeholder="默认为创建人" showSearch optionFilterProp="children" size="large">
<Select allowClear placeholder="默认为创建人" showSearch optionFilterProp="children">
{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={32}>
<Col span={12}>
<Row gutter={24}>
<Col span={24}>
<Form.Item name="tags" label="会议标签">
<Select mode="tags" placeholder="输入标签" size="large" />
<Select mode="tags" placeholder="输入标签" />
</Form.Item>
</Col>
</Row>
<div style={{ margin: "32px 0", borderTop: "1px solid var(--app-border-color)" }} />
<div className="meeting-create-section-divider" />
<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 className="meeting-create-section-title">
<span className="meeting-create-section-title__bar" />
<Title level={5}></Title>
</div>
<Row gutter={32}>
<Col span={12}>
<Row gutter={24}>
<Col xs={24} md={12}>
<Form.Item name="asrModelId" label="语音识别模型 (ASR)" rules={[{ required: true }]}>
<Select placeholder="选择 ASR 模型" size="large">{asrModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select>
<Select placeholder="选择 ASR 模型">{asrModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select>
</Form.Item>
</Col>
<Col span={12}>
<Col xs={24} md={12}>
<Form.Item name="summaryModelId" label="总结模型 (LLM)" rules={[{ required: true }]}>
<Select placeholder="选择总结模型" size="large">{llmModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select>
<Select placeholder="选择总结模型">{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" size="large">
<Select placeholder="请选择总结模板" showSearch optionFilterProp="children">
{prompts.map(p => <Option key={p.id} value={p.id}>{p.templateName}</Option>)}
</Select>
) : (
<div style={{ padding: "2px" }}>
<div className="meeting-create-template-grid">
<Row gutter={[12, 12]}>
{prompts.map(p => {
const isSelected = watchedPromptId === p.id;
return (
<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>}
<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>}
</div>
</Col>
);
@ -534,15 +544,15 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
)}
</Form.Item>
<Row gutter={32}>
<Col span={12}>
<Row gutter={24}>
<Col xs={24} md={12}>
<Form.Item name="hotWordGroupId" label="热词组" tooltip={selectedPrompt?.hotWordGroupName ? `默认跟随模板:${selectedPrompt.hotWordGroupName}` : "模板未绑定热词组时可手动选择"} extra={watchedHotWordGroupId != null ? "创建会议时会优先使用这里选中的热词组" : undefined}>
<Select placeholder={selectedPrompt?.hotWordGroupId ? "默认已带出模板热词组,可按需修改" : "请选择热词组"} size="large" options={[{ label: "不使用热词组", value: 0 }, ...hotWordGroups.map((item) => ({ label: `${item.groupName} (${item.hotWordCount}/200)`, value: item.id }))]} />
<Select placeholder={selectedPrompt?.hotWordGroupId ? "默认已带出模板热词组,可按需修改" : "请选择热词组"} options={[{ label: "不使用热词组", value: 0 }, ...hotWordGroups.map((item) => ({ label: `${item.groupName} (${item.hotWordCount}/200)`, value: item.id }))]} />
</Form.Item>
</Col>
<Col span={12}>
<Col xs={24} md={12}>
<Form.Item name="summaryDetailLevel" label="总结详细程度" rules={[{ required: true }]}>
<Radio.Group size="large">
<Radio.Group className="meeting-create-radio-line">
<Radio value="DETAILED"></Radio>
<Radio value="STANDARD"></Radio>
<Radio value="BRIEF"></Radio>
@ -554,37 +564,37 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
<Collapse
ghost
expandIconPosition="end"
style={{ marginBottom: 24, background: "var(--app-bg-surface)", border: "1px solid var(--app-border-color)", borderRadius: 8, overflow: "hidden" }}
className="meeting-create-advanced"
items={[
{
key: "advanced",
forceRender: true,
label: (
<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 className="meeting-create-advanced__label">
<div className="meeting-create-advanced__title">
<div className="meeting-create-advanced__icon">
<SettingOutlined />
</div>
</div>
</div>
),
children: (
<div style={{ paddingTop: 8, borderTop: "1px dashed var(--app-border-color)" }}>
<Row gutter={32}>
<Col span={8}>
<div className="meeting-create-advanced__body">
<Row gutter={24}>
<Col xs={24} md={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 span={8}>
<Col xs={24} md={8}>
<Form.Item name="enableTextRefine" label={<span> <Tooltip title="自动识别并修正语音转写中的口语词、语气助词等"><QuestionCircleOutlined /></Tooltip></span>} valuePropName="checked">
<Switch />
</Form.Item>
</Col>
{type === "realtime" && (
<>
<Col span={8}>
<Col xs={24} md={8}>
<Form.Item name="saveAudio" label="保存录音" valuePropName="checked">
<Switch />
</Form.Item>
@ -611,10 +621,10 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
{type === "upload" && (
<>
<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 className="meeting-create-section-divider" />
<div className="meeting-create-section-title">
<span className="meeting-create-section-title__bar" />
<Title level={5}></Title>
</div>
<Dragger
accept=".mp3,.wav,.m4a"
@ -623,22 +633,22 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
customRequest={customUpload}
onChange={info => setFileList(info.fileList.slice(-1))}
maxCount={1}
style={{ borderRadius: 12, padding: "32px 0", background: "var(--app-bg-surface)", border: "1px dashed var(--app-border-color)" }}
className="meeting-create-upload"
>
<div>
<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)" }}> mp3wavm4a {createConfig.offlineAudioMaxSizeMb}MB</p>
<p className="ant-upload-drag-icon"><CloudUploadOutlined /></p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint"> mp3wavm4a {createConfig.offlineAudioMaxSizeMb}MB</p>
{uploadProgress > 0 && uploadProgress < 100 && (
<div style={{ width: "60%", margin: "32px auto 0" }}>
<div className="meeting-create-upload__progress">
<Progress percent={uploadProgress} size="small" />
<div style={{ fontSize: 13, color: "#1890ff", marginTop: 8 }}>...</div>
<div>...</div>
</div>
)}
{audioUrl && (
<Tag color="processing" style={{ marginTop: 24, padding: "6px 16px", fontSize: 14, borderRadius: 6, maxWidth: "90%", display: "inline-flex", alignItems: "center" }}>
<Tag color="processing" className="meeting-create-upload__file">
<span></span>
<span style={{ marginLeft: 4, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{audioUrl.split("/").pop()}</span>
<span>{audioUrl.split("/").pop()}</span>
</Tag>
)}
</div>

View File

@ -1,259 +0,0 @@
/* 帮助面板样式 */
.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;
}
}

View File

@ -1,228 +0,0 @@
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

View File

@ -2,21 +2,257 @@
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: 1 1 100%;
width: 100%;
flex: 0 1 auto;
min-width: 0;
min-height: 32px;
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 {
margin-right: auto;
display: none;
}
.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;
}
}

View File

@ -1,32 +1,140 @@
import React from 'react';
import { Pagination, PaginationProps } from 'antd';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Pagination, type 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, showSizeChanger, ...restProps } = props;
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 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 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 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>
);
}

View File

@ -1,304 +0,0 @@
/* 底部提示栏基础样式 */
.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%;
}
}

View File

@ -1,90 +0,0 @@
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

View File

@ -1,196 +0,0 @@
/* 按钮带引导 - 简洁现代设计 */
.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;
}
}

View File

@ -1,165 +0,0 @@
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

View File

@ -1,243 +0,0 @@
.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;
}
}

View File

@ -1,222 +0,0 @@
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

View File

@ -1,189 +0,0 @@
.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);
}
}
}

View File

@ -1,179 +0,0 @@
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

View File

@ -1,163 +0,0 @@
/* 按钮包裹容器 */
.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;
}
}

View File

@ -1,105 +0,0 @@
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

View File

@ -1,17 +0,0 @@
/* 图表面板 */
.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;
}

View File

@ -1,202 +0,0 @@
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

View File

@ -1,138 +0,0 @@
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

View File

@ -0,0 +1,244 @@
.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;
}
}

View File

@ -0,0 +1,42 @@
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>
);
}

View File

@ -1,119 +0,0 @@
/* 详情抽屉容器 */
.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;
}

View File

@ -1,113 +0,0 @@
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;

View File

@ -1,105 +0,0 @@
/* 扩展信息面板容器 */
.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);
}
}

View File

@ -1,68 +0,0 @@
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

View File

@ -0,0 +1,133 @@
.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;
}
}

View File

@ -0,0 +1,156 @@
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 };

View File

@ -1,96 +0,0 @@
/* 信息面板 */
.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;
}

View File

@ -1,58 +0,0 @@
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

View File

@ -1,96 +0,0 @@
.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;
}
}

View File

@ -1,134 +0,0 @@
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

View File

@ -1,16 +1,23 @@
/* 列表表格容器 */
.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 {
background-color: var(--item-hover-bg);
.list-table-container .row-selected > td {
background-color: var(--item-hover-bg) !important;
}
.list-table-container .row-selected:hover > td {
@ -25,7 +32,7 @@
}
.selection-count {
color: var(--text-color-secondary);
color: var(--app-text-secondary, #9095a1);
font-size: 14px;
}
@ -48,6 +55,33 @@
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,
@ -79,3 +113,64 @@
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;
}

View File

@ -1,5 +1,5 @@
import React from "react";
import { Table } from "antd";
import { Empty, Table } from "antd";
import type { TablePaginationConfig, TableProps } from "antd";
import "./ListTable.css";
import i18n from "../../../i18n";
@ -20,6 +20,7 @@ export type ListTableProps<T extends Record<string, any>> = {
selectedRow?: T | null;
loading?: boolean;
className?: string;
locale?: TableProps<T>["locale"];
onChange?: TableProps<T>["onChange"];
};
@ -43,6 +44,7 @@ function ListTable<T extends Record<string, any>>({
selectedRow,
loading = false,
className = "",
locale,
onChange,
}: ListTableProps<T>) {
const mergedScroll = React.useMemo(() => {
@ -53,6 +55,13 @@ 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,
@ -122,11 +131,19 @@ 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={hasVerticalScroll ? "list-table-table--y-scroll" : undefined}
className={tableClassName || undefined}
size="middle"
rowSelection={rowSelection}
columns={columns}
@ -135,12 +152,14 @@ 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>
);
}

View File

@ -1,154 +0,0 @@
.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;
}

View File

@ -1,203 +0,0 @@
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

View File

@ -1,96 +0,0 @@
.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;
}

View File

@ -1,195 +0,0 @@
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

View File

@ -1,32 +0,0 @@
.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;
}

View File

@ -1,31 +0,0 @@
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

View File

@ -1,4 +0,0 @@
export { default } from './MainLayout'
export { default as MainLayout } from './MainLayout'
export { default as AppSider } from './AppSider'
export { default as AppHeader } from './AppHeader'

View File

@ -1,221 +0,0 @@
.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 */
}

View File

@ -1,154 +0,0 @@
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;

View File

@ -1,62 +0,0 @@
.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;
}

View File

@ -1,138 +0,0 @@
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

View File

@ -1,132 +0,0 @@
.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%;
}

View File

@ -1,268 +0,0 @@
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

View File

@ -0,0 +1,102 @@
.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;
}
}

View File

@ -1,6 +1,7 @@
import React from 'react';
import { Space, Typography } from 'antd';
import { Typography } from 'antd';
import type { ReactNode } from 'react';
import './PageContainer.css';
const { Title, Text } = Typography;
@ -23,76 +24,37 @@ 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}`}
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 }}>
<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">
{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"
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 12
}}
>
{toolbar}
</div>
)}
{toolbar && <div className="page-container__toolbar">{toolbar}</div>}
<div
className="page-container__body"
style={{
flex: 1,
minHeight: 0,
display: 'flex',
flexDirection: 'column'
}}
>
{children}
</div>
<div className="page-container__body">{children}</div>
</div>
);
};
export default PageContainer;
export default PageContainer;

View File

@ -1,109 +0,0 @@
.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%;
}
}

View File

@ -1,35 +0,0 @@
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

View File

@ -1,41 +0,0 @@
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;

View File

@ -1,187 +0,0 @@
.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;
}
}

View File

@ -1,53 +0,0 @@
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

View File

@ -1,14 +0,0 @@
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

View File

@ -0,0 +1,191 @@
.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;
}
}

View File

@ -0,0 +1,46 @@
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>
);
}

View File

@ -1,49 +0,0 @@
.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;
}
}

View File

@ -1,89 +0,0 @@
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

View File

@ -1,88 +0,0 @@
/* 侧边信息面板容器 */
.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);
}
}

View File

@ -1,58 +0,0 @@
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

View File

@ -1,72 +0,0 @@
/* 分栏布局容器 */
.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;
}
}

View File

@ -1,69 +0,0 @@
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

View File

@ -1,108 +0,0 @@
/* 统计卡片 */
.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;
}

View File

@ -1,82 +0,0 @@
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

View File

@ -1,81 +0,0 @@
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;

View File

@ -1,58 +0,0 @@
/* 树形筛选面板 */
.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;
}

View File

@ -1,119 +0,0 @@
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

View File

@ -1,9 +1,8 @@
import { useState, useEffect } from 'react';
import { fetchDictItemsByTypeCode } from '../api/dict';
import { SysDictItem } from '../types';
import type { 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] || []);

View File

@ -11,6 +11,7 @@
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);
@ -19,7 +20,19 @@
--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"] {
@ -30,6 +43,7 @@
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);
@ -38,7 +52,17 @@
--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"] {
@ -50,6 +74,7 @@
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);
@ -58,7 +83,17 @@
--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 {
@ -261,16 +296,53 @@ body::after {
flex-wrap: wrap;
}
.app-page__page-actions {
.app-page__content-toolbar {
flex-shrink: 0;
min-height: 40px;
margin-bottom: 14px;
display: flex;
justify-content: flex-end;
align-items: center;
margin: -8px 0 16px;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
}
.app-page__page-actions .ant-btn {
min-width: 96px;
border-radius: 10px !important;
.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;
}
.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__toolbar .ant-input,
@ -293,6 +365,13 @@ 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;
@ -810,6 +889,11 @@ 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;
@ -829,52 +913,85 @@ 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 */
.ant-table-wrapper {
.app-page__table-wrap .ant-table-wrapper,
.orgs-table-shell .ant-table-wrapper {
height: 100%;
}
.ant-table-wrapper .ant-spin-nested-loading {
.app-page__table-wrap .ant-table-wrapper .ant-spin-nested-loading,
.orgs-table-shell .ant-table-wrapper .ant-spin-nested-loading {
height: 100%;
}
.ant-table-wrapper .ant-spin-container {
.app-page__table-wrap .ant-table-wrapper .ant-spin-container,
.orgs-table-shell .ant-table-wrapper .ant-spin-container {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.ant-table-wrapper .ant-table {
.app-page__table-wrap .ant-table-wrapper .ant-table,
.orgs-table-shell .ant-table-wrapper .ant-table {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.ant-table-wrapper .ant-table-container {
.app-page__table-wrap .ant-table-wrapper .ant-table-container,
.orgs-table-shell .ant-table-wrapper .ant-table-container {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.ant-table-wrapper .ant-table-content,
.ant-table-wrapper .ant-table-body {
.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 {
flex: 1;
min-height: 0;
}
.ant-table-wrapper .ant-table-header {
.app-page__table-wrap .ant-table-wrapper .ant-table-header,
.orgs-table-shell .ant-table-wrapper .ant-table-header {
flex-shrink: 0;
}
.ant-table-wrapper .ant-table-body {
.app-page__table-wrap .ant-table-wrapper .ant-table-body,
.orgs-table-shell .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;
@ -900,11 +1017,44 @@ 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,
@ -1172,3 +1322,119 @@ 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;
}

View File

@ -0,0 +1,271 @@
.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;
}

View File

@ -1,4 +1,3 @@
import * as AntIcons from "@ant-design/icons";
import {
BellOutlined,
ApartmentOutlined,
@ -6,7 +5,6 @@ import {
DashboardOutlined,
DesktopOutlined,
GlobalOutlined,
LayoutOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
@ -27,11 +25,21 @@ import { useAuth } from "@/hooks/useAuth";
import { usePermission } from "@/hooks/usePermission";
import type { PlatformRuntime, SysPermission, SysPlatformConfig } from "@/types";
import ThemeSelector from "@/components/ThemeSelector/ThemeSelector";
import { useThemeStore } from "@/store/themeStore";
import "./AppLayout.css";
const { Header, Sider, Content, Footer } = Layout;
const { Header, Sider, Content } = 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 />,
@ -47,9 +55,7 @@ const iconMap: Record<string, ReactNode> = {
function resolveMenuIcon(icon?: string): ReactNode {
if (!icon) return <SettingOutlined />;
const aliasIcon = iconMap[icon];
if (aliasIcon) return aliasIcon;
const IconComponent = (AntIcons as unknown as Record<string, React.ComponentType>)[icon];
return IconComponent ? <IconComponent /> : <SettingOutlined />;
return aliasIcon ?? <SettingOutlined />;
}
type PermissionMenuNode = SysPermission & {
@ -75,7 +81,7 @@ function findActiveMenu(nodes: PermissionMenuNode[], path: string, parentKeys: s
for (const node of nodes) {
const key = getMenuKey(node);
if (node.path === path) {
if (node.path === path || (node.path && node.path !== "/" && path.startsWith(`${node.path}/`))) {
return { key, parentKeys };
}
@ -129,7 +135,6 @@ export default function AppLayout() {
const navigate = useNavigate();
const { logout } = useAuth();
const { load: loadPermissions } = usePermission();
const { layoutMode } = useThemeStore();
const fetchInitialData = useCallback(async () => {
try {
@ -324,16 +329,6 @@ 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",
@ -380,155 +375,56 @@ export default function AppLayout() {
</Space>
);
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>
)}
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>
</div>
);
return (
<Layout style={{ height: "100vh", overflow: "hidden" }}>
{layoutMode === 'side' && (
<Layout className="main-layout">
<Header className="main-header">
{renderLogo()}
<div className="header-right">{headerRightTools}</div>
</Header>
<Layout className="main-shell">
<Sider
trigger={null}
collapsible
collapsed={collapsed}
className="app-sider"
style={{ boxShadow: "var(--app-shadow)", zIndex: 10 }}
className={`main-sider app-sider ${collapsed ? "main-sider--collapsed" : ""}`}
theme="light"
width={180}
>
{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' ? (
<div className="collapsedCenter">
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
shape="circle"
onClick={() => setCollapsed(!collapsed)}
style={{ fontSize: "16px", width: 64, height: 64, color: "var(--app-text-main)", marginLeft: -24 }}
className="trigger"
aria-label={collapsed ? "展开菜单" : "收起菜单"}
/>
) : (
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>
</Footer>
<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>
</Layout>
</Layout>
);

View File

@ -1,7 +1,15 @@
.permissions-page {
display: flex;
flex-direction: column;
height: 100%;
padding: 8px;
min-width: 0;
background: #f5f6fa;
}
.permissions-page > .page-container__body {
padding: 0;
overflow: hidden;
border: none;
border-radius: 0;
background: transparent;
}
.permissions-content-card {
@ -9,9 +17,9 @@
min-height: 0;
display: flex;
flex-direction: column;
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;
border-radius: 4px !important;
border: 1px solid #e6e6e6 !important;
box-shadow: none !important;
overflow: hidden;
}
@ -73,7 +81,7 @@
}
&::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 10px;
border-radius: 3px;
&:hover {
background: #cbd5e1;
}

View File

@ -1,17 +1,39 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
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 { Button, Col, Drawer, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Table, Tag, Tooltip, Typography, App } from 'antd';
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 { CheckSquareOutlined, ClusterOutlined, DeleteOutlined, EditOutlined, FolderOutlined, InfoCircleOutlined, MenuOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, HolderOutlined } from "@ant-design/icons";
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 { 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";
@ -31,6 +53,19 @@ 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;
}
@ -79,15 +114,12 @@ const DragHandle = () => {
);
};
const menuIconOptions = Object.keys(AntIcons)
.filter((key) => /(?:Outlined|Filled|TwoTone)$/.test(key))
.sort((left, right) => left.localeCompare(right));
const menuIconOptions = Object.keys(menuIconComponents).sort((left, right) => left.localeCompare(right));
function renderSelectableIcon(iconName?: string) {
const resolvedName = iconName ? (legacyIconAliases[iconName] || iconName) : undefined;
if (!resolvedName) return null;
const iconsMap = AntIcons as unknown as Record<string, React.ComponentType<{ className?: string; style?: React.CSSProperties }>>;
const IconComponent = iconsMap[resolvedName];
const IconComponent = menuIconComponents[resolvedName];
return IconComponent ? <IconComponent aria-hidden="true" /> : null;
}
@ -419,18 +451,21 @@ export default function Permissions() {
];
return (
<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={
<>
<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>
<Input
placeholder={t("permissions.permName")}
value={query.name}
@ -463,9 +498,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
@ -486,6 +521,8 @@ 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>}

View File

@ -1,14 +1,21 @@
.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 {
@ -28,11 +35,30 @@
.roles-detail-card {
flex: 1;
min-height: 0;
border-radius: 18px !important;
border-radius: 4px !important;
display: flex;
flex-direction: column;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04) !important;
border: 1px solid rgba(226, 232, 240, 0.8) !important;
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;
}
.roles-side-card .ant-card-body,
@ -41,7 +67,7 @@
min-height: 0;
display: flex;
flex-direction: column;
padding: 16px !important;
padding: 8px !important;
overflow: hidden;
}
@ -49,22 +75,22 @@
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
gap: 8px;
margin-bottom: 8px;
}
.role-search-bar {
display: flex;
gap: 10px;
gap: 8px;
}
.role-search-bar .ant-input-affix-wrapper {
flex: 1;
border-radius: 8px;
border-radius: 4px;
}
.role-search-bar .ant-btn {
border-radius: 8px;
border-radius: 4px;
}
.role-list-container-v3 {
@ -72,8 +98,8 @@
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding-right: 8px;
margin-right: -8px;
padding-right: 0;
margin-right: 0;
/* Modern scrollbar */
&::-webkit-scrollbar {
@ -84,7 +110,7 @@
}
&::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 10px;
border-radius: 3px;
&:hover {
background: #cbd5e1;
}
@ -95,26 +121,24 @@
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
margin-bottom: 10px;
border: 1px solid #f1f5f9;
border-radius: 12px;
padding: 10px 12px;
margin-bottom: 8px;
border: 1px solid #e6e6e6;
border-radius: 4px;
background: #ffffff;
transition: all 0.2s ease;
transition: border-color 0.2s ease, background 0.2s ease;
cursor: pointer;
position: relative;
overflow: hidden;
&:hover {
border-color: #cbd5e1;
background: #f8fafc;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03);
border-color: #c8d8ff;
background: #f9fafe;
}
&.active {
border-color: #bfdbfe;
background: #eff6ff;
border-color: #9cb8ff;
background: #f3f7ff;
&::before {
content: '';
@ -123,16 +147,16 @@
top: 0;
bottom: 0;
width: 4px;
background: #3b82f6;
background: #3c70f5;
}
.role-name {
color: #1e3a8a;
color: #2f5edb;
font-weight: 600;
}
.role-item-symbol {
background: #3b82f6;
background: #3c70f5;
color: #ffffff;
}
}
@ -141,9 +165,9 @@
.role-item-symbol {
width: 40px;
height: 40px;
border-radius: 10px;
background: #f1f5f9;
color: #64748b;
border-radius: 4px;
background: #eef4ff;
color: #3c70f5;
display: flex;
align-items: center;
justify-content: center;
@ -207,7 +231,7 @@
.role-list-pagination {
flex-shrink: 0;
padding-top: 16px;
padding-top: 8px;
margin-top: auto;
border-top: 1px solid #f1f5f9;
display: flex;
@ -224,9 +248,9 @@
.role-detail-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: #eff6ff;
color: #3b82f6;
border-radius: 4px;
background: #eef4ff;
color: #3c70f5;
display: flex;
align-items: center;
justify-content: center;
@ -263,7 +287,8 @@
min-height: 0;
.ant-tabs-nav {
margin-bottom: 16px !important;
flex-shrink: 0;
margin-bottom: 8px !important;
&::before {
border-bottom: 1px solid #f1f5f9;
}
@ -272,7 +297,7 @@
.ant-tabs-content-holder {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow: hidden;
&::-webkit-scrollbar {
width: 6px;
@ -282,20 +307,45 @@
}
&::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 10px;
border-radius: 3px;
}
}
.ant-tabs-content,
.ant-tabs-tabpane {
height: 100%;
min-height: 0;
}
}
.role-detail-pane {
padding: 4px;
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;
}
}
.permission-tree-wrapper {
padding: 16px;
background: #f8fafc;
border-radius: 12px;
border: 1px solid #e2e8f0;
min-height: 100%;
padding: 12px;
background: #ffffff;
border-radius: 4px;
border: 1px solid #e6e6e6;
}
.role-members-toolbar {
@ -321,8 +371,8 @@
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.78);
border-radius: 16px;
border: 1px dashed rgba(148, 163, 184, 0.5);
background: #ffffff;
border-radius: 4px;
border: 1px dashed #cccccc;
margin: 0;
}

View File

@ -34,9 +34,11 @@ import {
} 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 { getStandardPagination } from "@/utils/pagination";
import type { RoleDataScope, SysOrg, SysPermission, SysRole, SysTenant, SysUser } from "@/types";
import type { SysOrg, SysPermission, SysRole, SysTenant, SysUser } from "@/types";
import "./index.less";
const { Text, Title } = Typography;
@ -422,200 +424,215 @@ export default function Roles() {
const saveLabel = "同步保存";
return (
<div className="app-page roles-page-v2">
<PageHeader title="角色管理" subtitle="维护角色基础信息、功能权限、数据权限与成员绑定" />
<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__page-actions">
{can("sys:role:create") && <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>{"新增角色"}</Button>}
</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 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>
<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>
<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>
) : 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>
</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="当前范围不需要选择部门" />
)}
</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.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>
) : (
<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>
</Tabs.TabPane>
</Tabs>
</Card>
) : (
<div className="app-page__empty-state"><Empty description="请选择左侧角色查看详情" /></div>
)}
</Col>
</Row>
</div>
</DataListPanel>
</SectionCard>
<Modal title="绑定用户到角色" open={userModalOpen} onCancel={() => setUserModalOpen(false)} onOk={() => void handleAddUsers()} okText="确定" cancelText="取消" width={650} destroyOnClose>
<div style={{ marginBottom: 16 }}>
@ -643,6 +660,6 @@ export default function Roles() {
</Form.Item>
</Form>
</Drawer>
</div>
</PageContainer>
);
}

View File

@ -1,5 +1,15 @@
.users-page {
min-height: 100%;
padding: 8px;
min-width: 0;
background: #f5f6fa;
}
.users-page > .page-container__body {
padding: 0;
overflow: hidden;
border: none;
border-radius: 0;
background: transparent;
}
.users-header {
@ -14,8 +24,8 @@
}
.users-table-card {
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
border-radius: 4px;
box-shadow: none;
}
.users-table-toolbar {
@ -32,8 +42,8 @@
justify-content: space-between;
gap: 16px;
padding: 18px 24px 14px;
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%);
border-bottom: 1px solid #e6e6e6;
background: #ffffff;
}
.users-content-header__count {
@ -66,6 +76,63 @@
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;
@ -74,6 +141,7 @@
.user-drawer-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
}
@ -89,6 +157,24 @@
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;

View File

@ -12,7 +12,6 @@ import {
Select,
Space,
Switch,
Table,
Tag,
Tooltip,
TreeSelect,
@ -52,11 +51,13 @@ 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";
@ -64,9 +65,13 @@ const { Title, Text } = Typography;
type OrgTreeNode = { value: number; title: string; children: OrgTreeNode[] };
type Membership = { tenantId?: number; orgId?: number; orgName?: string };
type Membership = { tenantId?: number; orgId?: number; orgName?: string; orgPath?: string; orgFullName?: 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[] = [];
@ -261,8 +266,6 @@ export default function Users() {
);
}, [data, searchText]);
const activeFilterLabel = filterTenantId ? tenantMap[filterTenantId] || `Tenant ${filterTenantId}` : "全部租户";
const openCreate = () => {
setEditing(null);
setRoleSelectOpen(false);
@ -398,16 +401,26 @@ 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="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>
<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>
))}
{record.memberships.length > 2 && <Text type="secondary" style={{ fontSize: 11 }}>+{record.memberships.length - 2} more</Text>}
</div>
);
}
@ -418,17 +431,22 @@ 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 = record.memberships.map((membership: any) => membership.orgName).filter(Boolean);
const orgNames = Array.from(
new Set(record.memberships.map((membership: Membership) => getMembershipOrgName(membership).trim()).filter(Boolean))
);
if (orgNames.length > 0) {
return (
<div className="flex flex-col gap-1">
<div className="user-affiliation-list">
{orgNames.map((name: string, index: number) => (
<Space key={index} size={4} style={{ fontSize: 13, color: "#555" }}>
<ApartmentOutlined />
<span>{name}</span>
</Space>
<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>
))}
</div>
);
@ -476,14 +494,21 @@ export default function Users() {
];
return (
<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">
<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>
{isPlatformMode &&
<Select placeholder={t("users.tenantFilter")} style={{width: 200}} allowClear value={filterTenantId}
onChange={setFilterTenantId}
@ -498,30 +523,31 @@ export default function Users() {
onClick={handleSearch}>{t("common.search")}</Button>
<Button onClick={handleResetSearch}>{t("common.reset")}</Button>
</Space>
{can("sys:user:create") && <Button type="primary" icon={<PlusOutlined aria-hidden="true"/>}
onClick={openCreate}>{t("common.create")}</Button>}
</Space>
</div>
</Card>
}
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>
<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")}
<Drawer title={<div className="user-drawer-title"><UserOutlined aria-hidden="true"/><span>{editing ? t("users.drawerTitleEdit") : t("users.drawerTitleCreate")}</span>
</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"
@ -560,6 +586,63 @@ 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")}
@ -596,49 +679,12 @@ 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>
@ -706,6 +752,6 @@ export default function Users() {
</Form.Item>
</Form>
</Modal>
</div>
</PageContainer>
);
}

View File

@ -1,9 +1,7 @@
.auth-shell {
min-height: 100vh;
padding: 32px 24px;
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%);
background: #f5f6fa;
display: flex;
align-items: center;
justify-content: center;
@ -14,19 +12,17 @@
min-height: 680px;
display: grid;
grid-template-columns: minmax(320px, 0.92fr) minmax(420px, 1fr);
border-radius: 28px;
border-radius: 4px;
overflow: hidden;
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);
background: #ffffff;
border: 1px solid #e6e6e6;
}
.auth-shell__aside {
padding: 56px 48px;
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%);
color: #333333;
background: #f9fafe;
border-right: 1px solid #e6e6e6;
display: flex;
flex-direction: column;
justify-content: space-between;
@ -35,13 +31,7 @@
}
.auth-shell__aside::after {
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%);
content: none;
}
.auth-shell__aside-top {
@ -57,28 +47,26 @@
display: inline-flex;
align-items: center;
gap: 8px;
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);
padding: 6px 10px;
border-radius: 4px;
background: #ffffff;
border: 1px solid #e6e6e6;
color: #3c70f5;
font-size: 13px;
font-weight: 600;
}
.auth-shell__aside-extra {
padding: 16px 20px;
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);
border-radius: 4px;
background: #ffffff;
border: 1px solid #e6e6e6;
}
.auth-shell__aside-extra-title {
display: block;
margin-bottom: 10px;
color: rgba(255, 255, 255, 0.95);
color: #333333;
font-size: 14px;
font-weight: 600;
}
@ -86,13 +74,13 @@
.auth-shell__aside-extra-list {
margin: 0;
padding-left: 18px;
color: rgba(226, 232, 240, 0.9);
color: #606775;
font-size: 13px;
line-height: 1.8;
}
.auth-shell__aside-extra-list li::marker {
color: #38bdf8;
color: #3c70f5;
}
.auth-shell__aside-copy {
@ -103,15 +91,15 @@
.auth-shell__aside-title.ant-typography {
margin: 0 0 16px;
color: #ffffff;
font-size: 36px;
color: #333333;
font-size: 32px;
line-height: 1.18;
letter-spacing: -0.8px;
letter-spacing: 0;
}
.auth-shell__aside-description.ant-typography {
margin: 0;
color: rgba(226, 232, 240, 0.9);
color: #606775;
font-size: 15px;
line-height: 1.8;
}
@ -129,18 +117,18 @@
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 4px;
background: #ffffff;
border: 1px solid #e6e6e6;
}
.auth-shell__highlight-item .anticon {
margin-top: 4px;
color: #7dd3fc;
color: #3c70f5;
}
.auth-shell__highlight-item .ant-typography {
color: rgba(241, 245, 249, 0.94);
color: #333333;
}
.auth-shell__main {
@ -160,10 +148,10 @@
.auth-shell__panel-title.ant-typography {
margin: 0 0 8px;
color: #10233b;
font-size: 30px;
color: #333333;
font-size: 28px;
line-height: 1.2;
letter-spacing: -0.5px;
letter-spacing: 0;
}
.auth-shell__panel-subtitle.ant-typography {
@ -180,7 +168,7 @@
.auth-shell__panel-content .ant-input-affix-wrapper,
.auth-shell__panel-content .ant-input,
.auth-shell__panel-content .ant-btn {
border-radius: 12px;
border-radius: 4px;
}
.auth-shell__panel-content .ant-input-affix-wrapper,
@ -196,7 +184,7 @@
.auth-shell__panel-footer {
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid rgba(148, 163, 184, 0.18);
border-top: 1px solid #e6e6e6;
}
.auth-form__inline {
@ -231,9 +219,9 @@
.auth-form__policy-hints {
margin: -4px 0 20px;
padding: 12px 14px;
border-radius: 14px;
background: rgba(241, 245, 249, 0.78);
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 4px;
background: #f9fafe;
border: 1px solid #e6e6e6;
}
.auth-form__policy-hints-title {
@ -262,6 +250,11 @@
height: auto;
}
.auth-form__notice {
margin-bottom: 20px;
border-radius: 4px;
}
@media (max-width: 960px) {
.auth-shell {
padding: 20px;
@ -275,6 +268,8 @@
.auth-shell__aside {
padding: 36px 28px 28px;
gap: 24px;
border-right: none;
border-bottom: 1px solid #e6e6e6;
}
.auth-shell__main {

View File

@ -1,13 +1,12 @@
.login-page {
min-height: 100vh;
display: flex;
background: #ffffff;
background: #f5f6fa;
}
/* Left Hero Section */
.login-left {
flex: 1.1;
background: linear-gradient(140deg, #f0f5ff 0%, #eef5ff 40%, #f7fbff 100%);
background: #f9fafe;
padding: 56px 64px;
display: flex;
flex-direction: column;
@ -26,14 +25,13 @@
.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.2px;
letter-spacing: 0;
}
.login-hero {
@ -42,12 +40,12 @@
}
.hero-title {
font-size: 42px;
font-size: 36px;
font-weight: 700;
line-height: 1.2;
color: #1d2b3a;
color: #333;
margin-bottom: 24px;
letter-spacing: -0.5px;
letter-spacing: 0;
text-wrap: balance;
}
@ -59,7 +57,7 @@
.hero-desc {
font-size: 16px;
line-height: 1.8;
color: #687489;
color: #596275;
max-width: 440px;
}
@ -79,7 +77,6 @@
border-radius: 50%;
}
/* Right Form Section */
.login-right {
flex: 1;
display: flex;
@ -101,9 +98,9 @@
.login-header h2 {
font-size: 28px !important;
font-weight: 700 !important;
color: #1f2a37 !important;
color: #333 !important;
margin-bottom: 8px !important;
letter-spacing: -0.5px;
letter-spacing: 0;
}
.login-header span {
@ -117,7 +114,7 @@
.login-form .ant-input-affix-wrapper-lg {
padding: 10px 16px;
border-radius: 8px;
border-radius: 4px;
}
.captcha-wrapper {
@ -130,7 +127,7 @@
width: 120px;
height: 46px;
flex-shrink: 0;
border-radius: 8px;
border-radius: 4px;
overflow: hidden;
display: flex;
align-items: center;
@ -166,7 +163,7 @@
height: 48px;
font-size: 16px;
font-weight: 600;
border-radius: 8px;
border-radius: 4px;
}
.login-footer {
@ -174,14 +171,14 @@
text-align: center;
padding: 16px;
background: #f9fafb;
border-radius: 12px;
border: 1px solid #e6e6e6;
border-radius: 4px;
}
.tabular-nums {
font-variant-numeric: tabular-nums;
}
/* Responsive */
@media (max-width: 1024px) {
.login-left {
padding: 48px;
@ -201,8 +198,9 @@
.login-container {
background: #ffffff;
padding: 48px 32px;
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);
border: 1px solid #e6e6e6;
border-radius: 4px;
box-shadow: none;
}
}

View File

@ -87,7 +87,7 @@ export default function ResetPassword() {
type="info"
message="密码更新后将直接进入系统首页"
description="新密码提交成功后立即生效,旧密码会同时失效。"
style={{marginBottom: 20, borderRadius: 14}}
className="auth-form__notice"
/>
<Form form={form} layout="vertical" onFinish={onFinish} requiredMark={false} className="auth-form">

View File

@ -0,0 +1,36 @@
.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;
}

View File

@ -17,10 +17,11 @@ 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;
@ -175,22 +176,26 @@ export default function RolePermissionBinding() {
};
return (
<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)" }}>
<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">
<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">
@ -275,6 +280,7 @@ export default function RolePermissionBinding() {
</Card>
</Col>
</Row>
</SectionCard>
</PageContainer>
);
}

View File

@ -0,0 +1,36 @@
.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;
}

View File

@ -17,9 +17,10 @@ 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;
@ -101,16 +102,20 @@ export default function UserRoleBinding() {
};
return (
<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)" }}>
<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">
<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">
@ -197,6 +202,7 @@ export default function UserRoleBinding() {
</Card>
</Col>
</Row>
</SectionCard>
</PageContainer>
);
}

View File

@ -0,0 +1,45 @@
.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;
}
}

View File

@ -1,6 +1,8 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { AutoComplete, Button, Card, Col, Divider, Drawer, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Switch, Table, Tabs, Tag, Tooltip, Typography, App } from 'antd';
import { AutoComplete, Button, 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,
@ -26,6 +28,7 @@ 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;
@ -70,7 +73,6 @@ 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);
@ -148,7 +150,6 @@ const AiModels: React.FC = () => {
const openDrawer = (record?: AiModelVO) => {
setRemoteModels([]);
setSpeakerModels([]);
modelNameAutoFilledRef.current = false;
localProfileLoadedRef.current = false;
@ -178,7 +179,6 @@ const AiModels: React.FC = () => {
setRemoteModels([record.modelCode]);
}
if (speakerModel) {
setSpeakerModels([String(speakerModel)]);
}
} else {
setEditingId(null);
@ -252,9 +252,7 @@ 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) {
@ -449,56 +447,66 @@ const AiModels: React.FC = () => {
return (
<PageContainer
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 }}
/>
}
title={null}
className="ai-models-page"
>
<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>
<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>
<Drawer
width={600}
@ -718,12 +726,12 @@ const AiModels: React.FC = () => {
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="temperature" label="Temperature">
<Form.Item name="temperature" label="温度">
<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>

View File

@ -0,0 +1,53 @@
.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%;
}
}

View File

@ -1,10 +1,8 @@
import {
App,
Button,
Card,
Col,
Drawer,
Empty,
Form,
Input,
InputNumber,
@ -13,21 +11,23 @@ import {
Select,
Space,
Switch,
Table,
Tag,
Typography,
Upload
} from "antd";
import type { ColumnsType } from "antd/es/table";
import { CheckCircleOutlined, CloudUploadOutlined, DeleteOutlined, DownloadOutlined, EditOutlined, LaptopOutlined, MobileOutlined, PlusOutlined, ReloadOutlined, RocketOutlined, SearchOutlined, UploadOutlined, WindowsOutlined } from "@ant-design/icons";
import { CloudUploadOutlined, DeleteOutlined, DownloadOutlined, EditOutlined, LaptopOutlined, MobileOutlined, PlusOutlined, ReloadOutlined, RocketOutlined, 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 { 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,108 +406,76 @@ export default function ClientManagement() {
];
return (
<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>
<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>
</div>
<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
</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>
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}
/>
</div>
<AppPagination
current={page}
pageSize={pageSize}
total={filteredRecords.length}
onChange={(nextPage, nextSize) => { setPage(nextPage); setPageSize(nextSize); }}
/>
</Card>
</DataListPanel>
</SectionCard>
<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">

View File

@ -0,0 +1,145 @@
.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;
}
}

View File

@ -1,11 +1,14 @@
import { App, Avatar, Button, Card, Col, Drawer, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Switch, Table, Tag, Typography, Upload } from "antd";
import { App, Avatar, Button, Col, Drawer, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Switch, 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;
@ -52,6 +55,7 @@ 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);
@ -291,92 +295,57 @@ export default function ExternalAppManagement() {
];
return (
<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>
<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>
</div>
<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>
</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>
<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">
@ -385,14 +354,36 @@ 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>
<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)" }} />
<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>
</Upload>
<div style={{ flex: 1 }}>
<Form.Item name="iconUrl" label="图标地址"><Input placeholder="上传图标后自动回填,或手动填写" /></Form.Item>
</div>
</Space>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, curr) => prev.appType !== curr.appType}>
{({ getFieldValue }) => getFieldValue("appType") === "native" ? (

View File

@ -0,0 +1,343 @@
.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;
}
}

View File

@ -19,11 +19,15 @@ 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";
@ -45,6 +49,7 @@ import {
type HotWordGroupVO,
} from "../../api/business/hotwordGroup";
import AppPagination from "../../components/shared/AppPagination";
import "./HotWords.css";
const { Option } = Select;
const { Text } = Typography;
@ -136,8 +141,8 @@ const HotWords: React.FC = () => {
tenantId: isPlatformAdmin ? activeTenantId : undefined,
});
if (res.data?.data) {
setData(res.data.data.records);
setTotal(res.data.data.total);
setData(res.data.data.records || []);
setTotal(res.data.data.total || 0);
}
} finally {
setLoading(false);
@ -202,9 +207,9 @@ const HotWords: React.FC = () => {
await loadGroupPage();
};
const handleSubmit = async () => {
const handleSubmit = async (formValues?: HotWordFormValues) => {
try {
const values = await form.validateFields();
const values = formValues ?? await form.validateFields();
setSubmitLoading(true);
const payload = {
...values,
@ -305,6 +310,7 @@ const HotWords: React.FC = () => {
title: "热词原文",
dataIndex: "word",
key: "word",
width: 180,
ellipsis: true,
render: (text: string) => <Text strong ellipsis={{ tooltip: text }}>{text}</Text>,
},
@ -312,18 +318,21 @@ const HotWords: React.FC = () => {
title: "拼音",
dataIndex: "pinyinList",
key: "pinyinList",
render: (list: string[]) => list?.[0] ? <Tag style={{ borderRadius: 4 }}>{list[0]}</Tag> : <Text type="secondary">-</Text>,
width: 160,
render: (list: string[]) => list?.[0] ? <Tag className="hotwords-pinyin-tag">{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>;
@ -333,17 +342,21 @@ 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)}>
@ -366,177 +379,182 @@ const HotWords: React.FC = () => {
return (
<PageContainer
title="热词管理"
subtitle="管理ASR识别引擎的热词库提升特定场景下的识别准确率"
title={null}
className="hotwords-page"
>
<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);
}}
<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="新增热词组"
/>
<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()}
>
}
>
<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(
<Button
key={`edit-${item.id}`}
type="text"
danger
icon={<DeleteOutlined />}
onClick={(e) => e.stopPropagation()}
icon={<EditOutlined />}
onClick={(e) => openGroupEditor(item, e)}
size="small"
/>
</Popconfirm>
);
}
);
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)}
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>
);
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>
);
}}
/>
</div>
<AppPagination
className="hotwords-group-pagination"
simple
showSizeChanger={false}
showQuickJumper={false}
current={groupCurrent}
pageSize={groupSize}
total={groupTotal}
onChange={(page, pageSize) => {
setGroupCurrent(page);
setGroupSize(pageSize);
}}
/>
</div>
<AppPagination
current={groupCurrent}
pageSize={groupSize}
total={groupTotal}
onChange={(page, pageSize) => {
setGroupCurrent(page);
setGroupSize(pageSize);
}}
/>
</Card>
</Card>
{/* 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();
<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);
}}
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"
@ -544,31 +562,31 @@ const HotWords: React.FC = () => {
scroll={{ x: "max-content", y: "100%" }}
pagination={false}
/>
</div>
<AppPagination
current={current}
pageSize={size}
total={total}
onChange={(page, pageSize) => {
setCurrent(page);
setSize(pageSize);
}}
/>
</Card>
</div>
</DataListPanel>
</div>
</SectionCard>
<Modal
<FormDrawer
title={editingId ? "编辑热词" : "新增热词"}
subtitle={editingId ? "调整热词分类、分组、权重和启用状态" : "补充识别热词,提升会议转写命中率"}
open={modalVisible}
onOk={() => void handleSubmit()}
onCancel={() => setModalVisible(false)}
confirmLoading={submitLoading}
width={560}
destroyOnHidden
onClose={() => setModalVisible(false)}
size="md"
bodyDensity="compact"
okText="确定"
okIcon={<SaveOutlined />}
okLoading={submitLoading}
okButtonProps={{ htmlType: "submit", form: "hotword-form" }}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form
id="hotword-form"
form={form}
layout="vertical"
className="hotwords-modal-form hotwords-drawer-form"
onFinish={(values) => void handleSubmit(values)}
>
<Form.Item name="word" label="热词原文" rules={[{ required: true, message: "请输入热词原文" }]}>
<Input placeholder="输入识别关键词" onBlur={handleWordBlur} />
<Input placeholder="输入识别关键词" maxLength={80} onBlur={handleWordBlur} />
</Form.Item>
<Form.Item name="pinyin" hidden>
@ -576,7 +594,7 @@ const HotWords: React.FC = () => {
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Col xs={24} sm={12}>
<Form.Item name="category" label="热词分类">
<Select placeholder="请选择分类" allowClear>
{categories.map((item) => (
@ -587,7 +605,7 @@ const HotWords: React.FC = () => {
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Col xs={24} sm={12}>
<Form.Item name="hotWordGroupId" label="所属热词组">
<Select placeholder="请选择热词组" allowClear options={groupOptions.map((item) => ({ label: `${item.groupName} (${item.hotWordCount}/200)`, value: item.id }))} />
</Form.Item>
@ -595,16 +613,16 @@ const HotWords: React.FC = () => {
</Row>
<Row gutter={16}>
<Col span={12}>
<Col xs={24} sm={12}>
<Form.Item
name="weight"
label="识别权重 (1-5)"
tooltip="权重越高,识别引擎越倾向于将其识别为该热词"
>
<InputNumber min={1} max={5} precision={1} step={0.1} style={{ width: "100%" }} />
<InputNumber min={1} max={5} precision={1} step={0.1} className="hotwords-weight-input" />
</Form.Item>
</Col>
<Col span={12}>
<Col xs={24} sm={12}>
<Form.Item name="status" label="使用状态">
<Select>
<Option value={1}></Option>
@ -615,10 +633,10 @@ const HotWords: React.FC = () => {
</Row>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} placeholder="记录热词来源或适用场景" />
<Input.TextArea autoSize={{ minRows: 3, maxRows: 6 }} maxLength={255} showCount placeholder="记录热词来源或适用场景" />
</Form.Item>
</Form>
</Modal>
</FormDrawer>
<Modal
title={editingGroupId ? "编辑热词组" : "新增热词组"}
@ -628,7 +646,7 @@ const HotWords: React.FC = () => {
confirmLoading={groupSubmitLoading}
destroyOnHidden
>
<Form form={groupForm} layout="vertical" style={{ marginTop: 16 }}>
<Form form={groupForm} layout="vertical" className="hotwords-modal-form">
<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