feat(filesystem): 增强文件删除功能,支持递归删除目录并添加保护路径检查
This commit is contained in:
parent
061067aa89
commit
9bc86c070c
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import io
|
import io
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import shutil
|
||||||
from pathlib import PurePosixPath
|
from pathlib import PurePosixPath
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
@ -24,6 +25,7 @@ from yuxi.agents.middlewares.skills_middleware import normalize_selected_skills
|
|||||||
from yuxi.services.filesystem_service import _resolve_filesystem_state
|
from yuxi.services.filesystem_service import _resolve_filesystem_state
|
||||||
from yuxi.storage.postgres.models_business import User
|
from yuxi.storage.postgres.models_business import User
|
||||||
from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp
|
from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp
|
||||||
|
from yuxi.utils.paths import VIRTUAL_PATH_OUTPUTS, VIRTUAL_PATH_UPLOADS, VIRTUAL_PATH_WORKSPACE
|
||||||
|
|
||||||
_MARKDOWN_EXTENSIONS = frozenset({".md", ".markdown", ".mdx"})
|
_MARKDOWN_EXTENSIONS = frozenset({".md", ".markdown", ".mdx"})
|
||||||
_PDF_EXTENSIONS = frozenset({".pdf"})
|
_PDF_EXTENSIONS = frozenset({".pdf"})
|
||||||
@ -79,6 +81,13 @@ _BINARY_SIGNATURES = (
|
|||||||
b"GIF89a",
|
b"GIF89a",
|
||||||
b"RIFF",
|
b"RIFF",
|
||||||
)
|
)
|
||||||
|
_PROTECTED_USER_DATA_ROOTS = frozenset(
|
||||||
|
{
|
||||||
|
VIRTUAL_PATH_WORKSPACE,
|
||||||
|
VIRTUAL_PATH_UPLOADS,
|
||||||
|
VIRTUAL_PATH_OUTPUTS,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _detect_preview_type(path: str, raw_content: bytes) -> tuple[str, bool, str | None]:
|
def _detect_preview_type(path: str, raw_content: bytes) -> tuple[str, bool, str | None]:
|
||||||
@ -532,14 +541,17 @@ async def delete_viewer_file(
|
|||||||
|
|
||||||
if not _is_user_data_path(normalized_path):
|
if not _is_user_data_path(normalized_path):
|
||||||
raise HTTPException(status_code=400, detail="当前路径不支持删除")
|
raise HTTPException(status_code=400, detail="当前路径不支持删除")
|
||||||
|
if normalized_path in _PROTECTED_USER_DATA_ROOTS:
|
||||||
|
raise HTTPException(status_code=400, detail="当前目录不允许删除")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
actual_path = resolve_virtual_path(thread_id, normalized_path)
|
actual_path = resolve_virtual_path(thread_id, normalized_path)
|
||||||
if not actual_path.exists():
|
if not actual_path.exists():
|
||||||
raise HTTPException(status_code=404, detail="文件不存在")
|
raise HTTPException(status_code=404, detail="文件不存在")
|
||||||
if actual_path.is_dir():
|
if actual_path.is_dir():
|
||||||
raise HTTPException(status_code=400, detail="当前路径是目录")
|
await asyncio.to_thread(shutil.rmtree, actual_path)
|
||||||
await asyncio.to_thread(actual_path.unlink)
|
else:
|
||||||
|
await asyncio.to_thread(actual_path.unlink)
|
||||||
except PermissionError as e:
|
except PermissionError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@ -80,6 +80,23 @@ async def _download(
|
|||||||
return response.headers.get("content-disposition", ""), response.content
|
return response.headers.get("content-disposition", ""), response.content
|
||||||
|
|
||||||
|
|
||||||
|
async def _delete(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
headers: dict[str, str],
|
||||||
|
*,
|
||||||
|
agent_id: str,
|
||||||
|
thread_id: str,
|
||||||
|
path: str,
|
||||||
|
) -> dict:
|
||||||
|
response = await client.delete(
|
||||||
|
"/api/viewer/filesystem/file",
|
||||||
|
params={"thread_id": thread_id, "path": path, "agent_id": agent_id},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
return dict(response.json())
|
||||||
|
|
||||||
|
|
||||||
async def test_viewer_filesystem_e2e_respects_workspace_sharing_and_thread_local_uploads(
|
async def test_viewer_filesystem_e2e_respects_workspace_sharing_and_thread_local_uploads(
|
||||||
e2e_client: httpx.AsyncClient,
|
e2e_client: httpx.AsyncClient,
|
||||||
e2e_headers: dict[str, str],
|
e2e_headers: dict[str, str],
|
||||||
@ -193,3 +210,40 @@ async def test_viewer_filesystem_e2e_respects_workspace_sharing_and_thread_local
|
|||||||
)
|
)
|
||||||
assert "result.txt" in content_disposition, content_disposition
|
assert "result.txt" in content_disposition, content_disposition
|
||||||
assert payload == b"viewer-output\n", payload
|
assert payload == b"viewer-output\n", payload
|
||||||
|
|
||||||
|
|
||||||
|
async def test_viewer_filesystem_e2e_deletes_workspace_directory_recursively(
|
||||||
|
e2e_client: httpx.AsyncClient,
|
||||||
|
e2e_headers: dict[str, str],
|
||||||
|
e2e_agent_context: dict[str, str | int],
|
||||||
|
):
|
||||||
|
agent_id = str(e2e_agent_context["agent_id"])
|
||||||
|
thread_id = await _create_thread(e2e_client, e2e_headers, agent_id)
|
||||||
|
|
||||||
|
ensure_thread_dirs(thread_id)
|
||||||
|
target_dir = sandbox_workspace_dir(thread_id) / "delete-dir"
|
||||||
|
nested_dir = target_dir / "deep"
|
||||||
|
nested_dir.mkdir(parents=True)
|
||||||
|
(nested_dir / "artifact.txt").write_text("delete me\n", encoding="utf-8")
|
||||||
|
|
||||||
|
delete_payload = await _delete(
|
||||||
|
e2e_client,
|
||||||
|
e2e_headers,
|
||||||
|
agent_id=agent_id,
|
||||||
|
thread_id=thread_id,
|
||||||
|
path="/home/gem/user-data/workspace/delete-dir",
|
||||||
|
)
|
||||||
|
assert delete_payload.get("success") is True, delete_payload
|
||||||
|
assert not target_dir.exists()
|
||||||
|
|
||||||
|
workspace_paths = {
|
||||||
|
str(entry.get("path", ""))
|
||||||
|
for entry in await _tree(
|
||||||
|
e2e_client,
|
||||||
|
e2e_headers,
|
||||||
|
agent_id=agent_id,
|
||||||
|
thread_id=thread_id,
|
||||||
|
path="/home/gem/user-data/workspace",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assert "/home/gem/user-data/workspace/delete-dir/" not in workspace_paths, sorted(workspace_paths)
|
||||||
|
|||||||
@ -362,6 +362,104 @@ async def test_viewer_delete_removes_user_data_file(test_client, standard_user):
|
|||||||
assert file_path not in paths
|
assert file_path not in paths
|
||||||
|
|
||||||
|
|
||||||
|
async def test_viewer_delete_removes_empty_user_data_directory(test_client, standard_user):
|
||||||
|
headers = standard_user["headers"]
|
||||||
|
thread_id = await _create_thread_for_user(test_client, headers)
|
||||||
|
|
||||||
|
ensure_thread_dirs(thread_id)
|
||||||
|
actual_path = sandbox_workspace_dir(thread_id) / "empty-folder"
|
||||||
|
actual_path.mkdir()
|
||||||
|
dir_path = virtual_path_for_thread_file(thread_id, actual_path)
|
||||||
|
|
||||||
|
delete_response = await test_client.delete(
|
||||||
|
"/api/viewer/filesystem/file",
|
||||||
|
params={"thread_id": thread_id, "path": dir_path},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert delete_response.status_code == 200, delete_response.text
|
||||||
|
assert delete_response.json()["success"] is True
|
||||||
|
assert not actual_path.exists()
|
||||||
|
|
||||||
|
tree_response = await test_client.get(
|
||||||
|
"/api/viewer/filesystem/tree",
|
||||||
|
params={"thread_id": thread_id, "path": "/home/gem/user-data/workspace"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert tree_response.status_code == 200, tree_response.text
|
||||||
|
paths = {entry.get("path") for entry in tree_response.json().get("entries", [])}
|
||||||
|
assert f"{dir_path}/" not in paths
|
||||||
|
|
||||||
|
|
||||||
|
async def test_viewer_delete_recursively_removes_user_data_directory(test_client, standard_user):
|
||||||
|
headers = standard_user["headers"]
|
||||||
|
thread_id = await _create_thread_for_user(test_client, headers)
|
||||||
|
|
||||||
|
ensure_thread_dirs(thread_id)
|
||||||
|
actual_path = sandbox_workspace_dir(thread_id) / "nested-folder"
|
||||||
|
nested_dir = actual_path / "child"
|
||||||
|
nested_dir.mkdir(parents=True)
|
||||||
|
nested_file = nested_dir / "notes.txt"
|
||||||
|
nested_file.write_text("remove recursively", encoding="utf-8")
|
||||||
|
dir_path = virtual_path_for_thread_file(thread_id, actual_path)
|
||||||
|
|
||||||
|
delete_response = await test_client.delete(
|
||||||
|
"/api/viewer/filesystem/file",
|
||||||
|
params={"thread_id": thread_id, "path": dir_path},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert delete_response.status_code == 200, delete_response.text
|
||||||
|
assert delete_response.json()["success"] is True
|
||||||
|
assert not actual_path.exists()
|
||||||
|
assert not nested_file.exists()
|
||||||
|
|
||||||
|
tree_response = await test_client.get(
|
||||||
|
"/api/viewer/filesystem/tree",
|
||||||
|
params={"thread_id": thread_id, "path": "/home/gem/user-data/workspace"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert tree_response.status_code == 200, tree_response.text
|
||||||
|
paths = {entry.get("path") for entry in tree_response.json().get("entries", [])}
|
||||||
|
assert f"{dir_path}/" not in paths
|
||||||
|
|
||||||
|
|
||||||
|
async def test_viewer_delete_rejects_readonly_namespace_directory(test_client, standard_user):
|
||||||
|
headers = standard_user["headers"]
|
||||||
|
thread_id = await _create_thread_for_user(test_client, headers)
|
||||||
|
|
||||||
|
response = await test_client.delete(
|
||||||
|
"/api/viewer/filesystem/file",
|
||||||
|
params={"thread_id": thread_id, "path": "/home/gem/skills"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 400, response.text
|
||||||
|
assert response.json()["detail"] == "当前路径不支持删除"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"protected_path",
|
||||||
|
[
|
||||||
|
"/home/gem/user-data/workspace",
|
||||||
|
"/home/gem/user-data/uploads",
|
||||||
|
"/home/gem/user-data/outputs",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_viewer_delete_rejects_protected_user_data_root_directories(
|
||||||
|
test_client, standard_user, protected_path: str
|
||||||
|
):
|
||||||
|
headers = standard_user["headers"]
|
||||||
|
thread_id = await _create_thread_for_user(test_client, headers)
|
||||||
|
|
||||||
|
ensure_thread_dirs(thread_id)
|
||||||
|
|
||||||
|
response = await test_client.delete(
|
||||||
|
"/api/viewer/filesystem/file",
|
||||||
|
params={"thread_id": thread_id, "path": protected_path},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 400, response.text
|
||||||
|
assert response.json()["detail"] == "当前目录不允许删除"
|
||||||
|
|
||||||
|
|
||||||
async def test_viewer_tree_root_hides_kbs_namespace_when_no_database_is_visible(test_client, standard_user):
|
async def test_viewer_tree_root_hides_kbs_namespace_when_no_database_is_visible(test_client, standard_user):
|
||||||
headers = standard_user["headers"]
|
headers = standard_user["headers"]
|
||||||
thread_id = await _create_thread_for_user(test_client, headers)
|
thread_id = await _create_thread_for_user(test_client, headers)
|
||||||
|
|||||||
@ -58,9 +58,11 @@
|
|||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|
||||||
|
- 收敛“状态工作台”自动弹出规则:前端不再因为共享 `workspace` 或文件系统天然存在内容而默认展开,改为仅在 `/home/gem/user-data/uploads` 或 `/home/gem/user-data/outputs` 下检测到实际文件时自动弹出;手动打开、关闭、刷新和伸缩交互保持不变
|
||||||
- 调整智能体 todo 展示语义:待办状态不再作为 `capabilities` 前端开关,而是直接根据运行态 `agent_state.todos` 渲染;同时将 todo 入口从 Agent Panel 移到输入框内的轻量浮层,并让右侧“状态工作台”收敛为文件系统视图,输入框按钮文案同步由“状态”调整为“文件”
|
- 调整智能体 todo 展示语义:待办状态不再作为 `capabilities` 前端开关,而是直接根据运行态 `agent_state.todos` 渲染;同时将 todo 入口从 Agent Panel 移到输入框内的轻量浮层,并让右侧“状态工作台”收敛为文件系统视图,输入框按钮文案同步由“状态”调整为“文件”
|
||||||
- 优化 Agent 输入框 mention 行为:在保留附件 mention 的同时,将共享 `workspace` 文件纳入候选范围;并将 `@` 空查询时的候选列表改为空,仅在继续输入后再执行筛选,避免工作区文件过多时直接铺满下拉面板
|
- 优化 Agent 输入框 mention 行为:在保留附件 mention 的同时,将共享 `workspace` 文件纳入候选范围;并将 `@` 空查询时的候选列表改为空,仅在继续输入后再执行筛选,避免工作区文件过多时直接铺满下拉面板
|
||||||
- 为前端工作台文件树补齐文件删除能力:`/api/viewer/filesystem/file` 新增删除接口,`AgentPanel` 文件节点新增删除按钮与确认交互,删除后会同步刷新树与预览状态
|
- 为前端工作台文件树补齐文件删除能力:`/api/viewer/filesystem/file` 新增删除接口,`AgentPanel` 文件节点新增删除按钮与确认交互,删除后会同步刷新树与预览状态
|
||||||
|
- 扩展 Agent Panel 状态工作台删除能力:继续复用 `DELETE /api/viewer/filesystem/file`,在保持接口不变的前提下支持删除文件夹;空目录与非空目录现在都会递归删除,`workspace` 下目录也可直接清理,前端目录节点同步新增删除入口与对应确认文案
|
||||||
- 调整前端工作台文件预览交互:恢复默认侧边/弹窗预览,并新增显式“全屏预览”入口;全屏模式下由预览内容直接覆盖整页,仅保留右上角悬浮关闭按钮;同时修复 HTML 文件首次在弹窗中预览偶现白屏的问题,改为在内容更新后强制重建 `iframe`
|
- 调整前端工作台文件预览交互:恢复默认侧边/弹窗预览,并新增显式“全屏预览”入口;全屏模式下由预览内容直接覆盖整页,仅保留右上角悬浮关闭按钮;同时修复 HTML 文件首次在弹窗中预览偶现白屏的问题,改为在内容更新后强制重建 `iframe`
|
||||||
- 统一 Agent Panel 文件预览与消息区交付物预览组件:两处改为复用同一套 `AgentFilePreview` 预览实现,并为交付物预览补齐与工作台一致的“全屏预览”入口
|
- 统一 Agent Panel 文件预览与消息区交付物预览组件:两处改为复用同一套 `AgentFilePreview` 预览实现,并为交付物预览补齐与工作台一致的“全屏预览”入口
|
||||||
- 兼容旧版已安装的内置 `reporter` 技能记录:`update_builtin_skill` 现在会识别由 `system` 或 `builtin-system` 管理的历史记录,避免更新时误报“技能 `reporter` 不是内置 skill”
|
- 兼容旧版已安装的内置 `reporter` 技能记录:`update_builtin_skill` 现在会识别由 `system` 或 `builtin-system` 管理的历史记录,避免更新时误报“技能 `reporter` 不是内置 skill”
|
||||||
|
|||||||
@ -55,8 +55,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #actions="{ node }">
|
<template #actions="{ node }">
|
||||||
<div v-if="node.isLeaf" class="node-actions-container">
|
<div class="node-actions-container">
|
||||||
<button
|
<button
|
||||||
|
v-if="node.isLeaf"
|
||||||
class="tree-action-btn tree-download-btn"
|
class="tree-action-btn tree-download-btn"
|
||||||
@click.stop="downloadFile(node.fileData)"
|
@click.stop="downloadFile(node.fileData)"
|
||||||
title="下载文件"
|
title="下载文件"
|
||||||
@ -66,8 +67,8 @@
|
|||||||
<button
|
<button
|
||||||
class="tree-action-btn tree-delete-btn"
|
class="tree-action-btn tree-delete-btn"
|
||||||
:disabled="deletingPaths.has(node.key)"
|
:disabled="deletingPaths.has(node.key)"
|
||||||
@click.stop="confirmDeleteFile(node)"
|
@click.stop="confirmDeleteNode(node)"
|
||||||
title="删除文件"
|
:title="node.isLeaf ? '删除文件' : '删除文件夹'"
|
||||||
>
|
>
|
||||||
<Trash2 :size="14" />
|
<Trash2 :size="14" />
|
||||||
</button>
|
</button>
|
||||||
@ -127,7 +128,15 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { ChevronsDownUp, ChevronsUpDown, Download, FolderCode, RefreshCw, Trash2, X } from 'lucide-vue-next'
|
import {
|
||||||
|
ChevronsDownUp,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Download,
|
||||||
|
FolderCode,
|
||||||
|
RefreshCw,
|
||||||
|
Trash2,
|
||||||
|
X
|
||||||
|
} from 'lucide-vue-next'
|
||||||
import { Modal, message } from 'ant-design-vue'
|
import { Modal, message } from 'ant-design-vue'
|
||||||
import FileTreeComponent from '@/components/FileTreeComponent.vue'
|
import FileTreeComponent from '@/components/FileTreeComponent.vue'
|
||||||
import AgentFilePreview from '@/components/AgentFilePreview.vue'
|
import AgentFilePreview from '@/components/AgentFilePreview.vue'
|
||||||
@ -271,6 +280,17 @@ const removeTreeNode = (nodes, targetKey) => {
|
|||||||
}, [])
|
}, [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizePathKey = (path) => String(path || '').replace(/\/+$/, '')
|
||||||
|
|
||||||
|
const isSameOrChildPath = (path, targetPath) => {
|
||||||
|
const normalizedPath = normalizePathKey(path)
|
||||||
|
const normalizedTargetPath = normalizePathKey(targetPath)
|
||||||
|
if (!normalizedPath || !normalizedTargetPath) return false
|
||||||
|
return (
|
||||||
|
normalizedPath === normalizedTargetPath || normalizedPath.startsWith(`${normalizedTargetPath}/`)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const parseDownloadFilename = (contentDisposition) => {
|
const parseDownloadFilename = (contentDisposition) => {
|
||||||
if (!contentDisposition) return ''
|
if (!contentDisposition) return ''
|
||||||
|
|
||||||
@ -429,11 +449,21 @@ const closePreview = () => {
|
|||||||
selectedKeys.value = []
|
selectedKeys.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmDeleteFile = (node) => {
|
const pruneTreeStateAfterDelete = (targetPath) => {
|
||||||
|
selectedKeys.value = selectedKeys.value.filter((key) => !isSameOrChildPath(key, targetPath))
|
||||||
|
expandedKeys.value = expandedKeys.value.filter((key) => !isSameOrChildPath(key, targetPath))
|
||||||
|
|
||||||
|
if (isSameOrChildPath(currentFilePath.value, targetPath)) {
|
||||||
|
closePreview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDeleteNode = (node) => {
|
||||||
const fileName = node?.title || getFileName(node?.fileData)
|
const fileName = node?.title || getFileName(node?.fileData)
|
||||||
|
const isDirectory = !node?.isLeaf
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: `确认删除文件「${fileName}」?`,
|
title: isDirectory ? `确认删除文件夹「${fileName}」?` : `确认删除文件「${fileName}」?`,
|
||||||
content: '删除后不可恢复。',
|
content: isDirectory ? '将删除该文件夹及其所有内容,删除后不可恢复。' : '删除后不可恢复。',
|
||||||
okText: '删除',
|
okText: '删除',
|
||||||
okType: 'danger',
|
okType: 'danger',
|
||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
@ -445,14 +475,11 @@ const confirmDeleteFile = (node) => {
|
|||||||
try {
|
try {
|
||||||
await deleteViewerFile(props.threadId, node.key, props.agentId, props.agentConfigId)
|
await deleteViewerFile(props.threadId, node.key, props.agentId, props.agentConfigId)
|
||||||
dynamicTreeData.value = removeTreeNode(dynamicTreeData.value, node.key)
|
dynamicTreeData.value = removeTreeNode(dynamicTreeData.value, node.key)
|
||||||
selectedKeys.value = selectedKeys.value.filter((key) => key !== node.key)
|
pruneTreeStateAfterDelete(node.key)
|
||||||
if (currentFilePath.value === node.key) {
|
message.success(isDirectory ? '文件夹删除成功' : '文件删除成功')
|
||||||
closePreview()
|
|
||||||
}
|
|
||||||
message.success('文件删除成功')
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('删除文件失败:', error)
|
console.error(isDirectory ? '删除文件夹失败:' : '删除文件失败:', error)
|
||||||
message.error(error?.message || '删除文件失败')
|
message.error(error?.message || (isDirectory ? '删除文件夹失败' : '删除文件失败'))
|
||||||
} finally {
|
} finally {
|
||||||
const latestDeletingPaths = new Set(deletingPaths.value)
|
const latestDeletingPaths = new Set(deletingPaths.value)
|
||||||
latestDeletingPaths.delete(node.key)
|
latestDeletingPaths.delete(node.key)
|
||||||
@ -1087,30 +1114,30 @@ watch(useInlinePreview, (isInline) => {
|
|||||||
<style lang="less">
|
<style lang="less">
|
||||||
.agent-file-preview-modal {
|
.agent-file-preview-modal {
|
||||||
.ant-modal {
|
.ant-modal {
|
||||||
z-index: 1050;
|
z-index: 1050;
|
||||||
.ant-modal-content {
|
.ant-modal-content {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--gray-200);
|
border: 1px solid var(--gray-200);
|
||||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.ant-modal-header) {
|
:deep(.ant-modal-header) {
|
||||||
background: var(--main-5);
|
background: var(--main-5);
|
||||||
border-bottom: 1px solid var(--gray-200);
|
border-bottom: 1px solid var(--gray-200);
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.ant-modal-title) {
|
:deep(.ant-modal-title) {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--gray-1000);
|
color: var(--gray-1000);
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.ant-modal-body) {
|
:deep(.ant-modal-body) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user