上传文档跨端不可访问修复(ISSUE-20260927-upload-doc-cross-device): - 迁移 00076:freeform_documents 增加 source_filename/mime/size_bytes/asset_id - freedoc:Create/Get/List 读写源文件元数据,负值拒绝 - 前端:注册前全部附件先登记 CAS 并预读文本正文;文本正文嵌入注册 markdown;跨端恢复 sourceType/fileMeta 路由回文件预览器 - 大小显示 formatFileSize(<512B 不再显示 0 KB) - E2E 新增 FS-UPLOAD-02 跨浏览器上下文可访问性用例 回归:go vet/arch-check/go test -short(隔离 PG)+ web npm test/build 全绿; 存量无元数据旧文档不可恢复(重新上传即可)。
245 lines
7.2 KiB
TypeScript
245 lines
7.2 KiB
TypeScript
/**
|
|
* 自由文档 (Freeform Document) 前端 API 客户端
|
|
* 提供单条回复保存、私密列表、详情获取、编辑保存、共享流转及回收站接口
|
|
*/
|
|
|
|
import { getBearerAuthHeaders } from './authHeaders';
|
|
import { projectSettingsErrorFromResponse } from './projectSettingsError';
|
|
import { apiFetch } from './client';
|
|
|
|
export interface FreeformDocSummary {
|
|
doc_id: string;
|
|
project_id: number;
|
|
owner_user_id: number;
|
|
title: string;
|
|
space: 'my_private' | 'project_shared' | 'trash';
|
|
current_version_id: number;
|
|
current_sha256: string;
|
|
/** 上传附件注册时持久化的源文件元数据;非上传文档缺省。 */
|
|
source_filename?: string;
|
|
source_mime_type?: string;
|
|
source_size_bytes?: number;
|
|
source_asset_id?: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface FreeformDocDetail {
|
|
document: {
|
|
doc_id: string;
|
|
project_id: number;
|
|
owner_user_id: number;
|
|
source_message_id?: number;
|
|
source_content_sha256: string;
|
|
title: string;
|
|
governance_mode: 'freeform';
|
|
space: 'my_private' | 'project_shared' | 'trash';
|
|
current_version_id: number;
|
|
current_sha256: string;
|
|
/** 上传附件注册时持久化的源文件元数据;非上传文档缺省。 */
|
|
source_filename?: string;
|
|
source_mime_type?: string;
|
|
source_size_bytes?: number;
|
|
source_asset_id?: number;
|
|
is_deleted: boolean;
|
|
created_at: string;
|
|
updated_at: string;
|
|
};
|
|
content_markdown: string;
|
|
}
|
|
|
|
export interface CreateFromMessageResponse {
|
|
code: number;
|
|
message: string;
|
|
is_existing: boolean;
|
|
data: FreeformDocDetail;
|
|
}
|
|
|
|
/**
|
|
* 直接创建自由文档(上传附件即时注册服务端事实源)
|
|
*/
|
|
export async function createFreeformDoc(payload: {
|
|
projectId: number;
|
|
title: string;
|
|
contentMarkdown: string;
|
|
space?: 'my_private' | 'project_shared';
|
|
clientKey?: string;
|
|
sourceFilename?: string;
|
|
sourceMimeType?: string;
|
|
sourceSizeBytes?: number;
|
|
sourceAssetId?: number;
|
|
}): Promise<FreeformDocDetail> {
|
|
const res = await apiFetch('/api/v1/freeform-documents', {
|
|
method: 'POST',
|
|
headers: getBearerAuthHeaders('application/json'),
|
|
body: JSON.stringify({
|
|
project_id: payload.projectId,
|
|
title: payload.title,
|
|
content_markdown: payload.contentMarkdown,
|
|
space: payload.space || 'my_private',
|
|
client_key: payload.clientKey || undefined,
|
|
source_filename: payload.sourceFilename || undefined,
|
|
source_mime_type: payload.sourceMimeType || undefined,
|
|
source_size_bytes: payload.sourceSizeBytes || undefined,
|
|
source_asset_id: payload.sourceAssetId || undefined,
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
const errData = await res.json().catch(() => ({}));
|
|
const msg = errData.message || errData.error_code || `创建失败 (${res.status})`;
|
|
throw new Error(typeof msg === 'string' ? msg : `创建失败 (${res.status})`);
|
|
}
|
|
const body = await res.json();
|
|
return body.data;
|
|
}
|
|
|
|
/**
|
|
* 从单条智能体回复保存为我的私密文档(幂等)
|
|
*/
|
|
export async function saveMessageToPrivate(
|
|
messageId: number,
|
|
projectId: number = 1,
|
|
title?: string,
|
|
expectedSha256?: string,
|
|
): Promise<CreateFromMessageResponse> {
|
|
const res = await apiFetch('/api/v1/freeform-documents/from-message', {
|
|
method: 'POST',
|
|
headers: getBearerAuthHeaders('application/json'),
|
|
body: JSON.stringify({
|
|
message_id: messageId,
|
|
project_id: projectId,
|
|
title: title || undefined,
|
|
expected_sha256: expectedSha256 || undefined,
|
|
}),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const errData = await res.json().catch(() => ({}));
|
|
throw new Error(errData.message || `保存失败 (${res.status})`);
|
|
}
|
|
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* 按空间获取自由文档概要列表
|
|
*/
|
|
export async function listFreeformDocs(
|
|
space: 'my_private' | 'project_shared' | 'trash' = 'my_private',
|
|
projectId?: number,
|
|
): Promise<FreeformDocSummary[]> {
|
|
let url = `/api/v1/freeform-documents?space=${encodeURIComponent(space)}`;
|
|
if (projectId) {
|
|
url += `&project_id=${projectId}`;
|
|
}
|
|
|
|
const res = await apiFetch(url, { headers: getBearerAuthHeaders() });
|
|
if (!res.ok) {
|
|
throw new Error(`获取列表失败 (${res.status})`);
|
|
}
|
|
const body = await res.json();
|
|
return body.data || [];
|
|
}
|
|
|
|
/**
|
|
* 获取自由文档详情与 Markdown 正文
|
|
*/
|
|
export async function fetchFreeformDoc(docId: string): Promise<FreeformDocDetail> {
|
|
const res = await apiFetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}`, {
|
|
headers: getBearerAuthHeaders(),
|
|
});
|
|
if (!res.ok) {
|
|
if (res.status === 403) {
|
|
throw new Error('该文档为他人私密文档,严格禁止跨用户访问');
|
|
}
|
|
if (res.status === 404) {
|
|
throw new Error('文档不存在或已被删除');
|
|
}
|
|
throw new Error(`读取文档失败 (${res.status})`);
|
|
}
|
|
const body = await res.json();
|
|
return body.data;
|
|
}
|
|
|
|
/**
|
|
* 更新自由文档正文或标题(支持 If-Match 乐观锁)
|
|
*/
|
|
export async function updateFreeformDoc(
|
|
docId: string,
|
|
payload: {
|
|
title?: string;
|
|
contentMarkdown?: string;
|
|
changeSummary?: string;
|
|
expectedVersion?: number;
|
|
expectedSha256?: string;
|
|
}
|
|
): Promise<FreeformDocDetail> {
|
|
const headers = getBearerAuthHeaders('application/json');
|
|
if (payload.expectedVersion !== undefined) {
|
|
headers['If-Match'] = `"${payload.expectedVersion}"`;
|
|
} else if (payload.expectedSha256) {
|
|
headers['If-Match'] = `"${payload.expectedSha256}"`;
|
|
}
|
|
|
|
const res = await apiFetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}`, {
|
|
method: 'PUT',
|
|
headers,
|
|
body: JSON.stringify({
|
|
title: payload.title,
|
|
content_markdown: payload.contentMarkdown,
|
|
change_summary: payload.changeSummary,
|
|
expected_version: payload.expectedVersion,
|
|
expected_sha256: payload.expectedSha256,
|
|
}),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw projectSettingsErrorFromResponse(res.status, text, `保存失败 (${res.status})`);
|
|
}
|
|
const body = await res.json();
|
|
return body.data;
|
|
}
|
|
|
|
/**
|
|
* 一键共享至项目参考库
|
|
*/
|
|
export async function shareFreeformDoc(docId: string): Promise<void> {
|
|
const res = await apiFetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}/share`, {
|
|
method: 'POST',
|
|
headers: getBearerAuthHeaders(),
|
|
});
|
|
if (!res.ok) {
|
|
const errData = await res.json().catch(() => ({}));
|
|
throw new Error(errData.message || `共享失败 (${res.status})`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 撤回共享,恢复为个人私密
|
|
*/
|
|
export async function unshareFreeformDoc(docId: string): Promise<void> {
|
|
const res = await apiFetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}/unshare`, {
|
|
method: 'POST',
|
|
headers: getBearerAuthHeaders(),
|
|
});
|
|
if (!res.ok) {
|
|
const errData = await res.json().catch(() => ({}));
|
|
throw new Error(errData.message || `撤回失败 (${res.status})`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 软删除文档移至回收站
|
|
*/
|
|
export async function deleteFreeformDoc(docId: string): Promise<void> {
|
|
const res = await apiFetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}`, {
|
|
method: 'DELETE',
|
|
headers: getBearerAuthHeaders(),
|
|
});
|
|
if (!res.ok) {
|
|
const errData = await res.json().catch(() => ({}));
|
|
throw new Error(errData.message || `删除失败 (${res.status})`);
|
|
}
|
|
}
|