This commit is contained in:
owenchen committed 2026-10-17 16:39:54 +08:00
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>