feat(workspace): 新增工作区文件内容编辑功能,支持 Markdown 和 TXT 文件的保存与预览
This commit is contained in:
parent
981fd3170d
commit
596eeeaaac
@ -19,6 +19,9 @@ from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp
|
|||||||
from yuxi.utils.paths import WORKSPACE_DIR_NAME
|
from yuxi.utils.paths import WORKSPACE_DIR_NAME
|
||||||
|
|
||||||
|
|
||||||
|
EDITABLE_WORKSPACE_SUFFIXES = {".md", ".markdown", ".mdx", ".txt"}
|
||||||
|
|
||||||
|
|
||||||
def _workspace_root(user: User) -> Path:
|
def _workspace_root(user: User) -> Path:
|
||||||
try:
|
try:
|
||||||
root = _global_user_data_dir(str(user.id)) / WORKSPACE_DIR_NAME
|
root = _global_user_data_dir(str(user.id)) / WORKSPACE_DIR_NAME
|
||||||
@ -143,6 +146,37 @@ async def read_workspace_file_content(*, path: str, current_user: User) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def write_workspace_file_content(*, path: str, content: str, current_user: User) -> dict:
|
||||||
|
root = _workspace_root(current_user)
|
||||||
|
target = _resolve_workspace_path(current_user, path)
|
||||||
|
if not target.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="文件不存在")
|
||||||
|
if not target.is_file():
|
||||||
|
raise HTTPException(status_code=400, detail="当前路径是目录")
|
||||||
|
if target.suffix.lower() not in EDITABLE_WORKSPACE_SUFFIXES:
|
||||||
|
raise HTTPException(status_code=400, detail="当前文件类型不支持编辑")
|
||||||
|
|
||||||
|
raw_content = await asyncio.to_thread(target.read_bytes)
|
||||||
|
preview_type, supported, _message = _detect_preview_type(path, raw_content)
|
||||||
|
if preview_type not in {"markdown", "text"} or not supported:
|
||||||
|
raise HTTPException(status_code=400, detail="当前文件类型不支持编辑")
|
||||||
|
try:
|
||||||
|
raw_content.decode("utf-8")
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="当前文件不是 UTF-8 文本") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(target.write_text, content, encoding="utf-8")
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"path": _normalize_workspace_path(path).as_posix(),
|
||||||
|
"entry": _entry_for_path(root, target),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def delete_workspace_path(*, path: str, current_user: User) -> dict:
|
async def delete_workspace_path(*, path: str, current_user: User) -> dict:
|
||||||
root = _workspace_root(current_user)
|
root = _workspace_root(current_user)
|
||||||
target = _resolve_workspace_path(current_user, path)
|
target = _resolve_workspace_path(current_user, path)
|
||||||
|
|||||||
@ -11,6 +11,7 @@ from yuxi.services.workspace_service import (
|
|||||||
list_workspace_tree,
|
list_workspace_tree,
|
||||||
read_workspace_file_content,
|
read_workspace_file_content,
|
||||||
upload_workspace_file,
|
upload_workspace_file,
|
||||||
|
write_workspace_file_content,
|
||||||
)
|
)
|
||||||
from yuxi.storage.postgres.models_business import User
|
from yuxi.storage.postgres.models_business import User
|
||||||
|
|
||||||
@ -22,6 +23,11 @@ class CreateWorkspaceDirectoryRequest(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateWorkspaceFileContentRequest(BaseModel):
|
||||||
|
path: str
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
@workspace.get("/tree", response_model=dict)
|
@workspace.get("/tree", response_model=dict)
|
||||||
async def get_workspace_tree(
|
async def get_workspace_tree(
|
||||||
path: str = Query("/", description="工作区目录路径"),
|
path: str = Query("/", description="工作区目录路径"),
|
||||||
@ -38,6 +44,18 @@ async def get_workspace_file(
|
|||||||
return await read_workspace_file_content(path=path, current_user=current_user)
|
return await read_workspace_file_content(path=path, current_user=current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@workspace.put("/file", response_model=dict)
|
||||||
|
async def update_workspace_file(
|
||||||
|
payload: UpdateWorkspaceFileContentRequest,
|
||||||
|
current_user: User = Depends(get_required_user),
|
||||||
|
):
|
||||||
|
return await write_workspace_file_content(
|
||||||
|
path=payload.path,
|
||||||
|
content=payload.content,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@workspace.delete("/file", response_model=dict)
|
@workspace.delete("/file", response_model=dict)
|
||||||
async def delete_workspace_file_route(
|
async def delete_workspace_file_route(
|
||||||
path: str = Query(..., description="工作区文件或目录路径"),
|
path: str = Query(..., description="工作区文件或目录路径"),
|
||||||
|
|||||||
@ -3,6 +3,9 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from yuxi.agents.backends.sandbox import paths as workspace_paths
|
from yuxi.agents.backends.sandbox import paths as workspace_paths
|
||||||
from yuxi.services import workspace_service as svc
|
from yuxi.services import workspace_service as svc
|
||||||
|
|
||||||
@ -38,3 +41,76 @@ def test_workspace_root_keeps_existing_agents_prompt_file(tmp_path: Path, monkey
|
|||||||
|
|
||||||
assert root == tmp_path / "threads" / "shared" / "user-1" / "workspace"
|
assert root == tmp_path / "threads" / "shared" / "user-1" / "workspace"
|
||||||
assert agents_file.read_text(encoding="utf-8") == "保留已有内容"
|
assert agents_file.read_text(encoding="utf-8") == "保留已有内容"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_write_workspace_file_content_updates_markdown_file(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
|
||||||
|
user = SimpleNamespace(id="user-1")
|
||||||
|
root = svc._workspace_root(user)
|
||||||
|
target = root / "note.md"
|
||||||
|
target.write_text("旧内容", encoding="utf-8")
|
||||||
|
|
||||||
|
result = await svc.write_workspace_file_content(path="/note.md", content="# 新内容", current_user=user)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["path"] == "/note.md"
|
||||||
|
assert result["entry"]["path"] == "/note.md"
|
||||||
|
assert target.read_text(encoding="utf-8") == "# 新内容"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_write_workspace_file_content_updates_txt_file(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
|
||||||
|
user = SimpleNamespace(id="user-1")
|
||||||
|
root = svc._workspace_root(user)
|
||||||
|
target = root / "note.txt"
|
||||||
|
target.write_text("old", encoding="utf-8")
|
||||||
|
|
||||||
|
await svc.write_workspace_file_content(path="/note.txt", content="new", current_user=user)
|
||||||
|
|
||||||
|
assert target.read_text(encoding="utf-8") == "new"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_write_workspace_file_content_rejects_unsupported_suffix(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
|
||||||
|
user = SimpleNamespace(id="user-1")
|
||||||
|
root = svc._workspace_root(user)
|
||||||
|
target = root / "script.py"
|
||||||
|
target.write_text("print('hello')", encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await svc.write_workspace_file_content(path="/script.py", content="print('bye')", current_user=user)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
assert target.read_text(encoding="utf-8") == "print('hello')"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_write_workspace_file_content_rejects_directory_and_missing_file(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
|
||||||
|
user = SimpleNamespace(id="user-1")
|
||||||
|
svc._workspace_root(user)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as directory_error:
|
||||||
|
await svc.write_workspace_file_content(path="/agents/", content="x", current_user=user)
|
||||||
|
with pytest.raises(HTTPException) as missing_error:
|
||||||
|
await svc.write_workspace_file_content(path="/missing.md", content="x", current_user=user)
|
||||||
|
|
||||||
|
assert directory_error.value.status_code == 400
|
||||||
|
assert missing_error.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_write_workspace_file_content_blocks_path_traversal(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await svc.write_workspace_file_content(
|
||||||
|
path="/../outside.md",
|
||||||
|
content="x",
|
||||||
|
current_user=SimpleNamespace(id="user-1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 403
|
||||||
|
|||||||
@ -37,7 +37,7 @@
|
|||||||
### 0.6.2 开发记录
|
### 0.6.2 开发记录
|
||||||
|
|
||||||
<!-- 0.6.2 的内容请放在这里 -->
|
<!-- 0.6.2 的内容请放在这里 -->
|
||||||
- 新增个人工作区预览与管理:提供独立于对话 thread 的用户级 workspace API,并增加“工作区”页面,用于浏览个人 workspace 文件、预览 Markdown/文本/代码/图片/PDF;支持新建文件夹、上传文件、下载文件、删除文件/文件夹和多选删除;知识库与团队空间入口先展示到占位层级;默认创建 `agents/AGENTS.md`,并在 Agent 执行时将其内容追加到系统提示词。
|
- 新增个人工作区预览与管理:提供独立于对话 thread 的用户级 workspace API,并增加“工作区”页面,用于浏览个人 workspace 文件、预览 Markdown/文本/代码/图片/PDF;支持新建文件夹、上传文件、下载文件、删除文件/文件夹和多选删除;工作区预览支持 Markdown/TXT 在右侧预览框内切换编辑并保存,其他格式和非工作区预览默认只读;知识库与团队空间入口先展示到占位层级;默认创建 `agents/AGENTS.md`,并在 Agent 执行时将其内容追加到系统提示词。
|
||||||
- 扩展管理界面交互逻辑重构:将 MCP / Subagents / Skills 三个标签页从「左侧边栏 + 右侧详情面板」布局重构为「卡片式网格布局 + 路由跳转二级页面」布局,工具标签页改为卡片网格布局 + 弹窗详情(保持弹窗内容不变)。新增共享组件 `ExtensionCard`、`ExtensionCardGrid`、`ExtensionToolbar`、`ExtensionDetailLayout`,详情页(`McpDetailView`、`SubagentDetailView`、`SkillDetailView`)使用居中宽度限制,路由规划为 `/extensions/mcp/:name`、`/extensions/subagent/:name`、`/extensions/skill/:slug`。
|
- 扩展管理界面交互逻辑重构:将 MCP / Subagents / Skills 三个标签页从「左侧边栏 + 右侧详情面板」布局重构为「卡片式网格布局 + 路由跳转二级页面」布局,工具标签页改为卡片网格布局 + 弹窗详情(保持弹窗内容不变)。新增共享组件 `ExtensionCard`、`ExtensionCardGrid`、`ExtensionToolbar`、`ExtensionDetailLayout`,详情页(`McpDetailView`、`SubagentDetailView`、`SkillDetailView`)使用居中宽度限制,路由规划为 `/extensions/mcp/:name`、`/extensions/subagent/:name`、`/extensions/skill/:slug`。
|
||||||
- 统一卡片样式:`ExtensionCard` 新增 `tags` prop 支持传入 `[{label, color}]` 数组,内部使用 `<a-tag bordered=false size=small>` 渲染,与知识库卡片标签风格统一;知识库列表页 `DataBaseView` 改用 `ExtensionCard` + `ExtensionCardGrid` 替代原有自定义卡片,移除冗余 card 样式。
|
- 统一卡片样式:`ExtensionCard` 新增 `tags` prop 支持传入 `[{label, color}]` 数组,内部使用 `<a-tag bordered=false size=small>` 渲染,与知识库卡片标签风格统一;知识库列表页 `DataBaseView` 改用 `ExtensionCard` + `ExtensionCardGrid` 替代原有自定义卡片,移除冗余 card 样式。
|
||||||
- 调整应用主导航:`AppLayout` 从默认窄栏升级为默认展开的侧边栏,保留折叠态图标导航;侧边栏样式收敛为 14px 文本 + 18px 图标的标准紧凑密度,并统一导航项、任务中心、GitHub、用户信息的图标与文字对齐。折叠态改为仅通过显式按钮展开,避免空白区域误触发。
|
- 调整应用主导航:`AppLayout` 从默认窄栏升级为默认展开的侧边栏,保留折叠态图标导航;侧边栏样式收敛为 14px 文本 + 18px 图标的标准紧凑密度,并统一导航项、任务中心、GitHub、用户信息的图标与文字对齐。折叠态改为仅通过显式按钮展开,避免空白区域误触发。
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { apiDelete, apiGet, apiPost } from './base'
|
import { apiDelete, apiGet, apiPost, apiPut } from './base'
|
||||||
|
|
||||||
const buildQuery = (params) => {
|
const buildQuery = (params) => {
|
||||||
const query = new URLSearchParams()
|
const query = new URLSearchParams()
|
||||||
@ -20,6 +20,10 @@ export const getWorkspaceFileContent = (path) => {
|
|||||||
return apiGet(`/api/workspace/file?${query}`)
|
return apiGet(`/api/workspace/file?${query}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const saveWorkspaceFileContent = (path, content) => {
|
||||||
|
return apiPut('/api/workspace/file', { path, content })
|
||||||
|
}
|
||||||
|
|
||||||
export const deleteWorkspacePath = (path) => {
|
export const deleteWorkspacePath = (path) => {
|
||||||
const query = buildQuery({ path })
|
const query = buildQuery({ path })
|
||||||
return apiDelete(`/api/workspace/file?${query}`)
|
return apiDelete(`/api/workspace/file?${query}`)
|
||||||
|
|||||||
@ -9,6 +9,42 @@
|
|||||||
<span class="file-path-title">{{ filePath }}</span>
|
<span class="file-path-title">{{ filePath }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
|
<div v-if="canEdit" class="preview-mode-switch">
|
||||||
|
<button
|
||||||
|
class="preview-mode-btn"
|
||||||
|
:class="{ active: editMode === 'preview' }"
|
||||||
|
@click="editMode = 'preview'"
|
||||||
|
title="预览"
|
||||||
|
>
|
||||||
|
<Eye :size="16" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="preview-mode-btn"
|
||||||
|
:class="{ active: editMode === 'edit' }"
|
||||||
|
@click="editMode = 'edit'"
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<Pencil :size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="canEdit && editMode === 'edit'"
|
||||||
|
class="modal-action-btn"
|
||||||
|
:disabled="saving || !draftChanged"
|
||||||
|
@click="requestSave"
|
||||||
|
title="保存"
|
||||||
|
>
|
||||||
|
<Save :size="18" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canEdit && editMode === 'edit'"
|
||||||
|
class="modal-action-btn"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="cancelEdit"
|
||||||
|
title="取消编辑"
|
||||||
|
>
|
||||||
|
<X :size="18" />
|
||||||
|
</button>
|
||||||
<div v-if="isHtmlFile" class="preview-mode-switch">
|
<div v-if="isHtmlFile" class="preview-mode-switch">
|
||||||
<button
|
<button
|
||||||
class="preview-mode-btn"
|
class="preview-mode-btn"
|
||||||
@ -50,7 +86,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="file-content" :class="contentClass">
|
<div class="file-content" :class="contentClass">
|
||||||
<template v-if="file?.previewType === 'image' && file?.previewUrl">
|
<template v-if="canEdit && editMode === 'edit'">
|
||||||
|
<textarea
|
||||||
|
v-model="draftContent"
|
||||||
|
class="file-edit-textarea"
|
||||||
|
:disabled="saving"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="file?.previewType === 'image' && file?.previewUrl">
|
||||||
<div class="image-preview-wrapper">
|
<div class="image-preview-wrapper">
|
||||||
<img :src="file.previewUrl" :alt="filePath" class="image-preview" />
|
<img :src="file.previewUrl" :alt="filePath" class="image-preview" />
|
||||||
</div>
|
</div>
|
||||||
@ -190,13 +234,20 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||||
import { Code2, Download, Globe, Maximize2, X } from 'lucide-vue-next'
|
import { Code2, Download, Eye, Globe, Maximize2, Pencil, Save, X } from 'lucide-vue-next'
|
||||||
import { MdPreview } from 'md-editor-v3'
|
import { MdPreview } from 'md-editor-v3'
|
||||||
import hljs from 'highlight.js/lib/common'
|
import hljs from 'highlight.js/lib/common'
|
||||||
import 'md-editor-v3/lib/preview.css'
|
import 'md-editor-v3/lib/preview.css'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
import { getFileIcon, getFileIconColor } from '@/utils/file_utils'
|
import { getFileIcon, getFileIconColor } from '@/utils/file_utils'
|
||||||
import { getCodeLanguageByPath, isHtmlPreview, isMarkdownPreview } from '@/utils/file_preview'
|
import {
|
||||||
|
getCodeLanguageByPath,
|
||||||
|
getPreviewFileExtension,
|
||||||
|
isHtmlPreview,
|
||||||
|
isMarkdownPreview
|
||||||
|
} from '@/utils/file_preview'
|
||||||
|
|
||||||
|
const EDITABLE_EXTENSIONS = new Set(['.md', '.markdown', '.mdx', '.txt'])
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
file: {
|
file: {
|
||||||
@ -227,6 +278,14 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
},
|
},
|
||||||
|
editable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
saving: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
containerClass: {
|
containerClass: {
|
||||||
type: [String, Array, Object],
|
type: [String, Array, Object],
|
||||||
default: ''
|
default: ''
|
||||||
@ -237,15 +296,26 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['close', 'download'])
|
const emit = defineEmits(['close', 'download', 'save'])
|
||||||
|
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
const theme = computed(() => (themeStore.isDark ? 'dark' : 'light'))
|
const theme = computed(() => (themeStore.isDark ? 'dark' : 'light'))
|
||||||
const htmlPreviewMode = ref('render')
|
const htmlPreviewMode = ref('render')
|
||||||
|
const editMode = ref('preview')
|
||||||
|
const draftContent = ref('')
|
||||||
const fullscreenPreviewVisible = ref(false)
|
const fullscreenPreviewVisible = ref(false)
|
||||||
const htmlPreviewRenderKey = ref(0)
|
const htmlPreviewRenderKey = ref(0)
|
||||||
|
|
||||||
const isMarkdown = computed(() => isMarkdownPreview(props.filePath, props.file?.previewType))
|
const isMarkdown = computed(() => isMarkdownPreview(props.filePath, props.file?.previewType))
|
||||||
|
const canEdit = computed(
|
||||||
|
() =>
|
||||||
|
props.editable &&
|
||||||
|
props.file?.supported !== false &&
|
||||||
|
typeof props.file?.content === 'string' &&
|
||||||
|
EDITABLE_EXTENSIONS.has(getPreviewFileExtension(props.filePath))
|
||||||
|
)
|
||||||
|
const savedContent = computed(() => formatContent(props.file?.content))
|
||||||
|
const draftChanged = computed(() => draftContent.value !== savedContent.value)
|
||||||
const isHtmlFile = computed(
|
const isHtmlFile = computed(
|
||||||
() =>
|
() =>
|
||||||
props.file?.previewType === 'text' &&
|
props.file?.previewType === 'text' &&
|
||||||
@ -293,6 +363,20 @@ const formatContent = (content) => {
|
|||||||
return String(content)
|
return String(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const syncDraftContent = () => {
|
||||||
|
draftContent.value = savedContent.value
|
||||||
|
editMode.value = 'preview'
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestSave = () => {
|
||||||
|
if (!canEdit.value || props.saving) return
|
||||||
|
emit('save', draftContent.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
syncDraftContent()
|
||||||
|
}
|
||||||
|
|
||||||
const openFullscreenPreview = () => {
|
const openFullscreenPreview = () => {
|
||||||
if (!props.file) return
|
if (!props.file) return
|
||||||
fullscreenPreviewVisible.value = true
|
fullscreenPreviewVisible.value = true
|
||||||
@ -309,6 +393,8 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch([() => props.filePath, () => props.file?.content, canEdit], syncDraftContent, { immediate: true })
|
||||||
|
|
||||||
watch([() => props.filePath, () => props.file?.previewType, () => props.file?.content], () => {
|
watch([() => props.filePath, () => props.file?.previewType, () => props.file?.content], () => {
|
||||||
if (isHtmlFile.value) {
|
if (isHtmlFile.value) {
|
||||||
htmlPreviewRenderKey.value += 1
|
htmlPreviewRenderKey.value += 1
|
||||||
@ -381,8 +467,8 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.modal-action-btn,
|
.modal-action-btn,
|
||||||
.preview-mode-btn {
|
.preview-mode-btn {
|
||||||
width: 32px;
|
width: 24px;
|
||||||
height: 32px;
|
height: 24px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@ -401,6 +487,18 @@ onUnmounted(() => {
|
|||||||
color: var(--gray-900);
|
color: var(--gray-900);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-action-btn:disabled,
|
||||||
|
.preview-mode-btn:disabled {
|
||||||
|
color: var(--gray-300);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-action-btn:disabled:hover,
|
||||||
|
.preview-mode-btn:disabled:hover {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--gray-300);
|
||||||
|
}
|
||||||
|
|
||||||
.preview-mode-btn.active {
|
.preview-mode-btn.active {
|
||||||
background: var(--gray-0);
|
background: var(--gray-0);
|
||||||
color: var(--gray-900);
|
color: var(--gray-900);
|
||||||
@ -436,6 +534,25 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-edit-textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: calc(80vh - 40px);
|
||||||
|
padding: 12px;
|
||||||
|
border: 0;
|
||||||
|
outline: none;
|
||||||
|
resize: none;
|
||||||
|
background: var(--gray-0);
|
||||||
|
color: var(--gray-1000);
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-edit-textarea:disabled {
|
||||||
|
color: var(--gray-600);
|
||||||
|
background: var(--gray-25);
|
||||||
|
}
|
||||||
|
|
||||||
.file-content pre,
|
.file-content pre,
|
||||||
.file-content-pre {
|
.file-content-pre {
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
|||||||
@ -8,9 +8,12 @@
|
|||||||
:show-close="true"
|
:show-close="true"
|
||||||
:show-fullscreen="true"
|
:show-fullscreen="true"
|
||||||
:full-height="true"
|
:full-height="true"
|
||||||
|
:editable="editable"
|
||||||
|
:saving="saving"
|
||||||
container-class="workspace-preview-container"
|
container-class="workspace-preview-container"
|
||||||
content-class="workspace-preview-content"
|
content-class="workspace-preview-content"
|
||||||
@close="$emit('close')"
|
@close="$emit('close')"
|
||||||
|
@save="$emit('save', $event)"
|
||||||
/>
|
/>
|
||||||
<div v-else-if="loading" class="preview-state">
|
<div v-else-if="loading" class="preview-state">
|
||||||
<a-spin />
|
<a-spin />
|
||||||
@ -19,7 +22,7 @@
|
|||||||
<div v-else class="preview-empty">
|
<div v-else class="preview-empty">
|
||||||
<FileSearch :size="28" />
|
<FileSearch :size="28" />
|
||||||
<h3>选择文件以预览</h3>
|
<h3>选择文件以预览</h3>
|
||||||
<p>支持 Markdown、文本、代码、图片与 PDF 的只读预览。</p>
|
<p>支持 Markdown、TXT 编辑,其他格式保持只读预览。</p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
@ -31,10 +34,12 @@ import AgentFilePreview from '@/components/AgentFilePreview.vue'
|
|||||||
defineProps({
|
defineProps({
|
||||||
file: { type: Object, default: null },
|
file: { type: Object, default: null },
|
||||||
filePath: { type: String, default: '' },
|
filePath: { type: String, default: '' },
|
||||||
loading: { type: Boolean, default: false }
|
loading: { type: Boolean, default: false },
|
||||||
|
editable: { type: Boolean, default: false },
|
||||||
|
saving: { type: Boolean, default: false }
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['close'])
|
defineEmits(['close', 'save'])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
|
|||||||
@ -79,7 +79,10 @@
|
|||||||
:file="previewFile"
|
:file="previewFile"
|
||||||
:file-path="selectedEntry?.path || ''"
|
:file-path="selectedEntry?.path || ''"
|
||||||
:loading="loadingPreview"
|
:loading="loadingPreview"
|
||||||
|
:editable="true"
|
||||||
|
:saving="savingPreviewFile"
|
||||||
@close="closePreview"
|
@close="closePreview"
|
||||||
|
@save="handleSavePreviewFile"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -123,7 +126,10 @@
|
|||||||
:showClose="true"
|
:showClose="true"
|
||||||
:showDownload="false"
|
:showDownload="false"
|
||||||
:showFullscreen="true"
|
:showFullscreen="true"
|
||||||
|
:editable="activeSourceKey === 'personal'"
|
||||||
|
:saving="savingPreviewFile"
|
||||||
@close="closePreview"
|
@close="closePreview"
|
||||||
|
@save="handleSavePreviewFile"
|
||||||
/>
|
/>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
</div>
|
</div>
|
||||||
@ -145,6 +151,7 @@ import {
|
|||||||
downloadWorkspaceFile,
|
downloadWorkspaceFile,
|
||||||
getWorkspaceFileContent,
|
getWorkspaceFileContent,
|
||||||
getWorkspaceTree,
|
getWorkspaceTree,
|
||||||
|
saveWorkspaceFileContent,
|
||||||
uploadWorkspaceFile
|
uploadWorkspaceFile
|
||||||
} from '@/apis/workspace_api'
|
} from '@/apis/workspace_api'
|
||||||
|
|
||||||
@ -159,6 +166,7 @@ const previewObjectUrl = ref('')
|
|||||||
const previewModalVisible = ref(false)
|
const previewModalVisible = ref(false)
|
||||||
const loadingTree = ref(false)
|
const loadingTree = ref(false)
|
||||||
const loadingPreview = ref(false)
|
const loadingPreview = ref(false)
|
||||||
|
const savingPreviewFile = ref(false)
|
||||||
const loadingDatabases = ref(false)
|
const loadingDatabases = ref(false)
|
||||||
const databases = ref([])
|
const databases = ref([])
|
||||||
const selectedDatabase = ref(null)
|
const selectedDatabase = ref(null)
|
||||||
@ -339,6 +347,29 @@ const closePreview = () => {
|
|||||||
revokePreviewObjectUrl()
|
revokePreviewObjectUrl()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSavePreviewFile = async (content) => {
|
||||||
|
if (!selectedEntry.value?.path || savingPreviewFile.value) return
|
||||||
|
|
||||||
|
savingPreviewFile.value = true
|
||||||
|
try {
|
||||||
|
const response = await saveWorkspaceFileContent(selectedEntry.value.path, content)
|
||||||
|
if (response.entry) {
|
||||||
|
selectedEntry.value = response.entry
|
||||||
|
}
|
||||||
|
previewFile.value = {
|
||||||
|
...previewFile.value,
|
||||||
|
content
|
||||||
|
}
|
||||||
|
await loadWorkspaceEntries(currentPath.value)
|
||||||
|
message.success('文件保存成功')
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('保存工作区文件失败:', error)
|
||||||
|
message.error(error?.message || '文件保存失败')
|
||||||
|
} finally {
|
||||||
|
savingPreviewFile.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const openCreateDirectoryModal = () => {
|
const openCreateDirectoryModal = () => {
|
||||||
if (activeSourceKey.value !== 'personal') return
|
if (activeSourceKey.value !== 'personal') return
|
||||||
newDirectoryName.value = ''
|
newDirectoryName.value = ''
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user