fix(server,web): serve project asset raw and recover preview after refresh
Add GET /assets/{id}/raw with project access checks; frontend falls back
to CAS by assetId or filename when local blob is gone. Align upload accept
list with backend whitelist and register binaries into CAS on chat upload.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -118,6 +118,47 @@ func (s *Server) handleListProjectAssets(w http.ResponseWriter, r *http.Request)
|
||||
writeJSONResponse(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetProjectAssetRaw(w http.ResponseWriter, r *http.Request) {
|
||||
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || projectID <= 0 {
|
||||
writeSafeError(w, r, http.StatusBadRequest, "invalid project id")
|
||||
return
|
||||
}
|
||||
assetID, err := strconv.ParseInt(r.PathValue("asset_id"), 10, 64)
|
||||
if err != nil || assetID <= 0 {
|
||||
writeSafeError(w, r, http.StatusBadRequest, "invalid asset id")
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireProjectAccess(w, r, projectID); !ok {
|
||||
return
|
||||
}
|
||||
cas := s.cas
|
||||
if cas == nil {
|
||||
writeSafeError(w, r, http.StatusServiceUnavailable, "storage unavailable")
|
||||
return
|
||||
}
|
||||
rc, ref, err := cas.OpenFileByRef(r.Context(), assetID)
|
||||
if err != nil || ref == nil {
|
||||
writeSafeError(w, r, http.StatusNotFound, "asset not found")
|
||||
return
|
||||
}
|
||||
defer rc.Close()
|
||||
if ref.ProjectID != projectID {
|
||||
writeSafeError(w, r, http.StatusNotFound, "asset not found")
|
||||
return
|
||||
}
|
||||
filename := ref.FileName
|
||||
if filename == "" {
|
||||
filename = "download.bin"
|
||||
}
|
||||
mimeType := mimeFromExt(filename)
|
||||
w.Header().Set("Content-Type", mimeType)
|
||||
w.Header().Set("Content-Disposition", "inline; filename=\""+filepath.Base(filename)+"\"")
|
||||
w.Header().Set("Cache-Control", "private, max-age=300")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.Copy(w, rc)
|
||||
}
|
||||
|
||||
func parseUserIDHeader(r *http.Request) int64 {
|
||||
raw := strings.TrimSpace(r.Header.Get("X-User-ID"))
|
||||
if raw == "" {
|
||||
|
||||
@@ -237,6 +237,7 @@ func (s *Server) Routes() http.Handler {
|
||||
mux.HandleFunc("POST /api/v1/admin/workspaces/reconcile", s.handleReconcileWorkspace)
|
||||
mux.HandleFunc("POST /api/v1/projects/{id}/assets", s.handleUploadProjectAsset)
|
||||
mux.HandleFunc("GET /api/v1/projects/{id}/assets", s.handleListProjectAssets)
|
||||
mux.HandleFunc("GET /api/v1/projects/{id}/assets/{asset_id}/raw", s.handleGetProjectAssetRaw)
|
||||
|
||||
// 6. 多级上下文与偏好记忆管理
|
||||
mux.HandleFunc("GET /api/v1/context/user", s.handleGetUserContext)
|
||||
|
||||
@@ -36,3 +36,19 @@ export async function listProjectAssets(projectId: number): Promise<ProjectAsset
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** 构建鉴权后的资产原件 URL(供预览/下载回退)。 */
|
||||
export function projectAssetRawURL(projectId: number, assetId: number): string {
|
||||
return `/api/v1/projects/${projectId}/assets/${assetId}/raw`;
|
||||
}
|
||||
|
||||
/** 拉取资产二进制为 Blob(带 Bearer)。 */
|
||||
export async function fetchProjectAssetBlob(projectId: number, assetId: number): Promise<Blob> {
|
||||
const res = await fetch(projectAssetRawURL(projectId, assetId), {
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`fetch asset raw failed: HTTP ${res.status}`);
|
||||
}
|
||||
return res.blob();
|
||||
}
|
||||
|
||||
@@ -228,7 +228,7 @@ export const InputArea: React.FC<InputAreaProps> = ({
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
accept=".pdf,.doc,.docx,.md,.txt,.xlsx"
|
||||
accept=".pdf,.docx,.md,.txt,.xlsx"
|
||||
/>
|
||||
|
||||
{/* 超出限制或操作提示条 */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
FileText,
|
||||
FileSpreadsheet,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { DeliverableItem } from '../../../types/workbench';
|
||||
import { fetchProjectAssetBlob, listProjectAssets } from '../../../api/projectAssets';
|
||||
import { ImageViewer } from './renderers/ImageViewer';
|
||||
import { PdfViewer } from './renderers/PdfViewer';
|
||||
import { PptViewer } from './renderers/PptViewer';
|
||||
@@ -35,15 +36,69 @@ export const FilePreviewPanel: React.FC<FilePreviewPanelProps> = ({
|
||||
}) => {
|
||||
const [viewMode, setViewMode] = useState<'preview' | 'info'>('preview');
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [recoveredBlob, setRecoveredBlob] = useState<Blob | null>(null);
|
||||
const [recoverState, setRecoverState] = useState<'idle' | 'loading' | 'done' | 'miss'>('idle');
|
||||
|
||||
const fileMeta = deliverable.fileMeta;
|
||||
const fileName = fileMeta?.originalName || deliverable.title;
|
||||
const fileSize = fileMeta?.size || '未知大小';
|
||||
const fileRaw = fileMeta?.fileRaw;
|
||||
const fileRaw = fileMeta?.fileRaw || recoveredBlob || undefined;
|
||||
const fileUrl = fileMeta?.fileUrl;
|
||||
|
||||
// 文件内容可用性判断:fileRaw(当前 session 内存)或 fileUrl(非 blob 服务端 URL)任一有效即可预览
|
||||
const hasFileContent = Boolean(fileRaw) || Boolean(fileUrl && !fileUrl.startsWith('blob:'));
|
||||
const localHasContent = Boolean(fileMeta?.fileRaw) || Boolean(fileUrl && !fileUrl.startsWith('blob:'));
|
||||
const hasFileContent = localHasContent || Boolean(recoveredBlob);
|
||||
|
||||
useEffect(() => {
|
||||
if (localHasContent || recoveredBlob) {
|
||||
setRecoverState('done');
|
||||
return;
|
||||
}
|
||||
const projectId = deliverable.projectId;
|
||||
if (!projectId || projectId <= 0) {
|
||||
setRecoverState('miss');
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const assetIdHint = fileMeta?.assetId;
|
||||
setRecoverState('loading');
|
||||
void (async () => {
|
||||
try {
|
||||
let assetId = assetIdHint && assetIdHint > 0 ? assetIdHint : 0;
|
||||
if (!assetId) {
|
||||
const assets = await listProjectAssets(projectId);
|
||||
if (cancelled) return;
|
||||
const want = (fileMeta?.originalName || '').trim().toLowerCase();
|
||||
const match = assets.find((a) => (a.filename || '').trim().toLowerCase() === want)
|
||||
|| assets.find((a) => want && (a.filename || '').toLowerCase().includes(want.replace(/\.[^.]+$/, '')));
|
||||
assetId = match?.asset_id ?? 0;
|
||||
}
|
||||
if (!assetId) {
|
||||
if (!cancelled) setRecoverState('miss');
|
||||
return;
|
||||
}
|
||||
const blob = await fetchProjectAssetBlob(projectId, assetId);
|
||||
if (cancelled) return;
|
||||
setRecoveredBlob(blob);
|
||||
setRecoverState('done');
|
||||
} catch {
|
||||
if (!cancelled) setRecoverState('miss');
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [localHasContent, recoveredBlob, deliverable.projectId, deliverable.docId, fileMeta?.assetId, fileMeta?.originalName]);
|
||||
|
||||
const recoveredObjectUrl = useMemo(() => {
|
||||
if (!recoveredBlob) return undefined;
|
||||
return URL.createObjectURL(recoveredBlob);
|
||||
}, [recoveredBlob]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (recoveredObjectUrl) URL.revokeObjectURL(recoveredObjectUrl);
|
||||
};
|
||||
}, [recoveredObjectUrl]);
|
||||
|
||||
// 多级智能提取真实文件扩展名(兼容 originalName、summary、title 中包含的 .docx, .pdf 等)
|
||||
const detectExtension = (): string => {
|
||||
@@ -80,6 +135,7 @@ export const FilePreviewPanel: React.FC<FilePreviewPanelProps> = ({
|
||||
};
|
||||
|
||||
const fileType = getFileType();
|
||||
const effectiveFileUrl = fileUrl && !fileUrl.startsWith('blob:') ? fileUrl : recoveredObjectUrl;
|
||||
|
||||
// 获取类型对应的图标与主题色
|
||||
const getTypeBadge = () => {
|
||||
@@ -160,22 +216,44 @@ export const FilePreviewPanel: React.FC<FilePreviewPanelProps> = ({
|
||||
document.body.removeChild(a);
|
||||
// 延迟释放,保证浏览器有足够时间启动下载
|
||||
setTimeout(() => URL.revokeObjectURL(url), 2000);
|
||||
} else if (fileUrl && !fileUrl.startsWith('blob:')) {
|
||||
// 服务端持久化 URL,可直接使用
|
||||
} else if (effectiveFileUrl) {
|
||||
// 服务端持久化 URL 或恢复的 blob URL
|
||||
const a = document.createElement('a');
|
||||
a.href = fileUrl;
|
||||
a.href = effectiveFileUrl;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
} else {
|
||||
// 文件内容不可用(刷新后 File 对象/blob URL 均已失效)
|
||||
alert(`文件「${fileName}」的本地内容在页面刷新后已释放。请在对话框中重新上传该文件以恢复预览与下载功能。`);
|
||||
alert(`文件「${fileName}」暂无法下载。请确认项目资产仍在服务端,或重新上传该文件。`);
|
||||
}
|
||||
};
|
||||
|
||||
// 文件内容不可用时的占位说明(刷新后 File 对象丢失,需重新上传)
|
||||
const renderNoContentPlaceholder = () => (
|
||||
// 文件内容不可用时的占位说明(刷新后 File 对象丢失;优先尝试服务端回退)
|
||||
const renderNoContentPlaceholder = () => {
|
||||
if (recoverState === 'loading') {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
padding: '40px 28px',
|
||||
background: 'var(--bg-subtle, #f8fafc)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<h4 style={{ margin: '0 0 8px', fontSize: '15px', fontWeight: 600, color: '#1e293b' }}>
|
||||
正在从服务端恢复文件…
|
||||
</h4>
|
||||
<p style={{ margin: 0, fontSize: '13px', color: '#64748b' }}>文件名:{fileName}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -217,7 +295,7 @@ export const FilePreviewPanel: React.FC<FilePreviewPanelProps> = ({
|
||||
}}
|
||||
>
|
||||
浏览器本地文件(<code>{fileName}</code>)仅在上传当次会话内保留于内存。
|
||||
<strong>页面刷新后,内存中的文件内容会被浏览器释放</strong>,无法再次预览或下载。
|
||||
页面刷新后若服务端未登记同名项目资产,则无法自动恢复预览。
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
@@ -265,7 +343,8 @@ export const FilePreviewPanel: React.FC<FilePreviewPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染对应的文档预览子组件
|
||||
const renderViewer = () => {
|
||||
@@ -279,25 +358,25 @@ export const FilePreviewPanel: React.FC<FilePreviewPanelProps> = ({
|
||||
<OnDemand
|
||||
label="Word 预览"
|
||||
load={loadDocxViewer}
|
||||
componentProps={{ fileRaw, fileUrl, fileName }}
|
||||
componentProps={{ fileRaw, fileUrl: effectiveFileUrl, fileName }}
|
||||
/>
|
||||
);
|
||||
case 'pdf':
|
||||
return <PdfViewer fileRaw={fileRaw} fileUrl={fileUrl} fileName={fileName} />;
|
||||
return <PdfViewer fileRaw={fileRaw} fileUrl={effectiveFileUrl} fileName={fileName} />;
|
||||
case 'excel':
|
||||
return (
|
||||
<OnDemand
|
||||
label="表格预览"
|
||||
load={loadExcelViewer}
|
||||
componentProps={{ fileRaw, fileUrl, fileName }}
|
||||
componentProps={{ fileRaw, fileUrl: effectiveFileUrl, fileName }}
|
||||
/>
|
||||
);
|
||||
case 'image':
|
||||
return <ImageViewer fileRaw={fileRaw} fileUrl={fileUrl} fileName={fileName} />;
|
||||
return <ImageViewer fileRaw={fileRaw} fileUrl={effectiveFileUrl} fileName={fileName} />;
|
||||
case 'ppt':
|
||||
return <PptViewer fileRaw={fileRaw} fileUrl={fileUrl} fileName={fileName} fileSize={fileSize} />;
|
||||
return <PptViewer fileRaw={fileRaw} fileUrl={effectiveFileUrl} fileName={fileName} fileSize={fileSize} />;
|
||||
case 'text':
|
||||
return <TextViewer fileRaw={fileRaw} fileUrl={fileUrl} fileName={fileName} />;
|
||||
return <TextViewer fileRaw={fileRaw} fileUrl={effectiveFileUrl} fileName={fileName} />;
|
||||
default:
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -209,6 +209,24 @@ export function useSendMessage(input: SendMessageInput) {
|
||||
saveFile(r.serverDocId, r.attachment.fileRaw, r.attachment.name).catch((e) =>
|
||||
console.warn('[fileStore] 保存上传文件失败:', r.serverDocId, e),
|
||||
);
|
||||
// 二进制同步登记 CAS,刷新后可经 raw 端点回退预览
|
||||
const extMatch = r.attachment.name.match(/\.([^.]+)$/);
|
||||
const ext = extMatch ? extMatch[1].toLowerCase() : '';
|
||||
const TEXT_EXTS = new Set(['txt', 'md', 'csv', 'json', 'xml', 'yaml', 'yml', 'log']);
|
||||
if (!TEXT_EXTS.has(ext) && currentProjectId > 0) {
|
||||
try {
|
||||
const uploaded = await uploadProjectAsset(
|
||||
currentProjectId,
|
||||
r.attachment.fileRaw as Blob,
|
||||
r.attachment.name,
|
||||
);
|
||||
if (r.deliverable.fileMeta) {
|
||||
r.deliverable.fileMeta.assetId = uploaded.asset_id;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[assets] 上传附件到 CAS 失败(预览回退可能不可用):', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -296,6 +296,8 @@ export interface UploadedFileMetadata {
|
||||
extension: string;
|
||||
fileRaw?: File | Blob;
|
||||
fileUrl?: string;
|
||||
/** 服务端 CAS 资产 ID(刷新后可经 raw 端点回退预览) */
|
||||
assetId?: number;
|
||||
uploadedAt: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user