feat: 平台治理与对象存储增强,审批中心与运行日志整合

- 新增 storage/policy.py 落盘策略:按大小/类型决定文件存 MinIO 或内联数据库
- 数据处理源文件与生成结果写入 MinIO 并登记 storage_objects,支持失败回滚
- 算力节点训练产物按版本归档到 MinIO,登记 model_artifacts
- 数据转换任务输入输出对象化,支持从 MinIO 读写
- 新增审批中心(申请/我的/策略)、组织与权限、运行日志整合页面
- schema 与 docker 配置、前端路由侧边栏、治理文档同步更新

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
wuyongtao
2026-08-19 16:10:02 +08:00
parent 81c2f85c3a
commit 78e3baa9ba
30 changed files with 1994 additions and 249 deletions

View File

@@ -20,6 +20,8 @@ export interface AuditQuery {
actor_id?: string
action?: string
target_type?: string
target_id?: string
keyword?: string
start_time?: string
end_time?: string
limit?: number

View File

@@ -15,6 +15,10 @@ const activeMenu = computed(() => {
if (seg === 'training-log') return 'fine-tune'
// 维度管理归到模型评测
if (route.path.includes('model-eval/dimension')) return 'model-eval'
// 组织与权限承接用户、租户和历史治理入口
if (route.path.startsWith('/organization') || route.path.startsWith('/user-settings') || route.path.startsWith('/tenants')) return 'organization'
// 运行日志承接审计和操作诊断两个历史入口
if (route.path.startsWith('/logs') || route.path.startsWith('/audit-logs') || route.path.startsWith('/operation-logs')) return 'logs'
// 对比对话归到模型推理
if (route.path.startsWith('/model-compare/chat')) return 'model-inference'
// 合并权重归到模型管理
@@ -77,21 +81,16 @@ const menuGroups: MenuGroup[] = [
{
title: '平台治理',
items: [
{ key: 'tenants', label: '租户管理', icon: 'fa-building', to: '/tenants', permission: 'user-settings' },
{ key: 'projects', label: '项目空间', icon: 'fa-folder', to: '/projects', permission: 'user-settings' },
{ key: 'organization', label: '组织与权限', icon: 'fa-users', to: '/organization', permission: 'user-settings' },
{ key: 'resource-acl', label: '资源授权', icon: 'fa-key', to: '/resource-acl', permission: 'user-settings' },
{ key: 'audit-logs', label: '审计日志', icon: 'fa-history', to: '/audit-logs', permission: 'user-settings' },
{ key: 'operation-logs', label: '操作日志', icon: 'fa-list', to: '/operation-logs', permission: 'user-settings' },
{ key: 'approval-templates', label: '审批模板', icon: 'fa-list-alt', to: '/approval-templates', permission: 'user-settings' },
{ key: 'approval-instances', label: '审批中心', icon: 'fa-check-square', to: '/approval-instances', permission: 'user-settings' },
],
},
{
title: '系统设置',
items: [
{ key: 'user-settings', label: '用户设置', icon: 'fa-users', to: '/user-settings', permission: 'user-settings' },
{ key: 'hardware', label: '平台性能', icon: 'fa-bar-chart', to: '/hardware', permission: 'hardware' },
{ key: 'logs', label: '查看日志', icon: 'fa-file-text', to: '/logs', permission: 'logs' },
{ key: 'logs', label: '运行日志', icon: 'fa-file-text', to: '/logs', permission: 'logs' },
],
},
]
@@ -101,9 +100,9 @@ const menuGroups: MenuGroup[] = [
*
* 1. admin 用户:可以看到所有菜单
* 2. 非 admin 用户:
* - 默认可见所有业务菜单(模型训练、评测、推理、数据集、数据处理、转换、性能、日志等)
* - 默认可见所有业务菜单(模型训练、评测、推理、数据集、数据处理、转换、性能、运行日志等)
* - 仅以下菜单对非 admin 不可见:
* - user-settings用户设置、租户管理、项目空间、审批模板/中心、审计日志
* - user-settings组织与权限、资源授权、审批;运行日志中的审计/诊断页签
* - compute算力节点/GPU 分配)
*
* 注意移除了旧的权限码permission code过滤逻辑

View File

@@ -32,11 +32,17 @@ const routes: RouteRecordRaw[] = [
meta: { title: '服务看板' },
},
// 平台治理
{
path: 'organization',
name: 'organization',
component: () => import('@/views/governance/OrganizationPermissionView.vue'),
meta: { title: '组织与权限', pageSurface: 'self', permission: 'user-settings' },
},
{
path: 'tenants',
name: 'tenants',
component: () => import('@/views/tenants/TenantListView.vue'),
meta: { title: '租户管理', permission: 'user-settings' },
redirect: '/organization?tab=tenants',
meta: { title: '租户与配额', permission: 'user-settings' },
},
{
path: 'tenants/:id',
@@ -47,37 +53,37 @@ const routes: RouteRecordRaw[] = [
{
path: 'projects',
name: 'projects',
component: () => import('@/views/projects/ProjectListView.vue'),
meta: { title: '项目空间', permission: 'user-settings' },
redirect: '/organization?tab=users',
meta: { title: '组织与权限', permission: 'user-settings' },
},
{
path: 'projects/:id',
name: 'project-detail',
component: () => import('@/views/projects/ProjectDetailView.vue'),
meta: { title: '项目详情', permission: 'user-settings' },
redirect: '/organization?tab=users',
meta: { title: '组织与权限', permission: 'user-settings' },
},
{
path: 'audit-logs',
name: 'audit-logs',
component: () => import('@/views/audit/AuditLogView.vue'),
meta: { title: '审计日志', permission: 'user-settings' },
redirect: '/logs?tab=audit',
meta: { title: '运行日志', permission: 'user-settings' },
},
{
path: 'operation-logs',
name: 'operation-logs',
component: () => import('@/views/audit/OperationLogView.vue'),
meta: { title: '操作日志', permission: 'user-settings' },
redirect: '/logs?tab=operations',
meta: { title: '运行日志', permission: 'user-settings' },
},
{
path: 'approval-templates',
name: 'approval-templates',
component: () => import('@/views/approvals/ApprovalTemplateView.vue'),
meta: { title: '审批模板', permission: 'user-settings' },
redirect: '/approval-instances?tab=strategies',
meta: { title: '审批中心', permission: 'user-settings' },
},
{
path: 'approval-instances',
name: 'approval-instances',
component: () => import('@/views/approvals/ApprovalInstanceView.vue'),
component: () => import('@/views/approvals/ApprovalCenterView.vue'),
meta: { title: '审批中心', permission: 'user-settings' },
},
{
@@ -302,14 +308,14 @@ const routes: RouteRecordRaw[] = [
{
path: 'logs',
name: 'logs',
component: () => import('@/views/system/LogsView.vue'),
meta: { title: '查看日志' },
component: () => import('@/views/system/RuntimeLogsView.vue'),
meta: { title: '运行日志', pageSurface: 'self', permission: 'logs' },
},
{
path: 'user-settings',
name: 'user-settings',
component: () => import('@/views/system/UserSettingsView.vue'),
meta: { title: '用户设置', pageSurface: 'self', permission: 'user-settings' },
redirect: '/organization?tab=users',
meta: { title: '组织与权限', pageSurface: 'self', permission: 'user-settings' },
},
{
path: 'user-settings/create',
@@ -357,6 +363,7 @@ const permissionBySegment: Record<string, PermissionCode> = {
tools: 'data-convert',
hardware: 'hardware',
logs: 'logs',
organization: 'user-settings',
'user-settings': 'user-settings',
tenants: 'user-settings',
projects: 'user-settings',
@@ -379,7 +386,7 @@ function requiredPermission(path: string, explicit?: unknown) {
// 权限控制规则(基于 governance-user-guide.md 设计):
// - admin 用户:可以访问所有页面
// - 非 admin 用户:默认可访问所有业务页面(训练、评测、推理、数据等)
// 仅以下页面限制 admin 访问user-settings、compute(算力节点)
// 仅治理与资源管理页面限制 admin 访问:organization、user-settings、compute
router.beforeEach((to, _from, next) => {
if (!to.meta.public) routeLoading.value = true
const auth = useAuthStore()
@@ -404,7 +411,7 @@ router.beforeEach((to, _from, next) => {
if (!to.meta.skipPermission) {
const permission = requiredPermission(to.path, to.meta.permission)
// 仅限制管理员专属页面的访问权限
// user-settings用户设置、租户管理、项目空间、审批、审计日志)仅 admin 可访问
// user-settings组织与权限、资源授权、审批中心、运行日志)仅 admin 可访问
if (permission === 'user-settings' && !auth.isAdmin) {
next({ name: 'permission-denied', replace: true })
return

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ApprovalInstanceView from './ApprovalInstanceView.vue'
import ApprovalTemplateView from './ApprovalTemplateView.vue'
const route = useRoute()
const router = useRouter()
const activeTab = computed<'instances' | 'mine' | 'strategies'>({
get: () => route.query.tab === 'strategies' ? 'strategies' : route.query.tab === 'mine' ? 'mine' : 'instances',
set: (value: string) => {
void router.replace({ query: value === 'instances' ? {} : { tab: value } })
},
})
</script>
<template>
<div class="approval-center">
<header class="page-header">
<div>
<h2>审批中心</h2>
<p>集中处理审批申请审批历史和审批策略</p>
</div>
</header>
<el-tabs v-model="activeTab">
<el-tab-pane label="审批申请" name="instances">
<ApprovalInstanceView v-if="activeTab === 'instances'" />
</el-tab-pane>
<el-tab-pane label="我的申请" name="mine">
<ApprovalInstanceView v-if="activeTab === 'mine'" mine />
</el-tab-pane>
<el-tab-pane label="审批策略" name="strategies">
<ApprovalTemplateView v-if="activeTab === 'strategies'" />
</el-tab-pane>
</el-tabs>
</div>
</template>
<style scoped lang="scss">
.approval-center {
min-height: 100%;
padding: 20px;
}
.page-header {
margin-bottom: 4px;
h2 {
margin: 0;
color: #1f2937;
font-size: 22px;
}
p {
margin: 6px 0 0;
color: #64748b;
font-size: 13px;
}
}
:deep(.page) {
padding: 16px 0 0;
}
</style>

View File

@@ -1,11 +1,15 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import DataTablePage from '@/components/DataTablePage.vue'
import { getApprovalInstances, decideApproval, type ApprovalInstance } from '@/api/modules/approval'
import { getUsers } from '@/api/modules/system'
import { useAuthStore } from '@/stores/auth'
import type { SystemUser } from '@/types'
const props = defineProps<{ mine?: boolean }>()
const auth = useAuthStore()
const loading = ref(false)
const instances = ref<ApprovalInstance[]>([])
const users = ref<SystemUser[]>([])
@@ -14,6 +18,14 @@ const showDecide = ref(false)
const current = ref<ApprovalInstance | null>(null)
const decision = ref({ step_index: 0, approver_id: '', approved: true, comment: '' })
const visibleInstances = computed(() => {
if (!props.mine) return instances.value
const currentUserId = auth.currentUser?.id
return currentUserId
? instances.value.filter((item) => item.applicant_id === currentUserId)
: []
})
const statusOptions = [
{ label: '待审批', value: 'pending' },
{ label: '已通过', value: 'approved' },
@@ -77,7 +89,7 @@ onMounted(() => {
<template>
<div class="page">
<DataTablePage title="审批实例" :data="instances" :loading="loading" searchable :search-fields="['resource_type', 'resource_id']">
<DataTablePage :title="props.mine ? '我的申请' : '审批申请'" :data="visibleInstances" :loading="loading" searchable :search-fields="['resource_type', 'resource_id']">
<template #toolbar-extra>
<el-select v-model="statusFilter" placeholder="状态" clearable style="width: 140px" @change="load">
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />

View File

@@ -2,16 +2,21 @@
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getAuditLogs, exportAuditLogs, type AuditLog, type AuditQuery } from '@/api/modules/audit'
import { getUsers, type SystemUser } from '@/api/modules/system'
import { getTenants, type Tenant } from '@/api/modules/tenant'
const loading = ref(false)
const logs = ref<AuditLog[]>([])
const total = ref(0)
const users = ref<SystemUser[]>([])
const tenants = ref<Tenant[]>([])
const query = reactive<AuditQuery>({
tenant_id: '',
project_id: '',
actor_id: '',
action: '',
target_type: '',
target_id: '',
keyword: '',
start_time: '',
end_time: '',
limit: 50,
@@ -21,6 +26,87 @@ const query = reactive<AuditQuery>({
// 时间范围el-date-picker 双向绑定数组 [start, end]
const timeRange = ref<[string, string] | null>(null)
const actionOptions = [
{ value: 'create_dataset', label: '创建数据集' },
{ value: 'update_dataset', label: '修改数据集' },
{ value: 'delete_dataset', label: '删除数据集' },
{ value: 'create_model', label: '创建模型' },
{ value: 'update_model', label: '修改模型' },
{ value: 'delete_model', label: '删除模型' },
{ value: 'create_fine_tune', label: '创建训练任务' },
{ value: 'update_fine_tune', label: '修改训练任务' },
{ value: 'delete_fine_tune', label: '删除训练任务' },
{ value: 'create_inference', label: '创建推理任务' },
{ value: 'update_inference', label: '修改推理任务' },
{ value: 'delete_inference', label: '删除推理任务' },
{ value: 'create_user', label: '创建用户' },
{ value: 'update_user', label: '修改用户' },
{ value: 'delete_user', label: '删除用户' },
{ value: 'tenant.create', label: '创建租户' },
{ value: 'tenant.update', label: '修改租户' },
{ value: 'tenant.delete', label: '删除租户' },
{ value: 'tenant.quota.set', label: '设置租户配额' },
{ value: 'tenant.retention.set', label: '设置留存策略' },
{ value: 'grant_acl', label: '授予资源权限' },
{ value: 'revoke_acl', label: '撤销资源权限' },
{ value: 'gpu.assign', label: '分配算力卡' },
{ value: 'gpu.release', label: '释放算力卡' },
{ value: 'create', label: '创建' },
{ value: 'update', label: '修改' },
{ value: 'delete', label: '删除' },
{ value: 'start', label: '启动' },
{ value: 'stop', label: '停止' },
{ value: 'upload', label: '上传' },
{ value: 'download', label: '下载' },
{ value: 'convert', label: '转换' },
{ value: 'merge', label: '合并权重' },
{ value: 'publish', label: '发布' },
{ value: 'retry', label: '重试' },
{ value: 'login', label: '登录' },
{ value: 'logout', label: '退出登录' },
]
const targetTypeOptions = [
{ value: 'dataset', label: '数据集' },
{ value: 'model', label: '模型' },
{ value: 'fine_tune_task', label: '训练任务' },
{ value: 'fine_tune', label: '训练任务' },
{ value: 'inference_task', label: '推理任务' },
{ value: 'inference', label: '推理任务' },
{ value: 'eval_task', label: '评测任务' },
{ value: 'trained_model', label: '训练模型' },
{ value: 'convert_task', label: '数据转换任务' },
{ value: 'tenant', label: '租户' },
{ value: 'user', label: '用户' },
{ value: 'resource_acl', label: '资源权限' },
{ value: 'gpu', label: '算力卡' },
{ value: 'retention_policy', label: '留存策略' },
{ value: 'module', label: '业务模块' },
{ value: 'api', label: '接口' },
]
const actionLabels = Object.fromEntries(actionOptions.map((item) => [item.value, item.label]))
const targetTypeLabels = Object.fromEntries(targetTypeOptions.map((item) => [item.value, item.label]))
function userName(id?: string) {
if (!id) return '系统'
const user = users.value.find((item) => item.id === id)
return user ? `${user.display_name || user.username}${user.username}` : id
}
function tenantName(id?: string) {
if (!id) return '未关联租户'
return tenants.value.find((item) => item.id === id)?.name || id
}
function actionName(action?: string) {
return action ? actionLabels[action] || action : '未记录'
}
function targetTypeName(type?: string) {
return type ? targetTypeLabels[type] || type : '未指定'
}
function applyTimeRange() {
if (timeRange.value && timeRange.value.length === 2) {
query.start_time = timeRange.value[0]
@@ -42,6 +128,25 @@ async function load() {
}
}
async function loadFilterOptions() {
const [userResult, tenantResult] = await Promise.allSettled([getUsers(), getTenants()])
if (userResult.status === 'fulfilled') users.value = userResult.value
if (tenantResult.status === 'fulfilled') tenants.value = tenantResult.value
}
function resetFilters() {
query.tenant_id = ''
query.actor_id = ''
query.action = ''
query.target_type = ''
query.target_id = ''
query.keyword = ''
query.start_time = ''
query.end_time = ''
timeRange.value = null
void load()
}
async function handleExport() {
try {
const blob = await exportAuditLogs({ ...query, limit: 10000, offset: 0 })
@@ -56,7 +161,9 @@ async function handleExport() {
}
}
onMounted(load)
onMounted(() => {
void Promise.all([loadFilterOptions(), load()])
})
</script>
<template>
@@ -66,21 +173,32 @@ onMounted(load)
<el-button @click="handleExport">导出 CSV</el-button>
</div>
<el-card class="filter-card">
<el-form :inline="true">
<el-form :inline="true" class="filter-form">
<el-form-item label="租户">
<el-input v-model="query.tenant_id" placeholder="tenant_id" clearable />
</el-form-item>
<el-form-item label="项目">
<el-input v-model="query.project_id" placeholder="project_id" clearable />
<el-select v-model="query.tenant_id" placeholder="全部租户" clearable filterable style="width: 190px">
<el-option v-for="tenant in tenants" :key="tenant.id" :label="tenant.name" :value="tenant.id" />
</el-select>
</el-form-item>
<el-form-item label="操作人">
<el-input v-model="query.actor_id" placeholder="actor_id" clearable />
<el-select v-model="query.actor_id" placeholder="全部用户" clearable filterable style="width: 210px">
<el-option v-for="user in users" :key="user.id" :label="`${user.display_name || user.username}${user.username}`" :value="user.id" />
</el-select>
</el-form-item>
<el-form-item label="动作">
<el-input v-model="query.action" placeholder="action" clearable />
<el-select v-model="query.action" placeholder="全部动作" clearable filterable style="width: 180px">
<el-option v-for="item in actionOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="目标类型">
<el-input v-model="query.target_type" placeholder="target_type" clearable />
<el-select v-model="query.target_type" placeholder="全部资源" clearable filterable style="width: 160px">
<el-option v-for="item in targetTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="关键词">
<el-input v-model="query.keyword" placeholder="资源 ID 或详情" clearable style="width: 220px" />
</el-form-item>
<el-form-item label="目标 ID">
<el-input v-model="query.target_id" placeholder="精确查询,可选" clearable style="width: 180px" />
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
@@ -97,16 +215,24 @@ onMounted(load)
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-table :data="logs" v-loading="loading" border stripe class="log-table">
<el-table-column prop="time" label="时间" min-width="180" />
<el-table-column prop="tenant_id" label="租户" min-width="120" />
<el-table-column prop="project_id" label="项目" min-width="120" />
<el-table-column prop="actor_id" label="操作人" min-width="120" />
<el-table-column prop="action" label="动作" min-width="140" />
<el-table-column prop="target_type" label="目标类型" min-width="120" />
<el-table-column label="租户" min-width="140">
<template #default="{ row }">{{ tenantName(row.tenant_id) }}</template>
</el-table-column>
<el-table-column label="操作人" min-width="180">
<template #default="{ row }">{{ userName(row.actor_id) }}</template>
</el-table-column>
<el-table-column label="动作" min-width="140">
<template #default="{ row }">{{ actionName(row.action) }}</template>
</el-table-column>
<el-table-column label="目标类型" min-width="120">
<template #default="{ row }">{{ targetTypeName(row.target_type) }}</template>
</el-table-column>
<el-table-column prop="target_id" label="目标 ID" min-width="140" show-overflow-tooltip />
<el-table-column prop="detail" label="详情" min-width="200" show-overflow-tooltip />
<el-table-column prop="client_ip" label="IP" min-width="120" />
@@ -120,6 +246,7 @@ onMounted(load)
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.page-title { margin: 0; font-size: 18px; }
.filter-card { margin-bottom: 16px; }
.filter-form { display: flex; flex-wrap: wrap; }
.log-table { margin-top: 8px; }
.pager { margin-top: 12px; text-align: right; color: #909399; }
</style>

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import UserSettingsView from '@/views/system/UserSettingsView.vue'
import TenantListView from '@/views/tenants/TenantListView.vue'
const route = useRoute()
const router = useRouter()
const activeTab = computed({
get: () => route.query.tab === 'tenants' ? 'tenants' : 'users',
set: (value: string) => {
void router.replace({ query: { tab: value === 'tenants' ? 'tenants' : 'users' } })
},
})
</script>
<template>
<div class="organization-page">
<header class="page-header">
<div>
<h2>组织与权限</h2>
<p>统一管理平台用户角色租户和资源配额</p>
</div>
</header>
<el-tabs v-model="activeTab">
<el-tab-pane label="用户与角色" name="users">
<UserSettingsView v-if="activeTab === 'users'" />
</el-tab-pane>
<el-tab-pane label="租户与配额" name="tenants">
<TenantListView v-if="activeTab === 'tenants'" />
</el-tab-pane>
</el-tabs>
</div>
</template>
<style scoped lang="scss">
.organization-page {
min-height: 100%;
padding: 20px;
}
.page-header {
margin-bottom: 4px;
h2 {
margin: 0;
color: #1f2937;
font-size: 22px;
}
p {
margin: 6px 0 0;
color: #64748b;
font-size: 13px;
}
}
:deep(.user-settings),
:deep(.page) {
padding: 16px 0 0;
}
:deep(.user-settings .page-header > div:first-child) {
display: none;
}
</style>

View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import LogsView from './LogsView.vue'
import AuditLogView from '@/views/audit/AuditLogView.vue'
import OperationLogView from '@/views/audit/OperationLogView.vue'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const activeTab = computed({
get: () => {
if (!auth.isAdmin) return 'runtime'
if (route.query.tab === 'audit') return 'audit'
if (route.query.tab === 'operations') return 'operations'
return 'runtime'
},
set: (value: string) => {
void router.replace({ query: value === 'runtime' ? {} : { tab: value } })
},
})
</script>
<template>
<div class="runtime-logs">
<header class="page-header">
<div>
<h2>运行日志</h2>
<p>查看系统运行训练任务审计记录和操作诊断信息</p>
</div>
</header>
<el-tabs v-model="activeTab">
<el-tab-pane label="运行日志" name="runtime">
<LogsView v-if="activeTab === 'runtime'" />
</el-tab-pane>
<el-tab-pane v-if="auth.isAdmin" label="审计记录" name="audit">
<AuditLogView v-if="activeTab === 'audit'" />
</el-tab-pane>
<el-tab-pane v-if="auth.isAdmin" label="操作诊断" name="operations">
<OperationLogView v-if="activeTab === 'operations'" />
</el-tab-pane>
</el-tabs>
</div>
</template>
<style scoped lang="scss">
.runtime-logs {
min-height: 100%;
padding: 20px;
}
.page-header {
margin-bottom: 4px;
h2 {
margin: 0;
color: #1f2937;
font-size: 22px;
}
p {
margin: 6px 0 0;
color: #64748b;
font-size: 13px;
}
}
:deep(.page) {
padding: 16px 0 0;
}
:deep(.page-header .page-title) {
display: none;
}
</style>