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,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>