feat(documents): rebuild project document workbench
This commit is contained in:
@@ -1400,6 +1400,386 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.documents-canvas {
|
||||
position: relative;
|
||||
height: calc(100vh - 90px);
|
||||
min-height: 560px;
|
||||
display: flex;
|
||||
margin: -16px;
|
||||
overflow: hidden;
|
||||
background: var(--senlin-panel);
|
||||
color: var(--senlin-text);
|
||||
}
|
||||
|
||||
.documents-tree-pane {
|
||||
min-width: 210px;
|
||||
max-width: 420px;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--senlin-border);
|
||||
background: color-mix(in srgb, var(--senlin-panel) 94%, var(--senlin-bg));
|
||||
}
|
||||
|
||||
.documents-tree-tools,
|
||||
.documents-tabs-bar {
|
||||
min-height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--senlin-border);
|
||||
}
|
||||
|
||||
.documents-tree-tools {
|
||||
gap: 2px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.documents-tree-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.documents-tree {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 6px 0 12px;
|
||||
}
|
||||
|
||||
.document-tree-row {
|
||||
width: calc(100% - 8px);
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin: 1px 4px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--senlin-text);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.document-tree-row:hover {
|
||||
background: color-mix(in srgb, var(--senlin-primary) 8%, transparent);
|
||||
}
|
||||
|
||||
.document-tree-row.active {
|
||||
background: color-mix(in srgb, var(--senlin-primary) 14%, transparent);
|
||||
color: var(--senlin-primary);
|
||||
}
|
||||
|
||||
.document-tree-row > span:last-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.document-tree-row .arco-icon {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.tree-chevron {
|
||||
width: 11px;
|
||||
flex: 0 0 11px;
|
||||
color: var(--senlin-muted);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.documents-resizer {
|
||||
z-index: 2;
|
||||
width: 4px;
|
||||
flex: 0 0 4px;
|
||||
margin-left: -4px;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.documents-resizer:hover {
|
||||
background: var(--senlin-primary);
|
||||
}
|
||||
|
||||
.documents-editor-pane {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.documents-tabs-bar {
|
||||
flex: 0 0 42px;
|
||||
background: color-mix(in srgb, var(--senlin-panel) 97%, var(--senlin-bg));
|
||||
}
|
||||
|
||||
.documents-tabs {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-self: stretch;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.document-tab {
|
||||
min-width: 132px;
|
||||
max-width: 220px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--senlin-border);
|
||||
border-bottom: 2px solid transparent;
|
||||
background: color-mix(in srgb, var(--senlin-bg) 50%, var(--senlin-panel));
|
||||
color: var(--senlin-muted);
|
||||
padding: 0 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.document-tab.active {
|
||||
border-bottom-color: var(--senlin-primary);
|
||||
background: var(--senlin-panel);
|
||||
color: var(--senlin-text);
|
||||
}
|
||||
|
||||
.document-tab > span {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.document-tab > .arco-icon:last-child {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.document-tab:hover > .arco-icon:last-child,
|
||||
.document-tab.active > .arco-icon:last-child {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.save-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
flex: 0 0 6px;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.save-dot.dirty {
|
||||
background: #ff7d00;
|
||||
}
|
||||
|
||||
.save-dot.saving {
|
||||
background: #165dff;
|
||||
animation: document-pulse 1s infinite;
|
||||
}
|
||||
|
||||
.save-dot.saved {
|
||||
background: #00b42a;
|
||||
}
|
||||
|
||||
.save-dot.error {
|
||||
background: #f53f3f;
|
||||
}
|
||||
|
||||
@keyframes document-pulse {
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.documents-tab-actions {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
border-left: 1px solid var(--senlin-border);
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.document-editor-body {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background: var(--senlin-panel);
|
||||
}
|
||||
|
||||
.document-codemirror,
|
||||
.document-codemirror .cm-editor {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.document-markdown-preview,
|
||||
.document-office-preview,
|
||||
.document-text-preview,
|
||||
.document-sheet-preview {
|
||||
width: min(900px, calc(100% - 48px));
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
overflow: auto;
|
||||
padding: 28px 34px 60px;
|
||||
}
|
||||
|
||||
.document-markdown-preview {
|
||||
font-size: 15px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.document-markdown-preview img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.document-markdown-preview pre,
|
||||
.document-text-preview {
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--senlin-bg) 78%, var(--senlin-panel));
|
||||
padding: 14px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.document-markdown-preview table,
|
||||
.document-sheet-preview table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.document-markdown-preview th,
|
||||
.document-markdown-preview td,
|
||||
.document-sheet-preview td {
|
||||
border: 1px solid var(--senlin-border);
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.document-pdf-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.document-media-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.document-media-preview img,
|
||||
.document-media-preview video {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.document-upload-summary {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
border-top: 1px solid var(--senlin-border);
|
||||
padding: 9px 10px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.document-upload-summary > div,
|
||||
.document-upload-row {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.document-upload-row span:first-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.document-upload-row button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--senlin-primary);
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.document-context-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
width: 150px;
|
||||
display: grid;
|
||||
border: 1px solid var(--senlin-border);
|
||||
border-radius: 6px;
|
||||
background: var(--senlin-panel);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 16%);
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.document-context-menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--senlin-text);
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.document-context-menu button:hover {
|
||||
background: var(--senlin-soft-blue);
|
||||
}
|
||||
|
||||
.document-context-menu button.danger {
|
||||
color: #f53f3f;
|
||||
}
|
||||
|
||||
.documents-mobile-switch {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.documents-canvas {
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.documents-canvas .documents-tree-pane,
|
||||
.documents-canvas .documents-editor-pane {
|
||||
width: 100% !important;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.documents-canvas.mobile-tree .documents-editor-pane,
|
||||
.documents-canvas.mobile-editor .documents-tree-pane,
|
||||
.documents-canvas .documents-resizer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.documents-mobile-switch {
|
||||
display: block;
|
||||
border-bottom: 1px solid var(--senlin-border);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.document-markdown-preview,
|
||||
.document-office-preview,
|
||||
.document-text-preview,
|
||||
.document-sheet-preview {
|
||||
width: 100%;
|
||||
padding: 20px 18px 48px;
|
||||
}
|
||||
|
||||
.documents-tab-actions .arco-btn {
|
||||
padding: 0 7px;
|
||||
}
|
||||
}
|
||||
|
||||
.compact-card .arco-card-body {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export type DatasetItemPageDTO = {
|
||||
}
|
||||
|
||||
export type DatasetDepositDTO = {
|
||||
noteId: string
|
||||
documentId: string
|
||||
projectId: string
|
||||
}
|
||||
|
||||
|
||||
189
apps/web_v1/src/api/documents.ts
Normal file
189
apps/web_v1/src/api/documents.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { ApiError, apiRequest, getApiBaseUrl, type ApiSession } from './client'
|
||||
|
||||
export type DocumentDTO = {
|
||||
id: string
|
||||
projectId: string
|
||||
parentId: string | null
|
||||
kind: 'folder' | 'file'
|
||||
name: string
|
||||
extension: string
|
||||
mimeType: string
|
||||
size: number
|
||||
revision: number
|
||||
markdown?: string
|
||||
hasBlob: boolean
|
||||
sourceInboxItemId: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type DocumentOpenIntent = {
|
||||
intentId: string
|
||||
mode: 'create' | 'open'
|
||||
projectId: string
|
||||
documentId?: string
|
||||
suggestedName?: string
|
||||
initialMarkdown?: string
|
||||
targetDirectoryId?: string
|
||||
sourceType?: string
|
||||
sourceId?: string
|
||||
}
|
||||
|
||||
export type DocumentShareDTO = {
|
||||
id: string
|
||||
url?: string
|
||||
expiresAt: string
|
||||
revokedAt: string | null
|
||||
}
|
||||
|
||||
export function listDocuments(session: ApiSession, projectId: string, signal?: AbortSignal) {
|
||||
return apiRequest<DocumentDTO[]>(`/api/v1/projects/${projectId}/documents`, { token: session.token, signal })
|
||||
}
|
||||
|
||||
export function getDocument(session: ApiSession, projectId: string, documentId: string, signal?: AbortSignal) {
|
||||
return apiRequest<DocumentDTO>(`/api/v1/projects/${projectId}/documents/${documentId}`, { token: session.token, signal })
|
||||
}
|
||||
|
||||
export function createDocumentFolder(session: ApiSession, projectId: string, name: string, parentId = '') {
|
||||
return apiRequest<DocumentDTO>(`/api/v1/projects/${projectId}/documents/folders`, {
|
||||
method: 'POST',
|
||||
token: session.token,
|
||||
body: { name, parentId },
|
||||
})
|
||||
}
|
||||
|
||||
export function createMarkdownDocument(
|
||||
session: ApiSession,
|
||||
projectId: string,
|
||||
input: { name: string; parentId?: string; markdown?: string; sourceInboxItemId?: string },
|
||||
) {
|
||||
return apiRequest<DocumentDTO>(`/api/v1/projects/${projectId}/documents/markdown`, {
|
||||
method: 'POST',
|
||||
token: session.token,
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateDocument(
|
||||
session: ApiSession,
|
||||
projectId: string,
|
||||
documentId: string,
|
||||
input: { name?: string; parentId?: string; markdown?: string; revision?: number },
|
||||
) {
|
||||
return apiRequest<DocumentDTO>(`/api/v1/projects/${projectId}/documents/${documentId}`, {
|
||||
method: 'PATCH',
|
||||
token: session.token,
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteDocument(session: ApiSession, projectId: string, documentId: string) {
|
||||
return apiRequest<void>(`/api/v1/projects/${projectId}/documents/${documentId}`, {
|
||||
method: 'DELETE',
|
||||
token: session.token,
|
||||
responseType: 'void',
|
||||
})
|
||||
}
|
||||
|
||||
export function createDocumentShare(session: ApiSession, projectId: string, documentId: string, expiresInDays = 7) {
|
||||
return apiRequest<DocumentShareDTO>(`/api/v1/projects/${projectId}/documents/${documentId}/shares`, {
|
||||
method: 'POST',
|
||||
token: session.token,
|
||||
body: { expiresInDays },
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchDocumentBlob(session: ApiSession, projectId: string, documentId: string, download = false) {
|
||||
const suffix = download ? 'download' : 'content'
|
||||
const response = await fetch(`${getApiBaseUrl()}/api/v1/projects/${projectId}/documents/${documentId}/${suffix}`, {
|
||||
headers: { Authorization: `Bearer ${session.token}` },
|
||||
})
|
||||
if (!response.ok) throw await responseError(response)
|
||||
return response.blob()
|
||||
}
|
||||
|
||||
export function fetchLegacySheetPreview(session: ApiSession, projectId: string, documentId: string) {
|
||||
return apiRequest<{ sheetName: string; rows: string[][]; truncated: boolean }>(
|
||||
`/api/v1/projects/${projectId}/documents/${documentId}/sheet-preview`,
|
||||
{ token: session.token },
|
||||
)
|
||||
}
|
||||
|
||||
export async function exportDocument(session: ApiSession, projectId: string, documentId: string, format: 'md' | 'pdf' | 'docx') {
|
||||
const response = await fetch(
|
||||
`${getApiBaseUrl()}/api/v1/projects/${projectId}/documents/${documentId}/export?format=${format}`,
|
||||
{ headers: { Authorization: `Bearer ${session.token}` } },
|
||||
)
|
||||
if (!response.ok) throw await responseError(response)
|
||||
const blob = await response.blob()
|
||||
const disposition = response.headers.get('content-disposition') ?? ''
|
||||
downloadBlob(blob, dispositionName(disposition) || `document.${format}`)
|
||||
}
|
||||
|
||||
export type UploadControl = {
|
||||
promise: Promise<DocumentDTO>
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
export function uploadDocument(
|
||||
session: ApiSession,
|
||||
projectId: string,
|
||||
file: File,
|
||||
parentId: string,
|
||||
onProgress: (progress: number) => void,
|
||||
): UploadControl {
|
||||
const xhr = new XMLHttpRequest()
|
||||
const promise = new Promise<DocumentDTO>((resolve, reject) => {
|
||||
xhr.open('POST', `${getApiBaseUrl()}/api/v1/projects/${projectId}/documents/uploads`)
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${session.token}`)
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100))
|
||||
}
|
||||
xhr.onerror = () => reject(new ApiError(0, 'network_error', '上传失败,请检查网络后重试'))
|
||||
xhr.onabort = () => reject(new ApiError(0, 'upload_cancelled', '上传已取消'))
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
resolve(JSON.parse(xhr.responseText) as DocumentDTO)
|
||||
} catch {
|
||||
reject(new ApiError(xhr.status, 'invalid_response', '服务器返回了无效的上传结果'))
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(xhr.responseText) as { error?: { code?: string; message?: string } }
|
||||
reject(new ApiError(xhr.status, payload.error?.code ?? 'upload_failed', payload.error?.message ?? '上传失败'))
|
||||
} catch {
|
||||
reject(new ApiError(xhr.status, 'upload_failed', '上传失败'))
|
||||
}
|
||||
}
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
if (parentId) body.append('parentId', parentId)
|
||||
xhr.send(body)
|
||||
})
|
||||
return { promise, cancel: () => xhr.abort() }
|
||||
}
|
||||
|
||||
export function downloadBlob(blob: Blob, name: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = name
|
||||
anchor.click()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
|
||||
async function responseError(response: Response) {
|
||||
try {
|
||||
const payload = await response.json() as { error?: { code?: string; message?: string } }
|
||||
return new ApiError(response.status, payload.error?.code ?? 'request_failed', payload.error?.message ?? '请求失败')
|
||||
} catch {
|
||||
return new ApiError(response.status, 'request_failed', '请求失败')
|
||||
}
|
||||
}
|
||||
|
||||
function dispositionName(value: string) {
|
||||
const encoded = value.match(/filename\*=UTF-8''([^;]+)/i)?.[1]
|
||||
return encoded ? decodeURIComponent(encoded) : ''
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export type InboxItemDTO = {
|
||||
|
||||
export type InboxSuggestionDTO = {
|
||||
id: string
|
||||
kind: 'task' | 'note' | 'source'
|
||||
kind: 'task' | 'document'
|
||||
title: string
|
||||
body: string
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ import type {
|
||||
WorkspaceChannelDTO,
|
||||
WorkspaceCronPlanDTO,
|
||||
WorkspaceInboxDTO,
|
||||
WorkspaceNoteSourceDTO,
|
||||
WorkspaceDocumentDTO,
|
||||
WorkspaceTaskDTO,
|
||||
} from './projects'
|
||||
import type { AISession, ChannelKey, CronJob, InboxItem, NoteSource, Project, ProjectChannel, ProjectWorkspace, TaskItem } from '../pages/projects/project-types'
|
||||
import type { AISession, ChannelKey, CronJob, DocumentSummary, InboxItem, Project, ProjectChannel, ProjectWorkspace, TaskItem } from '../pages/projects/project-types'
|
||||
|
||||
const projectColors = ['#165DFF', '#00B42A', '#722ED1', '#FF7D00', '#14C9C9', '#F53F3F']
|
||||
|
||||
@@ -34,7 +34,7 @@ export function mapWorkspace(payload: ProjectWorkspaceDTO, index = 0): ProjectWo
|
||||
inbox: payload.inbox.map(mapInbox),
|
||||
tasks: payload.tasks.map(mapTask),
|
||||
aiSessions: payload.aiSessions.map(mapAISession),
|
||||
notes: payload.notesSources.map(mapNoteSource),
|
||||
documents: payload.documents.map(mapDocument),
|
||||
cronJobs: payload.cronPlans.map(mapCronPlan),
|
||||
}
|
||||
}
|
||||
@@ -75,8 +75,8 @@ function mapChannelKey(type: string, id = ''): ChannelKey {
|
||||
return 'tasks'
|
||||
case 'ai_sessions':
|
||||
return 'ai'
|
||||
case 'notes_sources':
|
||||
return 'notes'
|
||||
case 'documents':
|
||||
return 'documents'
|
||||
case 'cron':
|
||||
return 'cron'
|
||||
case 'overview':
|
||||
@@ -96,7 +96,7 @@ function channelLabel(channel: WorkspaceChannelDTO) {
|
||||
return '工作计划'
|
||||
case 'ai_sessions':
|
||||
return 'AI 会话'
|
||||
case 'notes_sources':
|
||||
case 'documents':
|
||||
return '笔记资料'
|
||||
case 'cron':
|
||||
return '计划任务'
|
||||
@@ -115,7 +115,7 @@ function channelIcon(channel: WorkspaceChannelDTO) {
|
||||
return <IconList />
|
||||
case 'ai_sessions':
|
||||
return <IconRobot />
|
||||
case 'notes_sources':
|
||||
case 'documents':
|
||||
return <IconFile />
|
||||
case 'cron':
|
||||
return <IconClockCircle />
|
||||
@@ -194,14 +194,14 @@ function mapAISession(session: WorkspaceAISessionDTO): AISession {
|
||||
}
|
||||
}
|
||||
|
||||
function mapNoteSource(note: WorkspaceNoteSourceDTO): NoteSource {
|
||||
function mapDocument(document: WorkspaceDocumentDTO): DocumentSummary {
|
||||
return {
|
||||
id: note.id,
|
||||
title: note.title,
|
||||
type: note.kind === 'note' ? '笔记' : note.source,
|
||||
updated: formatBackendDate(note.updatedAt),
|
||||
owner: note.source,
|
||||
source: note.source,
|
||||
id: document.id,
|
||||
name: document.name,
|
||||
kind: document.kind,
|
||||
extension: document.extension,
|
||||
mimeType: document.mimeType,
|
||||
updated: formatBackendDate(document.updatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,14 +70,14 @@ export type WorkspaceAISessionDTO = {
|
||||
references: string[]
|
||||
}
|
||||
|
||||
export type WorkspaceNoteSourceDTO = {
|
||||
export type WorkspaceDocumentDTO = {
|
||||
id: string
|
||||
projectId: string
|
||||
kind: string
|
||||
title: string
|
||||
name: string
|
||||
extension: string
|
||||
mimeType: string
|
||||
updatedAt: string
|
||||
tag: string
|
||||
source: string
|
||||
}
|
||||
|
||||
export type WorkspaceCronPlanDTO = {
|
||||
@@ -99,7 +99,7 @@ export type ProjectWorkspaceDTO = {
|
||||
inbox: WorkspaceInboxDTO[]
|
||||
tasks: WorkspaceTaskDTO[]
|
||||
aiSessions: WorkspaceAISessionDTO[]
|
||||
notesSources: WorkspaceNoteSourceDTO[]
|
||||
documents: WorkspaceDocumentDTO[]
|
||||
cronPlans: WorkspaceCronPlanDTO[]
|
||||
}
|
||||
|
||||
@@ -119,16 +119,6 @@ export type TaskDTO = {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type SourceDTO = {
|
||||
id: string
|
||||
projectId: string
|
||||
kind: string
|
||||
title: string
|
||||
filePath: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type CronPlanDTO = {
|
||||
id: string
|
||||
projectId: string
|
||||
@@ -168,11 +158,6 @@ export type UpdateTaskInput = {
|
||||
tag?: string
|
||||
}
|
||||
|
||||
export type UploadSourceInput = {
|
||||
title?: string
|
||||
file: File
|
||||
}
|
||||
|
||||
export type CreateCronPlanInput = {
|
||||
title: string
|
||||
schedule: string
|
||||
@@ -228,17 +213,6 @@ export async function updateTask(session: ApiSession, projectId: string, taskId:
|
||||
})
|
||||
}
|
||||
|
||||
export async function uploadSource(session: ApiSession, projectId: string, input: UploadSourceInput) {
|
||||
const body = new FormData()
|
||||
body.append('file', input.file)
|
||||
if (input.title) body.append('title', input.title)
|
||||
return apiRequest<SourceDTO>(`/api/v1/projects/${projectId}/sources`, {
|
||||
method: 'POST',
|
||||
token: session.token,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export async function createCronPlan(session: ApiSession, projectId: string, input: CreateCronPlanInput) {
|
||||
return apiRequest<CronPlanDTO>(`/api/v1/projects/${projectId}/cron-plans`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { apiRequest, type ApiSession } from './client'
|
||||
|
||||
export type SearchResultDTO = {
|
||||
type: 'project' | 'task' | 'note'
|
||||
type: 'project' | 'task' | 'document'
|
||||
id: string
|
||||
projectId: string
|
||||
title: string
|
||||
|
||||
@@ -27,11 +27,11 @@ import {
|
||||
fetchProjects,
|
||||
updateProject,
|
||||
updateTask,
|
||||
uploadSource,
|
||||
} from '../api/projects'
|
||||
import type { DocumentOpenIntent } from '../api/documents'
|
||||
import type { SearchResultDTO } from '../api/search'
|
||||
import { LoginPage } from '../pages/login'
|
||||
import { ProjectActionModals, type CronDraft, type ProjectActionModal, type ProjectDraft, type SourceDraft, type TaskDraft } from '../pages/projects/project-action-modals'
|
||||
import { ProjectActionModals, type CronDraft, type ProjectActionModal, type ProjectDraft, type TaskDraft } from '../pages/projects/project-action-modals'
|
||||
import { SearchResultPreview } from '../pages/projects/search-result-preview'
|
||||
import type { ProjectSettingsUpdate } from '../pages/projects/project-sidebar'
|
||||
import { ProjectPage } from '../pages/workspace-home'
|
||||
@@ -54,6 +54,7 @@ function App() {
|
||||
const [actionLoading, setActionLoading] = useState(false)
|
||||
const [activeModal, setActiveModal] = useState<ProjectActionModal>(null)
|
||||
const [searchResultPreview, setSearchResultPreview] = useState<SearchResultDTO | null>(null)
|
||||
const [documentIntent, setDocumentIntent] = useState<DocumentOpenIntent | null>(null)
|
||||
const workspaceSearch = useWorkbenchSearch(session)
|
||||
|
||||
const handleListAISessions = useCallback((projectId: string, signal?: AbortSignal) => {
|
||||
@@ -119,6 +120,17 @@ function App() {
|
||||
return { ...result, refreshFailed: true }
|
||||
}
|
||||
}
|
||||
setDocumentIntent({
|
||||
intentId: crypto.randomUUID(),
|
||||
mode: 'open',
|
||||
projectId,
|
||||
documentId: result.documentId,
|
||||
sourceType: 'dataset',
|
||||
sourceId: itemId,
|
||||
})
|
||||
selectActiveProject(projectId)
|
||||
setActiveView('project')
|
||||
setActiveChannel('documents')
|
||||
return result
|
||||
}, [session, workspaces])
|
||||
|
||||
@@ -238,22 +250,6 @@ function App() {
|
||||
}, '任务已创建')
|
||||
}
|
||||
|
||||
function handleUploadSource(draft: SourceDraft) {
|
||||
if (!draft.file) {
|
||||
Message.warning('请选择文件')
|
||||
return
|
||||
}
|
||||
const file = draft.file
|
||||
void runAction(async () => {
|
||||
await uploadSource(requireSession(), requireActiveProject(), {
|
||||
title: draft.title.trim(),
|
||||
file,
|
||||
})
|
||||
await refreshAfterAction()
|
||||
setActiveChannel('notes')
|
||||
}, '文件已上传')
|
||||
}
|
||||
|
||||
function handleCreateCronPlan(draft: CronDraft) {
|
||||
if (!draft.title.trim()) {
|
||||
Message.warning('请输入计划名称')
|
||||
@@ -375,7 +371,16 @@ function App() {
|
||||
setActiveView('project')
|
||||
setActiveChannel(target.channel)
|
||||
setActiveTaskID(target.openTask ? result.id : null)
|
||||
if (result.type === 'note') setSelectedItem(result.title)
|
||||
if (result.type === 'document') {
|
||||
setDocumentIntent({
|
||||
intentId: crypto.randomUUID(),
|
||||
mode: 'open',
|
||||
projectId: result.projectId,
|
||||
documentId: result.id,
|
||||
sourceType: 'search',
|
||||
sourceId: result.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -389,6 +394,11 @@ function App() {
|
||||
<ProjectPage
|
||||
activeView={activeView}
|
||||
activeWorkspace={activeWorkspace}
|
||||
session={session}
|
||||
documentIntent={documentIntent}
|
||||
onDocumentIntentConsumed={(intentId) => {
|
||||
setDocumentIntent((current) => current?.intentId === intentId ? null : current)
|
||||
}}
|
||||
workspaces={workspaces}
|
||||
activeChannel={activeChannel}
|
||||
activeTaskID={activeTaskID}
|
||||
@@ -411,7 +421,6 @@ function App() {
|
||||
onToggleTheme={() => setTheme(dark ? 'light' : 'dark')}
|
||||
onCreateProject={() => setActiveModal('project')}
|
||||
onCreateTask={() => setActiveModal('task')}
|
||||
onUploadSource={() => setActiveModal('source')}
|
||||
onCreateCronPlan={() => setActiveModal('cron')}
|
||||
onUpdateWorkspaceTask={handleUpdateWorkspaceTask}
|
||||
onUpdateProject={handleUpdateProject}
|
||||
@@ -446,7 +455,6 @@ function App() {
|
||||
onClose={() => setActiveModal(null)}
|
||||
onCreateProject={handleCreateProject}
|
||||
onCreateTask={handleCreateTask}
|
||||
onUploadSource={handleUploadSource}
|
||||
onCreateCronPlan={handleCreateCronPlan}
|
||||
tagOptions={activeTagOptions}
|
||||
/>
|
||||
@@ -459,13 +467,13 @@ function App() {
|
||||
function searchResultTarget(type: string): { channel: ChannelKey; openTask: boolean } | null {
|
||||
if (type === 'project') return { channel: 'overview', openTask: false }
|
||||
if (type === 'task') return { channel: 'tasks', openTask: true }
|
||||
if (type === 'note') return { channel: 'notes', openTask: false }
|
||||
if (type === 'document') return { channel: 'documents', openTask: false }
|
||||
return null
|
||||
}
|
||||
|
||||
function isAuthorizedExternalPreview(result: SearchResultDTO) {
|
||||
return (
|
||||
(result.type === 'task' || result.type === 'note')
|
||||
(result.type === 'task' || result.type === 'document')
|
||||
&& typeof result.projectId === 'string'
|
||||
&& result.projectId.trim() !== ''
|
||||
&& typeof result.title === 'string'
|
||||
|
||||
78
apps/web_v1/src/pages/projects/document-markdown-editor.tsx
Normal file
78
apps/web_v1/src/pages/projects/document-markdown-editor.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { EditorView, keymap, lineNumbers } from '@codemirror/view'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
|
||||
export function DocumentMarkdownEditor({
|
||||
value,
|
||||
dark,
|
||||
onChange,
|
||||
onSave,
|
||||
}: {
|
||||
value: string
|
||||
dark: boolean
|
||||
onChange: (value: string) => void
|
||||
onSave: () => void
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const viewRef = useRef<EditorView | null>(null)
|
||||
const onChangeRef = useRef(onChange)
|
||||
const onSaveRef = useRef(onSave)
|
||||
onChangeRef.current = onChange
|
||||
onSaveRef.current = onSave
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostRef.current) return
|
||||
const view = new EditorView({
|
||||
parent: hostRef.current,
|
||||
state: EditorState.create({
|
||||
doc: value,
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
history(),
|
||||
markdown(),
|
||||
keymap.of([
|
||||
{
|
||||
key: 'Mod-s',
|
||||
preventDefault: true,
|
||||
run: () => {
|
||||
onSaveRef.current()
|
||||
return true
|
||||
},
|
||||
},
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
]),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) onChangeRef.current(update.state.doc.toString())
|
||||
}),
|
||||
EditorView.theme({
|
||||
'&': { height: '100%', fontSize: '14px' },
|
||||
'.cm-scroller': { fontFamily: 'JetBrains Mono, Cascadia Code, Consolas, monospace', lineHeight: '1.7' },
|
||||
'.cm-content': { padding: '22px 26px 40px' },
|
||||
'.cm-gutters': { borderRight: '1px solid var(--senlin-border)' },
|
||||
}),
|
||||
...(dark ? [oneDark] : []),
|
||||
],
|
||||
}),
|
||||
})
|
||||
viewRef.current = view
|
||||
return () => {
|
||||
view.destroy()
|
||||
viewRef.current = null
|
||||
}
|
||||
// Recreate only when theme changes; external value updates are handled below.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dark])
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
if (!view || view.state.doc.toString() === value) return
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } })
|
||||
}, [value])
|
||||
|
||||
return <div className="document-codemirror" ref={hostRef} />
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Input, Modal, Select, Space, Switch, Typography } from '@arco-design/we
|
||||
const { Text } = Typography
|
||||
const { TextArea } = Input
|
||||
|
||||
export type ProjectActionModal = 'project' | 'task' | 'source' | 'cron' | null
|
||||
export type ProjectActionModal = 'project' | 'task' | 'cron' | null
|
||||
|
||||
export type ProjectDraft = {
|
||||
name: string
|
||||
@@ -20,11 +20,6 @@ export type TaskDraft = {
|
||||
tag: string
|
||||
}
|
||||
|
||||
export type SourceDraft = {
|
||||
title: string
|
||||
file: File | null
|
||||
}
|
||||
|
||||
export type CronDraft = {
|
||||
title: string
|
||||
schedule: string
|
||||
@@ -38,7 +33,6 @@ export function ProjectActionModals({
|
||||
onClose,
|
||||
onCreateProject,
|
||||
onCreateTask,
|
||||
onUploadSource,
|
||||
onCreateCronPlan,
|
||||
tagOptions,
|
||||
}: {
|
||||
@@ -47,20 +41,17 @@ export function ProjectActionModals({
|
||||
onClose: () => void
|
||||
onCreateProject: (draft: ProjectDraft) => void
|
||||
onCreateTask: (draft: TaskDraft) => void
|
||||
onUploadSource: (draft: SourceDraft) => void
|
||||
onCreateCronPlan: (draft: CronDraft) => void
|
||||
tagOptions: string[]
|
||||
}) {
|
||||
const [project, setProject] = useState<ProjectDraft>({ name: '', identifier: '', icon: '', background: '', description: '' })
|
||||
const [task, setTask] = useState<TaskDraft>({ title: '', description: '', tag: '' })
|
||||
const [source, setSource] = useState<SourceDraft>({ title: '', file: null })
|
||||
const [cron, setCron] = useState<CronDraft>({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' })
|
||||
|
||||
useEffect(() => {
|
||||
if (activeModal === null) {
|
||||
setProject({ name: '', identifier: '', icon: '', background: '', description: '' })
|
||||
setTask({ title: '', description: '', tag: '' })
|
||||
setSource({ title: '', file: null })
|
||||
setCron({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' })
|
||||
}
|
||||
}, [activeModal])
|
||||
@@ -132,30 +123,6 @@ export function ProjectActionModals({
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
className="action-modal"
|
||||
title="上传文件"
|
||||
visible={activeModal === 'source'}
|
||||
confirmLoading={loading}
|
||||
onCancel={onClose}
|
||||
onOk={() => onUploadSource(source)}
|
||||
>
|
||||
<Space direction="vertical" size={12} className="action-form">
|
||||
<label>
|
||||
<Text>资料标题</Text>
|
||||
<Input placeholder="默认使用文件名" value={source.title} onChange={(title) => setSource((draft) => ({ ...draft, title }))} />
|
||||
</label>
|
||||
<label>
|
||||
<Text>选择文件</Text>
|
||||
<input
|
||||
className="native-file-input"
|
||||
type="file"
|
||||
onChange={(event) => setSource((draft) => ({ ...draft, file: event.target.files?.[0] ?? null }))}
|
||||
/>
|
||||
</label>
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
className="action-modal"
|
||||
title="新建计划任务"
|
||||
|
||||
@@ -2,23 +2,28 @@ import { ProjectAi } from './project-ai'
|
||||
import { ProjectCron } from './project-cron'
|
||||
import { ProjectInbox } from './project-inbox'
|
||||
import { ProjectNewChannel } from './project-new-channel'
|
||||
import { ProjectNotes } from './project-notes'
|
||||
import { ProjectDocuments } from './project-documents'
|
||||
import { ProjectOverview } from './project-overview'
|
||||
import type { ProjectTaskUpdate } from './project-task-edit-modal'
|
||||
import { ProjectTasks } from './project-tasks'
|
||||
import type { ChannelKey, InboxConfirmationOutcome, ProjectWorkspace } from './project-types'
|
||||
import type { InboxSuggestionDTO } from '../../api/inbox'
|
||||
import type { AIExpertDTO, AISessionDTO, CreateAISessionInput } from '../../api/ai'
|
||||
import type { ApiSession } from '../../api/client'
|
||||
import type { DocumentOpenIntent } from '../../api/documents'
|
||||
|
||||
export function ProjectChannelPage({
|
||||
activeChannel,
|
||||
activeWorkspace,
|
||||
session,
|
||||
theme,
|
||||
documentIntent,
|
||||
onDocumentIntentConsumed,
|
||||
activeTaskID,
|
||||
onSelectItem,
|
||||
onOpenTask,
|
||||
onCloseTask,
|
||||
onCreateTask,
|
||||
onUploadSource,
|
||||
onCreateCronPlan,
|
||||
onCreateProjectTag,
|
||||
onUpdateTask,
|
||||
@@ -30,12 +35,15 @@ export function ProjectChannelPage({
|
||||
}: {
|
||||
activeChannel: ChannelKey
|
||||
activeWorkspace: ProjectWorkspace
|
||||
session: ApiSession
|
||||
theme: 'light' | 'dark'
|
||||
documentIntent: DocumentOpenIntent | null
|
||||
onDocumentIntentConsumed: (intentId: string) => void
|
||||
activeTaskID: string | null
|
||||
onSelectItem: (title: string) => void
|
||||
onOpenTask: (taskID: string) => void
|
||||
onCloseTask: () => void
|
||||
onCreateTask: () => void
|
||||
onUploadSource: () => void
|
||||
onCreateCronPlan: () => void
|
||||
onCreateProjectTag: (name: string) => void
|
||||
onUpdateTask: (update: ProjectTaskUpdate) => void
|
||||
@@ -52,8 +60,17 @@ export function ProjectChannelPage({
|
||||
return <ProjectTasks activeWorkspace={activeWorkspace} activeTaskID={activeTaskID} onOpenTask={onOpenTask} onCloseTask={onCloseTask} onSelectItem={onSelectItem} onCreateTask={onCreateTask} onCreateProjectTag={onCreateProjectTag} onUpdateTask={onUpdateTask} />
|
||||
case 'ai':
|
||||
return <ProjectAi key={activeWorkspace.project.id} activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onListSessions={onListAISessions} onListExperts={onListAIExperts} onCreateSession={onCreateAISession} />
|
||||
case 'notes':
|
||||
return <ProjectNotes activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onUploadSource={onUploadSource} />
|
||||
case 'documents':
|
||||
return (
|
||||
<ProjectDocuments
|
||||
key={activeWorkspace.project.id}
|
||||
session={session}
|
||||
projectId={activeWorkspace.project.id}
|
||||
theme={theme}
|
||||
intent={documentIntent}
|
||||
onIntentConsumed={onDocumentIntentConsumed}
|
||||
/>
|
||||
)
|
||||
case 'cron':
|
||||
return <ProjectCron activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onCreateCronPlan={onCreateCronPlan} />
|
||||
case 'new-channel':
|
||||
|
||||
821
apps/web_v1/src/pages/projects/project-documents.tsx
Normal file
821
apps/web_v1/src/pages/projects/project-documents.tsx
Normal file
@@ -0,0 +1,821 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
Empty,
|
||||
Menu,
|
||||
Message,
|
||||
Progress,
|
||||
Spin,
|
||||
Tooltip,
|
||||
} from '@arco-design/web-react'
|
||||
import {
|
||||
IconClose,
|
||||
IconDelete,
|
||||
IconDownload,
|
||||
IconEdit,
|
||||
IconEye,
|
||||
IconFile,
|
||||
IconFileAudio,
|
||||
IconFileImage,
|
||||
IconFilePdf,
|
||||
IconFileVideo,
|
||||
IconFolder,
|
||||
IconFolderAdd,
|
||||
IconLeft,
|
||||
IconPlus,
|
||||
IconRight,
|
||||
IconSave,
|
||||
IconShareAlt,
|
||||
IconUpload,
|
||||
} from '@arco-design/web-react/icon'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import rehypeSanitize from 'rehype-sanitize'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import DOMPurify from 'dompurify'
|
||||
import mammoth from 'mammoth'
|
||||
import { readSheet as readXlsxSheet } from 'read-excel-file/browser'
|
||||
import type { ApiSession } from '../../api/client'
|
||||
import {
|
||||
createDocumentFolder,
|
||||
createDocumentShare,
|
||||
createMarkdownDocument,
|
||||
deleteDocument,
|
||||
downloadBlob,
|
||||
exportDocument,
|
||||
fetchDocumentBlob,
|
||||
fetchLegacySheetPreview,
|
||||
getDocument,
|
||||
listDocuments,
|
||||
updateDocument,
|
||||
uploadDocument,
|
||||
type DocumentDTO,
|
||||
type DocumentOpenIntent,
|
||||
type UploadControl,
|
||||
} from '../../api/documents'
|
||||
import type { Theme } from './project-types'
|
||||
import { DocumentMarkdownEditor } from './document-markdown-editor'
|
||||
|
||||
type SaveStatus = 'clean' | 'dirty' | 'saving' | 'saved' | 'error'
|
||||
|
||||
type DocumentTab = {
|
||||
key: string
|
||||
documentId?: string
|
||||
parentId?: string
|
||||
name: string
|
||||
extension: string
|
||||
mimeType: string
|
||||
markdown: string
|
||||
revision: number
|
||||
status: SaveStatus
|
||||
mode: 'edit' | 'preview'
|
||||
}
|
||||
|
||||
type UploadItem = {
|
||||
id: string
|
||||
file: File
|
||||
progress: number
|
||||
status: 'queued' | 'uploading' | 'done' | 'error' | 'cancelled'
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function ProjectDocuments({
|
||||
session,
|
||||
projectId,
|
||||
theme,
|
||||
intent,
|
||||
onIntentConsumed,
|
||||
}: {
|
||||
session: ApiSession
|
||||
projectId: string
|
||||
theme: Theme
|
||||
intent: DocumentOpenIntent | null
|
||||
onIntentConsumed: (intentId: string) => void
|
||||
}) {
|
||||
const [documents, setDocuments] = useState<DocumentDTO[]>([])
|
||||
const [tabs, setTabs] = useState<DocumentTab[]>([])
|
||||
const [activeKey, setActiveKey] = useState('')
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [treeWidth, setTreeWidth] = useState(260)
|
||||
const [treeCollapsed, setTreeCollapsed] = useState(false)
|
||||
const [mobilePane, setMobilePane] = useState<'tree' | 'editor'>('tree')
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; document: DocumentDTO } | null>(null)
|
||||
const [uploads, setUploads] = useState<UploadItem[]>([])
|
||||
const tabsRef = useRef<DocumentTab[]>([])
|
||||
const timersRef = useRef(new Map<string, number>())
|
||||
const uploadControlsRef = useRef(new Map<string, UploadControl>())
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
tabsRef.current = tabs
|
||||
|
||||
const activeTab = tabs.find((tab) => tab.key === activeKey)
|
||||
const rootDocuments = useMemo(() => buildTree(documents), [documents])
|
||||
|
||||
const refreshTree = useCallback(async () => {
|
||||
const result = await listDocuments(session, projectId)
|
||||
setDocuments(result)
|
||||
return result
|
||||
}, [projectId, session])
|
||||
|
||||
const openDocument = useCallback(async (document: DocumentDTO) => {
|
||||
if (document.kind === 'folder') {
|
||||
setExpanded((current) => toggleSet(current, document.id))
|
||||
return
|
||||
}
|
||||
const existing = tabsRef.current.find((tab) => tab.documentId === document.id)
|
||||
if (existing) {
|
||||
setActiveKey(existing.key)
|
||||
setMobilePane('editor')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const detail = await getDocument(session, projectId, document.id)
|
||||
const next = tabFromDocument(detail)
|
||||
setTabs((current) => [...current, next])
|
||||
setActiveKey(next.key)
|
||||
setMobilePane('editor')
|
||||
} catch (error) {
|
||||
Message.error(error instanceof Error ? error.message : '文档打开失败')
|
||||
}
|
||||
}, [projectId, session])
|
||||
|
||||
const createDraft = useCallback((name = '新建文件1.md', markdown = '', parentId = '') => {
|
||||
const key = `draft:${crypto.randomUUID()}`
|
||||
const tab: DocumentTab = {
|
||||
key,
|
||||
parentId,
|
||||
name,
|
||||
extension: '.md',
|
||||
mimeType: 'text/markdown; charset=utf-8',
|
||||
markdown,
|
||||
revision: 0,
|
||||
status: markdown ? 'dirty' : 'clean',
|
||||
mode: 'edit',
|
||||
}
|
||||
setTabs((current) => [...current, tab])
|
||||
setActiveKey(key)
|
||||
setMobilePane('editor')
|
||||
return key
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const timers = timersRef.current
|
||||
setTabs([])
|
||||
setActiveKey('')
|
||||
setLoading(true)
|
||||
void refreshTree().then(async (items) => {
|
||||
if (cancelled) return
|
||||
const stored = readStoredTabs(projectId)
|
||||
const restored: DocumentTab[] = []
|
||||
for (const id of stored) {
|
||||
const item = items.find((document) => document.id === id && document.kind === 'file')
|
||||
if (!item) continue
|
||||
try {
|
||||
const detail = await getDocument(session, projectId, id)
|
||||
if (!cancelled) restored.push(tabFromDocument(detail))
|
||||
} catch {
|
||||
// Missing tabs are discarded.
|
||||
}
|
||||
}
|
||||
if (cancelled) return
|
||||
if (restored.length > 0) {
|
||||
setTabs(restored)
|
||||
setActiveKey(restored[0].key)
|
||||
setMobilePane('editor')
|
||||
} else {
|
||||
createDraft()
|
||||
}
|
||||
}).catch((error) => {
|
||||
if (!cancelled) Message.error(error instanceof Error ? error.message : '文档树加载失败')
|
||||
}).finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
for (const timer of timers.values()) window.clearTimeout(timer)
|
||||
timers.clear()
|
||||
}
|
||||
}, [createDraft, projectId, refreshTree, session])
|
||||
|
||||
useEffect(() => {
|
||||
const ids = tabs.flatMap((tab) => tab.documentId ? [tab.documentId] : [])
|
||||
localStorage.setItem(storageKey(projectId), JSON.stringify(ids))
|
||||
}, [projectId, tabs])
|
||||
|
||||
useEffect(() => {
|
||||
if (!intent || intent.projectId !== projectId) return
|
||||
if (intent.mode === 'open' && intent.documentId) {
|
||||
const document = documents.find((item) => item.id === intent.documentId)
|
||||
if (document) void openDocument(document)
|
||||
} else {
|
||||
createDraft(intent.suggestedName || '新建文件1.md', intent.initialMarkdown || '', intent.targetDirectoryId || '')
|
||||
}
|
||||
onIntentConsumed(intent.intentId)
|
||||
}, [createDraft, documents, intent, onIntentConsumed, openDocument, projectId])
|
||||
|
||||
const persistTab = useCallback(async (key: string, forcedMarkdown?: string) => {
|
||||
const tab = tabsRef.current.find((item) => item.key === key)
|
||||
if (!tab || tab.extension !== '.md' || tab.status === 'saving') return
|
||||
const markdown = forcedMarkdown ?? tab.markdown
|
||||
setTabs((current) => patchTab(current, key, { status: 'saving' }))
|
||||
try {
|
||||
if (!tab.documentId) {
|
||||
const created = await createMarkdownDocument(session, projectId, { name: tab.name, parentId: tab.parentId, markdown })
|
||||
setDocuments((current) => [...current, created])
|
||||
setTabs((current) => patchTab(current, key, {
|
||||
documentId: created.id,
|
||||
key: created.id,
|
||||
name: created.name,
|
||||
revision: created.revision,
|
||||
status: 'saved',
|
||||
}))
|
||||
setActiveKey((current) => current === key ? created.id : current)
|
||||
} else {
|
||||
const updated = await updateDocument(session, projectId, tab.documentId, {
|
||||
markdown,
|
||||
revision: tab.revision,
|
||||
})
|
||||
setDocuments((current) => current.map((item) => item.id === updated.id ? updated : item))
|
||||
setTabs((current) => patchTab(current, key, {
|
||||
markdown: updated.markdown ?? markdown,
|
||||
revision: updated.revision,
|
||||
status: 'saved',
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
setTabs((current) => patchTab(current, key, { status: 'error' }))
|
||||
Message.error(error instanceof Error ? error.message : '自动保存失败,本地草稿已保留')
|
||||
}
|
||||
}, [projectId, session])
|
||||
|
||||
function changeMarkdown(key: string, markdown: string) {
|
||||
setTabs((current) => patchTab(current, key, { markdown, status: 'dirty' }))
|
||||
const previous = timersRef.current.get(key)
|
||||
if (previous) window.clearTimeout(previous)
|
||||
timersRef.current.set(key, window.setTimeout(() => {
|
||||
timersRef.current.delete(key)
|
||||
void persistTab(key, markdown)
|
||||
}, 800))
|
||||
}
|
||||
|
||||
async function addFolder(parentId = '') {
|
||||
const name = window.prompt('目录名称')
|
||||
if (!name?.trim()) return
|
||||
try {
|
||||
const created = await createDocumentFolder(session, projectId, name.trim(), parentId)
|
||||
setDocuments((current) => [...current, created])
|
||||
} catch (error) {
|
||||
Message.error(error instanceof Error ? error.message : '目录创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function renameNode(document: DocumentDTO) {
|
||||
const name = window.prompt('重命名', document.name)
|
||||
if (!name?.trim() || name.trim() === document.name) return
|
||||
try {
|
||||
const updated = await updateDocument(session, projectId, document.id, { name: name.trim() })
|
||||
setDocuments((current) => current.map((item) => item.id === updated.id ? updated : item))
|
||||
setTabs((current) => current.map((tab) => tab.documentId === updated.id ? { ...tab, name: updated.name } : tab))
|
||||
} catch (error) {
|
||||
Message.error(error instanceof Error ? error.message : '重命名失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeNode(document: DocumentDTO) {
|
||||
if (!window.confirm(`确定删除“${document.name}”${document.kind === 'folder' ? '及其全部内容' : ''}吗?`)) return
|
||||
try {
|
||||
await deleteDocument(session, projectId, document.id)
|
||||
const removed = descendantIds(documents, document.id)
|
||||
setDocuments((current) => current.filter((item) => !removed.has(item.id)))
|
||||
setTabs((current) => current.filter((tab) => !tab.documentId || !removed.has(tab.documentId)))
|
||||
if (activeTab?.documentId && removed.has(activeTab.documentId)) setActiveKey('')
|
||||
} catch (error) {
|
||||
Message.error(error instanceof Error ? error.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function moveNode(documentId: string, parentId: string) {
|
||||
const document = documents.find((item) => item.id === documentId)
|
||||
if (!document || document.parentId === (parentId || null)) return
|
||||
try {
|
||||
const updated = await updateDocument(session, projectId, documentId, { parentId })
|
||||
setDocuments((current) => current.map((item) => item.id === updated.id ? updated : item))
|
||||
} catch (error) {
|
||||
Message.error(error instanceof Error ? error.message : '移动失败')
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueFiles(files: FileList | File[]) {
|
||||
const accepted = Array.from(files).filter((file) => {
|
||||
if (file.size <= 50 * 1024 * 1024) return true
|
||||
Message.error(`${file.name} 超过 50 MB,未加入上传队列`)
|
||||
return false
|
||||
})
|
||||
setUploads((current) => [
|
||||
...current,
|
||||
...accepted.map((file) => ({
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
progress: 0,
|
||||
status: 'queued' as const,
|
||||
})),
|
||||
])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const running = uploads.filter((item) => item.status === 'uploading').length
|
||||
const queued = uploads.filter((item) => item.status === 'queued').slice(0, Math.max(0, 3 - running))
|
||||
for (const item of queued) {
|
||||
setUploads((current) => current.map((candidate) => candidate.id === item.id ? { ...candidate, status: 'uploading' } : candidate))
|
||||
const control = uploadDocument(session, projectId, item.file, '', (progress) => {
|
||||
setUploads((current) => current.map((candidate) => candidate.id === item.id ? { ...candidate, progress } : candidate))
|
||||
})
|
||||
uploadControlsRef.current.set(item.id, control)
|
||||
void control.promise.then((document) => {
|
||||
setDocuments((current) => [...current, document])
|
||||
setUploads((current) => current.map((candidate) => candidate.id === item.id ? { ...candidate, progress: 100, status: 'done' } : candidate))
|
||||
void openDocument(document)
|
||||
}).catch((error) => {
|
||||
setUploads((current) => current.map((candidate) => candidate.id === item.id
|
||||
? { ...candidate, status: 'error', error: error instanceof Error ? error.message : '上传失败' }
|
||||
: candidate))
|
||||
}).finally(() => {
|
||||
uploadControlsRef.current.delete(item.id)
|
||||
window.setTimeout(() => {
|
||||
setUploads((current) => current.filter((candidate) => candidate.id !== item.id || candidate.status !== 'done'))
|
||||
}, 1800)
|
||||
})
|
||||
}
|
||||
}, [openDocument, projectId, session, uploads])
|
||||
|
||||
function closeTab(tab: DocumentTab) {
|
||||
if ((tab.status === 'dirty' || tab.status === 'error') && !window.confirm(`“${tab.name}”尚未成功保存,仍要关闭吗?`)) return
|
||||
const index = tabs.findIndex((item) => item.key === tab.key)
|
||||
const next = tabs.filter((item) => item.key !== tab.key)
|
||||
if (tab.key !== activeKey) {
|
||||
setTabs(next)
|
||||
return
|
||||
}
|
||||
const replacement = next[Math.max(0, index - 1)]
|
||||
if (replacement) {
|
||||
setTabs(next)
|
||||
setActiveKey(replacement.key)
|
||||
return
|
||||
}
|
||||
const key = `draft:${crypto.randomUUID()}`
|
||||
setTabs([{
|
||||
key,
|
||||
name: '新建文件1.md',
|
||||
extension: '.md',
|
||||
mimeType: 'text/markdown; charset=utf-8',
|
||||
markdown: '',
|
||||
revision: 0,
|
||||
status: 'clean',
|
||||
mode: 'edit',
|
||||
}])
|
||||
setActiveKey(key)
|
||||
}
|
||||
|
||||
async function downloadActive() {
|
||||
if (!activeTab?.documentId) return
|
||||
try {
|
||||
const blob = await fetchDocumentBlob(session, projectId, activeTab.documentId, true)
|
||||
downloadBlob(blob, activeTab.name)
|
||||
} catch (error) {
|
||||
Message.error(error instanceof Error ? error.message : '下载失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function shareActive(days: 1 | 7 | 30) {
|
||||
if (!activeTab?.documentId) {
|
||||
Message.warning('请先保存文档')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const share = await createDocumentShare(session, projectId, activeTab.documentId, days)
|
||||
if (share.url) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(share.url)
|
||||
Message.success(`${days} 天有效的预览链接已复制`)
|
||||
} catch {
|
||||
window.prompt('复制分享链接', share.url)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Message.error(error instanceof Error ? error.message : '分享链接创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
function beginResize(event: React.MouseEvent) {
|
||||
const startX = event.clientX
|
||||
const startWidth = treeWidth
|
||||
function move(moveEvent: MouseEvent) {
|
||||
setTreeWidth(Math.min(420, Math.max(210, startWidth + moveEvent.clientX - startX)))
|
||||
}
|
||||
function finish() {
|
||||
window.removeEventListener('mousemove', move)
|
||||
window.removeEventListener('mouseup', finish)
|
||||
}
|
||||
window.addEventListener('mousemove', move)
|
||||
window.addEventListener('mouseup', finish)
|
||||
}
|
||||
|
||||
const uploadProgress = uploads.length === 0
|
||||
? 0
|
||||
: Math.round(uploads.reduce((sum, item) => sum + item.progress, 0) / uploads.length)
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`documents-canvas mobile-${mobilePane}`}
|
||||
onClick={() => setContextMenu(null)}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
if (event.dataTransfer.files.length > 0) enqueueFiles(event.dataTransfer.files)
|
||||
}}
|
||||
>
|
||||
{!treeCollapsed && (
|
||||
<aside className="documents-tree-pane" style={{ width: treeWidth }}>
|
||||
<div className="documents-tree-tools">
|
||||
<Tooltip content="新建目录"><Button type="text" icon={<IconFolderAdd />} onClick={() => void addFolder()} /></Tooltip>
|
||||
<Tooltip content="新建 Markdown"><Button type="text" icon={<IconPlus />} onClick={() => createDraft()} /></Tooltip>
|
||||
<span className="documents-tree-spacer" />
|
||||
<Tooltip content="上传文件"><Button type="text" icon={<IconUpload />} onClick={() => fileInputRef.current?.click()} /></Tooltip>
|
||||
<Tooltip content="折叠文件树"><Button type="text" icon={<IconLeft />} onClick={() => setTreeCollapsed(true)} /></Tooltip>
|
||||
<input ref={fileInputRef} hidden multiple type="file" onChange={(event) => event.target.files && enqueueFiles(event.target.files)} />
|
||||
</div>
|
||||
<div
|
||||
className="documents-tree"
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
const id = event.dataTransfer.getData('application/x-senlin-document')
|
||||
if (id) {
|
||||
event.stopPropagation()
|
||||
void moveNode(id, '')
|
||||
}
|
||||
}}
|
||||
>
|
||||
{loading ? <Spin /> : rootDocuments.length === 0 ? <Empty description="暂无文件" /> : rootDocuments.map((node) => (
|
||||
<TreeNode
|
||||
key={node.document.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
expanded={expanded}
|
||||
activeDocumentId={activeTab?.documentId}
|
||||
onToggle={(id) => setExpanded((current) => toggleSet(current, id))}
|
||||
onOpen={(document) => void openDocument(document)}
|
||||
onMove={(id, parent) => void moveNode(id, parent)}
|
||||
onContextMenu={(event, document) => {
|
||||
event.preventDefault()
|
||||
setContextMenu({ x: event.clientX, y: event.clientY, document })
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{uploads.length > 0 && (
|
||||
<div className="document-upload-summary">
|
||||
<div><span>上传 {uploads.filter((item) => item.status === 'done').length}/{uploads.length}</span><span>{uploadProgress}%</span></div>
|
||||
<Progress percent={uploadProgress} showText={false} size="small" />
|
||||
{uploads.filter((item) => item.status === 'uploading' || item.status === 'error').map((item) => (
|
||||
<div className="document-upload-row" key={item.id}>
|
||||
<span title={item.error}>{item.file.name}</span>
|
||||
<span>{item.status === 'error' ? '失败' : `${item.progress}%`}</span>
|
||||
{item.status === 'uploading' ? (
|
||||
<button onClick={() => uploadControlsRef.current.get(item.id)?.cancel()}>取消</button>
|
||||
) : (
|
||||
<button onClick={() => setUploads((current) => current.map((candidate) => candidate.id === item.id
|
||||
? { ...candidate, progress: 0, status: 'queued', error: undefined }
|
||||
: candidate))}>重试</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
{!treeCollapsed && <div className="documents-resizer" onMouseDown={beginResize} />}
|
||||
|
||||
<main className="documents-editor-pane">
|
||||
<div className="documents-tabs-bar">
|
||||
{treeCollapsed && <Button type="text" icon={<IconRight />} onClick={() => setTreeCollapsed(false)} />}
|
||||
<div className="documents-tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button className={tab.key === activeKey ? 'document-tab active' : 'document-tab'} key={tab.key} onClick={() => setActiveKey(tab.key)}>
|
||||
{fileIcon(tab.extension, tab.mimeType)}
|
||||
<span>{tab.name}</span>
|
||||
<i className={`save-dot ${tab.status}`} title={saveLabel(tab.status)} />
|
||||
<IconClose onClick={(event) => { event.stopPropagation(); closeTab(tab) }} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{activeTab && (
|
||||
<div className="documents-tab-actions">
|
||||
{activeTab.extension === '.md' && (
|
||||
<Tooltip content={activeTab.mode === 'edit' ? '预览' : '编辑'}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={activeTab.mode === 'edit' ? <IconEye /> : <IconEdit />}
|
||||
onClick={() => setTabs((current) => patchTab(current, activeTab.key, { mode: activeTab.mode === 'edit' ? 'preview' : 'edit' }))}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{activeTab.extension === '.md' && (
|
||||
<Tooltip content="保存"><Button type="text" icon={<IconSave />} onClick={() => void persistTab(activeTab.key)} /></Tooltip>
|
||||
)}
|
||||
{activeTab.documentId && activeTab.extension === '.md' ? (
|
||||
<Dropdown
|
||||
trigger="click"
|
||||
droplist={<Menu onClickMenuItem={(format: string) => void exportDocument(session, projectId, activeTab.documentId!, format as 'md' | 'pdf' | 'docx')}>
|
||||
<Menu.Item key="md">导出 Markdown</Menu.Item>
|
||||
<Menu.Item key="pdf">导出 PDF</Menu.Item>
|
||||
<Menu.Item key="docx">导出 Word</Menu.Item>
|
||||
</Menu>}
|
||||
>
|
||||
<Button type="text" icon={<IconDownload />} />
|
||||
</Dropdown>
|
||||
) : activeTab.documentId ? (
|
||||
<Tooltip content="下载原文件"><Button type="text" icon={<IconDownload />} onClick={() => void downloadActive()} /></Tooltip>
|
||||
) : null}
|
||||
<Dropdown
|
||||
trigger="click"
|
||||
droplist={<Menu onClickMenuItem={(days) => void shareActive(Number(days) as 1 | 7 | 30)}>
|
||||
<Menu.Item key="1">有效期 1 天</Menu.Item>
|
||||
<Menu.Item key="7">有效期 7 天</Menu.Item>
|
||||
<Menu.Item key="30">有效期 30 天</Menu.Item>
|
||||
</Menu>}
|
||||
>
|
||||
<Tooltip content="分享预览"><Button type="text" icon={<IconShareAlt />} /></Tooltip>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="documents-mobile-switch">
|
||||
<Button icon={<IconLeft />} onClick={() => setMobilePane('tree')}>文件</Button>
|
||||
</div>
|
||||
|
||||
<div className="document-editor-body">
|
||||
{!activeTab ? <Empty description="没有打开的文件" /> : activeTab.extension === '.md' && activeTab.mode === 'edit' ? (
|
||||
<DocumentMarkdownEditor
|
||||
value={activeTab.markdown}
|
||||
dark={theme === 'dark'}
|
||||
onChange={(value) => changeMarkdown(activeTab.key, value)}
|
||||
onSave={() => void persistTab(activeTab.key)}
|
||||
/>
|
||||
) : activeTab.extension === '.md' ? (
|
||||
<MarkdownPreview markdown={activeTab.markdown} />
|
||||
) : activeTab.documentId ? (
|
||||
<SmartPreview session={session} projectId={projectId} tab={activeTab} />
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{contextMenu && (
|
||||
<div className="document-context-menu" style={{ left: contextMenu.x, top: contextMenu.y }}>
|
||||
{contextMenu.document.kind === 'folder' && (
|
||||
<>
|
||||
<button onClick={() => void addFolder(contextMenu.document.id)}><IconFolderAdd />新建子目录</button>
|
||||
<button onClick={() => createDraft('新建文件1.md', '', contextMenu.document.id)}><IconPlus />新建 Markdown</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => void renameNode(contextMenu.document)}><IconEdit />重命名</button>
|
||||
<button className="danger" onClick={() => void removeNode(contextMenu.document)}><IconDelete />删除</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
type TreeEntry = { document: DocumentDTO; children: TreeEntry[] }
|
||||
|
||||
function TreeNode({
|
||||
node,
|
||||
depth,
|
||||
expanded,
|
||||
activeDocumentId,
|
||||
onToggle,
|
||||
onOpen,
|
||||
onMove,
|
||||
onContextMenu,
|
||||
}: {
|
||||
node: TreeEntry
|
||||
depth: number
|
||||
expanded: Set<string>
|
||||
activeDocumentId?: string
|
||||
onToggle: (id: string) => void
|
||||
onOpen: (document: DocumentDTO) => void
|
||||
onMove: (documentId: string, parentId: string) => void
|
||||
onContextMenu: (event: React.MouseEvent, document: DocumentDTO) => void
|
||||
}) {
|
||||
const { document } = node
|
||||
const open = expanded.has(document.id)
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={document.id === activeDocumentId ? 'document-tree-row active' : 'document-tree-row'}
|
||||
style={{ paddingLeft: 8 + depth * 16 }}
|
||||
draggable
|
||||
onDragStart={(event) => event.dataTransfer.setData('application/x-senlin-document', document.id)}
|
||||
onDragOver={(event) => { if (document.kind === 'folder') event.preventDefault() }}
|
||||
onDrop={(event) => {
|
||||
if (document.kind !== 'folder') return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const id = event.dataTransfer.getData('application/x-senlin-document')
|
||||
if (id) onMove(id, document.id)
|
||||
}}
|
||||
onContextMenu={(event) => onContextMenu(event, document)}
|
||||
onClick={() => document.kind === 'folder' ? onToggle(document.id) : onOpen(document)}
|
||||
onDoubleClick={() => onOpen(document)}
|
||||
>
|
||||
{document.kind === 'folder' ? <span className="tree-chevron">{open ? '⌄' : '›'}</span> : <span className="tree-chevron" />}
|
||||
{document.kind === 'folder' ? <IconFolder /> : fileIcon(document.extension, document.mimeType)}
|
||||
<span>{document.name}</span>
|
||||
</button>
|
||||
{document.kind === 'folder' && open && node.children.map((child) => (
|
||||
<TreeNode
|
||||
key={child.document.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
expanded={expanded}
|
||||
activeDocumentId={activeDocumentId}
|
||||
onToggle={onToggle}
|
||||
onOpen={onOpen}
|
||||
onMove={onMove}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkdownPreview({ markdown }: { markdown: string }) {
|
||||
return (
|
||||
<article className="document-markdown-preview">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSanitize]}>{markdown}</ReactMarkdown>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function SmartPreview({ session, projectId, tab }: { session: ApiSession; projectId: string; tab: DocumentTab }) {
|
||||
const [url, setUrl] = useState('')
|
||||
const [text, setText] = useState('')
|
||||
const [html, setHtml] = useState('')
|
||||
const [rows, setRows] = useState<unknown[][]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
let objectURL = ''
|
||||
setLoading(true)
|
||||
setError('')
|
||||
const extension = tab.extension.toLowerCase()
|
||||
if (extension === '.xls') {
|
||||
void fetchLegacySheetPreview(session, projectId, tab.documentId!).then((preview) => {
|
||||
if (active) setRows(preview.rows)
|
||||
}).catch((reason) => {
|
||||
if (active) setError(reason instanceof Error ? reason.message : '预览加载失败')
|
||||
}).finally(() => {
|
||||
if (active) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}
|
||||
void fetchDocumentBlob(session, projectId, tab.documentId!).then(async (blob) => {
|
||||
if (!active) return
|
||||
if (isText(extension, tab.mimeType)) {
|
||||
setText(await blob.text())
|
||||
} else if (extension === '.docx') {
|
||||
const result = await mammoth.convertToHtml({ arrayBuffer: await blob.arrayBuffer() })
|
||||
if (active) setHtml(DOMPurify.sanitize(result.value))
|
||||
} else if (extension === '.xlsx') {
|
||||
const parsed = await readXlsxSheet(blob)
|
||||
if (active) setRows(parsed as unknown[][])
|
||||
} else {
|
||||
const previewBlob = extension === '.svg'
|
||||
? new Blob([DOMPurify.sanitize(await blob.text(), { USE_PROFILES: { svg: true, svgFilters: true } })], { type: 'image/svg+xml' })
|
||||
: blob
|
||||
objectURL = URL.createObjectURL(previewBlob)
|
||||
setUrl(objectURL)
|
||||
}
|
||||
}).catch((reason) => {
|
||||
if (active) setError(reason instanceof Error ? reason.message : '预览加载失败')
|
||||
}).finally(() => {
|
||||
if (active) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
if (objectURL) URL.revokeObjectURL(objectURL)
|
||||
}
|
||||
}, [projectId, session, tab.documentId, tab.extension, tab.mimeType])
|
||||
|
||||
if (loading) return <Spin />
|
||||
if (error) return <Empty description={error} />
|
||||
if (tab.mimeType.startsWith('image/')) return <div className="document-media-preview"><img src={url} alt={tab.name} /></div>
|
||||
if (tab.mimeType.startsWith('audio/')) return <div className="document-media-preview"><audio src={url} controls /></div>
|
||||
if (tab.mimeType.startsWith('video/')) return <div className="document-media-preview"><video src={url} controls /></div>
|
||||
if (tab.extension === '.pdf') return <iframe className="document-pdf-preview" src={url} title={tab.name} />
|
||||
if (tab.extension === '.docx') return <article className="document-office-preview" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
if (tab.extension === '.xlsx' || tab.extension === '.xls') return (
|
||||
<div className="document-sheet-preview"><table><tbody>{rows.map((row, rowIndex) => (
|
||||
<tr key={rowIndex}>{row.map((cell, cellIndex) => <td key={cellIndex}>{String(cell ?? '')}</td>)}</tr>
|
||||
))}</tbody></table></div>
|
||||
)
|
||||
if (text) return <pre className="document-text-preview">{text}</pre>
|
||||
return <Empty description="此格式暂不支持在线预览,可使用右上角下载" />
|
||||
}
|
||||
|
||||
function buildTree(documents: DocumentDTO[]) {
|
||||
const entries = new Map<string, TreeEntry>()
|
||||
for (const document of documents) entries.set(document.id, { document, children: [] })
|
||||
const roots: TreeEntry[] = []
|
||||
for (const entry of entries.values()) {
|
||||
const parent = entry.document.parentId ? entries.get(entry.document.parentId) : null
|
||||
if (parent) parent.children.push(entry)
|
||||
else roots.push(entry)
|
||||
}
|
||||
const sort = (items: TreeEntry[]) => {
|
||||
items.sort((left, right) => {
|
||||
if (left.document.kind !== right.document.kind) return left.document.kind === 'folder' ? -1 : 1
|
||||
return left.document.name.localeCompare(right.document.name, 'zh-CN', { numeric: true, sensitivity: 'base' })
|
||||
})
|
||||
items.forEach((item) => sort(item.children))
|
||||
}
|
||||
sort(roots)
|
||||
return roots
|
||||
}
|
||||
|
||||
function tabFromDocument(document: DocumentDTO): DocumentTab {
|
||||
return {
|
||||
key: document.id,
|
||||
documentId: document.id,
|
||||
parentId: document.parentId ?? '',
|
||||
name: document.name,
|
||||
extension: document.extension.toLowerCase(),
|
||||
mimeType: document.mimeType,
|
||||
markdown: document.markdown ?? '',
|
||||
revision: document.revision,
|
||||
status: 'clean',
|
||||
mode: document.extension.toLowerCase() === '.md' ? 'edit' : 'preview',
|
||||
}
|
||||
}
|
||||
|
||||
function patchTab(tabs: DocumentTab[], key: string, patch: Partial<DocumentTab>) {
|
||||
return tabs.map((tab) => tab.key === key ? { ...tab, ...patch } : tab)
|
||||
}
|
||||
|
||||
function toggleSet(current: Set<string>, value: string) {
|
||||
const next = new Set(current)
|
||||
if (next.has(value)) next.delete(value)
|
||||
else next.add(value)
|
||||
return next
|
||||
}
|
||||
|
||||
function descendantIds(documents: DocumentDTO[], root: string) {
|
||||
const ids = new Set([root])
|
||||
let changed = true
|
||||
while (changed) {
|
||||
changed = false
|
||||
for (const document of documents) {
|
||||
if (document.parentId && ids.has(document.parentId) && !ids.has(document.id)) {
|
||||
ids.add(document.id)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
function storageKey(projectId: string) {
|
||||
return `senlin:documents:tabs:${projectId}`
|
||||
}
|
||||
|
||||
function readStoredTabs(projectId: string): string[] {
|
||||
try {
|
||||
const value: unknown = JSON.parse(localStorage.getItem(storageKey(projectId)) ?? '[]')
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function saveLabel(status: SaveStatus) {
|
||||
return { clean: '已保存', dirty: '未保存', saving: '保存中', saved: '已保存', error: '保存失败' }[status]
|
||||
}
|
||||
|
||||
function fileIcon(extension: string, mimeType: string) {
|
||||
if (extension === '.pdf') return <IconFilePdf />
|
||||
if (mimeType.startsWith('image/')) return <IconFileImage />
|
||||
if (mimeType.startsWith('audio/')) return <IconFileAudio />
|
||||
if (mimeType.startsWith('video/')) return <IconFileVideo />
|
||||
return <IconFile />
|
||||
}
|
||||
|
||||
function isText(extension: string, mimeType: string) {
|
||||
return mimeType.startsWith('text/') || [
|
||||
'.txt', '.json', '.yaml', '.yml', '.xml', '.csv', '.ts', '.tsx', '.js', '.jsx', '.go', '.py', '.css', '.html',
|
||||
].includes(extension)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { IconFile, IconFileImage, IconFilePdf, IconFolder } from '@arco-design/web-react/icon'
|
||||
import type { NoteSource } from './project-types'
|
||||
import type { DocumentSummary } from './project-types'
|
||||
|
||||
export type ProjectFileItem = NoteSource | {
|
||||
export type ProjectFileItem = DocumentSummary | {
|
||||
id: string
|
||||
title: string
|
||||
type: string
|
||||
@@ -23,11 +23,12 @@ export function ProjectFileGrid({
|
||||
<div className={compact ? 'project-file-grid compact' : 'project-file-grid'}>
|
||||
{items.map((item) => {
|
||||
const fileKind = inferFileKind(item)
|
||||
const name = 'name' in item ? item.name : item.title
|
||||
return (
|
||||
<button className="project-file-tile" key={item.id} onClick={() => onSelectItem(item.title)}>
|
||||
<button className="project-file-tile" key={item.id} onClick={() => onSelectItem(name)}>
|
||||
<span className={`project-file-icon ${fileKind.kind}`}>{fileIcon(fileKind.kind)}</span>
|
||||
<span className="project-file-copy">
|
||||
<span className="project-file-name">{item.title}</span>
|
||||
<span className="project-file-name">{name}</span>
|
||||
<span className="project-file-type">{fileKind.label}</span>
|
||||
<span className="project-file-meta">{fileKind.meta}</span>
|
||||
</span>
|
||||
@@ -39,6 +40,15 @@ export function ProjectFileGrid({
|
||||
}
|
||||
|
||||
function inferFileKind(item: ProjectFileItem) {
|
||||
if ('name' in item) {
|
||||
if (item.kind === 'folder') return { kind: 'folder', label: '文件夹', meta: item.updated }
|
||||
const value = `${item.name} ${item.extension} ${item.mimeType}`.toLowerCase()
|
||||
if (item.extension === '.md') return { kind: 'document', label: 'Markdown 文档', meta: item.updated }
|
||||
if (item.extension === '.pdf') return { kind: 'pdf', label: 'PDF 文档', meta: item.updated }
|
||||
if (item.mimeType.startsWith('image/')) return { kind: 'image', label: '图片文件', meta: item.updated }
|
||||
if (value.includes('sheet') || value.includes('excel')) return { kind: 'sheet', label: '表格文件', meta: item.updated }
|
||||
return { kind: 'file', label: item.extension || '文件', meta: item.updated }
|
||||
}
|
||||
const value = `${item.title} ${item.type} ${item.source ?? ''}`.toLowerCase()
|
||||
if (item.type === 'folder' || item.source === 'folder') return { kind: 'folder', label: '文件夹', meta: item.updated }
|
||||
if (value.includes('pdf')) return { kind: 'pdf', label: 'PDF 文档', meta: item.updated }
|
||||
|
||||
@@ -238,12 +238,10 @@ function isInboxItemProcessed(item: InboxItem, locallyConfirmedItemIds: string[]
|
||||
|
||||
function suggestionLabel(kind: InboxSuggestionDTO['kind']) {
|
||||
if (kind === 'task') return '任务'
|
||||
if (kind === 'note') return '笔记'
|
||||
return '资料'
|
||||
return 'Markdown 文档'
|
||||
}
|
||||
|
||||
function suggestionColor(kind: InboxSuggestionDTO['kind']) {
|
||||
if (kind === 'task') return 'arcoblue'
|
||||
if (kind === 'note') return 'green'
|
||||
return 'purple'
|
||||
return 'green'
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Button, Card, Space, Typography } from '@arco-design/web-react'
|
||||
import { IconPlus, IconSearch } from '@arco-design/web-react/icon'
|
||||
import { ProjectFileGrid } from './project-file-grid'
|
||||
import type { ProjectWorkspace } from './project-types'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
export function ProjectNotes({ activeWorkspace, onSelectItem, onUploadSource }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => void; onUploadSource: () => void }) {
|
||||
const { notes, project } = activeWorkspace
|
||||
|
||||
return (
|
||||
<div className="project-channel-page project-notes-page overview-page">
|
||||
<div className="overview-head">
|
||||
<div>
|
||||
<Title heading={4}>笔记资料</Title>
|
||||
<Text type="secondary">{project.name} 的在线文件管理页,集中管理笔记、资料和附件。</Text>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<IconSearch />}>搜索资料</Button>
|
||||
<Button type="primary" icon={<IconPlus />} onClick={onUploadSource}>上传/新建</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card className="queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>最近更新</Title>
|
||||
<Button type="text" size="mini">按更新时间排序</Button>
|
||||
</div>
|
||||
<ProjectFileGrid items={notes} onSelectItem={onSelectItem} />
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export function ProjectOverview({
|
||||
onSelectItem: (title: string) => void
|
||||
onUpdateTask: (update: ProjectTaskUpdate) => void
|
||||
}) {
|
||||
const { project, tasks, notes, cronJobs } = activeWorkspace
|
||||
const { project, tasks, documents, cronJobs } = activeWorkspace
|
||||
const runningTasks = useMemo(() => tasks.filter((task) => !task.completed), [tasks])
|
||||
const completedTasks = useMemo(() => tasks.filter((task) => task.completed), [tasks])
|
||||
const [editingTask, setEditingTask] = useState<TaskItem | null>(null)
|
||||
@@ -26,9 +26,9 @@ export function ProjectOverview({
|
||||
const metrics = useMemo(() => [
|
||||
{ title: '进行中的任务', value: runningTasks.length, icon: <IconCheckCircleFill />, color: 'green' },
|
||||
{ title: '完成的任务', value: completedTasks.length, icon: <IconCheckCircleFill />, color: 'arcoblue' },
|
||||
{ title: '笔记资料总数', value: notes.length, icon: <IconFile />, color: 'cyan' },
|
||||
{ title: '笔记资料总数', value: documents.length, icon: <IconFile />, color: 'cyan' },
|
||||
{ title: '计划任务', value: cronJobs.length, icon: <IconClockCircle />, color: 'orange' },
|
||||
], [completedTasks.length, cronJobs.length, notes.length, runningTasks.length])
|
||||
], [completedTasks.length, cronJobs.length, documents.length, runningTasks.length])
|
||||
|
||||
return (
|
||||
<div className="overview-page project-overview-page">
|
||||
@@ -65,7 +65,7 @@ export function ProjectOverview({
|
||||
|
||||
<TaskCardSection title={`进行中的任务(${runningTasks.length})`} tasks={runningTasks} emptyText="当前没有进行中的任务" onEditTask={setEditingTask} />
|
||||
<TaskCardSection title={`完成的任务(${completedTasks.length})`} tasks={completedTasks} emptyText="当前没有完成的任务" onSelectItem={onSelectItem} />
|
||||
<NoteSection notes={notes} onSelectItem={onSelectItem} />
|
||||
<DocumentSection documents={documents} onSelectItem={onSelectItem} />
|
||||
<ProjectTaskEditModal
|
||||
task={editingTask}
|
||||
workspace={activeWorkspace}
|
||||
@@ -122,14 +122,14 @@ function TaskCardSection({
|
||||
)
|
||||
}
|
||||
|
||||
function NoteSection({ notes, onSelectItem }: { notes: ProjectWorkspace['notes']; onSelectItem: (title: string) => void }) {
|
||||
function DocumentSection({ documents, onSelectItem }: { documents: ProjectWorkspace['documents']; onSelectItem: (title: string) => void }) {
|
||||
return (
|
||||
<Card className="queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>最近笔记资料({notes.length})</Title>
|
||||
<Title heading={6}>最近笔记资料({documents.length})</Title>
|
||||
<Button type="text" size="mini">查看全部</Button>
|
||||
</div>
|
||||
<ProjectFileGrid items={notes.slice(0, 8)} onSelectItem={onSelectItem} compact />
|
||||
<ProjectFileGrid items={documents.slice(0, 8)} onSelectItem={onSelectItem} compact />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -106,6 +106,6 @@ export function ProjectTopbar({
|
||||
function searchTypeLabel(type: string) {
|
||||
if (type === 'project') return '项目'
|
||||
if (type === 'task') return '任务'
|
||||
if (type === 'note') return '笔记'
|
||||
if (type === 'document') return '笔记资料'
|
||||
return '未知'
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ReactElement } from 'react'
|
||||
export type Screen = 'login' | 'workbench'
|
||||
export type Theme = 'light' | 'dark'
|
||||
export type WorkbenchView = 'workspace' | 'workspace-explore' | 'project'
|
||||
export type ChannelKey = 'overview' | 'inbox' | 'tasks' | 'ai' | 'notes' | 'cron' | 'new-channel' | `custom:${string}`
|
||||
export type ChannelKey = 'overview' | 'inbox' | 'tasks' | 'ai' | 'documents' | 'cron' | 'new-channel' | `custom:${string}`
|
||||
|
||||
export type Project = {
|
||||
id: string
|
||||
@@ -66,13 +66,13 @@ export type AISession = {
|
||||
time: string
|
||||
}
|
||||
|
||||
export type NoteSource = {
|
||||
export type DocumentSummary = {
|
||||
id: string
|
||||
title: string
|
||||
type: string
|
||||
name: string
|
||||
kind: string
|
||||
extension: string
|
||||
mimeType: string
|
||||
updated: string
|
||||
owner: string
|
||||
source: string
|
||||
}
|
||||
|
||||
export type CronJob = {
|
||||
@@ -94,6 +94,6 @@ export type ProjectWorkspace = {
|
||||
inbox: InboxItem[]
|
||||
tasks: TaskItem[]
|
||||
aiSessions: AISession[]
|
||||
notes: NoteSource[]
|
||||
documents: DocumentSummary[]
|
||||
cronJobs: CronJob[]
|
||||
}
|
||||
|
||||
@@ -75,13 +75,13 @@ export function WorkspacePage({
|
||||
setDraft((value) => ({ ...value, tag: '' }))
|
||||
}, [draft.tag, tagOptions])
|
||||
const aiSessions = workspaces.flatMap((workspace) => workspace.aiSessions)
|
||||
const notes = workspaces.flatMap((workspace) => workspace.notes)
|
||||
const documents = workspaces.flatMap((workspace) => workspace.documents)
|
||||
const urgentProjects = projects.filter((project) => project.urgent).length
|
||||
const workspaceMetrics = [
|
||||
{ title: '项目总数', value: projects.length, delta: `${urgentProjects} 个高优先级`, icon: <IconDashboard />, color: 'arcoblue' },
|
||||
{ title: '未完成计划', value: unfinishedTasks.length, delta: '跨项目聚合', icon: <IconCheckCircleFill />, color: 'green' },
|
||||
{ title: 'AI 会话', value: aiSessions.length, delta: '项目内上下文', icon: <IconRobot />, color: 'purple' },
|
||||
{ title: '知识资料', value: notes.length, delta: '笔记与资料合计', icon: <IconFile />, color: 'cyan' },
|
||||
{ title: '知识资料', value: documents.length, delta: '笔记与资料合计', icon: <IconFile />, color: 'cyan' },
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
@@ -20,12 +20,17 @@ import type {
|
||||
DatasetSyncResultDTO,
|
||||
} from '../api/dataset'
|
||||
import type { ChannelKey, InboxConfirmationOutcome, Project, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types'
|
||||
import type { ApiSession } from '../api/client'
|
||||
import type { DocumentOpenIntent } from '../api/documents'
|
||||
|
||||
const { Content } = Layout
|
||||
|
||||
export function ProjectPage({
|
||||
activeView,
|
||||
activeWorkspace,
|
||||
session,
|
||||
documentIntent,
|
||||
onDocumentIntentConsumed,
|
||||
workspaces,
|
||||
activeChannel,
|
||||
activeTaskID,
|
||||
@@ -40,7 +45,6 @@ export function ProjectPage({
|
||||
onToggleTheme,
|
||||
onCreateProject,
|
||||
onCreateTask,
|
||||
onUploadSource,
|
||||
onCreateCronPlan,
|
||||
onUpdateWorkspaceTask,
|
||||
onUpdateProject,
|
||||
@@ -68,6 +72,9 @@ export function ProjectPage({
|
||||
}: {
|
||||
activeView: WorkbenchView
|
||||
activeWorkspace?: ProjectWorkspace
|
||||
session: ApiSession
|
||||
documentIntent: DocumentOpenIntent | null
|
||||
onDocumentIntentConsumed: (intentId: string) => void
|
||||
workspaces: ProjectWorkspace[]
|
||||
activeChannel: ChannelKey
|
||||
activeTaskID: string | null
|
||||
@@ -82,7 +89,6 @@ export function ProjectPage({
|
||||
onToggleTheme: () => void
|
||||
onCreateProject: () => void
|
||||
onCreateTask: () => void
|
||||
onUploadSource: () => void
|
||||
onCreateCronPlan: () => void
|
||||
onUpdateWorkspaceTask: (update: WorkspaceTaskUpdate) => void
|
||||
onUpdateProject: (update: ProjectSettingsUpdate) => Promise<void>
|
||||
@@ -179,12 +185,15 @@ export function ProjectPage({
|
||||
<ProjectChannelPage
|
||||
activeChannel={activeChannel}
|
||||
activeWorkspace={activeWorkspace}
|
||||
session={session}
|
||||
theme={theme}
|
||||
documentIntent={documentIntent}
|
||||
onDocumentIntentConsumed={onDocumentIntentConsumed}
|
||||
activeTaskID={activeTaskID}
|
||||
onSelectItem={onSelectItem}
|
||||
onOpenTask={(taskID) => onOpenTask(activeWorkspace.project, taskID)}
|
||||
onCloseTask={onCloseTask}
|
||||
onCreateTask={onCreateTask}
|
||||
onUploadSource={onUploadSource}
|
||||
onCreateCronPlan={onCreateCronPlan}
|
||||
onCreateProjectTag={onCreateProjectTag}
|
||||
onUpdateTask={updateActiveProjectTask}
|
||||
|
||||
Reference in New Issue
Block a user