ow
This commit is contained in:
1 parent
a8896cbfb2
commit
282d8ce583
20 files changed
+1256
-524
No files matched your search
@@ -0,0 +1,465 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 通用"弹窗选择器"(底部抽屉):替代旧的跳页选择(useAdminPick → 全页面 enquiry → navigateBack)。
|
||||
*
|
||||
* 对齐旧 WebVue 的 <dragModal> + 内嵌 Enquiry 组件模式:
|
||||
* 在调用方页面内弹出,选完 confirm 回传行数据,不离开当前编辑上下文。
|
||||
* 用户可通过 X / 遮罩 / 取消按钮随时关闭,彻底解决"进去无法返回"的问题。
|
||||
*
|
||||
* 列表用 scroll-view + 手动分页(触底加载更多),避免 z-paging 的 fixed 定位在弹层内冲突。
|
||||
*/
|
||||
import type { PropType } from "vue";
|
||||
import type { AdminRow } from "@/admin/admin-types";
|
||||
import { useLocale } from "uview-pro";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
/** 列定义:精简版,第一列为 title,其余为副信息 */
|
||||
export interface PickColumn {
|
||||
label: string;
|
||||
prop: string;
|
||||
}
|
||||
|
||||
/** 取数函数签名 */
|
||||
type PickLoader = (
|
||||
page: number,
|
||||
pageSize: number,
|
||||
keyword: string,
|
||||
fixedFilters: Record<string, string>,
|
||||
) => Promise<{ rows: AdminRow[]; total: number }>;
|
||||
|
||||
const props = defineProps({
|
||||
/** 弹层是否展开(v-model:visible) */
|
||||
visible: { type: Boolean, default: false },
|
||||
/** 弹窗标题 */
|
||||
title: { type: String, default: "" },
|
||||
/** 显示列:第一列为卡片主标题,其余为副字段 */
|
||||
columns: { type: Array as PropType<PickColumn[]>, required: true },
|
||||
/** 取数函数 */
|
||||
loader: { type: Function as PropType<PickLoader>, required: true },
|
||||
/** 行唯一标识字段 */
|
||||
rowKey: { type: String, required: true },
|
||||
/** 主标题字段(优先于 columns[0].prop) */
|
||||
titleKey: { type: String, default: "" },
|
||||
/** 是否多选 */
|
||||
multiple: { type: Boolean, default: false },
|
||||
/** 固定过滤条件(如 { dependent: "xxx" }),透传给 loader */
|
||||
fixedFilters: { type: Object as PropType<Record<string, string>>, default: () => ({}) },
|
||||
/** 每页条数 */
|
||||
pageSize: { type: Number, default: 20 },
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:visible", value: boolean): void;
|
||||
(e: "close"): void;
|
||||
(e: "confirm", rows: AdminRow[]): void;
|
||||
}>();
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
// ===== 列表状态 =====
|
||||
const rows = ref<AdminRow[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const loading = ref(false);
|
||||
const keyword = ref("");
|
||||
|
||||
// ===== 选择状态 =====
|
||||
const selectedKeys = ref<string[]>([]);
|
||||
const selectedPool = ref<Record<string, AdminRow>>({});
|
||||
|
||||
const titleProp = computed(() => props.titleKey || props.columns[0]?.prop || "");
|
||||
|
||||
const hasMore = computed(() => rows.value.length < total.value);
|
||||
|
||||
const selectedRows = computed(() =>
|
||||
selectedKeys.value.map(key => selectedPool.value[key]).filter(Boolean),
|
||||
);
|
||||
|
||||
function keyOf(row: AdminRow): string {
|
||||
return String(row[props.rowKey] ?? "");
|
||||
}
|
||||
|
||||
function isSelected(row: AdminRow): boolean {
|
||||
return selectedKeys.value.includes(keyOf(row));
|
||||
}
|
||||
|
||||
function getTitle(row: AdminRow): string {
|
||||
return String(row[titleProp.value] ?? "");
|
||||
}
|
||||
|
||||
function getSubtitles(): PickColumn[] {
|
||||
return props.columns.filter(col => col.prop !== titleProp.value);
|
||||
}
|
||||
|
||||
// ===== 数据加载 =====
|
||||
async function loadData(reset = false): Promise<void> {
|
||||
if (loading.value) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
if (reset) {
|
||||
page.value = 1;
|
||||
rows.value = [];
|
||||
total.value = 0;
|
||||
}
|
||||
try {
|
||||
const result = await props.loader(page.value, props.pageSize, keyword.value, props.fixedFilters);
|
||||
if (reset) {
|
||||
rows.value = result.rows;
|
||||
}
|
||||
else {
|
||||
rows.value = [...rows.value, ...result.rows];
|
||||
}
|
||||
total.value = result.total;
|
||||
}
|
||||
catch {
|
||||
// 错误由 http.interceptor 统一提示
|
||||
}
|
||||
finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore(): void {
|
||||
if (!hasMore.value || loading.value) {
|
||||
return;
|
||||
}
|
||||
page.value += 1;
|
||||
loadData();
|
||||
}
|
||||
|
||||
function onSearch(): void {
|
||||
loadData(true);
|
||||
}
|
||||
|
||||
// ===== 选择操作 =====
|
||||
function toggleSelect(row: AdminRow): void {
|
||||
const key = keyOf(row);
|
||||
if (!props.multiple) {
|
||||
// 单选:直接确认
|
||||
emit("confirm", [row]);
|
||||
closePopup();
|
||||
return;
|
||||
}
|
||||
const idx = selectedKeys.value.indexOf(key);
|
||||
const pool = { ...selectedPool.value };
|
||||
if (idx >= 0) {
|
||||
selectedKeys.value = selectedKeys.value.filter(k => k !== key);
|
||||
delete pool[key];
|
||||
}
|
||||
else {
|
||||
selectedKeys.value = [...selectedKeys.value, key];
|
||||
pool[key] = row;
|
||||
}
|
||||
selectedPool.value = pool;
|
||||
}
|
||||
|
||||
function toggleSelectAll(): void {
|
||||
if (!props.multiple) {
|
||||
return;
|
||||
}
|
||||
const visibleKeys = rows.value.map(row => keyOf(row));
|
||||
const pool = { ...selectedPool.value };
|
||||
if (selectedKeys.value.length >= rows.value.length && rows.value.length > 0) {
|
||||
// 取消全选(仅当前可见)
|
||||
selectedKeys.value = selectedKeys.value.filter(k => !visibleKeys.includes(k));
|
||||
for (const key of visibleKeys) {
|
||||
delete pool[key];
|
||||
}
|
||||
}
|
||||
else {
|
||||
for (const row of rows.value) {
|
||||
pool[keyOf(row)] = row;
|
||||
}
|
||||
selectedKeys.value = [...new Set([...selectedKeys.value, ...visibleKeys])];
|
||||
}
|
||||
selectedPool.value = pool;
|
||||
}
|
||||
|
||||
function onConfirm(): void {
|
||||
if (!selectedRows.value.length) {
|
||||
uni.showToast({ title: t("AdminPage.pickNoneTip"), icon: "none" });
|
||||
return;
|
||||
}
|
||||
emit("confirm", selectedRows.value);
|
||||
closePopup();
|
||||
}
|
||||
|
||||
// ===== 生命周期 =====
|
||||
function resetState(): void {
|
||||
rows.value = [];
|
||||
total.value = 0;
|
||||
page.value = 1;
|
||||
keyword.value = "";
|
||||
selectedKeys.value = [];
|
||||
selectedPool.value = {};
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
function closePopup(): void {
|
||||
emit("update:visible", false);
|
||||
emit("close");
|
||||
}
|
||||
|
||||
// 每次展开重置并加载
|
||||
watch(
|
||||
() => props.visible,
|
||||
(on) => {
|
||||
if (on) {
|
||||
resetState();
|
||||
loadData(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<u-popup :model-value="props.visible" mode="bottom" round="24" :safe-area-inset-bottom="true"
|
||||
:close-on-click-overlay="true" @close="closePopup">
|
||||
<view class="pick-popup">
|
||||
<!-- 头部:标题 + 关闭 -->
|
||||
<view class="pick-popup__header">
|
||||
<text class="pick-popup__title">
|
||||
{{ props.title }}
|
||||
</text>
|
||||
<view class="pick-popup__close" @tap="closePopup">
|
||||
<u-icon name="close-circle-fill" size="40" color="#9aa0a6" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<view class="pick-popup__search">
|
||||
<u-search v-model="keyword" :placeholder="t('AdminPage.searchPlaceholder')" :show-action="false"
|
||||
:animation="false" @search="onSearch" @clear="onSearch" />
|
||||
</view>
|
||||
|
||||
<!-- 列表 -->
|
||||
<scroll-view scroll-y class="pick-popup__list" @scrolltolower="loadMore">
|
||||
<!-- 加载中占位(首次) -->
|
||||
<view v-if="loading && !rows.length" class="pick-popup__loading">
|
||||
<u-loading mode="circle" />
|
||||
</view>
|
||||
|
||||
<!-- 空态 -->
|
||||
<view v-else-if="!rows.length" class="pick-popup__empty">
|
||||
<text>{{ t("AdminPage.emptyText") }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 数据行 -->
|
||||
<template v-else>
|
||||
<view v-for="row in rows" :key="keyOf(row)" class="pick-popup__row"
|
||||
:class="{ 'is-selected': isSelected(row) }" @tap="toggleSelect(row)">
|
||||
<!-- 多选勾选框 -->
|
||||
<view v-if="props.multiple" class="pick-popup__check" :class="{ 'is-on': isSelected(row) }">
|
||||
<u-icon v-if="isSelected(row)" name="checkbox-mark" size="24" color="#ffffff" />
|
||||
</view>
|
||||
<view class="pick-popup__content">
|
||||
<text class="pick-popup__main">
|
||||
{{ getTitle(row) }}
|
||||
</text>
|
||||
<view v-for="col in getSubtitles()" :key="col.prop" class="pick-popup__sub">
|
||||
<text class="pick-popup__sub-label">{{ col.label }}</text>
|
||||
<text class="pick-popup__sub-value">{{ row[col.prop] ?? "" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部加载状态 -->
|
||||
<view class="pick-popup__footer-hint">
|
||||
<text v-if="loading">{{ t("AdminPage.pickLoadingMore") }}</text>
|
||||
<text v-else-if="!hasMore">{{ t("AdminPage.pickNoMore") }}</text>
|
||||
</view>
|
||||
</template>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部操作栏(多选时) -->
|
||||
<view v-if="props.multiple" class="pick-popup__actions">
|
||||
<view class="pick-popup__action-left" @tap="toggleSelectAll">
|
||||
<text class="pick-popup__toggle">
|
||||
{{ selectedKeys.length >= rows.length && rows.length > 0 ? t("AdminPage.unselectAllBut") :
|
||||
t("AdminPage.selectAllBut") }}
|
||||
</text>
|
||||
<text class="pick-popup__count">
|
||||
{{ t("AdminPage.selectedText", { count: selectedKeys.length }) }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="pick-popup__action-btns">
|
||||
<view class="pick-popup__btn pick-popup__btn--cancel" @tap="closePopup">
|
||||
<text>{{ t("AdminPage.cancelBut") }}</text>
|
||||
</view>
|
||||
<view class="pick-popup__btn pick-popup__btn--confirm"
|
||||
:class="{ 'is-disabled': !selectedKeys.length }" @tap="onConfirm">
|
||||
<text>{{ t("AdminPage.confirmBut") }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</u-popup>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$brand-green: #2f7d5c;
|
||||
$brand-green-light: #47a678;
|
||||
|
||||
.pick-popup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 80vh;
|
||||
background-color: #f7f8fa;
|
||||
}
|
||||
|
||||
.pick-popup__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 28rpx 32rpx 16rpx;
|
||||
background-color: #ffffff;
|
||||
border-radius: 24rpx 24rpx 0 0;
|
||||
}
|
||||
|
||||
.pick-popup__title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.pick-popup__close {
|
||||
padding: 8rpx;
|
||||
}
|
||||
|
||||
.pick-popup__search {
|
||||
padding: 12rpx 24rpx;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.pick-popup__list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 16rpx 24rpx;
|
||||
}
|
||||
|
||||
.pick-popup__loading,
|
||||
.pick-popup__empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80rpx 0;
|
||||
font-size: 26rpx;
|
||||
color: #9aa0a6;
|
||||
}
|
||||
|
||||
.pick-popup__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 12rpx;
|
||||
padding: 24rpx 28rpx;
|
||||
border-radius: 16rpx;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.pick-popup__row.is-selected {
|
||||
background-color: rgba(47, 125, 92, 0.06);
|
||||
border: 2rpx solid $brand-green;
|
||||
}
|
||||
|
||||
.pick-popup__check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border: 2rpx solid #c4c8cf;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.pick-popup__check.is-on {
|
||||
background-color: $brand-green;
|
||||
border-color: $brand-green;
|
||||
}
|
||||
|
||||
.pick-popup__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pick-popup__main {
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.pick-popup__sub {
|
||||
display: flex;
|
||||
gap: 8rpx;
|
||||
margin-top: 6rpx;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.pick-popup__sub-label {
|
||||
color: #9aa0a6;
|
||||
}
|
||||
|
||||
.pick-popup__sub-value {
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.pick-popup__footer-hint {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 24rpx 0;
|
||||
font-size: 24rpx;
|
||||
color: #9aa0a6;
|
||||
}
|
||||
|
||||
.pick-popup__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20rpx 32rpx;
|
||||
border-top: 2rpx solid #eee;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.pick-popup__action-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.pick-popup__toggle {
|
||||
font-size: 26rpx;
|
||||
color: $brand-green;
|
||||
}
|
||||
|
||||
.pick-popup__count {
|
||||
font-size: 22rpx;
|
||||
color: #9aa0a6;
|
||||
}
|
||||
|
||||
.pick-popup__action-btns {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.pick-popup__btn {
|
||||
padding: 16rpx 40rpx;
|
||||
border-radius: 40rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.pick-popup__btn--cancel {
|
||||
color: #606266;
|
||||
background-color: #f2f3f5;
|
||||
}
|
||||
|
||||
.pick-popup__btn--confirm {
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, $brand-green-light, $brand-green);
|
||||
}
|
||||
|
||||
.pick-popup__btn--confirm.is-disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user