mirror of
https://github.com/ccmjga/zhilu-admin
synced 2026-04-08 14:37:38 +00:00
重构组件结构,优化导入路径并移除不必要的组件,新增多个模态框组件以支持部门、角色、权限和用户管理功能
This commit is contained in:
69
frontend/src/components/tables/MobileCardList.vue
Normal file
69
frontend/src/components/tables/MobileCardList.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div v-for="(item, index) in items ?? []" :key="getItemKey(item, index)"
|
||||
class="p-4 bg-white rounded-lg shadow relative border border-gray-100">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<!-- 标题区域 -->
|
||||
<div class="font-medium text-gray-900">
|
||||
<slot name="title" :item="item"></slot>
|
||||
</div>
|
||||
<!-- 状态区域 -->
|
||||
<div>
|
||||
<slot name="status" :item="item"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="text-sm text-gray-600 mb-3 space-y-2">
|
||||
<slot name="content" :item="item"></slot>
|
||||
</div>
|
||||
|
||||
<!-- 标签/分类区域 -->
|
||||
<div v-if="$slots.tags" class="flex flex-wrap gap-2 mb-3">
|
||||
<slot name="tags" :item="item"></slot>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮区域 -->
|
||||
<div class="flex justify-between items-center mt-4">
|
||||
<slot name="actions" :item="item"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup generic="T" lang="ts">
|
||||
import { ref } from "vue";
|
||||
|
||||
/** 通用对象类型 */
|
||||
type ItemRecord = Record<string, unknown>;
|
||||
|
||||
const props = defineProps<{
|
||||
/** 数据项数组 */
|
||||
items: T[] | undefined;
|
||||
/** 数据项ID字段名 */
|
||||
idField?: string;
|
||||
/** 数据项唯一键字段名 */
|
||||
keyField?: string;
|
||||
}>();
|
||||
|
||||
/**
|
||||
* 获取数据项的唯一键
|
||||
* @param item 数据项
|
||||
* @param index 索引
|
||||
* @returns 唯一键
|
||||
*/
|
||||
const getItemKey = (item: T, index: number): string | number => {
|
||||
if (props.keyField) {
|
||||
const key = (item as ItemRecord)[props.keyField];
|
||||
if (key !== undefined) return String(key);
|
||||
}
|
||||
|
||||
if (props.idField) {
|
||||
const id = (item as ItemRecord)[props.idField];
|
||||
if (id !== undefined) return String(id);
|
||||
}
|
||||
|
||||
const id = (item as ItemRecord).id;
|
||||
return id !== undefined ? String(id) : index;
|
||||
};
|
||||
</script>
|
||||
108
frontend/src/components/tables/MobileCardListWithCheckbox.vue
Normal file
108
frontend/src/components/tables/MobileCardListWithCheckbox.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div v-for="(item, index) in items ?? []" :key="getItemKey(item, index)"
|
||||
class="p-4 bg-white rounded-lg shadow relative border border-gray-100">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<!-- 标题区域 -->
|
||||
<div class="flex items-center">
|
||||
<input :id="'mobile-checkbox-' + getItemId(item)" :value="getItemId(item)" type="checkbox"
|
||||
v-model="checkedItems"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded-sm focus:ring-blue-500 focus:ring-2 mr-3">
|
||||
<div class="font-medium text-gray-900">
|
||||
<slot name="title" :item="item"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 状态区域 -->
|
||||
<div>
|
||||
<slot name="status" :item="item"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="text-sm text-gray-600 mb-3 space-y-2">
|
||||
<slot name="content" :item="item"></slot>
|
||||
</div>
|
||||
|
||||
<!-- 标签/分类区域 -->
|
||||
<div v-if="$slots.tags" class="flex flex-wrap gap-2 mb-3">
|
||||
<slot name="tags" :item="item"></slot>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮区域 -->
|
||||
<div class="flex justify-between items-center mt-4">
|
||||
<slot name="actions" :item="item"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup generic="T" lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
/** 通用对象类型 */
|
||||
type ItemRecord = Record<string, unknown>;
|
||||
|
||||
const props = defineProps<{
|
||||
/** 数据项数组 */
|
||||
items: T[] | undefined;
|
||||
/** 数据项ID字段名 */
|
||||
idField?: string;
|
||||
/** 数据项唯一键字段名 */
|
||||
keyField?: string;
|
||||
/** 选中项的值数组 */
|
||||
modelValue?: (string | number)[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [checkedItems: (string | number)[]];
|
||||
}>();
|
||||
|
||||
const checkedItems = ref<(string | number)[]>(props.modelValue || []);
|
||||
|
||||
/**
|
||||
* 获取数据项的唯一键
|
||||
* @param item 数据项
|
||||
* @param index 索引
|
||||
* @returns 唯一键
|
||||
*/
|
||||
const getItemKey = (item: T, index: number): string | number => {
|
||||
if (props.keyField) {
|
||||
const key = (item as ItemRecord)[props.keyField];
|
||||
if (key !== undefined) return String(key);
|
||||
}
|
||||
|
||||
const id = getItemId(item);
|
||||
return id !== undefined ? id : index;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取数据项的ID
|
||||
* @param item 数据项
|
||||
* @returns ID值
|
||||
*/
|
||||
const getItemId = (item: T): string | number => {
|
||||
if (props.idField) {
|
||||
return (item as ItemRecord)[props.idField] as string | number;
|
||||
}
|
||||
return (
|
||||
((item as ItemRecord).id as string | number) ||
|
||||
(item as unknown as string | number)
|
||||
);
|
||||
};
|
||||
|
||||
// 监听选中项变化
|
||||
watch(checkedItems, (newVal) => {
|
||||
emit("update:modelValue", newVal);
|
||||
});
|
||||
|
||||
// 监听modelValue变化
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
checkedItems.value = newVal;
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
</script>
|
||||
108
frontend/src/components/tables/TableButton.vue
Normal file
108
frontend/src/components/tables/TableButton.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<button :class="[
|
||||
'flex items-center justify-center gap-x-1 whitespace-nowrap font-medium rounded-lg focus:ring-4 focus:outline-none',
|
||||
sizeClasses,
|
||||
colorClasses,
|
||||
(disabled || (isLoading && !abortable)) ? 'opacity-50 cursor-not-allowed' : '',
|
||||
className
|
||||
]" :disabled="disabled || (isLoading && !abortable)" @click="handleClick" type="button">
|
||||
<StopIcon v-if="isLoading && abortable" :class="iconSizeClasses" />
|
||||
<slot v-else name="icon"></slot>
|
||||
<span>
|
||||
<slot></slot>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { StopIcon } from "@/components/icons";
|
||||
import { computed } from "vue";
|
||||
|
||||
export type ButtonVariant =
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "success"
|
||||
| "danger"
|
||||
| "warning"
|
||||
| "info";
|
||||
export type ButtonSize = "xs" | "sm" | "md" | "lg";
|
||||
|
||||
const props = defineProps<{
|
||||
/** 按钮变体类型 */
|
||||
variant?: ButtonVariant;
|
||||
/** 按钮尺寸 */
|
||||
size?: ButtonSize;
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
/** 自定义CSS类名 */
|
||||
className?: string;
|
||||
/** 是否为移动端尺寸 */
|
||||
isMobile?: boolean;
|
||||
/** 是否处于加载状态 */
|
||||
isLoading?: boolean;
|
||||
/** 是否可中止 */
|
||||
abortable?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [event: MouseEvent];
|
||||
}>();
|
||||
|
||||
/** 按钮颜色样式映射 */
|
||||
const colorClasses = computed(() => {
|
||||
const variants: Record<ButtonVariant, string> = {
|
||||
primary: "text-white bg-blue-700 hover:bg-blue-800 focus:ring-blue-300",
|
||||
secondary:
|
||||
"text-gray-900 bg-white border border-gray-300 hover:bg-gray-100 focus:ring-gray-100",
|
||||
success: "text-white bg-green-700 hover:bg-green-800 focus:ring-green-300",
|
||||
danger: "text-white bg-red-700 hover:bg-red-800 focus:ring-red-300",
|
||||
warning:
|
||||
"text-gray-900 bg-yellow-400 hover:bg-yellow-500 focus:ring-yellow-300",
|
||||
info: "text-white bg-cyan-700 hover:bg-cyan-800 focus:ring-cyan-300",
|
||||
};
|
||||
|
||||
return variants[props.variant || "primary"];
|
||||
});
|
||||
|
||||
/** 按钮尺寸样式映射 */
|
||||
const sizeClasses = computed(() => {
|
||||
// 移动端尺寸
|
||||
if (props.isMobile) {
|
||||
const sizes: Record<ButtonSize, string> = {
|
||||
xs: "text-xs px-2 py-1",
|
||||
sm: "text-xs px-3 py-1.5",
|
||||
md: "text-sm px-3 py-2",
|
||||
lg: "text-sm px-4 py-2.5",
|
||||
};
|
||||
return sizes[props.size || "sm"];
|
||||
}
|
||||
|
||||
// PC端尺寸
|
||||
const sizes: Record<ButtonSize, string> = {
|
||||
xs: "text-xs px-3 py-1.5",
|
||||
sm: "text-sm px-3 py-2",
|
||||
md: "text-sm px-4 py-2.5",
|
||||
lg: "text-base px-5 py-3",
|
||||
};
|
||||
|
||||
return sizes[props.size || "md"];
|
||||
});
|
||||
|
||||
/** 图标尺寸样式映射 */
|
||||
const iconSizeClasses = computed(() => {
|
||||
const sizes: Record<ButtonSize, string> = {
|
||||
xs: "w-3.5 h-3.5",
|
||||
sm: "w-4 h-4",
|
||||
md: "w-4.5 h-4.5",
|
||||
lg: "w-5 h-5",
|
||||
};
|
||||
return sizes[props.size || "md"];
|
||||
});
|
||||
|
||||
/** 处理点击事件 */
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (!props.disabled && !(props.isLoading && !props.abortable)) {
|
||||
emit("click", event);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
130
frontend/src/components/tables/TableFilterForm.vue
Normal file
130
frontend/src/components/tables/TableFilterForm.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center mb-4 gap-y-3 sm:gap-y-0">
|
||||
<form
|
||||
class="grid grid-cols-2 sm:grid-cols-1 w-full min-w-[200px] sm:w-auto gap-2 xs:gap-3 items-stretch xs:items-center">
|
||||
<template v-for="(filter, index) in filters" :key="index">
|
||||
<!-- 输入框类型 -->
|
||||
<div v-if="filter.type === 'input'" class="flex-grow">
|
||||
<label :for="`filter-input-${index}`" class="mb-2 text-sm font-medium text-gray-900 sr-only">{{ filter.label
|
||||
}}</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
|
||||
</div>
|
||||
<input type="search" :id="`filter-input-${index}`" v-model="filterValues[filter.name]"
|
||||
class="block w-full p-2.5 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500"
|
||||
:placeholder="filter.placeholder || ''" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日期范围选择器 -->
|
||||
<div v-else-if="filter.type === 'date-range'" class="flex-grow">
|
||||
<VueDatePicker v-model="filterValues[filter.name]" locale="zh-CN" range
|
||||
:format="filter.format || 'yyyy/MM/dd HH:mm:ss - yyy/MM/dd HH:mm:ss'" :placeholder="filter.placeholder"
|
||||
:enable-time-picker="filter.enableTimePicker !== false" :auto-apply="filter.autoApply !== false" />
|
||||
</div>
|
||||
|
||||
<!-- 选择器 -->
|
||||
<div v-else-if="filter.type === 'select'" class="flex-grow">
|
||||
<select :id="`filter-select-${index}`" v-model="filterValues[filter.name]"
|
||||
class="w-full xs:w-auto bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5">
|
||||
<option v-for="option in filter.options" :key="String(option.value)" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
<button type="submit"
|
||||
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-3 py-2 min-w-[70px] flex items-center justify-center"
|
||||
@click.prevent="handleSearch">
|
||||
<svg class="w-4 h-4 mr-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" />
|
||||
</svg>
|
||||
搜索
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- 额外操作按钮插槽 -->
|
||||
<div class="flex justify-end">
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, watch } from "vue";
|
||||
|
||||
export interface FilterOption {
|
||||
value: string | number | boolean;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface FilterItem {
|
||||
type: "input" | "select" | "date-range";
|
||||
name: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
options?: FilterOption[];
|
||||
format?: string;
|
||||
enableTimePicker?: boolean;
|
||||
autoApply?: boolean;
|
||||
}
|
||||
|
||||
type FilterValues = Record<
|
||||
string,
|
||||
string | number | boolean | Date[] | undefined
|
||||
>;
|
||||
|
||||
const props = defineProps<{
|
||||
filters: FilterItem[];
|
||||
initialValues?: FilterValues;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
search: [values: FilterValues];
|
||||
"update:values": [values: FilterValues];
|
||||
}>();
|
||||
|
||||
// 初始化筛选值
|
||||
const filterValues = reactive<FilterValues>({});
|
||||
|
||||
// 初始化默认值
|
||||
onMounted(() => {
|
||||
// 初始化所有筛选项的默认值
|
||||
for (const filter of props.filters) {
|
||||
if (props.initialValues && props.initialValues[filter.name] !== undefined) {
|
||||
filterValues[filter.name] = props.initialValues[filter.name];
|
||||
} else {
|
||||
// 设置默认值
|
||||
switch (filter.type) {
|
||||
case "input":
|
||||
filterValues[filter.name] = "";
|
||||
break;
|
||||
case "select":
|
||||
filterValues[filter.name] =
|
||||
filter.options && filter.options.length > 0
|
||||
? filter.options[0].value
|
||||
: "";
|
||||
break;
|
||||
case "date-range":
|
||||
filterValues[filter.name] = undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 监听筛选值变化
|
||||
watch(
|
||||
filterValues,
|
||||
(newValues) => {
|
||||
emit("update:values", { ...newValues });
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = () => {
|
||||
emit("search", { ...filterValues });
|
||||
};
|
||||
</script>
|
||||
206
frontend/src/components/tables/TableFormLayout.vue
Normal file
206
frontend/src/components/tables/TableFormLayout.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div class="relative overflow-x-auto shadow-md sm:rounded-lg">
|
||||
<table class="w-full text-sm text-left rtl:text-right text-gray-500">
|
||||
<thead class="text-xs text-gray-700 uppercase bg-gray-50">
|
||||
<tr>
|
||||
<th v-if="hasCheckbox" scope="col" class="p-4 w-4">
|
||||
<div class="flex items-center">
|
||||
<input id="checkbox-all-search" type="checkbox" v-model="allChecked" @change="handleAllCheckedChange"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded-sm focus:ring-blue-500 focus:ring-2">
|
||||
<label for="checkbox-all-search" class="sr-only">checkbox</label>
|
||||
</div>
|
||||
</th>
|
||||
<th v-for="(column, index) in columns" :key="index" scope="col" :class="[
|
||||
'px-6 py-3',
|
||||
column.sortable ? 'cursor-pointer' : '',
|
||||
column.class || ''
|
||||
]" @click="column.sortable ? handleSortClick(column.field) : null">
|
||||
<div class="flex items-center">
|
||||
<span>{{ column.title }}</span>
|
||||
<slot v-if="column.sortable" name="sort-icon" :field="column.field"></slot>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, rowIndex) in items" :key="getItemKey(item, rowIndex)"
|
||||
class="bg-white border-b border-gray-200 hover:bg-gray-50">
|
||||
<td v-if="hasCheckbox" class="w-4 p-4">
|
||||
<div class="flex items-center">
|
||||
<input :id="`checkbox-table-search-${rowIndex}`" :value="getItemId(item)" type="checkbox"
|
||||
v-model="checkedItems"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded-sm focus:ring-blue-500 focus:ring-2">
|
||||
<label :for="`checkbox-table-search-${rowIndex}`" class="sr-only">checkbox</label>
|
||||
</div>
|
||||
</td>
|
||||
<td v-for="(column, colIndex) in columns" :key="colIndex" :class="[
|
||||
'px-6 py-4',
|
||||
column.class || '',
|
||||
colIndex === 0 ? 'font-medium text-gray-900' : ''
|
||||
]">
|
||||
<slot :name="column.field" :item="item" :index="rowIndex">
|
||||
<div class="max-w-sm whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{{ getItemValue(item, column.field) }}
|
||||
</div>
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup generic="T" lang="ts">
|
||||
import { defineEmits, ref, watch } from "vue";
|
||||
|
||||
/**
|
||||
* 表格列配置接口
|
||||
*/
|
||||
export interface Column {
|
||||
/** 列标题 */
|
||||
title: string;
|
||||
/** 数据字段名 */
|
||||
field: string;
|
||||
/** 是否可排序 */
|
||||
sortable?: boolean;
|
||||
/** 自定义CSS类名 */
|
||||
class?: string;
|
||||
}
|
||||
|
||||
/** 通用对象类型 */
|
||||
type ItemRecord = Record<string, unknown>;
|
||||
|
||||
const props = defineProps<{
|
||||
/** 数据项数组 */
|
||||
items: T[];
|
||||
/** 列配置数组 */
|
||||
columns: Column[];
|
||||
/** 是否显示复选框 */
|
||||
hasCheckbox?: boolean;
|
||||
/** 数据项ID字段名 */
|
||||
idField?: string;
|
||||
/** 数据项唯一键字段名 */
|
||||
keyField?: string;
|
||||
/** 选中项的值数组,用于v-model绑定 */
|
||||
modelValue?: (string | number)[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [checkedItems: (string | number)[]];
|
||||
sort: [field: string];
|
||||
"all-checked-change": [checked: boolean];
|
||||
}>();
|
||||
|
||||
const checkedItems = ref<(string | number)[]>(props.modelValue || []);
|
||||
const allChecked = ref(false);
|
||||
|
||||
/**
|
||||
* 获取数据项的唯一键
|
||||
* @param item 数据项
|
||||
* @param index 索引
|
||||
* @returns 唯一键
|
||||
*/
|
||||
const getItemKey = (item: T, index: number): string | number => {
|
||||
if (props.keyField) {
|
||||
const key = (item as ItemRecord)[props.keyField];
|
||||
if (key !== undefined) return String(key);
|
||||
}
|
||||
|
||||
const id = getItemId(item);
|
||||
return id !== undefined ? id : index;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取数据项的ID
|
||||
* @param item 数据项
|
||||
* @returns ID值
|
||||
*/
|
||||
const getItemId = (item: T): string | number => {
|
||||
if (props.idField) {
|
||||
return (item as ItemRecord)[props.idField] as string | number;
|
||||
}
|
||||
return (
|
||||
((item as ItemRecord).id as string | number) ||
|
||||
(item as unknown as string | number)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取数据项的字段值
|
||||
* @param item 数据项
|
||||
* @param field 字段名
|
||||
* @returns 字段值
|
||||
*/
|
||||
const getItemValue = (item: T, field: string): string => {
|
||||
if (!field) return "";
|
||||
|
||||
return String(
|
||||
field
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(obj, key) =>
|
||||
obj &&
|
||||
typeof obj === "object" &&
|
||||
key in (obj as Record<string, unknown>)
|
||||
? (obj as Record<string, unknown>)[key]
|
||||
: "",
|
||||
item as ItemRecord,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理全选/取消全选
|
||||
*/
|
||||
const handleAllCheckedChange = () => {
|
||||
if (allChecked.value) {
|
||||
checkedItems.value = props.items.map(getItemId);
|
||||
} else {
|
||||
checkedItems.value = [];
|
||||
}
|
||||
emit("all-checked-change", allChecked.value);
|
||||
emit("update:modelValue", checkedItems.value);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理排序点击
|
||||
* @param field 排序字段
|
||||
*/
|
||||
const handleSortClick = (field: string) => {
|
||||
emit("sort", field);
|
||||
};
|
||||
|
||||
// 监听选中项变化
|
||||
watch(checkedItems, (newVal) => {
|
||||
emit("update:modelValue", newVal);
|
||||
// 更新全选状态
|
||||
if (props.items.length > 0) {
|
||||
allChecked.value = newVal.length === props.items.length;
|
||||
} else {
|
||||
allChecked.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
// 监听modelValue变化
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
checkedItems.value = newVal;
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// 监听items变化,重置选中状态
|
||||
watch(
|
||||
() => props.items,
|
||||
() => {
|
||||
if (allChecked.value) {
|
||||
checkedItems.value = props.items.map(getItemId);
|
||||
emit("update:modelValue", checkedItems.value);
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
</script>
|
||||
78
frontend/src/components/tables/TablePagination.vue
Normal file
78
frontend/src/components/tables/TablePagination.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<nav class="flex items-center flex-col md:flex-row flex-wrap justify-between py-4 px-3 sm:px-5"
|
||||
aria-label="Table navigation">
|
||||
<span class="text-xs sm:text-sm font-normal text-gray-500 mb-4 md:mb-0 block w-full md:inline md:w-auto">
|
||||
显示
|
||||
<span class="font-semibold text-gray-900">
|
||||
{{ displayRange.start }}-{{ displayRange.end }}
|
||||
</span>
|
||||
共
|
||||
<span class="font-semibold text-gray-900">{{ total }}</span> 条
|
||||
</span>
|
||||
<ul class="inline-flex -space-x-px rtl:space-x-reverse text-sm h-8">
|
||||
<li>
|
||||
<a href="#" @click.prevent="handlePageChangeClick(currentPage - 1)" :class="[
|
||||
'flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-700',
|
||||
{ 'opacity-50 cursor-not-allowed': isFirstPage }
|
||||
]">上一页</a>
|
||||
</li>
|
||||
<li v-for="page in pageNumbers" :key="page">
|
||||
<button @click.prevent="handlePageChangeClick(page)" :class="[
|
||||
'flex items-center justify-center px-3 h-8 leading-tight border border-gray-300 hover:bg-gray-100 hover:text-gray-700',
|
||||
currentPage === page
|
||||
? 'text-blue-600 bg-blue-50 hover:bg-blue-100 hover:text-blue-700 font-medium'
|
||||
: 'text-gray-500 bg-white'
|
||||
]">{{ page }}</button>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<button @click.prevent="handlePageChangeClick(currentPage + 1)" :class="[
|
||||
'flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-700',
|
||||
{ 'opacity-50 cursor-not-allowed': isLastPage }
|
||||
]">下一页</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePagination } from "@/composables/page";
|
||||
import { watch } from "vue";
|
||||
|
||||
const { pageChange, total } = defineProps<{
|
||||
pageChange: (page: number, size: number) => Promise<void>;
|
||||
total: number;
|
||||
}>();
|
||||
|
||||
const {
|
||||
currentPage,
|
||||
pageNumbers,
|
||||
pageSize,
|
||||
displayRange,
|
||||
isFirstPage,
|
||||
isLastPage,
|
||||
totalPages,
|
||||
updatePaginationState,
|
||||
} = usePagination();
|
||||
|
||||
const handlePageChangeClick = async (page: number) => {
|
||||
if (page < 1 || page > totalPages.value) return;
|
||||
await pageChange(page, pageSize.value);
|
||||
updatePaginationState({
|
||||
currentPage: page,
|
||||
pageSize: pageSize.value,
|
||||
total,
|
||||
});
|
||||
};
|
||||
|
||||
watch(
|
||||
() => total,
|
||||
() => {
|
||||
updatePaginationState({
|
||||
currentPage: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
total,
|
||||
});
|
||||
},
|
||||
);
|
||||
</script>
|
||||
Reference in New Issue
Block a user