Files
Docker/uniapp/youjiala/src/components/admin-list/admin-list.vue
T
2026-10-13 17:02:14 +08:00

421 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>