feat: datasource
This commit is contained in:
@@ -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>支持 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,
|
||||
} 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);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user