Просмотр исходного кода

feat(wm-base): #73 文档管理完整实现

bot_dev2 5 дней назад
Родитель
Сommit
3f022a828a

+ 69
- 0
db/postgresql/V2__doc.sql Просмотреть файл

@@ -0,0 +1,69 @@
1
+-- =============================================
2
+-- 文档管理模块 DDL
3
+-- =============================================
4
+
5
+-- 文档分类表
6
+CREATE TABLE IF NOT EXISTS doc_category (
7
+    id BIGSERIAL PRIMARY KEY,
8
+    name VARCHAR(100) NOT NULL,
9
+    parent_id BIGINT DEFAULT 0,
10
+    sort INT DEFAULT 0,
11
+    deleted SMALLINT DEFAULT 0,
12
+    create_time TIMESTAMP DEFAULT NOW()
13
+);
14
+COMMENT ON TABLE doc_category IS '文档分类表';
15
+
16
+-- 文档主表
17
+CREATE TABLE IF NOT EXISTS doc (
18
+    id BIGSERIAL PRIMARY KEY,
19
+    name VARCHAR(255) NOT NULL,
20
+    description TEXT,
21
+    file_type VARCHAR(20),
22
+    file_size BIGINT DEFAULT 0,
23
+    storage_path VARCHAR(500) NOT NULL,
24
+    category_id BIGINT REFERENCES doc_category(id),
25
+    tags VARCHAR(500),
26
+    current_version INT DEFAULT 1,
27
+    uploader_id BIGINT,
28
+    uploader_name VARCHAR(50),
29
+    access_level SMALLINT DEFAULT 0,
30
+    dept_id BIGINT,
31
+    download_count INT DEFAULT 0,
32
+    search_text TEXT,
33
+    deleted SMALLINT DEFAULT 0,
34
+    create_time TIMESTAMP DEFAULT NOW(),
35
+    update_time TIMESTAMP DEFAULT NOW()
36
+);
37
+COMMENT ON TABLE doc IS '文档主表';
38
+COMMENT ON COLUMN doc.access_level IS '权限级别: 0-公开 1-部门可见 2-仅自己';
39
+
40
+-- 文档版本表
41
+CREATE TABLE IF NOT EXISTS doc_version (
42
+    id BIGSERIAL PRIMARY KEY,
43
+    doc_id BIGINT NOT NULL REFERENCES doc(id),
44
+    version INT NOT NULL,
45
+    storage_path VARCHAR(500) NOT NULL,
46
+    file_size BIGINT DEFAULT 0,
47
+    change_log VARCHAR(500),
48
+    uploader_id BIGINT,
49
+    uploader_name VARCHAR(50),
50
+    create_time TIMESTAMP DEFAULT NOW()
51
+);
52
+COMMENT ON TABLE doc_version IS '文档版本表';
53
+
54
+-- 索引
55
+CREATE INDEX IF NOT EXISTS idx_doc_category ON doc(category_id);
56
+CREATE INDEX IF NOT EXISTS idx_doc_uploader ON doc(uploader_id);
57
+CREATE INDEX IF NOT EXISTS idx_doc_dept ON doc(dept_id);
58
+CREATE INDEX IF NOT EXISTS idx_doc_file_type ON doc(file_type);
59
+CREATE INDEX IF NOT EXISTS idx_doc_search_text ON doc USING gin(to_tsvector('simple', COALESCE(search_text, '')));
60
+CREATE INDEX IF NOT EXISTS idx_doc_version_doc_id ON doc_version(doc_id);
61
+
62
+-- 默认分类
63
+INSERT INTO doc_category (name, parent_id, sort) VALUES
64
+('技术规范', 0, 1),
65
+('操作手册', 0, 2),
66
+('设计图纸', 0, 3),
67
+('合同文件', 0, 4),
68
+('会议纪要', 0, 5)
69
+ON CONFLICT DO NOTHING;

+ 2
- 0
frontend/src/router/index.ts Просмотреть файл

@@ -11,6 +11,8 @@ const routes = [
11 11
       { path: 'system/role', name: 'role', component: () => import('@/views/system/role/RoleList.vue') },
12 12
       { path: 'system/menu', name: 'menu', component: () => import('@/views/system/menu/MenuList.vue') },
13 13
       { path: 'system/dept', name: 'dept', component: () => import('@/views/system/dept/DeptList.vue') },
14
+      { path: 'doc', name: 'doc', component: () => import('@/views/doc/DocList.vue') },
15
+      { path: 'doc/detail/:id', name: 'docDetail', component: () => import('@/views/doc/DocDetail.vue') },
14 16
     ]
15 17
   },
16 18
   { path: '/:pathMatch(.*)*', redirect: '/dashboard' }

+ 321
- 0
frontend/src/views/doc/DocDetail.vue Просмотреть файл

@@ -0,0 +1,321 @@
1
+<template>
2
+  <div class="doc-detail-container">
3
+    <el-page-header @back="goBack" :title="'返回'" />
4
+
5
+    <el-skeleton :loading="loading" animated>
6
+      <template #default>
7
+        <el-row :gutter="20" style="margin-top: 20px">
8
+          <!-- 左侧: 文档信息 -->
9
+          <el-col :span="16">
10
+            <el-card>
11
+              <template #header>
12
+                <div class="card-header">
13
+                  <span>{{ doc.name }}</span>
14
+                  <div>
15
+                    <el-button type="primary" @click="downloadDoc">下载</el-button>
16
+                    <el-button @click="showEditDialog = true">编辑</el-button>
17
+                  </div>
18
+                </div>
19
+              </template>
20
+
21
+              <el-descriptions :column="2" border>
22
+                <el-descriptions-item label="文档名称">{{ doc.name }}</el-descriptions-item>
23
+                <el-descriptions-item label="文件类型">
24
+                  <el-tag>{{ doc.fileType }}</el-tag>
25
+                </el-descriptions-item>
26
+                <el-descriptions-item label="文件大小">{{ formatSize(doc.fileSize) }}</el-descriptions-item>
27
+                <el-descriptions-item label="当前版本">v{{ doc.currentVersion }}</el-descriptions-item>
28
+                <el-descriptions-item label="分类">{{ doc.categoryName || '-' }}</el-descriptions-item>
29
+                <el-descriptions-item label="下载次数">{{ doc.downloadCount }}</el-descriptions-item>
30
+                <el-descriptions-item label="上传人">{{ doc.uploaderName }}</el-descriptions-item>
31
+                <el-descriptions-item label="权限级别">
32
+                  <el-tag :type="accessLevelType">{{ accessLevelText }}</el-tag>
33
+                </el-descriptions-item>
34
+                <el-descriptions-item label="创建时间">{{ doc.createTime }}</el-descriptions-item>
35
+                <el-descriptions-item label="更新时间">{{ doc.updateTime }}</el-descriptions-item>
36
+                <el-descriptions-item label="描述" :span="2">{{ doc.description || '-' }}</el-descriptions-item>
37
+                <el-descriptions-item label="标签" :span="2">
38
+                  <el-tag v-for="tag in doc.tagList" :key="tag" size="small" class="tag-item">{{ tag }}</el-tag>
39
+                  <span v-if="!doc.tagList || doc.tagList.length === 0">-</span>
40
+                </el-descriptions-item>
41
+              </el-descriptions>
42
+            </el-card>
43
+          </el-col>
44
+
45
+          <!-- 右侧: 版本历史 + 上传新版本 -->
46
+          <el-col :span="8">
47
+            <el-card>
48
+              <template #header>
49
+                <div class="card-header">
50
+                  <span>版本历史</span>
51
+                  <el-button type="primary" size="small" @click="showVersionUpload = true">上传新版本</el-button>
52
+                </div>
53
+              </template>
54
+
55
+              <el-timeline>
56
+                <el-timeline-item
57
+                  v-for="ver in versions"
58
+                  :key="ver.id"
59
+                  :timestamp="ver.createTime"
60
+                  :type="ver.version === doc.currentVersion ? 'primary' : 'info'"
61
+                >
62
+                  <div class="version-item">
63
+                    <div class="version-header">
64
+                      <strong>v{{ ver.version }}</strong>
65
+                      <el-button
66
+                        v-if="ver.version !== doc.currentVersion"
67
+                        size="small"
68
+                        text
69
+                        type="primary"
70
+                        @click="downloadVersion(ver.version)"
71
+                      >下载</el-button>
72
+                    </div>
73
+                    <div class="version-meta">{{ ver.uploaderName }} · {{ formatSize(ver.fileSize) }}</div>
74
+                    <div class="version-log">{{ ver.changeLog || '无说明' }}</div>
75
+                  </div>
76
+                </el-timeline-item>
77
+              </el-timeline>
78
+            </el-card>
79
+          </el-col>
80
+        </el-row>
81
+      </template>
82
+    </el-skeleton>
83
+
84
+    <!-- 编辑对话框 -->
85
+    <el-dialog v-model="showEditDialog" title="编辑文档信息" width="500px">
86
+      <el-form :model="editForm" label-width="80px">
87
+        <el-form-item label="名称">
88
+          <el-input v-model="editForm.name" />
89
+        </el-form-item>
90
+        <el-form-item label="描述">
91
+          <el-input v-model="editForm.description" type="textarea" :rows="3" />
92
+        </el-form-item>
93
+        <el-form-item label="标签">
94
+          <el-input v-model="editForm.tags" placeholder="逗号分隔" />
95
+        </el-form-item>
96
+        <el-form-item label="权限">
97
+          <el-radio-group v-model="editForm.accessLevel">
98
+            <el-radio :label="0">公开</el-radio>
99
+            <el-radio :label="1">部门可见</el-radio>
100
+            <el-radio :label="2">仅自己</el-radio>
101
+          </el-radio-group>
102
+        </el-form-item>
103
+      </el-form>
104
+      <template #footer>
105
+        <el-button @click="showEditDialog = false">取消</el-button>
106
+        <el-button type="primary" @click="submitEdit">保存</el-button>
107
+      </template>
108
+    </el-dialog>
109
+
110
+    <!-- 上传新版本对话框 -->
111
+    <el-dialog v-model="showVersionUpload" title="上传新版本" width="500px">
112
+      <el-form label-width="80px">
113
+        <el-form-item label="文件" required>
114
+          <el-upload :auto-upload="false" :limit="1" :on-change="handleVersionFileChange" drag>
115
+            <el-icon class="el-icon--upload"><UploadFilled /></el-icon>
116
+            <div class="el-upload__text">拖拽文件到此处或 <em>点击上传</em></div>
117
+          </el-upload>
118
+        </el-form-item>
119
+        <el-form-item label="变更说明">
120
+          <el-input v-model="versionChangeLog" type="textarea" :rows="2" placeholder="描述本次修改内容" />
121
+        </el-form-item>
122
+      </el-form>
123
+      <template #footer>
124
+        <el-button @click="showVersionUpload = false">取消</el-button>
125
+        <el-button type="primary" @click="submitVersionUpload" :loading="uploading">上传</el-button>
126
+      </template>
127
+    </el-dialog>
128
+  </div>
129
+</template>
130
+
131
+<script setup>
132
+import { ref, reactive, computed, onMounted } from 'vue'
133
+import { useRoute, useRouter } from 'vue-router'
134
+import { ElMessage } from 'element-plus'
135
+import { UploadFilled } from '@element-plus/icons-vue'
136
+import axios from 'axios'
137
+
138
+const route = useRoute()
139
+const router = useRouter()
140
+const docId = route.params.id
141
+
142
+const loading = ref(true)
143
+const uploading = ref(false)
144
+const showEditDialog = ref(false)
145
+const showVersionUpload = ref(false)
146
+const doc = ref({})
147
+const versions = ref([])
148
+const versionFile = ref(null)
149
+const versionChangeLog = ref('')
150
+
151
+const editForm = reactive({
152
+  name: '',
153
+  description: '',
154
+  tags: '',
155
+  accessLevel: 0
156
+})
157
+
158
+const accessLevelText = computed(() => {
159
+  const map = { 0: '公开', 1: '部门可见', 2: '仅自己' }
160
+  return map[doc.value.accessLevel] || '未知'
161
+})
162
+
163
+const accessLevelType = computed(() => {
164
+  const map = { 0: 'success', 1: 'warning', 2: 'danger' }
165
+  return map[doc.value.accessLevel] || 'info'
166
+})
167
+
168
+onMounted(async () => {
169
+  await loadDetail()
170
+  await loadVersions()
171
+  loading.value = false
172
+})
173
+
174
+const loadDetail = async () => {
175
+  try {
176
+    const res = await axios.get(`/api/base/doc/${docId}`)
177
+    if (res.data.code === 200) {
178
+      doc.value = res.data.data
179
+      Object.assign(editForm, {
180
+        name: doc.value.name,
181
+        description: doc.value.description || '',
182
+        tags: doc.value.tags || '',
183
+        accessLevel: doc.value.accessLevel || 0
184
+      })
185
+    }
186
+  } catch (e) {
187
+    ElMessage.error('加载文档详情失败')
188
+  }
189
+}
190
+
191
+const loadVersions = async () => {
192
+  try {
193
+    const res = await axios.get(`/api/base/doc/${docId}/versions`)
194
+    if (res.data.code === 200) {
195
+      versions.value = res.data.data
196
+    }
197
+  } catch (e) {
198
+    console.error('加载版本列表失败', e)
199
+  }
200
+}
201
+
202
+const downloadDoc = async () => {
203
+  try {
204
+    const res = await axios.get(`/api/base/doc/${docId}/download`, { responseType: 'blob' })
205
+    const url = window.URL.createObjectURL(new Blob([res.data]))
206
+    const link = document.createElement('a')
207
+    link.href = url
208
+    link.download = doc.value.name || `doc_${docId}`
209
+    link.click()
210
+    window.URL.revokeObjectURL(url)
211
+  } catch (e) {
212
+    ElMessage.error('下载失败')
213
+  }
214
+}
215
+
216
+const downloadVersion = async (version) => {
217
+  try {
218
+    const res = await axios.get(`/api/base/doc/${docId}/version/${version}/download`, { responseType: 'blob' })
219
+    const url = window.URL.createObjectURL(new Blob([res.data]))
220
+    const link = document.createElement('a')
221
+    link.href = url
222
+    link.download = `${doc.value.name}_v${version}`
223
+    link.click()
224
+    window.URL.revokeObjectURL(url)
225
+  } catch (e) {
226
+    ElMessage.error('下载失败')
227
+  }
228
+}
229
+
230
+const submitEdit = async () => {
231
+  try {
232
+    const res = await axios.put(`/api/base/doc/${docId}`, editForm)
233
+    if (res.data.code === 200) {
234
+      ElMessage.success('更新成功')
235
+      showEditDialog.value = false
236
+      await loadDetail()
237
+    }
238
+  } catch (e) {
239
+    ElMessage.error('更新失败')
240
+  }
241
+}
242
+
243
+const handleVersionFileChange = (file) => {
244
+  versionFile.value = file.raw
245
+}
246
+
247
+const submitVersionUpload = async () => {
248
+  if (!versionFile.value) {
249
+    ElMessage.warning('请选择文件')
250
+    return
251
+  }
252
+  uploading.value = true
253
+  const formData = new FormData()
254
+  formData.append('file', versionFile.value)
255
+  if (versionChangeLog.value) formData.append('changeLog', versionChangeLog.value)
256
+
257
+  try {
258
+    const res = await axios.post(`/api/base/doc/${docId}/version`, formData, {
259
+      headers: { 'Content-Type': 'multipart/form-data' }
260
+    })
261
+    if (res.data.code === 200) {
262
+      ElMessage.success('新版本上传成功')
263
+      showVersionUpload.value = false
264
+      versionFile.value = null
265
+      versionChangeLog.value = ''
266
+      await loadDetail()
267
+      await loadVersions()
268
+    }
269
+  } catch (e) {
270
+    ElMessage.error('上传失败')
271
+  } finally {
272
+    uploading.value = false
273
+  }
274
+}
275
+
276
+const goBack = () => {
277
+  router.push('/doc')
278
+}
279
+
280
+const formatSize = (bytes) => {
281
+  if (!bytes) return '0 B'
282
+  const units = ['B', 'KB', 'MB', 'GB']
283
+  let i = 0
284
+  let size = bytes
285
+  while (size >= 1024 && i < units.length - 1) {
286
+    size /= 1024
287
+    i++
288
+  }
289
+  return size.toFixed(1) + ' ' + units[i]
290
+}
291
+</script>
292
+
293
+<style scoped>
294
+.doc-detail-container {
295
+  padding: 20px;
296
+}
297
+.card-header {
298
+  display: flex;
299
+  justify-content: space-between;
300
+  align-items: center;
301
+}
302
+.tag-item {
303
+  margin-right: 4px;
304
+}
305
+.version-item {
306
+  line-height: 1.6;
307
+}
308
+.version-header {
309
+  display: flex;
310
+  justify-content: space-between;
311
+  align-items: center;
312
+}
313
+.version-meta {
314
+  font-size: 12px;
315
+  color: #999;
316
+}
317
+.version-log {
318
+  font-size: 13px;
319
+  color: #666;
320
+}
321
+</style>

+ 310
- 0
frontend/src/views/doc/DocList.vue Просмотреть файл

@@ -0,0 +1,310 @@
1
+<template>
2
+  <div class="doc-container">
3
+    <!-- 搜索栏 -->
4
+    <el-card class="search-card">
5
+      <el-form :model="queryForm" inline>
6
+        <el-form-item label="关键词">
7
+          <el-input v-model="queryForm.keyword" placeholder="搜索文档名称/描述/标签" clearable @keyup.enter="handleSearch" />
8
+        </el-form-item>
9
+        <el-form-item label="分类">
10
+          <el-select v-model="queryForm.categoryId" placeholder="全部分类" clearable>
11
+            <el-option v-for="cat in categories" :key="cat.id" :label="cat.name" :value="cat.id" />
12
+          </el-select>
13
+        </el-form-item>
14
+        <el-form-item label="文件类型">
15
+          <el-select v-model="queryForm.fileType" placeholder="全部类型" clearable>
16
+            <el-option label="PDF" value="pdf" />
17
+            <el-option label="Word" value="docx" />
18
+            <el-option label="Excel" value="xlsx" />
19
+            <el-option label="图片" value="jpg" />
20
+            <el-option label="图片" value="png" />
21
+          </el-select>
22
+        </el-form-item>
23
+        <el-form-item>
24
+          <el-button type="primary" @click="handleSearch">
25
+            <el-icon><Search /></el-icon> 搜索
26
+          </el-button>
27
+          <el-button @click="resetQuery">重置</el-button>
28
+        </el-form-item>
29
+      </el-form>
30
+    </el-card>
31
+
32
+    <!-- 操作栏 -->
33
+    <el-card class="table-card">
34
+      <template #header>
35
+        <div class="card-header">
36
+          <span>文档列表</span>
37
+          <el-button type="primary" @click="showUploadDialog = true">
38
+            <el-icon><Upload /></el-icon> 上传文档
39
+          </el-button>
40
+        </div>
41
+      </template>
42
+
43
+      <el-table :data="docList" v-loading="loading" border stripe>
44
+        <el-table-column prop="name" label="文档名称" min-width="200" show-overflow-tooltip>
45
+          <template #default="{ row }">
46
+            <el-link type="primary" @click="viewDetail(row.id)">{{ row.name }}</el-link>
47
+          </template>
48
+        </el-table-column>
49
+        <el-table-column prop="fileType" label="类型" width="80" align="center">
50
+          <template #default="{ row }">
51
+            <el-tag size="small">{{ row.fileType }}</el-tag>
52
+          </template>
53
+        </el-table-column>
54
+        <el-table-column prop="currentVersion" label="版本" width="70" align="center">
55
+          <template #default="{ row }">v{{ row.currentVersion }}</template>
56
+        </el-table-column>
57
+        <el-table-column prop="fileSize" label="大小" width="100" align="center">
58
+          <template #default="{ row }">{{ formatSize(row.fileSize) }}</template>
59
+        </el-table-column>
60
+        <el-table-column prop="tags" label="标签" width="150" show-overflow-tooltip>
61
+          <template #default="{ row }">
62
+            <el-tag v-for="tag in parseTags(row.tags)" :key="tag" size="small" class="tag-item">{{ tag }}</el-tag>
63
+          </template>
64
+        </el-table-column>
65
+        <el-table-column prop="uploaderName" label="上传人" width="100" />
66
+        <el-table-column prop="downloadCount" label="下载" width="70" align="center" />
67
+        <el-table-column prop="updateTime" label="更新时间" width="160" />
68
+        <el-table-column label="操作" width="200" fixed="right">
69
+          <template #default="{ row }">
70
+            <el-button size="small" type="primary" @click="downloadDoc(row.id)">下载</el-button>
71
+            <el-button size="small" @click="viewDetail(row.id)">详情</el-button>
72
+            <el-button size="small" type="danger" @click="deleteDoc(row.id)">删除</el-button>
73
+          </template>
74
+        </el-table-column>
75
+      </el-table>
76
+
77
+      <!-- 分页 -->
78
+      <el-pagination
79
+        v-model:current-page="queryForm.pageNum"
80
+        v-model:page-size="queryForm.pageSize"
81
+        :total="total"
82
+        :page-sizes="[10, 20, 50, 100]"
83
+        layout="total, sizes, prev, pager, next, jumper"
84
+        @size-change="handleSearch"
85
+        @current-change="handleSearch"
86
+        class="pagination"
87
+      />
88
+    </el-card>
89
+
90
+    <!-- 上传对话框 -->
91
+    <el-dialog v-model="showUploadDialog" title="上传文档" width="600px">
92
+      <el-form :model="uploadForm" label-width="100px">
93
+        <el-form-item label="文件" required>
94
+          <el-upload
95
+            ref="uploadRef"
96
+            :auto-upload="false"
97
+            :limit="1"
98
+            :on-change="handleFileChange"
99
+            drag
100
+          >
101
+            <el-icon class="el-icon--upload"><UploadFilled /></el-icon>
102
+            <div class="el-upload__text">拖拽文件到此处或 <em>点击上传</em></div>
103
+          </el-upload>
104
+        </el-form-item>
105
+        <el-form-item label="文档名称">
106
+          <el-input v-model="uploadForm.name" placeholder="留空则使用文件名" />
107
+        </el-form-item>
108
+        <el-form-item label="描述">
109
+          <el-input v-model="uploadForm.description" type="textarea" :rows="3" />
110
+        </el-form-item>
111
+        <el-form-item label="分类">
112
+          <el-select v-model="uploadForm.categoryId" placeholder="选择分类">
113
+            <el-option v-for="cat in categories" :key="cat.id" :label="cat.name" :value="cat.id" />
114
+          </el-select>
115
+        </el-form-item>
116
+        <el-form-item label="标签">
117
+          <el-input v-model="uploadForm.tags" placeholder="多个标签用逗号分隔" />
118
+        </el-form-item>
119
+        <el-form-item label="权限">
120
+          <el-radio-group v-model="uploadForm.accessLevel">
121
+            <el-radio :label="0">公开</el-radio>
122
+            <el-radio :label="1">部门可见</el-radio>
123
+            <el-radio :label="2">仅自己</el-radio>
124
+          </el-radio-group>
125
+        </el-form-item>
126
+      </el-form>
127
+      <template #footer>
128
+        <el-button @click="showUploadDialog = false">取消</el-button>
129
+        <el-button type="primary" @click="submitUpload" :loading="uploading">上传</el-button>
130
+      </template>
131
+    </el-dialog>
132
+  </div>
133
+</template>
134
+
135
+<script setup>
136
+import { ref, reactive, onMounted } from 'vue'
137
+import { useRouter } from 'vue-router'
138
+import { ElMessage, ElMessageBox } from 'element-plus'
139
+import { Search, Upload, UploadFilled } from '@element-plus/icons-vue'
140
+import axios from 'axios'
141
+
142
+const router = useRouter()
143
+const loading = ref(false)
144
+const uploading = ref(false)
145
+const showUploadDialog = ref(false)
146
+const docList = ref([])
147
+const categories = ref([])
148
+const total = ref(0)
149
+const uploadRef = ref(null)
150
+
151
+const queryForm = reactive({
152
+  keyword: '',
153
+  categoryId: null,
154
+  fileType: '',
155
+  pageNum: 1,
156
+  pageSize: 20
157
+})
158
+
159
+const uploadForm = reactive({
160
+  file: null,
161
+  name: '',
162
+  description: '',
163
+  categoryId: null,
164
+  tags: '',
165
+  accessLevel: 0
166
+})
167
+
168
+onMounted(() => {
169
+  loadCategories()
170
+  handleSearch()
171
+})
172
+
173
+const loadCategories = async () => {
174
+  try {
175
+    const res = await axios.get('/api/base/doc/categories')
176
+    if (res.data.code === 200) {
177
+      categories.value = res.data.data
178
+    }
179
+  } catch (e) {
180
+    console.error('加载分类失败', e)
181
+  }
182
+}
183
+
184
+const handleSearch = async () => {
185
+  loading.value = true
186
+  try {
187
+    const res = await axios.get('/api/base/doc/search', { params: queryForm })
188
+    if (res.data.code === 200) {
189
+      docList.value = res.data.data.records || []
190
+      total.value = res.data.data.total || 0
191
+    }
192
+  } catch (e) {
193
+    ElMessage.error('搜索失败')
194
+  } finally {
195
+    loading.value = false
196
+  }
197
+}
198
+
199
+const resetQuery = () => {
200
+  Object.assign(queryForm, { keyword: '', categoryId: null, fileType: '', pageNum: 1, pageSize: 20 })
201
+  handleSearch()
202
+}
203
+
204
+const handleFileChange = (file) => {
205
+  uploadForm.file = file.raw
206
+  if (!uploadForm.name) {
207
+    uploadForm.name = file.name
208
+  }
209
+}
210
+
211
+const submitUpload = async () => {
212
+  if (!uploadForm.file) {
213
+    ElMessage.warning('请选择文件')
214
+    return
215
+  }
216
+  uploading.value = true
217
+  const formData = new FormData()
218
+  formData.append('file', uploadForm.file)
219
+  if (uploadForm.name) formData.append('name', uploadForm.name)
220
+  if (uploadForm.description) formData.append('description', uploadForm.description)
221
+  if (uploadForm.categoryId) formData.append('categoryId', uploadForm.categoryId)
222
+  if (uploadForm.tags) formData.append('tags', uploadForm.tags)
223
+  formData.append('accessLevel', uploadForm.accessLevel)
224
+
225
+  try {
226
+    const res = await axios.post('/api/base/doc/upload', formData, {
227
+      headers: { 'Content-Type': 'multipart/form-data' }
228
+    })
229
+    if (res.data.code === 200) {
230
+      ElMessage.success('上传成功')
231
+      showUploadDialog.value = false
232
+      Object.assign(uploadForm, { file: null, name: '', description: '', categoryId: null, tags: '', accessLevel: 0 })
233
+      handleSearch()
234
+    }
235
+  } catch (e) {
236
+    ElMessage.error('上传失败')
237
+  } finally {
238
+    uploading.value = false
239
+  }
240
+}
241
+
242
+const downloadDoc = async (docId) => {
243
+  try {
244
+    const res = await axios.get(`/api/base/doc/${docId}/download`, { responseType: 'blob' })
245
+    const url = window.URL.createObjectURL(new Blob([res.data]))
246
+    const link = document.createElement('a')
247
+    link.href = url
248
+    link.download = `doc_${docId}`
249
+    link.click()
250
+    window.URL.revokeObjectURL(url)
251
+  } catch (e) {
252
+    ElMessage.error('下载失败')
253
+  }
254
+}
255
+
256
+const viewDetail = (docId) => {
257
+  router.push(`/doc/detail/${docId}`)
258
+}
259
+
260
+const deleteDoc = async (docId) => {
261
+  try {
262
+    await ElMessageBox.confirm('确认删除该文档?', '提示', { type: 'warning' })
263
+    const res = await axios.delete(`/api/base/doc/${docId}`)
264
+    if (res.data.code === 200) {
265
+      ElMessage.success('删除成功')
266
+      handleSearch()
267
+    }
268
+  } catch (e) {
269
+    if (e !== 'cancel') ElMessage.error('删除失败')
270
+  }
271
+}
272
+
273
+const formatSize = (bytes) => {
274
+  if (!bytes) return '0 B'
275
+  const units = ['B', 'KB', 'MB', 'GB']
276
+  let i = 0
277
+  let size = bytes
278
+  while (size >= 1024 && i < units.length - 1) {
279
+    size /= 1024
280
+    i++
281
+  }
282
+  return size.toFixed(1) + ' ' + units[i]
283
+}
284
+
285
+const parseTags = (tags) => {
286
+  if (!tags) return []
287
+  return tags.split(',').filter(t => t.trim())
288
+}
289
+</script>
290
+
291
+<style scoped>
292
+.doc-container {
293
+  padding: 20px;
294
+}
295
+.search-card {
296
+  margin-bottom: 16px;
297
+}
298
+.card-header {
299
+  display: flex;
300
+  justify-content: space-between;
301
+  align-items: center;
302
+}
303
+.pagination {
304
+  margin-top: 16px;
305
+  justify-content: flex-end;
306
+}
307
+.tag-item {
308
+  margin-right: 4px;
309
+}
310
+</style>

+ 212
- 0
wm-base/src/main/java/com/water/base/controller/DocController.java Просмотреть файл

@@ -0,0 +1,212 @@
1
+package com.water.base.controller;
2
+
3
+import com.baomidou.mybatisplus.core.metadata.IPage;
4
+import com.water.base.entity.Doc;
5
+import com.water.base.entity.DocCategory;
6
+import com.water.base.entity.DocVersion;
7
+import com.water.base.entity.dto.DocQueryRequest;
8
+import com.water.base.entity.dto.DocVO;
9
+import com.water.base.service.DocService;
10
+import lombok.RequiredArgsConstructor;
11
+import org.springframework.core.io.InputStreamResource;
12
+import org.springframework.http.HttpHeaders;
13
+import org.springframework.http.MediaType;
14
+import org.springframework.http.ResponseEntity;
15
+import org.springframework.web.bind.annotation.*;
16
+import org.springframework.web.multipart.MultipartFile;
17
+
18
+import java.io.InputStream;
19
+import java.net.URLEncoder;
20
+import java.nio.charset.StandardCharsets;
21
+import java.util.HashMap;
22
+import java.util.List;
23
+import java.util.Map;
24
+
25
+@RestController
26
+@RequestMapping("/api/base/doc")
27
+@RequiredArgsConstructor
28
+public class DocController {
29
+
30
+    private final DocService docService;
31
+
32
+    /**
33
+     * 上传文档
34
+     */
35
+    @PostMapping("/upload")
36
+    public Map<String, Object> upload(
37
+            @RequestParam("file") MultipartFile file,
38
+            @RequestParam(value = "name", required = false) String name,
39
+            @RequestParam(value = "description", required = false) String description,
40
+            @RequestParam(value = "categoryId", required = false) Long categoryId,
41
+            @RequestParam(value = "tags", required = false) String tags,
42
+            @RequestParam(value = "accessLevel", required = false, defaultValue = "0") Integer accessLevel,
43
+            @RequestParam(value = "deptId", required = false) Long deptId,
44
+            @RequestParam(value = "uploaderId", required = false) Long uploaderId,
45
+            @RequestParam(value = "uploaderName", required = false) String uploaderName) throws Exception {
46
+
47
+        Doc doc = docService.upload(file, name, description, categoryId, tags, accessLevel, deptId, uploaderId, uploaderName);
48
+        Map<String, Object> result = new HashMap<>();
49
+        result.put("code", 200);
50
+        result.put("msg", "上传成功");
51
+        result.put("data", doc);
52
+        return result;
53
+    }
54
+
55
+    /**
56
+     * 上传新版本
57
+     */
58
+    @PostMapping("/{docId}/version")
59
+    public Map<String, Object> uploadVersion(
60
+            @PathVariable Long docId,
61
+            @RequestParam("file") MultipartFile file,
62
+            @RequestParam(value = "changeLog", required = false) String changeLog,
63
+            @RequestParam(value = "uploaderId", required = false) Long uploaderId,
64
+            @RequestParam(value = "uploaderName", required = false) String uploaderName) throws Exception {
65
+
66
+        DocVersion version = docService.uploadNewVersion(docId, file, changeLog, uploaderId, uploaderName);
67
+        Map<String, Object> result = new HashMap<>();
68
+        result.put("code", 200);
69
+        result.put("msg", "版本上传成功");
70
+        result.put("data", version);
71
+        return result;
72
+    }
73
+
74
+    /**
75
+     * 下载文档(当前版本)
76
+     */
77
+    @GetMapping("/{docId}/download")
78
+    public ResponseEntity<InputStreamResource> download(@PathVariable Long docId) throws Exception {
79
+        DocVO doc = docService.getDocDetail(docId);
80
+        if (doc == null) {
81
+            return ResponseEntity.notFound().build();
82
+        }
83
+        InputStream stream = docService.download(docId);
84
+        String filename = URLEncoder.encode(doc.getName(), StandardCharsets.UTF_8);
85
+        return ResponseEntity.ok()
86
+                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + filename)
87
+                .contentType(MediaType.APPLICATION_OCTET_STREAM)
88
+                .body(new InputStreamResource(stream));
89
+    }
90
+
91
+    /**
92
+     * 下载指定版本
93
+     */
94
+    @GetMapping("/{docId}/version/{version}/download")
95
+    public ResponseEntity<InputStreamResource> downloadVersion(
96
+            @PathVariable Long docId,
97
+            @PathVariable Integer version) throws Exception {
98
+        DocVO doc = docService.getDocDetail(docId);
99
+        if (doc == null) {
100
+            return ResponseEntity.notFound().build();
101
+        }
102
+        InputStream stream = docService.downloadVersion(docId, version);
103
+        String filename = URLEncoder.encode(doc.getName() + "_v" + version, StandardCharsets.UTF_8);
104
+        return ResponseEntity.ok()
105
+                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + filename)
106
+                .contentType(MediaType.APPLICATION_OCTET_STREAM)
107
+                .body(new InputStreamResource(stream));
108
+    }
109
+
110
+    /**
111
+     * 获取文档详情
112
+     */
113
+    @GetMapping("/{docId}")
114
+    public Map<String, Object> getDetail(@PathVariable Long docId) {
115
+        DocVO doc = docService.getDocDetail(docId);
116
+        Map<String, Object> result = new HashMap<>();
117
+        if (doc == null) {
118
+            result.put("code", 404);
119
+            result.put("msg", "文档不存在");
120
+        } else {
121
+            result.put("code", 200);
122
+            result.put("data", doc);
123
+        }
124
+        return result;
125
+    }
126
+
127
+    /**
128
+     * 获取版本列表
129
+     */
130
+    @GetMapping("/{docId}/versions")
131
+    public Map<String, Object> getVersions(@PathVariable Long docId) {
132
+        List<DocVersion> versions = docService.getVersionList(docId);
133
+        Map<String, Object> result = new HashMap<>();
134
+        result.put("code", 200);
135
+        result.put("data", versions);
136
+        return result;
137
+    }
138
+
139
+    /**
140
+     * 更新文档信息
141
+     */
142
+    @PutMapping("/{docId}")
143
+    public Map<String, Object> update(@PathVariable Long docId, @RequestBody Doc doc) {
144
+        doc.setId(docId);
145
+        docService.updateDoc(doc);
146
+        Map<String, Object> result = new HashMap<>();
147
+        result.put("code", 200);
148
+        result.put("msg", "更新成功");
149
+        return result;
150
+    }
151
+
152
+    /**
153
+     * 删除文档
154
+     */
155
+    @DeleteMapping("/{docId}")
156
+    public Map<String, Object> delete(@PathVariable Long docId) {
157
+        docService.deleteDoc(docId);
158
+        Map<String, Object> result = new HashMap<>();
159
+        result.put("code", 200);
160
+        result.put("msg", "删除成功");
161
+        return result;
162
+    }
163
+
164
+    /**
165
+     * 搜索文档(分页)
166
+     */
167
+    @GetMapping("/search")
168
+    public Map<String, Object> search(DocQueryRequest query) {
169
+        IPage<Doc> page = docService.search(query);
170
+        Map<String, Object> result = new HashMap<>();
171
+        result.put("code", 200);
172
+        result.put("data", page);
173
+        return result;
174
+    }
175
+
176
+    /**
177
+     * 获取分类列表
178
+     */
179
+    @GetMapping("/categories")
180
+    public Map<String, Object> getCategories() {
181
+        List<DocCategory> categories = docService.getCategoryTree();
182
+        Map<String, Object> result = new HashMap<>();
183
+        result.put("code", 200);
184
+        result.put("data", categories);
185
+        return result;
186
+    }
187
+
188
+    /**
189
+     * 创建分类
190
+     */
191
+    @PostMapping("/categories")
192
+    public Map<String, Object> createCategory(@RequestBody DocCategory category) {
193
+        DocCategory created = docService.createCategory(category);
194
+        Map<String, Object> result = new HashMap<>();
195
+        result.put("code", 200);
196
+        result.put("msg", "创建成功");
197
+        result.put("data", created);
198
+        return result;
199
+    }
200
+
201
+    /**
202
+     * 删除分类
203
+     */
204
+    @DeleteMapping("/categories/{categoryId}")
205
+    public Map<String, Object> deleteCategory(@PathVariable Long categoryId) {
206
+        docService.deleteCategory(categoryId);
207
+        Map<String, Object> result = new HashMap<>();
208
+        result.put("code", 200);
209
+        result.put("msg", "删除成功");
210
+        return result;
211
+    }
212
+}

+ 64
- 0
wm-base/src/main/java/com/water/base/entity/Doc.java Просмотреть файл

@@ -0,0 +1,64 @@
1
+package com.water.base.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.time.LocalDateTime;
6
+
7
+@Data
8
+@TableName("doc")
9
+public class Doc {
10
+    @TableId(type = IdType.AUTO)
11
+    private Long id;
12
+
13
+    /** 文档名称 */
14
+    private String name;
15
+
16
+    /** 文档描述 */
17
+    private String description;
18
+
19
+    /** 文件类型: pdf, docx, xlsx, jpg, png 等 */
20
+    private String fileType;
21
+
22
+    /** 文件大小(字节) */
23
+    private Long fileSize;
24
+
25
+    /** MinIO objectName */
26
+    private String storagePath;
27
+
28
+    /** 分类ID */
29
+    private Long categoryId;
30
+
31
+    /** 标签(逗号分隔) */
32
+    private String tags;
33
+
34
+    /** 当前版本号 */
35
+    private Integer currentVersion;
36
+
37
+    /** 上传人ID */
38
+    private Long uploaderId;
39
+
40
+    /** 上传人姓名 */
41
+    private String uploaderName;
42
+
43
+    /** 权限级别: 0-公开 1-部门可见 2-仅自己 */
44
+    private Integer accessLevel;
45
+
46
+    /** 所属部门ID */
47
+    private Long deptId;
48
+
49
+    /** 下载次数 */
50
+    private Integer downloadCount;
51
+
52
+    /** 全文检索文本 */
53
+    private String searchText;
54
+
55
+    /** 逻辑删除 */
56
+    @TableLogic
57
+    private Integer deleted;
58
+
59
+    @TableField(fill = FieldFill.INSERT)
60
+    private LocalDateTime createTime;
61
+
62
+    @TableField(fill = FieldFill.INSERT_UPDATE)
63
+    private LocalDateTime updateTime;
64
+}

+ 27
- 0
wm-base/src/main/java/com/water/base/entity/DocCategory.java Просмотреть файл

@@ -0,0 +1,27 @@
1
+package com.water.base.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.time.LocalDateTime;
6
+
7
+@Data
8
+@TableName("doc_category")
9
+public class DocCategory {
10
+    @TableId(type = IdType.AUTO)
11
+    private Long id;
12
+
13
+    /** 分类名称 */
14
+    private String name;
15
+
16
+    /** 父分类ID */
17
+    private Long parentId;
18
+
19
+    /** 排序 */
20
+    private Integer sort;
21
+
22
+    @TableLogic
23
+    private Integer deleted;
24
+
25
+    @TableField(fill = FieldFill.INSERT)
26
+    private LocalDateTime createTime;
27
+}

+ 36
- 0
wm-base/src/main/java/com/water/base/entity/DocVersion.java Просмотреть файл

@@ -0,0 +1,36 @@
1
+package com.water.base.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.time.LocalDateTime;
6
+
7
+@Data
8
+@TableName("doc_version")
9
+public class DocVersion {
10
+    @TableId(type = IdType.AUTO)
11
+    private Long id;
12
+
13
+    /** 文档ID */
14
+    private Long docId;
15
+
16
+    /** 版本号 */
17
+    private Integer version;
18
+
19
+    /** MinIO objectName */
20
+    private String storagePath;
21
+
22
+    /** 文件大小(字节) */
23
+    private Long fileSize;
24
+
25
+    /** 变更说明 */
26
+    private String changeLog;
27
+
28
+    /** 上传人ID */
29
+    private Long uploaderId;
30
+
31
+    /** 上传人姓名 */
32
+    private String uploaderName;
33
+
34
+    @TableField(fill = FieldFill.INSERT)
35
+    private LocalDateTime createTime;
36
+}

+ 30
- 0
wm-base/src/main/java/com/water/base/entity/dto/DocQueryRequest.java Просмотреть файл

@@ -0,0 +1,30 @@
1
+package com.water.base.entity.dto;
2
+
3
+import lombok.Data;
4
+
5
+@Data
6
+public class DocQueryRequest {
7
+    /** 关键词(全文检索) */
8
+    private String keyword;
9
+
10
+    /** 分类ID */
11
+    private Long categoryId;
12
+
13
+    /** 标签 */
14
+    private String tag;
15
+
16
+    /** 文件类型 */
17
+    private String fileType;
18
+
19
+    /** 权限级别 */
20
+    private Integer accessLevel;
21
+
22
+    /** 上传人ID */
23
+    private Long uploaderId;
24
+
25
+    /** 页码 */
26
+    private Integer pageNum = 1;
27
+
28
+    /** 每页数量 */
29
+    private Integer pageSize = 20;
30
+}

+ 26
- 0
wm-base/src/main/java/com/water/base/entity/dto/DocVO.java Просмотреть файл

@@ -0,0 +1,26 @@
1
+package com.water.base.entity.dto;
2
+
3
+import lombok.Data;
4
+import java.time.LocalDateTime;
5
+import java.util.List;
6
+
7
+@Data
8
+public class DocVO {
9
+    private Long id;
10
+    private String name;
11
+    private String description;
12
+    private String fileType;
13
+    private Long fileSize;
14
+    private Long categoryId;
15
+    private String categoryName;
16
+    private String tags;
17
+    private List<String> tagList;
18
+    private Integer currentVersion;
19
+    private Long uploaderId;
20
+    private String uploaderName;
21
+    private Integer accessLevel;
22
+    private Long deptId;
23
+    private Integer downloadCount;
24
+    private LocalDateTime createTime;
25
+    private LocalDateTime updateTime;
26
+}

+ 9
- 0
wm-base/src/main/java/com/water/base/mapper/DocCategoryMapper.java Просмотреть файл

@@ -0,0 +1,9 @@
1
+package com.water.base.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.base.entity.DocCategory;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface DocCategoryMapper extends BaseMapper<DocCategory> {
9
+}

+ 17
- 0
wm-base/src/main/java/com/water/base/mapper/DocMapper.java Просмотреть файл

@@ -0,0 +1,17 @@
1
+package com.water.base.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.baomidou.mybatisplus.core.metadata.IPage;
5
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
6
+import com.water.base.entity.Doc;
7
+import com.water.base.entity.dto.DocQueryRequest;
8
+import org.apache.ibatis.annotations.Mapper;
9
+import org.apache.ibatis.annotations.Param;
10
+
11
+@Mapper
12
+public interface DocMapper extends BaseMapper<Doc> {
13
+    /**
14
+     * 全文检索分页查询
15
+     */
16
+    IPage<Doc> searchPage(Page<Doc> page, @Param("q") DocQueryRequest query);
17
+}

+ 9
- 0
wm-base/src/main/java/com/water/base/mapper/DocVersionMapper.java Просмотреть файл

@@ -0,0 +1,9 @@
1
+package com.water.base.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.base.entity.DocVersion;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface DocVersionMapper extends BaseMapper<DocVersion> {
9
+}

+ 301
- 0
wm-base/src/main/java/com/water/base/service/DocService.java Просмотреть файл

@@ -0,0 +1,301 @@
1
+package com.water.base.service;
2
+
3
+import com.baomidou.mybatisplus.core.metadata.IPage;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.water.base.entity.Doc;
6
+import com.water.base.entity.DocCategory;
7
+import com.water.base.entity.DocVersion;
8
+import com.water.base.entity.dto.DocQueryRequest;
9
+import com.water.base.entity.dto.DocVO;
10
+import com.water.base.mapper.DocCategoryMapper;
11
+import com.water.base.mapper.DocMapper;
12
+import com.water.base.mapper.DocVersionMapper;
13
+import com.water.common.core.storage.MinioService;
14
+import lombok.RequiredArgsConstructor;
15
+import lombok.extern.slf4j.Slf4j;
16
+import org.springframework.stereotype.Service;
17
+import org.springframework.transaction.annotation.Transactional;
18
+import org.springframework.web.multipart.MultipartFile;
19
+
20
+import java.io.InputStream;
21
+import java.time.LocalDateTime;
22
+import java.util.Arrays;
23
+import java.util.List;
24
+import java.util.stream.Collectors;
25
+
26
+@Slf4j
27
+@Service
28
+@RequiredArgsConstructor
29
+public class DocService {
30
+
31
+    private final DocMapper docMapper;
32
+    private final DocVersionMapper docVersionMapper;
33
+    private final DocCategoryMapper docCategoryMapper;
34
+    private final MinioService minioService;
35
+
36
+    /**
37
+     * 上传新文档
38
+     */
39
+    @Transactional
40
+    public Doc upload(MultipartFile file, String name, String description, Long categoryId,
41
+                      String tags, Integer accessLevel, Long deptId,
42
+                      Long uploaderId, String uploaderName) throws Exception {
43
+        // 上传文件到 MinIO
44
+        String storagePath = minioService.upload(file, "doc");
45
+
46
+        // 创建文档记录
47
+        Doc doc = new Doc();
48
+        doc.setName(name != null ? name : file.getOriginalFilename());
49
+        doc.setDescription(description);
50
+        doc.setFileType(getFileExtension(file.getOriginalFilename()));
51
+        doc.setFileSize(file.getSize());
52
+        doc.setStoragePath(storagePath);
53
+        doc.setCategoryId(categoryId);
54
+        doc.setTags(tags);
55
+        doc.setCurrentVersion(1);
56
+        doc.setUploaderId(uploaderId);
57
+        doc.setUploaderName(uploaderName);
58
+        doc.setAccessLevel(accessLevel != null ? accessLevel : 0);
59
+        doc.setDeptId(deptId);
60
+        doc.setDownloadCount(0);
61
+        doc.setSearchText(buildSearchText(name, description, tags));
62
+        doc.setDeleted(0);
63
+        doc.setCreateTime(LocalDateTime.now());
64
+        doc.setUpdateTime(LocalDateTime.now());
65
+        docMapper.insert(doc);
66
+
67
+        // 创建初始版本记录
68
+        DocVersion version = new DocVersion();
69
+        version.setDocId(doc.getId());
70
+        version.setVersion(1);
71
+        version.setStoragePath(storagePath);
72
+        version.setFileSize(file.getSize());
73
+        version.setChangeLog("初始上传");
74
+        version.setUploaderId(uploaderId);
75
+        version.setUploaderName(uploaderName);
76
+        version.setCreateTime(LocalDateTime.now());
77
+        docVersionMapper.insert(version);
78
+
79
+        log.info("文档上传成功: id={}, name={}", doc.getId(), doc.getName());
80
+        return doc;
81
+    }
82
+
83
+    /**
84
+     * 上传新版本
85
+     */
86
+    @Transactional
87
+    public DocVersion uploadNewVersion(Long docId, MultipartFile file, String changeLog,
88
+                                        Long uploaderId, String uploaderName) throws Exception {
89
+        Doc doc = docMapper.selectById(docId);
90
+        if (doc == null) {
91
+            throw new RuntimeException("文档不存在: " + docId);
92
+        }
93
+
94
+        // 上传新版本文件
95
+        String storagePath = minioService.upload(file, "doc/version");
96
+        int newVersion = doc.getCurrentVersion() + 1;
97
+
98
+        // 更新文档主记录
99
+        doc.setCurrentVersion(newVersion);
100
+        doc.setFileSize(file.getSize());
101
+        doc.setStoragePath(storagePath);
102
+        doc.setFileType(getFileExtension(file.getOriginalFilename()));
103
+        doc.setUpdateTime(LocalDateTime.now());
104
+        docMapper.updateById(doc);
105
+
106
+        // 创建版本记录
107
+        DocVersion version = new DocVersion();
108
+        version.setDocId(docId);
109
+        version.setVersion(newVersion);
110
+        version.setStoragePath(storagePath);
111
+        version.setFileSize(file.getSize());
112
+        version.setChangeLog(changeLog);
113
+        version.setUploaderId(uploaderId);
114
+        version.setUploaderName(uploaderName);
115
+        version.setCreateTime(LocalDateTime.now());
116
+        docVersionMapper.insert(version);
117
+
118
+        log.info("文档新版本上传成功: docId={}, version={}", docId, newVersion);
119
+        return version;
120
+    }
121
+
122
+    /**
123
+     * 下载文档
124
+     */
125
+    @Transactional
126
+    public InputStream download(Long docId) throws Exception {
127
+        Doc doc = docMapper.selectById(docId);
128
+        if (doc == null) {
129
+            throw new RuntimeException("文档不存在: " + docId);
130
+        }
131
+
132
+        // 增加下载计数
133
+        doc.setDownloadCount(doc.getDownloadCount() + 1);
134
+        docMapper.updateById(doc);
135
+
136
+        return minioService.download(doc.getStoragePath());
137
+    }
138
+
139
+    /**
140
+     * 下载指定版本
141
+     */
142
+    public InputStream downloadVersion(Long docId, Integer version) throws Exception {
143
+        DocVersion docVersion = docVersionMapper.selectOne(
144
+            new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<DocVersion>()
145
+                .eq(DocVersion::getDocId, docId)
146
+                .eq(DocVersion::getVersion, version)
147
+        );
148
+        if (docVersion == null) {
149
+            throw new RuntimeException("版本不存在: docId=" + docId + ", version=" + version);
150
+        }
151
+        return minioService.download(docVersion.getStoragePath());
152
+    }
153
+
154
+    /**
155
+     * 更新文档元信息
156
+     */
157
+    @Transactional
158
+    public void updateDoc(Doc doc) {
159
+        Doc existing = docMapper.selectById(doc.getId());
160
+        if (existing == null) {
161
+            throw new RuntimeException("文档不存在");
162
+        }
163
+        doc.setSearchText(buildSearchText(
164
+            doc.getName() != null ? doc.getName() : existing.getName(),
165
+            doc.getDescription() != null ? doc.getDescription() : existing.getDescription(),
166
+            doc.getTags() != null ? doc.getTags() : existing.getTags()
167
+        ));
168
+        doc.setUpdateTime(LocalDateTime.now());
169
+        docMapper.updateById(doc);
170
+    }
171
+
172
+    /**
173
+     * 删除文档(逻辑删除)
174
+     */
175
+    @Transactional
176
+    public void deleteDoc(Long docId) {
177
+        Doc doc = docMapper.selectById(docId);
178
+        if (doc == null) {
179
+            throw new RuntimeException("文档不存在");
180
+        }
181
+        docMapper.deleteById(docId);
182
+        log.info("文档已删除: id={}", docId);
183
+    }
184
+
185
+    /**
186
+     * 获取文档详情
187
+     */
188
+    public DocVO getDocDetail(Long docId) {
189
+        Doc doc = docMapper.selectById(docId);
190
+        if (doc == null) {
191
+            return null;
192
+        }
193
+        return convertToVO(doc);
194
+    }
195
+
196
+    /**
197
+     * 获取文档版本列表
198
+     */
199
+    public List<DocVersion> getVersionList(Long docId) {
200
+        return docVersionMapper.selectList(
201
+            new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<DocVersion>()
202
+                .eq(DocVersion::getDocId, docId)
203
+                .orderByDesc(DocVersion::getVersion)
204
+        );
205
+    }
206
+
207
+    /**
208
+     * 分页搜索文档
209
+     */
210
+    public IPage<Doc> search(DocQueryRequest query) {
211
+        Page<Doc> page = new Page<>(query.getPageNum(), query.getPageSize());
212
+        return docMapper.searchPage(page, query);
213
+    }
214
+
215
+    /**
216
+     * 获取分类树
217
+     */
218
+    public List<DocCategory> getCategoryTree() {
219
+        return docCategoryMapper.selectList(
220
+            new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<DocCategory>()
221
+                .eq(DocCategory::getDeleted, 0)
222
+                .orderByAsc(DocCategory::getSort)
223
+        );
224
+    }
225
+
226
+    /**
227
+     * 创建分类
228
+     */
229
+    public DocCategory createCategory(DocCategory category) {
230
+        category.setDeleted(0);
231
+        category.setCreateTime(LocalDateTime.now());
232
+        docCategoryMapper.insert(category);
233
+        return category;
234
+    }
235
+
236
+    /**
237
+     * 删除分类
238
+     */
239
+    @Transactional
240
+    public void deleteCategory(Long categoryId) {
241
+        // 检查是否有文档使用该分类
242
+        long count = docMapper.selectCount(
243
+            new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Doc>()
244
+                .eq(Doc::getCategoryId, categoryId)
245
+                .eq(Doc::getDeleted, 0)
246
+        );
247
+        if (count > 0) {
248
+            throw new RuntimeException("该分类下有 " + count + " 个文档,无法删除");
249
+        }
250
+        docCategoryMapper.deleteById(categoryId);
251
+    }
252
+
253
+    private DocVO convertToVO(Doc doc) {
254
+        DocVO vo = new DocVO();
255
+        vo.setId(doc.getId());
256
+        vo.setName(doc.getName());
257
+        vo.setDescription(doc.getDescription());
258
+        vo.setFileType(doc.getFileType());
259
+        vo.setFileSize(doc.getFileSize());
260
+        vo.setCategoryId(doc.getCategoryId());
261
+        vo.setTags(doc.getTags());
262
+        vo.setCurrentVersion(doc.getCurrentVersion());
263
+        vo.setUploaderId(doc.getUploaderId());
264
+        vo.setUploaderName(doc.getUploaderName());
265
+        vo.setAccessLevel(doc.getAccessLevel());
266
+        vo.setDeptId(doc.getDeptId());
267
+        vo.setDownloadCount(doc.getDownloadCount());
268
+        vo.setCreateTime(doc.getCreateTime());
269
+        vo.setUpdateTime(doc.getUpdateTime());
270
+
271
+        // 设置分类名称
272
+        if (doc.getCategoryId() != null) {
273
+            DocCategory category = docCategoryMapper.selectById(doc.getCategoryId());
274
+            if (category != null) {
275
+                vo.setCategoryName(category.getName());
276
+            }
277
+        }
278
+
279
+        // 设置标签列表
280
+        if (doc.getTags() != null && !doc.getTags().isEmpty()) {
281
+            vo.setTagList(Arrays.asList(doc.getTags().split(",")));
282
+        }
283
+
284
+        return vo;
285
+    }
286
+
287
+    private String buildSearchText(String name, String description, String tags) {
288
+        StringBuilder sb = new StringBuilder();
289
+        if (name != null) sb.append(name).append(" ");
290
+        if (description != null) sb.append(description).append(" ");
291
+        if (tags != null) sb.append(tags);
292
+        return sb.toString().trim();
293
+    }
294
+
295
+    private String getFileExtension(String filename) {
296
+        if (filename == null || !filename.contains(".")) {
297
+            return "unknown";
298
+        }
299
+        return filename.substring(filename.lastIndexOf(".") + 1).toLowerCase();
300
+    }
301
+}

+ 10
- 0
wm-base/src/main/resources/application.yml Просмотреть файл

@@ -4,6 +4,10 @@ server:
4 4
 spring:
5 5
   application:
6 6
     name: wm-base
7
+  servlet:
8
+    multipart:
9
+      max-file-size: 100MB
10
+      max-request-size: 100MB
7 11
   datasource:
8 12
     url: jdbc:postgresql://${PG_HOST:127.0.0.1}:5432/water_management
9 13
     username: ${PG_USER:water}
@@ -29,3 +33,9 @@ mybatis-plus:
29 33
       logic-delete-field: deleted
30 34
       logic-delete-value: 1
31 35
       logic-not-delete-value: 0
36
+
37
+minio:
38
+  endpoint: ${MINIO_ENDPOINT:http://127.0.0.1:9000}
39
+  access-key: ${MINIO_ACCESS_KEY:minioadmin}
40
+  secret-key: ${MINIO_SECRET_KEY:minioadmin}
41
+  bucket: ${MINIO_BUCKET:water-management}

+ 34
- 0
wm-base/src/main/resources/mapper/DocMapper.xml Просмотреть файл

@@ -0,0 +1,34 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
3
+<mapper namespace="com.water.base.mapper.DocMapper">
4
+
5
+    <select id="searchPage" resultType="com.water.base.entity.Doc">
6
+        SELECT * FROM doc
7
+        WHERE deleted = 0
8
+        <if test="q.keyword != null and q.keyword != ''">
9
+            AND (
10
+                name ILIKE CONCAT('%', #{q.keyword}, '%')
11
+                OR description ILIKE CONCAT('%', #{q.keyword}, '%')
12
+                OR search_text ILIKE CONCAT('%', #{q.keyword}, '%')
13
+                OR tags ILIKE CONCAT('%', #{q.keyword}, '%')
14
+            )
15
+        </if>
16
+        <if test="q.categoryId != null">
17
+            AND category_id = #{q.categoryId}
18
+        </if>
19
+        <if test="q.tag != null and q.tag != ''">
20
+            AND tags LIKE CONCAT('%', #{q.tag}, '%')
21
+        </if>
22
+        <if test="q.fileType != null and q.fileType != ''">
23
+            AND file_type = #{q.fileType}
24
+        </if>
25
+        <if test="q.accessLevel != null">
26
+            AND access_level = #{q.accessLevel}
27
+        </if>
28
+        <if test="q.uploaderId != null">
29
+            AND uploader_id = #{q.uploaderId}
30
+        </if>
31
+        ORDER BY update_time DESC
32
+    </select>
33
+
34
+</mapper>

+ 263
- 0
wm-base/src/test/java/com/water/base/service/DocServiceTest.java Просмотреть файл

@@ -0,0 +1,263 @@
1
+package com.water.base.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.core.metadata.IPage;
5
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
6
+import com.water.base.entity.Doc;
7
+import com.water.base.entity.DocCategory;
8
+import com.water.base.entity.DocVersion;
9
+import com.water.base.entity.dto.DocQueryRequest;
10
+import com.water.base.entity.dto.DocVO;
11
+import com.water.base.mapper.DocCategoryMapper;
12
+import com.water.base.mapper.DocMapper;
13
+import com.water.base.mapper.DocVersionMapper;
14
+import com.water.common.core.storage.MinioService;
15
+import org.junit.jupiter.api.BeforeEach;
16
+import org.junit.jupiter.api.Test;
17
+import org.junit.jupiter.api.extension.ExtendWith;
18
+import org.mockito.InjectMocks;
19
+import org.mockito.Mock;
20
+import org.mockito.junit.jupiter.MockitoExtension;
21
+import org.springframework.mock.web.MockMultipartFile;
22
+
23
+import java.io.ByteArrayInputStream;
24
+import java.io.InputStream;
25
+import java.util.Arrays;
26
+import java.util.List;
27
+
28
+import static org.junit.jupiter.api.Assertions.*;
29
+import static org.mockito.ArgumentMatchers.*;
30
+import static org.mockito.Mockito.*;
31
+
32
+@ExtendWith(MockitoExtension.class)
33
+class DocServiceTest {
34
+
35
+    @Mock
36
+    private DocMapper docMapper;
37
+    @Mock
38
+    private DocVersionMapper docVersionMapper;
39
+    @Mock
40
+    private DocCategoryMapper docCategoryMapper;
41
+    @Mock
42
+    private MinioService minioService;
43
+
44
+    @InjectMocks
45
+    private DocService docService;
46
+
47
+    private MockMultipartFile testFile;
48
+
49
+    @BeforeEach
50
+    void setUp() {
51
+        testFile = new MockMultipartFile("file", "test.pdf", "application/pdf", "test content".getBytes());
52
+    }
53
+
54
+    @Test
55
+    void testUpload() throws Exception {
56
+        when(minioService.upload(any(), eq("doc"))).thenReturn("doc/uuid_test.pdf");
57
+        when(docMapper.insert(any(Doc.class))).thenAnswer(invocation -> {
58
+            Doc doc = invocation.getArgument(0);
59
+            doc.setId(1L);
60
+            return 1;
61
+        });
62
+        when(docVersionMapper.insert(any(DocVersion.class))).thenReturn(1);
63
+
64
+        Doc result = docService.upload(testFile, "测试文档", "描述", 1L, "标签1,标签2", 0, 1L, 1L, "张三");
65
+
66
+        assertNotNull(result);
67
+        assertEquals("测试文档", result.getName());
68
+        assertEquals("pdf", result.getFileType());
69
+        assertEquals(1, result.getCurrentVersion());
70
+        assertEquals(0, result.getDownloadCount());
71
+        verify(minioService).upload(any(), eq("doc"));
72
+        verify(docMapper).insert(any(Doc.class));
73
+        verify(docVersionMapper).insert(any(DocVersion.class));
74
+    }
75
+
76
+    @Test
77
+    void testUploadNewVersion() throws Exception {
78
+        Doc existingDoc = new Doc();
79
+        existingDoc.setId(1L);
80
+        existingDoc.setCurrentVersion(1);
81
+        existingDoc.setStoragePath("doc/old.pdf");
82
+
83
+        when(docMapper.selectById(1L)).thenReturn(existingDoc);
84
+        when(minioService.upload(any(), eq("doc/version"))).thenReturn("doc/version/uuid_new.pdf");
85
+        when(docMapper.updateById(any(Doc.class))).thenReturn(1);
86
+        when(docVersionMapper.insert(any(DocVersion.class))).thenReturn(1);
87
+
88
+        DocVersion result = docService.uploadNewVersion(1L, testFile, "修复错误", 1L, "张三");
89
+
90
+        assertNotNull(result);
91
+        assertEquals(2, result.getVersion());
92
+        assertEquals("修复错误", result.getChangeLog());
93
+        assertEquals(2, existingDoc.getCurrentVersion());
94
+    }
95
+
96
+    @Test
97
+    void testUploadNewVersionDocNotFound() {
98
+        when(docMapper.selectById(999L)).thenReturn(null);
99
+
100
+        assertThrows(RuntimeException.class, () ->
101
+            docService.uploadNewVersion(999L, testFile, "test", 1L, "张三")
102
+        );
103
+    }
104
+
105
+    @Test
106
+    void testDownload() throws Exception {
107
+        Doc doc = new Doc();
108
+        doc.setId(1L);
109
+        doc.setStoragePath("doc/test.pdf");
110
+        doc.setDownloadCount(5);
111
+
112
+        when(docMapper.selectById(1L)).thenReturn(doc);
113
+        when(docMapper.updateById(any(Doc.class))).thenReturn(1);
114
+        when(minioService.download("doc/test.pdf")).thenReturn(new ByteArrayInputStream("data".getBytes()));
115
+
116
+        InputStream result = docService.download(1L);
117
+
118
+        assertNotNull(result);
119
+        assertEquals(6, doc.getDownloadCount());
120
+    }
121
+
122
+    @Test
123
+    void testDownloadDocNotFound() {
124
+        when(docMapper.selectById(999L)).thenReturn(null);
125
+
126
+        assertThrows(RuntimeException.class, () -> docService.download(999L));
127
+    }
128
+
129
+    @Test
130
+    void testGetDocDetail() {
131
+        Doc doc = new Doc();
132
+        doc.setId(1L);
133
+        doc.setName("测试文档");
134
+        doc.setFileType("pdf");
135
+        doc.setTags("标签1,标签2");
136
+        doc.setCategoryId(1L);
137
+
138
+        DocCategory cat = new DocCategory();
139
+        cat.setId(1L);
140
+        cat.setName("技术规范");
141
+
142
+        when(docMapper.selectById(1L)).thenReturn(doc);
143
+        when(docCategoryMapper.selectById(1L)).thenReturn(cat);
144
+
145
+        DocVO result = docService.getDocDetail(1L);
146
+
147
+        assertNotNull(result);
148
+        assertEquals("测试文档", result.getName());
149
+        assertEquals("技术规范", result.getCategoryName());
150
+        assertEquals(2, result.getTagList().size());
151
+    }
152
+
153
+    @Test
154
+    void testGetDocDetailNotFound() {
155
+        when(docMapper.selectById(999L)).thenReturn(null);
156
+
157
+        DocVO result = docService.getDocDetail(999L);
158
+        assertNull(result);
159
+    }
160
+
161
+    @Test
162
+    void testGetVersionList() {
163
+        DocVersion v1 = new DocVersion();
164
+        v1.setVersion(1);
165
+        DocVersion v2 = new DocVersion();
166
+        v2.setVersion(2);
167
+
168
+        when(docVersionMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Arrays.asList(v2, v1));
169
+
170
+        List<DocVersion> result = docService.getVersionList(1L);
171
+
172
+        assertEquals(2, result.size());
173
+        assertEquals(2, result.get(0).getVersion());
174
+    }
175
+
176
+    @Test
177
+    void testSearch() {
178
+        DocQueryRequest query = new DocQueryRequest();
179
+        query.setKeyword("测试");
180
+        query.setPageNum(1);
181
+        query.setPageSize(10);
182
+
183
+        Page<Doc> page = new Page<>(1, 10);
184
+        page.setTotal(1);
185
+        page.setRecords(List.of(new Doc()));
186
+
187
+        when(docMapper.searchPage(any(Page.class), any(DocQueryRequest.class))).thenReturn(page);
188
+
189
+        IPage<Doc> result = docService.search(query);
190
+
191
+        assertNotNull(result);
192
+        assertEquals(1, result.getTotal());
193
+    }
194
+
195
+    @Test
196
+    void testCreateCategory() {
197
+        DocCategory category = new DocCategory();
198
+        category.setName("新分类");
199
+        category.setParentId(0L);
200
+
201
+        when(docCategoryMapper.insert(any(DocCategory.class))).thenAnswer(invocation -> {
202
+            DocCategory c = invocation.getArgument(0);
203
+            c.setId(1L);
204
+            return 1;
205
+        });
206
+
207
+        DocCategory result = docService.createCategory(category);
208
+
209
+        assertNotNull(result);
210
+        assertEquals("新分类", result.getName());
211
+        assertEquals(0, result.getDeleted());
212
+    }
213
+
214
+    @Test
215
+    void testDeleteCategory() {
216
+        when(docMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(0L);
217
+        when(docCategoryMapper.deleteById(1L)).thenReturn(1);
218
+
219
+        assertDoesNotThrow(() -> docService.deleteCategory(1L));
220
+    }
221
+
222
+    @Test
223
+    void testDeleteCategoryWithDocs() {
224
+        when(docMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(5L);
225
+
226
+        RuntimeException ex = assertThrows(RuntimeException.class, () -> docService.deleteCategory(1L));
227
+        assertTrue(ex.getMessage().contains("无法删除"));
228
+    }
229
+
230
+    @Test
231
+    void testDeleteDoc() {
232
+        Doc doc = new Doc();
233
+        doc.setId(1L);
234
+        when(docMapper.selectById(1L)).thenReturn(doc);
235
+        when(docMapper.deleteById(1L)).thenReturn(1);
236
+
237
+        assertDoesNotThrow(() -> docService.deleteDoc(1L));
238
+    }
239
+
240
+    @Test
241
+    void testDeleteDocNotFound() {
242
+        when(docMapper.selectById(999L)).thenReturn(null);
243
+
244
+        assertThrows(RuntimeException.class, () -> docService.deleteDoc(999L));
245
+    }
246
+
247
+    @Test
248
+    void testUpdateDoc() {
249
+        Doc existing = new Doc();
250
+        existing.setId(1L);
251
+        existing.setName("旧名称");
252
+
253
+        Doc update = new Doc();
254
+        update.setId(1L);
255
+        update.setName("新名称");
256
+
257
+        when(docMapper.selectById(1L)).thenReturn(existing);
258
+        when(docMapper.updateById(any(Doc.class))).thenReturn(1);
259
+
260
+        assertDoesNotThrow(() -> docService.updateDoc(update));
261
+        verify(docMapper).updateById(any(Doc.class));
262
+    }
263
+}