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:
liuyun 2026-10-11 01:26:11 +08:00
parent 5549b2e75c
commit 45b22acd6c
9 changed files with 786 additions and 272 deletions

12
components.d.ts vendored
View File

@ -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
View 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>

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

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

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

View File

@ -1,295 +1,196 @@
<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-button link type="warning" @click="openResetPwd(row)">重置密码</el-button>
<el-option label="停用" value="disabled" /> </template>
<el-option label="锁定" value="locked" /> </ty-crud>
</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"> <el-dialog v-model="resetPwd.visible" title="重置密码" width="420px" destroy-on-close>
<div class="page-user__toolbar"> <el-form label-width="80px">
<el-button type="primary" @click="openCreate">新增</el-button> <el-form-item label="用户名"><span>{{ resetPwd.username }}</span></el-form-item>
</div> <el-form-item label="新密码">
<el-input v-model="resetPwd.password" type="password" show-password placeholder="请输入新密码" />
<el-table v-loading="loading" :data="list" border> </el-form-item>
<el-table-column prop="username" label="用户名" min-width="110" /> </el-form>
<el-table-column prop="nickname" label="昵称" min-width="100" /> <template #footer>
<el-table-column prop="email" label="邮箱" min-width="160" /> <el-button @click="resetPwd.visible = false">取消</el-button>
<el-table-column prop="phone" label="手机" min-width="120" /> <el-button type="primary" @click="submitResetPwd">确定</el-button>
<el-table-column label="状态" width="80" align="center"> </template>
<template #default="{ row }"> </el-dialog>
<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="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>
<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="新密码">
<el-input v-model="resetPwd.password" type="password" show-password placeholder="请输入新密码" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="resetPwd.visible = false">取消</el-button>
<el-button type="primary" @click="submitResetPwd">确定</el-button>
</template>
</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
View 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