优化pagetype列表页基础组件,支持列表页工具栏两种覆盖接口props和context

This commit is contained in:
jingrow 2025-11-02 17:13:27 +08:00
parent e1b379971e
commit 5113228bc8
2 changed files with 351 additions and 1 deletions

View File

@ -10,7 +10,7 @@
<h2>{{ t(title) }}</h2>
</div>
<div class="header-right">
<!-- 工具栏覆盖组件 -->
<!-- 工具栏覆盖组件 - 支持两种接口context props -->
<component
v-if="toolbarComponent"
:is="toolbarComponent"
@ -28,6 +28,16 @@
router,
t
}"
:entity="entity"
:search-query="searchQuery"
:view-mode="viewMode"
:selected-keys="selectedKeys"
:loading="loading"
@update:search-query="searchQuery = $event"
@update:view-mode="viewMode = $event"
@reload="reload"
@create="createRecordHandler"
@delete-selected="handleDeleteSelected"
/>
<GenericListPageToolBar
v-else

View File

@ -0,0 +1,340 @@
<template>
<div class="toolbar">
<div class="filters">
<n-input
v-model:value="searchQueryModel"
:placeholder="t('Search')"
clearable
style="width: 200px"
/>
</div>
<div class="view-toggle">
<button
class="toggle-btn"
:class="{ active: viewModeModel === 'list' }"
@click="viewModeModel = 'list'"
:title="t('List View')"
>
<i class="fa fa-list"></i>
</button>
<button
class="toggle-btn"
:class="{ active: viewModeModel === 'card' }"
@click="viewModeModel = 'card'"
:title="t('Card View')"
>
<i class="fa fa-th-large"></i>
</button>
</div>
<button class="refresh-btn" @click="reload" :disabled="loading">
<i :class="loading ? 'fa fa-spinner fa-spin' : 'fa fa-refresh'"></i>
</button>
<n-dropdown
trigger="click"
:options="menuOptions"
@select="onMenuSelect"
>
<button class="refresh-btn" :disabled="loading || importing" :title="t('More')">
<i :class="importing ? 'fa fa-ellipsis-h fa-spin' : 'fa fa-ellipsis-h'"></i>
</button>
</n-dropdown>
<button
v-if="selectedKeys.length === 0"
class="create-btn"
@click="createRecordHandler"
:disabled="loading"
>
<i class="fa fa-plus"></i>
{{ t('Create') }}
</button>
<button
v-else
class="delete-btn"
@click="handleDeleteSelected"
:disabled="loading"
>
<i class="fa fa-trash"></i>
{{ t('Delete Selected') }} ({{ selectedKeys.length }})
</button>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { NInput, NDropdown, useDialog } from 'naive-ui'
import { t } from '@/shared/i18n'
import { importLocalNodes } from '@/shared/api/nodes'
interface Props {
entity: string
searchQuery: string
viewMode: 'card' | 'list'
selectedKeys: string[]
loading: boolean
}
interface Emits {
(e: 'update:searchQuery', value: string): void
(e: 'update:viewMode', value: 'card' | 'list'): void
(e: 'reload'): void
(e: 'create'): void
(e: 'delete-selected'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const dialog = useDialog()
const importing = ref(false)
// 使 computed
const searchQueryModel = computed({
get: () => props.searchQuery,
set: (value) => emit('update:searchQuery', value)
})
const viewModeModel = computed({
get: () => props.viewMode,
set: (value) => emit('update:viewMode', value)
})
function reload() {
emit('reload')
}
function createRecordHandler() {
emit('create')
}
function handleDeleteSelected() {
emit('delete-selected')
}
//
const menuOptions = computed(() => [
{ label: t('Import local nodes'), key: 'import-local-nodes' }
])
function onMenuSelect(key: string) {
if (key === 'import-local-nodes') {
handleImportLocalNodes()
}
}
//
async function handleImportLocalNodes() {
if (importing.value) return
importing.value = true
try {
const res = await importLocalNodes()
emit('reload')
if (!res?.success) {
dialog.error({ title: t('Error'), content: t('Import failed') })
return
}
const matchedCount = res.matched || 0
const importedCount = res.imported || 0
const skippedExisting = res.skipped_existing || 0
if (matchedCount === 0) {
dialog.info({ title: t('Info'), content: t('No local node definitions found') })
} else if (importedCount === 0 && skippedExisting > 0) {
dialog.info({ title: t('Info'), content: t('All local nodes already exist') })
} else if (importedCount > 0) {
dialog.success({ title: t('Success'), content: `${t('Imported')} ${importedCount} ${t('nodes')}` })
} else {
dialog.info({ title: t('Info'), content: t('No new local nodes found') })
}
} catch (e) {
console.error('Import local nodes error:', e)
dialog.error({ title: t('Error'), content: t('Import failed') })
} finally {
importing.value = false
}
}
</script>
<style scoped>
.toolbar {
display: flex;
align-items: center;
gap: 12px;
}
.filters {
display: flex;
gap: 8px;
align-items: center;
}
/* 视图切换按钮 */
.view-toggle {
display: flex;
background: #f8fafc;
border-radius: 8px;
padding: 2px;
border: 1px solid #e2e8f0;
}
/* 切换按钮 - 使用灰色系 */
.toggle-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
color: #6b7280;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.toggle-btn:hover {
background: #f1f5f9;
color: #475569;
}
.toggle-btn.active {
background: #e2e8f0;
color: #1e293b;
}
/* 刷新按钮 */
.refresh-btn {
width: 36px;
height: 36px;
border: none;
border-radius: 8px;
background: #f8fafc;
color: #64748b;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.refresh-btn:hover {
background: #e2e8f0;
color: #475569;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.refresh-btn:active {
transform: translateY(0);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
.refresh-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.refresh-btn:disabled:hover {
background: #f8fafc;
color: #64748b;
transform: none;
box-shadow: none;
}
/* 新建按钮 - 使用柔和的品牌色系,与整体风格协调 */
.create-btn {
height: 36px;
padding: 0 16px;
border: 1px solid #1fc76f;
border-radius: 8px;
background: #e6f8f0;
color: #0d684b;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 500;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.create-btn:hover {
background: #dcfce7;
border-color: #1fc76f;
color: #166534;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(31, 199, 111, 0.15);
}
.create-btn:active {
background: #1fc76f;
border-color: #1fc76f;
color: white;
transform: translateY(0);
box-shadow: 0 1px 4px rgba(31, 199, 111, 0.2);
}
.create-btn:disabled {
background: #f1f5f9;
border-color: #e2e8f0;
color: #94a3b8;
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.create-btn:disabled:hover {
background: #f1f5f9;
border-color: #e2e8f0;
color: #94a3b8;
transform: none;
box-shadow: none;
}
.create-btn i {
font-size: 12px;
}
/* 删除选中按钮 */
.delete-btn {
background: #ef4444;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.delete-btn:hover {
background: #dc2626;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
}
.delete-btn:active {
transform: translateY(0);
box-shadow: 0 2px 6px rgba(239, 68, 68, 0.3);
}
.delete-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.delete-btn i {
font-size: 12px;
}
</style>