release: v0.17.0
GitHub Pages / build (push) Canceled after 0s
GitHub Pages / deploy (push) Canceled after 0s
Docker image / meta (push) Successful in 24s
Docs Docker image / meta (push) Successful in 7s
Docker image / build (amd64, linux/amd64, ubuntu-latest) (push) Failing after 59s
Docs Docker image / build (amd64, linux/amd64, ubuntu-latest) (push) Failing after 30s
Docker image / merge (push) Canceled after 0s
Docs Docker image / merge (push) Canceled after 0s
Docker image / build (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Docs Docker image / build (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s

This commit is contained in:
Qiufeng
2026-08-19 22:09:49 +08:00
parent 9414048f9d
commit 84e9e7bff5
469 changed files with 19997 additions and 39561 deletions
@@ -0,0 +1,29 @@
.sidePanel {
height: 100%;
display: flex;
flex-direction: row!important;
}
.sideContainer {
flex: 1;
min-width: 760px;
overflow: auto;
}
.topPanel {
min-width: 1150px;
}
.mixPanel {
height: 100%;
}
.mixMain {
flex-direction: row!important;
flex: 1;
overflow: auto;
}
.mixContent {
overflow: auto;
}
@@ -0,0 +1,51 @@
import React from 'react';
import { Layout } from 'tdesign-react';
import { ELayout } from 'modules/global';
import Header from './Header';
import Footer from './Footer';
import Menu from './Menu';
import classnames from 'classnames';
import Content from './AppRouter';
import Style from './AppLayout.module.less';
const SideLayout = React.memo(() => (
<Layout className={classnames(Style.sidePanel, 'narrow-scrollbar')}>
<Menu showLogo showOperation />
<Layout className={Style.sideContainer}>
<Header />
<Content />
<Footer />
</Layout>
</Layout>
));
const TopLayout = React.memo(() => (
<Layout className={Style.topPanel}>
<Header showMenu />
<Content />
<Footer />
</Layout>
));
const MixLayout = React.memo(() => (
<Layout className={Style.mixPanel}>
<Header />
<Layout className={Style.mixMain}>
<Menu />
<Layout className={Style.mixContent}>
<Content />
<Footer />
</Layout>
</Layout>
</Layout>
));
const FullPageLayout = React.memo(() => <Content />);
export default {
[ELayout.side]: SideLayout,
[ELayout.top]: TopLayout,
[ELayout.mix]: MixLayout,
[ELayout.fullPage]: FullPageLayout,
};
@@ -0,0 +1,7 @@
.loading {
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-content: center;
}
@@ -0,0 +1,65 @@
import React, { Suspense, memo } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { Layout, Loading } from 'tdesign-react';
import routers, { IRouter } from 'router';
import { resolve } from 'utils/path';
import Page from './Page';
import Style from './AppRouter.module.less';
const { Content } = Layout;
type TRenderRoutes = (routes: IRouter[], parentPath?: string, breadcrumbs?: string[]) => React.ReactNode[];
/**
* 渲染应用路由
* @param routes
* @param parentPath
* @param breadcrumb
*/
const renderRoutes: TRenderRoutes = (routes, parentPath = '', breadcrumb = []) =>
routes.map((route, index: number) => {
const { Component, children, redirect, meta } = route;
const currentPath = resolve(parentPath, route.path);
let currentBreadcrumb = breadcrumb;
if (meta?.title) {
currentBreadcrumb = currentBreadcrumb.concat([meta?.title]);
}
if (redirect) {
// 重定向
return <Route key={index} path={currentPath} element={<Navigate to={redirect} replace />} />;
}
if (Component) {
// 有路由菜单
return (
<Route
key={index}
path={currentPath}
element={
<Page isFullPage={route.isFullPage} breadcrumbs={currentBreadcrumb}>
<Component />
</Page>
}
/>
);
}
// 无路由菜单
return children ? renderRoutes(children, currentPath, currentBreadcrumb) : null;
});
const AppRouter = () => (
<Content>
<Suspense
fallback={
<div className={Style.loading}>
<Loading />
</div>
}
>
<Routes>{renderRoutes(routers)}</Routes>
</Suspense>
</Content>
);
export default memo(AppRouter);
+21
View File
@@ -0,0 +1,21 @@
import React from 'react';
import { Layout, Row } from 'tdesign-react';
import { useAppSelector } from 'modules/store';
import { selectGlobal } from 'modules/global';
const { Footer: TFooter } = Layout;
const Footer = () => {
const globalState = useAppSelector(selectGlobal);
if (!globalState.showFooter) {
return null;
}
return (
<TFooter>
<Row justify='center'>Copyright © 2022-{new Date().getFullYear()} Tencent. All Rights Reserved</Row>
</TFooter>
);
};
export default React.memo(Footer);
@@ -0,0 +1,54 @@
.header {
display: flex;
align-items: center;
justify-content: center;
}
.badge {
:global {
.t-badge--circle {
top: 5px;
right: 6px;
}
}
}
.menuIcon {
width: 30px !important;
height: 30px !important;
}
.dropItem {
display: flex;
align-items: center;
span {
margin-left: 8px;
}
}
.dropdown {
padding-left: 0;
padding-right: 0;
.icon {
font-size: 20px;
display: inline-block;
margin: 0 5px;
}
:global {
.t-button--variant-text {
padding-left: 9px;
padding-right: 9px;
}
.t-button__text {
display: flex;
align-items: center;
}
.t-dropdown__item {
max-width: none !important;
width: 117px;
&-text {
display: flex;
align-items: center;
}
.t-icon {
margin-right: 8px;
}
}
}
}
@@ -0,0 +1,100 @@
import React, { memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button, Popup, Badge, Dropdown, Space } from 'tdesign-react';
import {
Icon,
LogoGithubIcon,
MailIcon,
HelpCircleIcon,
SettingIcon,
PoweroffIcon,
UserCircleIcon,
} from 'tdesign-icons-react';
import { useAppDispatch } from 'modules/store';
import { toggleSetting } from 'modules/global';
import { logout } from 'modules/user';
import Style from './HeaderIcon.module.less';
const { DropdownMenu, DropdownItem } = Dropdown;
export default memo(() => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const gotoWiki = () => {
window.open('https://tdesign.tencent.com/react/overview');
};
const gotoGitHub = () => {
window.open('https://github.com/Tencent/tdesign-react-starter');
};
const clickHandler = (data: any) => {
if (data.value === 1) {
navigate('/user/index');
}
};
const handleLogout = async () => {
await dispatch(logout());
navigate('/login/index');
};
return (
<Space align='center'>
<Badge className={Style.badge} count={6} dot={false} maxCount={99} shape='circle' showZero={false} size='medium'>
<Button className={Style.menuIcon} shape='square' size='large' variant='text' icon={<MailIcon />} />
</Badge>
<Popup content='代码仓库' placement='bottom' showArrow destroyOnClose>
<Button
className={Style.menuIcon}
shape='square'
size='large'
variant='text'
onClick={gotoGitHub}
icon={<LogoGithubIcon />}
/>
</Popup>
<Popup content='帮助文档' placement='bottom' showArrow destroyOnClose>
<Button
className={Style.menuIcon}
shape='square'
size='large'
variant='text'
onClick={gotoWiki}
icon={<HelpCircleIcon />}
/>
</Popup>
<Dropdown trigger={'click'} onClick={clickHandler}>
<Button variant='text' className={Style.dropdown}>
<Icon name='user-circle' className={Style.icon} />
<span className={Style.text}>Tencent</span>
<Icon name='chevron-down' className={Style.icon} />
</Button>
<DropdownMenu>
<DropdownItem value={1}>
<div className={Style.dropItem}>
<UserCircleIcon />
<span>个人中心</span>
</div>
</DropdownItem>
<DropdownItem value={1} onClick={handleLogout}>
<div className={Style.dropItem}>
<PoweroffIcon />
<span>退出登录</span>
</div>
</DropdownItem>
</DropdownMenu>
</Dropdown>
<Popup content='页面设置' placement='bottom' showArrow destroyOnClose>
<Button
className={Style.menuIcon}
shape='square'
size='large'
variant='text'
onClick={() => dispatch(toggleSetting())}
icon={<SettingIcon />}
/>
</Popup>
</Space>
);
});
@@ -0,0 +1,14 @@
.panel {
:global {
.t-input {
border: none;
}
.t-input:hover {
background: var(--td-bg-color-secondarycontainer);
transition: background var(--td-anim-duration-base) linear
}
.t-input--focused {
box-shadow: none;
}
}
}
@@ -0,0 +1,8 @@
import React from 'react';
import { Input } from 'tdesign-react';
import { SearchIcon } from 'tdesign-icons-react';
import Style from './Search.module.less';
const Search = () => <Input className={Style.panel} prefixIcon={<SearchIcon />} placeholder='请输入搜索内容' />;
export default React.memo(Search);
@@ -0,0 +1,11 @@
.panel {
flex-shrink: 0;
padding-left: 20px;
padding-right: 20px;
position: sticky;
top: 0;
z-index: 101;
display: flex;
justify-content: space-between;
border-bottom: 1px solid var(--td-component-stroke);
}
@@ -0,0 +1,49 @@
import React, { memo } from 'react';
import { Layout, Button, Space } from 'tdesign-react';
import { ViewListIcon } from 'tdesign-icons-react';
import { useAppDispatch, useAppSelector } from 'modules/store';
import { selectGlobal, toggleMenu } from 'modules/global';
import HeaderIcon from './HeaderIcon';
import { HeaderMenu } from '../Menu';
import Search from './Search';
import Style from './index.module.less';
const { Header } = Layout;
export default memo((props: { showMenu?: boolean }) => {
const globalState = useAppSelector(selectGlobal);
const dispatch = useAppDispatch();
if (!globalState.showHeader) {
return null;
}
let HeaderLeft;
if (props.showMenu) {
HeaderLeft = (
<div>
<HeaderMenu />
</div>
);
} else {
HeaderLeft = (
<Space align='center'>
<Button
shape='square'
size='large'
variant='text'
onClick={() => dispatch(toggleMenu(null))}
icon={<ViewListIcon />}
/>
<Search />
</Space>
);
}
return (
<Header className={Style.panel}>
{HeaderLeft}
<HeaderIcon />
</Header>
);
});
@@ -0,0 +1,34 @@
.menuPanel {
display: flex;
flex-direction: column;
height: 100%;
}
.menuTip {
color: var(--td-text-color-primary);
opacity: 0.4;
height: 38px;
line-height: 38px;
text-align: center;
}
.menuLogo {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
color: var(--td-text-color-primary);
}
.menuMiniLogo {
width: 28px;
height: 28px;
}
.logoFull {
color: var(--td-text-color-primary);
width: 184px;
height: 32px;
}
+115
View File
@@ -0,0 +1,115 @@
import React, { memo, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Menu, MenuValue } from 'tdesign-react';
import router, { IRouter } from 'router';
import { resolve } from 'utils/path';
import { useAppSelector } from 'modules/store';
import { selectGlobal } from 'modules/global';
import MenuLogo from './MenuLogo';
import Style from './Menu.module.less';
const { SubMenu, MenuItem, HeadMenu } = Menu;
interface IMenuProps {
showLogo?: boolean;
showOperation?: boolean;
}
const renderMenuItems = (menu: IRouter[], parentPath = '') => {
const navigate = useNavigate();
return menu.map((item) => {
const { children, meta, path } = item;
if (!meta || meta?.hidden === true) {
// 无meta信息 或 hidden == true,路由不显示为菜单
return null;
}
const { Icon, title, single } = meta;
const routerPath = resolve(parentPath, path);
if (!children || children.length === 0) {
return (
<MenuItem
key={routerPath}
value={routerPath}
icon={Icon ? <Icon /> : undefined}
onClick={() => navigate(routerPath)}
>
{title}
</MenuItem>
);
}
if (single && children?.length > 0) {
const firstChild = children[0];
if (firstChild?.meta && !firstChild?.meta?.hidden) {
const { Icon, title } = meta;
const singlePath = resolve(resolve(parentPath, path), firstChild.path);
return (
<MenuItem
key={singlePath}
value={singlePath}
icon={Icon ? <Icon /> : undefined}
onClick={() => navigate(singlePath)}
>
{title}
</MenuItem>
);
}
}
return (
<SubMenu key={routerPath} value={routerPath} title={title} icon={Icon ? <Icon /> : undefined}>
{renderMenuItems(children, routerPath)}
</SubMenu>
);
});
};
/**
* 顶部菜单
*/
export const HeaderMenu = memo(() => {
const globalState = useAppSelector(selectGlobal);
const location = useLocation();
const [active, setActive] = useState<MenuValue>(location.pathname); // todo
return (
<HeadMenu
expandType='popup'
style={{ marginBottom: 20 }}
value={active}
theme={globalState.theme}
onChange={(v) => setActive(v)}
>
{renderMenuItems(router)}
</HeadMenu>
);
});
/**
* 左侧菜单
*/
export default memo((props: IMenuProps) => {
const location = useLocation();
const globalState = useAppSelector(selectGlobal);
const { version } = globalState;
const bottomText = globalState.collapsed ? version : `元境幻生管理台 ${version}`;
return (
<Menu
width='232px'
style={{ flexShrink: 0, height: '100%' }}
className={Style.menuPanel2}
value={location.pathname}
theme={globalState.theme}
collapsed={globalState.collapsed}
operations={props.showOperation ? <div className={Style.menuTip}>{bottomText}</div> : undefined}
logo={props.showLogo ? <MenuLogo collapsed={globalState.collapsed} /> : undefined}
>
{renderMenuItems(router)}
</Menu>
);
});
+23
View File
@@ -0,0 +1,23 @@
import React, { memo } from 'react';
import Style from './Menu.module.less';
import FullLogo from 'assets/svg/assets-logo-full.svg?component';
import MiniLogo from 'assets/svg/assets-t-logo.svg?component';
import { useNavigate } from 'react-router-dom';
interface IProps {
collapsed?: boolean;
}
export default memo((props: IProps) => {
const navigate = useNavigate();
const handleClick = () => {
navigate('/');
};
return (
<div className={Style.menuLogo} onClick={handleClick}>
{props.collapsed ? <MiniLogo /> : <FullLogo />}
</div>
);
});
@@ -0,0 +1,9 @@
.panel {
margin: 24px;
padding: 0;
overflow: auto;
}
.breadcrumb {
margin-bottom: 8px;
}
+39
View File
@@ -0,0 +1,39 @@
import React, { useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '../../modules/store';
import { selectGlobal, switchFullPage } from '../../modules/global';
import { Layout, Breadcrumb } from 'tdesign-react';
import Style from './Page.module.less';
const { Content } = Layout;
const { BreadcrumbItem } = Breadcrumb;
const Page = ({
children,
isFullPage,
breadcrumbs,
}: React.PropsWithChildren<{ isFullPage?: boolean; breadcrumbs?: string[] }>) => {
const globalState = useAppSelector(selectGlobal);
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(switchFullPage(isFullPage));
}, [isFullPage]);
if (isFullPage) {
return <>{children}</>;
}
return (
<Content className={Style.panel}>
{globalState.showBreadcrumbs && (
<Breadcrumb className={Style.breadcrumb}>
{breadcrumbs?.map((item, index) => (
<BreadcrumbItem key={index}>{item}</BreadcrumbItem>
))}
</Breadcrumb>
)}
{children}
</Content>
);
};
export default React.memo(Page);
@@ -0,0 +1,17 @@
.panel {
display: flex;
justify-content: space-between;
}
.box {
cursor: pointer;
border: 2px solid;
padding: 4px;
border-radius: 50%;
}
.item {
width: 24px;
height: 24px;
border-radius: 50%;
}
@@ -0,0 +1,67 @@
import React, { useState } from 'react';
import { defaultColor } from 'configs/color';
import { Popup, ColorPickerPanel } from 'tdesign-react';
import Style from './RadioColor.module.less';
interface IProps {
defaultValue?: number | string;
onChange: (color: string) => void;
}
const RadioColor = (props: IProps) => {
const [isColoPickerDisplay, onPopupVisibleChange] = useState(false);
return (
<div className={Style.panel}>
{defaultColor.map((color, index) => (
<div
key={index}
onClick={() => props?.onChange(color)}
className={Style.box}
style={{ borderColor: props.defaultValue === color && !isColoPickerDisplay ? color : 'transparent' }}
>
<div className={Style.item} style={{ backgroundColor: color }} />
</div>
))}
<Popup
trigger='click'
placement='bottom-right'
expandAnimation
visible={isColoPickerDisplay}
onVisibleChange={(v) => onPopupVisibleChange(v)}
overlayInnerStyle={{ padding: 0 }}
content={
<ColorPickerPanel
onChange={(v) => props?.onChange(v)}
colorModes={['monochrome']}
format='HEX'
swatchColors={[]}
defaultValue={props.defaultValue as string}
/>
}
>
<div
key='dynamic'
className={Style.box}
style={{
borderColor:
isColoPickerDisplay || defaultColor.indexOf(props.defaultValue as string) === -1
? (props.defaultValue as string)
: 'transparent',
}}
>
<div
className={Style.item}
style={{
background:
'conic-gradient(from 90deg at 50% 50%, #FF0000 -19.41deg, #FF0000 18.76deg, #FF8A00 59.32deg, #FFE600 99.87deg, #14FF00 141.65deg, #00A3FF 177.72deg, #0500FF 220.23deg, #AD00FF 260.13deg, #FF00C7 300.69deg, #FF0000 340.59deg, #FF0000 378.76deg)',
}}
/>
</div>
</Popup>
</div>
);
};
export default React.memo(RadioColor);
@@ -0,0 +1,28 @@
.radioRectPanel {
display: flex;
justify-content: space-between;
}
.rectItem {
border: 2px solid #e3e6eb;
padding: 8px;
border-radius: 3px;
cursor: pointer;
}
.rectItemSelected {
border-color: var(--td-brand-color);
}
.rectText {
text-align: center;
font-size: 14px;
margin-top: 8px;
line-height: 21px;
}
.rectImg {
width: 88px;
height: 48px;
background-size: 100% 100%;
}
@@ -0,0 +1,47 @@
import React, { memo, useState } from 'react';
import classname from 'classnames';
import Style from './RadioRect.module.less';
interface IOption {
value?: any;
image: JSX.Element | string;
name?: string;
}
interface IProps {
defaultValue?: number | string;
onChange: (value?: any) => void;
options: IOption[];
}
export default memo((props: IProps) => {
const [selectValue, setSelectValue] = useState(props.defaultValue);
const handleClick = (option: IOption) => {
setSelectValue(option.value);
props?.onChange(option.value);
};
return (
<div className={Style.radioRectPanel}>
{props.options.map((item, index) => {
let ImageItem = item.image;
if (typeof item.image === 'string') {
ImageItem = <div className={Style.rectImg} style={{ backgroundImage: `url(${item.image})` }} />;
}
return (
<div key={index}>
<div
className={classname(Style.rectItem, { [Style.rectItemSelected]: selectValue === item.value })}
onClick={() => handleClick(item)}
>
{ImageItem}
</div>
{item.name && <div className={Style.rectText}>{item.name}</div>}
</div>
);
})}
</div>
);
});
@@ -0,0 +1,13 @@
.settingTitle {
margin: 32px 0 24px 0;
color: var(--td-text-color-primary);
font-size: 14px;
line-height: 22px;
font-weight: 500;
}
.settingSubTitle {
font-size: 14px;
line-height: 21px;
margin-bottom: 24px;
}
@@ -0,0 +1,123 @@
import React, { memo } from 'react';
import { Row, Col, Switch } from 'tdesign-react';
import { useAppDispatch, useAppSelector } from 'modules/store';
import {
selectGlobal,
toggleShowHeader,
toggleShowBreadcrumbs,
toggleShowFooter,
switchTheme,
switchColor,
switchLayout,
openSystemTheme,
ELayout,
} from 'modules/global';
import { ETheme } from 'types/index.d';
import RadioColor from './RadioColor';
import RadioRect from './RadioRect';
import Light from 'assets/svg/assets-setting-light.svg?component';
import Dark from 'assets/svg/assets-setting-dark.svg?component';
import System from 'assets/svg/assets-setting-auto.svg?component';
import Style from './index.module.less';
enum ESettingTheme {
system,
}
const themeList = [
{
value: ETheme.light,
image: <Light />,
name: '明亮',
},
{
value: ETheme.dark,
image: <Dark />,
name: '黑暗',
},
{
value: ESettingTheme.system,
image: <System />,
name: '跟随系统',
},
];
const layoutList = [
{
value: ELayout.side,
image: 'https://tdesign.gtimg.com/starter/setting/side.png',
},
{
value: ELayout.top,
image: 'https://tdesign.gtimg.com/starter/setting/top.png',
},
{
value: ELayout.mix,
image: 'https://tdesign.gtimg.com/starter/setting/mix.png',
},
];
export default memo(() => {
const dispatch = useAppDispatch();
const globalState = useAppSelector(selectGlobal);
const handleThemeSwitch = (value: any) => {
if (value === ESettingTheme.system) {
dispatch(openSystemTheme());
} else {
dispatch(switchTheme(value));
dispatch(switchColor(globalState.color));
}
};
return (
<div>
<div className={Style.settingTitle}>主题模式</div>
<RadioRect
defaultValue={globalState.systemTheme ? ESettingTheme.system : globalState.theme}
onChange={handleThemeSwitch}
options={themeList}
/>
<div className={Style.settingTitle}>主题色</div>
<RadioColor defaultValue={globalState.color} onChange={(value) => dispatch(switchColor(value))} />
<div className={Style.settingTitle}>导航布局</div>
<RadioRect
defaultValue={globalState.layout}
onChange={(value) => dispatch(switchLayout(value))}
options={layoutList}
/>
<div className={Style.settingTitle}>元素开关</div>
<Row justify='space-between'>
<Col>
<div className={Style.settingSubTitle}>显示 Header</div>
</Col>
<Col>
<Switch size='large' value={globalState.showHeader} onChange={() => dispatch(toggleShowHeader())} />
</Col>
</Row>
<Row justify='space-between'>
<Col>
<div className={Style.settingSubTitle}>显示 Breadcrumbs</div>
</Col>
<Col>
<Switch size='large' value={globalState.showBreadcrumbs} onChange={() => dispatch(toggleShowBreadcrumbs())} />
</Col>
</Row>
<Row justify='space-between'>
<Col>
<div className={Style.settingSubTitle}>显示 Footer</div>
</Col>
<Col>
<Switch size='large' value={globalState.showFooter} onChange={() => dispatch(toggleShowFooter())} />
</Col>
</Row>
</div>
);
});