feat: 统一管理端弹窗与内容区交互样式

- 收敛管理端公共 Modal 链路,新增表单弹窗与普通内容弹窗包装\n- 迁移 Bot、知识库、插件、工作流、资源、MCP、数据中枢与系统管理页面级弹窗\n- 统一内容区工具栏、列表容器、导航与顶部按钮的视觉密度和交互节奏
This commit is contained in:
2026-03-06 19:58:26 +08:00
parent 76c2954a70
commit b191d1aaed
99 changed files with 3148 additions and 1623 deletions

View File

@@ -1,7 +1,7 @@
<script setup>
import { computed, ref } from 'vue';
import { ArrowDown } from '@element-plus/icons-vue';
import { ArrowDown, Search } from '@element-plus/icons-vue';
import {
ElButton,
ElDropdown,
@@ -14,9 +14,7 @@ import {
import { hasPermission } from '#/api/common/hasPermission.ts';
import { $t } from '#/locales';
// 定义组件属性
const props = defineProps({
// 按钮配置数组
buttons: {
type: Array,
default: () => [],
@@ -29,40 +27,43 @@ const props = defineProps({
});
},
},
// 最大显示按钮数量(不包括下拉菜单)
maxVisibleButtons: {
type: Number,
default: 3,
},
// 搜索框占位符
searchPlaceholder: {
type: String,
default: $t('common.searchPlaceholder'),
},
});
const emit = defineEmits(['search', 'button-click', 'buttonClick']);
const emit = defineEmits(['search', 'buttonClick']);
// 搜索值
const searchValue = ref('');
// 计算显示的按钮
const visibleButtons = computed(() => {
return props.buttons.slice(0, props.maxVisibleButtons);
});
// 计算下拉菜单中的按钮
const dropdownButtons = computed(() => {
const dropdownButtonsTemp = props.buttons.slice(props.maxVisibleButtons);
if (dropdownButtonsTemp.length === 0) {
return [];
}
return dropdownButtonsTemp.value.filter((action) => {
return hasPermission([action.permission]);
const filterButtonsByPermission = (buttons) => {
return buttons.filter((button) => {
return !button.permission || hasPermission([button.permission]);
});
};
const visibleButtons = computed(() => {
return filterButtonsByPermission(props.buttons).slice(
0,
props.maxVisibleButtons,
);
});
// 处理搜索
const dropdownButtons = computed(() => {
return filterButtonsByPermission(props.buttons).slice(
props.maxVisibleButtons,
);
});
const emitButtonEvent = (payload) => {
emit('buttonClick', payload);
};
const handleSearch = () => {
emit('search', searchValue.value);
};
@@ -72,9 +73,8 @@ const handleReset = () => {
emit('search', '');
};
// 处理按钮点击
const handleButtonClick = (button) => {
emit('buttonClick', {
emitButtonEvent({
type: 'button',
key: button.key,
button,
@@ -82,9 +82,8 @@ const handleButtonClick = (button) => {
});
};
// 处理下拉菜单点击
const handleDropdownClick = (button) => {
emit('buttonClick', {
emitButtonEvent({
type: 'dropdown',
key: button.key,
button,
@@ -95,34 +94,34 @@ const handleDropdownClick = (button) => {
<template>
<div class="custom-header">
<!-- 左侧搜索区域 -->
<div class="header-left">
<div class="search-container">
<div>
<ElInput
v-model="searchValue"
:placeholder="$t('common.searchPlaceholder')"
class="search-input"
@keyup.enter="handleSearch"
clearable
/>
</div>
<div>
<ElButton type="primary" auto-insert-space @click="handleSearch">
{{ $t('button.query') }}
</ElButton>
</div>
<div>
<ElButton auto-insert-space @click="handleReset">
{{ $t('button.reset') }}
</ElButton>
</div>
<div class="search-group">
<ElInput
v-model="searchValue"
:placeholder="searchPlaceholder"
class="search-input"
clearable
@keyup.enter="handleSearch"
>
<template #prefix>
<ElIcon class="search-prefix">
<Search />
</ElIcon>
</template>
</ElInput>
<ElButton type="primary" auto-insert-space @click="handleSearch">
{{ $t('button.query') }}
</ElButton>
<ElButton auto-insert-space @click="handleReset">
{{ $t('button.reset') }}
</ElButton>
</div>
</div>
<!-- 右侧按钮区域 -->
<div class="header-right">
<!-- 显示的按钮最多3个 -->
<div
v-if="visibleButtons.length > 0 || dropdownButtons.length > 0"
class="header-right"
>
<template
v-for="(button, index) in visibleButtons"
:key="button.key || index"
@@ -138,14 +137,13 @@ const handleDropdownClick = (button) => {
</ElButton>
</template>
<!-- 下拉菜单隐藏的按钮 -->
<ElDropdown
v-if="dropdownButtons.length > 0"
@command="handleDropdownClick"
>
<ElButton>
{{ $t('button.more')
}}<ElIcon class="el-icon--right"><ArrowDown /></ElIcon>
{{ $t('button.more') }}
<ElIcon class="el-icon--right"><ArrowDown /></ElIcon>
</ElButton>
<template #dropdown>
<ElDropdownMenu>
@@ -158,7 +156,7 @@ const handleDropdownClick = (button) => {
<ElIcon v-if="button.icon">
<component :is="button.icon" />
</ElIcon>
<span style="margin-left: 8px">{{ button.text }}</span>
<span class="dropdown-label">{{ button.text }}</span>
</ElDropdownItem>
</ElDropdownMenu>
</template>
@@ -168,54 +166,109 @@ const handleDropdownClick = (button) => {
</template>
<style scoped>
/* 响应式设计 */
@media (max-width: 768px) {
.custom-header {
flex-direction: column;
gap: 16px;
padding: 12px 16px;
}
.header-left,
.header-right {
justify-content: center;
width: 100%;
}
.search-container {
width: 100%;
}
.header-right {
flex-wrap: wrap;
}
}
.custom-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
}
.header-left {
.header-left,
.header-right {
display: flex;
align-items: center;
min-width: 0;
}
.search-container {
.header-left {
flex: 1;
}
.search-group {
display: flex;
flex: 1;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.search-input {
width: 300px;
border-radius: 4px;
width: min(360px, 100%);
}
.search-input :deep(.el-input__wrapper) {
min-height: 40px;
background: hsl(var(--surface-contrast-soft) / 0.92);
border-radius: 14px;
box-shadow:
inset 0 1px 0 hsl(var(--glass-border) / 0.44),
0 10px 24px -24px hsl(var(--foreground) / 0.24);
}
.search-input :deep(.el-input__wrapper:hover),
.search-input :deep(.el-input__wrapper.is-focus) {
background: hsl(var(--surface-subtle) / 0.98);
box-shadow:
inset 0 1px 0 hsl(var(--glass-border) / 0.56),
0 0 0 3px hsl(var(--primary) / 0.08),
0 12px 24px -24px hsl(var(--foreground) / 0.24);
}
.search-prefix {
color: hsl(var(--text-muted));
}
.header-right {
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
justify-content: flex-end;
gap: 12px;
align-items: center;
}
.header-right :deep(.el-button),
.search-group :deep(.el-button) {
min-width: 64px;
height: 32px;
min-height: 32px;
padding-inline: 18px;
border-radius: 10px;
border-color: transparent;
box-shadow: none;
}
.header-right :deep(.el-button:not(.el-button--primary)),
.search-group :deep(.el-button:not(.el-button--primary)) {
color: hsl(var(--text-strong));
background: hsl(var(--surface-contrast-soft) / 0.86);
border-color: transparent;
box-shadow:
inset 0 1px 0 hsl(var(--glass-border) / 0.34),
0 10px 24px -24px hsl(var(--foreground) / 0.2);
}
.header-right :deep(.el-button--primary),
.search-group :deep(.el-button--primary) {
box-shadow: 0 16px 28px -22px hsl(var(--primary) / 0.48);
}
.dropdown-label {
margin-left: 8px;
}
@media (max-width: 768px) {
.custom-header {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.header-right {
justify-content: flex-start;
}
.search-input {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,227 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue';
import { computed, useSlots } from 'vue';
interface Props {
contentPadding?: number | string;
dense?: boolean;
stickyToolbar?: boolean;
surface?: 'panel' | 'subtle';
}
const props = withDefaults(defineProps<Props>(), {
contentPadding: 0,
dense: false,
stickyToolbar: false,
surface: 'panel',
});
const slots = useSlots();
const hasToolbar = computed(() => Boolean(slots.filters || slots.actions));
const contentStyle = computed((): CSSProperties => {
const padding =
typeof props.contentPadding === 'number'
? `${props.contentPadding}px`
: props.contentPadding;
return padding ? { '--list-page-shell-content-padding': padding } : {};
});
</script>
<template>
<section
:class="[
'list-page-shell',
`is-${surface}`,
{
'is-dense': dense,
},
]"
>
<div
v-if="hasToolbar"
:class="[
'list-page-shell__toolbar',
{
'is-sticky': stickyToolbar,
},
]"
>
<div v-if="$slots.filters" class="list-page-shell__filters">
<slot name="filters"></slot>
</div>
<div v-if="$slots.actions" class="list-page-shell__actions">
<slot name="actions"></slot>
</div>
</div>
<div class="list-page-shell__content" :style="contentStyle">
<slot></slot>
<slot name="empty"></slot>
</div>
</section>
</template>
<style scoped>
.list-page-shell {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
gap: 16px;
}
.list-page-shell.is-dense {
gap: 12px;
}
.list-page-shell__toolbar {
position: relative;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 8px 6px 2px;
background: transparent;
border: none;
border-radius: 0;
}
.list-page-shell__toolbar.is-sticky {
position: sticky;
top: 12px;
z-index: 5;
padding: 10px 10px 6px;
background: hsl(var(--glass-tint) / 0.7);
border: 1px solid hsl(var(--glass-border) / 0.44);
border-radius: 20px;
box-shadow: var(--shadow-toolbar);
backdrop-filter: blur(var(--glass-blur)) saturate(155%);
}
.list-page-shell__filters,
.list-page-shell__actions {
min-width: 0;
}
.list-page-shell__filters {
flex: 1;
}
.list-page-shell__content {
--list-page-shell-content-padding: 0px;
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding: var(--list-page-shell-content-padding);
background: hsl(var(--surface-panel));
border: none;
border-radius: 24px;
box-shadow: var(--shadow-subtle);
}
.list-page-shell.is-subtle .list-page-shell__content {
background: hsl(var(--surface-contrast-soft));
box-shadow: none;
}
.list-page-shell__content :deep(.page-data-container) {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.list-page-shell__content :deep(.el-empty) {
margin: auto;
}
.list-page-shell__content :deep(.el-table) {
--el-table-bg-color: transparent;
--el-table-tr-bg-color: transparent;
--el-table-header-bg-color: hsl(var(--table-header-bg));
--el-table-border-color: hsl(var(--table-row-border));
--el-table-current-row-bg-color: hsl(var(--table-row-hover));
--el-fill-color-blank: transparent;
background: transparent;
}
.list-page-shell__content :deep(.el-table--border),
.list-page-shell__content :deep(.el-table__inner-wrapper),
.list-page-shell__content :deep(.el-table__header-wrapper),
.list-page-shell__content :deep(.el-table__body-wrapper) {
border: none;
}
.list-page-shell__content :deep(.el-table__inner-wrapper::before),
.list-page-shell__content :deep(.el-table--border::before),
.list-page-shell__content :deep(.el-table--border::after) {
display: none;
}
.list-page-shell__content :deep(.el-table th.el-table__cell) {
padding: 12px 0;
font-size: 13px;
font-weight: 600;
color: hsl(var(--text-strong));
background: hsl(var(--table-header-bg) / 0.86);
border-bottom: 1px solid hsl(var(--divider-faint) / 0.38);
}
.list-page-shell__content :deep(.el-table th.el-table__cell:first-child) {
border-top-left-radius: 12px;
}
.list-page-shell__content :deep(.el-table th.el-table__cell:last-child) {
border-top-right-radius: 12px;
}
.list-page-shell__content :deep(.el-table td.el-table__cell),
.list-page-shell__content :deep(.el-table--border .el-table__cell) {
padding: 14px 0;
border-right: none;
border-bottom: 1px solid hsl(var(--divider-faint) / 0.46);
}
.list-page-shell__content :deep(.el-table__row:hover > td.el-table__cell) {
background: hsl(var(--table-row-hover)) !important;
}
.list-page-shell__content :deep(.el-tag) {
border-color: transparent;
box-shadow: none;
}
.list-page-shell__content :deep(.el-pagination) {
padding: 0 2px;
}
.list-page-shell__content :deep(.el-pagination button),
.list-page-shell__content :deep(.el-pager li) {
border-radius: 10px;
}
.list-page-shell__toolbar :deep(.custom-header) {
width: 100%;
}
@media (max-width: 768px) {
.list-page-shell {
gap: 12px;
}
.list-page-shell__toolbar {
flex-direction: column;
align-items: stretch;
padding: 2px 0 0;
}
.list-page-shell__actions {
width: 100%;
}
}
</style>

View File

@@ -23,7 +23,7 @@ const props = withDefaults(defineProps<PageDataProps>(), {
// 响应式数据
const pageList = ref([]);
const loading = ref(false);
const queryParams = ref({});
const queryParams = ref<Record<string, any>>({});
const pageInfo = reactive({
pageNumber: 1,
@@ -32,7 +32,7 @@ const pageInfo = reactive({
});
// 模拟 API 调用 - 这里需要根据你的实际 API 调用方式调整
const doGet = async (params: any) => {
const doGet = async (params: Record<string, any>) => {
loading.value = true;
try {
// 这里替换为你的实际 API 调用
@@ -76,7 +76,7 @@ const handleCurrentChange = (newPage: number) => {
};
// 暴露给父组件的方法 (替代 useImperativeHandle)
const setQuery = (newQueryParams: string) => {
const setQuery = (newQueryParams: Record<string, any>) => {
pageInfo.pageNumber = 1;
pageInfo.pageSize = props.pageSize;
queryParams.value = newQueryParams;
@@ -85,6 +85,7 @@ const setQuery = (newQueryParams: string) => {
// 暴露方法给父组件
defineExpose({
reload: getPageList,
setQuery,
});
@@ -106,10 +107,13 @@ onMounted(() => {
<template>
<div class="page-data-container" v-loading="loading">
<template v-if="pageList.length > 0">
<div>
<div class="page-data-container__body">
<slot :page-list="pageList"></slot>
</div>
<div v-if="pageInfo.total > pageInfo.pageSize" class="mx-auto mt-8 w-fit">
<div
v-if="pageInfo.total > pageInfo.pageSize"
class="page-data-container__pagination mx-auto mt-6 w-fit"
>
<ElPagination
v-model:current-page="pageInfo.pageNumber"
v-model:page-size="pageInfo.pageSize"
@@ -121,9 +125,36 @@ onMounted(() => {
/>
</div>
</template>
<ElEmpty
:image="`/empty${preferences.theme.mode === 'dark' ? '-dark' : ''}.png`"
v-else
/>
<slot v-else name="empty">
<ElEmpty
:image="`/empty${preferences.theme.mode === 'dark' ? '-dark' : ''}.png`"
/>
</slot>
</div>
</template>
<style scoped>
.page-data-container {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding: 18px 20px 18px;
}
.page-data-container__body {
flex: 1;
min-height: 0;
}
.page-data-container__pagination {
flex-shrink: 0;
padding-top: 4px;
}
@media (max-width: 768px) {
.page-data-container {
padding: 14px 14px 12px;
}
}
</style>

View File

@@ -25,6 +25,18 @@ const props = defineProps({
default: () => ['image/gif', 'image/jpeg', 'image/png', 'image/webp'],
},
modelValue: { type: String, default: '' },
shape: {
type: String,
default: 'circle',
},
size: {
type: Number,
default: 100,
},
theme: {
type: String,
default: 'default',
},
});
const emit = defineEmits(['success', 'update:modelValue']);
@@ -77,6 +89,11 @@ const beforeAvatarUpload: UploadProps['beforeUpload'] = (rawFile) => {
<template>
<ElUpload
class="avatar-uploader"
:class="[
`avatar-uploader--${props.theme}`,
{ 'avatar-uploader--rounded': props.shape === 'rounded' },
]"
:style="{ '--avatar-size': `${props.size}px` }"
:action="`${apiURL}${props.action}`"
:headers="headers"
:show-file-list="false"
@@ -96,30 +113,65 @@ const beforeAvatarUpload: UploadProps['beforeUpload'] = (rawFile) => {
<style scoped>
.avatar-uploader .avatar {
display: block;
width: 100px;
height: 100px;
width: var(--avatar-size);
height: var(--avatar-size);
}
</style>
<style>
.avatar-uploader .el-upload {
width: var(--avatar-size);
height: var(--avatar-size);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
cursor: pointer;
border: 1px solid #e6e9ee;
border: 1px solid hsl(var(--input) / 0.92);
border-radius: 50%;
transition: var(--el-transition-duration-fast);
background: hsl(var(--modal-surface-strong) / 0.92);
box-shadow:
inset 0 1px 0 hsl(0 0% 100% / 0.72),
0 16px 28px -24px hsl(var(--foreground) / 0.24);
transition:
transform var(--el-transition-duration-fast),
border-color var(--el-transition-duration-fast),
box-shadow var(--el-transition-duration-fast);
}
.avatar-uploader .el-upload:hover {
border-color: var(--el-color-primary);
border-color: hsl(var(--primary) / 0.52);
transform: translateY(-1px);
box-shadow:
inset 0 1px 0 hsl(0 0% 100% / 0.76),
0 20px 36px -26px hsl(var(--modal-preview-glow) / 0.3);
}
.avatar-uploader--soft-panel .el-upload {
background:
radial-gradient(
circle at top,
hsl(var(--modal-preview-glow) / 0.14),
transparent 60%
),
linear-gradient(
145deg,
hsl(var(--modal-preview-surface) / 0.98),
hsl(var(--modal-preview-surface-strong) / 0.9)
);
border-color: hsl(var(--modal-preview-border) / 0.9);
}
.avatar-uploader--rounded .el-upload {
border-radius: 26px;
}
.el-icon.avatar-uploader-icon {
width: 100px;
height: 100px;
width: var(--avatar-size);
height: var(--avatar-size);
font-size: 28px;
color: var(--el-text-color-secondary);
color: hsl(var(--text-muted));
text-align: center;
}
</style>