This commit is contained in:
owenchen committed 2026-10-13 14:23:10 +08:00
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>