소스 검색

帮助中心更新上传功能

wangfumin 23 시간 전
부모
커밋
41873fb0b2

+ 64 - 2
packages/backend/src/modules/menu/menu.controller.ts

@@ -17,9 +17,10 @@ import { CreateMenuDto, GetMenuDto, QueryMenuDto, UpdateMenuDto, UploadCoverDto
 import { JwtGuard } from '@/common/guards';
 import { FileInterceptor } from '@nestjs/platform-express';
 import { diskStorage } from 'multer';
-import { extname } from 'path';
+import { extname, basename } from 'path';
 import { OSSService } from '../oss/oss.service';
 import { ConfigService } from '@nestjs/config';
+import * as fs from 'fs';
 
 @Controller('menu')
 @ApiTags('menu')
@@ -89,10 +90,71 @@ export class MenuController {
     const result = await this.oSSService.upload(file, {
       filepath: this.configService.get('OSS_FOLDER'),
     });
-    if (result && result.length > 0) {
+    if (result && result.length > 0 && result[0].uploaded && result[0].path) {
       return this.configService.get('OSS_DOMAIN') + '/' + result[0].path;
     } else {
       return false;
     }
   }
+
+  @Post('package/upload')
+  @ApiConsumes('multipart/form-data')
+  @UseInterceptors(
+    FileInterceptor('file', {
+      storage: diskStorage({
+        destination: './uploads_temp',
+        filename: (req, file, cb) => {
+          const randomName = Array(32)
+            .fill(null)
+            .map(() => Math.round(Math.random() * 16).toString(16))
+            .join('');
+          cb(null, `${randomName}${extname(file.originalname)}`);
+        },
+      }),
+    }),
+  )
+  async uploadPackage(@Body() body: { type: string }, @UploadedFile() file) {
+    try {
+      if (!file) {
+        console.error('UploadPackage: No file received');
+        return false;
+      }
+      console.log('UploadPackage: Starting upload for', file.originalname, 'size:', file.size, 'path:', file.path);
+
+      let folder = 'OfflineVersion';
+      if (body.type === 'ga') {
+        folder = 'OfflineVersion_ga';
+      } else {
+        folder = 'OfflineVersion';
+      }
+      const result = await this.oSSService.upload(file, {
+        filepath: `helperCenter/${folder}`,
+        filename: basename(file.originalname, extname(file.originalname)),
+        isInitDateDic: false,
+      });
+
+      console.log('UploadPackage: Upload result', JSON.stringify(result));
+
+      if (file.path) {
+        fs.unlink(file.path, (err) => {
+          if (err) console.error('Error deleting temp file:', err);
+        });
+      }
+
+      if (result && result.length > 0 && result[0].uploaded && result[0].path) {
+        return this.configService.get('OSS_DOMAIN') + '/' + result[0].path;
+      } else {
+        console.error('UploadPackage: Upload failed or returned invalid result');
+        return false;
+      }
+    } catch (error) {
+      console.error('UploadPackage: Exception caught', error);
+      if (file && file.path) {
+        fs.unlink(file.path, (err) => {
+           if (err) console.error('Error deleting temp file after exception:', err);
+        });
+      }
+      return false; // Or throw HttpException
+    }
+  }
 }

+ 34 - 11
packages/backend/src/modules/oss/oss.base.ts

@@ -4,6 +4,7 @@ import * as stream from 'stream';
 import * as moment from 'moment';
 import * as Path from 'path';
 import * as OSS from 'ali-oss';
+import * as fs from 'fs';
 
 export interface UploadResult {
   uploaded: boolean;
@@ -18,8 +19,9 @@ export interface File {
   originalname: string;
   encoding: string;
   mimetype: string;
-  buffer: Buffer;
+  buffer?: Buffer;
   size: number;
+  path?: string;
 }
 
 export interface OSSSucessResponse {
@@ -67,7 +69,7 @@ export class OSSBase {
    */
   protected async putStream(
     target: string,
-    imageStream: stream.PassThrough,
+    imageStream: stream.Readable | stream.PassThrough,
   ): Promise<OSSSucessResponse> {
     return await this.ossClient.putStream(target, imageStream);
   }
@@ -99,19 +101,40 @@ export class OSSBase {
         };
 
         try {
-          const imageStream = new stream.PassThrough();
-          imageStream.end(item.buffer);
-          const uploadResult = await this.putStream(target, imageStream);
-
-          if (uploadResult.res.status === 200) {
-            info.path = uploadResult.name;
-            info.src = this.formatDomain(uploadResult.url);
-            info.srcSign = this.getOssSign(info.src);
+          if (item.path) {
+            // Use multipartUpload for file paths (large files)
+            const uploadResult = await this.ossClient.multipartUpload(target, item.path);
+            if (uploadResult.res.status === 200) {
+              info.path = uploadResult.name;
+              // multipartUpload result might not contain url directly in the same way, construct it or use what's available
+              // Usually uploadResult.res.requestUrls contains the URL, or we construct it.
+              // But formatDomain expects a full URL.
+              // Let's reconstruct the URL based on bucket and endpoint if needed, or use existing method if compatible.
+              // uploadResult.name is the object key.
+              const bucket = this.options.client.bucket;
+              const endpoint = this.options.client.endpoint;
+              const protocol = this.options.client.secure ? 'https' : 'http';
+              const url = `${protocol}://${bucket}.${endpoint}/${uploadResult.name}`;
+              info.src = this.formatDomain(url);
+              info.srcSign = this.getOssSign(info.src);
+            }
+          } else if (item.buffer) {
+            const imageStream = new stream.PassThrough();
+            imageStream.end(item.buffer);
+            const uploadResult = await this.putStream(target, imageStream);
+
+            if (uploadResult.res.status === 200) {
+              info.path = uploadResult.name;
+              info.src = this.formatDomain(uploadResult.url);
+              info.srcSign = this.getOssSign(info.src);
+            }
+          } else {
+            throw new Error('File has no buffer or path');
           }
         } catch (error) {
           console.error('error', error);
           info.uploaded = false;
-          info.path = item.originalname;
+          // info.path = item.originalname;
           info.message = `上传失败: ${error}`;
         }
 

+ 22 - 12
packages/frontend/src/views/article/add.vue

@@ -7,50 +7,50 @@
     </template>
 
     <div class="editor-wrap">
-      <n-form
+      <n-form
         ref="modalFormRef" class="form wh-full" label-placement="left" label-align="left" :label-width="80"
-        :model="modalForm"
+        :model="modalForm"
       >
-        <n-form-item
+        <n-form-item
           label="文章名称" path="title" :rule="{
             required: true,
             message: '请输入文章名称',
             trigger: ['input', 'blur'],
-          }"
+          }"
         >
           <n-input v-model:value="modalForm.title" :maxlength="200" show-count />
         </n-form-item>
 
-        <n-form-item
+        <n-form-item
           label="文章分类" path="categoryId" :rule="{
             required: true,
             type: 'number',
             trigger: ['change', 'blur'],
             message: '请输入文章分类',
-          }"
+          }"
         >
-          <n-tree-select
+          <n-tree-select
             v-model:value="modalForm.categoryId" :options="allCategory" label-field="title" key-field="id"
-            placeholder="根分类" clearable style="max-width: 300px;"
+            placeholder="根分类" clearable style="max-width: 300px;"
           />
         </n-form-item>
 
         <n-tabs type="line" animated>
           <template v-for="(lang, index) in langs" :key="lang">
             <n-tab-pane :name="lang" :tab="langLabel[lang]" :index="index">
-              <n-form-item
+              <n-form-item
                 label="文章名称" :path="`translations[${index}].title`" :rule="{
                   required: true,
                   message: '请输入文章名称',
                   trigger: ['input', 'blur'],
-                }"
+                }"
               >
                 <n-input v-model:value="modalForm.translations[index].title" :maxlength="200" show-count />
               </n-form-item>
               <div class="h-450">
-                <VividEditor
+                <VividEditor
                   v-model="modalForm.translations[index].content" :dark="isDark"
-                  :handle-image-upload="handleUpload" :handle-video-upload="handleVideoUpload"
+                  :handle-image-upload="handleUpload" :handle-video-upload="handleVideoUpload" :handle-package-upload="handlePackageUpload"
                 >
                   <SlashCommand />
                   <DragHandle />
@@ -143,6 +143,16 @@ function handleVideoUpload(file) {
     resolve(res.data)
   })
 }
+
+function handlePackageUpload(file, type) {
+  return new Promise(async (resolve) => {
+    const data = new FormData()
+    data.append('file', file)
+    data.append('type', type)
+    const res = await articleApi.uploadPackage(data)
+    resolve(res.data)
+  })
+}
 </script>
 
 <style>

+ 6 - 0
packages/frontend/src/views/article/api.js

@@ -15,4 +15,10 @@ export default {
     },
     timeout: 1000000,
   }),
+  uploadPackage: data => request.post('/menu/package/upload', data, {
+    headers: {
+      'Content-Type': 'multipart/form-data',
+    },
+    timeout: 1000000,
+  }),
 }

+ 11 - 1
packages/frontend/src/views/article/edit.vue

@@ -48,7 +48,7 @@
                 <n-input v-model:value="modalForm.translations[index].title" :maxlength="200" show-count />
               </n-form-item>
               <div class="h-450">
-                <VividEditor v-model="modalForm.translations[index].content" :dark="isDark" :handle-image-upload="handleUpload" :handle-video-upload="handleVideoUpload">
+                <VividEditor v-model="modalForm.translations[index].content" :dark="isDark" :handle-image-upload="handleUpload" :handle-video-upload="handleVideoUpload" :handle-package-upload="handlePackageUpload">
                   <SlashCommand />
                   <DragHandle />
                 </VividEditor>
@@ -146,6 +146,16 @@ function handleVideoUpload(file) {
     resolve(res.data)
   })
 }
+
+function handlePackageUpload(file, type) {
+  return new Promise(async (resolve) => {
+    const data = new FormData()
+    data.append('file', file)
+    data.append('type', type)
+    const res = await articleApi.uploadPackage(data)
+    resolve(res.data)
+  })
+}
 </script>
 
 <style>

+ 4 - 0
packages/frontend/vite.config.js

@@ -48,6 +48,10 @@ export default defineConfig(({ mode }) => {
       alias: {
         '@': path.resolve(process.cwd(), 'src'),
         '~': path.resolve(process.cwd()),
+        // 本地调试编辑器使用
+        '@4dkankan/vivid/dist/style.css': path.resolve(process.cwd(), '../vivid/src/style/index.css'),
+        '@4dkankan/vivid': path.resolve(process.cwd(), '../vivid/src/index.ts'),
+        '@lib': path.resolve(process.cwd(), '../vivid/src'),
       },
     },
     server: {

+ 6 - 1
packages/vivid/src/core/extension/link/LinkExt.vue

@@ -20,6 +20,7 @@
 		onEditorCreated,
 		useEditorInstance,
 	} from "@lib/core/extension/utils/common";
+	import { UploadFunction } from "@lib/core/extension/types";
 	import { EditorView } from "prosemirror-view";
 	import { MarkType } from "@tiptap/pm/model";
 	import tippy, { Instance } from "tippy.js";
@@ -30,6 +31,10 @@
 			type: Object as PropType<Partial<VividLinkOptions>>,
 			required: false,
 		},
+		handleUpload: {
+			type: Function as PropType<UploadFunction>,
+			required: false,
+		},
 	});
 
 	const editorInstance = useEditorInstance();
@@ -196,7 +201,7 @@
 				:action="handleOpenLink"
 				:is-active="() => editorInstance?.isActive('link')"
 			/>
-			<vivid-link-modal ref="HTL" @ok="setLink" />
+			<vivid-link-modal ref="HTL" @ok="setLink" :handleUpload="handleUpload"/>
 		</slot>
 		<div style="display: none">
 			<div ref="root">

+ 87 - 33
packages/vivid/src/core/extension/link/VividLinkModal.vue

@@ -10,6 +10,7 @@
 		NFormItem,
 		NButton,
 		NSpace,
+		NSpin,
 	} from "naive-ui";
 	import { ref } from "vue";
 	import { useEditorInstance } from "@lib/core/extension/utils/common";
@@ -20,6 +21,19 @@
 	const text = ref("");
 	const target = ref("_blank");
 	const isInsert = ref(false)
+  const packageType = ref(null)
+  const loading = ref(false)
+  const packageOptions = [
+    { label: '标准本地版', value: 'standard' },
+    { label: '公安本地版', value: 'ga' }
+  ]
+
+  const props = defineProps({
+    handleUpload: {
+      type: Function,
+      required: false,
+    },
+  });
 
 	const emit = defineEmits(["ok"]);
 
@@ -30,10 +44,41 @@
 		target.value = "_blank";
 		href.value = "";
 		text.value = "";
+    packageType.value = null;
 		const selection = editor.value.state.selection
 		isInsert.value = selection.from === selection.to
 	}
 
+  function triggerUpload() {
+    if (!packageType.value) {
+      window.$message?.warning('请选择类型')
+      return
+    }
+    const input = document.createElement('input')
+    input.type = 'file'
+    input.onchange = async (e) => {
+      const file = e.target.files[0]
+      if (!file) return
+
+      loading.value = true
+      try {
+        if (props.handleUpload) {
+          const res = await props.handleUpload(file, packageType.value)
+          if (res) {
+            href.value = res
+						text.value = file.name
+          }
+        }
+      } catch (error) {
+        console.error(error)
+        window.$message?.error('上传失败')
+      } finally {
+        loading.value = false
+      }
+    }
+    input.click()
+  }
+
 	function onOk() {
 		showModal.value = false;
 		emit("ok", text.value, href.value, target.value);
@@ -47,39 +92,48 @@
 </script>
 
 <template>
-	<n-modal v-model:show="showModal" preset="card" style="width: 450px">
-		<template #header>
-			<div>插入超链接</div>
-		</template>
-		<div>
-			<n-form label-placement="left" label-width="auto">
-				<n-form-item label="文字" v-if="isInsert">
-					<n-input-group>
-						<n-input v-model:value="text" />
-					</n-input-group>
-				</n-form-item>
-				<n-form-item label="链接地址">
-					<n-input-group>
-						<n-input v-model:value="href"/>
-					</n-input-group>
-				</n-form-item>
-				<n-form-item label="打开方式">
-					<n-radio-group v-model:value="target">
-						<n-space>
-							<n-radio value="_self"> 当前窗口 </n-radio>
-							<n-radio value="_blank"> 新窗口 </n-radio>
-						</n-space>
-					</n-radio-group>
-				</n-form-item>
-			</n-form>
-		</div>
-		<template #footer>
-			<n-space justify="end">
-				<n-button @click="onCancel"> 取消 </n-button>
-				<n-button type="info" @click="onOk"> 确定 </n-button>
-			</n-space>
-		</template>
-	</n-modal>
+  <n-modal v-model:show="showModal" preset="card" style="width: 450px" :mask-closable="!loading" :close-on-esc="!loading" :closable="!loading">
+    <template #header>
+      <div>插入超链接</div>
+    </template>
+    <n-spin :show="loading">
+      <div>
+        <n-form label-placement="left" label-width="auto">
+          <n-form-item label="文字" v-if="isInsert">
+            <n-input-group>
+              <n-input v-model:value="text" />
+            </n-input-group>
+          </n-form-item>
+					<n-form-item label="类型">
+            <n-select v-model:value="packageType" :options="packageOptions" placeholder="请选择类型" clearable />
+          </n-form-item>
+          <n-form-item label="链接地址">
+            <n-input-group>
+              <n-input style="margin-right: 10px;" v-model:value="href"/>
+              <n-button type="primary" @click="triggerUpload" :loading="loading">上传安装包</n-button>
+            </n-input-group>
+          </n-form-item>
+          <n-form-item label="打开方式">
+            <n-radio-group v-model:value="target">
+              <n-space>
+                <n-radio value="_self"> 当前窗口 </n-radio>
+                <n-radio value="_blank"> 新窗口 </n-radio>
+              </n-space>
+            </n-radio-group>
+          </n-form-item>
+        </n-form>
+      </div>
+      <template #description>
+        正在上传中...
+      </template>
+    </n-spin>
+    <template #footer>
+      <n-space justify="end">
+        <n-button @click="onCancel" :disabled="loading"> 取消 </n-button>
+        <n-button type="info" @click="onOk" :disabled="loading"> 确定 </n-button>
+      </n-space>
+    </template>
+  </n-modal>
 </template>
 
 <style scoped></style>

+ 5 - 1
packages/vivid/src/editor/Editor.vue

@@ -53,6 +53,10 @@
 			type: Function,
 			required: false,
 		},
+    handlePackageUpload: {
+      type: Function,
+      required: false,
+    },
   });
 
   let internalExt: (Extension | Node | Mark)[] = [];
@@ -251,7 +255,7 @@
         >
           <div :class="{ 'editor-readonly': readonly }" style="width: 100%">
             <slot name="menu" :readonly="readonly">
-              <vivid-menu class="editor-header" :editor="editor" :handleImageUpload="handleImageUpload" :handleVideoUpload="handleVideoUpload"/>
+              <vivid-menu class="editor-header" :editor="editor" :handleImageUpload="handleImageUpload" :handleVideoUpload="handleVideoUpload" :handlePackageUpload="handlePackageUpload"/>
             </slot>
           </div>
           <div class="editor-page" v-if="page">

+ 5 - 1
packages/vivid/src/editor/components/VividMenu.vue

@@ -53,6 +53,10 @@
 			type: Function,
 			required: false,
 		},
+		handlePackageUpload: {
+			type: Function,
+			required: false,
+		},
 	})
 	const vars = useThemeVars();
 </script>
@@ -94,7 +98,7 @@
 		<math-ext />
 		<image-ext :handleUpload="handleImageUpload" />
 		<video-ext :handleUpload="handleVideoUpload"/>
-		<link-ext />
+		<link-ext :handleUpload="handlePackageUpload" />
 		<divider-ext />
 		<table-ext />
 		<code-ext />

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 1 - 0
packages/vivid/tsconfig.app.tsbuildinfo