feat: 页面重构优化

dev_na
puz 2026-07-03 09:57:59 +08:00
parent 0d6acd1056
commit 37696b13a9
17 changed files with 1631 additions and 423 deletions

View File

@ -11,6 +11,46 @@ export default function App() {
const { colorPrimary, themeMode, initTheme } = useThemeStore();
const { i18n } = useTranslation();
const antdLocale = useMemo(() => (i18n.language === "en-US" ? enUS : zhCN), [i18n.language]);
const isTechTheme = themeMode === "tech";
const antdTheme = useMemo(() => ({
algorithm: theme.defaultAlgorithm,
token: {
colorPrimary,
borderRadius: 4,
colorBgLayout: isTechTheme ? "#edf6ff" : "#f5f6fa",
colorBgContainer: isTechTheme ? "#ffffff" : "#ffffff",
colorBgElevated: isTechTheme ? "#ffffff" : "#ffffff",
colorBorder: isTechTheme ? "rgba(32, 111, 218, 0.18)" : "#e6e6e6",
colorText: isTechTheme ? "#102033" : "#333333",
colorTextSecondary: isTechTheme ? "#5f728f" : "#9095a1",
colorFillSecondary: isTechTheme ? "rgba(32, 111, 218, 0.08)" : "rgba(0, 0, 0, 0.04)",
},
components: {
Card: {
borderRadiusLG: 4
},
Button: {
borderRadius: 4
},
Input: {
borderRadius: 4
},
Select: {
borderRadius: 4
},
Modal: {
borderRadiusLG: 4
},
Drawer: {
colorBgElevated: "#ffffff"
},
Segmented: {
itemSelectedBg: isTechTheme ? "#ffffff" : "#ffffff",
itemSelectedColor: isTechTheme ? colorPrimary : "#333333"
}
}
}), [colorPrimary, isTechTheme]);
useEffect(() => {
initTheme();
@ -41,31 +81,7 @@ export default function App() {
return (
<ConfigProvider
locale={antdLocale}
theme={{
algorithm: themeMode === 'tech' ? theme.darkAlgorithm : theme.defaultAlgorithm,
token: {
colorPrimary: colorPrimary,
borderRadius: 4,
colorBgLayout: '#f5f6fa',
},
components: {
Card: {
borderRadiusLG: 4
},
Button: {
borderRadius: 4
},
Input: {
borderRadius: 4
},
Select: {
borderRadius: 4
},
Modal: {
borderRadiusLG: 4
}
}
}}
theme={antdTheme}
>
<AntdApp>
<AppRoutes />

View File

@ -0,0 +1,144 @@
.theme-selector-container {
display: inline-flex;
align-items: center;
}
.theme-selector-trigger {
width: 34px;
height: 34px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--app-text-main);
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
transition:
color 0.18s ease,
background-color 0.18s ease,
border-color 0.18s ease;
}
.theme-selector-trigger:hover,
.theme-selector-trigger:focus-visible {
color: var(--app-primary-color);
background: rgba(var(--app-primary-rgb), 0.08);
border-color: rgba(var(--app-primary-rgb), 0.16);
outline: none;
}
.theme-selector-trigger .anticon {
font-size: 18px;
line-height: 1;
}
.theme-settings-drawer-root .ant-drawer-mask {
background: rgba(15, 23, 42, 0.42);
}
.theme-settings-drawer .ant-drawer-header {
min-height: 56px;
padding: 0 24px;
border-bottom: 1px solid var(--app-border-color);
background: var(--app-surface-color);
}
.theme-settings-drawer .ant-drawer-title,
.theme-settings-drawer .ant-typography {
color: var(--app-text-main);
}
.theme-settings-drawer .ant-drawer-close {
color: var(--app-text-secondary);
}
.theme-settings-drawer .ant-drawer-close:hover {
color: var(--app-primary-color);
}
.theme-settings-drawer .ant-drawer-body {
padding: 24px;
background: var(--app-bg-surface);
}
.theme-settings-content {
width: 100%;
}
.theme-settings-section {
min-width: 0;
}
.theme-settings-label {
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 8px;
color: var(--app-text-main);
}
.theme-settings-label .anticon {
color: var(--app-primary-color);
}
.theme-color-picker {
width: 100%;
}
.theme-color-picker .ant-color-picker-trigger {
width: 100%;
justify-content: center;
border-color: var(--app-border-color);
background: var(--app-surface-color);
}
.theme-style-segmented.ant-segmented {
padding: 4px;
background: var(--app-bg-surface-soft);
border: 1px solid var(--app-border-color);
}
.theme-style-segmented .ant-segmented-item {
min-width: 0;
color: var(--app-text-secondary);
}
.theme-style-segmented .ant-segmented-item-label {
padding-inline: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.theme-style-segmented .ant-segmented-item-selected {
color: var(--app-text-main);
}
:root[data-theme="tech"] .theme-selector-trigger:hover,
:root[data-theme="tech"] .theme-selector-trigger:focus-visible {
background: rgba(var(--app-primary-rgb), 0.14);
border-color: rgba(var(--app-primary-rgb), 0.28);
}
:root[data-theme="tech"] .theme-settings-drawer-root .ant-drawer-mask {
background: rgba(15, 23, 42, 0.34);
backdrop-filter: blur(2px);
}
:root[data-theme="tech"] .theme-color-picker .ant-color-picker-trigger {
background: #ffffff;
}
:root[data-theme="tech"] .theme-settings-drawer .ant-drawer-header {
background: linear-gradient(90deg, #ffffff, #f0f7ff);
}
:root[data-theme="tech"] .theme-settings-drawer .ant-drawer-body {
background:
linear-gradient(rgba(255, 255, 255, 0.72) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.72) 1px, transparent 1px),
linear-gradient(180deg, #f8fbff 0%, #eef6ff 100%);
background-size: 28px 28px, 28px 28px, auto;
}

View File

@ -3,6 +3,7 @@ import { FormatPainterOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import { useThemeStore, type ThemeMode } from '@/store/themeStore';
import './ThemeSelector.css';
const { Text } = Typography;
@ -11,50 +12,58 @@ export default function ThemeSelector() {
const [open, setOpen] = useState(false);
const { colorPrimary, themeMode, setColorPrimary, setThemeMode } = useThemeStore();
const themeOptions = [
{ label: t('theme.default', 'Default'), value: 'default' },
const themeOptions: Array<{ label: string; value: ThemeMode }> = [
{ label: t('theme.minimal', 'Minimal'), value: 'minimal' },
{ label: t('theme.tech', 'Tech'), value: 'tech' },
];
return (
<div className="theme-selector-container">
<Space align="center" style={{ cursor: 'pointer', padding: '0 8px' }} onClick={() => setOpen(true)}>
<FormatPainterOutlined style={{ fontSize: '18px', color: 'var(--app-text-main)' }} title={t("layout.theme", "Theme")} />
</Space>
<button
type="button"
className="theme-selector-trigger"
onClick={() => setOpen(true)}
aria-label={t("theme.settings", "Theme Settings")}
title={t("layout.theme", "Theme")}
>
<FormatPainterOutlined />
</button>
<Drawer
title={t('theme.settings', 'Theme Settings')}
placement="right"
onClose={() => setOpen(false)}
open={open}
width={300}
rootClassName="theme-settings-drawer-root"
className="theme-settings-drawer"
>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
<FormatPainterOutlined style={{ color: 'var(--app-primary-color)' }} />
<Space direction="vertical" size="large" className="theme-settings-content">
<section className="theme-settings-section">
<div className="theme-settings-label">
<FormatPainterOutlined />
<Text strong>{t('theme.color', 'Theme Color')}</Text>
</div>
<ColorPicker
className="theme-color-picker"
value={colorPrimary}
onChangeComplete={(color) => setColorPrimary(color.toHexString())}
showText
style={{ width: '100%' }}
/>
</div>
</section>
<div>
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
<FormatPainterOutlined style={{ color: 'var(--app-primary-color)' }} />
<section className="theme-settings-section">
<div className="theme-settings-label">
<FormatPainterOutlined />
<Text strong>{t('theme.style', 'Style Mode')}</Text>
</div>
<Segmented
className="theme-style-segmented"
block
options={themeOptions}
value={themeMode}
onChange={(value) => setThemeMode(value as ThemeMode)}
/>
</div>
</section>
</Space>
</Drawer>
</div>

View File

@ -9,7 +9,7 @@
overflow: hidden;
padding: 12px 16px 10px;
border-radius: 4px;
background-color: #fff;
background-color: var(--app-surface-color, #fff);
}
.data-list-panel--auto {
@ -155,22 +155,22 @@
.data-list-panel__table-area .ant-table-thead > tr > th {
height: 45px;
color: #000;
color: var(--app-text-main, #000);
font-size: 14px;
font-weight: 600;
background: #fafafa !important;
background: var(--app-bg-surface-soft, #fafafa) !important;
}
.data-list-panel__table-area .ant-table-tbody > tr > td {
height: 47px;
color: #000;
color: var(--app-text-main, #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;
background: var(--app-surface-color, #fff) !important;
}
.data-list-panel__table-area .ant-table-cell {
@ -205,7 +205,7 @@
min-width: 0;
overflow: visible;
border-radius: 0;
background: #fff;
background: var(--app-surface-color, #fff);
box-sizing: border-box;
min-height: 50px;
}
@ -222,11 +222,24 @@
.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;
color: var(--app-text-main, #333);
font-family: "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif;
font-size: 14px;
}
:root[data-theme="tech"] .data-list-panel {
border: 1px solid rgba(22, 119, 255, 0.14);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(246, 251, 255, 0.92));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
:root[data-theme="tech"] .data-list-panel__table-area .ant-table-thead > tr > th {
background:
linear-gradient(180deg, rgba(var(--app-primary-rgb), 0.12), rgba(47, 211, 255, 0.08)) !important;
border-bottom-color: rgba(22, 119, 255, 0.18) !important;
}
@media (max-width: 768px) {
.data-list-panel {
padding: 12px;

View File

@ -175,6 +175,23 @@
overflow: visible;
}
:root[data-theme="tech"] .section-card {
background-image:
radial-gradient(circle at 90% 0%, rgba(47, 211, 255, 0.18), transparent 28%),
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(232, 244, 255, 0.88)),
linear-gradient(rgba(22, 119, 255, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(22, 119, 255, 0.08) 1px, transparent 1px);
background-size: auto, auto, 28px 28px, 28px 28px;
box-shadow: var(--app-shadow);
border-color: rgba(22, 119, 255, 0.24);
}
:root[data-theme="tech"] .section-card__content {
background:
linear-gradient(180deg, rgba(246, 251, 255, 0.94), rgba(232, 244, 255, 0.9));
border: 1px solid rgba(22, 119, 255, 0.12);
}
@media (max-width: 768px) {
.section-card {
padding: 12px;

View File

@ -1325,8 +1325,8 @@ body::after {
/* web-fe baseline overrides */
:root,
:root[data-theme="minimal"],
:root[data-theme="tech"] {
:root[data-theme="default"],
:root[data-theme="minimal"] {
--app-bg-main: #f5f6fa;
--app-bg-overlay: none;
--app-bg-card: #fff;
@ -1353,9 +1353,48 @@ body::after {
--app-form-drawer-max-width: calc(100vw - 48px);
}
:root[data-theme="tech"] {
--app-bg-main:
radial-gradient(circle at 16% 8%, rgba(22, 119, 255, 0.2), transparent 26%),
radial-gradient(circle at 88% 18%, rgba(47, 211, 255, 0.22), transparent 24%),
linear-gradient(rgba(255, 255, 255, 0.62) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.62) 1px, transparent 1px),
linear-gradient(135deg, #f7fbff 0%, #e6f3ff 45%, #f5fbff 100%);
--app-bg-overlay: none;
--app-bg-overlay-size: 32px 32px;
--app-bg-card: rgba(255, 255, 255, 0.9);
--app-bg-layout: #e6f3ff;
--app-text-main: #0e2f56;
--app-text-secondary: #4f6f98;
--app-border-color: rgba(22, 119, 255, 0.22);
--app-shadow: 0 14px 34px rgba(22, 119, 255, 0.12);
--app-bg-page: #e6f3ff;
--app-bg-surface: #f6fbff;
--app-bg-surface-soft: #e7f3ff;
--app-bg-surface-strong: #ffffff;
--app-surface-color: rgba(255, 255, 255, 0.92);
--app-text-muted: #5f7da3;
--item-hover-bg: rgba(22, 119, 255, 0.12);
--text-color-secondary: #4f6f98;
--link-color: #1677ff;
--list-table-scroll-y: 100%;
--meeting-status-border: rgba(22, 119, 255, 0.22);
--meeting-status-bg: rgba(22, 119, 255, 0.08);
--meeting-status-color: #1677ff;
--meeting-source-color: #1677ff;
--meeting-progress-color: #1677ff;
--app-form-drawer-width: 600px;
--app-form-drawer-max-width: calc(100vw - 48px);
}
html,
body {
background: #f5f6fa;
background: var(--app-bg-layout);
}
:root[data-theme="tech"] body {
background: var(--app-bg-main);
background-size: auto, auto, var(--app-bg-overlay-size), var(--app-bg-overlay-size), auto;
}
body {
@ -1370,8 +1409,8 @@ body::after,
}
.ant-layout-sider {
background: #fff !important;
border-right: 1px solid #f5f6fa;
background: var(--app-surface-color) !important;
border-right: 1px solid var(--app-bg-layout);
backdrop-filter: none;
}
@ -1389,11 +1428,11 @@ body::after,
.ant-menu-light {
background: transparent !important;
color: #333 !important;
color: var(--app-text-main) !important;
}
.ant-menu-light .ant-menu-item a {
color: #333 !important;
color: var(--app-text-main) !important;
}
.app-page__filter-card,
@ -1402,7 +1441,7 @@ body::after,
border: none;
border-radius: 4px !important;
box-shadow: none;
background: #fff;
background: var(--app-surface-color);
backdrop-filter: none;
}
@ -1425,16 +1464,16 @@ body::after,
}
.app-page__empty-state {
background: #fff;
background: var(--app-surface-color);
border-radius: 4px;
border: 1px dashed #d9d9d9;
border: 1px dashed var(--app-border-color);
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;
background: var(--app-surface-color);
border-top: none;
border-radius: 0;
}

View File

@ -2,19 +2,19 @@
min-height: 100vh;
height: 100vh;
overflow: hidden;
background: #f5f6fa;
background: var(--app-bg-layout);
}
.main-shell {
min-height: 0;
flex: 1;
background: #f5f6fa;
background: var(--app-bg-layout);
}
.main-header {
height: 64px;
padding: 0 24px;
background: #fff;
background: var(--app-surface-color);
display: flex;
align-items: center;
justify-content: space-between;
@ -38,7 +38,7 @@
}
.app-header-logo-title {
color: #1f2a37;
color: var(--app-text-main);
font-size: 18px;
font-weight: 600;
line-height: 1;
@ -58,9 +58,9 @@
position: relative;
height: calc(100vh - 64px);
overflow: visible;
border-right: 1px solid #f5f6fa;
border-top: 2px solid #f5f6fa;
background: #fff !important;
border-right: 1px solid var(--app-bg-layout);
border-top: 2px solid var(--app-bg-layout);
background: var(--app-surface-color) !important;
transition:
flex-basis 0.24s ease,
max-width 0.24s ease,
@ -81,18 +81,18 @@
.main-sider .ant-layout-sider-children::-webkit-scrollbar-track,
.main-menu::-webkit-scrollbar-track {
background: #f8f8f8 !important;
background: var(--app-bg-surface-soft) !important;
}
.main-sider .ant-layout-sider-children::-webkit-scrollbar-thumb,
.main-menu::-webkit-scrollbar-thumb {
background: #f8f8f8 !important;
background: rgba(144, 149, 161, 0.34) !important;
border-radius: 3px !important;
}
.main-sider .ant-layout-sider-children::-webkit-scrollbar-thumb:hover,
.main-menu::-webkit-scrollbar-thumb:hover {
background: #e0e0e0 !important;
background: rgba(144, 149, 161, 0.58) !important;
}
.main-sider .ant-layout-sider-trigger {
@ -181,9 +181,9 @@
border-radius: 50%;
text-align: center;
line-height: 35px;
border: 1px solid #f0f0f0;
border: 1px solid var(--app-border-color);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05);
background: #fff;
background: var(--app-surface-color);
transition:
border-color 0.18s ease,
box-shadow 0.18s ease,
@ -204,8 +204,8 @@
height: 100%;
min-width: 0;
padding: 0;
background: #fff;
color: #666;
background: var(--app-surface-color);
color: var(--app-text-secondary);
border: none;
box-shadow: none;
display: flex;
@ -243,8 +243,8 @@
}
.main-sider .collapsedCenter .trigger:hover {
color: #1890ff;
background: #fff;
color: var(--app-primary-color);
background: var(--app-bg-surface-soft);
}
.main-menu {
@ -253,13 +253,13 @@
overflow-x: hidden;
border-inline-end: none !important;
padding-top: 8px;
background: #fff;
background: var(--app-surface-color);
}
.main-content-layout {
min-width: 0;
min-height: 0;
background: #f5f6fa;
background: var(--app-bg-layout);
}
.main-content {
@ -267,5 +267,73 @@
height: calc(100vh - 64px);
min-height: 0;
overflow: hidden;
background: #f5f6fa;
background: var(--app-bg-layout);
}
:root[data-theme="tech"] .main-header,
:root[data-theme="tech"] .main-sider,
:root[data-theme="tech"] .main-menu,
:root[data-theme="tech"] .collapsedCenter {
box-shadow: inset 0 -1px 0 rgba(22, 119, 255, 0.08), 0 8px 24px rgba(37, 99, 235, 0.06);
}
:root[data-theme="tech"] .main-layout,
:root[data-theme="tech"] .main-shell,
:root[data-theme="tech"] .main-content-layout,
:root[data-theme="tech"] .main-content {
background:
radial-gradient(circle at 12% 8%, rgba(22, 119, 255, 0.18), transparent 28%),
radial-gradient(circle at 92% 22%, rgba(47, 211, 255, 0.18), transparent 24%),
linear-gradient(rgba(255, 255, 255, 0.62) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.62) 1px, transparent 1px),
linear-gradient(135deg, #f7fbff 0%, #e6f3ff 48%, #f5fbff 100%);
background-size: auto, auto, 32px 32px, 32px 32px, auto;
}
:root[data-theme="tech"] .main-header {
background:
linear-gradient(90deg, rgba(var(--app-primary-rgb), 0.1), rgba(47, 211, 255, 0.08)),
linear-gradient(90deg, rgba(255, 255, 255, 0.98), rgba(230, 243, 255, 0.96));
border-bottom: 1px solid rgba(22, 119, 255, 0.16);
}
:root[data-theme="tech"] .main-sider,
:root[data-theme="tech"] .main-menu {
background:
linear-gradient(180deg, rgba(var(--app-primary-rgb), 0.1), rgba(47, 211, 255, 0.08)),
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(224, 240, 255, 0.94)) !important;
}
:root[data-theme="tech"] .main-sider::before {
content: "";
position: absolute;
inset: 0 0 auto;
height: 3px;
background: linear-gradient(90deg, var(--app-primary-color), rgba(47, 211, 255, 0.9));
}
:root[data-theme="tech"] .collapsedCenter,
:root[data-theme="tech"] .main-sider .collapsedCenter .trigger {
background: #ffffff;
}
:root[data-theme="tech"] .main-sider .ant-menu-light,
:root[data-theme="tech"] .main-sider .ant-menu-light .ant-menu-submenu-title,
:root[data-theme="tech"] .main-sider .ant-menu-light .ant-menu-item {
color: var(--app-text-main) !important;
background: transparent !important;
}
:root[data-theme="tech"] .main-sider .ant-menu-light .ant-menu-item-selected,
:root[data-theme="tech"] .main-sider .ant-menu-light .ant-menu-submenu-selected > .ant-menu-submenu-title {
color: var(--app-primary-color) !important;
background:
linear-gradient(90deg, rgba(var(--app-primary-rgb), 0.18), rgba(47, 211, 255, 0.12)) !important;
box-shadow: inset 3px 0 0 var(--app-primary-color);
}
:root[data-theme="tech"] .main-sider .ant-menu-light .ant-menu-item:hover,
:root[data-theme="tech"] .main-sider .ant-menu-light .ant-menu-submenu-title:hover {
color: var(--app-primary-color) !important;
background: rgba(var(--app-primary-rgb), 0.08) !important;
}

View File

@ -249,6 +249,10 @@
"uploadIcon": "Upload Icon",
"uploadBackground": "Upload Background",
"previewHint": "Preview appears here after upload",
"previewMask": "Click to enlarge",
"logoHint": "Used for the top-left brand area. A transparent horizontal image is recommended.",
"iconHint": "Used for browser tabs and small-size recognition. A square image is recommended.",
"loginBgHint": "Used for the login page background. A clear wide image is recommended.",
"icpPlaceholder": "Example: ICP 12345678",
"copyrightPlaceholder": "Example: © 2026 UnisBase Team"
},

View File

@ -249,6 +249,10 @@
"uploadIcon": "上传图标",
"uploadBackground": "上传背景图",
"previewHint": "上传后将在此处预览",
"previewMask": "点击放大预览",
"logoHint": "用于左上角品牌展示,建议使用透明底横版图",
"iconHint": "用于浏览器标签和小尺寸识别,建议使用正方形图",
"loginBgHint": "用于登录页背景展示,建议使用清晰横版大图",
"icpPlaceholder": "例如:京 ICP 12345678 号",
"copyrightPlaceholder": "例如:© 2026 UnisBase Team"
},

View File

@ -1,7 +1,7 @@
.meeting-points-page {
padding: 8px;
min-width: 0;
background: #f5f6fa;
background: var(--app-bg-layout, #f5f6fa);
}
.meeting-points-page > .page-container__body {

View File

@ -1,7 +1,6 @@
.tenant-meeting-points {
padding: 8px;
min-width: 0;
background: #f5f6fa;
background: var(--app-bg-layout, #f5f6fa);
}
.tenant-meeting-points > .page-container__body {
@ -12,33 +11,216 @@
background: transparent;
}
.tenant-meeting-points__tenant-section {
max-width: 100%;
}
.tenant-meeting-points__tenant-content {
padding: 12px;
}
.tenant-meeting-points__tenant-card {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
padding: 8px 12px;
padding: 18px;
box-sizing: border-box;
border: 1px solid #edf0f5;
border-radius: 4px;
background: #fff;
box-shadow: 0 8px 24px rgba(24, 38, 73, 0.04);
}
.tenant-meeting-points__tenant-heading {
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.tenant-meeting-points__mode-card {
.tenant-meeting-points__tenant-identity {
display: flex;
align-items: center;
min-width: 0;
gap: 12px;
}
.tenant-meeting-points__tenant-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 44px;
width: 44px;
height: 44px;
border-radius: 4px;
color: #2f6cf6;
font-size: 22px;
background: #edf4ff;
}
.tenant-meeting-points__tenant-name {
display: block;
color: var(--app-text-main, #202733);
font-size: 18px;
line-height: 26px;
}
.tenant-meeting-points__tenant-actions {
justify-content: flex-end;
}
.tenant-meeting-points__mode-panel {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
border: 1px solid transparent;
border-radius: 4px;
}
.tenant-meeting-points__mode-card--enabled {
background: #f6ffed;
.tenant-meeting-points__mode-panel--enabled {
border-color: #b7eb8f;
background: linear-gradient(90deg, #f6ffed 0%, #fbfffa 100%);
}
.tenant-meeting-points__mode-card--unlimited {
background: #fff7e6;
.tenant-meeting-points__mode-panel--unlimited {
border-color: #ffd591;
background: linear-gradient(90deg, #fff7e6 0%, #fffdf7 100%);
}
.tenant-meeting-points__mode-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 30px;
width: 30px;
height: 30px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.76);
font-size: 16px;
}
.tenant-meeting-points__mode-panel--enabled .tenant-meeting-points__mode-icon {
color: #389e0d;
}
.tenant-meeting-points__mode-panel--unlimited .tenant-meeting-points__mode-icon {
color: #d46b08;
}
.tenant-meeting-points__mode-copy {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.tenant-meeting-points__stats {
display: grid;
grid-template-columns: repeat(3, minmax(180px, 1fr));
gap: 12px;
width: 100%;
}
.tenant-meeting-points__stat-card {
position: relative;
min-width: 0;
padding: 16px;
overflow: hidden;
border: 1px solid #edf0f5;
border-radius: 4px;
background: #fafbfe;
}
.tenant-meeting-points__stat-card::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 3px;
background: #d5dce8;
}
.tenant-meeting-points__stat-card--primary {
background: #f4f8ff;
}
.tenant-meeting-points__stat-card--primary::before {
background: #2f6cf6;
}
.tenant-meeting-points__stat-card .ant-statistic-title {
margin-bottom: 8px;
color: var(--app-text-secondary, #778195);
font-size: 13px;
line-height: 20px;
}
.tenant-meeting-points__stat-card .ant-statistic-content {
color: var(--app-text-main, #202733);
font-size: 24px;
line-height: 32px;
}
.tenant-meeting-points__meta {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: 10px;
padding-top: 2px;
}
.tenant-meeting-points__meta-item {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 10px 12px;
border: 1px solid #edf0f5;
border-radius: 4px;
color: var(--app-text-secondary, #778195);
background: #fbfcff;
font-size: 14px;
line-height: 22px;
}
.tenant-meeting-points__meta-item span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 992px) {
.tenant-meeting-points__stats,
.tenant-meeting-points__meta {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.tenant-meeting-points__tenant-content {
padding: 8px;
}
.tenant-meeting-points__tenant-card {
padding: 14px;
}
.tenant-meeting-points__tenant-heading {
flex-direction: column;
}
.tenant-meeting-points__tenant-actions,
.tenant-meeting-points__tenant-actions .ant-btn {
width: 100%;
}
.tenant-meeting-points__tenant-actions .ant-space-item {
width: 100%;
}
.tenant-meeting-points__mode-panel {
padding: 12px;
}
}

View File

@ -1,4 +1,11 @@
import { ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import {
CheckCircleOutlined,
ClockCircleOutlined,
DatabaseOutlined,
ReloadOutlined,
SearchOutlined,
SafetyCertificateOutlined,
} from "@ant-design/icons";
import { getCurrentUser } from "@/api";
import AppPagination from "@/components/shared/AppPagination";
import DataListPanel from "@/components/shared/DataListPanel";
@ -13,7 +20,7 @@ import {
updateTenantMeetingPointsBalanceCheck,
} from "@/api/business/meetingPoints";
import type { UserProfile } from "@/types";
import { Button, Card, Input, message, Modal, Select, Space, Statistic, Tag, Typography } from "antd";
import { Button, Input, message, Modal, Select, Space, Statistic, Tag, Typography } from "antd";
import { useEffect, useState } from "react";
import "./TenantMeetingPointsSettings.css";
@ -207,67 +214,95 @@ export default function TenantMeetingPointsSettings() {
if (!currentTenantSetting) {
return null;
}
const isBalanceCheckEnabled = currentTenantSetting.balanceCheckEnabled;
return (
<SectionCard
title="当前租户"
description="租户管理员可查看并切换当前租户的积分余额校验模式。"
layout="auto"
className="tenant-meeting-points__tenant-section"
contentClassName="tenant-meeting-points__tenant-content"
>
<Space direction="vertical" size="large" className="tenant-meeting-points__tenant-card">
<div className="tenant-meeting-points__tenant-card">
<div className="tenant-meeting-points__tenant-heading">
<Space direction="vertical" size={4}>
<Text strong style={{ fontSize: 18 }}>{currentTenantSetting.tenantName || "当前租户"}</Text>
<Text type="secondary">{currentTenantSetting.tenantCode || "-"}</Text>
<div className="tenant-meeting-points__tenant-identity">
<div className="tenant-meeting-points__tenant-icon">
<SafetyCertificateOutlined />
</div>
<Space direction="vertical" size={4}>
<Text strong className="tenant-meeting-points__tenant-name">
{currentTenantSetting.tenantName || "当前租户"}
</Text>
<Text type="secondary">{currentTenantSetting.tenantCode || "-"}</Text>
</Space>
</div>
<Space className="tenant-meeting-points__tenant-actions" wrap>
{renderStatusTag(isBalanceCheckEnabled)}
{canUpdateBalanceCheck ? (
<Button
type="primary"
loading={switchingTenantId === currentTenantSetting.tenantId}
onClick={() => confirmSwitch(currentTenantSetting, !isBalanceCheckEnabled)}
>
{isBalanceCheckEnabled ? "切换为无限余额" : "开启余额校验"}
</Button>
) : null}
<Button icon={<ReloadOutlined />} onClick={() => void handleRefresh()}>
</Button>
</Space>
{renderStatusTag(currentTenantSetting.balanceCheckEnabled)}
</div>
<Card
size="small"
<div
className={
currentTenantSetting.balanceCheckEnabled
? "tenant-meeting-points__mode-card tenant-meeting-points__mode-card--enabled"
: "tenant-meeting-points__mode-card tenant-meeting-points__mode-card--unlimited"
isBalanceCheckEnabled
? "tenant-meeting-points__mode-panel tenant-meeting-points__mode-panel--enabled"
: "tenant-meeting-points__mode-panel tenant-meeting-points__mode-panel--unlimited"
}
>
<Space direction="vertical" size={4}>
<Text strong>
{currentTenantSetting.balanceCheckEnabled ? "当前为校验余额模式" : "当前为无限余额模式"}
</Text>
<div className="tenant-meeting-points__mode-icon">
<CheckCircleOutlined />
</div>
<div className="tenant-meeting-points__mode-copy">
<Text strong>{isBalanceCheckEnabled ? "当前为校验余额模式" : "当前为无限余额模式"}</Text>
<Text type="secondary">
{currentTenantSetting.balanceCheckEnabled
{isBalanceCheckEnabled
? "后续会议提交将按当前账面余额执行拦截与扣减。"
: "后续会议只记录消耗与流水,不扣减账面余额,积分分配也会被禁用。"}
</Text>
</Space>
</Card>
</div>
</div>
<Space size={40} wrap className="tenant-meeting-points__stats">
<Statistic title="当前可用额度" value={currentTenantSetting.balanceCheckEnabled ? currentTenantSetting.publicBalance ?? 0 : "无限"} />
<Statistic title="公共账户余额" value={currentTenantSetting.publicBalance ?? 0} />
<Statistic title="公共账户累计消耗" value={currentTenantSetting.publicTotalPointsUsed ?? 0} />
</Space>
<div className="tenant-meeting-points__stats">
<div className="tenant-meeting-points__stat-card tenant-meeting-points__stat-card--primary">
<Statistic
title="当前可用额度"
value={isBalanceCheckEnabled ? currentTenantSetting.publicBalance ?? 0 : "无限"}
/>
</div>
<div className="tenant-meeting-points__stat-card">
<Statistic
title="公共账户余额"
value={currentTenantSetting.publicBalance ?? 0}
prefix={<DatabaseOutlined />}
/>
</div>
<div className="tenant-meeting-points__stat-card">
<Statistic title="公共账户累计消耗" value={currentTenantSetting.publicTotalPointsUsed ?? 0} />
</div>
</div>
<Space direction="vertical" size={4}>
<Text type="secondary">{formatDateTime(currentTenantSetting.lastSwitchAt)}</Text>
<Text type="secondary">{currentTenantSetting.lastSwitchByName || "-"}</Text>
</Space>
<Space>
{canUpdateBalanceCheck ? (
<Button
type="primary"
loading={switchingTenantId === currentTenantSetting.tenantId}
onClick={() => confirmSwitch(currentTenantSetting, !currentTenantSetting.balanceCheckEnabled)}
>
{currentTenantSetting.balanceCheckEnabled ? "切换为无限余额" : "开启余额校验"}
</Button>
) : null}
<Button icon={<ReloadOutlined />} onClick={() => void handleRefresh()}>
</Button>
</Space>
</Space>
<div className="tenant-meeting-points__meta">
<div className="tenant-meeting-points__meta-item">
<ClockCircleOutlined />
<span>{formatDateTime(currentTenantSetting.lastSwitchAt)}</span>
</div>
<div className="tenant-meeting-points__meta-item">
<SafetyCertificateOutlined />
<span>{currentTenantSetting.lastSwitchByName || "-"}</span>
</div>
</div>
</div>
</SectionCard>
);
};

View File

@ -16,180 +16,520 @@
overflow: auto;
}
.profile-layout {
.profile-shell {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.profile-side-col,
.profile-main-col {
.profile-hero {
position: relative;
display: flex;
align-items: stretch;
justify-content: space-between;
gap: 18px;
min-width: 0;
overflow: hidden;
padding: 22px 24px;
border: 1px solid #dce6f8;
border-radius: 8px;
background:
linear-gradient(112deg, rgba(60, 112, 245, 0.11), rgba(255, 255, 255, 0.68) 42%, rgba(25, 135, 84, 0.08)),
#ffffff;
}
.profile-hero::after {
content: "";
position: absolute;
right: -54px;
top: -70px;
width: 280px;
height: 180px;
border: 1px solid rgba(60, 112, 245, 0.14);
border-radius: 8px;
background: repeating-linear-gradient(
-35deg,
rgba(60, 112, 245, 0.08) 0,
rgba(60, 112, 245, 0.08) 1px,
transparent 1px,
transparent 10px
);
transform: rotate(8deg);
pointer-events: none;
}
.profile-hero__identity {
position: relative;
z-index: 1;
}
.profile-hero__identity {
display: flex;
align-items: center;
gap: 18px;
min-width: 0;
}
.profile-avatar-button {
position: relative;
flex: 0 0 auto;
width: 96px;
height: 96px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
cursor: pointer;
}
.profile-avatar-button:focus-visible {
outline: 3px solid rgba(60, 112, 245, 0.28);
outline-offset: 4px;
}
.profile-hero__avatar.ant-avatar {
width: 88px;
height: 88px;
border: 4px solid #ffffff;
background: #3c70f5;
box-shadow: 0 10px 28px rgba(60, 112, 245, 0.22);
}
.profile-avatar-button__badge {
position: absolute;
right: 4px;
bottom: 5px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 2px solid #ffffff;
border-radius: 50%;
color: #ffffff;
background: #198754;
box-shadow: 0 8px 18px rgba(25, 135, 84, 0.2);
}
.profile-hero__copy {
min-width: 0;
}
.profile-hero__eyebrow,
.profile-panel__eyebrow {
color: #64748b;
font-size: 12px;
font-weight: 600;
line-height: 18px;
letter-spacing: 0;
text-transform: uppercase;
}
.profile-hero__title.ant-typography {
margin: 4px 0 2px;
color: #1f2937;
font-size: 26px;
line-height: 34px;
}
.profile-hero__username.ant-typography {
display: block;
color: #516070;
}
.profile-hero__tags {
margin-top: 12px;
}
.profile-workbench {
display: grid;
grid-template-columns: minmax(292px, 360px) minmax(0, 1fr);
gap: 14px;
min-width: 0;
}
.profile-side-stack {
width: 100%;
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.profile-panel-card.ant-card {
border: 1px solid #e6e6e6;
border-radius: 4px;
box-shadow: none;
.profile-panel {
min-width: 0;
border: 1px solid #e5e9f2;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 8px 22px rgba(30, 41, 59, 0.04);
}
.profile-panel-card.ant-card .ant-card-body {
.profile-overview-panel,
.profile-security-panel {
padding: 18px;
}
.profile-summary-card,
.profile-help-card,
.profile-tabs-card {
overflow: hidden;
}
.profile-summary-card__header {
.profile-panel__head,
.profile-editor-panel__head,
.profile-form-intro {
display: flex;
flex-direction: column;
align-items: center;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding-bottom: 20px;
min-width: 0;
}
.profile-summary-card__avatar-trigger {
width: fit-content;
cursor: pointer;
.profile-panel__title.ant-typography,
.profile-form-intro .ant-typography {
margin: 2px 0 0;
color: #1f2937;
}
.profile-summary-card__avatar.ant-avatar {
background: #3c70f5;
box-shadow: none;
}
.profile-summary-card__identity {
text-align: center;
}
.profile-summary-card__title.ant-typography {
margin: 0 0 4px;
}
.profile-summary-card__tags {
.profile-panel__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
margin: 0;
flex: 0 0 auto;
width: 36px;
height: 36px;
border-radius: 8px;
color: #3c70f5;
background: #eef4ff;
font-size: 18px;
}
.profile-summary-card__meta {
margin-top: 16px;
padding-top: 20px;
border-top: 1px solid #f0f0f0;
}
.profile-summary-card__meta .ant-descriptions-view {
border-radius: 4px;
overflow: hidden;
}
.profile-summary-card__meta .ant-descriptions-item-label {
width: 96px;
color: #64748b;
}
.profile-help-card__title.ant-typography {
margin-bottom: 12px;
}
.profile-help-card__list {
.profile-meta-list {
display: flex;
flex-direction: column;
gap: 10px;
color: #475569;
line-height: 1.7;
margin-top: 18px;
}
.profile-help-card__alert {
.profile-meta-list__item {
display: flex;
align-items: flex-start;
gap: 12px;
min-width: 0;
padding: 13px;
border: 1px solid #edf1f7;
border-radius: 8px;
background: #fafbfe;
}
.profile-meta-list__icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 32px;
height: 32px;
border-radius: 8px;
color: #2463eb;
background: #eef4ff;
}
.profile-meta-list__content {
min-width: 0;
}
.profile-meta-list__label {
margin-bottom: 4px;
color: #64748b;
font-size: 12px;
line-height: 18px;
}
.profile-meta-list__value {
min-width: 0;
color: #1f2937;
font-weight: 600;
line-height: 22px;
overflow-wrap: anywhere;
}
.profile-security-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 18px;
}
.profile-security-list > div {
padding-left: 12px;
border-left: 3px solid #d8e4fb;
}
.profile-security-list strong,
.profile-security-list span {
display: block;
}
.profile-security-list strong {
margin-bottom: 3px;
color: #1f2937;
font-size: 14px;
line-height: 22px;
}
.profile-security-list span {
color: #64748b;
line-height: 22px;
}
.profile-security-panel__alert,
.profile-security-alert {
margin-top: 16px;
border-radius: 4px;
border-radius: 8px;
}
.profile-tabs-card .ant-card-body {
padding: 0;
.profile-editor-panel {
display: flex;
flex-direction: column;
overflow: hidden;
}
.profile-editor-panel__head {
padding: 18px 20px 14px;
border-bottom: 1px solid #edf1f7;
}
.profile-tabs .ant-tabs-nav {
margin: 0;
padding: 0 18px;
border-bottom: 1px solid #f0f0f0;
background: #fff;
padding: 0 20px;
border-bottom: 1px solid #edf1f7;
background: #ffffff;
}
.profile-tabs .ant-tabs-nav::before {
border-bottom: none;
}
.profile-tabs .ant-tabs-tab {
padding: 14px 0;
}
.profile-tabs .ant-tabs-tab + .ant-tabs-tab {
margin-left: 28px;
}
.profile-tabs .ant-tabs-content-holder {
padding: 18px;
padding: 20px;
}
.profile-form {
max-width: 980px;
}
.profile-form-intro {
align-items: center;
margin-bottom: 18px;
padding: 14px 16px;
border: 1px solid #edf1f7;
border-radius: 8px;
background: #fafbfe;
}
.profile-form-intro .ant-typography-secondary {
display: block;
margin-top: 4px;
}
.profile-form .ant-form-item {
margin-bottom: 20px;
margin-bottom: 18px;
}
.profile-upload-panel {
.profile-form .ant-input,
.profile-form .ant-input-affix-wrapper {
border-radius: 6px;
}
.profile-avatar-control {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 18px;
border-radius: 4px;
border: 1px dashed #b7cdfd;
background: #f9fafe;
align-items: stretch;
gap: 18px;
box-sizing: border-box;
width: 100%;
min-width: 0;
margin-top: 2px;
padding: 16px;
border: 1px dashed #9fbcfb;
border-radius: 8px;
background: linear-gradient(90deg, rgba(60, 112, 245, 0.07), rgba(25, 135, 84, 0.05));
}
.profile-upload-panel__hint {
margin-top: 4px;
.profile-avatar-control__preview {
flex: 0 0 auto;
width: 132px;
height: 132px;
overflow: hidden;
border: 1px solid #dbe6fb;
border-radius: 8px;
background: #ffffff;
}
.profile-avatar-control__preview .ant-image,
.profile-avatar-control__image {
display: block;
width: 132px !important;
height: 132px !important;
}
.profile-avatar-control__image {
object-fit: cover;
}
.profile-avatar-control__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
height: 100%;
padding: 0;
border: none;
color: #2463eb;
background: #f8fbff;
cursor: pointer;
}
.profile-avatar-control__empty .anticon {
font-size: 26px;
}
.profile-avatar-control__empty span {
color: #475569;
font-weight: 600;
line-height: 22px;
}
.profile-avatar-control__content {
display: flex;
flex: 1;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
min-width: 0;
gap: 14px;
padding: 4px 0;
}
.profile-avatar-control__title {
margin-bottom: 6px;
color: #1f2937;
font-size: 15px;
font-weight: 600;
line-height: 22px;
}
.profile-avatar-control__hint {
max-width: 520px;
color: #64748b;
font-size: 13px;
}
.profile-security-alert,
.profile-credential-panel__card {
margin-bottom: 20px;
}
.profile-security-alert {
border-radius: 4px;
line-height: 22px;
}
.profile-credential-panel {
display: flex;
flex-direction: column;
gap: 18px;
}
.profile-credential-panel__card {
padding: 0;
background: transparent;
box-shadow: none;
}
.profile-credential-panel__card .ant-card-body {
padding: 0;
}
.profile-credential-panel__descriptions .ant-descriptions-view {
border-radius: 4px;
.profile-credential-card {
border-radius: 8px;
overflow: hidden;
}
.profile-credential-panel__descriptions .ant-descriptions-view {
border-radius: 8px;
overflow: hidden;
}
.profile-credential-panel__descriptions .ant-descriptions-item-label {
width: 180px;
color: #475569;
background: #f8fafc;
}
.profile-tab-actions {
margin: 8px 0 0;
margin: 16px 0 0;
padding-top: 16px;
border-top: 1px solid #edf1f7;
}
@media (max-width: 1200px) {
.profile-hero {
flex-direction: column;
}
.profile-workbench {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.profile-tabs .ant-tabs-nav {
padding: 0 16px;
.profile-shell {
gap: 12px;
}
.profile-hero {
padding: 18px;
}
.profile-hero__identity {
align-items: flex-start;
}
.profile-hero__title.ant-typography {
font-size: 22px;
line-height: 30px;
}
.profile-workbench {
grid-template-columns: 1fr;
}
.profile-editor-panel__head,
.profile-form-intro,
.profile-avatar-control {
flex-direction: column;
align-items: flex-start;
}
.profile-avatar-control__preview {
width: 112px;
height: 112px;
}
.profile-avatar-control__preview .ant-image,
.profile-avatar-control__image {
width: 112px !important;
height: 112px !important;
}
.profile-tabs .ant-tabs-nav,
.profile-tabs .ant-tabs-content-holder {
padding-right: 16px;
padding-left: 16px;
}
.profile-tabs .ant-tabs-tab + .ant-tabs-tab {
margin-left: 18px;
}
}
@media (max-width: 520px) {
.profile-hero__identity {
flex-direction: column;
}
.profile-editor-panel__head {
padding: 16px;
}
.profile-upload-panel {
align-items: flex-start;
flex-direction: column;
.profile-overview-panel,
.profile-security-panel,
.profile-tabs .ant-tabs-content-holder {
padding: 16px;
}
}

View File

@ -2,10 +2,10 @@ import {
Alert,
Avatar,
Button,
Card,
Col,
Descriptions,
Form,
Image,
Input,
Row,
Space,
@ -17,10 +17,17 @@ import {
message
} from "antd";
import {
ApartmentOutlined,
CheckCircleOutlined,
CloudUploadOutlined,
IdcardOutlined,
KeyOutlined,
LockOutlined,
MailOutlined,
PhoneOutlined,
QuestionCircleOutlined,
ReloadOutlined,
SafetyCertificateOutlined,
SaveOutlined,
SolutionOutlined,
UploadOutlined,
@ -38,6 +45,7 @@ import AvatarCropDialog, { type CropModalState } from "./AvatarCropDialog";
import "./index.less";
const {Paragraph, Text, Title} = Typography;
const AVATAR_UPLOAD_ACCEPT = "image/png,image/jpeg";
export default function Profile() {
const { t } = useTranslation();
@ -109,6 +117,11 @@ export default function Profile() {
};
const handleAvatarUpload = (file: File) => {
if (file.type !== "image/png" && file.type !== "image/jpeg") {
message.error("仅支持上传 PNG 或 JPG 图片");
return Upload.LIST_IGNORE;
}
const reader = new FileReader();
reader.onload = () => {
setCropState({
@ -172,6 +185,43 @@ export default function Profile() {
const avatarUrlValue = Form.useWatch("avatarUrl", profileForm) as string | undefined;
const avatarUrl = avatarUrlValue?.trim() || undefined;
const userStatus = user ? (user.status === 0 ? <Tag color="red"></Tag> : <Tag color="green"></Tag>) : "-";
const displayName = user?.displayName || "-";
const username = user?.username || "-";
const organization = renderValue(user?.orgPath || user?.orgName);
const roleTags = (
<>
{user?.isPlatformAdmin ? <Tag color="gold">{t("users.platformAdmin")}</Tag> : null}
{user?.isTenantAdmin ? <Tag color="blue"></Tag> : null}
{user && !user.isPlatformAdmin && !user.isTenantAdmin ? <Tag color="blue">{t("profile.standardUser")}</Tag> : null}
{user && !user.isPlatformAdmin && user.hasPlatformAdminPrivilege ? <Tag color="geekblue"></Tag> : null}
</>
);
const profileMetaItems = [
{
key: "email",
icon: <MailOutlined />,
label: t("users.email"),
value: renderValue(user?.email)
},
{
key: "phone",
icon: <PhoneOutlined />,
label: t("users.phone"),
value: renderValue(user?.phone)
},
{
key: "organization",
icon: <ApartmentOutlined />,
label: t("users.org"),
value: organization
},
{
key: "status",
icon: <CheckCircleOutlined />,
label: t("common.status"),
value: userStatus
}
];
return (
<PageContainer title={null} className="profile-page">
@ -180,95 +230,106 @@ export default function Profile() {
description={t("profile.subtitle")}
contentClassName="profile-section-content"
>
<Row gutter={[16, 16]} className="profile-layout">
<Col xs={24} xl={8} className="profile-side-col">
<Space direction="vertical" size={12} className="profile-side-stack">
<Card className="profile-panel-card profile-summary-card" loading={loading}>
<div className="profile-summary-card__header">
<Upload accept="image/*" showUploadList={false} beforeUpload={handleAvatarUpload}
<div className="profile-shell">
<section className="profile-hero" aria-busy={loading}>
<div className="profile-hero__identity">
<Upload accept={AVATAR_UPLOAD_ACCEPT} showUploadList={false} beforeUpload={handleAvatarUpload}
disabled={avatarUploading}>
<div className="profile-summary-card__avatar-trigger">
<button type="button" className="profile-avatar-button" aria-label="上传头像">
<Avatar
size={92}
size={88}
src={avatarUrl}
icon={avatarUrl ? undefined : <UserOutlined/>}
className="profile-summary-card__avatar"
className="profile-hero__avatar"
/>
</div>
<span className="profile-avatar-button__badge">
<CloudUploadOutlined />
</span>
</button>
</Upload>
<div className="profile-summary-card__identity">
<Title level={4} className="profile-summary-card__title">
{user?.displayName || "-"}
<div className="profile-hero__copy">
<div className="profile-hero__eyebrow"></div>
<Title level={3} className="profile-hero__title">
{displayName}
</Title>
<Text type="secondary" className="tabular-nums">
@{user?.username || "-"}
<Text className="profile-hero__username tabular-nums">
@{username}
</Text>
</div>
<Space wrap size={[8, 8]} className="profile-summary-card__tags">
{user?.isPlatformAdmin ? <Tag color="gold">{t("users.platformAdmin")}</Tag> : null}
{user?.isTenantAdmin ? <Tag color="blue"></Tag> : null}
{user && !user.isPlatformAdmin && !user.isTenantAdmin ?
<Tag color="blue">{t("profile.standardUser")}</Tag> : null}
{user && !user.isPlatformAdmin && user.hasPlatformAdminPrivilege ?
<Tag color="geekblue"></Tag> : null}
<Space wrap size={[8, 8]} className="profile-hero__tags">
{roleTags}
</Space>
</div>
</div>
<Descriptions
column={1}
size="small"
className="profile-summary-card__meta"
items={[
{
key: "email",
label: t("users.email"),
children: <span className="tabular-nums">{renderValue(user?.email)}</span>
},
{
key: "phone",
label: t("users.phone"),
children: <span className="tabular-nums">{renderValue(user?.phone)}</span>
},
{
key: "organization",
label: t("users.org"),
children: renderValue(user?.orgPath || user?.orgName)
},
{
key: "status",
label: t("common.status"),
children: userStatus
}
]}
/>
</Card>
</section>
<Card className="profile-panel-card profile-help-card">
<Title level={5} className="profile-help-card__title">
</Title>
<div className="profile-help-card__list">
<div></div>
<div></div>
<div>Bot </div>
<div className="profile-workbench">
<aside className="profile-side-stack">
<section className="profile-panel profile-overview-panel">
<div className="profile-panel__head">
<div>
<div className="profile-panel__eyebrow"></div>
<Title level={5} className="profile-panel__title"></Title>
</div>
<IdcardOutlined className="profile-panel__icon" />
</div>
<div className="profile-meta-list">
{profileMetaItems.map((item) => (
<div className="profile-meta-list__item" key={item.key}>
<div className="profile-meta-list__icon">{item.icon}</div>
<div className="profile-meta-list__content">
<div className="profile-meta-list__label">{item.label}</div>
<div className="profile-meta-list__value tabular-nums">{item.value}</div>
</div>
</div>
))}
</div>
</section>
<section className="profile-panel profile-security-panel">
<div className="profile-panel__head">
<div>
<div className="profile-panel__eyebrow"></div>
<Title level={5} className="profile-panel__title"></Title>
</div>
<SafetyCertificateOutlined className="profile-panel__icon" />
</div>
<div className="profile-security-list">
<div>
<strong></strong>
<span></span>
</div>
<div>
<strong></strong>
<span></span>
</div>
<div>
<strong>Bot </strong>
<span></span>
</div>
</div>
{user?.pwdResetRequired ? (
<Alert
showIcon
type="warning"
message="当前账号仍需要完成密码重置"
description="请尽快在“安全设置”页签中更新密码。"
className="profile-security-panel__alert"
/>
) : null}
</section>
</aside>
<section className="profile-panel profile-editor-panel">
<div className="profile-editor-panel__head">
<div>
<div className="profile-panel__eyebrow"></div>
<Title level={5} className="profile-panel__title"></Title>
</div>
<Text type="secondary"></Text>
</div>
{user?.pwdResetRequired ? (
<Alert
showIcon
type="warning"
message="当前账号仍需要完成密码重置"
description="请尽快在“安全设置”页签中更新密码。"
className="profile-help-card__alert"
/>
) : null}
</Card>
</Space>
</Col>
<Col xs={24} xl={16} className="profile-main-col">
<Card className="profile-panel-card profile-tabs-card">
<Tabs
defaultActiveKey="basic"
className="profile-tabs"
@ -282,6 +343,12 @@ export default function Profile() {
),
children: (
<Form form={profileForm} layout="vertical" onFinish={handleUpdateProfile} className="profile-form">
<div className="profile-form-intro">
<div>
<Title level={5}></Title>
<Text type="secondary"></Text>
</div>
</div>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item label={t("users.displayName")} name="displayName" rules={[{required: true}]}>
@ -305,24 +372,58 @@ export default function Profile() {
<Input/>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item label={t("profile.avatarUrl")} name="avatarUrl">
<Input placeholder={t("profile.avatarUrlPlaceholder")}/>
</Form.Item>
</Col>
</Row>
<div className="profile-upload-panel">
<div>
<Text strong>{t("profile.uploadAvatar")}</Text>
<div className="profile-upload-panel__hint"></div>
</div>
<Upload accept="image/*" showUploadList={false} beforeUpload={handleAvatarUpload}>
<Button icon={<UploadOutlined/>} loading={avatarUploading}>
{t("profile.uploadAvatar")}
</Button>
</Upload>
</div>
<Form.Item name="avatarUrl" hidden>
<Input />
</Form.Item>
<Row gutter={16}>
<Col xs={24} md={12}>
<div className="profile-avatar-control">
<div className="profile-avatar-control__preview">
{avatarUrl ? (
<Image
src={avatarUrl}
alt="头像预览"
className="profile-avatar-control__image"
preview={{ mask: "查看大图" }}
/>
) : (
<Upload
accept={AVATAR_UPLOAD_ACCEPT}
showUploadList={false}
beforeUpload={handleAvatarUpload}
disabled={avatarUploading}
>
<button type="button" className="profile-avatar-control__empty">
<CloudUploadOutlined />
<span></span>
</button>
</Upload>
)}
</div>
<div className="profile-avatar-control__content">
<div>
<div className="profile-avatar-control__title"></div>
<div className="profile-avatar-control__hint">
{avatarUrl ? "当前头像已回显,点击图片可放大预览。" : "支持 PNG/JPG 图片,上传后会先裁剪成统一尺寸。"}
</div>
</div>
<Upload
accept={AVATAR_UPLOAD_ACCEPT}
showUploadList={false}
beforeUpload={handleAvatarUpload}
disabled={avatarUploading}
>
<Button icon={<UploadOutlined/>} loading={avatarUploading}>
{avatarUrl ? "更换头像" : t("profile.uploadAvatar")}
</Button>
</Upload>
</div>
</div>
</Col>
</Row>
<div className="app-page__content-toolbar profile-tab-actions">
<div className="app-page__content-toolbar-actions">
@ -344,6 +445,12 @@ export default function Profile() {
),
children: (
<Form form={pwdForm} layout="vertical" onFinish={handleUpdatePassword} className="profile-form">
<div className="profile-form-intro">
<div>
<Title level={5}></Title>
<Text type="secondary"></Text>
</div>
</div>
<Alert
showIcon
type="info"
@ -426,6 +533,12 @@ export default function Profile() {
),
children: (
<div className="profile-credential-panel">
<div className="profile-form-intro">
<div>
<Title level={5}>Bot </Title>
<Text type="secondary"> Bot 便</Text>
</div>
</div>
<Alert
type="info"
showIcon
@ -433,7 +546,7 @@ export default function Profile() {
description={t("profile.botCredentialHintDesc")}
/>
<Card bordered={false} loading={credentialLoading} className="profile-credential-panel__card">
<div className="profile-credential-card" aria-busy={credentialLoading}>
<Descriptions
bordered
size="middle"
@ -480,7 +593,7 @@ export default function Profile() {
}
]}
/>
</Card>
</div>
<div className="app-page__content-toolbar profile-tab-actions">
<div className="app-page__content-toolbar-actions">
@ -500,9 +613,9 @@ export default function Profile() {
}
]}
/>
</Card>
</Col>
</Row>
</section>
</div>
</div>
</SectionCard>
<AvatarCropDialog

View File

@ -21,29 +21,177 @@
}
.platform-settings-toolbar {
position: sticky;
top: 0;
z-index: 2;
margin: 0 4px 12px;
padding: 8px 0;
background: var(--app-bg-surface-soft, #f9fafe);
}
.platform-settings-form {
width: 100%;
}
.platform-settings-page .app-page__content-card {
overflow: visible;
.platform-settings-layout {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.platform-settings-page .ant-card {
.platform-settings-card.ant-card {
border: 1px solid #e6e6e6;
border-radius: 4px;
box-shadow: none;
overflow: hidden;
}
.platform-settings-card > .ant-card-head {
min-height: 54px;
border-bottom-color: #edf0f5;
}
.platform-settings-card > .ant-card-body {
padding: 22px 24px 24px;
}
.platform-settings-card .ant-form-item {
margin-bottom: 0;
}
.platform-settings-page .ant-row {
row-gap: 24px;
}
.platform-assets-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.platform-asset-card {
min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px;
border: 1px solid #edf0f5;
border-radius: 4px;
background: #fbfcff;
}
.platform-asset-card__header {
min-height: 52px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.platform-asset-card__title {
color: var(--app-text-main, #333);
font-size: 14px;
font-weight: 600;
line-height: 22px;
}
.platform-asset-card__description {
margin-top: 4px;
color: var(--app-text-secondary, #9095a1);
font-size: 12px;
line-height: 20px;
}
.platform-asset-card__preview {
position: relative;
height: 168px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px dashed #d8dde8;
border-radius: 4px;
background: #fff;
}
.platform-asset-card--icon .platform-asset-card__preview {
background-image:
linear-gradient(45deg, #f3f5f9 25%, transparent 25%),
linear-gradient(-45deg, #f3f5f9 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #f3f5f9 75%),
linear-gradient(-45deg, transparent 75%, #f3f5f9 75%);
background-position: 0 0, 0 10px, 10px -10px, -10px 0;
background-size: 20px 20px;
}
.platform-asset-card--background .platform-asset-card__preview {
height: 188px;
}
.platform-asset-card__preview .ant-image {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.platform-asset-card__preview .ant-image-img {
width: 100%;
height: 100%;
object-fit: contain;
}
.platform-asset-card--background .platform-asset-card__preview .ant-image-img {
object-fit: cover;
}
.platform-asset-card__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: #8f96a3;
font-size: 12px;
line-height: 20px;
text-align: center;
}
.platform-asset-card__empty .anticon {
color: #b4bac6;
font-size: 28px;
}
.platform-asset-card .ant-upload-wrapper,
.platform-asset-card .ant-upload {
width: 100%;
}
@media (max-height: 1080px) {
.platform-settings-scroll {
padding-bottom: 48px;
}
}
@media (max-width: 1200px) {
.platform-assets-grid {
grid-template-columns: 1fr;
}
.platform-asset-card__preview,
.platform-asset-card--background .platform-asset-card__preview {
height: 180px;
}
}
@media (max-width: 768px) {
.platform-settings-card > .ant-card-body {
padding: 16px;
}
.platform-settings-toolbar {
margin-bottom: 8px;
}
}

View File

@ -1,4 +1,4 @@
import { Button, Card, Col, Form, Input, Row, Space, Upload, message } from "antd";
import { Button, Card, Col, Form, Image, Input, Row, Space, Upload, message } from "antd";
import { FileTextOutlined, GlobalOutlined, PictureOutlined, SaveOutlined, UploadOutlined } from "@ant-design/icons";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@ -9,18 +9,60 @@ import type { SysPlatformConfig } from "@/types";
import "./index.less";
type PlatformFormValues = SysPlatformConfig;
type AssetField = Extract<keyof SysPlatformConfig, "logoUrl" | "iconUrl" | "loginBgUrl">;
function ImagePreview({ url, label, hint }: { url?: string; label: string; hint: string }) {
interface AssetUploadCardProps {
title: string;
description: string;
buttonText: string;
previewLabel: string;
previewHint: string;
previewMask: string;
url?: string;
fieldName: AssetField;
variant: "logo" | "icon" | "background";
uploading: boolean;
onUpload: (file: File, fieldName: AssetField) => Promise<boolean | string>;
}
function AssetUploadCard({
title,
description,
buttonText,
previewLabel,
previewHint,
previewMask,
url,
fieldName,
variant,
uploading,
onUpload,
}: AssetUploadCardProps) {
return (
<div className="flex flex-col items-center justify-center p-4 border border-dashed border-gray-300 rounded-lg bg-gray-50 h-32">
{url ? (
<img src={url} alt={label} className="max-h-full max-w-full object-contain" />
) : (
<div className="text-gray-400 text-center">
<PictureOutlined style={{ fontSize: 24 }} />
<div className="text-xs mt-1">{hint}</div>
<div className={`platform-asset-card platform-asset-card--${variant}`}>
<div className="platform-asset-card__header">
<div>
<div className="platform-asset-card__title">{title}</div>
<div className="platform-asset-card__description">{description}</div>
</div>
)}
</div>
<div className="platform-asset-card__preview" aria-label={previewLabel}>
{url ? (
<Image src={url} alt={previewLabel} preview={{ mask: previewMask }} />
) : (
<div className="platform-asset-card__empty">
<PictureOutlined />
<span>{previewHint}</span>
</div>
)}
</div>
<Upload accept="image/*" showUploadList={false} beforeUpload={(file) => onUpload(file as File, fieldName)}>
<Button icon={<UploadOutlined />} loading={uploading} block>
{buttonText}
</Button>
</Upload>
</div>
);
}
@ -29,6 +71,7 @@ export default function PlatformSettings() {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [uploadingField, setUploadingField] = useState<AssetField | null>(null);
const [form] = Form.useForm<PlatformFormValues>();
const logoUrl = Form.useWatch("logoUrl", form);
const iconUrl = Form.useWatch("iconUrl", form);
@ -48,7 +91,8 @@ export default function PlatformSettings() {
loadConfig();
}, []);
const handleUpload = async (file: File, fieldName: keyof SysPlatformConfig) => {
const handleUpload = async (file: File, fieldName: AssetField) => {
setUploadingField(fieldName);
try {
const url = await uploadPlatformAsset(file);
form.setFieldValue(fieldName, url);
@ -56,6 +100,8 @@ export default function PlatformSettings() {
} catch (error) {
message.error(error instanceof Error ? error.message : t("common.error"));
return Upload.LIST_IGNORE;
} finally {
setUploadingField(null);
}
return false;
};
@ -89,75 +135,97 @@ export default function PlatformSettings() {
<div className="app-page__content-toolbar-filters" />
</div>
<Form form={form} layout="vertical" onFinish={onFinish} initialValues={{ projectName: "UnisBase" }} className="platform-settings-form">
<Row gutter={16}>
<Col span={24}>
<Card title={<Space size={8}><GlobalOutlined />{t("platformSettings.basicInfo")}</Space>} className="mb-6" loading={loading}>
<Form.Item label={t("platformSettings.projectName")} name="projectName" rules={[
{ required: true, message: t("platformSettings.projectNameRequired") },
{
max:50,
message:t('common.maxLength',{length:50})
}
]}>
<Input placeholder={t("platformSettings.projectNamePlaceholder")} />
</Form.Item>
<Form.Item label={t("platformSettings.desc")} name="systemDescription">
<Input.TextArea rows={3} placeholder={t("platformSettings.systemDescriptionPlaceholder")} />
</Form.Item>
</Card>
</Col>
<Form.Item name="logoUrl" hidden>
<Input />
</Form.Item>
<Form.Item name="iconUrl" hidden>
<Input />
</Form.Item>
<Form.Item name="loginBgUrl" hidden>
<Input />
</Form.Item>
<Col span={24}>
<Card title={<Space size={8}><PictureOutlined />{t("platformSettings.brandAssets")}</Space>} className="app-page__content-card mb-6" loading={loading}>
<Row gutter={24}>
<Col xs={24} md={8}>
<Form.Item label={t("platformSettings.logoUrl")} name="logoUrl">
<Input placeholder={t("platformSettings.logoUrl")} className="mb-2" />
</Form.Item>
<ImagePreview url={logoUrl} label={t("platformSettings.logo")} hint={t("platformSettings.previewHint")} />
<Upload accept="image/*" showUploadList={false} beforeUpload={(file) => handleUpload(file, "logoUrl")}>
<Button icon={<UploadOutlined />} block className="mt-2">{t("platformSettings.uploadLogo")}</Button>
</Upload>
</Col>
<Col xs={24} md={8}>
<Form.Item label={t("platformSettings.iconUrl")} name="iconUrl">
<Input placeholder={t("platformSettings.iconUrl")} className="mb-2" />
</Form.Item>
<ImagePreview url={iconUrl} label={t("platformSettings.icon")} hint={t("platformSettings.previewHint")} />
<Upload accept="image/*" showUploadList={false} beforeUpload={(file) => handleUpload(file, "iconUrl")}>
<Button icon={<UploadOutlined />} block className="mt-2">{t("platformSettings.uploadIcon")}</Button>
</Upload>
</Col>
<Col xs={24} md={8}>
<Form.Item label={t("platformSettings.loginBgUrl")} name="loginBgUrl">
<Input placeholder={t("platformSettings.loginBgUrl")} className="mb-2" />
</Form.Item>
<ImagePreview url={loginBgUrl} label={t("platformSettings.loginBg")} hint={t("platformSettings.previewHint")} />
<Upload accept="image/*" showUploadList={false} beforeUpload={(file) => handleUpload(file, "loginBgUrl")}>
<Button icon={<UploadOutlined />} block className="mt-2">{t("platformSettings.uploadBackground")}</Button>
</Upload>
</Col>
</Row>
</Card>
</Col>
<div className="platform-settings-layout">
<Card title={<Space size={8}><GlobalOutlined />{t("platformSettings.basicInfo")}</Space>} className="platform-settings-card" loading={loading}>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item label={t("platformSettings.projectName")} name="projectName" rules={[
{ required: true, message: t("platformSettings.projectNameRequired") },
{
max: 50,
message: t("common.maxLength", { length: 50 })
}
]}>
<Input placeholder={t("platformSettings.projectNamePlaceholder")} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item label={t("platformSettings.desc")} name="systemDescription">
<Input.TextArea rows={4} placeholder={t("platformSettings.systemDescriptionPlaceholder")} />
</Form.Item>
</Col>
</Row>
</Card>
<Col span={24}>
<Card title={<Space size={8}><FileTextOutlined />{t("platformSettings.complianceFooter")}</Space>} className="app-page__content-card" loading={loading}>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item label={t("platformSettings.icp")} name="icpInfo">
<Input placeholder={t("platformSettings.icpPlaceholder")} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item label={t("platformSettings.copyright")} name="copyrightInfo">
<Input placeholder={t("platformSettings.copyrightPlaceholder")} />
</Form.Item>
</Col>
</Row>
</Card>
</Col>
</Row>
<Card title={<Space size={8}><PictureOutlined />{t("platformSettings.brandAssets")}</Space>} className="platform-settings-card platform-settings-card--assets" loading={loading}>
<div className="platform-assets-grid">
<AssetUploadCard
title={t("platformSettings.logo")}
description={t("platformSettings.logoHint")}
buttonText={t("platformSettings.uploadLogo")}
previewLabel={t("platformSettings.logo")}
previewHint={t("platformSettings.previewHint")}
previewMask={t("platformSettings.previewMask")}
url={logoUrl}
fieldName="logoUrl"
variant="logo"
uploading={uploadingField === "logoUrl"}
onUpload={handleUpload}
/>
<AssetUploadCard
title={t("platformSettings.icon")}
description={t("platformSettings.iconHint")}
buttonText={t("platformSettings.uploadIcon")}
previewLabel={t("platformSettings.icon")}
previewHint={t("platformSettings.previewHint")}
previewMask={t("platformSettings.previewMask")}
url={iconUrl}
fieldName="iconUrl"
variant="icon"
uploading={uploadingField === "iconUrl"}
onUpload={handleUpload}
/>
<AssetUploadCard
title={t("platformSettings.loginBg")}
description={t("platformSettings.loginBgHint")}
buttonText={t("platformSettings.uploadBackground")}
previewLabel={t("platformSettings.loginBg")}
previewHint={t("platformSettings.previewHint")}
previewMask={t("platformSettings.previewMask")}
url={loginBgUrl}
fieldName="loginBgUrl"
variant="background"
uploading={uploadingField === "loginBgUrl"}
onUpload={handleUpload}
/>
</div>
</Card>
<Card title={<Space size={8}><FileTextOutlined />{t("platformSettings.complianceFooter")}</Space>} className="platform-settings-card" loading={loading}>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item label={t("platformSettings.icp")} name="icpInfo">
<Input placeholder={t("platformSettings.icpPlaceholder")} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item label={t("platformSettings.copyright")} name="copyrightInfo">
<Input placeholder={t("platformSettings.copyrightPlaceholder")} />
</Form.Item>
</Col>
</Row>
</Card>
</div>
</Form>
</div>
</SectionCard>

View File

@ -1,6 +1,6 @@
import { create } from 'zustand';
export type ThemeMode = 'minimal' | 'tech' | 'default';
export type ThemeMode = 'minimal' | 'tech';
export type LayoutMode = 'side' | 'top';
interface ThemeState {
@ -14,7 +14,7 @@ interface ThemeState {
}
const DEFAULT_COLOR = '#1677ff';
const DEFAULT_MODE: ThemeMode = 'default';
const DEFAULT_MODE: ThemeMode = 'minimal';
const DEFAULT_LAYOUT: LayoutMode = 'side';
function hexToRgbChannels(color: string): string {
@ -37,6 +37,10 @@ function applyThemeColor(color: string) {
document.documentElement.style.setProperty('--app-primary-rgb', hexToRgbChannels(color));
}
function normalizeThemeMode(mode: string | null): ThemeMode {
return mode === 'tech' ? 'tech' : DEFAULT_MODE;
}
const getColorStorageKey = () => {
const username = localStorage.getItem("username") || "default";
return `unis_theme_color_${username}`;
@ -63,10 +67,11 @@ export const useThemeStore = create<ThemeState>((set) => ({
applyThemeColor(color);
},
setThemeMode: (mode: ThemeMode) => {
set({ themeMode: mode });
const nextMode = normalizeThemeMode(mode);
set({ themeMode: nextMode });
const key = getModeStorageKey();
localStorage.setItem(key, mode);
document.documentElement.setAttribute('data-theme', mode);
localStorage.setItem(key, nextMode);
document.documentElement.setAttribute('data-theme', nextMode);
},
setLayoutMode: (mode: LayoutMode) => {
set({ layoutMode: mode });
@ -81,8 +86,11 @@ export const useThemeStore = create<ThemeState>((set) => ({
const storedColor = localStorage.getItem(colorKey);
const color = storedColor || DEFAULT_COLOR;
const storedMode = localStorage.getItem(modeKey) as ThemeMode;
const mode = storedMode || DEFAULT_MODE;
const storedMode = localStorage.getItem(modeKey);
const mode = normalizeThemeMode(storedMode);
if (storedMode !== mode) {
localStorage.setItem(modeKey, mode);
}
const storedLayout = localStorage.getItem(layoutKey) as LayoutMode;
const layout = storedLayout || DEFAULT_LAYOUT;