490 lines
13 KiB
Vue

<template>
<div>
<!-- 横幅 -->
<component
:is="banner.dismissable ? 'DismissableBanner' : 'AlertBanner'"
v-if="banner"
v-bind="banner"
class="mb-4"
>
<Button v-if="banner.button" v-bind="banner.button" class="ml-auto" />
</component>
<div class="flex items-center justify-between">
<slot name="header-left" v-bind="context">
<div v-if="showControls" class="flex items-center space-x-2">
<TextInput
placeholder="搜索"
class="max-w-[20rem]"
:debounce="500"
v-model="searchQuery"
>
<template #prefix>
<i-lucide-search class="h-4 w-4 text-gray-500" />
</template>
<template #suffix>
<span class="text-sm text-gray-500" v-if="searchQuery">
{{ searchQuerySummary }}
</span>
</template>
</TextInput>
<ObjectListFilters
v-if="filterControls.length"
:filterControls="filterControls"
@update:filter="onFilterControlChange"
/>
</div>
<div v-else></div>
</slot>
<div class="ml-2 flex shrink-0 items-center space-x-2">
<slot name="header-right" v-bind="context" />
<Tooltip
v-if="options.experimental"
text="这是一个实验性功能"
>
<div class="rounded-md bg-purple-100 p-1.5">
<i-lucide-flask-conical class="h-4 w-4 text-purple-500" />
</div>
</Tooltip>
<Tooltip v-if="options.documentation" text="查看文档">
<div class="rounded-md bg-gray-100 p-1.5">
<a :href="options.documentation" target="_blank">
<i-lucide-help-circle class="h-4 w-4" />
</a>
</div>
</Tooltip>
<Button
label="刷新"
v-if="$list"
@click="$list.reload()"
:loading="isLoading"
>
<template #icon>
<Tooltip text="刷新">
<i-lucide-refresh-ccw class="h-4 w-4" />
</Tooltip>
</template>
</Button>
<Dropdown v-if="moreActions.length" :options="moreActions">
<Button>
<FeatherIcon name="more-horizontal" class="h-4 w-4" />
</Button>
</Dropdown>
<ActionButton
v-for="button in actions"
v-bind="button"
:key="button.label"
:context="context"
/>
<ActionButton v-bind="secondaryAction" :context="context" />
<ActionButton v-bind="primaryAction" :context="context" />
</div>
</div>
<div class="mt-3 min-h-0 flex-1 overflow-y-auto">
<ListView
:columns="columns"
:rows="filteredRows"
:options="{
selectable: this.options.selectable || false,
onRowClick: this.options.onRowClick
? (row) => this.options.onRowClick(row, context)
: null,
getRowRoute: this.options.route
? (row) => this.options.route(row)
: null,
rowHeight: this.options.rowHeight,
emptyState: {},
}"
row-key="name"
@update:selections="(e) => this.$emit('update:selections', e)"
>
<template v-if="options.groupHeader" #group-header="{ group }">
<component :is="options.groupHeader({ ...context, group })" />
</template>
<template #cell="{ item, row, column }">
<ObjectListCell
:class="[
column == columns[0] ? ' text-gray-900' : ' text-gray-700',
]"
:row="row"
:column="column"
:context="context"
/>
</template>
</ListView>
<div class="px-5" v-if="filteredRows.length === 0">
<div
class="text-center text-sm leading-10 text-gray-500"
v-if="isLoading"
>
加载中...
</div>
<div v-else-if="$list?.list?.error" class="py-4 text-center">
<ErrorMessage :message="$list.list.error" />
</div>
<div v-else class="text-center text-sm leading-10 text-gray-500">
{{ emptyStateMessage }}
</div>
</div>
<div class="px-2 py-2 text-right" v-if="$list">
<Button
v-if="$list.next && $list.hasNextPage"
@click="$list.next()"
:loading="isLoading"
>
加载更多
</Button>
</div>
</div>
</div>
</template>
<script>
import { reactive } from 'vue';
import { throttle } from '../utils/throttle';
import DismissableBanner from './DismissableBanner.vue';
import AlertBanner from './AlertBanner.vue';
import ActionButton from './ActionButton.vue';
import ObjectListCell from './ObjectListCell.vue';
import ObjectListFilters from './ObjectListFilters.vue';
import {
ListView,
ListHeader,
ListRow,
TextInput,
Tooltip,
ErrorMessage,
} from 'jingrow-ui';
let subscribed = {};
export default {
name: 'ObjectList',
props: ['options'],
emits: ['update:selections'],
components: {
AlertBanner,
DismissableBanner,
ActionButton,
ObjectListCell,
ObjectListFilters,
ListView,
ListHeader,
ListRow,
TextInput,
Tooltip,
ErrorMessage,
},
data() {
return {
searchQuery: '',
};
},
watch: {
searchQuery(value) {
if (this.options.searchField && this.$list?.list) {
if (value) {
this.$list.update({
filters: {
...this.$list.filters,
[this.options.searchField]: ['like', `%${value.toLowerCase()}%`],
},
start: 0,
pageLength: this.options.pageLength || 20,
});
} else {
this.$list.update({
filters: {
...this.$list.filters,
[this.options.searchField]: undefined,
},
start: 0,
pageLength: this.options.pageLength || 20,
});
}
this.$list.reload();
}
},
},
resources: {
list() {
if (this.options.data) return;
if (this.options.list) return;
if (this.options.resource) {
return this.options.resource(this.context);
}
return {
type: 'list',
cache: [
'ObjectList',
this.options.pagetype || this.options.url,
this.options.filters,
],
url: this.options.url || null,
pagetype: this.options.pagetype,
pageLength: this.options.pageLength || 20,
fields: [
'name',
...(this.options.fields || []),
...this.options.columns.map((column) => column.fieldname),
],
filters: this.options.filters || {},
orderBy: this.options.orderBy,
auto: true,
onError: (e) => {
if (this.$list.data) {
this.$list.data = [];
}
},
};
},
},
beforeUpdate() {
if (this.$list?.list) {
const filters = this.$list.list?.params?.filters || {};
for (let control of this.filterControls) {
if (control.value !== filters[control.fieldname]) {
control.value = filters[control.fieldname];
}
}
}
},
mounted() {
if (this.options.data) return;
if (this.options.list) {
const resource = this.$list.list || this.$list;
if (!resource.fetched && !resource.loading && this.$list.auto != false) {
resource.fetch();
}
}
if (this.options.pagetype) {
const pagetype = this.options.pagetype;
if (subscribed[pagetype]) return;
this.$socket.emit('pagetype_subscribe', pagetype);
subscribed[pagetype] = true;
const throttledReload = throttle(this.$list.reload, 5000);
this.$socket.on('list_update', (data) => {
const names = (this.$list.data || []).map((d) => d.name);
if (data.pagetype === pagetype && names.includes(data.name)) {
throttledReload();
}
});
}
},
beforeUnmount() {
if (this.options.pagetype) {
const pagetype = this.options.pagetype;
this.$socket.emit('pagetype_unsubscribe', pagetype);
subscribed[pagetype] = false;
}
},
computed: {
$list() {
if (this.$resources.list) return this.$resources.list;
if (this.options.list) {
if (typeof this.options.list === 'function') {
return this.options.list(this.options.context);
}
return this.options.list;
}
},
columns() {
let columns = [];
for (let column of this.options.columns || []) {
if (column.condition && !column.condition(this.context)) continue;
columns.push({
...column,
label: column.label,
key: column.fieldname,
align: column.align || 'left',
});
}
if (this.options.rowActions) {
columns.push({
label: '',
key: '__actions',
type: 'Actions',
width: '100px',
align: 'right',
actions: (row) => this.options.rowActions({ ...this.context, row }),
});
}
return columns;
},
rows() {
if (this.options.data) {
return this.options.data(this.context);
}
return this.$list.data || [];
},
filteredRows() {
if (this.options.searchField || !this.searchQuery) return this.rows;
let query = this.searchQuery.toLowerCase();
return this.rows
.map((row) => {
if (row.rows && row.group) {
// 分组
let filteredRows = row.rows.filter((row) =>
this.filterRow(query, row),
);
if (filteredRows.length) {
return {
...row,
rows: row.rows.filter((row) => this.filterRow(query, row)),
};
}
}
if (this.filterRow(query, row)) {
return row;
}
return false;
})
.filter(Boolean);
},
searchQuerySummary() {
if (this.options.searchField) return;
let summary;
if (this.filteredRows.length === 0) {
summary = '无结果';
} else if (this.filteredRows[0].rows) {
let total = this.rows.reduce(
(acc, group) => acc + group.rows.length,
0,
);
let filtered = this.filteredRows.reduce(
(acc, group) => acc + group.rows.length,
0,
);
summary = `${filtered} / ${total}`;
} else {
summary = `${this.filteredRows.length} / ${this.rows.length}`;
}
return summary;
},
filterControls() {
if (!this.options.filterControls) return [];
let controls = this.options.filterControls(this.context);
return controls
.filter((control) => control.fieldname)
.map((control) => {
return reactive({ ...control, value: control.default || undefined });
});
},
actions() {
if (!this.options.actions) return [];
let actions = this.options.actions(this.context);
return actions.filter((action) => {
if (action.condition) {
return action.condition(this.context);
}
return true;
});
},
moreActions() {
if (!this.options.moreActions) return [];
const actions = this.options.moreActions(this.context);
return actions.filter((action) => {
if (action.condition) {
return action.condition(this.context);
}
return true;
});
},
primaryAction() {
if (!this.options.primaryAction) return null;
let props = this.options.primaryAction(this.context);
if (!props) return null;
return props;
},
secondaryAction() {
if (!this.options.secondaryAction) return null;
let props = this.options.secondaryAction(this.context);
if (!props) return null;
return props;
},
context() {
return {
...this.options.context,
listResource: this.$list,
};
},
isLoading() {
if (this.options.data) return false;
return this.$list.list?.loading || this.$list.loading;
},
showControls() {
return (
(this.searchQuery ||
this.rows.length > 5 ||
this.filterControls.length) &&
!this.options.hideControls
);
},
emptyStateMessage() {
return this.options.emptyStateMessage || '未找到结果';
},
banner() {
if (this.options.banner) {
return this.options.banner(this.context);
}
},
},
methods: {
filterRow(query, row) {
let values = this.options.columns.map((column) => {
let value = row[column.fieldname];
if (column.deploys) {
value = column.format(value, row);
}
return value;
});
for (let value of values) {
if (value && value.toLowerCase?.().includes(query)) {
return true;
}
}
return false;
},
onFilterControlChange(control) {
// 如果提供了资源,则直接更新参数
// 否则更新列表资源中的过滤器
//
// 注意:这需要在列表资源中实现 makeParams 方法才能工作
// 在 makeParams 中,如果存在参数则返回,这样旧参数不会覆盖我们正在设置的参数
//
// 如果向 options 提供了 `updateFilters` 函数,它将被调用并传入更新后的过滤器
// 当我们不使用任何标准资源但仍想更新过滤器时,这很有用
if (this.options.resource && !this.$list.filters) {
const params = {
...this.$list.params,
[control.fieldname]: control.value,
};
this.$list.update({ params });
this.$list.reload();
} else if (this.options.updateFilters) {
this.options.updateFilters({
[control.fieldname]: control.value,
});
} else {
let filters = { ...this.$list.filters };
for (let c of this.filterControls) {
filters[c.fieldname] = c.value;
}
this.$list.update({
filters,
start: 0,
pageLength: this.options.pageLength || 20,
});
this.$list.reload();
}
},
},
};
</script>