zhuliu пре 1 месец
комит
acbf4ea0ed

+ 8 - 0
.editorconfig

@@ -0,0 +1,8 @@
+[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
+charset = utf-8
+indent_size = 2
+indent_style = space
+insert_final_newline = true
+trim_trailing_whitespace = true
+end_of_line = lf
+max_line_length = 100

+ 1 - 0
.gitattributes

@@ -0,0 +1 @@
+* text=auto eol=lf

+ 30 - 0
.gitignore

@@ -0,0 +1,30 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+.DS_Store
+dist
+dist-ssr
+coverage
+*.local
+
+/cypress/videos/
+/cypress/screenshots/
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+*.tsbuildinfo

+ 6 - 0
.prettierrc.json

@@ -0,0 +1,6 @@
+{
+  "$schema": "https://json.schemastore.org/prettierrc",
+  "semi": false,
+  "singleQuote": true,
+  "printWidth": 100
+}

+ 8 - 0
.vscode/extensions.json

@@ -0,0 +1,8 @@
+{
+  "recommendations": [
+    "Vue.volar",
+    "dbaeumer.vscode-eslint",
+    "EditorConfig.EditorConfig",
+    "esbenp.prettier-vscode"
+  ]
+}

+ 39 - 0
README.md

@@ -0,0 +1,39 @@
+# news-management-frontend
+
+This template should help get you started developing with Vue 3 in Vite.
+
+## Recommended IDE Setup
+
+[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
+
+## Type Support for `.vue` Imports in TS
+
+TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
+
+## Customize configuration
+
+See [Vite Configuration Reference](https://vite.dev/config/).
+
+## Project Setup
+
+```sh
+npm install
+```
+
+### Compile and Hot-Reload for Development
+
+```sh
+npm run dev
+```
+
+### Type-Check, Compile and Minify for Production
+
+```sh
+npm run build
+```
+
+### Lint with [ESLint](https://eslint.org/)
+
+```sh
+npm run lint
+```

+ 1 - 0
env.d.ts

@@ -0,0 +1 @@
+/// <reference types="vite/client" />

+ 22 - 0
eslint.config.ts

@@ -0,0 +1,22 @@
+import { globalIgnores } from 'eslint/config'
+import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
+import pluginVue from 'eslint-plugin-vue'
+import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
+
+// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
+// import { configureVueProject } from '@vue/eslint-config-typescript'
+// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
+// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
+
+export default defineConfigWithVueTs(
+  {
+    name: 'app/files-to-lint',
+    files: ['**/*.{ts,mts,tsx,vue}'],
+  },
+
+  globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
+
+  pluginVue.configs['flat/essential'],
+  vueTsConfigs.recommended,
+  skipFormatting,
+)

+ 13 - 0
index.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="">
+  <head>
+    <meta charset="UTF-8">
+    <link rel="icon" href="/favicon.ico">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Vite App</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

Разлика између датотеке није приказан због своје велике величине
+ 8737 - 0
package-lock.json


+ 43 - 0
package.json

@@ -0,0 +1,43 @@
+{
+  "name": "news-management-frontend",
+  "version": "0.0.0",
+  "private": true,
+  "type": "module",
+  "engines": {
+    "node": "^20.19.0 || >=22.12.0"
+  },
+  "scripts": {
+    "dev": "vite",
+    "build": "run-p type-check \"build-only {@}\" --",
+    "preview": "vite preview",
+    "build-only": "vite build",
+    "type-check": "vue-tsc --build",
+    "lint": "eslint . --fix",
+    "format": "prettier --write src/"
+  },
+  "dependencies": {
+    "axios": "^1.11.0",
+    "date-fns": "^4.1.0",
+    "element-plus": "^2.10.6",
+    "pinia": "^3.0.3",
+    "vue": "^3.5.18",
+    "vue-router": "^4.5.1"
+  },
+  "devDependencies": {
+    "@tsconfig/node22": "^22.0.2",
+    "@types/node": "^22.16.5",
+    "@vitejs/plugin-vue": "^6.0.1",
+    "@vue/eslint-config-prettier": "^10.2.0",
+    "@vue/eslint-config-typescript": "^14.6.0",
+    "@vue/tsconfig": "^0.7.0",
+    "eslint": "^9.31.0",
+    "eslint-plugin-vue": "~10.3.0",
+    "jiti": "^2.4.2",
+    "npm-run-all2": "^8.0.4",
+    "prettier": "3.6.2",
+    "typescript": "~5.8.0",
+    "vite": "^7.0.6",
+    "vite-plugin-vue-devtools": "^8.0.0",
+    "vue-tsc": "^3.0.4"
+  }
+}

BIN
public/favicon.ico


+ 77 - 0
src/App.vue

@@ -0,0 +1,77 @@
+<template>
+  <div id="app">
+    <el-container>
+      <el-header>
+        <div class="header-content">
+          <h1>资讯管理系统</h1>
+        </div>
+      </el-header>
+
+      <el-container>
+        <el-aside :width="isCollapse ? '64px' : '220px'">
+          <Sidebar v-model:collapse="isCollapse" />
+        </el-aside>
+
+        <el-main class="main-content">
+          <router-view />
+        </el-main>
+      </el-container>
+    </el-container>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import Sidebar from '@/components/Sidebar.vue'
+
+const isCollapse = ref(false)
+</script>
+
+<style scoped>
+#app {
+  height: 100vh;
+  width: 100vw;
+}
+
+.el-container {
+  height: 100%;
+}
+
+.el-header {
+  background-color: #409eff;
+  color: white;
+  padding: 0;
+  flex-shrink: 0;
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.header-content {
+  display: flex;
+  align-items: center;
+  height: 100%;
+  padding: 0 20px;
+}
+
+.header-content h1 {
+  margin: 0;
+  font-size: 20px;
+  font-weight: 600;
+}
+
+.el-aside {
+  background-color: #ffffff;
+  flex-shrink: 0;
+  position: relative;
+  transition: all 0.3s ease;
+  box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
+  height: calc(100vh - 60px);
+  border-right: 1px solid #ebeef5;
+}
+
+.main-content {
+  background-color: #f5f7fa;
+  padding: 0;
+  height: calc(100vh - 60px);
+  overflow: auto;
+}
+</style>

+ 86 - 0
src/assets/base.css

@@ -0,0 +1,86 @@
+/* color palette from <https://github.com/vuejs/theme> */
+:root {
+  --vt-c-white: #ffffff;
+  --vt-c-white-soft: #f8f8f8;
+  --vt-c-white-mute: #f2f2f2;
+
+  --vt-c-black: #181818;
+  --vt-c-black-soft: #222222;
+  --vt-c-black-mute: #282828;
+
+  --vt-c-indigo: #2c3e50;
+
+  --vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
+  --vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
+  --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
+  --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
+
+  --vt-c-text-light-1: var(--vt-c-indigo);
+  --vt-c-text-light-2: rgba(60, 60, 60, 0.66);
+  --vt-c-text-dark-1: var(--vt-c-white);
+  --vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
+}
+
+/* semantic color variables for this project */
+:root {
+  --color-background: var(--vt-c-white);
+  --color-background-soft: var(--vt-c-white-soft);
+  --color-background-mute: var(--vt-c-white-mute);
+
+  --color-border: var(--vt-c-divider-light-2);
+  --color-border-hover: var(--vt-c-divider-light-1);
+
+  --color-heading: var(--vt-c-text-light-1);
+  --color-text: var(--vt-c-text-light-1);
+
+  --section-gap: 160px;
+}
+
+@media (prefers-color-scheme: dark) {
+  :root {
+    --color-background: var(--vt-c-black);
+    --color-background-soft: var(--vt-c-black-soft);
+    --color-background-mute: var(--vt-c-black-mute);
+
+    --color-border: var(--vt-c-divider-dark-2);
+    --color-border-hover: var(--vt-c-divider-dark-1);
+
+    --color-heading: var(--vt-c-text-dark-1);
+    --color-text: var(--vt-c-text-dark-2);
+  }
+}
+
+*,
+*::before,
+*::after {
+  box-sizing: border-box;
+  margin: 0;
+  font-weight: normal;
+}
+
+body {
+  min-height: 100vh;
+  color: var(--color-text);
+  background: var(--color-background);
+  transition:
+    color 0.5s,
+    background-color 0.5s;
+  line-height: 1.6;
+  font-family:
+    Inter,
+    -apple-system,
+    BlinkMacSystemFont,
+    'Segoe UI',
+    Roboto,
+    Oxygen,
+    Ubuntu,
+    Cantarell,
+    'Fira Sans',
+    'Droid Sans',
+    'Helvetica Neue',
+    sans-serif;
+  font-size: 15px;
+  text-rendering: optimizeLegibility;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}

+ 1 - 0
src/assets/logo.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

+ 37 - 0
src/assets/main.css

@@ -0,0 +1,37 @@
+@import './base.css';
+
+#app {
+  max-width: 100%;
+  margin: 0;
+  padding: 0;
+  font-weight: normal;
+  height: 100vh;
+}
+
+a,
+.green {
+  text-decoration: none;
+  color: hsla(160, 100%, 37%, 1);
+  transition: 0.4s;
+  padding: 3px;
+}
+
+@media (hover: hover) {
+  a:hover {
+    background-color: hsla(160, 100%, 37%, 0.2);
+  }
+}
+
+@media (min-width: 1024px) {
+  body {
+    display: flex;
+    place-items: center;
+  }
+
+  #app {
+    max-width: 100%;
+    margin: 0;
+    padding: 0;
+    display: block;
+  }
+}

+ 41 - 0
src/components/HelloWorld.vue

@@ -0,0 +1,41 @@
+<script setup lang="ts">
+defineProps<{
+  msg: string
+}>()
+</script>
+
+<template>
+  <div class="greetings">
+    <h1 class="green">{{ msg }}</h1>
+    <h3>
+      You’ve successfully created a project with
+      <a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
+      <a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
+    </h3>
+  </div>
+</template>
+
+<style scoped>
+h1 {
+  font-weight: 500;
+  font-size: 2.6rem;
+  position: relative;
+  top: -10px;
+}
+
+h3 {
+  font-size: 1.2rem;
+}
+
+.greetings h1,
+.greetings h3 {
+  text-align: center;
+}
+
+@media (min-width: 1024px) {
+  .greetings h1,
+  .greetings h3 {
+    text-align: left;
+  }
+}
+</style>

+ 311 - 0
src/components/Sidebar.vue

@@ -0,0 +1,311 @@
+<template>
+  <div class="sidebar">
+    <div class="logo-wrapper" v-show="!isCollapse">
+      <div class="logo-container">
+        <div class="logo-icon-bg">
+          <el-icon class="logo-icon-main"><Reading /></el-icon>
+        </div>
+        <span class="logo-text">资讯管理系统</span>
+      </div>
+    </div>
+
+    <div class="logo-collapsed" v-show="isCollapse">
+      <el-icon class="logo-collapsed-icon"><Reading /></el-icon>
+    </div>
+
+    <el-menu
+      :default-active="activeMenu"
+      class="el-menu-vertical"
+      :collapse="isCollapse"
+      router
+      background-color="#ffffff"
+      text-color="#606266"
+      active-text-color="#409eff"
+      :collapse-transition="false"
+    >
+      <el-menu-item index="/news" class="menu-item">
+        <el-icon><Document /></el-icon>
+        <template #title>
+          <div class="menu-title">资讯管理</div>
+        </template>
+      </el-menu-item>
+
+      <el-menu-item index="/reports" class="menu-item">
+        <el-icon><Folder /></el-icon>
+        <template #title>
+          <div class="menu-title">报告管理</div>
+        </template>
+      </el-menu-item>
+
+      <!-- <el-menu-item index="/sources" class="menu-item">
+        <el-icon><Link /></el-icon>
+        <template #title>
+          <div class="menu-title">来源管理</div>
+        </template>
+      </el-menu-item> -->
+
+      <el-menu-item index="/categories" class="menu-item">
+        <el-icon><Collection /></el-icon>
+        <template #title>
+          <div class="menu-title">分类管理</div>
+        </template>
+      </el-menu-item>
+    </el-menu>
+
+    <div class="collapse-toggle" @click="toggleCollapse">
+      <el-icon><component :is="isCollapse ? Expand : Fold" /></el-icon>
+      <span v-show="!isCollapse" class="collapse-text">收起菜单</span>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, watch } from 'vue'
+import { useRoute } from 'vue-router'
+import { Document, Folder, Link, Collection, Fold, Expand, Reading } from '@element-plus/icons-vue'
+
+const route = useRoute()
+const activeMenu = ref(route.path)
+const isCollapse = defineModel('collapse', { type: Boolean, default: false })
+
+// Watch for route changes
+watch(
+  () => route.path,
+  (newPath) => {
+    activeMenu.value = newPath
+  }
+)
+
+const toggleCollapse = () => {
+  isCollapse.value = !isCollapse.value
+}
+</script>
+
+<style>
+.sidebar .el-menu--collapse {
+  .el-menu-item {
+    .el-menu-tooltip__trigger {
+      justify-content: center;
+    }
+  }
+}
+</style>
+<style scoped>
+.sidebar {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  border-right: 1px solid #ebeef5;
+  overflow-x: hidden; /* 防止横向滚动条 */
+}
+
+/* 展开状态的logo容器 */
+.logo-wrapper {
+  padding: 20px 0;
+  text-align: center;
+  border-bottom: 1px solid #ebeef5;
+}
+
+.logo-container {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 12px;
+}
+
+.logo-icon-bg {
+  width: 40px;
+  height: 40px;
+  background: linear-gradient(135deg, #409eff, #66b1ff);
+  border-radius: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-shadow: 0 4px 6px rgba(64, 158, 255, 0.2);
+}
+
+.logo-icon-main {
+  font-size: 20px;
+  color: white;
+}
+
+.logo-text {
+  color: #303133;
+  font-size: 18px;
+  font-weight: 600;
+  letter-spacing: 0.5px;
+}
+
+/* 折叠状态的logo */
+.logo-collapsed {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  padding: 20px 0;
+  border-bottom: 1px solid #ebeef5;
+}
+
+.logo-collapsed-icon {
+  font-size: 24px;
+  color: #409eff;
+}
+
+.el-menu-vertical {
+  border: none;
+  flex: 1;
+  padding: 10px 0;
+  overflow-x: hidden; /* 防止横向滚动条 */
+}
+
+.el-menu-vertical:not(.el-menu--collapse) {
+  width: 220px;
+}
+
+.collapse-toggle {
+  padding: 15px 0;
+  text-align: center;
+  color: #606266;
+  cursor: pointer;
+  transition: all 0.3s ease;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  border-top: 1px solid #ebeef5;
+}
+
+.collapse-toggle:hover {
+  background-color: #f5f7fa;
+  color: #409eff;
+}
+
+.collapse-text {
+  font-size: 14px;
+}
+
+/* 菜单项通用样式 */
+.menu-item {
+  margin: 2px 10px;
+  border-radius: 6px;
+  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+  position: relative;
+  overflow: hidden;
+  height: 46px;
+}
+
+.menu-item:hover {
+  background-color: #f5f7fa !important;
+  transform: translateX(3px);
+}
+
+.menu-item.is-active {
+  background-color: #ecf5ff !important;
+  color: #409eff !important;
+}
+
+.menu-item.is-active::before {
+  content: '';
+  position: absolute;
+  left: 0;
+  top: 0;
+  height: 100%;
+  width: 3px;
+  background-color: #409eff;
+}
+
+/* 菜单项内容容器 */
+:deep(.el-menu-item > .el-menu-item-content) {
+  display: flex;
+  align-items: center;
+  height: 100%;
+  padding: 0 20px;
+}
+
+/* 菜单项标题 */
+.menu-title {
+  font-size: 14px;
+  font-weight: 500;
+  margin-left: 10px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  flex: 1;
+}
+
+/* 图标样式 */
+.el-menu-item .el-icon {
+  font-size: 16px;
+  transition: all 0.3s ease;
+  flex-shrink: 0;
+}
+
+.menu-item:hover .el-icon {
+  transform: scale(1.1);
+}
+
+/* 折叠状态样式 */
+.el-menu--collapse {
+  :deep(.el-menu-item) {
+    margin: 2px 5px;
+    height: 46px;
+    padding: 0;
+
+    &:hover {
+      background-color: #f5f7fa !important;
+    }
+  }
+
+  /* 菜单项文字在折叠时隐藏 */
+  :deep(.menu-title) {
+    display: none;
+  }
+
+  /* 激活项的特殊样式 */
+  :deep(.menu-item.is-active) {
+    border-right: 3px solid #409eff;
+  }
+
+  /* 图标居中 - 最终修复 */
+  :deep(.el-menu-item > .el-menu-item-content) {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 100%;
+    height: 100%;
+    padding: 0 !important;
+  }
+
+  /* 确保图标本身居中 */
+  :deep(.el-menu-item > .el-menu-item-content > .el-icon) {
+    margin: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 100%;
+    height: 100%;
+  }
+
+  /* 特别处理SVG图标居中 */
+  :deep(.el-menu-item > .el-menu-item-content > .el-icon svg) {
+    margin: 0 auto;
+  }
+}
+
+/* 展开状态样式 */
+:not(.el-menu--collapse) {
+  :deep(.menu-item.is-active) {
+    border-right: 3px solid #409eff;
+  }
+}
+
+/* 响应式优化 */
+@media (max-width: 768px) {
+  .el-menu-vertical:not(.el-menu--collapse) {
+    width: 180px;
+  }
+
+  .logo-text {
+    font-size: 16px;
+  }
+}
+</style>

Разлика између датотеке није приказан због своје велике величине
+ 7 - 0
src/components/icons/IconCommunity.vue


Разлика између датотеке није приказан због своје велике величине
+ 7 - 0
src/components/icons/IconDocumentation.vue


Разлика између датотеке није приказан због своје велике величине
+ 7 - 0
src/components/icons/IconEcosystem.vue


+ 7 - 0
src/components/icons/IconSupport.vue

@@ -0,0 +1,7 @@
+<template>
+  <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
+    <path
+      d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
+    />
+  </svg>
+</template>

+ 19 - 0
src/components/icons/IconTooling.vue

@@ -0,0 +1,19 @@
+<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
+<template>
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    xmlns:xlink="http://www.w3.org/1999/xlink"
+    aria-hidden="true"
+    role="img"
+    class="iconify iconify--mdi"
+    width="24"
+    height="24"
+    preserveAspectRatio="xMidYMid meet"
+    viewBox="0 0 24 24"
+  >
+    <path
+      d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
+      fill="currentColor"
+    ></path>
+  </svg>
+</template>

+ 16 - 0
src/main.ts

@@ -0,0 +1,16 @@
+import './assets/main.css'
+
+import { createApp } from 'vue'
+import App from './App.vue'
+import router from './router'
+
+// Element Plus
+import ElementPlus from 'element-plus'
+import 'element-plus/dist/index.css'
+
+const app = createApp(App)
+
+app.use(ElementPlus)
+app.use(router)
+
+app.mount('#app')

+ 45 - 0
src/router/index.ts

@@ -0,0 +1,45 @@
+import { createRouter, createWebHistory } from 'vue-router'
+import NewsList from '@/views/NewsList.vue'
+import ReportList from '@/views/ReportList.vue'
+import ReportDetail from '@/views/ReportDetail.vue'
+import SourceList from '@/views/SourceList.vue'
+import CategoryList from '@/views/CategoryList.vue'
+
+const router = createRouter({
+  history: createWebHistory(import.meta.env.BASE_URL),
+  routes: [
+    {
+      path: '/',
+      name: 'home',
+      redirect: '/news'
+    },
+    {
+      path: '/news',
+      name: 'news',
+      component: NewsList
+    },
+    {
+      path: '/reports',
+      name: 'reports',
+      component: ReportList
+    },
+    {
+      path: '/reports/:id',
+      name: 'report-detail',
+      component: ReportDetail,
+      props: true
+    },
+    {
+      path: '/sources',
+      name: 'sources',
+      component: SourceList
+    },
+    {
+      path: '/categories',
+      name: 'categories',
+      component: CategoryList
+    }
+  ]
+})
+
+export default router

+ 347 - 0
src/services/api.ts

@@ -0,0 +1,347 @@
+// api.ts
+import axios from 'axios'
+import type { Category, Source, NewsItem, Report } from '@/types'
+
+const API_BASE_URL = '/api'
+
+// Create an axios instance
+const apiClient = axios.create({
+  baseURL: API_BASE_URL,
+  timeout: 10000,
+  headers: {
+    'Content-Type': 'application/json',
+  },
+})
+
+// Response interceptor to handle errors
+apiClient.interceptors.response.use(
+  (response) => response.data,
+  (error) => {
+    console.error('API Error:', error)
+    return Promise.reject(error)
+  },
+)
+
+interface ApiResponse<T> {
+  data: T
+  message: string
+  code: boolean
+}
+
+// Categories API
+export const categoryApi = {
+  // 获取所有类别
+  getCategories: async (params?: Record<string, any>): Promise<unknown> => {
+    try {
+      const url = `/xiaoshi/ppa/category/selectCategoryList`
+      const response = await apiClient.post<unknown>(url, params)
+      return response
+    } catch (error) {
+      console.error('Error fetching categories:', error)
+      throw error
+    }
+  },
+
+  // 新增或编辑类别
+  createCategory: async (params: Record<string, any>): Promise<unknown> => {
+    try {
+      const response = await apiClient.post<unknown>(
+        '/xiaoshi/ppa/category/addOrEditCategory',
+        params,
+      )
+      return response
+    } catch (error) {
+      console.error('Error creating category:', error)
+      throw error
+    }
+  },
+
+  // 删除类别信息
+  deleteCategory: async (params: Record<string, any>): Promise<unknown> => {
+    try {
+      const response = await apiClient.post<unknown>(`/xiaoshi/ppa/category/deleteCategory`, params)
+      return response
+    } catch (error) {
+      console.error(`Error deleting category`, error)
+      throw error
+    }
+  },
+}
+
+// Sources API
+export const sourceApi = {
+  // Get all sources
+  getSources: async (page?: number, pageSize?: number): Promise<ApiResponse<Source[]>> => {
+    try {
+      let url = `${API_BASE_URL}/sources/`
+      if (page !== undefined && pageSize !== undefined) {
+        const skip = (page - 1) * pageSize
+        url += `?skip=${skip}&limit=${pageSize}`
+      }
+      const response = await fetch(url)
+      if (!response.ok) {
+        throw new Error(`HTTP error! status: ${response.status}`)
+      }
+      const data = await response.json()
+      return {
+        data,
+        message: 'Sources fetched successfully',
+        success: true,
+      }
+    } catch (error) {
+      console.error('Error fetching sources:', error)
+      throw error
+    }
+  },
+
+  // Get sources count
+  getSourcesCount: async (): Promise<ApiResponse<{ count: number }>> => {
+    try {
+      const response = await fetch(`${API_BASE_URL}/sources/count/`)
+      if (!response.ok) {
+        throw new Error(`HTTP error! status: ${response.status}`)
+      }
+      const data = await response.json()
+      return {
+        data,
+        message: 'Sources count fetched successfully',
+        success: true,
+      }
+    } catch (error) {
+      console.error('Error fetching sources count:', error)
+      throw error
+    }
+  },
+
+  // Get source by ID
+  getSource: async (id: number): Promise<ApiResponse<Source>> => {
+    try {
+      const response = await fetch(`${API_BASE_URL}/sources/${id}`)
+      if (!response.ok) {
+        throw new Error(`HTTP error! status: ${response.status}`)
+      }
+      const data = await response.json()
+      return {
+        data,
+        message: 'Source fetched successfully',
+        success: true,
+      }
+    } catch (error) {
+      console.error(`Error fetching source ${id}:`, error)
+      throw error
+    }
+  },
+
+  // Create source
+  createSource: async (source: Omit<Source, 'id'>): Promise<ApiResponse<Source>> => {
+    try {
+      const response = await fetch(`${API_BASE_URL}/sources/`, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+        },
+        body: JSON.stringify(source),
+      })
+      if (!response.ok) {
+        throw new Error(`HTTP error! status: ${response.status}`)
+      }
+      const data = await response.json()
+      return {
+        data,
+        message: 'Source created successfully',
+        success: true,
+      }
+    } catch (error) {
+      console.error('Error creating source:', error)
+      throw error
+    }
+  },
+
+  // Update source
+  updateSource: async (id: number, source: Omit<Source, 'id'>): Promise<ApiResponse<Source>> => {
+    try {
+      const response = await fetch(`${API_BASE_URL}/sources/${id}`, {
+        method: 'PUT',
+        headers: {
+          'Content-Type': 'application/json',
+        },
+        body: JSON.stringify(source),
+      })
+      if (!response.ok) {
+        throw new Error(`HTTP error! status: ${response.status}`)
+      }
+      const data = await response.json()
+      return {
+        data,
+        message: 'Source updated successfully',
+        success: true,
+      }
+    } catch (error) {
+      console.error(`Error updating source ${id}:`, error)
+      throw error
+    }
+  },
+
+  // Delete source
+  deleteSource: async (id: number): Promise<ApiResponse<Source>> => {
+    try {
+      const response = await fetch(`${API_BASE_URL}/sources/${id}`, {
+        method: 'DELETE',
+      })
+      if (!response.ok) {
+        throw new Error(`HTTP error! status: ${response.status}`)
+      }
+      const data = await response.json()
+      return {
+        data,
+        message: 'Source deleted successfully',
+        success: true,
+      }
+    } catch (error) {
+      console.error(`Error deleting source ${id}:`, error)
+      throw error
+    }
+  },
+}
+
+// 资讯
+export const newsApi = {
+  // 获取资讯
+  getNews: async (params?: Record<string, any>): Promise<unknown> => {
+    try {
+      const url = `/xiaoshi/ppa/articleInfo/selectArticleInfoList`
+      const response = await apiClient.post<unknown>(url, params)
+      return response
+    } catch (error) {
+      console.error('Error fetching news:', error)
+      throw error
+    }
+  },
+
+  // 更新资讯
+  updateNews: async (params: Record<string, any>): Promise<unknown> => {
+    try {
+      const response = await apiClient.post<unknown>(
+        `/xiaoshi/ppa/articleInfo/updateArticleInfo`,
+        params,
+      )
+      return response
+    } catch (error) {
+      console.error(`Error updating news`, error)
+      throw error
+    }
+  },
+
+  // 删除资讯
+  deleteNews: async (params: Record<string, any>): Promise<unknown> => {
+    try {
+      const response = await apiClient.post<unknown>(
+        `/xiaoshi/ppa/articleInfo/deleteArticleInfo`,
+        params,
+      )
+      return response
+    } catch (error) {
+      console.error(`Error deleting news`, error)
+      throw error
+    }
+  },
+}
+
+// Reports API
+export const reportApi = {
+  // 获取报告
+  getReports: async (params?: Record<string, any>): Promise<unknown> => {
+    try {
+      const url = `/xiaoshi/ppa/report/selectReportList`
+      const response = await apiClient.post<unknown>(url, params)
+      return response
+    } catch (error) {
+      console.error('Error fetching reports:', error)
+      throw error
+    }
+  },
+
+  // 根据报告id获取单个报告
+  getReport: async (params: Record<string, any>): Promise<unknown> => {
+    try {
+      const url = `/xiaoshi/ppa/report/selectReportDetail`
+      const response = await apiClient.post<unknown>(url, params)
+      return response
+    } catch (error) {
+      console.error(`Error fetching report`, error)
+      throw error
+    }
+  },
+
+  // 创建报告
+  createReport: async (params: Record<string, any>): Promise<unknown> => {
+    try {
+      const url = `/xiaoshi/ppa/report/addReport`
+      const response = await apiClient.post<unknown>(url, params)
+      return response
+    } catch (error) {
+      console.error('Error creating report:', error)
+      throw error
+    }
+  },
+
+  // 编辑报告信息
+  updateReport: async (params: Record<string, any>): Promise<unknown> => {
+    try {
+      const url = `/xiaoshi/ppa/report/editReport`
+      const response = await apiClient.post<unknown>(url, params)
+      return response
+    } catch (error) {
+      console.error(`Error updating report`, error)
+      throw error
+    }
+  },
+
+  // 删除报告
+  deleteReport: async (params: Record<string, any>): Promise<unknown> => {
+    try {
+      const url = `/xiaoshi/ppa/report/deleteReport`
+      const response = await apiClient.post<unknown>(url, params)
+      return response
+    } catch (error) {
+      console.error(`Error deleting report`, error)
+      throw error
+    }
+  },
+
+  // 添加资讯到报告
+  addNewsToReport: async (params: Record<string, any>): Promise<unknown> => {
+    try {
+      const url = `/xiaoshi/ppa/report/addArticleToReport`
+      const response = await apiClient.post<unknown>(url, params)
+      return response
+    } catch (error) {
+      console.error(`Error adding news to report`, error)
+      throw error
+    }
+  },
+
+  // Remove news from report
+  removeNewsFromReport: async (params: Record<string, any>): Promise<unknown> => {
+    try {
+      const url = `/xiaoshi/ppa/report/removeAssoArticleReport`
+      const response = await apiClient.post<unknown>(url, params)
+      return response
+    } catch (error) {
+      console.error(`Error removing news from report:`, error)
+      throw error
+    }
+  },
+
+  // 查询报告及关联资讯列表
+  getReportNews: async (params: Record<string, any>): Promise<unknown> => {
+    try {
+      const url = `/xiaoshi/ppa/report/selectAssoReportArticleList`
+      const response = await apiClient.post<unknown>(url, params)
+      return response
+    } catch (error) {
+      console.error(`Error fetching news for report`, error)
+      throw error
+    }
+  },
+}

+ 12 - 0
src/stores/counter.ts

@@ -0,0 +1,12 @@
+import { ref, computed } from 'vue'
+import { defineStore } from 'pinia'
+
+export const useCounterStore = defineStore('counter', () => {
+  const count = ref(0)
+  const doubleCount = computed(() => count.value * 2)
+  function increment() {
+    count.value++
+  }
+
+  return { count, doubleCount, increment }
+})

+ 41 - 0
src/types/index.ts

@@ -0,0 +1,41 @@
+// 类别
+export interface Category {
+  id: number //类别id
+  name: string //类别名称
+  createTime?: Date //创建时间
+  creator?: string //创建人
+}
+// 来源
+export interface Source {
+  id: number
+  name: string
+  type: 'website' | 'official_account'
+  default_category_id: number
+  default_category_name: string
+  url: string
+  created_at?: Date
+}
+// 资讯
+export interface NewsItem {
+  assoId?: number //关联id
+  articleId: number //资讯id
+  title: string //标题
+  sourceId: number //来源id
+  sourceName: string //来源名称
+  sourceType: number //来源类型序号
+  articleUrl: string //资讯连接
+  categoryId: number // 类别id
+  categoryName: string // 类别名称
+  digest: string //摘要
+  publicTime: Date //发布时间
+  createTime?: Date //创建时间
+}
+// 报告
+export interface Report {
+  reportId: number //报告id
+  reportName: string // 报告名称
+  creator: string //创建人
+  createTime: Date //创建时间
+  approveTime?: Date //审核时间
+  approveStatus?: number //创建时间
+}

+ 279 - 0
src/views/CategoryList.vue

@@ -0,0 +1,279 @@
+<template>
+  <div class="category-list">
+    <el-card class="box-card" style="flex: 1; display: flex; flex-direction: column">
+      <template #header>
+        <div class="card-header">
+          <div class="header-title">
+            <el-icon><Folder /></el-icon>
+            <span>分类管理</span>
+          </div>
+          <div class="actions">
+            <el-button type="primary" :icon="Plus" @click="showAddDialog">添加</el-button>
+            <el-button :icon="Refresh" @click="fetchCategories">刷新</el-button>
+          </div>
+        </div>
+      </template>
+
+      <!-- Categories table -->
+      <el-table
+        :data="categories"
+        style="width: 100%; flex: 1"
+        v-loading="loading"
+        border
+        stripe
+        highlight-current-row
+        :header-cell-style="{ background: '#f8f9fa', color: '#606266' }"
+        :row-style="{ height: '55px' }"
+        :cell-style="{ padding: '8px' }"
+      >
+        <el-table-column label="序号" width="80">
+          <template #default="scope">
+            {{ (pagination.currentPage - 1) * pagination.pageSize + scope.$index + 1 }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="name" label="分类名称" min-width="200"></el-table-column>
+        <el-table-column prop="createTime" label="创建时间" width="180">
+          <template #default="scope">
+            {{ formatDate(scope.row.createTime) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="150" fixed="right">
+          <template #default="scope">
+            <el-button size="small" @click="editCategory(scope.row)">编辑</el-button>
+            <el-button size="small" type="danger" @click="deleteCategory(scope.row)"
+              >删除</el-button
+            >
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <!-- Pagination -->
+      <div class="pagination-container">
+        <el-pagination
+          v-model:current-page="pagination.currentPage"
+          v-model:page-size="pagination.pageSize"
+          :page-sizes="[10, 20, 50, 100]"
+          :total="pagination.total"
+          layout="total, sizes, prev, pager, next, jumper"
+          @size-change="handleSizeChange"
+          @current-change="handleCurrentChange"
+        />
+      </div>
+    </el-card>
+
+    <!-- Add/Edit Category Dialog -->
+    <el-dialog v-model="dialogVisible" :title="isEditing ? '编辑分类' : '添加分类'" width="500px">
+      <el-form :model="currentCategory" label-width="100px">
+        <el-form-item label="分类名称">
+          <el-input v-model="currentCategory.name" placeholder="请输入分类名称" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <span class="dialog-footer">
+          <el-button @click="dialogVisible = false">取消</el-button>
+          <el-button type="primary" @click="saveCategory">保存</el-button>
+        </span>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { Folder, Plus, Refresh } from '@element-plus/icons-vue'
+import type { Category } from '@/types'
+import { format } from 'date-fns'
+import { categoryApi } from '@/services/api'
+
+// State
+const categories = ref<Category[]>([])
+const loading = ref(false)
+const dialogVisible = ref(false)
+const isEditing = ref(false)
+
+const currentCategory = ref<Category>({
+  id: 0,
+  name: '',
+})
+
+const pagination = ref({
+  currentPage: 1,
+  pageSize: 10,
+  total: 0,
+})
+
+const fetchCategories = async () => {
+  loading.value = true
+  try {
+    const response = await categoryApi.getCategories(pagination.value)
+    categories.value = response.data.data
+    pagination.value.total = response.data.data.total
+  } catch (error) {
+    ElMessage.error('获取分类列表失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+const saveCategory = async () => {
+  try {
+    if (!currentCategory.value.name) {
+      ElMessage.warning('请输入分类名称')
+      return
+    }
+    const params = {
+      categoryId: currentCategory.value.id,
+      name: currentCategory.value.name,
+    }
+    await categoryApi.createCategory(params)
+    if (isEditing.value) {
+      ElMessage.success('分类更新成功')
+    } else {
+      ElMessage.success('分类添加成功')
+    }
+
+    dialogVisible.value = false
+    fetchCategories()
+  } catch (error) {
+    ElMessage.error(isEditing.value ? '更新分类失败' : '添加分类失败')
+  }
+}
+
+const deleteCategory = async (category: Category) => {
+  try {
+    await ElMessageBox.confirm('确定要删除该分类吗?', '提示', {
+      type: 'warning',
+    })
+    const params = {
+      categoryIds: [category.id],
+    }
+    await categoryApi.deleteCategory(params)
+    ElMessage.success('分类删除成功')
+    fetchCategories()
+  } catch (error) {
+    if (error !== 'cancel') {
+      ElMessage.error('删除分类失败')
+    }
+  }
+}
+
+// Event handlers
+const handleSizeChange = (val: number) => {
+  pagination.value.pageSize = val
+  fetchCategories()
+}
+
+const handleCurrentChange = (val: number) => {
+  pagination.value.currentPage = val
+  fetchCategories()
+}
+
+const showAddDialog = () => {
+  currentCategory.value = {
+    id: 0,
+    name: '',
+  }
+  isEditing.value = false
+  dialogVisible.value = true
+}
+
+const editCategory = (category: Category) => {
+  currentCategory.value = { ...category }
+  isEditing.value = true
+  dialogVisible.value = true
+}
+
+const formatDate = (date: Date, formatStr = 'yyyy-MM-dd HH:mm:ss') => {
+  return format(new Date(date), formatStr)
+}
+
+// Lifecycle
+onMounted(() => {
+  fetchCategories()
+})
+</script>
+
+<style scoped>
+.category-list {
+  padding: 20px;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  background-color: #f5f7fa;
+  position: relative;
+}
+
+.box-card {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  border-radius: 8px;
+  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
+}
+
+.box-card :deep(.el-card__header) {
+  border-bottom: 1px solid #ebeef5;
+  padding: 15px 20px;
+}
+
+.box-card :deep(.el-card__body) {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  padding: 15px;
+}
+
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.header-title {
+  display: flex;
+  align-items: center;
+  font-size: 18px;
+  font-weight: 600;
+  color: #303133;
+}
+
+.header-title .el-icon {
+  margin-right: 10px;
+  font-size: 20px;
+  color: #409eff;
+}
+
+.box-card :deep(.el-table) {
+  flex: 1;
+  border-radius: 4px;
+  overflow: hidden;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+.box-card :deep(.el-table__header th) {
+  background-color: #f8f9fa;
+  color: #606266;
+  font-weight: 600;
+}
+
+.box-card :deep(.el-table tr) {
+  height: 45px;
+}
+
+.pagination-container {
+  margin-top: 12px;
+  padding: 8px 0;
+  background-color: #fff;
+  border-top: 1px solid #ebeef5;
+  border-radius: 0 0 4px 4px;
+  position: relative;
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 10px;
+}
+</style>

+ 729 - 0
src/views/NewsList.vue

@@ -0,0 +1,729 @@
+<template>
+  <div class="news-list">
+    <el-card class="box-card" style="flex: 1; display: flex; flex-direction: column">
+      <template #header>
+        <div class="card-header">
+          <div class="header-title">
+            <el-icon><Collection /></el-icon>
+            <span>资讯管理</span>
+          </div>
+          <div class="actions">
+            <el-button type="primary" :icon="Refresh" @click="fetchNews">刷新</el-button>
+          </div>
+        </div>
+      </template>
+
+      <!-- Search form -->
+      <div class="search-container">
+        <el-form :model="searchForm" label-width="100px" class="search-form">
+          <el-row :gutter="20">
+            <el-col :span="6">
+              <el-form-item label="资讯发布日期">
+                <div class="dateRange">
+                  <el-date-picker
+                    v-model="searchForm.beginTime"
+                    type="date"
+                    placeholder="选择日期"
+                    value-format="YYYY-MM-DD"
+                    style="width: 100%"
+                    :disabledDate="startPickerOptions"
+                  />
+                  <span>至</span>
+                  <el-date-picker
+                    v-model="searchForm.endTime"
+                    type="date"
+                    placeholder="选择日期"
+                    value-format="YYYY-MM-DD"
+                    style="width: 100%"
+                    :disabledDate="endPickerOptions"
+                  />
+                </div>
+              </el-form-item>
+            </el-col>
+            <el-col :span="6">
+              <el-form-item label="关键字">
+                <el-input v-model="searchForm.key" placeholder="请输入关键字" />
+              </el-form-item>
+            </el-col>
+            <el-col :span="6">
+              <el-form-item label="分类">
+                <el-select
+                  v-model="searchForm.categoryId"
+                  placeholder="请选择分类"
+                  clearable
+                  style="width: 100%"
+                >
+                  <el-option
+                    v-for="category in categories"
+                    :key="category.id"
+                    :label="category.name"
+                    :value="category.id"
+                  />
+                </el-select>
+              </el-form-item>
+            </el-col>
+            <el-col :span="6" class="search-actions">
+              <el-form-item label=" ">
+                <el-button type="primary" @click="handleSearch">搜索</el-button>
+                <el-button @click="resetSearch">重置</el-button>
+              </el-form-item>
+            </el-col>
+          </el-row>
+        </el-form>
+      </div>
+
+      <!-- News table -->
+      <el-table
+        ref="newsTableRef"
+        :data="newsList"
+        style="width: 100%; flex: 1"
+        v-loading="loading"
+        @selection-change="handleSelectionChange"
+        border
+        stripe
+        highlight-current-row
+        :header-cell-style="{ background: '#f8f9fa', color: '#606266' }"
+        :row-style="{ height: '55px' }"
+        :cell-style="{ padding: '8px' }"
+      >
+        <el-table-column type="selection" width="55"></el-table-column>
+        <el-table-column label="序号" width="80">
+          <template #default="scope">
+            {{ (pagination.currentPage - 1) * pagination.pageSize + scope.$index + 1 }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="title" label="标题" min-width="200">
+          <template #default="scope">
+            <el-link :href="scope.row.articleUrl" target="_blank" :underline="false">
+              {{ scope.row.title }}
+            </el-link>
+          </template>
+        </el-table-column>
+        <el-table-column prop="sourceName" label="来源名称" width="150"></el-table-column>
+        <el-table-column label="来源类型" width="120">
+          <template #default="scope">
+            <el-tag :type="scope.row.sourceType === 1 ? 'primary' : 'success'">
+              {{ scope.row.sourceType === 1 ? '网站' : '公众号' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="categoryName" label="分类" width="180">
+          <template #default="scope">
+            <el-select
+              v-if="scope.row.isEditing"
+              v-model="scope.row.categoryId"
+              placeholder="请选择分类"
+              style="width: 100%"
+            >
+              <el-option
+                v-for="category in categories"
+                :key="category.id"
+                :label="category.name"
+                :value="category.id"
+              />
+            </el-select>
+            <el-tag v-else>{{ scope.row.categoryName }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="digest" label="摘要" min-width="300">
+          <template #default="scope">
+            <el-input
+              v-if="scope.row.isEditing"
+              v-model="scope.row.digest"
+              type="textarea"
+              :rows="4"
+              style="width: 100%"
+            />
+            <span v-else>{{ scope.row.digest }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="publicTime" label="发布时间" width="180">
+          <template #default="scope">
+            {{ formatDate(scope.row.publicTime) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="180" fixed="right">
+          <template #default="scope">
+            <div v-if="!scope.row.isEditing">
+              <el-button size="small" @click="startEditing(scope.row)">编辑</el-button>
+              <el-button size="small" type="danger" @click="deleteNews(scope.row)">删除</el-button>
+            </div>
+            <div v-else>
+              <el-button size="small" type="success" @click="saveNewsField(scope.row)"
+                >保存</el-button
+              >
+              <el-button size="small" @click="cancelEditing(scope.row)">取消</el-button>
+            </div>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <!-- Pagination and selection info -->
+      <div class="pagination-container">
+        <div class="pagination-and-selection">
+          <div>
+            <div class="selection-info-bottom" v-if="selectedNews.length > 0">
+              <div class="selection-text">已选择 {{ selectedNews.length }} 项</div>
+              <div class="selection-actions">
+                <el-button type="primary" size="small" @click="showCreateReportDialog = true"
+                  >创建报告</el-button
+                >
+                <el-button size="small" @click="showAddToReportDialog = true"
+                  >加入已有报告</el-button
+                >
+              </div>
+            </div>
+          </div>
+          <el-pagination
+            v-model:current-page="pagination.currentPage"
+            v-model:page-size="pagination.pageSize"
+            :page-sizes="[10, 20, 50, 100]"
+            :total="pagination.total"
+            layout="total, sizes, prev, pager, next, jumper"
+            @size-change="handleSizeChange"
+            @current-change="handleCurrentChange"
+          />
+        </div>
+      </div>
+    </el-card>
+
+    <!-- Floating selection indicator -->
+    <div
+      class="floating-selection-indicator"
+      v-if="selectedNews.length > 0"
+      @click="showSelectedNews = true"
+    >
+      <el-badge :value="selectedNews.length" :max="99" type="primary">
+        <el-button type="primary" :icon="Collection" circle></el-button>
+      </el-badge>
+    </div>
+
+    <!-- Selected news dialog -->
+    <el-dialog
+      v-model="showSelectedNews"
+      title="已选择的资讯"
+      width="80%"
+      class="selected-news-dialog"
+    >
+      <el-table :data="selectedNews" border stripe max-height="400">
+        <el-table-column type="index" label="#" width="60"></el-table-column>
+        <el-table-column prop="title" label="标题" min-width="200">
+          <template #default="scope">
+            <el-link :href="scope.row.articleUrl" target="_blank" :underline="false">
+              {{ scope.row.title }}
+            </el-link>
+          </template>
+        </el-table-column>
+        <el-table-column prop="sourceName" label="来源" width="120"></el-table-column>
+        <el-table-column prop="categoryName" label="分类" width="120"></el-table-column>
+        <el-table-column prop="publicTime" label="发布时间" width="180">
+          <template #default="scope">
+            {{ formatDate(scope.row.publicTime) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="100">
+          <template #default="scope">
+            <el-button type="danger" size="small" @click="removeFromSelection(scope.row)">
+              移除
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <div class="selected-news-actions">
+        <el-button type="primary" @click="showCreateReportDialog = true">创建报告</el-button>
+        <el-button @click="showAddToReportDialog = true">加入已有报告</el-button>
+        <el-button @click="clearSelection">清空选择</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- Create Report Dialog -->
+    <el-dialog v-model="showCreateReportDialog" title="创建报告" width="500px">
+      <el-form :model="newReport" label-width="80px">
+        <el-form-item label="报告名称">
+          <el-input v-model="newReport.reportName" placeholder="请输入报告名称" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <span class="dialog-footer">
+          <el-button @click="showCreateReportDialog = false">取消</el-button>
+          <el-button type="primary" @click="createReport">创建</el-button>
+        </span>
+      </template>
+    </el-dialog>
+
+    <!-- Add to Report Dialog -->
+    <el-dialog v-model="showAddToReportDialog" title="加入已有报告" width="500px">
+      <el-form label-width="80px">
+        <el-form-item label="选择报告">
+          <el-select v-model="selectedReportId" placeholder="请选择报告">
+            <el-option
+              v-for="report in reports"
+              :key="report.reportId"
+              :label="report.reportName"
+              :value="report.reportId"
+            />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <span class="dialog-footer">
+          <el-button @click="showAddToReportDialog = false">取消</el-button>
+          <el-button type="primary" @click="addToReport">添加</el-button>
+        </span>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, reactive, nextTick } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { Collection, Refresh } from '@element-plus/icons-vue'
+import type { NewsItem, Category, Report } from '@/types'
+import { format } from 'date-fns'
+import type { TableInstance } from 'element-plus'
+
+// Extend NewsItem type to include editing state
+interface EditableNewsItem extends NewsItem {
+  isEditing?: boolean
+  originalData?: {
+    categoryId: number
+    categoryName: string
+    digest: string
+  }
+}
+
+// State
+const newsList = ref<EditableNewsItem[]>([])
+const categories = ref<Category[]>([])
+const reports = ref<Report[]>([])
+const loading = ref(false)
+const selectedNews = ref<EditableNewsItem[]>([])
+const showCreateReportDialog = ref(false)
+const showAddToReportDialog = ref(false)
+const showSelectedNews = ref(false)
+const selectedReportId = ref<number | null>(null)
+const newsTableRef = ref<TableInstance>()
+
+// Search form
+const searchForm = reactive({
+  beginTime: '',
+  endTime: '',
+  key: '',
+  categoryId: null as number | null,
+})
+
+const newReport = ref({
+  reportName: '',
+})
+
+const pagination = ref({
+  currentPage: 1,
+  pageSize: 10,
+  total: 0,
+})
+
+// 禁用结束日期的可选范围(不能早于开始日期)
+const startPickerOptions = (time) => {
+  if (searchForm.endTime) {
+    return time.getTime() > new Date(searchForm.endTime).getTime()
+  }
+  return false
+}
+// 禁用开始日期的可选范围(不能晚于结束日期)
+const endPickerOptions = (time) => {
+  if (searchForm.beginTime) {
+    return time.getTime() < new Date(searchForm.beginTime).getTime()
+  }
+  return false
+}
+
+// Import API functions
+import { newsApi, categoryApi, reportApi } from '@/services/api'
+
+// Fetch news with search filters
+const fetchNews = async () => {
+  loading.value = true
+  try {
+    // 构造搜索参数
+    const params: any = {
+      pageNum: pagination.value.currentPage,
+      pageSize: pagination.value.pageSize,
+      ...searchForm,
+    }
+
+    const response = await newsApi.getNews(params)
+    // 为每条资讯添加编辑状态
+    newsList.value = response.data.data.map((news) => ({
+      ...news,
+      isEditing: false,
+    }))
+    pagination.value.total = response.data.total
+  } catch (error) {
+    ElMessage.error('获取资讯列表失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleSearch = () => {
+  // 重置到第一页
+  pagination.value.currentPage = 1
+  fetchNews()
+}
+
+const resetSearch = () => {
+  // 重置搜索表单
+  searchForm.beginTime = ''
+  searchForm.endTime = ''
+  searchForm.key = ''
+  searchForm.categoryId = null
+  // 重置到第一页
+  pagination.value.currentPage = 1
+  fetchNews()
+}
+
+const fetchCategories = async () => {
+  try {
+    // 获取所有分类,不分页
+    const response = await categoryApi.getCategories({})
+    categories.value = response.data.data
+  } catch (error) {
+    ElMessage.error('获取分类列表失败')
+  }
+}
+
+const fetchReports = async () => {
+  try {
+    const response = await reportApi.getReports({
+      pageNum: 1,
+      pageSize: 1000,
+    })
+    reports.value = response.data.data
+  } catch (error) {
+    ElMessage.error('获取报告列表失败')
+  }
+}
+
+const saveNewsField = async (news: EditableNewsItem, field?: string) => {
+  try {
+    // 如果没有指定字段,保存所有字段
+    const updates: Partial<NewsItem> = field
+      ? { [field]: news[field as keyof NewsItem] }
+      : { categoryId: news.categoryId, digest: news.digest, articleId: news.articleId }
+
+    // 更新资讯
+    await newsApi.updateNews(updates)
+    ElMessage.success('资讯更新成功')
+
+    // 如果是保存所有字段,则退出编辑模式
+    if (!field) {
+      news.isEditing = false
+      // 更新本地列表中的分类名称
+      const category = categories.value.find((c) => c.id === news.categoryId)
+      if (category) {
+        news.categoryName = category.name
+      }
+      // 清除原始数据
+      news.originalData = undefined
+      // 重新获取数据以确保一致性
+      fetchNews()
+    }
+  } catch (error) {
+    ElMessage.error('更新资讯失败')
+  }
+}
+
+const deleteNews = async (news: EditableNewsItem) => {
+  try {
+    await ElMessageBox.confirm('确定要删除该资讯吗?', '提示', {
+      type: 'warning',
+    })
+    const params: Record<string, any> = {
+      articleIds: [news.articleId],
+    }
+    await newsApi.deleteNews(params)
+    ElMessage.success('资讯删除成功')
+    fetchNews()
+  } catch (error) {
+    if (error !== 'cancel') {
+      ElMessage.error('删除资讯失败')
+    }
+  }
+}
+
+const createReport = async () => {
+  try {
+    if (!newReport.value.reportName) {
+      ElMessage.warning('请输入报告名称')
+      return
+    }
+    const params = {
+      articleIds: selectedNews.value.map((news) => news.articleId),
+      reportName: newReport.value.reportName,
+    }
+    await reportApi.createReport(params)
+    ElMessage.success('报告创建成功')
+    showCreateReportDialog.value = false
+    newReport.value.reportName = ''
+    fetchReports()
+  } catch (error) {
+    ElMessage.error('创建报告失败')
+  }
+}
+
+const addToReport = async () => {
+  try {
+    if (!selectedReportId.value) {
+      ElMessage.warning('请选择报告')
+      return
+    }
+    const params = {
+      articleIds: selectedNews.value.map((news) => news.articleId),
+      reportId: selectedReportId.value,
+    }
+    await reportApi.addNewsToReport(params)
+    ElMessage.success('资讯已添加到报告')
+    showAddToReportDialog.value = false
+    selectedReportId.value = null
+  } catch (error) {
+    ElMessage.error('添加到报告失败')
+  }
+}
+
+// Remove a news item from selection
+const removeFromSelection = (news: EditableNewsItem) => {
+  const index = selectedNews.value.findIndex((item) => item.articleId === news.articleId)
+  if (index > -1) {
+    selectedNews.value.splice(index, 1)
+    newsTableRef.value?.toggleRowSelection(news, false)
+  }
+}
+
+// Clear all selections
+const clearSelection = async () => {
+  selectedNews.value = []
+  // Also clear selection in the table
+  newsTableRef.value?.clearSelection()
+}
+
+// Event handlers
+const handleSelectionChange = (selection: EditableNewsItem[]) => {
+  selectedNews.value = selection
+}
+
+const handleSizeChange = (val: number) => {
+  pagination.value.pageSize = val
+  fetchNews()
+}
+
+const handleCurrentChange = (val: number) => {
+  pagination.value.currentPage = val
+  fetchNews()
+}
+
+const startEditing = (news: EditableNewsItem) => {
+  // 保存原始数据用于取消编辑
+  news.originalData = {
+    categoryId: news.categoryId,
+    categoryName: news.categoryName,
+    digest: news.digest,
+  }
+  news.isEditing = true
+}
+
+const cancelEditing = (news: EditableNewsItem) => {
+  // 恢复原始数据
+  if (news.originalData) {
+    news.categoryId = news.originalData.categoryId
+    news.categoryName = news.originalData.categoryName
+    news.digest = news.originalData.digest
+  }
+  news.isEditing = false
+}
+
+const formatDate = (date: Date, formatStr = 'yyyy-MM-dd') => {
+  return format(new Date(date), formatStr)
+}
+
+// Lifecycle
+onMounted(() => {
+  fetchNews()
+  fetchCategories()
+  fetchReports()
+})
+</script>
+
+<style scoped>
+.news-list {
+  padding: 20px;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  background-color: #f5f7fa;
+  position: relative;
+}
+.dateRange {
+  display: flex;
+  align-items: center;
+}
+.dateRange span {
+  padding: 0 10px;
+}
+
+.box-card {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  border-radius: 8px;
+  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
+}
+
+.box-card :deep(.el-card__header) {
+  border-bottom: 1px solid #ebeef5;
+  padding: 15px 20px;
+}
+
+.box-card :deep(.el-card__body) {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  padding: 15px;
+}
+
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.header-title {
+  display: flex;
+  align-items: center;
+  font-size: 18px;
+  font-weight: 600;
+  color: #303133;
+}
+
+.header-title .el-icon {
+  margin-right: 10px;
+  font-size: 20px;
+  color: #409eff;
+}
+
+.search-container {
+  background-color: #fff;
+  border: 1px solid #ebeef5;
+  border-radius: 4px;
+  padding: 10px 15px;
+  margin-bottom: 12px;
+}
+
+.search-form {
+  padding: 0;
+}
+
+.search-actions {
+  text-align: right;
+  padding-top: 5px;
+}
+
+.box-card :deep(.el-table) {
+  flex: 1;
+  border-radius: 4px;
+  overflow: hidden;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+.box-card :deep(.el-table__header th) {
+  background-color: #f8f9fa;
+  color: #606266;
+  font-weight: 600;
+}
+
+.box-card :deep(.el-table tr) {
+  height: 45px;
+}
+
+.pagination-container {
+  margin-top: 12px;
+  padding: 8px 0;
+  background-color: #fff;
+  border-top: 1px solid #ebeef5;
+  border-radius: 0 0 4px 4px;
+  position: relative;
+}
+
+.pagination-and-selection {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 10px;
+}
+
+.selection-info-bottom {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  background-color: #e8f4ff;
+  padding: 5px 15px;
+  border-radius: 4px;
+  border: 1px solid #c3e0ff;
+  white-space: nowrap;
+}
+
+.selection-text {
+  font-size: 14px;
+  color: #409eff;
+  font-weight: 500;
+}
+
+.selection-actions {
+  margin-left: 5px;
+  display: flex;
+  gap: 10px;
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 10px;
+}
+
+.selected-news-actions {
+  margin: 20px 0;
+  display: flex;
+  gap: 10px;
+  justify-content: center;
+}
+
+/* Floating selection indicator */
+.floating-selection-indicator {
+  position: absolute;
+  bottom: 200px;
+  right: 50px;
+  z-index: 1000;
+  cursor: pointer;
+}
+
+.floating-selection-indicator :deep(.el-badge__content) {
+  transform: translateY(-50%) translateX(100%) scale(1) !important;
+  top: 0;
+  right: 10px;
+}
+
+@media (max-width: 768px) {
+  .floating-selection-indicator {
+    bottom: 20px;
+    right: 20px;
+  }
+}
+
+/* Selected news dialog */
+.selected-news-dialog :deep(.el-dialog__body) {
+  padding: 15px 20px;
+}
+</style>

+ 415 - 0
src/views/ReportDetail.vue

@@ -0,0 +1,415 @@
+<template>
+  <div class="report-detail">
+    <el-card class="box-card">
+      <template #header>
+        <div class="card-header">
+          <span class="header-title">报告详情</span>
+          <div class="header-actions">
+            <el-button @click="goBack" type="primary">返回</el-button>
+          </div>
+        </div>
+      </template>
+
+      <div class="report-content">
+        <!-- Report info -->
+        <el-descriptions :column="1" border class="report-info">
+          <el-descriptions-item label="报告名称">{{ report.reportName }}</el-descriptions-item>
+          <el-descriptions-item label="创建时间">{{
+            formatDate(report.createTime)
+          }}</el-descriptions-item>
+        </el-descriptions>
+
+        <!-- News by category -->
+        <div class="news-by-category" v-loading="loading">
+          <div
+            v-for="(newsList, category) in newsByCategory"
+            :key="category"
+            class="category-section"
+          >
+            <h3 class="category-title">{{ category }}</h3>
+            <div class="news-list">
+              <div v-for="news in newsList" :key="news.articleId" class="news-item">
+                <div class="news-item-header">
+                  <el-link :href="news.articleUrl" target="_blank" :underline="false">
+                    <h4 class="news-title">{{ news.title }}</h4>
+                  </el-link>
+                  <div class="news-item-actions">
+                    <el-button
+                      v-if="!editingNewsId || editingNewsId !== news.articleId"
+                      type="primary"
+                      size="small"
+                      @click="startEdit(news)"
+                    >
+                      编辑
+                    </el-button>
+                    <el-button
+                      v-if="editingNewsId && editingNewsId === news.articleId"
+                      type="success"
+                      size="small"
+                      @click="saveEdit"
+                    >
+                      保存
+                    </el-button>
+                    <el-button
+                      v-if="editingNewsId && editingNewsId === news.articleId"
+                      type="info"
+                      size="small"
+                      @click="cancelEdit"
+                    >
+                      取消
+                    </el-button>
+                    <el-button type="danger" size="small" @click="removeFromReport(news)"
+                      >移除</el-button
+                    >
+                  </div>
+                </div>
+
+                <div class="news-source">
+                  <el-tag :type="news.sourceType === 1 ? 'primary' : 'success'">
+                    {{ news.sourceType === 1 ? '网站' : '公众号' }}
+                  </el-tag>
+                  <span class="source-name">{{ news.sourceName }}</span>
+                </div>
+
+                <!-- Edit form -->
+                <div v-if="editingNewsId && editingNewsId === news.articleId" class="edit-form">
+                  <el-form :model="editForm" label-width="60px" size="small">
+                    <el-form-item label="分类">
+                      <el-select
+                        v-model="editForm.categoryId"
+                        placeholder="请选择分类"
+                        style="width: 100%"
+                      >
+                        <el-option
+                          v-for="category in categories"
+                          :key="category.id"
+                          :label="category.name"
+                          :value="category.id"
+                        />
+                      </el-select>
+                    </el-form-item>
+                    <el-form-item label="摘要">
+                      <el-input
+                        v-model="editForm.digest"
+                        type="textarea"
+                        :rows="3"
+                        placeholder="请输入摘要"
+                      />
+                    </el-form-item>
+                  </el-form>
+                </div>
+
+                <!-- Display summary when not editing -->
+                <div v-else class="news-summary">
+                  {{ news.digest }}
+                </div>
+
+                <div class="news-footer">
+                  <div class="news-date">{{ formatDate(news.publicTime) }}</div>
+                  <div
+                    class="news-category"
+                    v-if="!editingNewsId || editingNewsId !== news.articleId"
+                  >
+                    分类: {{ news.categoryName }}
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { useRoute, useRouter } from 'vue-router'
+import type { NewsItem, Report, Category } from '@/types'
+import { format } from 'date-fns'
+import { reportApi, categoryApi, newsApi } from '@/services/api'
+
+// Router
+const route = useRoute()
+const router = useRouter()
+
+// State
+const report = ref<Report>({
+  reportId: 0,
+  reportName: '',
+  creator: '',
+  createTime: new Date(),
+})
+
+const newsByCategory = ref<Record<string, NewsItem[]>>({})
+const categories = ref<Category[]>([])
+const loading = ref(false)
+
+// Edit state
+const editingNewsId = ref<number | null>(null)
+const editForm = ref({
+  categoryId: 0,
+  digest: '',
+})
+const originalNews = ref<NewsItem | null>(null)
+
+const fetchReport = async () => {
+  try {
+    // Get report ID from route
+    const reportId = parseInt(route.params.id as string)
+
+    const response = await reportApi.getReport({ reportId: reportId })
+    report.value = response.data.data
+  } catch (error) {
+    ElMessage.error('获取报告信息失败')
+  }
+}
+
+const fetchReportNews = async () => {
+  loading.value = true
+  try {
+    const reportId = parseInt(route.params.id as string)
+    const response = await reportApi.getReportNews({ reportId: reportId })
+    const newsList = response.data.data
+    // Group news by category
+    const grouped: Record<string, NewsItem[]> = {}
+    newsList.forEach((news) => {
+      if (!grouped[news.categoryName]) {
+        grouped[news.categoryName] = []
+      }
+      grouped[news.categoryName].push(news)
+    })
+
+    newsByCategory.value = grouped
+  } catch (error) {
+    ElMessage.error('获取报告资讯失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+const fetchCategories = async () => {
+  try {
+    const response = await categoryApi.getCategories({})
+    categories.value = response.data.data
+  } catch (error) {
+    ElMessage.error('获取分类列表失败')
+  }
+}
+
+const startEdit = (news: NewsItem) => {
+  editingNewsId.value = news.articleId
+  editForm.value = {
+    categoryId: news.categoryId,
+    digest: news.digest,
+  }
+  originalNews.value = { ...news }
+}
+
+const saveEdit = async () => {
+  if (!editingNewsId.value) return
+
+  try {
+    const params = {
+      articleId: editingNewsId.value,
+      ...editForm.value,
+    }
+    await newsApi.updateNews(params)
+    ElMessage.success('资讯更新成功')
+    editingNewsId.value = null
+    originalNews.value = null
+    fetchReportNews()
+  } catch (error) {
+    ElMessage.error('更新资讯失败')
+  }
+}
+
+const cancelEdit = () => {
+  editingNewsId.value = null
+  originalNews.value = null
+}
+
+const removeFromReport = async (news: NewsItem) => {
+  try {
+    await ElMessageBox.confirm('确定要从报告中移除该资讯吗?', '提示', {
+      type: 'warning',
+    })
+    await reportApi.removeNewsFromReport({ assoId: news.assoId })
+    ElMessage.success('资讯已从报告中移除')
+    fetchReportNews()
+  } catch (error) {
+    if (error !== 'cancel') {
+      ElMessage.error('移除资讯失败')
+    }
+  }
+}
+
+// Event handlers
+const goBack = () => {
+  router.push('/reports')
+}
+
+const formatDate = (date: Date) => {
+  return format(new Date(date), 'yyyy-MM-dd HH:mm:ss')
+}
+
+// Lifecycle
+onMounted(() => {
+  fetchReport()
+  fetchReportNews()
+  fetchCategories()
+})
+</script>
+
+<style scoped>
+.report-detail {
+  padding: 20px;
+  height: calc(100vh - 60px);
+  display: flex;
+  flex-direction: column;
+  box-sizing: border-box;
+}
+
+.box-card {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  box-sizing: border-box;
+}
+
+.box-card :deep(.el-card__body) {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  padding: 20px;
+}
+
+.report-content {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+.report-info {
+  margin-bottom: 20px;
+}
+
+.news-by-category {
+  flex: 1;
+  overflow-y: auto;
+}
+
+.category-section {
+  margin-bottom: 30px;
+}
+
+.category-title {
+  font-size: 18px;
+  font-weight: bold;
+  margin-bottom: 15px;
+  color: #333;
+}
+
+.news-list {
+  border: 1px solid #ebeef5;
+  border-radius: 8px;
+  overflow: hidden;
+}
+
+.news-item {
+  padding: 20px;
+  border-bottom: 1px solid #ebeef5;
+}
+
+.news-item:last-child {
+  border-bottom: none;
+}
+
+.news-item-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  margin-bottom: 10px;
+}
+
+.news-title {
+  margin: 0;
+  font-size: 16px;
+  color: #333;
+}
+
+.news-source {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  margin-bottom: 10px;
+}
+
+.source-name {
+  font-size: 14px;
+  color: #666;
+}
+
+.news-summary {
+  font-size: 14px;
+  color: #666;
+  line-height: 1.5;
+  margin-bottom: 15px;
+}
+
+.news-footer {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.news-item-actions {
+  display: flex;
+  gap: 10px;
+}
+
+.news-category {
+  font-size: 12px;
+  color: #999;
+}
+
+.edit-form {
+  margin: 15px 0;
+  padding: 15px;
+  background-color: #f5f7fa;
+  border-radius: 4px;
+}
+
+.edit-form :deep(.el-form-item) {
+  margin-bottom: 12px;
+}
+
+.edit-form :deep(.el-form-item__label) {
+  font-size: 12px;
+}
+
+/* 头部样式 */
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 15px 20px;
+  background-color: #f5f7fa;
+  border-bottom: 1px solid #ebeef5;
+}
+
+.header-title {
+  font-size: 20px;
+  font-weight: bold;
+  color: #333;
+}
+
+.header-actions {
+  display: flex;
+  align-items: center;
+}
+</style>

+ 288 - 0
src/views/ReportList.vue

@@ -0,0 +1,288 @@
+<template>
+  <div class="report-list">
+    <el-card class="box-card" style="flex: 1; display: flex; flex-direction: column">
+      <template #header>
+        <div class="card-header">
+          <div class="header-title">
+            <el-icon><Document /></el-icon>
+            <span>报告管理</span>
+          </div>
+          <div class="actions">
+            <el-button type="primary" :icon="Refresh" @click="fetchReports">刷新</el-button>
+          </div>
+        </div>
+      </template>
+
+      <!-- Reports table -->
+      <el-table
+        :data="reports"
+        style="width: 100%; flex: 1"
+        v-loading="loading"
+        border
+        stripe
+        highlight-current-row
+        :header-cell-style="{ background: '#f8f9fa', color: '#606266' }"
+        :row-style="{ height: '55px' }"
+        :cell-style="{ padding: '8px' }"
+      >
+        <el-table-column label="序号" width="80">
+          <template #default="scope">
+            {{ (pagination.currentPage - 1) * pagination.pageSize + scope.$index + 1 }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="reportName" label="报告名称" min-width="200"></el-table-column>
+        <el-table-column prop="createTime" label="创建时间" width="180">
+          <template #default="scope">
+            {{ formatDate(scope.row.createTime) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="220" fixed="right">
+          <template #default="scope">
+            <el-button size="small" @click="viewReport(scope.row)">查看</el-button>
+            <el-button size="small" @click="editReport(scope.row)">编辑</el-button>
+            <el-button size="small" type="danger" @click="deleteReport(scope.row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <!-- Pagination -->
+      <div class="pagination-container">
+        <el-pagination
+          v-model:current-page="pagination.currentPage"
+          v-model:page-size="pagination.pageSize"
+          :page-sizes="[10, 20, 50, 100]"
+          :total="pagination.total"
+          layout="total, sizes, prev, pager, next, jumper"
+          @size-change="handleSizeChange"
+          @current-change="handleCurrentChange"
+        />
+      </div>
+    </el-card>
+
+    <!-- Edit Report Dialog -->
+    <el-dialog
+      v-model="editDialogVisible"
+      :title="isEditing ? '编辑报告' : '创建报告'"
+      width="500px"
+    >
+      <el-form :model="currentReport" label-width="80px">
+        <el-form-item label="报告名称">
+          <el-input v-model="currentReport.reportName" placeholder="请输入报告名称" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <span class="dialog-footer">
+          <el-button @click="editDialogVisible = false">取消</el-button>
+          <el-button type="primary" @click="saveReport">保存</el-button>
+        </span>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { useRouter } from 'vue-router'
+import { Document, Refresh } from '@element-plus/icons-vue'
+import type { Report } from '@/types'
+import { format } from 'date-fns'
+
+// Router
+const router = useRouter()
+
+// State
+const reports = ref<Report[]>([])
+const loading = ref(false)
+const editDialogVisible = ref(false)
+const isEditing = ref(false)
+
+const currentReport = ref<Report>({
+  reportId: 0,
+  reportName: '',
+  creator: '',
+  createTime: new Date(),
+})
+
+const pagination = ref({
+  currentPage: 1,
+  pageSize: 10,
+  total: 0,
+})
+
+// Import API functions
+import { reportApi } from '@/services/api'
+
+// Fetch reports with pagination
+const fetchReports = async () => {
+  loading.value = true
+  try {
+    const response = await reportApi.getReports(pagination.value)
+    reports.value = response.data.data
+    pagination.value.total = response.data.total
+  } catch (error) {
+    ElMessage.error('获取报告列表失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+const saveReport = async () => {
+  try {
+    if (!currentReport.value.reportName) {
+      ElMessage.warning('请输入报告名称')
+      return
+    }
+    const params = {
+      reportName: currentReport.value.reportName,
+    }
+    if (isEditing.value) {
+      // Update existing report
+      params.reportId = currentReport.value.reportId
+      await reportApi.updateReport(params)
+      ElMessage.success('报告更新成功')
+    } else {
+      // Create new report
+      await reportApi.createReport(params)
+      ElMessage.success('报告创建成功')
+    }
+    editDialogVisible.value = false
+    fetchReports() // Refresh current page
+  } catch (error) {
+    ElMessage.error(isEditing.value ? '更新报告失败' : '创建报告失败')
+  }
+}
+
+const deleteReport = async (report: Report) => {
+  try {
+    await ElMessageBox.confirm('确定要删除该报告吗?', '提示', {
+      type: 'warning',
+    })
+    const params = {
+      reportIds: [report.reportId],
+    }
+    await reportApi.deleteReport(params)
+    ElMessage.success('报告删除成功')
+    fetchReports()
+  } catch (error) {
+    if (error !== 'cancel') {
+      ElMessage.error('删除报告失败')
+    }
+  }
+}
+
+// Event handlers
+const handleSizeChange = (val: number) => {
+  pagination.value.pageSize = val
+  pagination.value.currentPage = 1 // Reset to first page when changing page size
+  fetchReports()
+}
+
+const handleCurrentChange = (val: number) => {
+  pagination.value.currentPage = val
+  fetchReports()
+}
+
+const viewReport = (report: Report) => {
+  router.push(`/reports/${report.reportId}`)
+}
+
+const editReport = (report: Report) => {
+  currentReport.value = { ...report }
+  isEditing.value = true
+  editDialogVisible.value = true
+}
+
+const formatDate = (date: Date, formatStr = 'yyyy-MM-dd HH:mm:ss') => {
+  return format(new Date(date), formatStr)
+}
+
+// Lifecycle
+onMounted(() => {
+  fetchReports()
+})
+</script>
+
+<style scoped>
+.report-list {
+  padding: 20px;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  background-color: #f5f7fa;
+  position: relative;
+}
+
+.box-card {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  border-radius: 8px;
+  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
+}
+
+.box-card :deep(.el-card__header) {
+  border-bottom: 1px solid #ebeef5;
+  padding: 15px 20px;
+}
+
+.box-card :deep(.el-card__body) {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  padding: 15px;
+}
+
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.header-title {
+  display: flex;
+  align-items: center;
+  font-size: 18px;
+  font-weight: 600;
+  color: #303133;
+}
+
+.header-title .el-icon {
+  margin-right: 10px;
+  font-size: 20px;
+  color: #409eff;
+}
+
+.box-card :deep(.el-table) {
+  flex: 1;
+  border-radius: 4px;
+  overflow: hidden;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+.box-card :deep(.el-table__header th) {
+  background-color: #f8f9fa;
+  color: #606266;
+  font-weight: 600;
+}
+
+.box-card :deep(.el-table tr) {
+  height: 45px;
+}
+
+.pagination-container {
+  margin-top: 12px;
+  padding: 8px 0;
+  background-color: #fff;
+  border-top: 1px solid #ebeef5;
+  border-radius: 0 0 4px 4px;
+  position: relative;
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 10px;
+}
+</style>

+ 340 - 0
src/views/SourceList.vue

@@ -0,0 +1,340 @@
+<template>
+  <div class="source-list">
+    <el-card class="box-card" style="flex: 1; display: flex; flex-direction: column">
+      <template #header>
+        <div class="card-header">
+          <div class="header-title">
+            <el-icon><Link /></el-icon>
+            <span>网站/公众号管理</span>
+          </div>
+          <div class="actions">
+            <el-button type="primary" :icon="Plus" @click="showAddDialog">添加</el-button>
+            <el-button :icon="Refresh" @click="fetchSources">刷新</el-button>
+          </div>
+        </div>
+      </template>
+
+      <!-- Sources table -->
+      <el-table
+        :data="sources"
+        style="width: 100%; flex: 1"
+        v-loading="loading"
+        border
+        stripe
+        highlight-current-row
+        :header-cell-style="{ background: '#f8f9fa', color: '#606266' }"
+        :row-style="{ height: '55px' }"
+        :cell-style="{ padding: '8px' }"
+      >
+        <el-table-column label="序号" width="80">
+          <template #default="scope">
+            {{ (pagination.currentPage - 1) * pagination.pageSize + scope.$index + 1 }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="name" label="名称" min-width="150"></el-table-column>
+        <el-table-column label="类型" width="120">
+          <template #default="scope">
+            <el-tag :type="scope.row.type === 'website' ? 'primary' : 'success'">
+              {{ scope.row.type === 'website' ? '网站' : '公众号' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="default_category_name" label="默认分类" width="150"></el-table-column>
+        <el-table-column prop="url" label="链接" min-width="200">
+          <template #default="scope">
+            <el-link :href="scope.row.url" target="_blank" :underline="false">
+              {{ scope.row.url }}
+            </el-link>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="150" fixed="right">
+          <template #default="scope">
+            <el-button size="small" @click="editSource(scope.row)">编辑</el-button>
+            <el-button size="small" type="danger" @click="deleteSource(scope.row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <!-- Pagination -->
+      <div class="pagination-container">
+        <el-pagination
+          v-model:current-page="pagination.currentPage"
+          v-model:page-size="pagination.pageSize"
+          :page-sizes="[10, 20, 50, 100]"
+          :total="pagination.total"
+          layout="total, sizes, prev, pager, next, jumper"
+          @size-change="handleSizeChange"
+          @current-change="handleCurrentChange"
+        />
+      </div>
+    </el-card>
+
+    <!-- Add/Edit Source Dialog -->
+    <el-dialog v-model="dialogVisible" :title="isEditing ? '编辑来源' : '添加来源'" width="500px">
+      <el-form :model="currentSource" label-width="100px">
+        <el-form-item label="名称">
+          <el-input v-model="currentSource.name" placeholder="请输入名称" />
+        </el-form-item>
+        <el-form-item label="类型">
+          <el-select v-model="currentSource.type" placeholder="请选择类型">
+            <el-option label="网站" value="website" />
+            <el-option label="公众号" value="official_account" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="默认分类">
+          <el-select v-model="currentSource.default_category_id" placeholder="请选择默认分类">
+            <el-option
+              v-for="category in categories"
+              :key="category.id"
+              :label="category.name"
+              :value="category.id"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="链接">
+          <el-input v-model="currentSource.url" placeholder="请输入链接" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <span class="dialog-footer">
+          <el-button @click="dialogVisible = false">取消</el-button>
+          <el-button type="primary" @click="saveSource">保存</el-button>
+        </span>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { Link, Plus, Refresh } from '@element-plus/icons-vue'
+import type { Source, Category } from '@/types'
+import { sourceApi, categoryApi } from '@/services/api'
+
+// State
+const sources = ref<Source[]>([])
+const categories = ref<Category[]>([])
+const loading = ref(false)
+const dialogVisible = ref(false)
+const isEditing = ref(false)
+
+const currentSource = ref<Source>({
+  id: 0,
+  name: '',
+  type: 'website',
+  default_category_id: 0,
+  default_category_name: '',
+  url: ''
+})
+
+const pagination = ref({
+  currentPage: 1,
+  pageSize: 10,
+  total: 0
+})
+
+const fetchSources = async () => {
+  loading.value = true
+  try {
+    const response = await sourceApi.getSources(
+      pagination.value.currentPage,
+      pagination.value.pageSize
+    )
+    sources.value = response.data
+    // 获取总数
+    const countResponse = await sourceApi.getSourcesCount()
+    pagination.value.total = countResponse.data.count
+  } catch (error) {
+    ElMessage.error('获取来源列表失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+const fetchCategories = async () => {
+  try {
+    // 获取所有分类,不分页
+    const response = await categoryApi.getCategories()
+    categories.value = response.data
+  } catch (error) {
+    ElMessage.error('获取分类列表失败')
+  }
+}
+
+const saveSource = async () => {
+  try {
+    if (!currentSource.value.name || !currentSource.value.url) {
+      ElMessage.warning('请填写必填项')
+      return
+    }
+    
+    // Find the category name for the selected category ID
+    const selectedCategory = categories.value.find(
+      category => category.id === currentSource.value.default_category_id
+    )
+    
+    if (!selectedCategory) {
+      ElMessage.error('请选择有效的分类')
+      return
+    }
+    
+    // Add category name to the source object
+    const sourceToSave = {
+      ...currentSource.value,
+      default_category_name: selectedCategory.name
+    }
+    
+    if (isEditing.value) {
+      // Update existing source
+      await sourceApi.updateSource(currentSource.value.id, sourceToSave)
+      ElMessage.success('来源更新成功')
+    } else {
+      // Create new source
+      await sourceApi.createSource(sourceToSave)
+      ElMessage.success('来源添加成功')
+    }
+    
+    dialogVisible.value = false
+    fetchSources()
+  } catch (error) {
+    ElMessage.error(isEditing.value ? '更新来源失败' : '添加来源失败')
+  }
+}
+
+const deleteSource = async (source: Source) => {
+  try {
+    await ElMessageBox.confirm('确定要删除该来源吗?', '提示', {
+      type: 'warning'
+    })
+    
+    await sourceApi.deleteSource(source.id)
+    ElMessage.success('来源删除成功')
+    fetchSources()
+  } catch (error) {
+    if (error !== 'cancel') {
+      ElMessage.error('删除来源失败')
+    }
+  }
+}
+
+// Event handlers
+const handleSizeChange = (val: number) => {
+  pagination.value.pageSize = val
+  fetchSources()
+}
+
+const handleCurrentChange = (val: number) => {
+  pagination.value.currentPage = val
+  fetchSources()
+}
+
+const showAddDialog = () => {
+  currentSource.value = {
+    id: 0,
+    name: '',
+    type: 'website',
+    default_category_id: 0,
+    default_category_name: '',
+    url: ''
+  }
+  isEditing.value = false
+  dialogVisible.value = true
+}
+
+const editSource = (source: Source) => {
+  currentSource.value = { ...source }
+  isEditing.value = true
+  dialogVisible.value = true
+}
+
+// Lifecycle
+onMounted(() => {
+  fetchSources()
+  fetchCategories()
+})
+</script>
+
+<style scoped>
+.source-list {
+  padding: 20px;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  background-color: #f5f7fa;
+  position: relative;
+}
+
+.box-card {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  border-radius: 8px;
+  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
+}
+
+.box-card :deep(.el-card__header) {
+  border-bottom: 1px solid #ebeef5;
+  padding: 15px 20px;
+}
+
+.box-card :deep(.el-card__body) {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  padding: 15px;
+}
+
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.header-title {
+  display: flex;
+  align-items: center;
+  font-size: 18px;
+  font-weight: 600;
+  color: #303133;
+}
+
+.header-title .el-icon {
+  margin-right: 10px;
+  font-size: 20px;
+  color: #409eff;
+}
+
+.box-card :deep(.el-table) {
+  flex: 1;
+  border-radius: 4px;
+  overflow: hidden;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+.box-card :deep(.el-table__header th) {
+  background-color: #f8f9fa;
+  color: #606266;
+  font-weight: 600;
+}
+
+.box-card :deep(.el-table tr) {
+  height: 45px;
+}
+
+.pagination-container {
+  margin-top: 12px;
+  padding: 8px 0;
+  background-color: #fff;
+  border-top: 1px solid #ebeef5;
+  border-radius: 0 0 4px 4px;
+  position: relative;
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 10px;
+}
+</style>

+ 12 - 0
tsconfig.app.json

@@ -0,0 +1,12 @@
+{
+  "extends": "@vue/tsconfig/tsconfig.dom.json",
+  "include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
+  "exclude": ["src/**/__tests__/*"],
+  "compilerOptions": {
+    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+
+    "paths": {
+      "@/*": ["./src/*"]
+    }
+  }
+}

+ 11 - 0
tsconfig.json

@@ -0,0 +1,11 @@
+{
+  "files": [],
+  "references": [
+    {
+      "path": "./tsconfig.node.json"
+    },
+    {
+      "path": "./tsconfig.app.json"
+    }
+  ]
+}

+ 19 - 0
tsconfig.node.json

@@ -0,0 +1,19 @@
+{
+  "extends": "@tsconfig/node22/tsconfig.json",
+  "include": [
+    "vite.config.*",
+    "vitest.config.*",
+    "cypress.config.*",
+    "nightwatch.conf.*",
+    "playwright.config.*",
+    "eslint.config.*"
+  ],
+  "compilerOptions": {
+    "noEmit": true,
+    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+
+    "module": "ESNext",
+    "moduleResolution": "Bundler",
+    "types": ["node"]
+  }
+}

+ 24 - 0
vite.config.ts

@@ -0,0 +1,24 @@
+import { fileURLToPath, URL } from 'node:url'
+
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import vueDevTools from 'vite-plugin-vue-devtools'
+
+// https://vite.dev/config/
+export default defineConfig({
+  plugins: [vue(), vueDevTools()],
+  resolve: {
+    alias: {
+      '@': fileURLToPath(new URL('./src', import.meta.url)),
+    },
+  },
+  server: {
+    proxy: {
+      '/api': {
+        target: 'http://192.168.2.107:8099',
+        changeOrigin: true,
+        // rewrite: (path) => path.replace(/^\/api/, ''),
+      },
+    },
+  },
+})