列表页标准示例 — Article
源文件:
admin/src/views/content/article/index.vue(生成代码必须模仿本示例的结构与风格)本示例为手写风格范本,用于学习结构与分层;经 make:crud 生成的骨架方法名以骨架为准,做业务增量时不要为对齐本示例而改名。
完整代码
vue
<template>
<div class="article-container">
<!-- 搜索区域 -->
<el-card class="search-card" shadow="never">
<el-form :model="searchForm" inline class="search-form">
<el-form-item :label="$t('articleMgmt.articleTitle')">
<el-input
v-model="searchForm.keyword"
:placeholder="$t('articleMgmt.titlePlaceholder')"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item :label="$t('articleMgmt.category')">
<el-select
v-model="searchForm.category_id"
:placeholder="$t('articleMgmt.categoryPlaceholder')"
clearable
style="width: 160px"
>
<el-option
v-for="item in categoryOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item :label="$t('common.status')">
<el-select
v-model="searchForm.status"
:placeholder="$t('common.selectPlaceholder')"
clearable
style="width: 120px"
>
<el-option :label="$t('articleMgmt.published')" :value="1" />
<el-option :label="$t('articleMgmt.draft')" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
{{ $t('common.search') }}
</el-button>
<el-button @click="resetSearch">
<el-icon><Refresh /></el-icon>
{{ $t('common.reset') }}
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作区域 -->
<el-card class="table-card" shadow="never">
<div class="table-header">
<div class="table-title">{{ $t('articleMgmt.title') }}</div>
<div class="table-actions">
<el-button v-has-perm="['article.create']" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
{{ $t('common.add') }}
</el-button>
</div>
</div>
<el-table v-loading="loading" :data="list">
<el-table-column
:label="$t('articleMgmt.articleTitle')"
prop="title"
min-width="250"
show-overflow-tooltip
/>
<el-table-column :label="$t('articleMgmt.cover')" width="90">
<template #default="{ row }">
<el-image
v-if="row.cover"
:src="appStore.getImageUrl(row.cover)"
style="width: 60px; height: 60px"
fit="cover"
:preview-src-list="[appStore.getImageUrl(row.cover)]"
preview-teleported
/>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column
:label="$t('articleMgmt.category')"
prop="category_name"
width="120"
/>
<el-table-column :label="$t('articleMgmt.tags')" width="180">
<template #default="{ row }">
<template v-if="row.tags && row.tags.length">
<el-tag
v-for="tag in row.tags"
:key="tag"
size="small"
class="tag-item"
>
{{ tag }}
</el-tag>
</template>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column
:label="$t('articleMgmt.viewCount')"
prop="view_count"
width="90"
/>
<el-table-column :label="$t('common.status')" prop="status" width="100">
<template #default="{ row }">
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
:disabled="!userStore.hasPermission('article.status')"
@change="handleStatusChange(row)"
/>
</template>
</el-table-column>
<el-table-column
:label="$t('articleMgmt.publishAt')"
prop="publish_at"
width="160"
/>
<el-table-column :label="$t('common.operation')" width="150" fixed="right">
<template #default="{ row }">
<el-button
v-has-perm="['article.update']"
type="primary"
size="small"
text
@click="handleEdit(row)"
>
{{ $t('common.edit') }}
</el-button>
<el-button
v-has-perm="['article.delete']"
type="danger"
size="small"
text
@click="handleDelete(row.id, row.title)"
>
{{ $t('common.delete') }}
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.limit"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
class="pagination"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</el-card>
<!-- 表单抽屉 -->
<ArticleForm
v-model="formVisible"
:form-data="formData"
:category-options="categoryOptions"
@success="getList"
/>
</div>
</template>
<script setup lang="ts" name="ArticleList">
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { articleApi } from '@/api/article'
import { articleCategoryApi } from '@/api/article-category'
import { useListPage } from '@/hooks/useListPage'
import { useAppStore, useUserStore } from '@/store'
import ArticleForm from './components/ArticleForm.vue'
const { t } = useI18n()
const userStore = useUserStore()
const appStore = useAppStore()
// 使用统一的列表页 composable
const {
list,
loading,
pagination,
searchForm,
getList,
handleSearch,
resetSearch,
handleSizeChange,
handlePageChange,
handleDelete,
handleStatusChange
} = useListPage<any, { keyword: string; category_id?: number; status?: number }>({
fetchFn: (params) => articleApi.getList(params),
deleteFn: (id) => articleApi.delete(id),
updateStatusFn: (id, status) => articleApi.updateStatus(id, status),
defaultSearchForm: { keyword: '', category_id: undefined, status: undefined }
})
// 分类选项
const categoryOptions = ref<any[]>([])
// 弹窗相关
const formVisible = ref(false)
const formData = ref<Record<string, any>>({})
// 获取分类选项
const getCategoryOptions = async () => {
try {
const res = await articleCategoryApi.getOptions()
categoryOptions.value = res.data
} catch {
// silent
}
}
// 新增
const handleAdd = () => {
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
const defaultTime = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
formData.value = { status: 0, tags: [], publish_at: defaultTime }
formVisible.value = true
}
// 编辑
const handleEdit = async (row: any) => {
try {
const res = await articleApi.getDetail(row.id)
formData.value = { ...res.data }
formVisible.value = true
} catch {
ElMessage.error(t('message.fetchFailed'))
}
}
onMounted(() => {
getCategoryOptions()
})
</script>
<style lang="scss" scoped>
.article-container {
.table-card {
.tag-item {
margin-right: 4px;
margin-bottom: 2px;
}
}
}
</style>约定:每个业务模块对应一个独立的 API 文件(
admin/src/api/<module>.ts),列表页、表单组件、其他关联组件统一从该文件导入请求方法,不在组件内散落myRequest裸调用。以下是本模块对应的admin/src/api/article.ts完整代码:
typescript
import { myRequest } from '@/utils/request'
export const articleApi = {
getList(params?: any) {
return myRequest.get('/adminapi/article/list', { params })
},
getDetail(id: number) {
return myRequest.get(`/adminapi/article/detail/${id}`)
},
create(data: any) {
return myRequest.post('/adminapi/article', data)
},
update(id: number, data: any) {
return myRequest.put(`/adminapi/article/${id}`, data)
},
delete(id: number) {
return myRequest.delete(`/adminapi/article/${id}`)
},
updateStatus(id: number, status: number) {
return myRequest.put(`/adminapi/article/${id}/status`, { status })
}
}要点注解
- 页面组件放在
admin/src/views/content/article/index.vue,路径与后端菜单/路由的模块层级(content分组下的article模块)一一对应,新增列表页需按同样的目录层级放置。 - 第 180 行
<script setup lang="ts" name="ArticleList">:统一使用<script setup>+ Composition API + TypeScript,组件命名通过name="ArticleList"显式声明(供keep-alive/调试使用),不用 Options API。 - 第 186-189 行:所有请求方法从
@/api/article、@/api/article-category等模块专属 API 文件导入(articleApi、articleCategoryApi),组件内部不直接import { myRequest } from '@/utils/request'发起裸请求;API 文件内部才统一使用myRequest.get/post/put/delete(见api/article.ts),实现「一模块一 API 文件」。 - 第 198-215 行:列表页的加载态、分页、搜索表单、增删改状态切换全部收敛进
useListPage()composable,只需传入fetchFn/deleteFn/updateStatusFn/defaultSearchForm,页面内不重复手写page/limit/total等分页状态和请求防抖逻辑。 - 第 158-167 行分页组件使用
v-model:current-page、v-model:page-size双向绑定pagination.page/limit,配合@size-change/@current-change调用 composable 暴露的handleSizeChange/handlePageChange,不自行拼接分页参数。 - 第 65-155 行
<el-table>结构:每列用<el-table-column>声明label/prop,需要自定义渲染(图片、标签、开关、操作按钮)的列用#default="{ row }"具名插槽,操作列固定fixed="right"。 - 第 58、115-125、121、136、145 行:按钮用
v-has-perm="['article.xxx']"指令、状态开关用userStore.hasPermission('article.xxx')方法做前端权限控制,权限点命名与后端#[Permission('article.xxx')]完全一致;状态列直接用<el-switch>绑定row.status,@change="handleStatusChange(row)"调用 composable 封装的updateStatusFn,无需额外弹窗确认。 - 第 171-176 行表单以抽屉组件
<ArticleForm>形式内嵌在列表页底部,通过v-model="formVisible"控制显隐、:form-data="formData"传入编辑数据、@success="getList"在提交成功后刷新列表。