mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +08:00
feat(wiki): download original raw material file
This commit is contained in:
parent
0d78beb44f
commit
4a15027a98
@ -296,6 +296,80 @@ public class WikiController {
|
|||||||
return R.ok();
|
return R.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequireWorkspaceRole("viewer")
|
||||||
|
@Operation(summary = "下载原始材料")
|
||||||
|
@GetMapping("/knowledge-bases/{kbId}/raw/{rawId}/download")
|
||||||
|
public org.springframework.http.ResponseEntity<org.springframework.core.io.Resource> downloadRaw(
|
||||||
|
@PathVariable Long kbId,
|
||||||
|
@PathVariable Long rawId,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) throws IOException {
|
||||||
|
verifyKBWorkspace(kbId, workspaceId);
|
||||||
|
WikiRawMaterialEntity raw = rawService.getById(rawId);
|
||||||
|
if (raw == null || !kbId.equals(raw.getKbId())) {
|
||||||
|
return org.springframework.http.ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
String rawTitle = raw.getTitle();
|
||||||
|
String filename = (rawTitle != null && !rawTitle.isBlank())
|
||||||
|
? rawTitle : ("source-" + rawId);
|
||||||
|
|
||||||
|
org.springframework.core.io.Resource resource;
|
||||||
|
long contentLength;
|
||||||
|
org.springframework.http.MediaType mediaType;
|
||||||
|
String sourceType = raw.getSourceType();
|
||||||
|
|
||||||
|
if ("text".equals(sourceType)) {
|
||||||
|
// Text materials live in the DB column — re-encode the stored content as bytes.
|
||||||
|
String content = raw.getOriginalContent();
|
||||||
|
if (content == null) {
|
||||||
|
return org.springframework.http.ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
byte[] bytes = content.getBytes(StandardCharsets.UTF_8);
|
||||||
|
resource = new org.springframework.core.io.ByteArrayResource(bytes);
|
||||||
|
contentLength = bytes.length;
|
||||||
|
mediaType = org.springframework.http.MediaType.parseMediaType("text/plain;charset=UTF-8");
|
||||||
|
// Manually-pasted text rows often have no extension on the title — give the
|
||||||
|
// download a sane suffix so the OS knows what to do with it.
|
||||||
|
if (!filename.contains(".")) filename = filename + ".txt";
|
||||||
|
} else {
|
||||||
|
// Binary materials live on disk — sandbox to the configured upload dir so
|
||||||
|
// a tampered source_path can't escape and serve arbitrary files.
|
||||||
|
String sourcePath = raw.getSourcePath();
|
||||||
|
if (sourcePath == null || sourcePath.isBlank()) {
|
||||||
|
return org.springframework.http.ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
Path path = Paths.get(sourcePath).toAbsolutePath().normalize();
|
||||||
|
Path uploadDir = Paths.get(properties.getUploadDir()).toAbsolutePath().normalize();
|
||||||
|
if (!path.startsWith(uploadDir)) {
|
||||||
|
log.warn("[Wiki] Download rejected: rawId={} path={} outside uploadDir={}",
|
||||||
|
rawId, path, uploadDir);
|
||||||
|
return org.springframework.http.ResponseEntity
|
||||||
|
.status(org.springframework.http.HttpStatus.FORBIDDEN).build();
|
||||||
|
}
|
||||||
|
if (!Files.isRegularFile(path)) {
|
||||||
|
return org.springframework.http.ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
resource = new org.springframework.core.io.FileSystemResource(path);
|
||||||
|
contentLength = Files.size(path);
|
||||||
|
mediaType = org.springframework.http.MediaType.APPLICATION_OCTET_STREAM;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 5987 — provide both ASCII-safe filename= (for old browsers) and
|
||||||
|
// UTF-8 filename*= so non-ASCII titles (e.g. 中医诊断学.docx) survive intact.
|
||||||
|
String asciiFallback = filename.replaceAll("[^\\x20-\\x7E]", "_")
|
||||||
|
.replace("\"", "_").replace("\\", "_");
|
||||||
|
String encoded = java.net.URLEncoder.encode(filename, StandardCharsets.UTF_8)
|
||||||
|
.replace("+", "%20");
|
||||||
|
String contentDisposition = "attachment; filename=\"" + asciiFallback
|
||||||
|
+ "\"; filename*=UTF-8''" + encoded;
|
||||||
|
|
||||||
|
return org.springframework.http.ResponseEntity.ok()
|
||||||
|
.contentType(mediaType)
|
||||||
|
.contentLength(contentLength)
|
||||||
|
.header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
|
||||||
|
.body(resource);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Wiki Pages ====================
|
// ==================== Wiki Pages ====================
|
||||||
|
|
||||||
@RequireWorkspaceRole("viewer")
|
@RequireWorkspaceRole("viewer")
|
||||||
|
|||||||
@ -441,6 +441,10 @@ export const wikiApi = {
|
|||||||
http.delete(`/wiki/knowledge-bases/${kbId}/raw/${rawId}`),
|
http.delete(`/wiki/knowledge-bases/${kbId}/raw/${rawId}`),
|
||||||
reprocessRaw: (kbId: number, rawId: number) =>
|
reprocessRaw: (kbId: number, rawId: number) =>
|
||||||
http.post(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/reprocess`),
|
http.post(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/reprocess`),
|
||||||
|
downloadRaw: (kbId: number, rawId: number) =>
|
||||||
|
http.get<Blob>(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/download`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
}),
|
||||||
|
|
||||||
// Wiki Pages
|
// Wiki Pages
|
||||||
listPages: (kbId: number, rawId?: number) =>
|
listPages: (kbId: number, rawId?: number) =>
|
||||||
|
|||||||
@ -1239,6 +1239,8 @@ export default {
|
|||||||
noRawMaterials: 'No raw materials yet',
|
noRawMaterials: 'No raw materials yet',
|
||||||
reprocess: 'Reprocess',
|
reprocess: 'Reprocess',
|
||||||
resume: 'Resume',
|
resume: 'Resume',
|
||||||
|
download: 'Download original file',
|
||||||
|
downloadFailed: 'Download failed',
|
||||||
processAll: 'Process All Pending',
|
processAll: 'Process All Pending',
|
||||||
materialTitle: 'Title',
|
materialTitle: 'Title',
|
||||||
materialContent: 'Content',
|
materialContent: 'Content',
|
||||||
|
|||||||
@ -1249,6 +1249,8 @@ export default {
|
|||||||
noRawMaterials: '暂无原始材料',
|
noRawMaterials: '暂无原始材料',
|
||||||
reprocess: '重新处理',
|
reprocess: '重新处理',
|
||||||
resume: '继续生成',
|
resume: '继续生成',
|
||||||
|
download: '下载原始文件',
|
||||||
|
downloadFailed: '下载失败',
|
||||||
processAll: '处理所有待处理材料',
|
processAll: '处理所有待处理材料',
|
||||||
materialTitle: '标题',
|
materialTitle: '标题',
|
||||||
materialContent: '内容',
|
materialContent: '内容',
|
||||||
|
|||||||
@ -172,6 +172,13 @@
|
|||||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="raw.processingStatus !== 'uploading'"
|
||||||
|
class="btn-icon" :title="t('wiki.download')"
|
||||||
|
@click="downloadRaw(raw)"
|
||||||
|
>
|
||||||
|
<el-icon :size="14"><Download /></el-icon>
|
||||||
|
</button>
|
||||||
<button class="btn-icon btn-icon-danger" :title="t('common.delete')" @click="deleteRaw(raw.id)">
|
<button class="btn-icon btn-icon-danger" :title="t('common.delete')" @click="deleteRaw(raw.id)">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<polyline points="3 6 5 6 21 6"/>
|
<polyline points="3 6 5 6 21 6"/>
|
||||||
@ -251,6 +258,7 @@
|
|||||||
import { ref, reactive, computed, watch, onBeforeUnmount } from 'vue'
|
import { ref, reactive, computed, watch, onBeforeUnmount } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Download } from '@element-plus/icons-vue'
|
||||||
import { useWikiStore } from '@/stores/useWikiStore'
|
import { useWikiStore } from '@/stores/useWikiStore'
|
||||||
import { wikiApi } from '@/api/index'
|
import { wikiApi } from '@/api/index'
|
||||||
import JobStageBar from './JobStageBar.vue'
|
import JobStageBar from './JobStageBar.vue'
|
||||||
@ -548,6 +556,30 @@ async function deleteRaw(rawId: number) {
|
|||||||
await store.fetchRawMaterials(store.currentKB.id)
|
await store.fetchRawMaterials(store.currentKB.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadRaw(raw: { id: number; title?: string }) {
|
||||||
|
if (!store.currentKB) return
|
||||||
|
try {
|
||||||
|
// The http interceptor returns the raw body for non-R-shaped responses,
|
||||||
|
// so this resolves directly to the Blob (no .data unwrap needed).
|
||||||
|
const blob = (await wikiApi.downloadRaw(store.currentKB.id, raw.id)) as unknown as Blob
|
||||||
|
let filename = raw.title && raw.title.trim().length > 0 ? raw.title : `raw-${raw.id}`
|
||||||
|
if (!filename.includes('.')) filename += '.txt'
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
a.remove()
|
||||||
|
// Revoke on next tick — some browsers cancel the in-flight download if we
|
||||||
|
// revoke synchronously before the click handler returns.
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
|
ElMessage.error(`${t('wiki.downloadFailed')}: ${msg}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function processAll() {
|
async function processAll() {
|
||||||
if (!store.currentKB) return
|
if (!store.currentKB) return
|
||||||
const kbId = store.currentKB.id
|
const kbId = store.currentKB.id
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user