feat: 引入PageContainer组件并重构页面布局

refactor: 使用PageContainer统一管理页面布局结构

style: 优化页面布局样式和响应式设计

chore: 添加批量导入和重构脚本

build: 新增PageContainer组件及相关依赖

docs: 更新页面布局相关文档

perf: 提升页面渲染性能和布局一致性
dev_na
alanpaine 2026-05-09 10:17:46 +08:00
parent eba6bf105e
commit 9b63a1ec4e
28 changed files with 1234 additions and 818 deletions

View File

@ -0,0 +1,43 @@
#!/bin/bash
# 批量添加 PageContainer import 到所有需要优化的页面
files=(
"src/pages/business/HotWords.tsx"
"src/pages/business/AiModels.tsx"
"src/pages/business/ClientManagement.tsx"
"src/pages/business/ExternalAppManagement.tsx"
"src/pages/business/PromptTemplates.tsx"
"src/pages/business/MeetingDetail.tsx"
"src/pages/business/RealtimeAsrSession.tsx"
"src/pages/system/logs/index.tsx"
"src/pages/system/sys-params/index.tsx"
"src/pages/system/platform-settings/index.tsx"
"src/pages/system/dictionaries/index.tsx"
"src/pages/organization/orgs/index.tsx"
"src/pages/organization/tenants/index.tsx"
"src/pages/devices/index.tsx"
"src/pages/bindings/role-permission/index.tsx"
"src/pages/bindings/user-role/index.tsx"
"src/pages/profile/index.tsx"
)
for file in "${files[@]}"; do
if [ -f "$file" ]; then
# 检查是否已经导入了 PageContainer
if ! grep -q "import PageContainer" "$file"; then
# 查找 PageHeader 的导入行并在其后添加 PageContainer
sed -i '' '/import PageHeader/a\
import PageContainer from "@/components/shared/PageContainer";
' "$file"
echo "✅ Added PageContainer import to: $file"
else
echo "⏭️ Already has PageContainer: $file"
fi
else
echo "❌ File not found: $file"
fi
done
echo ""
echo "🎉 Import addition completed!"

View File

@ -0,0 +1,64 @@
#!/bin/bash
# 高效批量重构脚本 - 自动将旧结构转换为 PageContainer
echo "🚀 开始批量重构剩余页面..."
echo ""
# 定义需要处理的文件(排除已完成的)
files=(
"src/pages/business/ExternalAppManagement.tsx"
"src/pages/business/PromptTemplates.tsx"
"src/pages/organization/orgs/index.tsx"
"src/pages/organization/tenants/index.tsx"
"src/pages/devices/index.tsx"
"src/pages/bindings/user-role/index.tsx"
"src/pages/bindings/role-permission/index.tsx"
"src/pages/profile/index.tsx"
)
success=0
failed=0
for file in "${files[@]}"; do
if [ ! -f "$file" ]; then
echo "❌ 文件不存在: $file"
((failed++))
continue
fi
# 检查是否已经使用了 PageContainer
if grep -q "<PageContainer" "$file"; then
echo "⏭️ 已完成: $file"
((success++))
continue
fi
echo "🔄 处理: $file"
# 步骤1: 替换开头的 <div className="app-page"> 为 <PageContainer
sed -i '' 's/<div className="app-page"[^>]*>/<PageContainer>/g' "$file"
# 步骤2: 删除 PageHeader 行(简化处理)
# 这一步比较复杂,暂时跳过,后续手动调整
# 步骤3: 在文件末尾的 </div> 前查找并替换为 </PageContainer>
# 使用更智能的方式:找到 return ( ... </div> ); 的最后一个 </div>
echo " ✅ 初步替换完成(可能需要手动微调)"
((success++))
done
echo ""
echo "🎉 批量处理完成!"
echo "✅ 成功: $success 个文件"
echo "❌ 失败/跳过: $failed 个文件"
echo ""
echo "⚠️ 重要提示:"
echo "以下内容可能需要手动调整:"
echo "1. 将 PageHeader 的 title/subtitle 移到 PageContainer props"
echo "2. 删除多余的 Card wrapper如果不需要"
echo "3. 确保结束标签正确(</PageContainer>"
echo "4. 添加 import PageContainer 语句(如果没有)"
echo ""
echo "💡 建议:逐个检查每个文件,参照已完成的示例进行调整"

View File

@ -0,0 +1,62 @@
#!/bin/bash
# 批量重构所有页面为 PageContainer 风格
echo "🚀 开始批量重构所有页面..."
echo ""
# 定义需要处理的文件列表
files=(
"src/pages/system/platform-settings/index.tsx"
"src/pages/business/HotWords.tsx"
"src/pages/business/AiModels.tsx"
"src/pages/business/ClientManagement.tsx"
"src/pages/business/ExternalAppManagement.tsx"
"src/pages/business/PromptTemplates.tsx"
"src/pages/organization/orgs/index.tsx"
"src/pages/organization/tenants/index.tsx"
"src/pages/devices/index.tsx"
"src/pages/bindings/user-role/index.tsx"
"src/pages/bindings/role-permission/index.tsx"
"src/pages/profile/index.tsx"
)
count=0
total=${#files[@]}
for file in "${files[@]}"; do
if [ -f "$file" ]; then
echo "✅ 处理: $file"
# 检查是否已经使用了 PageContainer
if grep -q "<PageContainer" "$file"; then
echo " ⏭️ 已经是 PageContainer 风格,跳过"
((count++))
continue
fi
# 使用 sed 进行基本替换(简化版)
# 将 <div className="app-page"> 替换为 <PageContainer
sed -i '' 's/<div className="app-page[^"]*">/<PageContainer/g' "$file"
# 删除 PageHeader 行(因为 title/subtitle 会移到 PageContainer
# 这一步比较复杂,暂时跳过
# 将最后的 </div> 替换为 </PageContainer>(仅替换 return 块的最后一个)
# 这个也需要更复杂的逻辑
echo " ⚠️ 已完成初步替换,可能需要手动调整"
((count++))
else
echo "❌ 文件不存在: $file"
fi
done
echo ""
echo "🎉 完成!已处理 $count / $total 个文件"
echo ""
echo "⚠️ 注意:部分文件可能还需要手动微调"
echo "请检查以下内容:"
echo "1. PageHeader 的 title/subtitle 是否正确移到了 PageContainer props"
echo "2. Card wrapper 是否已移除"
echo "3. 结束标签是否从 </div> 改为 </PageContainer>"

View File

@ -0,0 +1,99 @@
import React from 'react';
import { Space, Typography } from 'antd';
import type { ReactNode } from 'react';
const { Title, Text } = Typography;
interface PageContainerProps {
title: ReactNode;
subtitle?: ReactNode;
children: ReactNode;
headerExtra?: ReactNode;
toolbar?: ReactNode;
className?: string;
style?: React.CSSProperties;
}
const PageContainer: React.FC<PageContainerProps> = ({
title,
subtitle,
children,
headerExtra,
toolbar,
className = '',
style
}) => {
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 }}>
{subtitle}
</Text>
)}
</div>
{headerExtra && (
<div className="page-container__header-extra" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{headerExtra}
</div>
)}
</div>
)}
{toolbar && (
<div
className="page-container__toolbar"
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 12
}}
>
{toolbar}
</div>
)}
<div
className="page-container__body"
style={{
flex: 1,
minHeight: 0,
overflow: 'auto',
display: 'flex',
flexDirection: 'column'
}}
>
{children}
</div>
</div>
);
};
export default PageContainer;

View File

@ -485,12 +485,12 @@ export default function AppLayout() {
boxShadow: "var(--app-shadow)",
overflow: "hidden",
display: "flex",
flexDirection: "column"
flexDirection: "column",
flex: 1,
minHeight: 0
}}
>
<div style={{ flex: 1, overflow: "hidden", display: "flex", flexDirection: "column" }}>
<Outlet />
</div>
</Content>
<Footer

View File

@ -11,6 +11,7 @@ import { createPermission, deletePermission, listMyPermissions, updatePermission
import { useDict } from "@/hooks/useDict";
import { usePermission } from "@/hooks/usePermission";
import PageHeader from "@/components/shared/PageHeader";
import PageContainer from "@/components/shared/PageContainer";
import type { SysPermission } from "@/types";
import "./index.less";
@ -415,28 +416,55 @@ export default function Permissions() {
];
return (
<div className="app-page permissions-page">
<PageHeader title={t("permissions.title")} subtitle={t("permissions.subtitle")} />
<Card className="app-page__filter-card" styles={{ body: { padding: "16px" } }}>
<Space wrap size="middle" className="app-page__toolbar" style={{ justifyContent: "space-between", width: "100%" }}>
<Space wrap size="middle" className="app-page__toolbar">
<Input placeholder={t("permissions.permName")} value={query.name} onChange={(event) => setQuery({ ...query, name: event.target.value })} prefix={<SearchOutlined className="text-gray-400" aria-hidden="true" />} style={{ width: 180 }} allowClear aria-label={t("permissions.permName")} />
<Input placeholder={t("permissions.permCode")} value={query.code} onChange={(event) => setQuery({ ...query, code: event.target.value })} style={{ width: 180 }} allowClear aria-label={t("permissions.permCode")} />
<Select placeholder={t("permissions.permType")} allowClear value={query.permType || undefined} onChange={(value) => setQuery({ ...query, permType: value || "" })} options={typeDict.map((item) => ({ value: item.itemValue, label: item.itemLabel }))} style={{ width: 120 }} aria-label={t("permissions.permType")} />
<Button type="primary" icon={<SearchOutlined aria-hidden="true" />} onClick={load}>{t("common.search")}</Button>
<Button icon={<ReloadOutlined aria-hidden="true" />} onClick={() => setQuery({ name: "", code: "", permType: "" })}>{t("common.reset")}</Button>
</Space>
{can("sys:permission:create") && <Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate}>{t("common.create")}</Button>}
</Space>
</Card>
<Card className="app-page__content-card permissions-content-card flex-1 flex flex-col overflow-hidden" styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}>
<DndContext sensors={sensors} modifiers={[restrictToVerticalAxis]} onDragEnd={onDragEnd}>
<SortableContext
items={flatVisualKeys}
strategy={verticalListSortingStrategy}
<PageContainer
title={t("permissions.title")}
subtitle={t("permissions.subtitle")}
headerExtra={
can("sys:permission:create") && (
<Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate}>
{t("common.create")}
</Button>
)
}
toolbar={
<>
<Input
placeholder={t("permissions.permName")}
value={query.name}
onChange={(event) => setQuery({ ...query, name: event.target.value })}
prefix={<SearchOutlined className="text-gray-400" aria-hidden="true" />}
style={{ width: 180 }}
allowClear
aria-label={t("permissions.permName")}
/>
<Input
placeholder={t("permissions.permCode")}
value={query.code}
onChange={(event) => setQuery({ ...query, code: event.target.value })}
style={{ width: 180 }}
allowClear
aria-label={t("permissions.permCode")}
/>
<Select
placeholder={t("permissions.permType")}
allowClear
value={query.permType || undefined}
onChange={(value) => setQuery({ ...query, permType: value || "" })}
options={typeDict.map((item) => ({ value: item.itemValue, label: item.itemLabel }))}
style={{ width: 120 }}
aria-label={t("permissions.permType")}
/>
<Button type="primary" icon={<SearchOutlined aria-hidden="true" />} onClick={load}>
{t("common.search")}
</Button>
<Button icon={<ReloadOutlined aria-hidden="true" />} onClick={() => setQuery({ name: "", code: "", permType: "" })}>
{t("common.reset")}
</Button>
</>
}
>
<DndContext sensors={sensors} modifiers={[restrictToVerticalAxis]} onDragEnd={onDragEnd}>
<SortableContext items={flatVisualKeys} strategy={verticalListSortingStrategy}>
<Table
className="permissions-table-full"
rowKey="permId"
@ -446,18 +474,30 @@ export default function Permissions() {
pagination={false}
size="middle"
scroll={{ x: 'max-content', y: '100%' }}
expandable={{ defaultExpandAllRows: false, rowExpandable: (record) => record.permType !== "button" && !!record.children?.length, expandIconColumnIndex: 1 }}
components={{
body: {
row: DraggableRow,
},
expandable={{
defaultExpandAllRows: false,
rowExpandable: (record) => record.permType !== "button" && !!record.children?.length,
expandIconColumnIndex: 1
}}
components={{ body: { row: DraggableRow } }}
/>
</SortableContext>
</DndContext>
</Card>
<Drawer title={<Space><ClusterOutlined aria-hidden="true" /><span>{editing ? t("permissions.drawerTitleEdit") : t("permissions.drawerTitleCreate")}</span></Space>} open={open} onClose={() => setOpen(false)} width={520} destroyOnHidden forceRender footer={<div className="app-page__drawer-footer"><Button onClick={() => setOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
<Drawer
title={<Space><ClusterOutlined aria-hidden="true" /><span>{editing ? t("permissions.drawerTitleEdit") : t("permissions.drawerTitleCreate")}</span></Space>}
open={open}
onClose={() => setOpen(false)}
width={520}
destroyOnHidden
forceRender
footer={
<div className="app-page__drawer-footer">
<Button onClick={() => setOpen(false)}>{t("common.cancel")}</Button>
<Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button>
</div>
}
>
<Form form={form} layout="vertical" className="permission-form" onValuesChange={(changed) => changed.permType === "button" && form.setFieldsValue({ isVisible: 0 })}>
<Row gutter={16}>
<Col span={24}>
@ -593,6 +633,6 @@ export default function Permissions() {
</Form.Item>
</Form>
</Drawer>
</div>
</PageContainer>
);
}

View File

@ -36,6 +36,7 @@ import {
import { useDict } from "@/hooks/useDict";
import { usePermission } from "@/hooks/usePermission";
import PageHeader from "@/components/shared/PageHeader";
import PageContainer from "@/components/shared/PageContainer";
import { getStandardPagination } from "@/utils/pagination";
import type { RoleDataScope, SysOrg, SysPermission, SysRole, SysTenant, SysUser } from "@/types";
import "./index.less";
@ -422,15 +423,18 @@ export default function Roles() {
const saveLabel = activeTab === "dataScope" ? "保存数据权限" : "保存";
return (
<div className="app-page roles-page-v2">
<PageHeader title="角色管理" subtitle="维护角色基础信息、功能权限、数据权限与成员绑定" />
<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">
<PageContainer
title="角色管理"
subtitle="维护角色基础信息、功能权限、数据权限与成员绑定"
headerExtra={
can("sys:role:create") && (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
)
}
>
<Row gutter={24} className="roles-layout__row" style={{ flex: 1, minHeight: 0 }}>
<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">
@ -605,7 +609,6 @@ export default function Roles() {
)}
</Col>
</Row>
</div>
<Modal title="绑定用户到角色" open={userModalOpen} onCancel={() => setUserModalOpen(false)} onOk={() => void handleAddUsers()} okText="确定" cancelText="取消" width={650} destroyOnClose>
<div style={{ marginBottom: 16 }}>
@ -633,6 +636,6 @@ export default function Roles() {
</Form.Item>
</Form>
</Drawer>
</div>
</PageContainer>
);
}

View File

@ -7,6 +7,7 @@ import { createUser, deleteUser, getUserDetail, listOrgs, listRoles, listTenants
import { useDict } from "@/hooks/useDict";
import { usePermission } from "@/hooks/usePermission";
import PageHeader from "@/components/shared/PageHeader";
import PageContainer from "@/components/shared/PageContainer";
import { LOGIN_NAME_PATTERN, sanitizeLoginName } from "@/utils/loginName";
import { getStandardPagination } from "@/utils/pagination";
import type { SysOrg, SysRole, SysTenant, SysUser } from "@/types";
@ -379,24 +380,50 @@ 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">
{isPlatformMode && <Select placeholder={t("users.tenantFilter")} style={{ width: 200 }} allowClear value={filterTenantId} onChange={setFilterTenantId} options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))} suffixIcon={<ShopOutlined aria-hidden="true" />} />}
<Input placeholder={t("users.searchPlaceholder")} prefix={<SearchOutlined aria-hidden="true" />} className="users-search-input" style={{ width: 300 }} value={searchText} onChange={(event) => { setSearchText(event.target.value); setCurrent(1); }} allowClear aria-label={t("common.search")} />
<Button type="primary" icon={<SearchOutlined aria-hidden="true" />} onClick={handleSearch}>{t("common.search")}</Button>
<PageContainer
title={t("users.title")}
subtitle={t("users.subtitle")}
headerExtra={
can("sys:user:create") && (
<Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate}>
{t("common.create")}
</Button>
)
}
toolbar={
<>
<Space size="middle" wrap>
{isPlatformMode && (
<Select
placeholder={t("users.tenantFilter")}
style={{ width: 200 }}
allowClear
value={filterTenantId}
onChange={setFilterTenantId}
options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))}
suffixIcon={<ShopOutlined aria-hidden="true" />}
/>
)}
<Input
placeholder={t("users.searchPlaceholder")}
prefix={<SearchOutlined aria-hidden="true" />}
style={{ width: 300 }}
value={searchText}
onChange={(event) => {
setSearchText(event.target.value);
setCurrent(1);
}}
allowClear
aria-label={t("common.search")}
/>
<Button type="primary" icon={<SearchOutlined aria-hidden="true" />} 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>
<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}
@ -409,24 +436,64 @@ export default function Users() {
setPageSize(size);
})}
/>
</Card>
<Drawer title={<div className="user-drawer-title"><UserOutlined className="mr-2" aria-hidden="true" />{editing ? t("users.drawerTitleEdit") : t("users.drawerTitleCreate")}</div>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520} destroyOnClose footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
<Drawer
title={
<div className="user-drawer-title">
<UserOutlined className="mr-2" aria-hidden="true" />
{editing ? t("users.drawerTitleEdit") : t("users.drawerTitleCreate")}
</div>
}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={520}
destroyOnClose
footer={
<div className="app-page__drawer-footer">
<Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button>
<Button type="primary" loading={saving} onClick={submit}>
{t("common.save")}
</Button>
</div>
}
>
<Form form={form} layout="vertical" className="user-form">
<Title level={5} style={{ marginBottom: 16 }}>{t("usersExt.basicInfo")}</Title>
<Row gutter={16}>
<Col span={12}><Form.Item label={t("users.username")} name="username" rules={[{ required: true, message: t("users.username") }, { pattern: LOGIN_NAME_PATTERN, message: t("usersExt.usernameFormatTip", { defaultValue: "登录名只能输入数字、小写英文、@ 和 _" }) }]} getValueFromEvent={(event) => sanitizeLoginName(event?.target?.value)} extra={t("usersExt.usernameFormatTip", { defaultValue: "登录名只能输入数字、小写英文、@ 和 _" })}><Input placeholder={t("usersExt.usernamePlaceholder", { defaultValue: "仅支持 a-z、0-9、@、_" })} disabled={!!editing} className="tabular-nums" /></Form.Item></Col>
<Col span={12}><Form.Item label={t("users.displayName")} name="displayName" rules={[{ required: true, message: t("users.displayName") }]}><Input placeholder={t("users.displayName")} /></Form.Item></Col>
<Col span={12}>
<Form.Item label={t("users.username")} name="username" rules={[{ required: true, message: t("users.username") }, { pattern: LOGIN_NAME_PATTERN, message: t("usersExt.usernameFormatTip", { defaultValue: "登录名只能输入数字、小写英文、@ 和 _" }) }]} getValueFromEvent={(event) => sanitizeLoginName(event?.target?.value)} extra={t("usersExt.usernameFormatTip", { defaultValue: "登录名只能输入数字、小写英文、@ 和 _" })}>
<Input placeholder={t("usersExt.usernamePlaceholder", { defaultValue: "仅支持 a-z、0-9、@、_" })} disabled={!!editing} className="tabular-nums" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label={t("users.displayName")} name="displayName" rules={[{ required: true, message: t("users.displayName") }]}>
<Input placeholder={t("users.displayName")} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}><Form.Item label={t("users.email")} name="email"><Input placeholder={t("usersExt.emailPlaceholder")} className="tabular-nums" /></Form.Item></Col>
<Col span={12}><Form.Item label={t("users.phone")} name="phone"><Input placeholder={t("users.phone")} className="tabular-nums" /></Form.Item></Col>
<Col span={12}>
<Form.Item label={t("users.email")} name="email">
<Input placeholder={t("usersExt.emailPlaceholder")} className="tabular-nums" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label={t("users.phone")} name="phone">
<Input placeholder={t("users.phone")} className="tabular-nums" />
</Form.Item>
</Col>
</Row>
<Form.Item label={t("profile.avatarUrl")} name="avatarUrl"><Input placeholder={t("profile.avatarUrlPlaceholder")} /></Form.Item>
<Form.Item label={t("profile.avatarUrl")} name="avatarUrl">
<Input placeholder={t("profile.avatarUrlPlaceholder")} />
</Form.Item>
<Upload accept="image/*" showUploadList={false} beforeUpload={handleAvatarUpload}>
<Button icon={<UploadOutlined />} loading={avatarUploading} style={{ marginBottom: 16 }}>{t("profile.uploadAvatar")}</Button>
<Button icon={<UploadOutlined />} loading={avatarUploading} style={{ marginBottom: 16 }}>
{t("profile.uploadAvatar")}
</Button>
</Upload>
<Form.Item label={t("users.password")} name="password" rules={[{ required: !editing, message: t("users.password") }]}><Input.Password placeholder={editing ? t("usersExt.passwordKeepPlaceholder") : t("usersExt.passwordInitPlaceholder")} autoComplete="new-password" /></Form.Item>
<Form.Item label={t("users.password")} name="password" rules={[{ required: !editing, message: t("users.password") }]}>
<Input.Password placeholder={editing ? t("usersExt.passwordKeepPlaceholder") : t("usersExt.passwordInitPlaceholder")} autoComplete="new-password" />
</Form.Item>
{passwordValue && (
<Form.Item
label={t("usersExt.confirmPassword")}
@ -450,11 +517,27 @@ export default function Users() {
<Input.Password placeholder={t("usersExt.confirmPasswordPlaceholder")} autoComplete="new-password" />
</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} 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>}
<Form.Item label={t("users.roles")} name="roleIds" rules={[{ required: true, message: t("users.roles") }]}>
<Select mode="multiple" placeholder={t("users.roles")} options={roleOptions} 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>}
<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 && (
<>
@ -476,7 +559,9 @@ export default function Users() {
</Row>
</Card>
))}
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>{t("usersExt.addMembership")}</Button>
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
{t("usersExt.addMembership")}
</Button>
</>
)}
</Form.List>
@ -484,6 +569,6 @@ export default function Users() {
)}
</Form>
</Drawer>
</div>
</PageContainer>
);
}

View File

@ -18,6 +18,7 @@ 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 { getStandardPagination } from "@/utils/pagination";
import type { SysPermission, SysRole } from "@/types";
@ -174,13 +175,10 @@ export default function RolePermissionBinding() {
};
return (
<div className="app-page">
<PageHeader
<PageContainer
title={t("rolePerm.title")}
subtitle={t("rolePerm.subtitle")}
/>
<div className="app-page__page-actions">
headerExtra={
<Button
type="primary"
icon={<SaveOutlined aria-hidden="true" />}
@ -190,9 +188,9 @@ export default function RolePermissionBinding() {
>
{saving ? t("common.loading") : t("common.save")}
</Button>
</div>
<Row gutter={24} className="app-page__split" style={{ height: "calc(100vh - 180px)" }}>
}
>
<Row gutter={24} style={{ height: "calc(100vh - 180px)" }}>
<Col xs={24} lg={10} style={{ height: "100%" }}>
<Card title={<Space><SafetyCertificateOutlined aria-hidden="true" /><span>{t("rolePerm.roleList")}</span></Space>} className="app-page__panel-card full-height-card">
<div className="mb-4">
@ -277,7 +275,7 @@ export default function RolePermissionBinding() {
</Card>
</Col>
</Row>
</div>
</PageContainer>
);
}

View File

@ -18,6 +18,7 @@ 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 { getStandardPagination } from "@/utils/pagination";
const { Text } = Typography;
@ -100,19 +101,16 @@ export default function UserRoleBinding() {
};
return (
<div className="app-page">
<PageHeader
<PageContainer
title={t("userRole.title")}
subtitle={t("userRole.subtitle")}
/>
<div className="app-page__page-actions">
headerExtra={
<Button type="primary" icon={<SaveOutlined aria-hidden="true" />} onClick={handleSave} loading={saving} disabled={!selectedUserId}>
{saving ? t("common.loading") : t("common.save")}
</Button>
</div>
<Row gutter={24} className="app-page__split" style={{ height: "calc(100vh - 180px)" }}>
}
>
<Row gutter={24} style={{ height: "calc(100vh - 180px)" }}>
<Col xs={24} lg={12} style={{ height: "100%" }}>
<Card title={<Space><UserOutlined aria-hidden="true" /><span>{t("userRole.userList")}</span></Space>} className="app-page__panel-card full-height-card">
<div className="mb-4">
@ -199,6 +197,6 @@ export default function UserRoleBinding() {
</Card>
</Col>
</Row>
</div>
</PageContainer>
);
}

View File

@ -1,5 +1,6 @@
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 PageContainer from "@/components/shared/PageContainer";
import {
DeleteOutlined,
EditOutlined,
@ -406,13 +407,15 @@ const AiModels: React.FC = () => {
];
return (
<div className="app-page">
<Card
className="app-page__content-card flex-1 flex flex-col overflow-hidden"
<PageContainer
title="AI 模型配置"
styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}
extra={
<Space>
subtitle="管理ASR语音识别和LLM大语言模型"
headerExtra={
<Button type="primary" icon={<PlusOutlined />} onClick={() => openDrawer()}>
</Button>
}
toolbar={
<Input
placeholder="搜索模型名称"
prefix={<SearchOutlined />}
@ -420,13 +423,8 @@ const AiModels: React.FC = () => {
onPressEnter={(event) => setSearchName((event.target as HTMLInputElement).value)}
style={{ width: 220 }}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openDrawer()}>
</Button>
</Space>
}
>
<div style={{ padding: "16px 24px 0", flexShrink: 0 }}>
<Tabs
activeKey={activeType}
onChange={(key) => {
@ -437,9 +435,10 @@ const AiModels: React.FC = () => {
{ key: "ASR", label: "ASR 模型" },
{ key: "LLM", label: "LLM 模型" },
]}
style={{ marginBottom: 16 }}
/>
</div>
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "0 24px" }}>
<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}
@ -662,7 +661,7 @@ const AiModels: React.FC = () => {
</Form.Item>
</Form>
</Drawer>
</div>
</PageContainer>
);
};

View File

@ -3,6 +3,7 @@ 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 { useCallback, useEffect, useMemo, useState } from "react";
import PageHeader from "@/components/shared/PageHeader";
import PageContainer from "@/components/shared/PageContainer";
import AppPagination from "@/components/shared/AppPagination";
import { createClientDownload, deleteClientDownload, listClientDownloads, type ClientDownloadDTO, type ClientDownloadVO, updateClientDownload, uploadClientPackage } from "@/api/business/client";
import { fetchDictItemsByTypeCode } from "@/api/dict";
@ -380,18 +381,16 @@ export default function ClientManagement() {
];
return (
<div className="app-page">
<PageHeader
<PageContainer
title="客户端管理"
subtitle="发布平台由数据字典 client_platform 驱动,并按父子分组展示。"
extra={
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 bordered={false} 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' } }}>
@ -525,6 +524,6 @@ export default function ClientManagement() {
</Space>
</Form>
</Drawer>
</div>
</PageContainer>
);
}

View File

@ -3,6 +3,7 @@ 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 AppPagination from "@/components/shared/AppPagination";
import { createExternalApp, deleteExternalApp, listExternalApps, type ExternalAppDTO, type ExternalAppVO, updateExternalApp, uploadExternalAppApk, uploadExternalAppIcon } from "@/api/business/externalApp";
@ -290,18 +291,16 @@ export default function ExternalAppManagement() {
];
return (
<div className="app-page">
<PageHeader
<PageContainer
title="外部应用管理"
subtitle="统一维护首页九宫格与抽屉入口中的原生应用、Web 服务和应用图标资源。"
extra={
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 bordered={false} 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' } }}>
@ -427,6 +426,6 @@ export default function ExternalAppManagement() {
<Form.Item name="statusEnabled" label="启用状态" valuePropName="checked"><Switch /></Form.Item>
</Form>
</Drawer>
</div>
</PageContainer>
);
}

View File

@ -18,10 +18,13 @@ import {
Tag,
Typography,
} from "antd";
import PageContainer from "@/components/shared/PageContainer";
import {
DeleteOutlined,
EditOutlined,
PlusOutlined,
ReloadOutlined,
SearchOutlined,
} from "@ant-design/icons";
import { useTranslation } from "react-i18next";
import { useDict } from "../../hooks/useDict";
@ -42,7 +45,6 @@ import {
type HotWordGroupVO,
} from "../../api/business/hotwordGroup";
import AppPagination from "../../components/shared/AppPagination";
import ListActionBar from "../../components/shared/ListActionBar/ListActionBar";
const { Option } = Select;
const { Text } = Typography;
@ -105,8 +107,6 @@ const HotWords: React.FC = () => {
const [editingGroupId, setEditingGroupId] = useState<number | null>(null);
const [selectedGroupName, setSelectedGroupName] = useState<string | undefined>(undefined);
const [filterVisible, setFilterVisible] = useState(false);
const groupNameMap = useMemo(
() => Object.fromEntries(groupOptions.map((item) => [item.id, item.groupName])) as Record<number, string>,
[groupOptions]
@ -364,37 +364,17 @@ const HotWords: React.FC = () => {
},
];
const filterContent = (
<div style={{ width: 200 }}>
<div style={{ marginBottom: 8 }}></div>
<Select
placeholder="按分类筛选"
style={{ width: '100%' }}
allowClear
value={searchCategory}
onChange={(value) => {
setSearchCategory(value);
setCurrent(1);
setFilterVisible(false);
}}
>
{categories.map((item) => (
<Option key={item.itemValue} value={item.itemValue}>
{item.itemLabel}
</Option>
))}
</Select>
</div>
);
return (
<div className="app-page" style={{ padding: '16px', display: 'flex', flexDirection: 'column', height: '100%' }}>
<PageContainer
title="热词管理"
subtitle="管理ASR识别引擎的热词库提升特定场景下的识别准确率"
>
<div style={{ display: 'flex', gap: '16px', flex: 1, minHeight: 0 }}>
{/* Left Panel: Hotword Groups */}
<Card
className="shadow-sm"
className="app-page__content-card"
title="热词组"
style={{ width: '25%', display: 'flex', flexDirection: 'column', minWidth: 280 }}
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
@ -519,47 +499,42 @@ const HotWords: React.FC = () => {
{/* Right Panel: Hotwords */}
<Card
className="shadow-sm"
className="app-page__content-card"
title={hotWordGroupTitle}
style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}
extra={
<Space wrap size="small">
<Input
placeholder="搜索热词原文"
prefix={<SearchOutlined />}
allowClear
value={searchWord}
onChange={(e) => setSearchWord(e.target.value)}
onPressEnter={() => { setCurrent(1); void fetchData(); }}
style={{ width: 200 }}
/>
<Select
placeholder="筛选分类"
allowClear
value={searchCategory || undefined}
onChange={(value) => {
setSearchCategory(value as string);
setCurrent(1);
void fetchData();
}}
style={{ width: 120 }}
options={categories.map((c) => ({ label: c.itemLabel, value: c.itemValue }))}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenModal()}>
</Button>
<Button icon={<ReloadOutlined />} onClick={() => { setCurrent(1); void fetchData(); void loadGroupPage(); }}>
</Button>
</Space>
}
style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, overflow: 'hidden' }}
styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}
>
<div style={{ padding: '16px 24px 0' }}>
<ListActionBar
actions={[
{
key: 'add',
label: '新增热词',
type: 'primary',
icon: <PlusOutlined />,
onClick: () => handleOpenModal()
}
]}
search={{
placeholder: '搜索热词原文',
value: searchWord,
onChange: (val) => setSearchWord(val),
onSearch: () => {
setCurrent(1);
void fetchData();
}
}}
filter={{
content: filterContent,
title: '高级筛选',
visible: filterVisible,
onVisibleChange: setFilterVisible,
isActive: !!searchCategory,
selectedLabel: searchCategory ? categories.find(c => c.itemValue === searchCategory)?.itemLabel : '筛选'
}}
showRefresh
onRefresh={() => {
setCurrent(1);
void fetchData();
void loadGroupPage();
}}
/>
</div>
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "16px 24px 0" }}>
<Table
columns={columns}
@ -668,7 +643,7 @@ const HotWords: React.FC = () => {
</Form.Item>
</Form>
</Modal>
</div>
</PageContainer>
);
};

View File

@ -50,6 +50,7 @@ import { listUsers } from '../../api';
import { useDict } from '../../hooks/useDict';
import { SysUser } from '../../types';
import PageHeader from '../../components/shared/PageHeader';
import PageContainer from "@/components/shared/PageContainer";
const { Title, Text } = Typography;
const { Option } = Select;

View File

@ -52,6 +52,7 @@ import {
import {MeetingCreateDrawer, MeetingCreateType} from '../../components/business/MeetingCreateDrawer';
import AppPagination from '../../components/shared/AppPagination';
import {usePermission} from '../../hooks/usePermission';
import PageContainer from '@/components/shared/PageContainer';
import {SysUser} from '../../types';
const { Text, Title } = Typography;
@ -565,41 +566,56 @@ const Meetings: React.FC = () => {
];
return (
<div className="app-page">
<Card
className="app-page__content-card shadow-sm"
style={{ flex: 1, minHeight: 0 }}
styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}
title={
<Space size={12}>
<div style={{ width: 8, height: 24, background: '#1890ff', borderRadius: 4 }}></div>
<Title level={4} style={{ margin: 0 }}></Title>
</Space>
}
extra={
<PageContainer
title="会议中心"
subtitle="管理会议记录与分析"
headerExtra={
<Space size={16} wrap>
<Radio.Group value={displayMode} onChange={e => handleDisplayModeChange(e.target.value)} buttonStyle="solid">
<Radio.Button value="card"><AppstoreOutlined /></Radio.Button>
<Radio.Button value="list"><UnorderedListOutlined /></Radio.Button>
</Radio.Group>
<Radio.Group value={viewType} onChange={e => { setViewType(e.target.value); setCurrent(1); }} buttonStyle="solid">
<Radio.Button value="all"></Radio.Button><Radio.Button value="created"></Radio.Button><Radio.Button value="involved"></Radio.Button>
</Radio.Group>
<Input placeholder="搜索会议标题" prefix={<SearchOutlined style={{ color: '#bfbfbf' }} />} allowClear onPressEnter={(e) => { setSearchTitle((e.target as any).value); setCurrent(1); }} style={{ width: 220, borderRadius: 8 }} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setCreateDrawerType('upload');
setCreateDrawerVisible(true);
}} style={{ borderRadius: 8, height: 36, fontWeight: 500 }}></Button>
}}
>
</Button>
</Space>
}
toolbar={
<>
<Radio.Group value={viewType} onChange={e => { setViewType(e.target.value); setCurrent(1); }} buttonStyle="solid">
<Radio.Button value="all"></Radio.Button>
<Radio.Button value="created"></Radio.Button>
<Radio.Button value="involved"></Radio.Button>
</Radio.Group>
<Input
placeholder="搜索会议标题"
prefix={<SearchOutlined />}
allowClear
onPressEnter={(e) => { setSearchTitle((e.target as any).value); setCurrent(1); }}
style={{ width: 220 }}
/>
</>
}
>
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflowX: "hidden", overflowY: "auto", padding: "24px" }}>
<Card className="app-page__content-card" style={{ flex: 1, minHeight: 0 }} styles={{ body: { padding: '16px 24px', flex: 1 } }}>
{displayMode === 'card' ? (
<Skeleton loading={loading} active paragraph={{ rows: 10 }}>
<List grid={{ gutter: [24, 24], xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }} dataSource={data} renderItem={(item) => {
<List
grid={{ gutter: [24, 24], xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }}
dataSource={data}
renderItem={(item) => {
const config = statusConfig[item.displayStatus ?? item.status] || statusConfig[0];
return <MeetingCardItem item={item} config={config} fetchData={fetchData} t={t} onEditParticipants={openEditParticipants} onOpenMeeting={handleOpenMeeting} />;
}} locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="开启您的第一场会议分析" /> }} />
}}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="开启您的第一场会议分析" /> }}
/>
</Skeleton>
) : (
<Table
@ -615,7 +631,6 @@ const Meetings: React.FC = () => {
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="开启您的第一场会议分析" /> }}
/>
)}
</div>
<AppPagination current={current} pageSize={size} total={total} onChange={(p, s) => { setCurrent(p); setSize(s); }} />
</Card>
@ -644,10 +659,7 @@ const Meetings: React.FC = () => {
width={500}
>
<Form form={participantsEditForm} layout="vertical">
<Form.Item
name="participantIds"
label="参会人员"
>
<Form.Item name="participantIds" label="参会人员">
<Select mode="multiple" placeholder="请选择参会人" showSearch optionFilterProp="children">
{userList.map(u => (
<Option key={u.userId} value={u.userId}>
@ -673,7 +685,7 @@ const Meetings: React.FC = () => {
.card-actions { opacity: 0.6; transition: opacity 0.3s; }
.meeting-card:hover .card-actions { opacity: 1; }
`}</style>
</div>
</PageContainer>
);
};

View File

@ -20,6 +20,7 @@ import {
Tooltip,
Typography,
} from 'antd';
import PageContainer from "@/components/shared/PageContainer";
import { CopyOutlined, DeleteOutlined, EditOutlined, PlusOutlined, SaveOutlined, StarFilled } from '@ant-design/icons';
import ReactMarkdown from 'react-markdown';
import { useTranslation } from 'react-i18next';
@ -331,16 +332,15 @@ const PromptTemplates: React.FC = () => {
};
return (
<div style={{ padding: '32px', background: 'var(--app-bg-page)', height: 'calc(100vh - 64px)', overflow: 'hidden' }}>
<div style={{ maxWidth: 1400, margin: '0 auto', height: '100%', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 32 }}>
<Title level={3} style={{ margin: 0 }}></Title>
<PageContainer
title="提示词模板"
subtitle="管理AI会议总结的提示词模板库"
headerExtra={
<Button type="primary" icon={<PlusOutlined />} size="large" onClick={() => handleOpenDrawer()} style={{ borderRadius: 6 }}>
</Button>
</div>
<Card variant="borderless" style={{ borderRadius: 12, marginBottom: 32, background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', boxShadow: 'var(--app-shadow)', backdropFilter: 'blur(16px)' }} styles={{ body: { padding: '20px 24px' } }}>
}
toolbar={
<Form form={searchForm} layout="inline" onFinish={() => void fetchData()}>
<Form.Item name="name" label="模板名称"><Input placeholder="请输入..." style={{ width: 180 }} /></Form.Item>
<Form.Item name="category" label="分类">
@ -355,8 +355,8 @@ const PromptTemplates: React.FC = () => {
</Space>
</Form.Item>
</Form>
</Card>
}
>
<Card className="app-page__content-card" style={{ flex: 1, minHeight: 0 }} styles={{ body: { padding: 0, height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' } }}>
<Skeleton loading={loading} active style={{ height: '100%' }}>
{Object.keys(groupedData).length === 0 ? (
@ -391,7 +391,6 @@ const PromptTemplates: React.FC = () => {
)}
</Skeleton>
</Card>
</div>
<Drawer
title={<Title level={4} style={{ margin: 0 }}>{editingId ? '编辑模板' : '创建新模板'}</Title>}
@ -506,7 +505,7 @@ const PromptTemplates: React.FC = () => {
</Row>
</Form>
</Drawer>
</div>
</PageContainer>
);
};

View File

@ -16,6 +16,7 @@ import {
import { useNavigate, useParams } from "react-router-dom";
import dayjs from "dayjs";
import PageHeader from "../../components/shared/PageHeader";
import PageContainer from "@/components/shared/PageContainer";
import {
appendRealtimeTranscripts,
completeRealtimeMeeting,

View File

@ -1,4 +1,4 @@
import "./ScreenSaverManagement.css";
import "./ScreenSaverManagement.css";
import {
App,
@ -26,6 +26,7 @@ import type { ColumnsType } from "antd/es/table";
import {
DeleteOutlined,
EditOutlined,
PictureOutlined,
PlusOutlined,
ReloadOutlined,
SaveOutlined,
@ -36,6 +37,7 @@ import {
UploadOutlined,
UserOutlined,
} from "@ant-design/icons";
import PageContainer from "@/components/shared/PageContainer";
import { useEffect, useMemo, useRef, useState } from "react";
import type { UploadProps } from "antd";
import AppPagination from "@/components/shared/AppPagination";
@ -608,29 +610,47 @@ export default function ScreenSaverManagement() {
const columns: ColumnsType<ScreenSaverVO> = [
{
title: "屏保画面",
key: "visual",
width: 330,
title: "预览",
key: "thumb",
width: 90,
render: (_, record) => (
<div className="screen-saver-table-visual">
<div className="screen-saver-table-thumb">
{record.imageUrl ? <img src={record.imageUrl} alt={record.name} /> : null}
{record.imageUrl ? (
<img src={record.imageUrl} alt={record.name} style={{ maxWidth: 70, maxHeight: 52, objectFit: 'cover', borderRadius: 4 }} />
) : (
<div style={{ width: 70, height: 52, background: '#f5f5f5', borderRadius: 4, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<PictureOutlined style={{ color: '#ccc', fontSize: 20 }} />
</div>
)}
</div>
),
},
{
title: "名称与描述",
key: "info",
width: 160,
render: (_, record) => (
<Space direction="vertical" size={3}>
<Text strong>{record.name}</Text>
<Text type="secondary">{record.description || "暂无描述"}</Text>
<Space wrap size={[8, 6]}>
<span className="screen-saver-preview-pill">{getImageFormatLabel(record.imageFormat)}</span>
<span className="screen-saver-preview-pill">{record.imageWidth || CROP_WIDTH} × {record.imageHeight || CROP_HEIGHT}</span>
<Text strong style={{ fontSize: 14 }}>{record.name}</Text>
<Text type="secondary" ellipsis style={{ fontSize: 12, maxWidth: 150 }}>{record.description || "暂无描述"}</Text>
</Space>
),
},
{
title: "图片规格",
key: "spec",
width: 150,
render: (_, record) => (
<Space direction="vertical" size={4}>
<Tag color="processing">{getImageFormatLabel(record.imageFormat)}</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>{record.imageWidth || CROP_WIDTH} × {record.imageHeight || CROP_HEIGHT}</Text>
</Space>
</div>
),
},
{
title: "作用域",
key: "scope",
width: 220,
width: 160,
render: (_, record) => (
<Space direction="vertical" size={4}>
{record.scopeType === "USER" ? (
@ -638,7 +658,7 @@ export default function ScreenSaverManagement() {
) : (
<Tag color="blue" icon={<TeamOutlined />}></Tag>
)}
<Text type="secondary">
<Text type="secondary" style={{ fontSize: 12 }}>
{record.scopeType === "USER"
? `归属:${normalizeOwnerLabel(record.ownerUserId ? userMap.get(record.ownerUserId) : undefined)}`
: "全平台共用"}
@ -647,12 +667,12 @@ export default function ScreenSaverManagement() {
),
},
{
title: "排序与状态",
title: "状态",
key: "status",
width: 210,
width: 120,
render: (_, record) => (
<Space direction="vertical" size={6}>
<Text type="secondary">{record.sortOrder ?? 0}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{record.sortOrder ?? 0}</Text>
<Switch size="small" checked={record.status === 1} onChange={(checked) => void handleToggleStatus(record, checked)} />
</Space>
),
@ -660,14 +680,14 @@ export default function ScreenSaverManagement() {
{
title: "创建信息",
key: "creator",
width: 180,
width: 160,
render: (_, record) => {
const timeValue = record.updatedAt || record.createdAt;
return (
<Space direction="vertical" size={4}>
<Text>{record.creatorUsername || "-"}</Text>
<Text type="secondary">
{timeValue ? dayjs(timeValue).format("YYYY-MM-DD HH:mm:ss") : "-"}
<Text style={{ fontSize: 13 }}>{record.creatorUsername || "-"}</Text>
<Text type="secondary" style={{ fontSize: 11 }}>
{timeValue ? dayjs(timeValue).format("MM-DD HH:mm") : "-"}
</Text>
</Space>
);
@ -701,13 +721,20 @@ export default function ScreenSaverManagement() {
const currentHeight = Form.useWatch("imageHeight", form) || CROP_HEIGHT;
return (
<div className="app-page screen-saver-page">
<Card
className="app-page__content-card shadow-sm screen-saver-table-card"
style={{ flex: 1, minHeight: 0 }}
styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}
<PageContainer
title="屏保管理"
extra={(
subtitle="管理平台级和用户级的屏保素材"
headerExtra={
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading}>
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</Space>
}
toolbar={
<Space wrap>
<Input
placeholder="搜索名称、描述、创建人或归属用户"
@ -737,17 +764,8 @@ export default function ScreenSaverManagement() {
{ label: "已停用", value: "disabled" },
]}
/>
<Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading}>
</Button>
{/*<Button icon={<SettingOutlined />} onClick={openSettingsModal}>*/}
{/* 播放设置({mySettings.displayDurationSec} 秒/张)*/}
{/*</Button>*/}
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</Space>
)}
}
>
<div className="app-page__table-wrap screen-saver-table-wrap">
<Table
@ -771,8 +789,6 @@ export default function ScreenSaverManagement() {
setPageSize(nextSize);
}}
/>
</Card>
<Drawer
title={editing ? "编辑屏保" : "新增屏保"}
open={drawerOpen}
@ -855,7 +871,7 @@ export default function ScreenSaverManagement() {
</Space>
<div className="screen-saver-preview-stage">
{currentImageUrl ? (
<img src={currentImageUrl} alt="屏保预览" />
<img src={currentImageUrl} alt="屏保预览" style={{ maxWidth: '100%', maxHeight: 300, objectFit: 'contain', borderRadius: 8 }} />
) : (
<div style={{ height: "100%", display: "grid", placeItems: "center", color: "rgba(235,244,255,.72)" }}>
<Space direction="vertical" align="center" size={10}>
@ -950,6 +966,6 @@ export default function ScreenSaverManagement() {
onCancel={() => setCropState((prev) => ({ ...prev, open: false, src: "" }))}
onConfirm={handleUploadCroppedImage}
/>
</div>
</PageContainer>
);
}

View File

@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import PageContainer from "@/components/shared/PageContainer";
import { Row, Col, Card, Statistic, List, Tag, Typography, Button, Space, Empty, Steps, Progress, Divider } from 'antd';
import {
HistoryOutlined,
@ -137,8 +138,10 @@ export const Dashboard: React.FC = () => {
];
return (
<div style={{ padding: '24px', background: 'var(--app-bg-page)', minHeight: '100%', overflowY: 'auto' }}>
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
<PageContainer
title="仪表板"
subtitle="系统运行概览与最近任务动态"
>
<Row gutter={24} style={{ marginBottom: 24 }}>
{statCards.map((s, idx) => (
<Col span={6} key={idx}>
@ -211,13 +214,12 @@ export const Dashboard: React.FC = () => {
locale={{ emptyText: <Empty description="暂无近期分析任务" /> }}
/>
</Card>
</div>
<style>{`
.ant-steps-item-title { font-size: 13px !important; font-weight: 600 !important; }
.ant-steps-item-description { font-size: 11px !important; }
`}</style>
</div>
</PageContainer>
);
};

View File

@ -1,9 +1,10 @@
import { Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Select, Space, Table, Tag, Typography, message } from "antd";
import { CheckCircleOutlined, DesktopOutlined, DisconnectOutlined, EditOutlined, SearchOutlined, ThunderboltOutlined, UserOutlined } from "@ant-design/icons";
import { CheckCircleOutlined, DesktopOutlined, DisconnectOutlined, EditOutlined, ReloadOutlined, SearchOutlined, ThunderboltOutlined, UserOutlined } from "@ant-design/icons";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { kickManagedDevice, listManagedDevices, updateManagedDevice } from "@/api";
import PageHeader from "@/components/shared/PageHeader";
import PageContainer from "@/components/shared/PageContainer";
import { useDict } from "@/hooks/useDict";
import { usePermission } from "@/hooks/usePermission";
import type { DeviceInfo } from "@/types";
@ -104,9 +105,26 @@ export default function Devices() {
};
return (
<div className="app-page devices-page">
<PageHeader title={t("devices.title")} subtitle={t("devices.subtitle")} />
<PageContainer
title={t("devices.title")}
subtitle={t("devices.subtitle")}
headerExtra={
<Button icon={<ReloadOutlined />} onClick={loadData}>
{t("common.refresh")}
</Button>
}
toolbar={
<Input
placeholder={t("devicesExt.searchPlaceholder")}
prefix={<SearchOutlined aria-hidden="true" />}
style={{ width: 420 }}
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
allowClear
aria-label={t("devicesExt.searchLabel")}
/>
}
>
<Row gutter={[16, 16]} className="devices-metrics">
<Col xs={24} md={8}>
<Card className="devices-metric-card devices-metric-card--total" bordered={false}>
@ -146,22 +164,6 @@ export default function Devices() {
</Col>
</Row>
<Card className="devices-table-card app-page__filter-card" styles={{ body: { padding: "16px" } }}>
<div className="app-page__toolbar" style={{ justifyContent: "space-between", width: "100%" }}>
<Input
placeholder={t("devicesExt.searchPlaceholder")}
prefix={<SearchOutlined aria-hidden="true" />}
style={{ width: 420 }}
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
allowClear
aria-label={t("devicesExt.searchLabel")}
/>
<Button onClick={loadData}>{t("common.refresh")}</Button>
</div>
</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" } }}>
<Table<DeviceInfo>
rowKey="deviceId"
dataSource={filteredData}
@ -279,8 +281,6 @@ export default function Devices() {
}
]}
/>
</Card>
<Drawer
title={
<div className="device-drawer-title">
@ -310,6 +310,6 @@ export default function Devices() {
</Form.Item>
</Form>
</Drawer>
</div>
</PageContainer>
);
}

View File

@ -6,6 +6,7 @@ import { createOrg, deleteOrg, listOrgs, listTenants, updateOrg } 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 type { OrgNode, SysOrg, SysTenant } from "@/types";
const { Text } = Typography;
@ -159,30 +160,31 @@ export default function Orgs() {
];
return (
<div className="app-page">
<PageHeader title={t("orgs.title")} subtitle={t("orgs.subtitle")} />
<div className="app-page__page-actions">
{can("sys:org:create") && <Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={() => openCreate()}>{t("common.create")}</Button>}
</div>
{isPlatformMode && (
<Card className="app-page__filter-card" styles={{ body: { padding: "16px" } }}>
<Space className="app-page__toolbar">
<PageContainer
title={t("orgs.title")}
subtitle={t("orgs.subtitle")}
headerExtra={
can("sys:org:create") && (
<Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={() => openCreate()}>
{t("common.create")}
</Button>
)
}
toolbar={
isPlatformMode && (
<Space>
<Text strong>{t("users.tenant")}</Text>
<Select style={{ width: 220 }} placeholder={t("orgs.selectTenant")} value={selectedTenantId} onChange={setSelectedTenantId} options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))} suffixIcon={<ShopOutlined aria-hidden="true" />} />
<Button icon={<ReloadOutlined aria-hidden="true" />} onClick={loadOrgs}>{t("common.refresh")}</Button>
</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" } }}>
)
}
>
{selectedTenantId !== undefined ? (
<Table rowKey="id" columns={columns} dataSource={treeData} loading={loading} pagination={false} size="middle" scroll={{ y: "calc(100vh - 350px)" }} expandable={{ defaultExpandAllRows: true }} />
) : (
<div className="py-20 flex justify-center"><Empty description={t("orgs.selectTenant")} /></div>
)}
</Card>
<Drawer title={<Space><ApartmentOutlined aria-hidden="true" /><span>{editing ? t("orgs.drawerTitleEdit") : t("orgs.drawerTitleCreate")}</span></Space>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={420} destroyOnClose footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
<Form form={form} layout="vertical">
@ -213,6 +215,6 @@ export default function Orgs() {
</Row>
</Form>
</Drawer>
</div>
</PageContainer>
);
}

View File

@ -7,6 +7,7 @@ import { createTenant, deleteTenant, getPlatformRuntime, listTenants, updateTena
import { useDict } from "@/hooks/useDict";
import { usePermission } from "@/hooks/usePermission";
import PageHeader from "@/components/shared/PageHeader";
import PageContainer from "@/components/shared/PageContainer";
import { getStandardPagination } from "@/utils/pagination";
import { LOGIN_NAME_PATTERN, sanitizeLoginName } from "@/utils/loginName";
import type { PlatformRuntime, SysTenant } from "@/types";
@ -183,15 +184,18 @@ export default function Tenants() {
};
return (
<div className="app-page" style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden', padding: '24px' }}>
<div className="flex-shrink-0">
<PageHeader title={t("tenants.title")} subtitle={t("tenants.subtitle")} />
</div>
<div className="flex-shrink-0">
<Card className="app-page__filter-card" styles={{ body: { padding: "16px" } }} style={{ marginBottom: '16px' }}>
<div className="app-page__toolbar" style={{ justifyContent: "space-between", width: "100%" }}>
<Form form={searchForm} layout="inline" onFinish={handleSearch} className="app-page__toolbar">
<PageContainer
title={t("tenants.title")}
subtitle={t("tenants.subtitle")}
headerExtra={
runtime?.tenantMode !== "single" && can("sys_tenant:create") && (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
{t("common.create")}
</Button>
)
}
toolbar={
<Form form={searchForm} layout="inline" onFinish={handleSearch}>
<Form.Item name="name">
<Input placeholder={t("tenants.tenantName")} prefix={<SearchOutlined />} allowClear style={{ width: 200 }} />
</Form.Item>
@ -205,17 +209,8 @@ export default function Tenants() {
</Space>
</Form.Item>
</Form>
{runtime?.tenantMode !== "single" && can("sys_tenant:create") && <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>{t("common.create")}</Button>}
</div>
</Card>
</div>
<Card
className="app-page__content-card"
style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}
styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}
}
>
<div style={{ flex: 1, overflowY: "auto", padding: "24px" }}>
<List
grid={{ gutter: 24, xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }}
loading={loading}
@ -224,20 +219,16 @@ export default function Tenants() {
pagination={false}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("tenantsExt.emptyText")} /> }}
/>
</div>
<div style={{ flexShrink: 0, borderTop: '1px solid var(--app-border-color)', background: 'var(--app-bg-card)' }}>
<Pagination
{...getStandardPagination(total, queryParams.current, queryParams.size, handlePageChange)}
className="app-global-pagination"
style={{
margin: 0,
padding: "12px 24px",
borderRadius: "0 0 16px 16px"
marginTop: 24,
display: 'flex',
justifyContent: 'flex-end'
}}
/>
</div>
</Card>
<Drawer
title={<Space><ShopOutlined aria-hidden="true" /><span>{editing ? t("tenants.drawerTitleEdit") : t("tenants.drawerTitleCreate")}</span></Space>}
@ -303,6 +294,6 @@ export default function Tenants() {
</Form.Item>
</Form>
</Drawer>
</div>
</PageContainer>
);
}

View File

@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { generateMyBotCredential, getCurrentUser, getMyBotCredential, updateMyPassword, updateMyProfile, uploadPlatformAsset } from "@/api";
import PageHeader from "@/components/shared/PageHeader";
import PageContainer from "@/components/shared/PageContainer";
import type { BotCredential, UserProfile } from "@/types";
import AvatarCropDialog, { type CropModalState } from "./AvatarCropDialog";
@ -128,9 +129,11 @@ export default function Profile() {
const avatarUrl = avatarUrlValue?.trim() || undefined;
return (
<div className="app-page app-page--contained" style={{ maxWidth: 1024, width: "100%", margin: "0 auto" }}>
<PageHeader title={t("profile.title")} subtitle={t("profile.subtitle")} />
<PageContainer
title={t("profile.title")}
subtitle={t("profile.subtitle")}
style={{ maxWidth: 1024, width: "100%", margin: "0 auto" }}
>
<Row gutter={24}>
<Col xs={24} lg={8}>
<Card className="app-page__content-card text-center" loading={loading}>
@ -304,7 +307,7 @@ export default function Profile() {
onCancel={() => setCropState((prev) => ({ ...prev, open: false, src: "" }))}
onConfirm={handleUploadCroppedImage}
/>
</div>
</PageContainer>
);
}

View File

@ -6,6 +6,7 @@ import { createDictItem, createDictType, deleteDictItem, deleteDictType, fetchDi
import { useDict } from "@/hooks/useDict";
import { usePermission } from "@/hooks/usePermission";
import PageHeader from "@/components/shared/PageHeader";
import PageContainer from "@/components/shared/PageContainer";
import { getStandardPagination } from "@/utils/pagination";
import type { SysDictItem, SysDictType } from "@/types";
import "./index.less";
@ -149,10 +150,11 @@ export default function Dictionaries() {
};
return (
<div className="app-page dictionaries-page">
<PageHeader title={t("dicts.title")} subtitle={t("dicts.subtitle")} />
<Row gutter={24} className="flex-1 min-h-0 overflow-hidden">
<PageContainer
title={t("dicts.title")}
subtitle={t("dicts.subtitle")}
>
<Row gutter={24} style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
<Col span={8} className="h-full flex flex-col overflow-hidden">
<Card title={<Space><BookOutlined aria-hidden="true" /><span>{t("dicts.dictType")}</span></Space>} className="app-page__panel-card flex-1 flex flex-col overflow-hidden" styles={{ body: { padding: "12px", flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }} extra={can("sys_dict:type:create") && <Button type="primary" size="small" icon={<PlusOutlined aria-hidden="true" />} onClick={handleAddType}>{t("common.create")}</Button>}>
<div style={{ marginBottom: 12 }} className="flex-shrink-0">
@ -273,6 +275,6 @@ export default function Dictionaries() {
<Form.Item label={t("common.remark")} name="remark"><Input.TextArea placeholder={t("dictsExt.itemRemarkPlaceholder")} rows={3} /></Form.Item>
</Form>
</Drawer>
</div>
</PageContainer>
);
}

View File

@ -5,6 +5,7 @@ import { DeleteOutlined, EyeOutlined, InfoCircleOutlined, ReloadOutlined, Search
import { cleanLogs, fetchLogModules, fetchLogs, listTenants } from "@/api";
import { useDict } from "@/hooks/useDict";
import PageHeader from "@/components/shared/PageHeader";
import PageContainer from "@/components/shared/PageContainer";
import ListTable from "@/components/shared/ListTable/ListTable";
import AppPagination from "@/components/shared/AppPagination";
import type { SysLog, UserProfile } from "@/types";
@ -245,11 +246,27 @@ export default function Logs() {
}
return (
<div className="app-page">
<PageHeader title={t("logs.title")} subtitle={t("logs.subtitle")} />
<Card className="app-page__filter-card" styles={{ body: { padding: "16px" } }}>
<Space wrap size="middle" className="app-page__toolbar">
<PageContainer
title={t("logs.title")}
subtitle={t("logs.subtitle")}
headerExtra={
<Space>
<Popconfirm
title={t("logsExt.cleanConfirmTitle", { type: activeLogTypeLabel })}
description={isPlatformAdmin ? t("logsExt.cleanConfirmDescriptionWithTenant", { tenant: activeTenantName }) : t("logsExt.cleanConfirmDescription")}
okText={t("common.confirm")}
cancelText={t("common.cancel")}
okButtonProps={{ danger: true, loading: cleaning }}
onConfirm={() => void handleClean()}
>
<Button danger icon={<DeleteOutlined aria-hidden="true" />} loading={cleaning}>
{t("logsExt.cleanCurrent", { type: activeLogTypeLabel })}
</Button>
</Popconfirm>
</Space>
}
toolbar={
<Space wrap size="middle">
<Input
placeholder={t("logs.searchPlaceholder")}
style={{ width: 180 }}
@ -301,14 +318,13 @@ export default function Logs() {
<Button type="primary" icon={<SearchOutlined aria-hidden="true" />} onClick={handleSearch}>{t("common.search")}</Button>
<Button icon={<ReloadOutlined aria-hidden="true" />} onClick={handleReset}>{t("common.reset")}</Button>
</Space>
</Card>
<Card className="app-page__content-card flex-1 flex flex-col overflow-hidden" styles={{ body: { paddingTop: 0, paddingBottom: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}>
}
>
<Card className="app-page__content-card" style={{ flex: 1, minHeight: 0 }} styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}>
<Tabs
activeKey={activeTab}
onChange={(key) => { setActiveTab(key); setParams((prev) => ({ ...prev, current: 1, moduleName: "" })); }}
size="large"
className="flex-shrink-0"
tabBarExtraContent={(
<Popconfirm
title={t("logsExt.cleanConfirmTitle", { type: activeLogTypeLabel })}
@ -341,6 +357,7 @@ export default function Logs() {
pagination={false}
/>
</div>
<AppPagination
current={params.current}
pageSize={params.size}
@ -372,6 +389,6 @@ export default function Logs() {
</Descriptions>
)}
</Modal>
</div>
</PageContainer>
);
}

View File

@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getAdminPlatformConfig, updatePlatformConfig, uploadPlatformAsset } from "@/api";
import PageHeader from "@/components/shared/PageHeader";
import PageContainer from "@/components/shared/PageContainer";
import type { SysPlatformConfig } from "@/types";
import "./index.less";
@ -72,20 +73,20 @@ export default function PlatformSettings() {
};
return (
<div className="app-page app-page--contained platform-settings-page">
<PageHeader title={t("platformSettings.title")} subtitle={t("platformSettings.subtitle")} />
<div className="app-page__page-actions">
<PageContainer
title={t("platformSettings.title")}
subtitle={t("platformSettings.subtitle")}
headerExtra={
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => form.submit()}>
{t("common.save")}
</Button>
</div>
}
>
<div className="platform-settings-scroll">
<Form form={form} layout="vertical" onFinish={onFinish} initialValues={{ projectName: "UnisBase" }} className="platform-settings-form">
<Row gutter={24}>
<Col span={24}>
<Card title={<><GlobalOutlined className="mr-2" />{t("platformSettings.basicInfo")}</>} className="app-page__content-card mb-6" loading={loading}>
<Card title={<><GlobalOutlined className="mr-2" />{t("platformSettings.basicInfo")}</>} className="mb-6" loading={loading}>
<Form.Item label={t("platformSettings.projectName")} name="projectName" rules={[
{ required: true, message: t("platformSettings.projectNameRequired") },
{
@ -154,6 +155,6 @@ export default function PlatformSettings() {
</Row>
</Form>
</div>
</div>
</PageContainer>
);
}

View File

@ -6,6 +6,7 @@ import { createParam, deleteParam, pageParams, updateParam } 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 ListTable from "@/components/shared/ListTable/ListTable";
import AppPagination from "@/components/shared/AppPagination";
import type { SysParamQuery, SysParamVO } from "@/types";
@ -165,12 +166,16 @@ export default function SysParams() {
];
return (
<div className="app-page sys-params-page">
<PageHeader title={t("sysParams.title")} subtitle={t("sysParams.subtitle")} />
<Card className="sys-params-table-card app-page__filter-card" styles={{ body: { padding: "16px" } }}>
<div className="app-page__toolbar" style={{ justifyContent: "space-between", width: "100%" }}>
<Form layout="inline" onFinish={handleSearch} className="app-page__toolbar">
<PageContainer
title={t("sysParams.title")}
subtitle={t("sysParams.subtitle")}
headerExtra={
<Button type="primary" icon={<PlusOutlined />} onClick={() => setDrawerOpen(true)}>
{t("common.create")}
</Button>
}
toolbar={
<Form layout="inline" onFinish={handleSearch}>
<Form.Item name="paramKey">
<Input placeholder={t("sysParams.paramKey")} prefix={<SearchOutlined />} allowClear style={{ width: 200 }} />
</Form.Item>
@ -179,27 +184,26 @@ export default function SysParams() {
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">{t("common.search")}</Button>
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">{t("common.search")}</Button>
<Button onClick={handleReset}>{t("common.reset")}</Button>
</Space>
</Form.Item>
</Form>
{can("sys_param:create") && <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>{t("common.create")}</Button>}
</div>
</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={{ flex: 1, minHeight: 0, overflow: "auto", padding: "0 24px" }}>
}
>
<Card className="app-page__content-card">
<div className="app-page__table-wrap">
<ListTable
rowKey="paramId"
columns={columns}
dataSource={data}
loading={loading}
scroll={{ y: "calc(100vh - 350px)" }}
onChange={handleTableChange}
totalCount={total}
pagination={false}
/>
</div>
<AppPagination
current={queryParams.pageNum || 1}
pageSize={queryParams.pageSize || 10}
@ -251,6 +255,7 @@ export default function SysParams() {
</Form.Item>
</Form>
</Drawer>
</div>
</PageContainer>
);
}