Merge pull request #25 from zclzone/feature/crud-table

Feature/crud table
This commit is contained in:
Ronnie Zhang 2022-09-03 22:34:49 +08:00 committed by GitHub
commit b760cc34dd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 695 additions and 96 deletions

View File

@ -1,17 +1,8 @@
export default [
const posts = [
{
url: '/api/posts',
method: 'get',
response: () => {
return {
code: 0,
message: 'ok',
data: [
{
id: 36,
title: '使用纯css优雅配置移动端rem布局',
author: 'Ronnie',
category: '移动端,Css',
author: '大脸怪',
category: 'Css',
description: '通常配置rem布局会使用js进行处理比如750的设计稿会这样...',
content: '通常配置rem布局会使用js进行处理比如750的设计稿会这样',
isRecommend: true,
@ -20,7 +11,6 @@ export default [
updateDate: '2021-11-04T04:03:36.000Z',
},
{
id: 35,
title: 'Vue2&Vue3项目风格指南',
author: 'Ronnie',
category: 'Vue',
@ -32,7 +22,6 @@ export default [
updateDate: '2022-02-28T04:02:39.000Z',
},
{
id: 28,
title: '如何优雅的给图片添加水印',
author: '大脸怪',
category: 'JavaScript',
@ -45,7 +34,6 @@ export default [
},
{
id: 26,
title: '前端缓存的理解',
author: '大脸怪',
category: 'Http',
@ -57,7 +45,6 @@ export default [
updateDate: '2021-09-17T09:33:24.000Z',
},
{
id: 18,
title: 'Promise的五个静态方法',
author: '大脸怪',
category: 'JavaScript',
@ -68,7 +55,83 @@ export default [
createDate: '2021-02-22T22:37:06.000Z',
updateDate: '2021-09-17T09:33:24.000Z',
},
],
]
export default [
{
url: '/api/posts',
method: 'get',
response: (data = {}) => {
const { title, pageNo, pageSize } = data.query
let pageData = []
let total = 60
const filterData = posts.filter((item) => item.title.includes(title) || (!title && title !== 0))
if (filterData.length) {
if (pageSize) {
while (pageData.length < pageSize) {
pageData.push(filterData[Math.round(Math.random() * (filterData.length - 1))])
}
} else {
pageData = filterData
}
pageData = pageData.map((item, index) => ({
id: pageSize * (pageNo - 1) + index + 1,
...item,
}))
} else {
total = 0
}
return {
code: 0,
message: 'ok',
data: {
pageData,
total,
pageNo,
pageSize,
},
}
},
},
{
url: '/api/post',
method: 'post',
response: ({ body }) => {
return {
code: 0,
message: 'ok',
data: body,
}
},
},
{
url: '/api/post/:id',
method: 'put',
response: ({ query, body }) => {
return {
code: 0,
message: 'ok',
data: {
id: query.id,
body,
},
}
},
},
{
url: '/api/post/:id',
method: 'delete',
response: ({ query }) => {
if (!query.id) {
return { code: -1, message: '删除失败,id不能为空' }
} else {
return {
code: 0,
message: 'ok',
data: {
id: query.id,
},
}
}
},
},

View File

@ -0,0 +1,16 @@
<template>
<div min-h-60 p-15 flex items-start justify-between b-1 bc-ccc rounded-8 bg="#fafafc">
<n-space wrap :size="[35, 15]">
<slot />
</n-space>
<div flex-shrink-0>
<n-button secondary type="primary" @click="emit('reset')">重置</n-button>
<n-button ml-20 type="primary" @click="emit('search')">搜索</n-button>
</div>
</div>
</template>
<script setup>
const emit = defineEmits(['search', 'reset'])
</script>

View File

@ -0,0 +1,29 @@
<template>
<div flex items-center>
<label v-if="!isNullOrWhitespace(label)" w-80 flex-shrink-0 :style="{ width: labelWidth + 'px' }">
{{ label }}
</label>
<div :style="{ width: contentWidth + 'px' }" flex-shrink-0>
<slot />
</div>
</div>
</template>
<script setup>
import { isNullOrWhitespace } from '@/utils/is'
defineProps({
label: {
type: String,
default: '',
},
labelWidth: {
type: Number,
default: 80,
},
contentWidth: {
type: Number,
default: 220,
},
})
</script>

View File

@ -0,0 +1,48 @@
<template>
<n-modal v-model:show="show" :style="{ width }" preset="card" :title="title" size="huge" :bordered="false">
<slot />
<template v-if="showFooter" #footer>
<footer flex justify-end>
<slot name="footer">
<n-button @click="show = false">取消</n-button>
<n-button :loading="loading" ml-20 type="primary" @click="emit('onSave')">保存</n-button>
</slot>
</footer>
</template>
</n-modal>
</template>
<script setup>
const props = defineProps({
width: {
type: String,
default: '600px',
},
title: {
type: String,
default: '',
},
showFooter: {
type: Boolean,
default: true,
},
visible: {
type: Boolean,
required: true,
},
loading: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:visible', 'onSave'])
const show = computed({
get() {
return props.visible
},
set(v) {
emit('update:visible', v)
},
})
</script>

View File

@ -0,0 +1,127 @@
<template>
<QueryBar v-if="$slots.queryBar" mb-30 @search="handleSearch" @reset="handleReset">
<slot name="queryBar" />
</QueryBar>
<n-data-table
:remote="remote"
:loading="loading"
:scroll-x="scrollX"
:columns="columns"
:data="tableData"
:row-key="(row) => row[rowKey]"
:pagination="isPagination ? pagination : false"
@update:checked-row-keys="onChecked"
@update:page="onPageChange"
/>
</template>
<script setup>
const props = defineProps({
/**
* @remote true: 后端分页 false 前端分页
*/
remote: {
type: Boolean,
default: true,
},
/**
* @remote 是否分页
*/
isPagination: {
type: Boolean,
default: true,
},
scrollX: {
type: Number,
default: 1200,
},
rowKey: {
type: String,
default: 'id',
},
columns: {
type: Array,
required: true,
},
/** queryBar中的参数 */
queryItems: {
type: Object,
default() {
return {}
},
},
/** 补充参数(可选) */
extraParams: {
type: Object,
default() {
return {}
},
},
/**
* ! 约定接口入参出参
* * 分页模式需约定分页接口入参
* @pageSize 分页参数一页展示多少条默认10
* @pageNo 分页参数页码默认1
* * 需约定接口出参
* @pageData 分页模式必须,非分页模式如果没有pageData则取上一层data
* @total 分页模式必须非分页模式如果没有total则取上一层data.length
*/
getData: {
type: Function,
required: true,
},
})
const emit = defineEmits(['update:queryItems', 'onChecked'])
const loading = ref(false)
const initQuery = { ...props.queryItems }
const tableData = ref([])
const pagination = reactive({ page: 1, pageSize: 10 })
async function handleQuery() {
try {
loading.value = true
let paginationParams = {}
// 使,
if (props.isPagination && props.remote) {
paginationParams = { pageNo: pagination.page, pageSize: pagination.pageSize }
}
const { data } = await props.getData({ ...props.queryItems, ...props.extraParams, ...paginationParams })
tableData.value = data?.pageData || data
pagination.itemCount = data.total ?? data.length
} catch (error) {
tableData.value = []
pagination.itemCount = 0
$message.error(error.message)
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
handleQuery()
}
async function handleReset() {
emit('update:queryItems', { ...initQuery })
await nextTick()
pagination.page = 1
handleQuery()
}
function onPageChange(currentPage) {
pagination.page = currentPage
if (props.remote) {
handleQuery()
}
}
function onChecked(rowKeys) {
if (props.columns.some((item) => item.type === 'selection')) {
emit('onChecked', rowKeys)
}
}
defineExpose({
handleSearch,
handleReset,
})
</script>

1
src/composables/index.js Normal file
View File

@ -0,0 +1 @@
export { default as useCRUD } from './useCRUD'

104
src/composables/useCRUD.js Normal file
View File

@ -0,0 +1,104 @@
import { isNullOrWhitespace } from '../utils/is'
const ACTIONS = {
view: '查看',
edit: '编辑',
add: '新增',
}
export default function ({ name, initForm = {}, doCreate, doDelete, doUpdate, refresh }) {
const modalVisible = ref(false)
const modalAction = ref('')
const modalTitle = computed(() => ACTIONS[modalAction.value] + name)
const modalLoading = ref(false)
const modalFormRef = ref(null)
const modalForm = ref({ ...initForm })
/** 新增 */
function handleAdd() {
modalAction.value = 'add'
modalVisible.value = true
modalForm.value = { ...initForm }
}
/** 修改 */
function handleEdit(row) {
modalAction.value = 'edit'
modalVisible.value = true
modalForm.value = { ...row }
}
/** 查看 */
function handleView(row) {
modalAction.value = 'view'
modalVisible.value = true
modalForm.value = { ...row }
}
/** 保存 */
function handleSave() {
if (!['edit', 'add'].includes(modalAction.value)) {
modalVisible.value = false
return
}
modalFormRef.value?.validate(async (err) => {
if (err) return
const actions = {
add: {
api: () => doCreate(modalForm.value),
cb: () => $message.success('新增成功'),
},
edit: {
api: () => doUpdate(modalForm.value),
cb: () => $message.success('编辑成功'),
},
}
const action = actions[modalAction.value]
try {
modalLoading.value = true
const data = await action.api()
action.cb()
modalLoading.value = modalVisible.value = false
data && refresh(data)
} catch (error) {
$message.error('操作失败')
modalLoading.value = false
}
})
}
/** 删除 */
function handleDelete(id, confirmOptions) {
if (isNullOrWhitespace(id)) return
$dialog.confirm({
content: '确定删除?',
async confirm() {
try {
modalLoading.value = true
const data = await doDelete(id)
$message.success('删除成功')
modalLoading.value = false
refresh(data)
} catch (error) {
modalLoading.value = false
}
},
...confirmOptions,
})
}
return {
modalVisible,
modalAction,
modalTitle,
modalLoading,
handleAdd,
handleDelete,
handleEdit,
handleView,
handleSave,
modalForm,
modalFormRef,
}
}

View File

@ -35,7 +35,11 @@ export function reqReject(error) {
}
export function repResolve(response) {
return response?.data
if (response?.data?.code !== 0) {
$message.error(response?.data?.message || '操作异常')
return Promise.reject(response?.data)
}
return Promise.resolve(response?.data)
}
export function repReject(error) {
@ -67,5 +71,6 @@ export function repReject(error) {
}
}
console.error(`${code}${error}`)
return Promise.resolve({ code, message, error })
$message.error(message || '操作异常')
return Promise.reject({ code, message, error })
}

View File

@ -72,6 +72,7 @@ export function isNullOrWhitespace(val) {
return isNullOrUndef(val) || isWhitespace(val)
}
/** 空数组 | 空字符串 | 空对象 | 空Map | 空Set */
export function isEmpty(val) {
if (isArray(val) || isString(val)) {
return val.length === 0

View File

@ -3,11 +3,7 @@ import request from '@/utils/http'
export default {
getPosts: (params = {}) => request.get('posts', { params }),
getPostById: (id) => request.get(`/post/${id}`),
savePost: (id, data = {}) => {
if (id) {
return request.put(`/post/${id}`, data)
}
return request.post('/post', data)
},
addPost: (data) => request.post('/post', data),
updatePost: (data) => request.put(`/post/${data.id}`, data),
deletePost: (id) => request.delete(`/post/${id}`),
}

View File

@ -1,36 +1,221 @@
<template>
<CommonPage show-footer title="文章">
<n-data-table
mt-20
:loading="loading"
:scroll-x="1600"
:data="tableData"
<template #action>
<n-button type="primary" @click="handleAdd">
<TheIcon icon="material-symbols:add" :size="18" class="mr-5" /> 新建文章
</n-button>
</template>
<CrudTable
ref="$table"
v-model:query-items="queryItems"
:extra-params="extraParams"
:scroll-x="1200"
:columns="columns"
:pagination="pagination"
:row-key="(row) => row.id"
@update:checked-row-keys="handleCheck"
:get-data="api.getPosts"
@on-checked="onChecked"
>
<template #queryBar>
<QueryBarItem label="标题" :label-width="50">
<n-input
v-model:value="queryItems.title"
type="text"
placeholder="请输入标题"
@keydown.enter="$table?.handleSearch"
/>
</QueryBarItem>
</template>
</CrudTable>
<!-- 新增/编辑/查看 -->
<CrudModal
v-model:visible="modalVisible"
:title="modalTitle"
:loading="modalLoading"
:show-footer="modalAction !== 'view'"
@on-save="handleSave"
>
<n-form
ref="modalFormRef"
label-placement="left"
label-align="left"
:label-width="80"
:model="modalForm"
:disabled="modalAction === 'view'"
>
<n-form-item label="作者" path="author">
<n-input v-model:value="modalForm.author" disabled />
</n-form-item>
<n-form-item
label="文章标题"
path="title"
:rule="{
required: true,
message: '请输入文章标题',
trigger: ['input', 'blur'],
}"
>
<n-input v-model:value="modalForm.title" placeholder="请输入文章标题" />
</n-form-item>
<n-form-item
label="文章内容"
path="content"
:rule="{
required: true,
message: '请输入文章内容',
trigger: ['input', 'blur'],
}"
>
<n-input
v-model:value="modalForm.content"
placeholder="请输入文章内容"
type="textarea"
:autosize="{
minRows: 3,
maxRows: 5,
}"
/>
</n-form-item>
</n-form>
</CrudModal>
</CommonPage>
</template>
<script setup>
import { usePostTable } from './usePostTable'
import { NButton, NSwitch } from 'naive-ui'
import { formatDateTime } from '@/utils'
import { renderIcon } from '@/utils/icon'
import { useCRUD } from '@/composables'
import api from './api'
import { isNullOrUndef } from '@/utils/is'
const router = useRouter()
const $table = ref(null)
/** QueryBar筛选参数可选 */
const queryItems = ref({
title: '',
})
/** 补充参数(可选) */
const extraParams = ref({})
const pagination = ref({ pageSize: 10 })
const { loading, columns, tableData, initColumns, initTableData } = usePostTable()
onBeforeMount(() => {
initColumns()
initTableData()
onMounted(() => {
$table.value?.handleSearch()
})
function handleCreate() {
router.push('/example/table/post-create')
}
const columns = [
{ type: 'selection', fixed: 'left' },
{
title: '发布',
key: 'isPublish',
width: 60,
align: 'center',
fixed: 'left',
render(row) {
return h(NSwitch, {
size: 'small',
rubberBand: false,
value: row['isPublish'],
loading: !!row.publishing,
onUpdateValue: () => handlePublish(row),
})
},
},
{ title: '标题', key: 'title', width: 150, ellipsis: { tooltip: true } },
{ title: '分类', key: 'category', width: 80, ellipsis: { tooltip: true } },
{ title: '创建人', key: 'author', width: 80 },
{
title: '创建时间',
key: 'createDate',
width: 150,
render(row) {
return h('span', formatDateTime(row['createDate']))
},
},
{
title: '最后更新时间',
key: 'updateDate',
width: 150,
render(row) {
return h('span', formatDateTime(row['updateDate']))
},
},
{
title: '操作',
key: 'actions',
width: 240,
align: 'center',
fixed: 'right',
render(row) {
return [
h(
NButton,
{
size: 'small',
type: 'primary',
secondary: true,
onClick: () => handleView(row),
},
{ default: () => '查看', icon: renderIcon('majesticons:eye-line', { size: 14 }) }
),
h(
NButton,
{
size: 'small',
type: 'primary',
style: 'margin-left: 15px;',
onClick: () => handleEdit(row),
},
{ default: () => '编辑', icon: renderIcon('material-symbols:edit-outline', { size: 14 }) }
),
function handleCheck(rowKeys) {
h(
NButton,
{
size: 'small',
type: 'error',
style: 'margin-left: 15px;',
onClick: () => handleDelete(row.id),
},
{ default: () => '删除', icon: renderIcon('material-symbols:delete-outline', { size: 14 }) }
),
]
},
},
]
//
function onChecked(rowKeys) {
if (rowKeys.length) $message.info(`选中${rowKeys.join(' ')}`)
}
//
function handlePublish(row) {
if (isNullOrUndef(row.id)) return
row.publishing = true
setTimeout(() => {
row.isPublish = !row.isPublish
row.publishing = false
$message?.success(row.isPublish ? '已发布' : '已取消发布')
}, 1000)
}
const {
modalVisible,
modalAction,
modalTitle,
modalLoading,
handleAdd,
handleDelete,
handleEdit,
handleView,
handleSave,
modalForm,
modalFormRef,
} = useCRUD({
name: '文章',
initForm: { author: '大脸怪' },
doCreate: api.addPost,
doDelete: api.deletePost,
doUpdate: api.updatePost,
refresh: () => $table.value?.handleSearch(),
})
</script>

View File

@ -1,8 +1,8 @@
import { h } from 'vue'
import { NButton, NSwitch } from 'naive-ui'
import { formatDateTime } from '@/utils'
import api from './api'
import { renderIcon } from '@/utils/icon'
import api from './api'
export const usePostTable = () => {
// refs
@ -31,6 +31,21 @@ export const usePostTable = () => {
}
}
function handleEdit(row) {
if (row && row.id) {
$dialog.confirm({
content: '确定删除?',
confirm() {
$message.success('删除成功')
initTableData()
},
cancel() {
$message.success('已取消')
},
})
}
}
async function handleRecommend(row) {
if (row && row.id) {
row.recommending = true
@ -84,7 +99,7 @@ export const usePostTable = () => {
{
title: '推荐',
key: 'isRecommend',
width: 100,
width: 120,
align: 'center',
fixed: 'right',
render(row) {
@ -100,7 +115,7 @@ export const usePostTable = () => {
{
title: '发布',
key: 'isPublish',
width: 100,
width: 120,
align: 'center',
fixed: 'right',
render(row) {
@ -116,11 +131,20 @@ export const usePostTable = () => {
{
title: '操作',
key: 'actions',
width: 120,
width: 200,
align: 'center',
fixed: 'right',
render(row) {
return [
h(
NButton,
{
size: 'small',
type: 'primary',
onClick: () => handleEdit(row),
},
{ default: () => '编辑', icon: renderIcon('material-symbols:edit-outline', { size: 14 }) }
),
h(
NButton,
{