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']
|
ElForm: typeof import('element-plus/es')['ElForm']
|
||||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||||
ElInput: typeof import('element-plus/es')['ElInput']
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
|
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
@ -46,12 +47,17 @@ declare module 'vue' {
|
|||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
TyBreadcrumb: typeof import('./src/components/ty-breadcrumb.vue')['default']
|
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']
|
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']
|
TyHeader: typeof import('./src/components/ty-header.vue')['default']
|
||||||
TyMixbar: typeof import('./src/components/ty-mixbar.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']
|
TySetting: typeof import('./src/components/ty-setting.vue')['default']
|
||||||
TySettingPanel: typeof import('./src/components/ty-setting-panel.vue')['default']
|
TySettingPanel: typeof import('./src/components/ty-setting-panel.vue')['default']
|
||||||
TySidebar: typeof import('./src/components/ty-sidebar.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']
|
TyTabs: typeof import('./src/components/ty-tabs.vue')['default']
|
||||||
}
|
}
|
||||||
export interface GlobalDirectives {
|
export interface GlobalDirectives {
|
||||||
@ -75,6 +81,7 @@ declare global {
|
|||||||
const ElForm: typeof import('element-plus/es')['ElForm']
|
const ElForm: typeof import('element-plus/es')['ElForm']
|
||||||
const ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
const ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||||
const ElInput: typeof import('element-plus/es')['ElInput']
|
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 ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
const ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
const ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
const ElOption: typeof import('element-plus/es')['ElOption']
|
const ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
@ -95,11 +102,16 @@ declare global {
|
|||||||
const RouterLink: typeof import('vue-router')['RouterLink']
|
const RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
const RouterView: typeof import('vue-router')['RouterView']
|
const RouterView: typeof import('vue-router')['RouterView']
|
||||||
const TyBreadcrumb: typeof import('./src/components/ty-breadcrumb.vue')['default']
|
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 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 TyHeader: typeof import('./src/components/ty-header.vue')['default']
|
||||||
const TyMixbar: typeof import('./src/components/ty-mixbar.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 TySetting: typeof import('./src/components/ty-setting.vue')['default']
|
||||||
const TySettingPanel: typeof import('./src/components/ty-setting-panel.vue')['default']
|
const TySettingPanel: typeof import('./src/components/ty-setting-panel.vue')['default']
|
||||||
const TySidebar: typeof import('./src/components/ty-sidebar.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']
|
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>
|
<template>
|
||||||
<div class="page-user">
|
<ty-crud
|
||||||
<el-card class="page-user__search" shadow="never">
|
entity-name="用户"
|
||||||
<el-form :inline="true" :model="query" @submit.prevent>
|
:columns="columns"
|
||||||
<el-form-item label="用户名">
|
:search-schema="searchSchema"
|
||||||
<el-input v-model="query.username" placeholder="请输入用户名" clearable @keyup.enter="handleSearch" />
|
:form-schema="formSchema"
|
||||||
</el-form-item>
|
:list="list"
|
||||||
<el-form-item label="昵称">
|
:create="create"
|
||||||
<el-input v-model="query.nickname" placeholder="请输入昵称" clearable @keyup.enter="handleSearch" />
|
:update="update"
|
||||||
</el-form-item>
|
:remove="remove"
|
||||||
<el-form-item label="手机">
|
id-key="id"
|
||||||
<el-input v-model="query.phone" placeholder="请输入手机号" clearable @keyup.enter="handleSearch" />
|
name-key="username"
|
||||||
</el-form-item>
|
:edit-readonly-keys="['username']"
|
||||||
<el-form-item label="状态">
|
>
|
||||||
<el-select v-model="query.status" placeholder="全部" clearable style="width: 120px">
|
<template #row-actions="{ row }">
|
||||||
<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>
|
|
||||||
<el-button link type="warning" @click="openResetPwd(row)">重置密码</el-button>
|
<el-button link type="warning" @click="openResetPwd(row)">重置密码</el-button>
|
||||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</ty-crud>
|
||||||
</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>
|
|
||||||
|
|
||||||
<el-dialog v-model="resetPwd.visible" title="重置密码" width="420px" destroy-on-close>
|
<el-dialog v-model="resetPwd.visible" title="重置密码" width="420px" destroy-on-close>
|
||||||
<el-form label-width="80px">
|
<el-form label-width="80px">
|
||||||
<el-form-item label="用户名">
|
<el-form-item label="用户名"><span>{{ resetPwd.username }}</span></el-form-item>
|
||||||
<span>{{ resetPwd.username }}</span>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="新密码">
|
<el-form-item label="新密码">
|
||||||
<el-input v-model="resetPwd.password" type="password" show-password placeholder="请输入新密码" />
|
<el-input v-model="resetPwd.password" type="password" show-password placeholder="请输入新密码" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@ -119,177 +29,168 @@
|
|||||||
<el-button type="primary" @click="submitResetPwd">确定</el-button>
|
<el-button type="primary" @click="submitResetPwd">确定</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
import type { JsonSchema, PageQuery, PageResult, TableColumn } from '@/types/crud'
|
||||||
import {
|
|
||||||
createUser,
|
|
||||||
deleteUser,
|
|
||||||
listUsers,
|
|
||||||
mockOrgs,
|
|
||||||
orgName,
|
|
||||||
resetPassword,
|
|
||||||
updateUser,
|
|
||||||
type User,
|
|
||||||
type UserStatus,
|
|
||||||
} from '@/api/user'
|
|
||||||
|
|
||||||
const STATUS_MAP: Record<UserStatus, { label: string; type: 'success' | 'info' | 'danger' }> = {
|
const STATUS_MAP = {
|
||||||
enabled: { label: '启用', type: 'success' },
|
enabled: { label: '启用', type: 'success' },
|
||||||
disabled: { label: '停用', type: 'info' },
|
disabled: { label: '停用', type: 'info' },
|
||||||
locked: { label: '锁定', type: 'danger' },
|
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 formSchema: JsonSchema = {
|
||||||
const list = ref<User[]>([])
|
type: 'object',
|
||||||
const total = ref(0)
|
properties: {
|
||||||
const query = reactive({
|
username: { type: 'string', title: '用户名', placeholder: '请输入用户名', minLength: 2 },
|
||||||
page: 1,
|
password: {
|
||||||
pageSize: 10,
|
type: 'string',
|
||||||
username: '',
|
title: '密码',
|
||||||
nickname: '',
|
format: 'password',
|
||||||
phone: '',
|
placeholder: '请输入初始密码',
|
||||||
status: '' as UserStatus | '',
|
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'],
|
||||||
|
}
|
||||||
|
|
||||||
const formRef = ref<FormInstance>()
|
// ---- 占位数据(后续对接后端时替换为 http 调用)----
|
||||||
const dialog = reactive({ visible: false, isEdit: false })
|
interface UserRow {
|
||||||
const form = reactive({
|
id: string
|
||||||
id: '',
|
username: string
|
||||||
username: '',
|
nickname: string
|
||||||
password: '',
|
email: string
|
||||||
nickname: '',
|
phone: string
|
||||||
email: '',
|
status: 'enabled' | 'disabled' | 'locked'
|
||||||
phone: '',
|
orgCode: string
|
||||||
status: 'enabled' as UserStatus,
|
createdAt: number
|
||||||
orgCode: null as string | null,
|
}
|
||||||
})
|
|
||||||
|
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 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 resetPwd = reactive({ visible: false, id: '', username: '', password: '' })
|
||||||
|
|
||||||
const rules: FormRules = {
|
function openResetPwd(row: Record<string, any>): void {
|
||||||
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 {
|
|
||||||
resetPwd.visible = true
|
resetPwd.visible = true
|
||||||
resetPwd.id = row.id
|
resetPwd.id = String(row.id)
|
||||||
resetPwd.username = row.username
|
resetPwd.username = String(row.username)
|
||||||
resetPwd.password = ''
|
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> {
|
async function submitResetPwd(): Promise<void> {
|
||||||
await resetPassword(resetPwd.id, resetPwd.password)
|
// mock:无真实密码存储,仅模拟
|
||||||
|
await delay(() => null)
|
||||||
ElMessage.success('密码已重置')
|
ElMessage.success('密码已重置')
|
||||||
resetPwd.visible = false
|
resetPwd.visible = false
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(fetchList)
|
|
||||||
</script>
|
</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