Develop #16
@@ -55,15 +55,16 @@ def resource_payload(
|
|||||||
resource: DataResources,
|
resource: DataResources,
|
||||||
storage_object: StorageObjects,
|
storage_object: StorageObjects,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
# ``object_key`` is stored as ``workspace/{ws_id}/{user_id}/.resources/{file_name}``.
|
# ``object_key`` is stored as ``{ws_id}/{user_id}/.resources/{file_name}``
|
||||||
# Two Jupyter-facing path views:
|
# (see ``backend/services/storage.py:create_upload_record``). Two
|
||||||
|
# Jupyter-facing path views:
|
||||||
# - ``jupyter_accessible_path``: per-user Jupyter-relative path. The
|
# - ``jupyter_accessible_path``: per-user Jupyter-relative path. The
|
||||||
# Jupyter root_dir is already ``workspace/{ws_id}/{user_id}/``, so we
|
# Jupyter root_dir is already ``workspace/{ws_id}/{user_id}/``, so
|
||||||
# only return the tail ``.resources/{file_name}`` for copy-paste use.
|
# we return the tail ``.resources/{file_name}`` for copy-paste use.
|
||||||
# - ``absolute_path``: full filesystem path inside the Jupyter
|
# - ``absolute_path``: full filesystem path inside the Jupyter
|
||||||
# container (the rclone mount root). Shape:
|
# container (the rclone mount root). Shape:
|
||||||
# ``{workspaces_root}/{ws_id}/{user_id}/.resources/{file_name}``.
|
# ``{workspaces_root}/{ws_id}/{user_id}/.resources/{file_name}``.
|
||||||
workspace_prefix = f"workspace/{resource.workspace_id}/"
|
workspace_prefix = f"{resource.workspace_id}/"
|
||||||
user_prefix = f"{resource.owner_user_id}/"
|
user_prefix = f"{resource.owner_user_id}/"
|
||||||
jupyter_accessible_path = ""
|
jupyter_accessible_path = ""
|
||||||
absolute_path = ""
|
absolute_path = ""
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
type ActivePage = "home" | "scripts" | "schedules" | "system";
|
type ActivePage = "home" | "scripts" | "data-resources" | "schedules" | "system";
|
||||||
|
|
||||||
type SidebarProps = {
|
type SidebarProps = {
|
||||||
activePage: ActivePage;
|
activePage: ActivePage;
|
||||||
@@ -15,7 +15,7 @@ type SidebarProps = {
|
|||||||
|
|
||||||
type NavigationItem = {
|
type NavigationItem = {
|
||||||
label: string;
|
label: string;
|
||||||
icon: "home" | "script" | "schedule" | "settings";
|
icon: "home" | "script" | "database" | "schedule" | "settings";
|
||||||
page: ActivePage;
|
page: ActivePage;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -44,6 +44,7 @@ export function Sidebar({
|
|||||||
const navigation: NavigationItem[] = [
|
const navigation: NavigationItem[] = [
|
||||||
{ label: "工作台", icon: "home", page: "home" },
|
{ label: "工作台", icon: "home", page: "home" },
|
||||||
{ label: "构建脚本", icon: "script", page: "scripts" },
|
{ label: "构建脚本", icon: "script", page: "scripts" },
|
||||||
|
{ label: "数据资源", icon: "database", page: "data-resources" },
|
||||||
{ label: "调度配置", icon: "schedule", page: "schedules" },
|
{ label: "调度配置", icon: "schedule", page: "schedules" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export function Topbar({
|
|||||||
home: "工作台",
|
home: "工作台",
|
||||||
scripts: "构建脚本",
|
scripts: "构建脚本",
|
||||||
schedules: "调度配置",
|
schedules: "调度配置",
|
||||||
|
"data-resources": "数据资源",
|
||||||
system: "系统管理",
|
system: "系统管理",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
.data-resources-page {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 22px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-resources-page__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-resources-page__header h1 {
|
||||||
|
margin: 0 0 5px;
|
||||||
|
color: #1c2d42;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-resources-page__header p {
|
||||||
|
margin: 0;
|
||||||
|
color: #7a8999;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-resources-page__toolbar {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-resources-page__toolbar .search-box {
|
||||||
|
width: 320px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-resources-page__table-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
border: 1px solid #dce4eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-resources-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-resources-table th,
|
||||||
|
.data-resources-table td {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid #e8edf2;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-resources-table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
color: #5c6d7e;
|
||||||
|
background: #f7f9fb;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-resources-table tbody tr:hover {
|
||||||
|
background: #f4f8fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-resources-table td {
|
||||||
|
color: #33465a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-resources-table__name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-resources-table code.jupyter-path {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 260px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 3px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #2a4d6b;
|
||||||
|
font-family: Consolas, "SFMono-Regular", monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visibility-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visibility-badge--private {
|
||||||
|
color: #7a6a3f;
|
||||||
|
background: #f9f5e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visibility-badge--workspace {
|
||||||
|
color: #1a5e9c;
|
||||||
|
background: #eaf4fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visibility-badge--public {
|
||||||
|
color: #1f7a54;
|
||||||
|
background: #eaf9f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-resources-page__empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 200px;
|
||||||
|
color: #8b99a8;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-resources-page .form-field input[type="file"] {
|
||||||
|
height: auto;
|
||||||
|
padding: 8px 11px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-resources-table td .row-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-resources-table td .text-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { useAuth } from "../../context/AuthContext";
|
||||||
|
import Icon from "../../components/common/Icon";
|
||||||
|
import { useUiStore } from "./state/uiStore";
|
||||||
|
import {
|
||||||
|
type ResourceItem,
|
||||||
|
listResources,
|
||||||
|
createResourceUpload,
|
||||||
|
uploadResourceBytes,
|
||||||
|
bindResourceUpload,
|
||||||
|
} from "../../services/api";
|
||||||
|
|
||||||
|
import "./DataResourcesPage.css";
|
||||||
|
|
||||||
|
const ACCEPTED_TYPES = ".csv,.xlsx,.xls,.tsv,.json,.parquet,.txt";
|
||||||
|
|
||||||
|
const VISIBILITY_LABELS: Record<ResourceItem["visibility"], string> = {
|
||||||
|
private: "私有",
|
||||||
|
workspace: "工作区",
|
||||||
|
public: "公开",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
const units = ["B", "KB", "MB", "GB"];
|
||||||
|
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||||
|
const value = bytes / Math.pow(1024, index);
|
||||||
|
return `${value.toFixed(index === 0 ? 0 : 2)} ${units[index]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256Hex(file: File): Promise<string> {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
const digest = await crypto.subtle.digest("SHA-256", buffer);
|
||||||
|
return Array.from(new Uint8Array(digest))
|
||||||
|
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DataResourcesPage() {
|
||||||
|
const { currentWorkspace, user } = useAuth();
|
||||||
|
const workspaceId = currentWorkspace?.workspace_id ?? "";
|
||||||
|
const ownerUserId = user?.user_id ?? "";
|
||||||
|
const pushToast = useUiStore((state) => state.pushToast);
|
||||||
|
|
||||||
|
const [items, setItems] = useState<ResourceItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [resourceName, setResourceName] = useState("");
|
||||||
|
const [visibility, setVisibility] = useState<ResourceItem["visibility"]>("workspace");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
if (!workspaceId) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await listResources(workspaceId, { keyword });
|
||||||
|
// Defensive: apiRequest<T> returns T, but treat non-array payloads
|
||||||
|
// as empty so a malformed response never crashes the render.
|
||||||
|
setItems(Array.isArray(data) ? data : []);
|
||||||
|
} catch (error) {
|
||||||
|
pushToast({
|
||||||
|
tone: "error",
|
||||||
|
message: error instanceof Error ? error.message : "加载数据资源失败",
|
||||||
|
});
|
||||||
|
setItems([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [workspaceId, keyword]);
|
||||||
|
|
||||||
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const selected = event.target.files?.[0] ?? null;
|
||||||
|
setFile(selected);
|
||||||
|
if (selected && !resourceName.trim()) {
|
||||||
|
const baseName = selected.name.replace(/\.[^/.]+$/, "");
|
||||||
|
setResourceName(baseName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFile(null);
|
||||||
|
setResourceName("");
|
||||||
|
setVisibility("workspace");
|
||||||
|
setDescription("");
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!file || !workspaceId) return;
|
||||||
|
|
||||||
|
const name = resourceName.trim() || file.name;
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const expectedHash = await sha256Hex(file);
|
||||||
|
const { upload_id: uploadId } = await createResourceUpload(workspaceId, {
|
||||||
|
file_name: file.name,
|
||||||
|
content_type: file.type || "application/octet-stream",
|
||||||
|
expected_size_bytes: file.size,
|
||||||
|
expected_hash: expectedHash,
|
||||||
|
});
|
||||||
|
await uploadResourceBytes(
|
||||||
|
workspaceId,
|
||||||
|
uploadId,
|
||||||
|
await file.arrayBuffer(),
|
||||||
|
file.type || "application/octet-stream",
|
||||||
|
);
|
||||||
|
await bindResourceUpload(workspaceId, uploadId, {
|
||||||
|
resource_name: name,
|
||||||
|
description: description.trim(),
|
||||||
|
visibility,
|
||||||
|
});
|
||||||
|
pushToast({ tone: "success", message: "数据资源上传成功" });
|
||||||
|
setModalOpen(false);
|
||||||
|
resetForm();
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
pushToast({
|
||||||
|
tone: "error",
|
||||||
|
message: error instanceof Error ? error.message : "上传失败",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 把 {workspace_id}/{user_id}/ 前缀从路径里去掉,方便用户粘贴到 notebook。
|
||||||
|
// 后端目前返回的 jupyter_accessible_path 偶尔会带这层前缀(legacy 数据 +
|
||||||
|
// workspace_prefix 配错),所以前端再做一次防御性剥除。
|
||||||
|
const stripOwnerPrefix = (path: string): string => {
|
||||||
|
if (!path) return path;
|
||||||
|
const fullPrefix = `${workspaceId}/${ownerUserId}/`;
|
||||||
|
if (fullPrefix !== "/" && path.startsWith(fullPrefix)) {
|
||||||
|
return path.slice(fullPrefix.length);
|
||||||
|
}
|
||||||
|
const ownerPrefix = `${ownerUserId}/`;
|
||||||
|
if (ownerPrefix !== "/" && path.startsWith(ownerPrefix)) {
|
||||||
|
return path.slice(ownerPrefix.length);
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async (path: string) => {
|
||||||
|
try {
|
||||||
|
const stripped = stripOwnerPrefix(path);
|
||||||
|
await navigator.clipboard.writeText(stripped);
|
||||||
|
pushToast({ tone: "success", message: "已复制 Jupyter 路径" });
|
||||||
|
} catch {
|
||||||
|
pushToast({ tone: "error", message: "复制失败" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="data-resources-page">
|
||||||
|
<header className="data-resources-page__header">
|
||||||
|
<div>
|
||||||
|
<h1>数据资源</h1>
|
||||||
|
<p>管理可在 Jupyter 中读取的 CSV/Excel/JSON/Parquet 文件</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setModalOpen(true)}
|
||||||
|
>
|
||||||
|
<Icon name="upload" size={17} />
|
||||||
|
上传数据资源
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="data-resources-page__toolbar">
|
||||||
|
<div className="search-box">
|
||||||
|
<Icon name="search" size={16} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索资源名称或文件名"
|
||||||
|
value={keyword}
|
||||||
|
onChange={(event) => setKeyword(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="data-resources-page__table-wrap">
|
||||||
|
{loading && items.length === 0 ? (
|
||||||
|
<div className="data-resources-page__empty">加载中…</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="data-resources-page__empty">
|
||||||
|
暂无数据资源,点击右上角上传。
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="data-resources-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>资源名称</th>
|
||||||
|
<th>文件名</th>
|
||||||
|
<th>大小</th>
|
||||||
|
<th>可见性</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item) => (
|
||||||
|
<tr key={item.resource_id}>
|
||||||
|
<td className="data-resources-table__name">
|
||||||
|
{item.resource_name}
|
||||||
|
</td>
|
||||||
|
<td>{item.file.file_name}</td>
|
||||||
|
<td>{formatBytes(item.file.size_bytes)}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className={`visibility-badge visibility-badge--${item.visibility}`}
|
||||||
|
>
|
||||||
|
{VISIBILITY_LABELS[item.visibility]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="row-actions">
|
||||||
|
<button
|
||||||
|
className="text-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCopy(item.jupyter_accessible_path)}
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="text-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCopy(item.absolute_path)}
|
||||||
|
disabled={!item.absolute_path}
|
||||||
|
title={item.absolute_path || "绝对路径不可用"}
|
||||||
|
>
|
||||||
|
复制绝对路径
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{modalOpen && (
|
||||||
|
<div
|
||||||
|
className="modal-backdrop"
|
||||||
|
onClick={() => {
|
||||||
|
if (!uploading) setModalOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="modal modal--compact"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="modal__header">
|
||||||
|
<div>
|
||||||
|
<span className="modal__eyebrow">数据资源</span>
|
||||||
|
<h2>上传数据文件</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-field">
|
||||||
|
<label htmlFor="data-resource-file">选择文件</label>
|
||||||
|
<input
|
||||||
|
id="data-resource-file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={ACCEPTED_TYPES}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<small>支持 CSV、Excel、JSON、Parquet、TSV、TXT</small>
|
||||||
|
</div>
|
||||||
|
<div className="form-field">
|
||||||
|
<label htmlFor="data-resource-name">资源名称</label>
|
||||||
|
<input
|
||||||
|
id="data-resource-name"
|
||||||
|
type="text"
|
||||||
|
value={resourceName}
|
||||||
|
onChange={(event) => setResourceName(event.target.value)}
|
||||||
|
placeholder="输入资源名称"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-field">
|
||||||
|
<label htmlFor="data-resource-visibility">可见性</label>
|
||||||
|
<select
|
||||||
|
id="data-resource-visibility"
|
||||||
|
value={visibility}
|
||||||
|
onChange={(event) =>
|
||||||
|
setVisibility(event.target.value as ResourceItem["visibility"])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="private">私有</option>
|
||||||
|
<option value="workspace">工作区</option>
|
||||||
|
<option value="public">公开</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-field">
|
||||||
|
<label htmlFor="data-resource-description">描述(可选)</label>
|
||||||
|
<textarea
|
||||||
|
id="data-resource-description"
|
||||||
|
value={description}
|
||||||
|
onChange={(event) => setDescription(event.target.value)}
|
||||||
|
placeholder="输入描述"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="modal__footer">
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setModalOpen(false)}
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
type="submit"
|
||||||
|
disabled={!file || uploading}
|
||||||
|
>
|
||||||
|
{uploading ? "上传中…" : "上传"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
useScriptWorkspaceStore,
|
useScriptWorkspaceStore,
|
||||||
} from "../state/scriptWorkspaceStore";
|
} from "../state/scriptWorkspaceStore";
|
||||||
|
|
||||||
type ActivePage = "home" | "scripts" | "schedules" | "system";
|
type ActivePage = "home" | "scripts" | "data-resources" | "schedules" | "system";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 必须在 layout 层挂载,不能放在 ScriptsPage。
|
* 必须在 layout 层挂载,不能放在 ScriptsPage。
|
||||||
@@ -68,4 +68,4 @@ export function useEditSessionLifecycle({
|
|||||||
bindScriptWorkspaceApi(null);
|
bindScriptWorkspaceApi(null);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ export default [
|
|||||||
index("features/platform/RootIndex.tsx"),
|
index("features/platform/RootIndex.tsx"),
|
||||||
route("workbench", "features/platform/DashboardRoute.tsx"),
|
route("workbench", "features/platform/DashboardRoute.tsx"),
|
||||||
route("scripts", "features/platform/ScriptsPage.tsx"),
|
route("scripts", "features/platform/ScriptsPage.tsx"),
|
||||||
|
route("data-resources", "features/platform/DataResourcesPage.tsx"),
|
||||||
route("schedules", "features/schedules/SchedulesPageRoute.tsx"),
|
route("schedules", "features/schedules/SchedulesPageRoute.tsx"),
|
||||||
route("system", "features/admin/SystemAdminRoute.tsx"),
|
route("system", "features/admin/SystemAdminRoute.tsx"),
|
||||||
]),
|
]),
|
||||||
] satisfies RouteConfig;
|
] satisfies RouteConfig;
|
||||||
|
|||||||
@@ -18,17 +18,18 @@ import { bindSchedulesApi } from "../features/schedules/state/schedulesStore";
|
|||||||
|
|
||||||
import "../styles/platform.css";
|
import "../styles/platform.css";
|
||||||
|
|
||||||
type ActivePage = "home" | "scripts" | "schedules" | "system";
|
type ActivePage = "home" | "scripts" | "data-resources" | "schedules" | "system";
|
||||||
|
|
||||||
function pageFromPath(pathname: string): ActivePage {
|
function pageFromPath(pathname: string): ActivePage {
|
||||||
const page = pathname.replace(/^\/+|\/+$/g, "");
|
const page = pathname.replace(/^\/+|\/+$/g, "");
|
||||||
return ["scripts", "schedules", "system"].includes(page)
|
return ["scripts", "data-resources", "schedules", "system"].includes(page)
|
||||||
? (page as ActivePage)
|
? (page as ActivePage)
|
||||||
: "home";
|
: "home";
|
||||||
}
|
}
|
||||||
|
|
||||||
function pathForPage(page: ActivePage): string {
|
function pathForPage(page: ActivePage): string {
|
||||||
return page === "home" ? "/workbench" : `/${page}`;
|
if (page === "home") return "/workbench";
|
||||||
|
return `/${page}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function meta({}: Route.MetaArgs) {
|
export function meta({}: Route.MetaArgs) {
|
||||||
@@ -137,4 +138,4 @@ function AuthenticatedLayout() {
|
|||||||
<Toast toast={toast} />
|
<Toast toast={toast} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -388,6 +388,102 @@ export async function uploadScript(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ResourceItem = {
|
||||||
|
resource_id: string;
|
||||||
|
workspace_id: string;
|
||||||
|
storage_object_id: string;
|
||||||
|
owner_user_id: string;
|
||||||
|
resource_name: string;
|
||||||
|
description: string | null;
|
||||||
|
visibility: "private" | "workspace" | "public";
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
file: {
|
||||||
|
file_name: string;
|
||||||
|
file_extension: string | null;
|
||||||
|
mime_type: string | null;
|
||||||
|
size_bytes: number;
|
||||||
|
content_hash: string | null;
|
||||||
|
object_status: string;
|
||||||
|
};
|
||||||
|
jupyter_accessible_path: string;
|
||||||
|
absolute_path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listResources(
|
||||||
|
workspaceId: string,
|
||||||
|
opts?: { visibility?: string; keyword?: string },
|
||||||
|
): Promise<ResourceItem[]> {
|
||||||
|
const parameters = new URLSearchParams();
|
||||||
|
if (opts?.visibility) parameters.set("visibility", opts.visibility);
|
||||||
|
if (opts?.keyword) parameters.set("keyword", opts.keyword);
|
||||||
|
const query = parameters.toString();
|
||||||
|
// apiRequest<T> already unwraps the envelope's `data` field, so we
|
||||||
|
// request `ResourceItem[]` directly here (matching listScripts).
|
||||||
|
return apiRequest<ResourceItem[]>(
|
||||||
|
`/api/v1/data-resources${query ? `?${query}` : ""}`,
|
||||||
|
{},
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createResourceUpload(
|
||||||
|
workspaceId: string,
|
||||||
|
body: {
|
||||||
|
file_name: string;
|
||||||
|
content_type: string;
|
||||||
|
expected_size_bytes: number;
|
||||||
|
expected_hash: string | null;
|
||||||
|
},
|
||||||
|
): Promise<{ upload_id: string; upload_path: string }> {
|
||||||
|
return apiRequest<{ upload_id: string; upload_path: string }>(
|
||||||
|
"/api/v1/data-resources/uploads",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: { "Idempotency-Key": createUuid().replaceAll("-", "") },
|
||||||
|
},
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadResourceBytes(
|
||||||
|
workspaceId: string,
|
||||||
|
uploadId: string,
|
||||||
|
fileBytes: ArrayBuffer | Blob,
|
||||||
|
contentType: string,
|
||||||
|
): Promise<{ storage_object_id: string }> {
|
||||||
|
return apiRequest<{ storage_object_id: string }>(
|
||||||
|
`/api/v1/data-resources/uploads/${encodeURIComponent(uploadId)}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": contentType },
|
||||||
|
body: fileBytes,
|
||||||
|
},
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bindResourceUpload(
|
||||||
|
workspaceId: string,
|
||||||
|
uploadId: string,
|
||||||
|
body: {
|
||||||
|
resource_name: string;
|
||||||
|
description: string;
|
||||||
|
visibility: "private" | "workspace" | "public";
|
||||||
|
},
|
||||||
|
): Promise<ResourceItem> {
|
||||||
|
return apiRequest<ResourceItem>(
|
||||||
|
`/api/v1/data-resources/uploads/${encodeURIComponent(uploadId)}/bind`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
},
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function setScriptLock(
|
export async function setScriptLock(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
scriptId: string,
|
scriptId: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user