feat: datasource

This commit is contained in:
tao.chen
2026-08-13 20:13:37 +08:00
parent 0b5b50edb9
commit 462617b66d
9 changed files with 601 additions and 14 deletions
+6 -5
View File
@@ -55,15 +55,16 @@ def resource_payload(
resource: DataResources,
storage_object: StorageObjects,
) -> dict[str, Any]:
# ``object_key`` is stored as ``workspace/{ws_id}/{user_id}/.resources/{file_name}``.
# Two Jupyter-facing path views:
# ``object_key`` is stored as ``{ws_id}/{user_id}/.resources/{file_name}``
# (see ``backend/services/storage.py:create_upload_record``). Two
# Jupyter-facing path views:
# - ``jupyter_accessible_path``: per-user Jupyter-relative path. The
# Jupyter root_dir is already ``workspace/{ws_id}/{user_id}/``, so we
# only return the tail ``.resources/{file_name}`` for copy-paste use.
# Jupyter root_dir is already ``workspace/{ws_id}/{user_id}/``, so
# we return the tail ``.resources/{file_name}`` for copy-paste use.
# - ``absolute_path``: full filesystem path inside the Jupyter
# container (the rclone mount root). Shape:
# ``{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}/"
jupyter_accessible_path = ""
absolute_path = ""
+3 -2
View File
@@ -1,6 +1,6 @@
import Icon from "./Icon";
type ActivePage = "home" | "scripts" | "schedules" | "system";
type ActivePage = "home" | "scripts" | "data-resources" | "schedules" | "system";
type SidebarProps = {
activePage: ActivePage;
@@ -15,7 +15,7 @@ type SidebarProps = {
type NavigationItem = {
label: string;
icon: "home" | "script" | "schedule" | "settings";
icon: "home" | "script" | "database" | "schedule" | "settings";
page: ActivePage;
};
@@ -44,6 +44,7 @@ export function Sidebar({
const navigation: NavigationItem[] = [
{ label: "工作台", icon: "home", page: "home" },
{ label: "构建脚本", icon: "script", page: "scripts" },
{ label: "数据资源", icon: "database", page: "data-resources" },
{ label: "调度配置", icon: "schedule", page: "schedules" },
];
@@ -28,6 +28,7 @@ export function Topbar({
home: "工作台",
scripts: "构建脚本",
schedules: "调度配置",
"data-resources": "数据资源",
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> CSVExcelJSONParquetTSVTXT</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,
} from "../state/scriptWorkspaceStore";
type ActivePage = "home" | "scripts" | "schedules" | "system";
type ActivePage = "home" | "scripts" | "data-resources" | "schedules" | "system";
/**
* 必须在 layout 层挂载,不能放在 ScriptsPage。
@@ -68,4 +68,4 @@ export function useEditSessionLifecycle({
bindScriptWorkspaceApi(null);
};
}, []);
}
}
+2 -1
View File
@@ -6,7 +6,8 @@ export default [
index("features/platform/RootIndex.tsx"),
route("workbench", "features/platform/DashboardRoute.tsx"),
route("scripts", "features/platform/ScriptsPage.tsx"),
route("data-resources", "features/platform/DataResourcesPage.tsx"),
route("schedules", "features/schedules/SchedulesPageRoute.tsx"),
route("system", "features/admin/SystemAdminRoute.tsx"),
]),
] satisfies RouteConfig;
] satisfies RouteConfig;
+5 -4
View File
@@ -18,17 +18,18 @@ import { bindSchedulesApi } from "../features/schedules/state/schedulesStore";
import "../styles/platform.css";
type ActivePage = "home" | "scripts" | "schedules" | "system";
type ActivePage = "home" | "scripts" | "data-resources" | "schedules" | "system";
function pageFromPath(pathname: string): ActivePage {
const page = pathname.replace(/^\/+|\/+$/g, "");
return ["scripts", "schedules", "system"].includes(page)
return ["scripts", "data-resources", "schedules", "system"].includes(page)
? (page as ActivePage)
: "home";
}
function pathForPage(page: ActivePage): string {
return page === "home" ? "/workbench" : `/${page}`;
if (page === "home") return "/workbench";
return `/${page}`;
}
export function meta({}: Route.MetaArgs) {
@@ -137,4 +138,4 @@ function AuthenticatedLayout() {
<Toast toast={toast} />
</div>
);
}
}
+96
View File
@@ -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(
workspaceId: string,
scriptId: string,