瀏覽代碼

feat(news): 添加新闻管理功能模块

- 实现文章增删改查功能,包括标题、分类、封面、内容等字段管理
- 开发分类管理面板,支持分类的新增、编辑、删除和状态管理
- 集成富文本编辑器和文件管理器组件,实现图文内容编辑
- 添加文章列表展示,支持按分类和状态筛选查询
- 实现分页功能和响应式表格数据展示
- 创建统一的新闻管理视图界面,集成文章和分类管理面板
- 封装useNews组合式API,统一处理新闻相关的数据请求
- 添加表单验证和错误处理机制,确保数据完整性
runphp 1 天之前
父節點
當前提交
9e6aed50d4

+ 433 - 0
frontend/admin/components/article/ArticleEdit.vue

@@ -0,0 +1,433 @@
+<template>
+  <div class="article-edit">
+    <el-card class="article-card">
+      <template #header>
+        <div class="card-header">
+          <h3>{{ isEdit ? '编辑文章' : '新增文章' }}</h3>
+        </div>
+      </template>
+      
+      <el-form
+        ref="formRef"
+        :model="form"
+        :rules="rules"
+        label-width="100px"
+        v-loading="loading"
+      >
+        <el-form-item label="文章分类" prop="category_id">
+          <el-select v-model="form.category_id" placeholder="请选择分类" style="width: 100%">
+            <el-option
+              v-for="item in categories"
+              :key="item.id"
+              :label="item.name"
+              :value="item.id"
+            />
+          </el-select>
+        </el-form-item>
+
+        <el-form-item label="文章标题" prop="title">
+          <el-input v-model="form.title" placeholder="请输入文章标题" maxlength="128" show-word-limit />
+        </el-form-item>
+
+        <el-form-item label="封面图片" prop="cover">
+          <div class="cover-upload-container">
+            <div class="cover-preview" @click="fileManagerVisible = true">
+              <img v-if="form.cover" :src="form.cover" class="cover-image" />
+              <div v-else class="cover-placeholder">
+                <el-icon class="cover-uploader-icon"><Plus /></el-icon>
+                <div class="placeholder-text">选择封面图片</div>
+              </div>
+            </div>
+            <div class="cover-actions">
+              <el-button size="small" @click="fileManagerVisible = true">选择图片</el-button>
+              <el-button v-if="form.cover" size="small" type="danger" @click="removeCover">删除封面</el-button>
+            </div>
+          </div>
+          <div class="tip-text">建议上传尺寸: 16:9, 最佳尺寸: 1200x675</div>
+          
+          <FileManager
+            v-model="fileManagerVisible"
+            :multiple="false"
+            accept="image/*"
+            :max-size="2 * 1024 * 1024"
+            @confirm="handleFileManagerConfirm"
+          />
+        </el-form-item>
+
+        <el-form-item label="作者" prop="author">
+          <el-input v-model="form.author" placeholder="请输入作者名称" maxlength="64" />
+        </el-form-item>
+
+        <el-form-item label="摘要" prop="summary">
+          <el-input
+            v-model="form.summary"
+            type="textarea"
+            placeholder="请输入文章摘要"
+            maxlength="255"
+            :rows="3"
+            show-word-limit
+          />
+        </el-form-item>
+
+        <el-form-item label="内容" prop="content">
+          <div class="editor-container">
+            <RichEditor 
+              v-model="form.content" 
+              :height="400" 
+              :uploadRequest="uploadImage"
+            />
+          </div>
+        </el-form-item>
+
+        <el-form-item label="置顶" prop="is_top">
+          <el-radio-group v-model="form.is_top">
+            <el-radio :label="1">是</el-radio>
+            <el-radio :label="0">否</el-radio>
+          </el-radio-group>
+        </el-form-item>
+
+        <el-form-item label="状态" prop="status">
+          <el-radio-group v-model="form.status">
+            <el-radio :label="0">草稿</el-radio>
+            <el-radio :label="1">发布</el-radio>
+            <el-radio :label="2">下线</el-radio>
+          </el-radio-group>
+        </el-form-item>
+
+        <el-form-item>
+          <el-button type="primary" @click="submitForm" :loading="submitLoading">保存</el-button>
+          <el-button @click="goBack">取消</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { defineComponent, ref, reactive, onMounted, computed } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { ElMessage } from 'element-plus'
+import { Plus } from '@element-plus/icons-vue'
+import RichEditor from '@/components/RichEditor.vue'
+import FileManager from '@/components/flysystem/FileManager.vue'
+import request from '@/utils/request'
+import { useNews } from '../../composables/useNews'
+
+export default defineComponent({
+  name: 'ArticleEdit',
+  components: {
+    Plus,
+    RichEditor,
+    FileManager
+  },
+  setup() {
+    const route = useRoute()
+    const router = useRouter()
+    const formRef = ref(null)
+    const submitLoading = ref(false)
+    const fileManagerVisible = ref(false)
+    
+    const { 
+      loading, 
+      categories, 
+      getCategoryList, 
+      getArticleDetail, 
+      createArticle, 
+      updateArticle 
+    } = useNews()
+
+    // 是否是编辑模式
+    const isEdit = computed(() => {
+      return !!route.params.id
+    })
+
+    // 表单数据
+    const form = reactive({
+      id: null,
+      category_id: '',
+      title: '',
+      cover: '',
+      summary: '',
+      content: '',
+      author: '',
+      is_top: 0,
+      status: 0
+    })
+
+    // 表单验证规则
+    const rules = {
+      category_id: [
+        { required: true, message: '请选择文章分类', trigger: 'change' }
+      ],
+      title: [
+        { required: true, message: '请输入文章标题', trigger: 'blur' },
+        { min: 1, max: 128, message: '长度在 1 到 128 个字符', trigger: 'blur' }
+      ],
+      summary: [
+        { max: 255, message: '最多 255 个字符', trigger: 'blur' }
+      ],
+      content: [
+        { required: true, message: '请输入文章内容', trigger: 'blur' }
+      ],
+      author: [
+        { max: 64, message: '最多 64 个字符', trigger: 'blur' }
+      ]
+    }
+
+    // 初始化
+    const init = async () => {
+      await getCategoryList()
+      console.log('已加载分类数据:', categories.value)
+      
+      // 检查分类数据是否正确
+      if (categories.value && categories.value.length > 0) {
+        console.log('第一个分类项:', categories.value[0])
+        console.log('分类项字段:', Object.keys(categories.value[0]))
+      } else {
+        console.error('分类数据为空或格式不正确')
+      }
+      
+      if (isEdit.value) {
+        // 编辑模式,加载文章详情
+        const id = route.params.id
+        const article = await getArticleDetail(id)
+        
+        if (article) {
+          // 填充表单
+          Object.keys(form).forEach(key => {
+            if (article[key] !== undefined) {
+              form[key] = article[key]
+            }
+          })
+        }
+      }
+    }
+
+    // 上传图片(用于富文本编辑器)
+    const uploadImage = async (file) => {
+      // 文件大小限制: 2MB
+      const isLt2M = file.size / 1024 / 1024 < 2
+      if (!isLt2M) {
+        ElMessage.error('图片大小不能超过 2MB!')
+        return Promise.reject('图片大小不能超过 2MB')
+      }
+      
+      try {
+        // 创建FormData对象
+        const formData = new FormData()
+        formData.append('file', file)
+        
+        // 调用上传接口
+        const response = await request.post('/news/upload', formData, {
+          headers: {
+            'Content-Type': 'multipart/form-data'
+          }
+        })
+        
+        if (response.code === 200) {
+          return response.data.url
+        } else {
+          ElMessage.error(response.msg || '上传失败')
+          return Promise.reject('上传失败')
+        }
+      } catch (error) {
+        console.error('上传图片失败:', error)
+        ElMessage.error('上传图片失败')
+        return Promise.reject('上传失败')
+      }
+    }
+
+    // 文件管理器确认选择
+    const handleFileManagerConfirm = (result) => {
+      console.log('文件管理器返回结果:', result)
+      
+      // 单选模式下,result是一个文件对象;多选模式下,result是数组
+      if (result) {
+        const file = Array.isArray(result) ? result[0] : result
+        if (file && file.file_url) {
+          form.cover = file.file_url
+          ElMessage.success('封面图片选择成功')
+        } else {
+          ElMessage.error('文件信息不完整')
+        }
+      }
+      fileManagerVisible.value = false
+    }
+
+    // 删除封面图片
+    const removeCover = () => {
+      form.cover = ''
+      ElMessage.success('封面图片已删除')
+    }
+
+    // 提交表单
+    const submitForm = async () => {
+      if (!formRef.value) return
+      
+      await formRef.value.validate(async (valid) => {
+        if (valid) {
+          submitLoading.value = true
+          try {
+            let success = false
+            console.log('提交表单数据:', {
+              category_id: form.category_id,
+              title: form.title,
+              cover: form.cover ? '有封面图' : '无封面图',
+              summary: form.summary,
+              content: form.content ? '有内容' : '无内容',
+              author: form.author,
+              publish_time: form.publish_time,
+              sort: form.sort,
+              is_top: form.is_top,
+              status: form.status
+            })
+            
+            if (isEdit.value) {
+              // 编辑
+              success = await updateArticle(form.id, {
+                category_id: form.category_id,
+                title: form.title,
+                cover: form.cover,
+                summary: form.summary,
+                content: form.content,
+                author: form.author,
+                is_top: form.is_top,
+                status: form.status
+              })
+            } else {
+              // 新增
+              success = await createArticle({
+                category_id: form.category_id,
+                title: form.title,
+                cover: form.cover,
+                summary: form.summary,
+                content: form.content,
+                author: form.author,
+                is_top: form.is_top,
+                status: form.status
+              })
+            }
+            
+            console.log('提交结果:', success)
+            
+            if (success) {
+              goBack()
+            }
+          } finally {
+            submitLoading.value = false
+          }
+        }
+      })
+    }
+
+    // 返回列表页
+    const goBack = () => {
+      router.push('/news?tab=article')
+    }
+
+    onMounted(() => {
+      init()
+    })
+
+    return {
+      isEdit,
+      loading,
+      categories,
+      form,
+      rules,
+      formRef,
+      submitLoading,
+      uploadImage,
+      fileManagerVisible,
+      handleFileManagerConfirm,
+      removeCover,
+      submitForm,
+      goBack
+    }
+  }
+})
+</script>
+
+<style scoped>
+.article-edit {
+  padding: 20px;
+}
+
+.article-card {
+  margin-bottom: 20px;
+}
+
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.cover-upload-container {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.cover-actions {
+  display: flex;
+  gap: 10px;
+}
+
+.cover-preview {
+  cursor: pointer;
+  transition: all 0.3s;
+}
+
+.cover-preview:hover {
+  opacity: 0.8;
+}
+
+.cover-placeholder {
+  width: 178px;
+  height: 100px;
+  border: 1px dashed #d9d9d9;
+  border-radius: 6px;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  cursor: pointer;
+  transition: border-color 0.3s;
+  background-color: #fafafa;
+}
+
+.cover-placeholder:hover {
+  border-color: #409eff;
+}
+
+.cover-uploader-icon {
+  font-size: 28px;
+  color: #8c939d;
+  margin-bottom: 8px;
+}
+
+.placeholder-text {
+  font-size: 12px;
+  color: #8c939d;
+}
+
+.cover-image {
+  width: 178px;
+  height: 100px;
+  object-fit: cover;
+  border-radius: 6px;
+  cursor: pointer;
+}
+
+.editor-container {
+  border: 1px solid #dcdfe6;
+  border-radius: 4px;
+}
+
+.tip-text {
+  font-size: 12px;
+  color: #999;
+  margin-top: 5px;
+}
+</style>

+ 263 - 0
frontend/admin/components/article/ArticlePanel.vue

@@ -0,0 +1,263 @@
+<template>
+  <div class="article-panel">
+    <div class="article-header">
+      <div class="filter-container">
+        <el-select
+          v-model="filterForm.category_id"
+          placeholder="全部分类"
+          clearable
+          @change="handleFilter"
+        >
+          <el-option label="全部分类" value="" />
+          <el-option
+            v-for="item in categories"
+            :key="item.id"
+            :label="item.name"
+            :value="item.id"
+          />
+        </el-select>
+        <el-select
+          v-model="filterForm.status"
+          placeholder="全部状态"
+          clearable
+          @change="handleFilter"
+        >
+          <el-option label="全部状态" value="" />
+          <el-option label="草稿" :value="0" />
+          <el-option label="已发布" :value="1" />
+          <el-option label="已下线" :value="2" />
+        </el-select>
+      </div>
+      <el-button type="primary" @click="handleAddArticle">新增文章</el-button>
+    </div>
+    
+    <el-table
+      v-loading="loading"
+      :data="articles"
+      border
+      style="width: 100%">
+      <el-table-column prop="id" label="ID" width="80" />
+      <el-table-column label="封面" width="120">
+        <template #default="scope">
+          <el-image
+            v-if="scope.row.cover"
+            :src="scope.row.cover"
+            :preview-src-list="[scope.row.cover]"
+            fit="cover"
+            style="width: 80px; height: 45px"
+          />
+          <span v-else>无封面</span>
+        </template>
+      </el-table-column>
+      <el-table-column prop="title" label="标题" show-overflow-tooltip />
+      <el-table-column prop="category_name" label="分类" width="120" />
+      <el-table-column label="置顶" width="80">
+        <template #default="scope">
+          <el-tag :type="scope.row.is_top === 1 ? 'danger' : 'info'" size="small">
+            {{ scope.row.is_top === 1 ? '是' : '否' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="状态" width="80">
+        <template #default="scope">
+          <el-tag :type="getStatusType(scope.row.status)" size="small">
+            {{ getStatusText(scope.row.status) }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="创建时间" width="160">
+        <template #default="scope">
+          {{ formatDateTime(scope.row.create_time) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" width="200" fixed="right">
+        <template #default="scope">
+          <el-button type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button>
+          <el-button type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <div class="pagination-container">
+      <el-pagination
+        v-model:current-page="pagination.page"
+        v-model:page-size="pagination.limit"
+        :page-sizes="[10, 20, 50, 100]"
+        layout="total, sizes, prev, pager, next, jumper"
+        :total="pagination.total"
+        @size-change="handleSizeChange"
+        @current-change="handleCurrentChange"
+      />
+    </div>
+  </div>
+</template>
+
+<script>
+import { defineComponent, ref, reactive, onMounted, computed } from 'vue'
+import { useRouter } from 'vue-router'
+import { ElMessageBox } from 'element-plus'
+import { useNews } from '../../composables/useNews'
+
+export default defineComponent({
+  name: 'ArticlePanel',
+  setup() {
+    const router = useRouter()
+    const { 
+      loading, 
+      articles, 
+      categories,
+      pagination, 
+      getArticleList, 
+      getCategoryList, 
+      deleteArticle 
+    } = useNews()
+
+    const filterForm = reactive({
+      category_id: '',
+      status: ''
+    })
+
+    // 获取文章列表
+    const loadData = async () => {
+      const params = {}
+      if (filterForm.category_id) {
+        params.category_id = filterForm.category_id
+      }
+      if (filterForm.status !== '') {
+        params.status = filterForm.status
+      }
+      await getArticleList(params)
+    }
+
+    // 获取状态文本
+    const getStatusText = (status) => {
+      switch (status) {
+        case 0: return '草稿'
+        case 1: return '已发布'
+        case 2: return '已下线'
+        default: return '未知'
+      }
+    }
+
+    // 获取状态类型
+    const getStatusType = (status) => {
+      switch (status) {
+        case 0: return 'info'
+        case 1: return 'success'
+        case 2: return 'warning'
+        default: return 'info'
+      }
+    }
+
+    // 筛选
+    const handleFilter = () => {
+      pagination.page = 1
+      loadData()
+    }
+
+    // 分页大小变化
+    const handleSizeChange = (val) => {
+      pagination.limit = val
+      loadData()
+    }
+
+    // 页码变化
+    const handleCurrentChange = (val) => {
+      pagination.page = val
+      loadData()
+    }
+
+    // 新增文章
+    const handleAddArticle = () => {
+      router.push('/news/article/create')
+    }
+
+    // 编辑文章
+    const handleEdit = (row) => {
+      router.push(`/news/article/edit/${row.id}`)
+    }
+
+    // 删除文章
+    const handleDelete = (row) => {
+      ElMessageBox.confirm('确定要删除该文章吗?删除后不可恢复', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(async () => {
+        const success = await deleteArticle(row.id)
+        if (success) {
+          loadData()
+        }
+      }).catch(() => {})
+    }
+
+    // 时间格式化函数
+    const formatDateTime = (dateTimeStr) => {
+      if (!dateTimeStr || dateTimeStr === '0001-01-01T00:00:00Z') {
+        return '-'
+      }
+      
+      const date = new Date(dateTimeStr)
+      // 转换为东八区时间
+      const options = {
+        year: 'numeric',
+        month: '2-digit',
+        day: '2-digit',
+        hour: '2-digit',
+        minute: '2-digit',
+        second: '2-digit',
+        timeZone: 'Asia/Shanghai'
+      }
+      
+      return date.toLocaleString('zh-CN', options)
+    }
+
+    // 初始化
+    onMounted(async () => {
+      await getCategoryList()
+      await loadData()
+    })
+
+    return {
+      loading,
+      articles,
+      categories,
+      pagination,
+      filterForm,
+      getStatusText,
+      getStatusType,
+      formatDateTime,
+      handleFilter,
+      handleSizeChange,
+      handleCurrentChange,
+      handleAddArticle,
+      handleEdit,
+      handleDelete
+    }
+  }
+})
+</script>
+
+<style scoped>
+.article-panel {
+  padding: 20px 0;
+}
+
+.article-header {
+  margin-bottom: 20px;
+  display: flex;
+  justify-content: space-between;
+}
+
+.filter-container {
+  width: 380px;
+  display: flex;
+  gap: 10px;
+}
+
+.pagination-container {
+  margin-top: 20px;
+  display: flex;
+  justify-content: flex-end;
+}
+</style>

+ 16 - 0
frontend/admin/components/article/index.vue

@@ -0,0 +1,16 @@
+<template>
+  <router-view v-if="$route.name !== 'NewsArticle'" />
+  <ArticlePanel v-else />
+</template>
+
+<script>
+import { defineComponent } from 'vue'
+import ArticlePanel from './ArticlePanel.vue'
+
+export default defineComponent({
+  name: 'NewsArticleLayout',
+  components: {
+    ArticlePanel
+  }
+})
+</script>

+ 214 - 0
frontend/admin/components/category/CategoryPanel.vue

@@ -0,0 +1,214 @@
+<template>
+  <div class="category-panel">
+    <div class="category-header">
+      <el-button type="primary" @click="handleAddCategory">新增分类</el-button>
+    </div>
+    
+    <el-table
+      v-loading="loading"
+      :data="categories"
+      border
+      style="width: 100%">
+      <el-table-column prop="id" label="ID" width="80" />
+      <el-table-column prop="name" label="分类名称" />
+      <el-table-column prop="sort" label="排序" width="100" />
+      <el-table-column label="状态" width="100">
+        <template #default="scope">
+          <el-tag :type="scope.row.status === 1 ? 'success' : 'info'">
+            {{ scope.row.status === 1 ? '启用' : '禁用' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" width="200">
+        <template #default="scope">
+          <el-button type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button>
+          <el-button type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- 分类编辑对话框 -->
+    <el-dialog 
+      v-model="dialogVisible" 
+      :title="isEdit ? '编辑分类' : '新增分类'" 
+      width="500px"
+      destroy-on-close>
+      <el-form 
+        ref="formRef"
+        :model="form"
+        :rules="rules"
+        label-width="100px">
+        <el-form-item label="分类名称" prop="name">
+          <el-input v-model="form.name" placeholder="请输入分类名称" />
+        </el-form-item>
+        <el-form-item label="排序" prop="sort">
+          <el-input-number v-model="form.sort" :min="0" :max="9999" />
+        </el-form-item>
+        <el-form-item label="状态" prop="status">
+          <el-radio-group v-model="form.status">
+            <el-radio :label="1">启用</el-radio>
+            <el-radio :label="0">禁用</el-radio>
+          </el-radio-group>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="submitForm" :loading="submitLoading">确认</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { defineComponent, ref, reactive, onMounted } from 'vue'
+import { ElMessageBox } from 'element-plus'
+import { useNews } from '../../composables/useNews'
+
+export default defineComponent({
+  name: 'CategoryPanel',
+  setup() {
+    const { 
+      loading, 
+      categories, 
+      getCategoryList, 
+      createCategory, 
+      updateCategory, 
+      deleteCategory 
+    } = useNews()
+
+    const dialogVisible = ref(false)
+    const isEdit = ref(false)
+    const submitLoading = ref(false)
+    const formRef = ref(null)
+    
+    const form = reactive({
+      id: null,
+      name: '',
+      sort: 0,
+      status: 1
+    })
+
+    const rules = {
+      name: [
+        { required: true, message: '请输入分类名称', trigger: 'blur' },
+        { min: 1, max: 64, message: '长度在 1 到 64 个字符', trigger: 'blur' }
+      ],
+      sort: [
+        { type: 'number', message: '排序必须为数字', trigger: 'blur' }
+      ],
+      status: [
+        { required: true, message: '请选择状态', trigger: 'change' }
+      ]
+    }
+
+    // 获取分类列表
+    const loadCategoryList = async () => {
+      const success = await getCategoryList()
+      console.log('分类列表加载结果:', success)
+    }
+
+    // 新增分类
+    const handleAddCategory = () => {
+      isEdit.value = false
+      form.id = null
+      form.name = ''
+      form.sort = 0
+      form.status = 1
+      dialogVisible.value = true
+    }
+
+    // 编辑分类
+    const handleEdit = (row) => {
+      isEdit.value = true
+      form.id = row.id
+      form.name = row.name
+      form.sort = row.sort !== undefined ? row.sort : 0
+      form.status = row.status !== undefined ? row.status : 1
+      dialogVisible.value = true
+    }
+
+    // 删除分类
+    const handleDelete = (row) => {
+      ElMessageBox.confirm('确定要删除该分类吗?删除后不可恢复', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(async () => {
+        const success = await deleteCategory(row.id)
+        if (success) {
+          loadCategoryList()
+        }
+      }).catch(() => {})
+    }
+
+    // 提交表单
+    const submitForm = async () => {
+      if (!formRef.value) return
+      
+      await formRef.value.validate(async (valid) => {
+        if (valid) {
+          submitLoading.value = true
+          try {
+            if (isEdit.value) {
+              // 编辑
+              const result = await updateCategory(form.id, {
+                name: form.name,
+                sort: form.sort,
+                status: form.status
+              })
+              if (result !== null) {
+                dialogVisible.value = false
+                loadCategoryList()
+              }
+            } else {
+              // 新增
+              const result = await createCategory({
+                name: form.name,
+                sort: form.sort,
+                status: form.status
+              })
+              if (result !== null) {
+                dialogVisible.value = false
+                loadCategoryList()
+              }
+            }
+          } finally {
+            submitLoading.value = false
+          }
+        }
+      })
+    }
+
+    onMounted(() => {
+      loadCategoryList()
+    })
+
+    return {
+      loading,
+      categories,
+      dialogVisible,
+      isEdit,
+      form,
+      rules,
+      formRef,
+      submitLoading,
+      handleAddCategory,
+      handleEdit,
+      handleDelete,
+      submitForm
+    }
+  }
+})
+</script>
+
+<style scoped>
+.category-panel {
+  padding: 20px 0;
+}
+
+.category-header {
+  margin-bottom: 20px;
+  display: flex;
+  justify-content: flex-end;
+}
+</style>

+ 388 - 0
frontend/admin/composables/useNews.js

@@ -0,0 +1,388 @@
+import { ref } from 'vue'
+import request from '@/utils/request'
+import { ElMessage } from 'element-plus'
+
+export function useNews() {
+  const loading = ref(false)
+  const articles = ref([])
+  const article = ref({})
+  const categories = ref([])
+  const category = ref({})
+  const pagination = ref({
+    page: 1,
+    limit: 20,
+    total: 0
+  })
+
+  // 获取文章列表
+  const getArticleList = async (params = {}) => {
+    loading.value = true
+    try {
+      const response = await request.get('/news/news', {
+        params: {
+          page: Number(pagination.value.page),
+          limit: Number(pagination.value.limit),
+          ...params
+        }
+      })
+      console.log('Articles API response:', response.data);
+      
+      // 处理各种可能的响应格式
+      let articleData = [];
+      let totalCount = 0;
+      
+      if (Array.isArray(response.data)) {
+        // 直接返回数组
+        articleData = response.data;
+        totalCount = response.data.length;
+      } else if (response.data && response.data.data) {
+        if (Array.isArray(response.data.data)) {
+          // 返回 {code, data: [...]} 格式
+          articleData = response.data.data;
+          totalCount = response.data.data.length;
+        } else if (response.data.data.list && Array.isArray(response.data.data.list)) {
+          // 返回 {code, data: {list: [...], total: number}} 格式
+          articleData = response.data.data.list;
+          totalCount = response.data.data.total || articleData.length;
+        }
+      }
+      
+      // 转换字段名称为小写
+      const formattedList = convertKeysToLowerCase(articleData);
+      console.log('Formatted article data:', formattedList);
+      
+      articles.value = formattedList;
+      pagination.value.total = totalCount;
+      
+      if (articles.value.length === 0) {
+        console.log('没有文章数据');
+      } else {
+        console.log('文章数据加载成功,数量:', articles.value.length);
+      }
+      
+      return true; // 返回成功状态
+    } catch (error) {
+      console.error('获取文章列表失败:', error)
+      ElMessage.error('获取文章列表失败')
+      return false;
+    } finally {
+      loading.value = false
+    }
+  }
+
+  // 获取文章详情
+  const getArticleDetail = async (id) => {
+    loading.value = true
+    try {
+      console.log('获取文章详情,ID:', id)
+      const response = await request.get(`/news/news/${id}`)
+      console.log('文章详情原始响应:', response)
+      
+      // 响应拦截器已经返回了处理后的数据,不需要再访问 .data
+      console.log('文章详情响应处理后:', response)
+      
+      if (response && response.code === 200) {
+        const articleData = response.data || {}
+        article.value = articleData
+        // 转换字段名称
+        const convertedData = convertKeysToLowerCase(articleData)
+        console.log('转换后的文章详情:', convertedData)
+        return convertedData
+      } else {
+        ElMessage.error(response?.msg || '获取文章详情失败')
+        return null
+      }
+    } catch (error) {
+      console.error('获取文章详情失败:', error)
+      ElMessage.error('获取文章详情失败')
+      return null
+    } finally {
+      loading.value = false
+    }
+  }
+
+  // 创建文章
+  const createArticle = async (articleData) => {
+    loading.value = true
+    try {
+      console.log('创建文章数据:', articleData)
+      const response = await request.post('/news/news', articleData)
+      console.log('创建文章原始响应:', response)
+      
+      // 响应拦截器已经返回了处理后的数据,不需要再访问 .data
+      console.log('创建文章响应处理后:', response)
+      
+      // 后端返回成功
+      if (response && response.code === 200) {
+        ElMessage.success('创建文章成功')
+        // 存储数据并返回true表示成功
+        article.value = response.data || {}
+        return true
+      } else {
+        ElMessage.error(response?.msg || '创建文章失败')
+        return false
+      }
+    } catch (error) {
+      console.error('创建文章失败:', error)
+      ElMessage.error('创建文章失败')
+      return false
+    } finally {
+      loading.value = false
+    }
+  }
+
+  // 更新文章
+  const updateArticle = async (id, articleData) => {
+    loading.value = true
+    try {
+      console.log('更新文章数据:', articleData)
+      const response = await request.put(`/news/news/${id}`, articleData)
+      console.log('更新文章原始响应:', response)
+      
+      // 响应拦截器已经返回了处理后的数据,不需要再访问 .data
+      console.log('更新文章响应处理后:', response)
+      
+      // 后端返回成功
+      if (response && response.code === 200) {
+        ElMessage.success('更新文章成功')
+        // 存储数据并返回true表示成功
+        article.value = response.data || {}
+        return true
+      } else {
+        ElMessage.error(response?.msg || '更新文章失败')
+        return false
+      }
+    } catch (error) {
+      console.error('更新文章失败:', error)
+      ElMessage.error('更新文章失败')
+      return false
+    } finally {
+      loading.value = false
+    }
+  }
+
+  // 删除文章
+  const deleteArticle = async (id) => {
+    loading.value = true
+    try {
+      const response = await request.delete(`/news/news/${id}`)
+      console.log('删除文章原始响应:', response)
+      
+      // 响应拦截器已经返回了处理后的数据,不需要再访问 .data
+      if (response && response.code === 200) {
+        ElMessage.success('删除文章成功')
+        return true
+      } else {
+        ElMessage.error(response?.msg || '删除文章失败')
+        return false
+      }
+    } catch (error) {
+      console.error('删除文章失败:', error)
+      ElMessage.error('删除文章失败')
+      return false
+    } finally {
+      loading.value = false
+    }
+  }
+
+  // 转换大写字段为小写
+  function convertKeysToLowerCase(data) {
+    if (!data) return [];
+    
+    if (Array.isArray(data)) {
+      return data.map(item => {
+        const newItem = {};
+        Object.keys(item).forEach(key => {
+          // 处理大写字段名
+          // 多种情况处理:ID、Id、NAME、Name等
+          let newKey = key;
+          if (key === key.toUpperCase()) {
+            // 全部大写的情况,如ID -> id
+            newKey = key.toLowerCase();
+          } else if (key.charAt(0) === key.charAt(0).toUpperCase()) {
+            // 首字母大写的情况,如Name -> name
+            newKey = key.charAt(0).toLowerCase() + key.slice(1);
+          }
+          newItem[newKey] = item[key];
+        });
+        
+        // 调试日志
+        if (newItem.id) {
+          console.log('转换后的分类项:', newItem);
+        }
+        
+        return newItem;
+      });
+    } else {
+      const newItem = {};
+      Object.keys(data).forEach(key => {
+        let newKey = key;
+        if (key === key.toUpperCase()) {
+          newKey = key.toLowerCase();
+        } else if (key.charAt(0) === key.charAt(0).toUpperCase()) {
+          newKey = key.charAt(0).toLowerCase() + key.slice(1);
+        }
+        newItem[newKey] = data[key];
+      });
+      return newItem;
+    }
+  }
+
+  // 获取分类列表
+  const getCategoryList = async () => {
+    loading.value = true
+    try {
+      const response = await request.get('/news/category')
+      console.log('Category API response:', response.data);
+      
+      // 处理各种可能的响应格式
+      let categoryData;
+      if (Array.isArray(response.data)) {
+        // 直接返回数组
+        categoryData = response.data;
+      } else if (response.data && response.data.data && Array.isArray(response.data.data)) {
+        // 返回 {code, data} 对象
+        categoryData = response.data.data;
+      } else if (response.data && response.data.code === 200) {
+        // 返回 {code, data} 对象
+        categoryData = response.data.data || [];
+      } else {
+        categoryData = [];
+      }
+      
+      // 转换字段名称为小写
+      const formattedData = convertKeysToLowerCase(categoryData);
+      console.log('Formatted category data:', formattedData);
+      categories.value = formattedData;
+      
+      if (categories.value.length === 0) {
+        console.log('没有分类数据');
+      } else {
+        console.log('分类数据加载成功,数量:', categories.value.length);
+      }
+      
+      return true;
+    } catch (error) {
+      console.error('获取分类列表失败:', error);
+      ElMessage.error('获取分类列表失败');
+      return false;
+    } finally {
+      loading.value = false;
+    }
+  }
+
+  // 获取分类详情
+  const getCategoryDetail = async (id) => {
+    loading.value = true
+    try {
+      const { data } = await request.get(`/news/category/${id}`)
+      if (data.code === 200) {
+        const formattedData = convertKeysToLowerCase(data.data);
+        category.value = formattedData;
+        return formattedData;
+      } else {
+        ElMessage.error(data.msg || '获取分类详情失败')
+        return null
+      }
+    } catch (error) {
+      console.error('获取分类详情失败:', error)
+      ElMessage.error('获取分类详情失败')
+      return null
+    } finally {
+      loading.value = false
+    }
+  }
+
+  // 创建分类
+  const createCategory = async (categoryData) => {
+    loading.value = true
+    try {
+      const response = await request.post('/news/category', categoryData)
+      console.log('创建分类原始响应:', response)
+      
+      // 响应拦截器已经返回了处理后的数据,不需要再访问 .data
+      if (response && response.code === 200) {
+        ElMessage.success('创建分类成功')
+        const formattedData = convertKeysToLowerCase(response.data);
+        return formattedData;
+      } else {
+        ElMessage.error(response?.msg || '创建分类失败')
+        return null
+      }
+    } catch (error) {
+      console.error('创建分类失败:', error)
+      ElMessage.error('创建分类失败')
+      return null
+    } finally {
+      loading.value = false
+    }
+  }
+
+  // 更新分类
+  const updateCategory = async (id, categoryData) => {
+    loading.value = true
+    try {
+      const response = await request.put(`/news/category/${id}`, categoryData)
+      console.log('更新分类原始响应:', response)
+      
+      // 响应拦截器已经返回了处理后的数据,不需要再访问 .data
+      if (response && response.code === 200) {
+        ElMessage.success('更新分类成功')
+        const formattedData = convertKeysToLowerCase(response.data);
+        return formattedData;
+      } else {
+        ElMessage.error(response?.msg || '更新分类失败')
+        return null
+      }
+    } catch (error) {
+      console.error('更新分类失败:', error)
+      ElMessage.error('更新分类失败')
+      return null
+    } finally {
+      loading.value = false
+    }
+  }
+
+  // 删除分类
+  const deleteCategory = async (id) => {
+    loading.value = true
+    try {
+      const response = await request.delete(`/news/category/${id}`)
+      console.log('删除分类原始响应:', response)
+      
+      // 响应拦截器已经返回了处理后的数据,不需要再访问 .data
+      if (response && response.code === 200) {
+        ElMessage.success('删除分类成功')
+        return true
+      } else {
+        ElMessage.error(response?.msg || '删除分类失败')
+        return false
+      }
+    } catch (error) {
+      console.error('删除分类失败:', error)
+      ElMessage.error('删除分类失败')
+      return false
+    } finally {
+      loading.value = false
+    }
+  }
+
+  return {
+    loading,
+    articles,
+    article,
+    categories,
+    category,
+    pagination,
+    getArticleList,
+    getArticleDetail,
+    createArticle,
+    updateArticle,
+    deleteArticle,
+    getCategoryList,
+    getCategoryDetail,
+    createCategory,
+    updateCategory,
+    deleteCategory
+  }
+}

+ 55 - 0
frontend/admin/index.js

@@ -0,0 +1,55 @@
+/**
+ * News 插件路由配置
+ * 新闻资讯管理系统
+ * 
+ * @author SixShop Team
+ * @version 1.0.0
+ */
+
+export default {
+  // 路由配置 - 单个路由对象
+  routes: {
+    path: '/news',
+    name: 'NewsModule',
+    component: () => import('@/layout/index.vue'),
+    redirect: '/news/index',  // 添加默认重定向
+    meta: {
+      title: '新闻管理',
+      icon: 'Document',
+      permission: 'news',
+      priority: 60,  // 中等优先级
+      version: '1.0.0',
+      author: 'SixShop Team',
+      description: '新闻资讯和内容管理系统'
+    },
+    children: [
+      {
+        path: 'index',
+        name: 'NewsIndex',
+        component: () => import('./views/index.vue'),
+        meta: {
+          title: '新闻管理',
+          icon: 'Document',
+          keepAlive: true  // 启用页面缓存
+        }
+      }
+    ]
+  },
+  
+  // 菜单配置 - 与路由对应
+  menus: [
+    {
+      path: '/news',
+      title: '新闻管理',
+      icon: 'Document',
+      permission: 'news',
+      children: [
+        {
+          path: '/news/index',
+          title: '新闻管理',
+          icon: 'Document'
+        }
+      ]
+    }
+  ]
+}

+ 56 - 0
frontend/admin/views/index.vue

@@ -0,0 +1,56 @@
+<template>
+  <div class="news-container">
+    <el-card class="news-card">
+      <el-tabs v-model="activeTab" @tab-click="handleTabClick">
+        <el-tab-pane label="文章管理" name="article">
+          <ArticlePanel />
+        </el-tab-pane>
+        <el-tab-pane label="分类管理" name="category">
+          <CategoryPanel />
+        </el-tab-pane>
+      </el-tabs>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { defineComponent, ref } from 'vue'
+import { useRouter, useRoute } from 'vue-router'
+import ArticlePanel from '../components/article/ArticlePanel.vue'
+import CategoryPanel from '../components/category/CategoryPanel.vue'
+
+export default defineComponent({
+  name: 'NewsManagement',
+  components: {
+    ArticlePanel,
+    CategoryPanel
+  },
+  setup() {
+    const router = useRouter()
+    const route = useRoute()
+    const activeTab = ref(route.query.tab || 'article')
+
+    const handleTabClick = () => {
+      router.push({ 
+        path: '/news', 
+        query: { tab: activeTab.value } 
+      })
+    }
+
+    return {
+      activeTab,
+      handleTabClick
+    }
+  }
+})
+</script>
+
+<style scoped>
+.news-container {
+  padding: 20px;
+}
+
+.news-card {
+  margin-bottom: 20px;
+}
+</style>

+ 51 - 0
frontend/admin/views/router.js

@@ -0,0 +1,51 @@
+/**
+ * News 插件路由配置
+ * 新闻管理系统
+ * 
+ * @author SixShop Team
+ * @version 1.0.0
+ */
+
+export default {
+  path: '/news',
+  name: 'News',
+  component: () => import('@/layout/index.vue'),
+  meta: {
+    title: '新闻管理',
+    icon: 'Document',
+    plugin: 'news',
+    priority: 30,
+    version: '1.0.0',
+    author: 'SixShop Team',
+    description: '新闻资讯管理系统'
+  },
+  children: [
+    {
+      path: '',
+      name: 'NewsIndex',
+      component: () => import('./index.vue'),
+      meta: {
+        title: '新闻管理',
+        icon: 'Document'
+      }
+    },
+    {
+      path: 'article/create',
+      name: 'NewsArticleCreate',
+      component: () => import('./components/article/ArticleEdit.vue'),
+      meta: {
+        title: '新增文章',
+        hidden: true
+      }
+    },
+    {
+      path: 'article/edit/:id',
+      name: 'NewsArticleEdit',
+      component: () => import('./components/article/ArticleEdit.vue'),
+      meta: {
+        title: '编辑文章',
+        hidden: true
+      }
+    }
+  ]
+}