mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +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();
|
||||
}
|
||||
|
||||
@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 ====================
|
||||
|
||||
@RequireWorkspaceRole("viewer")
|
||||
|
||||
@ -441,6 +441,10 @@ export const wikiApi = {
|
||||
http.delete(`/wiki/knowledge-bases/${kbId}/raw/${rawId}`),
|
||||
reprocessRaw: (kbId: number, rawId: number) =>
|
||||
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
|
||||
listPages: (kbId: number, rawId?: number) =>
|
||||
|
||||
@ -1239,6 +1239,8 @@ export default {
|
||||
noRawMaterials: 'No raw materials yet',
|
||||
reprocess: 'Reprocess',
|
||||
resume: 'Resume',
|
||||
download: 'Download original file',
|
||||
downloadFailed: 'Download failed',
|
||||
processAll: 'Process All Pending',
|
||||
materialTitle: 'Title',
|
||||
materialContent: 'Content',
|
||||
|
||||
@ -1249,6 +1249,8 @@ export default {
|
||||
noRawMaterials: '暂无原始材料',
|
||||
reprocess: '重新处理',
|
||||
resume: '继续生成',
|
||||
download: '下载原始文件',
|
||||
downloadFailed: '下载失败',
|
||||
processAll: '处理所有待处理材料',
|
||||
materialTitle: '标题',
|
||||
materialContent: '内容',
|
||||
|
||||
@ -172,6 +172,13 @@
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
</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)">
|
||||
<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"/>
|
||||
@ -251,6 +258,7 @@
|
||||
import { ref, reactive, computed, watch, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Download } from '@element-plus/icons-vue'
|
||||
import { useWikiStore } from '@/stores/useWikiStore'
|
||||
import { wikiApi } from '@/api/index'
|
||||
import JobStageBar from './JobStageBar.vue'
|
||||
@ -548,6 +556,30 @@ async function deleteRaw(rawId: number) {
|
||||
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() {
|
||||
if (!store.currentKB) return
|
||||
const kbId = store.currentKB.id
|
||||
|
||||
Loading…
Reference in New Issue
Block a user