ow
This commit is contained in:
1 parent
3c0d389fcd
commit
32f237c4bf
22 files changed
+3351
-24
No files matched your search
@@ -0,0 +1,313 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 管理端筛选抽屉:一条 BindOption 一行控件,点"确定"才把条件交回上层。
|
||||
*
|
||||
* 为什么不用 v-model 直接改上层数组:
|
||||
* 抽屉里改的是 local(打开时从 items 拷一份),取消就整份丢掉。
|
||||
* 旧 SearchCriteria 是改一下立即查询,手机上极易误触发请求。
|
||||
*
|
||||
* 为什么日期不用 uview 组件:
|
||||
* uview-pro 0.7.1 没有 u-datetime-picker(只有 u-calendar / u-picker),
|
||||
* 而原生 <picker mode="date|time"> 在 H5、App 两端都能用且零依赖,
|
||||
* 所以这里直接用它,值形态由 @/admin/admin-date 补齐成后端要的字符串。
|
||||
*/
|
||||
import type { PropType } from "vue";
|
||||
import type { AdminFilterDraftItem } from "@/admin/admin-types";
|
||||
import { useLocale } from "uview-pro";
|
||||
import { ref, watch } from "vue";
|
||||
import { datePart } from "@/admin/admin-date";
|
||||
import {
|
||||
ADMIN_FILTER_QUICK_DAYS,
|
||||
clearDraftItem,
|
||||
draftKind,
|
||||
isDraftFilled,
|
||||
isDraftValueChecked,
|
||||
isRangeDraft,
|
||||
setDraftQuickRange,
|
||||
toggleDraftValue,
|
||||
} from "@/admin/admin-filter";
|
||||
|
||||
const props = defineProps({
|
||||
/** 抽屉是否展开:靠它触发"重新取快照",不用 v-if 挂载(u-popup 自己要做进出动画) */
|
||||
show: { type: Boolean, default: false },
|
||||
/** 打开时的初始内容,来自 useAdminList().draft */
|
||||
items: {
|
||||
type: Array as PropType<AdminFilterDraftItem[]>,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "apply", items: AdminFilterDraftItem[]): void;
|
||||
(e: "clearAll"): void;
|
||||
(e: "close"): void;
|
||||
}>();
|
||||
|
||||
const { t } = useLocale();
|
||||
const local = ref<AdminFilterDraftItem[]>([]);
|
||||
|
||||
/** 深拷一条:values 是数组,共享引用会让"取消"也污染上层 */
|
||||
function cloneItem(item: AdminFilterDraftItem): AdminFilterDraftItem {
|
||||
return {
|
||||
...item,
|
||||
values: [...item.values],
|
||||
choices: item.choices.map(choice => ({ ...choice })),
|
||||
};
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(visible: boolean) => {
|
||||
if (visible) {
|
||||
local.value = props.items.map(cloneItem);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function onDateChange(
|
||||
item: AdminFilterDraftItem,
|
||||
slot: "value" | "value1",
|
||||
event: any,
|
||||
): void {
|
||||
// 原生 picker 产出的就是 yyyy-MM-dd,直接存字符串;
|
||||
// DateTime 型要补成当天的起止秒,那一步在 applyDraft 里做(判定层集中,模板不管口径)
|
||||
item[slot] = String(event?.detail?.value ?? "");
|
||||
}
|
||||
|
||||
/** 布尔型筛选项:草稿里用 "true" / "" 表示,u-switch 只认布尔 */
|
||||
function boolValue(item: AdminFilterDraftItem): boolean {
|
||||
return item.value === "true";
|
||||
}
|
||||
|
||||
function setBoolValue(item: AdminFilterDraftItem, checked: boolean): void {
|
||||
item.value = checked ? "true" : "";
|
||||
}
|
||||
|
||||
function isActive(item: AdminFilterDraftItem, choice: string): boolean {
|
||||
if (draftKind(item) === "multiple") {
|
||||
return isDraftValueChecked(item, choice);
|
||||
}
|
||||
return item.value === choice;
|
||||
}
|
||||
|
||||
function pick(item: AdminFilterDraftItem, choice: string): void {
|
||||
if (draftKind(item) === "multiple") {
|
||||
toggleDraftValue(item, choice);
|
||||
return;
|
||||
}
|
||||
// 单选再点一次取消:手机上没有"清除"按钮可点,只能靠再点一次
|
||||
item.value = item.value === choice ? "" : choice;
|
||||
}
|
||||
|
||||
function confirm(): void {
|
||||
emit("apply", local.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<u-popup :model-value="props.show" mode="bottom" length="86%" border-radius="20" :safe-area-inset-bottom="true"
|
||||
:closeable="true" close-icon-pos="top-right" @close="emit('close')">
|
||||
<view class="drawer">
|
||||
<view class="drawer-title">
|
||||
<text>
|
||||
{{ t("AdminPage.filterTitle") }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<scroll-view class="drawer-body" scroll-y :show-scrollbar="false">
|
||||
<view v-for="item in local" :key="item.key" class="field">
|
||||
<view class="field-head">
|
||||
<text class="field-label">
|
||||
{{ item.label }}
|
||||
</text>
|
||||
<text v-if="isDraftFilled(item)" class="field-clear" @click="clearDraftItem(item)">
|
||||
{{ t("AdminPage.clearBut") }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 文本 / 数字 -->
|
||||
<view v-if="draftKind(item) === 'text' || draftKind(item) === 'number'" class="line">
|
||||
<u-input v-model="item.value" :type="draftKind(item) === 'number' ? 'number' : 'text'"
|
||||
:placeholder="item.label" :border="true" clearable />
|
||||
<template v-if="isRangeDraft(item)">
|
||||
<text class="line-sep">
|
||||
~
|
||||
</text>
|
||||
<u-input v-model="item.value1" type="number" :placeholder="t('AdminPage.endPlaceholder')"
|
||||
:border="true" />
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<!-- 日期 / 日期时间:原生 picker 的取值是字符串,不需要再格式化 -->
|
||||
<view v-else-if="draftKind(item) === 'date' || draftKind(item) === 'datetime'" class="dates">
|
||||
<view class="line">
|
||||
<picker mode="date" :value="datePart(item.value)"
|
||||
@change="onDateChange(item, 'value', $event)">
|
||||
<view class="picker-box">
|
||||
<text :class="{ 'is-empty': !item.value }">
|
||||
{{ item.value || t("AdminPage.startPlaceholder") }}
|
||||
</text>
|
||||
</view>
|
||||
</picker>
|
||||
<template v-if="isRangeDraft(item)">
|
||||
<text class="line-sep">
|
||||
~
|
||||
</text>
|
||||
<picker mode="date" :value="datePart(item.value1)"
|
||||
@change="onDateChange(item, 'value1', $event)">
|
||||
<view class="picker-box">
|
||||
<text :class="{ 'is-empty': !item.value1 }">
|
||||
{{ item.value1 || t("AdminPage.endPlaceholder") }}
|
||||
</text>
|
||||
</view>
|
||||
</picker>
|
||||
</template>
|
||||
</view>
|
||||
<view v-if="isRangeDraft(item)" class="quick">
|
||||
<view v-for="days in ADMIN_FILTER_QUICK_DAYS" :key="days" class="choice"
|
||||
@click="setDraftQuickRange(item, days)">
|
||||
<text>
|
||||
{{ t("AdminPage.quickDays", { days }) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 布尔型 -->
|
||||
<view v-else-if="draftKind(item) === 'switch'" class="line">
|
||||
<u-switch :model-value="boolValue(item)" @change="setBoolValue(item, $event)" />
|
||||
</view>
|
||||
|
||||
<!-- 候选项:单选与多选都用 chip,避免 u-radio/u-checkbox 的 name-value 歧义 -->
|
||||
<view v-else class="choices">
|
||||
<view v-for="choice in item.choices" :key="choice.value" class="choice"
|
||||
:class="{ 'is-active': isActive(item, choice.value) }" @click="pick(item, choice.value)">
|
||||
<text>
|
||||
{{ choice.label }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="!item.choices.length" class="line">
|
||||
<u-input v-model="item.value" :placeholder="item.label" :border="true" clearable />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="drawer-foot">
|
||||
<u-button shape="circle" :text="t('AdminPage.clearAllBut')" @click="emit('clearAll')" />
|
||||
<u-button shape="circle" :text="t('AdminPage.cancelBut')" @click="emit('close')" />
|
||||
<u-button type="primary" shape="circle" :text="t('AdminPage.confirmBut')" @click="confirm" />
|
||||
</view>
|
||||
</view>
|
||||
</u-popup>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.drawer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 0 28rpx;
|
||||
background-color: #ffffff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
padding: 32rpx 0 12rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: $u-main-color;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.drawer-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
padding: 20rpx 0 8rpx;
|
||||
border-bottom: 1rpx solid $u-border-color;
|
||||
}
|
||||
|
||||
.field-head {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14rpx;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
color: $u-main-color;
|
||||
}
|
||||
|
||||
.field-clear {
|
||||
font-size: 24rpx;
|
||||
color: $u-type-primary;
|
||||
}
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.line-sep {
|
||||
margin: 0 12rpx;
|
||||
color: $u-tips-color;
|
||||
}
|
||||
|
||||
.dates {
|
||||
padding-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.picker-box {
|
||||
min-width: 240rpx;
|
||||
padding: 14rpx 20rpx;
|
||||
font-size: 26rpx;
|
||||
color: $u-main-color;
|
||||
background-color: $u-bg-color;
|
||||
border-radius: 12rpx;
|
||||
|
||||
.is-empty {
|
||||
color: $u-tips-color;
|
||||
}
|
||||
}
|
||||
|
||||
.quick,
|
||||
.choices {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.choice {
|
||||
padding: 12rpx 28rpx;
|
||||
margin: 8rpx 14rpx 8rpx 0;
|
||||
font-size: 24rpx;
|
||||
color: $u-content-color;
|
||||
background-color: $u-bg-color;
|
||||
border: 1rpx solid transparent;
|
||||
border-radius: 999rpx;
|
||||
|
||||
&.is-active {
|
||||
color: $u-type-primary;
|
||||
background-color: $u-type-primary-light;
|
||||
border-color: $u-type-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-foot {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx 0 16rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,324 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 管理端详情页模板:浅色英雄卡 + iOS 分组行 + 明细插槽 + 动作条
|
||||
*
|
||||
* 对应旧 WebVue 的 ft-page / ft-hero / ft-card / ft-field 一套 CSS 结构
|
||||
* (GroupOperation、UserOperation 这些页面本来就已经是单列卡片流,
|
||||
* 所以详情页属于"翻译"而不是"重写",机械部分抽到这里)。
|
||||
*
|
||||
* 视觉走 iOS/微信「设置」式:分组卡片内一行一个字段,标签靠左、值靠右,
|
||||
* 行间细线分隔;空值统一显示"暂无"而不是刺眼的"-"。
|
||||
*
|
||||
* 组件不判权限:按钮由页面按 canAmend/canDelete/canCreate 组好 actions 数组传进来。
|
||||
* 这样公共组件不需要知道任何一个域的权限名,换域移植时零改动。
|
||||
*/
|
||||
import type { PropType } from "vue";
|
||||
import type {
|
||||
AdminFormAction,
|
||||
AdminFormField,
|
||||
AdminFormSection,
|
||||
AdminRow,
|
||||
} from "@/admin/admin-types";
|
||||
import { useLocale } from "uview-pro";
|
||||
import { computed } from "vue";
|
||||
import { EMPTY_CELL_TEXT } from "@/admin/admin-column";
|
||||
import { datePart, formatDate } from "@/admin/admin-date";
|
||||
|
||||
const props = defineProps({
|
||||
navTitle: { type: String, default: "" },
|
||||
showNavBack: { type: Boolean, default: true },
|
||||
/** 顶部卡的主标题(旧 ft-hero-name) */
|
||||
heroTitle: { type: String, default: "" },
|
||||
/** 顶部卡的副信息(旧 ft-chip),如"账号 xxx" */
|
||||
heroChips: { type: Array as PropType<string[]>, default: () => [] },
|
||||
sections: { type: Array as PropType<AdminFormSection[]>, default: () => [] },
|
||||
/** 页面持有的数据对象(SecurityUserData.SecurityUser 之类),字段直接读写它 */
|
||||
model: { type: Object as PropType<AdminRow>, default: () => ({}) },
|
||||
readonly: { type: Boolean, default: true },
|
||||
actions: { type: Array as PropType<AdminFormAction[]>, default: () => [] },
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "back"): void;
|
||||
(e: "action", action: AdminFormAction): void;
|
||||
/** 字段改写走事件回给页面:组件不 mutate props.model(vue/no-mutating-props) */
|
||||
(e: "field-change", prop: string, value: unknown): void;
|
||||
}>();
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
/** 只按当前态筛按钮:编辑态不该还能点"修改",只读态不该还能点"保存" */
|
||||
function actionVisible(action: AdminFormAction): boolean {
|
||||
if (!action.when) {
|
||||
return true;
|
||||
}
|
||||
return action.when === (props.readonly ? "view" : "edit");
|
||||
}
|
||||
|
||||
const visibleActions = computed(() => props.actions.filter(actionVisible));
|
||||
|
||||
function rawValue(field: AdminFormField): unknown {
|
||||
return props.model?.[field.prop];
|
||||
}
|
||||
|
||||
/** 只读态的显示文本:display 优先,其次日期格式化,最后原值;空值统一"未设置" */
|
||||
function displayValue(field: AdminFormField): string {
|
||||
const empty = t("AdminPage.notSetTip");
|
||||
if (field.display) {
|
||||
return field.display(props.model) || empty;
|
||||
}
|
||||
const raw = rawValue(field);
|
||||
if (raw === null || raw === undefined || raw === "") {
|
||||
return empty;
|
||||
}
|
||||
if (field.date) {
|
||||
return formatDate(raw as Date, field.date === "datetime") || empty;
|
||||
}
|
||||
return String(raw);
|
||||
}
|
||||
|
||||
/** 编辑态控件的值:统一成字符串,switch 例外(u-switch 只吃布尔) */
|
||||
function inputValue(field: AdminFormField): string {
|
||||
const raw = rawValue(field);
|
||||
if (raw === null || raw === undefined) {
|
||||
return "";
|
||||
}
|
||||
return String(raw);
|
||||
}
|
||||
|
||||
function switchValue(field: AdminFormField): boolean {
|
||||
const raw = rawValue(field);
|
||||
return raw === true || raw === "Y" || raw === "true" || raw === 1;
|
||||
}
|
||||
|
||||
function setInputValue(field: AdminFormField, value: unknown): void {
|
||||
emit("field-change", field.prop, value);
|
||||
}
|
||||
|
||||
function onDateChange(field: AdminFormField, event: any): void {
|
||||
setInputValue(field, String(event?.detail?.value ?? ""));
|
||||
}
|
||||
|
||||
function onTapAction(action: AdminFormAction): void {
|
||||
if (action.confirm) {
|
||||
uni.showModal({
|
||||
title: t("AdminPage.confirmDeleteTitle"),
|
||||
success: (res: any) => {
|
||||
if (res.confirm) {
|
||||
emit("action", action);
|
||||
}
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
emit("action", action);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="admin-form">
|
||||
<u-navbar v-if="navTitle" :title="navTitle" :is-back="showNavBack" :is-fixed="false" :immersive="false"
|
||||
back-icon-name="arrow-leftward" @left-click="emit('back')" />
|
||||
|
||||
<scroll-view class="form-body" scroll-y :show-scrollbar="false">
|
||||
<view class="hero">
|
||||
<text class="hero-title">
|
||||
{{ heroTitle || EMPTY_CELL_TEXT }}
|
||||
</text>
|
||||
<view v-if="heroChips.length" class="hero-chips">
|
||||
<text v-for="chip in heroChips" :key="chip" class="hero-chip">
|
||||
{{ chip }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-for="section in sections" :key="section.title" class="group">
|
||||
<text class="group-title">
|
||||
{{ section.title }}
|
||||
</text>
|
||||
<view class="group-card">
|
||||
<view v-for="field in section.fields" :key="field.prop" class="row">
|
||||
<view class="row-label">
|
||||
<text>
|
||||
{{ field.label }}
|
||||
</text>
|
||||
<text v-if="field.required && !readonly" class="row-required">
|
||||
*
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 只读:值右对齐,与标签同一行(iOS 设置式) -->
|
||||
<text v-if="readonly" class="row-value">
|
||||
{{ displayValue(field) }}
|
||||
</text>
|
||||
|
||||
<!-- 编辑:控件右对齐占位 -->
|
||||
<view v-else class="row-control">
|
||||
<u-switch v-if="field.type === 'switch'" :model-value="switchValue(field)"
|
||||
:disabled="field.lock" @change="setInputValue(field, $event)" />
|
||||
<u-textarea v-else-if="field.type === 'textarea'" :model-value="inputValue(field)"
|
||||
:disabled="field.lock" :placeholder="field.placeholder || field.label"
|
||||
:maxlength="field.maxlength ?? 200" @input="setInputValue(field, $event)" />
|
||||
<picker v-else-if="field.type === 'date'" mode="date" :value="datePart(inputValue(field))"
|
||||
:disabled="field.lock" @change="onDateChange(field, $event)">
|
||||
<view class="picker-box" :class="{ 'is-empty': !inputValue(field) }">
|
||||
<text>
|
||||
{{ inputValue(field) || field.placeholder || t('AdminPage.notSetTip') }}
|
||||
</text>
|
||||
<u-icon name="arrow-right" size="24" color="#c4c8cf" />
|
||||
</view>
|
||||
</picker>
|
||||
<!-- uview-pro 的 u-input border 是布尔,不是旧 uview 的 'surround' 字符串 -->
|
||||
<u-input v-else :model-value="inputValue(field)" :type="field.type || 'text'"
|
||||
:disabled="field.lock" :border="false" input-align="right"
|
||||
:placeholder="field.placeholder || field.label" :maxlength="field.maxlength ?? -1"
|
||||
@input="setInputValue(field, $event)" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 明细区(角色、成员、附件等子表)由页面自己画 -->
|
||||
<slot name="extra" :readonly="readonly" />
|
||||
</scroll-view>
|
||||
|
||||
<view v-if="visibleActions.length" class="foot">
|
||||
<u-button v-for="action in visibleActions" :key="action.key" shape="circle"
|
||||
:type="action.tone === 'error' ? 'error' : 'primary'" :plain="action.tone !== 'error'"
|
||||
:text="action.label" @click="onTapAction(action)" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.admin-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background-color: $u-bg-color;
|
||||
}
|
||||
|
||||
.form-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 32rpx 28rpx;
|
||||
margin-bottom: 28rpx;
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, $u-type-primary-dark, $u-type-primary);
|
||||
border-radius: 24rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(5, 150, 105, 0.22);
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
.hero-chips {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.hero-chip {
|
||||
padding: 6rpx 18rpx;
|
||||
margin: 8rpx 12rpx 0 0;
|
||||
font-size: 22rpx;
|
||||
background-color: rgba(255, 255, 255, 0.22);
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.group {
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
padding: 0 12rpx 12rpx;
|
||||
font-size: 24rpx;
|
||||
color: $u-tips-color;
|
||||
}
|
||||
|
||||
.group-card {
|
||||
padding: 0 28rpx;
|
||||
overflow: hidden;
|
||||
background-color: #ffffff;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
/* 一行一字段:标签靠左、值靠右,行间细线(最后一条不画线) */
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 92rpx;
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 1rpx solid $u-border-color;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.row-label {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 28rpx;
|
||||
color: $u-main-color;
|
||||
}
|
||||
|
||||
.row-required {
|
||||
margin-left: 6rpx;
|
||||
color: $u-type-error;
|
||||
}
|
||||
|
||||
.row-value {
|
||||
max-width: 60%;
|
||||
font-size: 28rpx;
|
||||
color: $u-content-color;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.row-control {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.picker-box {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
font-size: 28rpx;
|
||||
color: $u-main-color;
|
||||
|
||||
&.is-empty {
|
||||
color: $u-tips-color;
|
||||
}
|
||||
|
||||
text {
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 20rpx 28rpx;
|
||||
background-color: #ffffff;
|
||||
border-top: 1rpx solid $u-border-color;
|
||||
gap: 20rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user