Skip to content

列表页标准示例 — 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 文件导入(articleApiarticleCategoryApi),组件内部不直接 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-pagev-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" 在提交成功后刷新列表。

基于 MIT 许可发布