421 lines
13 KiB
Vue
421 lines
13 KiB
Vue
<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>
|
||
<!-- 页面可在此放次级动作按钮(如"新增"),排在筛选右侧、只留图标 -->
|
||
<slot name="toolbar-actions" />
|
||
</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;
|
||
}
|
||
|
||
.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>
|