ow
This commit is contained in:
1 parent
3c0d389fcd
commit
32f237c4bf
22 files changed
+3351
-24
No files matched your search
@@ -0,0 +1,432 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 管理端列表页模板:z-paging + iOS/微信设置式卡片 + 筛选入口 + 长按多选
|
||||
*
|
||||
* 这是"移植模式不移植框架"的主要落点:旧 WebVue 的 SearchCard(876 行)
|
||||
* 面向桌面多列网格,列配置、打印、排序表头、行选择一整套;
|
||||
* 手机上真正要保留的只有三件事——分页取数、条件筛选、选中若干条做动作。
|
||||
*
|
||||
* 视觉走 iOS/微信「设置」式:白卡片 + 左固定标签列右跟值,
|
||||
* 不再用「标签靠左 / 值靠右」两端对齐(那会在中间拉出一大片空白,很廉价)。
|
||||
*
|
||||
* 与 useAdminList 的分工:
|
||||
* - useAdminList 管"条件与请求"(纯判定,node 下可测);
|
||||
* - 本组件管"翻页、下拉、渲染、选中",不认识 service,也不认识权限;
|
||||
* - 权限按钮一律由页面通过插槽给,组件内不做任何 canXxx 判定,
|
||||
* 否则每个域都要改一遍这个公共组件。
|
||||
*/
|
||||
import type { PropType } from "vue";
|
||||
import type {
|
||||
AdminColumn,
|
||||
AdminFilterDraftItem,
|
||||
AdminLoadResult,
|
||||
AdminRow,
|
||||
} from "@/admin/admin-types";
|
||||
import { useLocale } from "uview-pro";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import {
|
||||
cellText,
|
||||
pickTitleColumn,
|
||||
restColumns,
|
||||
rowKeyOf,
|
||||
} from "@/admin/admin-column";
|
||||
|
||||
/** 列表取数:只给页码与每页条数,条件由上层闭包注入(见 useAdminList.query) */
|
||||
type AdminListLoader = (page: number, pageSize: number) => Promise<AdminLoadResult>;
|
||||
|
||||
const props = defineProps({
|
||||
/** 顶栏标题;空字符串表示页面自己画头部 */
|
||||
navTitle: { type: String, default: "" },
|
||||
showNavBack: { type: Boolean, default: true },
|
||||
/** 卡片显示项(旧表格列的等价物) */
|
||||
columns: { type: Array as PropType<AdminColumn[]>, required: true },
|
||||
loader: { type: Function as PropType<AdminListLoader>, required: true },
|
||||
pageSize: { type: Number, default: 20 },
|
||||
/** 行唯一标识的字段名;留空取 columns[0].prop,再取不到就用下标 */
|
||||
rowKey: { type: String, default: "" },
|
||||
/** 卡片主标题字段名;留空取 columns[0] */
|
||||
titleKey: { type: String, default: "" },
|
||||
keyword: { type: String, default: "" },
|
||||
filterCount: { type: Number, default: 0 },
|
||||
filterVisible: { type: Boolean, default: false },
|
||||
filterItems: {
|
||||
type: Array as PropType<AdminFilterDraftItem[]>,
|
||||
default: () => [],
|
||||
},
|
||||
/** 开启后长按卡片进入多选,底部出现操作条(操作条内容走 actions 插槽) */
|
||||
selectable: { type: Boolean, default: false },
|
||||
/** 页面每变一次就重新拉第一页(筛选生效、删除/保存后的刷新都走这里) */
|
||||
searchToken: { type: Number, default: 0 },
|
||||
/**
|
||||
* 是否在挂载时自动查询。默认 false:
|
||||
* 列表页的取数要排在登录守卫之后(见 useAuthGuard 的 onAuthorized),
|
||||
* 否则未登录时会先打一发必然 401 的请求,错误弹窗和跳转一起出现。
|
||||
*/
|
||||
auto: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:keyword", value: string): void;
|
||||
(e: "search"): void;
|
||||
(e: "open-filter"): void;
|
||||
(e: "close-filter"): void;
|
||||
(e: "apply-filter", items: AdminFilterDraftItem[]): void;
|
||||
(e: "clear-filter"): void;
|
||||
(e: "row-tap", row: AdminRow): void;
|
||||
(e: "selection-change", rows: AdminRow[]): void;
|
||||
}>();
|
||||
|
||||
const { t } = useLocale();
|
||||
const paging = ref<any>(null);
|
||||
const rows = ref<AdminRow[]>([]);
|
||||
const selectedKeys = ref<string[]>([]);
|
||||
const selectMode = ref(false);
|
||||
|
||||
const keywordModel = computed({
|
||||
get: () => props.keyword,
|
||||
set: (value: string) => emit("update:keyword", value),
|
||||
});
|
||||
|
||||
const titleColumn = computed(() => pickTitleColumn(props.columns, props.titleKey));
|
||||
const bodyColumns = computed(() => restColumns(props.columns, props.titleKey));
|
||||
|
||||
function keyOf(row: AdminRow): string {
|
||||
const fallback = props.columns.length ? props.columns[0].prop : "";
|
||||
return rowKeyOf(row, props.rowKey || fallback, rows.value.indexOf(row));
|
||||
}
|
||||
|
||||
function isSelected(row: AdminRow): boolean {
|
||||
return selectedKeys.value.includes(keyOf(row));
|
||||
}
|
||||
|
||||
const selectedRows = computed(() =>
|
||||
rows.value.filter(row => selectedKeys.value.includes(keyOf(row))),
|
||||
);
|
||||
|
||||
const allSelected = computed(
|
||||
() => rows.value.length > 0 && selectedRows.value.length === rows.value.length,
|
||||
);
|
||||
|
||||
function clearSelection(): void {
|
||||
selectedKeys.value = [];
|
||||
selectMode.value = false;
|
||||
emit("selection-change", []);
|
||||
}
|
||||
|
||||
function toggleSelect(row: AdminRow): void {
|
||||
const key = keyOf(row);
|
||||
const next = selectedKeys.value.filter(item => item !== key);
|
||||
// filter 后长度没变说明原本没选中,那就加上
|
||||
if (next.length === selectedKeys.value.length) {
|
||||
next.push(key);
|
||||
}
|
||||
selectedKeys.value = next;
|
||||
emit("selection-change", rows.value.filter(item => next.includes(keyOf(item))));
|
||||
}
|
||||
|
||||
function toggleSelectAll(): void {
|
||||
if (allSelected.value) {
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
selectedKeys.value = rows.value.map(row => keyOf(row));
|
||||
emit("selection-change", [...rows.value]);
|
||||
}
|
||||
|
||||
function onLongPress(row: AdminRow): void {
|
||||
if (!props.selectable) {
|
||||
return;
|
||||
}
|
||||
selectMode.value = true;
|
||||
toggleSelect(row);
|
||||
}
|
||||
|
||||
async function onQuery(page: number, pageSize: number): Promise<void> {
|
||||
try {
|
||||
const result = await props.loader(page, pageSize);
|
||||
if (page === 1) {
|
||||
clearSelection();
|
||||
}
|
||||
paging.value?.completeByTotal(result.rows, result.total);
|
||||
}
|
||||
catch {
|
||||
// 提示已由 http.interceptor 与全局错误边界统一弹过,这里只需让 z-paging 结束转圈
|
||||
paging.value?.complete(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 条件变了 / 数据变更后刷新:reload 会回到第 1 页并重新触发 @query
|
||||
watch(
|
||||
() => props.searchToken,
|
||||
() => {
|
||||
paging.value?.reload();
|
||||
},
|
||||
);
|
||||
|
||||
function refresh(): void {
|
||||
paging.value?.reload();
|
||||
}
|
||||
|
||||
defineExpose({ refresh, clearSelection, selectedRows });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="admin-list">
|
||||
<z-paging ref="paging" v-model="rows" :auto="auto" :default-page-size="pageSize" :refresher-enabled="true"
|
||||
:loading-more-enabled="true" :empty-view-text="t('AdminPage.emptyText')" @query="onQuery">
|
||||
<template #top>
|
||||
<u-navbar v-if="navTitle" :title="navTitle" :is-back="showNavBack" :is-fixed="false" :immersive="false"
|
||||
back-icon-name="arrow-leftward" />
|
||||
<view class="toolbar">
|
||||
<view class="toolbar-search">
|
||||
<u-search v-model="keywordModel" :placeholder="t('AdminPage.searchPlaceholder')"
|
||||
:show-action="false" :animation="false" @search="emit('search')" @clear="emit('search')" />
|
||||
</view>
|
||||
<view class="filter-entry" :class="{ 'is-active': filterCount }" @click="emit('open-filter')">
|
||||
<u-icon name="list" size="40" />
|
||||
<text v-if="filterCount" class="filter-badge">
|
||||
{{ filterCount }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="$slots.toolbar" class="toolbar-extra">
|
||||
<slot name="toolbar" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view v-for="row in rows" :key="keyOf(row)" class="card"
|
||||
:class="{ 'is-picking': selectMode, 'is-selected': isSelected(row) }"
|
||||
@click="selectMode ? toggleSelect(row) : emit('row-tap', row)" @longpress="onLongPress(row)">
|
||||
<view v-if="selectMode" class="card-check" :class="{ 'is-on': isSelected(row) }">
|
||||
<u-icon v-if="isSelected(row)" name="checkbox-mark" size="24" color="#ffffff" />
|
||||
</view>
|
||||
<view class="card-main">
|
||||
<view class="card-head">
|
||||
<text class="card-title">
|
||||
{{ cellText(row, titleColumn as AdminColumn) }}
|
||||
</text>
|
||||
<u-icon v-if="!selectMode" name="arrow-right" size="24" color="#c4c8cf" />
|
||||
</view>
|
||||
<slot name="card" :row="row">
|
||||
<view class="card-meta">
|
||||
<view v-for="column in bodyColumns" :key="column.prop" class="meta-line">
|
||||
<text class="meta-label">
|
||||
{{ column.label }}
|
||||
</text>
|
||||
<text class="meta-value">
|
||||
{{ cellText(row, column) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</slot>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<template #bottom>
|
||||
<view v-if="selectMode && selectable" class="action-bar">
|
||||
<view class="action-info" @click="toggleSelectAll">
|
||||
<text class="action-toggle">
|
||||
{{ allSelected ? t("AdminPage.unselectAllBut") : t("AdminPage.selectAllBut") }}
|
||||
</text>
|
||||
<text class="action-count">
|
||||
{{ t("AdminPage.selectedText", { count: selectedRows.length }) }}
|
||||
</text>
|
||||
</view>
|
||||
<slot name="actions" :rows="selectedRows" />
|
||||
<view class="action-cancel" @click="clearSelection">
|
||||
<text>
|
||||
{{ t("AdminPage.cancelBut") }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</z-paging>
|
||||
|
||||
<admin-filter-drawer :show="filterVisible" :items="filterItems"
|
||||
@apply="(items: AdminFilterDraftItem[]) => emit('apply-filter', items)" @close="emit('close-filter')"
|
||||
@clear-all="emit('clear-filter')" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.admin-list {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background-color: $u-bg-color;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 16rpx 24rpx;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.toolbar-search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filter-entry {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
margin-left: 12rpx;
|
||||
color: $u-tips-color;
|
||||
background-color: $u-bg-color;
|
||||
border-radius: 16rpx;
|
||||
|
||||
&.is-active {
|
||||
color: $u-type-primary;
|
||||
background-color: $u-type-primary-light;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-badge {
|
||||
position: absolute;
|
||||
top: -6rpx;
|
||||
right: -6rpx;
|
||||
min-width: 30rpx;
|
||||
height: 30rpx;
|
||||
padding: 0 6rpx;
|
||||
font-size: 20rpx;
|
||||
line-height: 30rpx;
|
||||
color: #ffffff;
|
||||
text-align: center;
|
||||
background-color: $u-type-error;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
/* 页面自定义的次级工具(如"新增")走白底一行,与搜索栏区分 */
|
||||
.toolbar-extra {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 4rpx 24rpx 16rpx;
|
||||
background-color: #ffffff;
|
||||
border-bottom: 1rpx solid $u-border-color;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
padding: 24rpx 28rpx;
|
||||
margin: 20rpx 24rpx;
|
||||
background-color: #ffffff;
|
||||
border-radius: 20rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(17, 24, 39, 0.05);
|
||||
|
||||
&.is-selected {
|
||||
box-shadow: inset 0 0 0 2rpx $u-type-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.card-check {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
margin-top: 4rpx;
|
||||
margin-right: 18rpx;
|
||||
border: 2rpx solid #d3d6db;
|
||||
border-radius: 50%;
|
||||
|
||||
&.is-on {
|
||||
background-color: $u-type-primary;
|
||||
border-color: $u-type-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.card-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: $u-main-color;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
|
||||
.meta-line {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
|
||||
/* 左固定标签列 + 右跟值(左对齐),避免两端对齐拉出中间空白 */
|
||||
.meta-label {
|
||||
flex-shrink: 0;
|
||||
width: 132rpx;
|
||||
font-size: 24rpx;
|
||||
color: $u-tips-color;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 26rpx;
|
||||
color: $u-content-color;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 28rpx;
|
||||
background-color: #ffffff;
|
||||
border-top: 1rpx solid $u-border-color;
|
||||
}
|
||||
|
||||
.action-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-toggle {
|
||||
font-size: 26rpx;
|
||||
color: $u-type-primary;
|
||||
}
|
||||
|
||||
.action-count {
|
||||
margin-top: 4rpx;
|
||||
font-size: 22rpx;
|
||||
color: $u-tips-color;
|
||||
}
|
||||
|
||||
.action-cancel {
|
||||
padding: 10rpx 24rpx;
|
||||
font-size: 26rpx;
|
||||
color: $u-content-color;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user