feat: extract JSON Schema-driven CRUD components, rebuild user page on them
- 新增公共组件:ty-schema-form(JSON Schema 表单)、ty-search-form、ty-table、ty-form-dialog、ty-crud(整页组合) - 类型 src/types/crud.ts - 用户管理页改用 ty-crud + JSON Schema + 内联数据(setTimeout 模拟异步),删除 src/api/user.ts Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
5549b2e75c
commit
45b22acd6c
12
components.d.ts
vendored
12
components.d.ts
vendored
@ -26,6 +26,7 @@ declare module 'vue' {
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
@ -46,12 +47,17 @@ declare module 'vue' {
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
TyBreadcrumb: typeof import('./src/components/ty-breadcrumb.vue')['default']
|
||||
TyCrud: typeof import('./src/components/ty-crud.vue')['default']
|
||||
TyFooter: typeof import('./src/components/ty-footer.vue')['default']
|
||||
TyFormDialog: typeof import('./src/components/ty-form-dialog.vue')['default']
|
||||
TyHeader: typeof import('./src/components/ty-header.vue')['default']
|
||||
TyMixbar: typeof import('./src/components/ty-mixbar.vue')['default']
|
||||
TySchemaForm: typeof import('./src/components/ty-schema-form.vue')['default']
|
||||
TySearchForm: typeof import('./src/components/ty-search-form.vue')['default']
|
||||
TySetting: typeof import('./src/components/ty-setting.vue')['default']
|
||||
TySettingPanel: typeof import('./src/components/ty-setting-panel.vue')['default']
|
||||
TySidebar: typeof import('./src/components/ty-sidebar.vue')['default']
|
||||
TyTable: typeof import('./src/components/ty-table.vue')['default']
|
||||
TyTabs: typeof import('./src/components/ty-tabs.vue')['default']
|
||||
}
|
||||
export interface GlobalDirectives {
|
||||
@ -75,6 +81,7 @@ declare global {
|
||||
const ElForm: typeof import('element-plus/es')['ElForm']
|
||||
const ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
const ElInput: typeof import('element-plus/es')['ElInput']
|
||||
const ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
const ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
const ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
const ElOption: typeof import('element-plus/es')['ElOption']
|
||||
@ -95,11 +102,16 @@ declare global {
|
||||
const RouterLink: typeof import('vue-router')['RouterLink']
|
||||
const RouterView: typeof import('vue-router')['RouterView']
|
||||
const TyBreadcrumb: typeof import('./src/components/ty-breadcrumb.vue')['default']
|
||||
const TyCrud: typeof import('./src/components/ty-crud.vue')['default']
|
||||
const TyFooter: typeof import('./src/components/ty-footer.vue')['default']
|
||||
const TyFormDialog: typeof import('./src/components/ty-form-dialog.vue')['default']
|
||||
const TyHeader: typeof import('./src/components/ty-header.vue')['default']
|
||||
const TyMixbar: typeof import('./src/components/ty-mixbar.vue')['default']
|
||||
const TySchemaForm: typeof import('./src/components/ty-schema-form.vue')['default']
|
||||
const TySearchForm: typeof import('./src/components/ty-search-form.vue')['default']
|
||||
const TySetting: typeof import('./src/components/ty-setting.vue')['default']
|
||||
const TySettingPanel: typeof import('./src/components/ty-setting-panel.vue')['default']
|
||||
const TySidebar: typeof import('./src/components/ty-sidebar.vue')['default']
|
||||
const TyTable: typeof import('./src/components/ty-table.vue')['default']
|
||||
const TyTabs: typeof import('./src/components/ty-tabs.vue')['default']
|
||||
}
|
||||
188
src/components/ty-crud.vue
Normal file
188
src/components/ty-crud.vue
Normal file
@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<div class="ty-crud">
|
||||
<el-card v-if="searchSchema" class="ty-crud__search" shadow="never">
|
||||
<ty-search-form :schema="searchSchema" :model-value="query" @search="handleSearch" @reset="handleReset" />
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<div class="ty-crud__toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增{{ entityName }}</el-button>
|
||||
<slot name="toolbar" />
|
||||
</div>
|
||||
|
||||
<ty-table
|
||||
:columns="columns"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
v-model:page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:actions-width="actionsWidth"
|
||||
>
|
||||
<template #actions="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
<slot name="row-actions" :row="row" />
|
||||
</template>
|
||||
</ty-table>
|
||||
</el-card>
|
||||
|
||||
<ty-form-dialog
|
||||
v-model:visible="dialogVisible"
|
||||
:title="dialogMode === 'edit' ? `编辑${entityName}` : `新增${entityName}`"
|
||||
:schema="formSchema"
|
||||
:model-value="form"
|
||||
:mode="dialogMode"
|
||||
:readonly-keys="dialogMode === 'edit' ? editReadonlyKeys : []"
|
||||
:submitting="submitting"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { JsonSchema, PageQuery, PageResult, TableColumn } from '@/types/crud'
|
||||
|
||||
defineOptions({ name: 'TyCrud' })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 实体名(用于标题与删除确认,如「用户」)。 */
|
||||
entityName: string
|
||||
/** 表格列配置。 */
|
||||
columns: TableColumn[]
|
||||
/** 表单 JSON Schema。 */
|
||||
formSchema: JsonSchema
|
||||
/** 搜索 JSON Schema(可选)。 */
|
||||
searchSchema?: JsonSchema
|
||||
/** 数据加载:分页查询。 */
|
||||
list: (query: PageQuery) => Promise<PageResult<Record<string, any>>>
|
||||
/** 新增。 */
|
||||
create: (data: Record<string, any>) => Promise<unknown>
|
||||
/** 更新。 */
|
||||
update: (id: string, data: Record<string, any>) => Promise<unknown>
|
||||
/** 删除。 */
|
||||
remove: (id: string) => Promise<unknown>
|
||||
/** 主键字段名。 */
|
||||
idKey?: string
|
||||
/** 删除确认展示的字段名。 */
|
||||
nameKey?: string
|
||||
/** 编辑模式下强制只读的字段。 */
|
||||
editReadonlyKeys?: string[]
|
||||
actionsWidth?: number | string
|
||||
}>(),
|
||||
{
|
||||
searchSchema: undefined,
|
||||
idKey: 'id',
|
||||
nameKey: 'name',
|
||||
editReadonlyKeys: () => [],
|
||||
actionsWidth: 220,
|
||||
},
|
||||
)
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Record<string, any>[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const query = reactive<Record<string, any>>(buildModel(props.searchSchema))
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const editingId = ref<string | null>(null)
|
||||
const submitting = ref(false)
|
||||
const form = reactive<Record<string, any>>({})
|
||||
|
||||
function buildModel(schema?: JsonSchema): Record<string, any> {
|
||||
const model: Record<string, any> = {}
|
||||
if (!schema) return model
|
||||
for (const [key, prop] of Object.entries(schema.properties)) {
|
||||
if (prop.default !== undefined) model[key] = prop.default
|
||||
else if (prop.enum?.length) model[key] = prop.enum[0]
|
||||
else if (prop.type === 'boolean') model[key] = false
|
||||
else model[key] = ''
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
async function fetchList(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await props.list({ page: page.value, pageSize: pageSize.value, ...query })
|
||||
list.value = result.list
|
||||
total.value = result.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch(): void {
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset(): void {
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function openCreate(): void {
|
||||
dialogMode.value = 'create'
|
||||
editingId.value = null
|
||||
Object.assign(form, buildModel(props.formSchema))
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: Record<string, any>): void {
|
||||
dialogMode.value = 'edit'
|
||||
editingId.value = String(row[props.idKey])
|
||||
const model = buildModel(props.formSchema)
|
||||
for (const key of Object.keys(props.formSchema.properties)) {
|
||||
model[key] = row[key] ?? model[key]
|
||||
}
|
||||
Object.assign(form, model)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
submitting.value = true
|
||||
try {
|
||||
if (dialogMode.value === 'edit') {
|
||||
await props.update(editingId.value!, { ...form })
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await props.create({ ...form })
|
||||
ElMessage.success('新增成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row: Record<string, any>): Promise<void> {
|
||||
const name = row[props.nameKey] ?? ''
|
||||
await ElMessageBox.confirm(`确认删除${props.entityName}「${name}」?`, '提示', { type: 'warning' })
|
||||
await props.remove(String(row[props.idKey]))
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ty-crud {
|
||||
&__search {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
65
src/components/ty-form-dialog.vue
Normal file
65
src/components/ty-form-dialog.vue
Normal file
@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" :title="title" :width="width" destroy-on-close @closed="onClosed">
|
||||
<ty-schema-form
|
||||
ref="formRef"
|
||||
:schema="schema"
|
||||
:model-value="modelValue"
|
||||
:readonly-keys="readonlyKeys"
|
||||
:mode="mode"
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { JsonSchema } from '@/types/crud'
|
||||
|
||||
defineOptions({ name: 'TyFormDialog' })
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false })
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
/** 表单 JSON Schema。 */
|
||||
schema: JsonSchema
|
||||
/** 表单数据(就地绑定)。 */
|
||||
modelValue: Record<string, any>
|
||||
readonlyKeys?: string[]
|
||||
/** 表单模式(create/edit),透传给 schema form 用于 showOn。 */
|
||||
mode?: 'create' | 'edit'
|
||||
submitting?: boolean
|
||||
width?: string
|
||||
}>(),
|
||||
{
|
||||
title: '',
|
||||
readonlyKeys: () => [],
|
||||
mode: undefined,
|
||||
submitting: false,
|
||||
width: '480px',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'submit'): void
|
||||
}>()
|
||||
|
||||
const formRef = ref<{ validate: () => Promise<unknown>; resetFields?: () => void }>()
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
emit('submit')
|
||||
}
|
||||
|
||||
function onClosed(): void {
|
||||
formRef.value?.resetFields?.()
|
||||
}
|
||||
</script>
|
||||
135
src/components/ty-schema-form.vue
Normal file
135
src/components/ty-schema-form.vue
Normal file
@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<el-form ref="formRef" :inline="inline" :model="modelValue" :rules="rules" :label-width="inline ? 'auto' : '90px'">
|
||||
<el-form-item
|
||||
v-for="(prop, key) in visibleProperties"
|
||||
:key="key"
|
||||
:label="prop.title ?? key"
|
||||
:prop="key"
|
||||
>
|
||||
<el-select
|
||||
v-if="prop.enum"
|
||||
v-model="modelValue[key]"
|
||||
:placeholder="prop.placeholder ?? '请选择'"
|
||||
:clearable="!isRequired(key)"
|
||||
:disabled="isReadonly(key, prop)"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="(v, i) in prop.enum"
|
||||
:key="String(v)"
|
||||
:label="prop.enumNames?.[i] ?? String(v)"
|
||||
:value="v"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-switch v-else-if="prop.type === 'boolean'" v-model="modelValue[key]" :disabled="isReadonly(key, prop)" />
|
||||
|
||||
<el-input-number
|
||||
v-else-if="prop.type === 'integer' || prop.type === 'number'"
|
||||
v-model="modelValue[key]"
|
||||
:min="prop.minimum"
|
||||
:max="prop.maximum"
|
||||
:disabled="isReadonly(key, prop)"
|
||||
/>
|
||||
|
||||
<el-input
|
||||
v-else-if="prop.format === 'password'"
|
||||
v-model="modelValue[key]"
|
||||
type="password"
|
||||
show-password
|
||||
:placeholder="prop.placeholder"
|
||||
:disabled="isReadonly(key, prop)"
|
||||
/>
|
||||
|
||||
<el-input
|
||||
v-else-if="prop.format === 'textarea'"
|
||||
v-model="modelValue[key]"
|
||||
type="textarea"
|
||||
:placeholder="prop.placeholder"
|
||||
:disabled="isReadonly(key, prop)"
|
||||
/>
|
||||
|
||||
<el-input
|
||||
v-else
|
||||
v-model="modelValue[key]"
|
||||
:placeholder="prop.placeholder"
|
||||
:disabled="isReadonly(key, prop)"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<slot />
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import type { JsonSchema, JsonSchemaProperty } from '@/types/crud'
|
||||
|
||||
defineOptions({ name: 'TySchemaForm' })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 表单 JSON Schema。 */
|
||||
schema: JsonSchema
|
||||
/** 表单数据(就地绑定,会直接修改传入对象)。 */
|
||||
modelValue: Record<string, any>
|
||||
/** 是否行内布局(用于搜索)。 */
|
||||
inline?: boolean
|
||||
/** 表单模式(create/edit),用于 showOn 字段过滤。 */
|
||||
mode?: 'create' | 'edit'
|
||||
/** 强制只读的字段名(如编辑模式下的 username)。 */
|
||||
readonlyKeys?: string[]
|
||||
}>(),
|
||||
{
|
||||
inline: false,
|
||||
mode: undefined,
|
||||
readonlyKeys: () => [],
|
||||
},
|
||||
)
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
/** 按 mode 过滤 showOn 字段。 */
|
||||
const visibleProperties = computed(() => {
|
||||
const result: Record<string, JsonSchemaProperty> = {}
|
||||
for (const [key, prop] of Object.entries(props.schema.properties)) {
|
||||
if (!props.mode || !prop.showOn || prop.showOn.includes(props.mode)) {
|
||||
result[key] = prop
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const rules = computed<FormRules>(() => {
|
||||
const result: FormRules = {}
|
||||
for (const [key, prop] of Object.entries(props.schema.properties)) {
|
||||
const itemRules: Record<string, unknown>[] = []
|
||||
if (props.schema.required?.includes(key)) {
|
||||
itemRules.push({ required: true, message: `${prop.title ?? key}不能为空`, trigger: 'blur' })
|
||||
}
|
||||
if (prop.minLength) {
|
||||
itemRules.push({ min: prop.minLength, message: `长度不能少于 ${prop.minLength} 位`, trigger: 'blur' })
|
||||
}
|
||||
if (prop.format === 'email') {
|
||||
itemRules.push({ type: 'email', message: '邮箱格式不正确', trigger: 'blur' })
|
||||
}
|
||||
if (itemRules.length) result[key] = itemRules
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
function isRequired(key: string): boolean {
|
||||
return props.schema.required?.includes(key) ?? false
|
||||
}
|
||||
|
||||
function isReadonly(key: string, prop: { readOnly?: boolean }): boolean {
|
||||
return prop.readOnly === true || props.readonlyKeys.includes(key)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
validate: () => formRef.value?.validate(),
|
||||
resetFields: () => formRef.value?.resetFields(),
|
||||
clearValidate: () => formRef.value?.clearValidate(),
|
||||
})
|
||||
</script>
|
||||
33
src/components/ty-search-form.vue
Normal file
33
src/components/ty-search-form.vue
Normal file
@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<ty-schema-form :schema="schema" :model-value="modelValue" inline>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="emit('search')">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</ty-schema-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { JsonSchema } from '@/types/crud'
|
||||
|
||||
defineOptions({ name: 'TySearchForm' })
|
||||
|
||||
const props = defineProps<{
|
||||
/** 搜索字段 JSON Schema。 */
|
||||
schema: JsonSchema
|
||||
/** 搜索条件(就地绑定)。 */
|
||||
modelValue: Record<string, any>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'search'): void
|
||||
(e: 'reset'): void
|
||||
}>()
|
||||
|
||||
function handleReset(): void {
|
||||
for (const [key, prop] of Object.entries(props.schema.properties)) {
|
||||
props.modelValue[key] = prop.default ?? ''
|
||||
}
|
||||
emit('reset')
|
||||
}
|
||||
</script>
|
||||
114
src/components/ty-table.vue
Normal file
114
src/components/ty-table.vue
Normal file
@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="ty-table">
|
||||
<el-table v-loading="loading" :data="data" border>
|
||||
<el-table-column
|
||||
v-for="col in columns"
|
||||
:key="col.prop"
|
||||
:label="col.label"
|
||||
:width="col.width"
|
||||
:min-width="col.minWidth"
|
||||
:align="col.align"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
v-if="col.type === 'tag'"
|
||||
:type="col.tagMap?.[String(row[col.prop])]?.type ?? 'info'"
|
||||
size="small"
|
||||
>
|
||||
{{ col.tagMap?.[String(row[col.prop])]?.label ?? row[col.prop] }}
|
||||
</el-tag>
|
||||
<span v-else-if="col.type === 'datetime'">{{ formatDateTime(row[col.prop]) }}</span>
|
||||
<span v-else-if="col.formatter">{{ col.formatter(row[col.prop], row) }}</span>
|
||||
<span v-else>{{ row[col.prop] }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="$slots.actions" label="操作" :width="actionsWidth" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<slot name="actions" :row="row" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-if="total > 0"
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="currentPageSize"
|
||||
class="ty-table__pagination"
|
||||
:total="total"
|
||||
:page-sizes="pageSizes"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { TableColumn } from '@/types/crud'
|
||||
|
||||
defineOptions({ name: 'TyTable' })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 列配置。 */
|
||||
columns: TableColumn[]
|
||||
/** 表格数据。 */
|
||||
data: Record<string, any>[]
|
||||
loading?: boolean
|
||||
/** 总条数(分页用)。 */
|
||||
total?: number
|
||||
page?: number
|
||||
pageSize?: number
|
||||
pageSizes?: number[]
|
||||
/** 操作列宽度。 */
|
||||
actionsWidth?: number | string
|
||||
}>(),
|
||||
{
|
||||
loading: false,
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
pageSizes: () => [10, 20, 50],
|
||||
actionsWidth: 200,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:page', page: number): void
|
||||
(e: 'update:pageSize', pageSize: number): void
|
||||
}>()
|
||||
|
||||
const currentPage = computed({
|
||||
get: () => props.page,
|
||||
set: (v) => emit('update:page', v),
|
||||
})
|
||||
const currentPageSize = computed({
|
||||
get: () => props.pageSize,
|
||||
set: (v) => emit('update:pageSize', v),
|
||||
})
|
||||
|
||||
function onPageChange(page: number): void {
|
||||
emit('update:page', page)
|
||||
}
|
||||
function onSizeChange(size: number): void {
|
||||
emit('update:pageSize', size)
|
||||
}
|
||||
|
||||
function formatDateTime(value: unknown): string {
|
||||
if (value == null || value === '') return '-'
|
||||
const d = new Date(Number(value))
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ty-table {
|
||||
&__pagination {
|
||||
margin-top: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,115 +1,25 @@
|
||||
<template>
|
||||
<div class="page-user">
|
||||
<el-card class="page-user__search" shadow="never">
|
||||
<el-form :inline="true" :model="query" @submit.prevent>
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="query.username" placeholder="请输入用户名" clearable @keyup.enter="handleSearch" />
|
||||
</el-form-item>
|
||||
<el-form-item label="昵称">
|
||||
<el-input v-model="query.nickname" placeholder="请输入昵称" clearable @keyup.enter="handleSearch" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机">
|
||||
<el-input v-model="query.phone" placeholder="请输入手机号" clearable @keyup.enter="handleSearch" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="query.status" placeholder="全部" clearable style="width: 120px">
|
||||
<el-option label="启用" value="enabled" />
|
||||
<el-option label="停用" value="disabled" />
|
||||
<el-option label="锁定" value="locked" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<div class="page-user__toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border>
|
||||
<el-table-column prop="username" label="用户名" min-width="110" />
|
||||
<el-table-column prop="nickname" label="昵称" min-width="100" />
|
||||
<el-table-column prop="email" label="邮箱" min-width="160" />
|
||||
<el-table-column prop="phone" label="手机" min-width="120" />
|
||||
<el-table-column label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="STATUS_MAP[row.status as UserStatus].type" size="small">
|
||||
{{ STATUS_MAP[row.status as UserStatus].label }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="所属组织" min-width="110">
|
||||
<template #default="{ row }">{{ orgName(row.orgCode) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="160">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<ty-crud
|
||||
entity-name="用户"
|
||||
:columns="columns"
|
||||
:search-schema="searchSchema"
|
||||
:form-schema="formSchema"
|
||||
:list="list"
|
||||
:create="create"
|
||||
:update="update"
|
||||
:remove="remove"
|
||||
id-key="id"
|
||||
name-key="username"
|
||||
:edit-readonly-keys="['username']"
|
||||
>
|
||||
<template #row-actions="{ row }">
|
||||
<el-button link type="warning" @click="openResetPwd(row)">重置密码</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="query.page"
|
||||
v-model:page-size="query.pageSize"
|
||||
class="page-user__pagination"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="fetchList"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialog.visible" :title="dialog.isEdit ? '编辑用户' : '新增用户'" width="480px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="form.username" :disabled="dialog.isEdit" placeholder="请输入用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!dialog.isEdit" label="密码" prop="password">
|
||||
<el-input v-model="form.password" type="password" show-password placeholder="请输入初始密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="昵称" prop="nickname">
|
||||
<el-input v-model="form.nickname" placeholder="请输入昵称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="form.email" placeholder="请输入邮箱" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机" prop="phone">
|
||||
<el-input v-model="form.phone" placeholder="请输入手机号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="form.status" style="width: 100%">
|
||||
<el-option label="启用" value="enabled" />
|
||||
<el-option label="停用" value="disabled" />
|
||||
<el-option label="锁定" value="locked" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属组织" prop="orgCode">
|
||||
<el-select v-model="form.orgCode" placeholder="请选择组织" clearable style="width: 100%">
|
||||
<el-option v-for="o in mockOrgs" :key="o.code" :label="o.name" :value="o.code" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialog.visible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitForm">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</ty-crud>
|
||||
|
||||
<el-dialog v-model="resetPwd.visible" title="重置密码" width="420px" destroy-on-close>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="用户名">
|
||||
<span>{{ resetPwd.username }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户名"><span>{{ resetPwd.username }}</span></el-form-item>
|
||||
<el-form-item label="新密码">
|
||||
<el-input v-model="resetPwd.password" type="password" show-password placeholder="请输入新密码" />
|
||||
</el-form-item>
|
||||
@ -119,177 +29,168 @@
|
||||
<el-button type="primary" @click="submitResetPwd">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
listUsers,
|
||||
mockOrgs,
|
||||
orgName,
|
||||
resetPassword,
|
||||
updateUser,
|
||||
type User,
|
||||
type UserStatus,
|
||||
} from '@/api/user'
|
||||
import { reactive } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { JsonSchema, PageQuery, PageResult, TableColumn } from '@/types/crud'
|
||||
|
||||
const STATUS_MAP: Record<UserStatus, { label: string; type: 'success' | 'info' | 'danger' }> = {
|
||||
const STATUS_MAP = {
|
||||
enabled: { label: '启用', type: 'success' },
|
||||
disabled: { label: '停用', type: 'info' },
|
||||
locked: { label: '锁定', type: 'danger' },
|
||||
} as const
|
||||
|
||||
const orgs = [
|
||||
{ code: 'touyun', name: '投云总部' },
|
||||
{ code: 'rd', name: '研发部' },
|
||||
{ code: 'ops', name: '运维部' },
|
||||
{ code: 'sales', name: '销售部' },
|
||||
]
|
||||
|
||||
const orgName = (code: unknown): string => orgs.find((o) => o.code === code)?.name ?? (code as string) ?? '-'
|
||||
|
||||
const columns: TableColumn[] = [
|
||||
{ prop: 'username', label: '用户名', minWidth: 110 },
|
||||
{ prop: 'nickname', label: '昵称', minWidth: 100 },
|
||||
{ prop: 'email', label: '邮箱', minWidth: 160 },
|
||||
{ prop: 'phone', label: '手机', minWidth: 120 },
|
||||
{ prop: 'status', label: '状态', width: 80, align: 'center', type: 'tag', tagMap: STATUS_MAP },
|
||||
{ prop: 'orgCode', label: '所属组织', minWidth: 110, formatter: (v) => orgName(v) },
|
||||
{ prop: 'createdAt', label: '创建时间', width: 160, type: 'datetime' },
|
||||
]
|
||||
|
||||
const searchSchema: JsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
username: { type: 'string', title: '用户名', placeholder: '请输入用户名' },
|
||||
nickname: { type: 'string', title: '昵称', placeholder: '请输入昵称' },
|
||||
phone: { type: 'string', title: '手机', placeholder: '请输入手机号' },
|
||||
status: {
|
||||
type: 'string',
|
||||
title: '状态',
|
||||
enum: ['enabled', 'disabled', 'locked'],
|
||||
enumNames: ['启用', '停用', '锁定'],
|
||||
placeholder: '全部',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<User[]>([])
|
||||
const total = ref(0)
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
username: '',
|
||||
nickname: '',
|
||||
phone: '',
|
||||
status: '' as UserStatus | '',
|
||||
const formSchema: JsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
username: { type: 'string', title: '用户名', placeholder: '请输入用户名', minLength: 2 },
|
||||
password: {
|
||||
type: 'string',
|
||||
title: '密码',
|
||||
format: 'password',
|
||||
placeholder: '请输入初始密码',
|
||||
minLength: 6,
|
||||
showOn: ['create'],
|
||||
},
|
||||
nickname: { type: 'string', title: '昵称', placeholder: '请输入昵称' },
|
||||
email: { type: 'string', title: '邮箱', format: 'email', placeholder: '请输入邮箱' },
|
||||
phone: { type: 'string', title: '手机', placeholder: '请输入手机号' },
|
||||
status: {
|
||||
type: 'string',
|
||||
title: '状态',
|
||||
enum: ['enabled', 'disabled', 'locked'],
|
||||
enumNames: ['启用', '停用', '锁定'],
|
||||
default: 'enabled',
|
||||
},
|
||||
orgCode: {
|
||||
type: 'string',
|
||||
title: '所属组织',
|
||||
enum: orgs.map((o) => o.code),
|
||||
enumNames: orgs.map((o) => o.name),
|
||||
placeholder: '请选择组织',
|
||||
},
|
||||
},
|
||||
required: ['username', 'password'],
|
||||
}
|
||||
|
||||
// ---- 占位数据(后续对接后端时替换为 http 调用)----
|
||||
interface UserRow {
|
||||
id: string
|
||||
username: string
|
||||
nickname: string
|
||||
email: string
|
||||
phone: string
|
||||
status: 'enabled' | 'disabled' | 'locked'
|
||||
orgCode: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const day = 24 * 60 * 60 * 1000
|
||||
const users: UserRow[] = [
|
||||
{ id: '1', username: 'touyun', nickname: '超级管理员', email: 'touyun@example.com', phone: '13800000001', status: 'enabled', orgCode: 'touyun', createdAt: now - 30 * day },
|
||||
{ id: '2', username: 'zhangsan', nickname: '张三', email: 'zhangsan@example.com', phone: '13800000002', status: 'enabled', orgCode: 'rd', createdAt: now - 20 * day },
|
||||
{ id: '3', username: 'lisi', nickname: '李四', email: 'lisi@example.com', phone: '13800000003', status: 'disabled', orgCode: 'rd', createdAt: now - 18 * day },
|
||||
{ id: '4', username: 'wangwu', nickname: '王五', email: 'wangwu@example.com', phone: '13800000004', status: 'locked', orgCode: 'ops', createdAt: now - 15 * day },
|
||||
{ id: '5', username: 'zhaoliu', nickname: '赵六', email: 'zhaoliu@example.com', phone: '13800000005', status: 'enabled', orgCode: 'sales', createdAt: now - 12 * day },
|
||||
{ id: '6', username: 'sunqi', nickname: '孙七', email: 'sunqi@example.com', phone: '13800000006', status: 'enabled', orgCode: 'ops', createdAt: now - 9 * day },
|
||||
{ id: '7', username: 'zhouba', nickname: '周八', email: 'zhouba@example.com', phone: '13800000007', status: 'enabled', orgCode: 'rd', createdAt: now - 7 * day },
|
||||
{ id: '8', username: 'wujiu', nickname: '吴九', email: 'wujiu@example.com', phone: '13800000008', status: 'disabled', orgCode: 'sales', createdAt: now - 5 * day },
|
||||
{ id: '9', username: 'zhengshi', nickname: '郑十', email: 'zhengshi@example.com', phone: '13800000009', status: 'enabled', orgCode: 'rd', createdAt: now - 3 * day },
|
||||
{ id: '10', username: 'chenyi', nickname: '陈一', email: 'chenyi@example.com', phone: '13800000010', status: 'enabled', orgCode: 'sales', createdAt: now - 1 * day },
|
||||
]
|
||||
|
||||
let seq = 0
|
||||
const nextId = () => `u-${Date.now()}-${++seq}`
|
||||
|
||||
function delay<T>(fn: () => T, ms = 300): Promise<T> {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(fn()), ms))
|
||||
}
|
||||
|
||||
const list = (query: PageQuery): Promise<PageResult<Record<string, any>>> =>
|
||||
delay(() => {
|
||||
const { page, pageSize, username, nickname, phone, status } = query
|
||||
const filtered = users.filter((u) => {
|
||||
if (username && !u.username.includes(String(username))) return false
|
||||
if (nickname && !u.nickname.includes(String(nickname))) return false
|
||||
if (phone && !u.phone.includes(String(phone))) return false
|
||||
if (status && u.status !== status) return false
|
||||
return true
|
||||
})
|
||||
const start = (page - 1) * pageSize
|
||||
return { list: filtered.slice(start, start + pageSize) as unknown as Record<string, any>[], total: filtered.length, page, pageSize }
|
||||
})
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const dialog = reactive({ visible: false, isEdit: false })
|
||||
const form = reactive({
|
||||
id: '',
|
||||
username: '',
|
||||
password: '',
|
||||
nickname: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
status: 'enabled' as UserStatus,
|
||||
orgCode: null as string | null,
|
||||
const create = (data: Record<string, any>): Promise<unknown> =>
|
||||
delay(() => {
|
||||
users.unshift({ id: nextId(), username: data.username, nickname: data.nickname, email: data.email, phone: data.phone, status: data.status, orgCode: data.orgCode, createdAt: Date.now() })
|
||||
return null
|
||||
})
|
||||
|
||||
const update = (id: string, data: Record<string, any>): Promise<unknown> =>
|
||||
delay(() => {
|
||||
const u = users.find((x) => x.id === id)
|
||||
if (u) Object.assign(u, data)
|
||||
return null
|
||||
})
|
||||
|
||||
const remove = (id: string): Promise<unknown> =>
|
||||
delay(() => {
|
||||
const idx = users.findIndex((x) => x.id === id)
|
||||
if (idx >= 0) users.splice(idx, 1)
|
||||
return null
|
||||
})
|
||||
|
||||
const resetPwd = reactive({ visible: false, id: '', username: '', password: '' })
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function formatDateTime(ms: number): string {
|
||||
const d = new Date(ms)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
async function fetchList(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await listUsers({ ...query, status: query.status })
|
||||
list.value = result.list
|
||||
total.value = result.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch(): void {
|
||||
query.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset(): void {
|
||||
query.username = ''
|
||||
query.nickname = ''
|
||||
query.phone = ''
|
||||
query.status = ''
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
function handleSizeChange(): void {
|
||||
query.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function openCreate(): void {
|
||||
dialog.visible = true
|
||||
dialog.isEdit = false
|
||||
Object.assign(form, { id: '', username: '', password: '', nickname: '', email: '', phone: '', status: 'enabled', orgCode: null })
|
||||
}
|
||||
|
||||
function openEdit(row: User): void {
|
||||
dialog.visible = true
|
||||
dialog.isEdit = true
|
||||
Object.assign(form, { id: row.id, username: row.username, password: '', nickname: row.nickname ?? '', email: row.email ?? '', phone: row.phone ?? '', status: row.status, orgCode: row.orgCode })
|
||||
}
|
||||
|
||||
function openResetPwd(row: User): void {
|
||||
function openResetPwd(row: Record<string, any>): void {
|
||||
resetPwd.visible = true
|
||||
resetPwd.id = row.id
|
||||
resetPwd.username = row.username
|
||||
resetPwd.id = String(row.id)
|
||||
resetPwd.username = String(row.username)
|
||||
resetPwd.password = ''
|
||||
}
|
||||
|
||||
async function submitForm(): Promise<void> {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (dialog.isEdit) {
|
||||
await updateUser(form.id, {
|
||||
nickname: form.nickname,
|
||||
email: form.email,
|
||||
phone: form.phone,
|
||||
status: form.status,
|
||||
orgCode: form.orgCode,
|
||||
})
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await createUser({
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
nickname: form.nickname,
|
||||
email: form.email,
|
||||
phone: form.phone,
|
||||
status: form.status,
|
||||
orgCode: form.orgCode,
|
||||
})
|
||||
ElMessage.success('新增成功')
|
||||
}
|
||||
dialog.visible = false
|
||||
fetchList()
|
||||
}
|
||||
|
||||
async function handleDelete(row: User): Promise<void> {
|
||||
await ElMessageBox.confirm(`确认删除用户「${row.username}」?`, '提示', { type: 'warning' })
|
||||
await deleteUser(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
async function submitResetPwd(): Promise<void> {
|
||||
await resetPassword(resetPwd.id, resetPwd.password)
|
||||
// mock:无真实密码存储,仅模拟
|
||||
await delay(() => null)
|
||||
ElMessage.success('密码已重置')
|
||||
resetPwd.visible = false
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-user {
|
||||
&__search {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__pagination {
|
||||
margin-top: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
66
src/types/crud.ts
Normal file
66
src/types/crud.ts
Normal file
@ -0,0 +1,66 @@
|
||||
/** JSON Schema 表单 + 表格列的共享类型。 */
|
||||
|
||||
export type JsonType = 'string' | 'integer' | 'number' | 'boolean' | 'object' | 'array'
|
||||
|
||||
/** JSON Schema 属性(表单字段描述)。 */
|
||||
export interface JsonSchemaProperty {
|
||||
type?: JsonType
|
||||
/** 显示标题。 */
|
||||
title?: string
|
||||
description?: string
|
||||
/** 格式化:password/email/textarea 等。 */
|
||||
format?: 'password' | 'email' | 'textarea' | 'date' | 'datetime'
|
||||
/** 枚举值(渲染为下拉)。 */
|
||||
enum?: (string | number | boolean)[]
|
||||
/** 枚举显示名(与 enum 一一对应)。 */
|
||||
enumNames?: string[]
|
||||
default?: unknown
|
||||
/** 只读(禁用编辑)。 */
|
||||
readOnly?: boolean
|
||||
/** 仅在指定模式下显示(如 password 仅 create)。缺省表示所有模式都显示。 */
|
||||
showOn?: ('create' | 'edit')[]
|
||||
minLength?: number
|
||||
maxLength?: number
|
||||
minimum?: number
|
||||
maximum?: number
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
/** JSON Schema 对象(表单 schema)。 */
|
||||
export interface JsonSchema {
|
||||
type: 'object'
|
||||
properties: Record<string, JsonSchemaProperty>
|
||||
required?: string[]
|
||||
}
|
||||
|
||||
export type TagType = 'success' | 'info' | 'warning' | 'danger' | 'primary'
|
||||
|
||||
/** 表格列配置。 */
|
||||
export interface TableColumn {
|
||||
prop: string
|
||||
label: string
|
||||
width?: number | string
|
||||
minWidth?: number | string
|
||||
align?: 'left' | 'center' | 'right'
|
||||
/** 渲染类型:text(默认)/tag(状态标签)/datetime(毫秒时间戳)。 */
|
||||
type?: 'text' | 'tag' | 'datetime'
|
||||
/** type=tag 时的值→{label,type} 映射。 */
|
||||
tagMap?: Record<string, { label: string; type: TagType }>
|
||||
/** 自定义格式化。 */
|
||||
formatter?: (value: unknown, row: Record<string, unknown>) => string
|
||||
}
|
||||
|
||||
/** 分页结果。 */
|
||||
export interface PageResult<T> {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** 分页查询参数。 */
|
||||
export interface PageQuery {
|
||||
page: number
|
||||
pageSize: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user