fix: file lock
This commit is contained in:
@@ -17,6 +17,8 @@ type IconName =
|
||||
| "close"
|
||||
| "check"
|
||||
| "info"
|
||||
| "lock"
|
||||
| "unlock"
|
||||
| "workspace"
|
||||
| "menu"
|
||||
| "external"
|
||||
@@ -143,6 +145,18 @@ export default function Icon({
|
||||
<path d="M12 11v5M12 8h.01" />
|
||||
</>
|
||||
),
|
||||
lock: (
|
||||
<>
|
||||
<rect x="5" y="11" width="14" height="9" rx="2" />
|
||||
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
|
||||
</>
|
||||
),
|
||||
unlock: (
|
||||
<>
|
||||
<rect x="5" y="11" width="14" height="9" rx="2" />
|
||||
<path d="M8 11V7a4 4 0 0 1 7.5-2" />
|
||||
</>
|
||||
),
|
||||
workspace: (
|
||||
<>
|
||||
<rect x="3.5" y="5" width="17" height="14" rx="2" />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { type ChangeEvent, type MouseEvent as ReactMouseEvent, type RefObject } from "react";
|
||||
import Icon from "../common/Icon";
|
||||
import { WorkspaceTreeGroup } from "../../features/platform/WorkspaceTree";
|
||||
import type { ScriptItem, WorkspaceDirectory } from "../../services/api";
|
||||
import type { AuthUser } from "../../context/AuthContext";
|
||||
import { WorkspaceTreeGroup } from "~/features/platform/WorkspaceTree";
|
||||
import type { ScriptItem, WorkspaceDirectory } from "~/services/api";
|
||||
import type { AuthUser } from "~/context/AuthContext";
|
||||
|
||||
type ScriptExplorerProps = {
|
||||
scripts: ScriptItem[];
|
||||
@@ -51,15 +51,55 @@ export function ScriptExplorer({
|
||||
onHandleUpload,
|
||||
}: ScriptExplorerProps) {
|
||||
const memberScriptGroups = (() => {
|
||||
const currentUserScripts = filteredScripts.filter(
|
||||
(item) => item.owner_user_id === user?.user_id,
|
||||
);
|
||||
const inferred = inferredDirectories(currentUserScripts);
|
||||
return [{
|
||||
user: user,
|
||||
scripts: currentUserScripts,
|
||||
directories: mergeDirectories(directories, inferred),
|
||||
}];
|
||||
const visibleScripts =
|
||||
user?.is_system_admin === true
|
||||
? filteredScripts
|
||||
: filteredScripts.filter(
|
||||
(item) =>
|
||||
item.owner_user_id === user?.user_id ||
|
||||
item.visibility === "workspace" ||
|
||||
item.visibility === "public",
|
||||
);
|
||||
|
||||
const byOwner = new Map<string, ScriptItem[]>();
|
||||
for (const item of visibleScripts) {
|
||||
const list = byOwner.get(item.owner_user_id) ?? [];
|
||||
list.push(item);
|
||||
byOwner.set(item.owner_user_id, list);
|
||||
}
|
||||
|
||||
const groups: {
|
||||
user: AuthUser | null;
|
||||
scripts: ScriptItem[];
|
||||
directories: WorkspaceDirectory[];
|
||||
}[] = [];
|
||||
for (const [ownerUserId, groupScripts] of byOwner.entries()) {
|
||||
const groupUser =
|
||||
ownerUserId === user?.user_id
|
||||
? user
|
||||
: ({
|
||||
user_id: ownerUserId,
|
||||
username: ownerUserId,
|
||||
display_name: ownerUserId,
|
||||
email: null,
|
||||
status: "unknown",
|
||||
role_code: null,
|
||||
is_system_admin: false,
|
||||
} as AuthUser);
|
||||
groups.push({
|
||||
user: groupUser,
|
||||
scripts: groupScripts,
|
||||
directories: inferredDirectories(groupScripts),
|
||||
});
|
||||
}
|
||||
|
||||
groups.sort((a, b) => {
|
||||
if (a.user?.user_id === user?.user_id) return -1;
|
||||
if (b.user?.user_id === user?.user_id) return 1;
|
||||
return (a.user?.user_id ?? "").localeCompare(b.user?.user_id ?? "");
|
||||
});
|
||||
|
||||
return groups;
|
||||
})();
|
||||
|
||||
return (
|
||||
|
||||
@@ -13,6 +13,7 @@ type TreeContextMenuProps = {
|
||||
contextMenu: ContextMenuState | null;
|
||||
onOpenScript: (scriptId: string) => void;
|
||||
onRemoveScript: (script: ScriptItem) => void;
|
||||
onToggleLock: (script: ScriptItem) => void;
|
||||
onOpenCreateDialog: (parentPath: string, scriptType: ScriptType) => void;
|
||||
onOpenFolderDialog: (parentPath: string) => void;
|
||||
onChooseUpload: (parentPath: string) => void;
|
||||
@@ -24,6 +25,7 @@ export function TreeContextMenu({
|
||||
contextMenu,
|
||||
onOpenScript,
|
||||
onRemoveScript,
|
||||
onToggleLock,
|
||||
onOpenCreateDialog,
|
||||
onOpenFolderDialog,
|
||||
onChooseUpload,
|
||||
@@ -33,7 +35,7 @@ export function TreeContextMenu({
|
||||
if (!contextMenu) return null;
|
||||
|
||||
const width = 188;
|
||||
const height = contextMenu.kind === "file" ? 92 : 190;
|
||||
const height = contextMenu.kind === "file" ? 124 : 190;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -60,6 +62,17 @@ export function TreeContextMenu({
|
||||
<Icon name="script" size={16} />
|
||||
打开文件
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
onToggleLock(contextMenu.script!);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Icon name={contextMenu.script.is_locked ? "unlock" : "lock"} size={16} />
|
||||
{contextMenu.script.is_locked ? "解锁文件" : "锁定文件"}
|
||||
</button>
|
||||
<button
|
||||
className="is-danger"
|
||||
type="button"
|
||||
|
||||
@@ -228,6 +228,8 @@ export function useApi(): WorkspaceBoundApi {
|
||||
updateScript: (scriptId, input) =>
|
||||
rawApi.updateScript(workspaceId, scriptId, input),
|
||||
deleteScript: (scriptId) => rawApi.deleteScript(workspaceId, scriptId),
|
||||
setScriptLock: (scriptId, isLocked) =>
|
||||
rawApi.setScriptLock(workspaceId, scriptId, isLocked),
|
||||
listWorkspaceDirectories: () => rawApi.listWorkspaceDirectories(workspaceId),
|
||||
createWorkspaceDirectory: (directoryName, parentPath) =>
|
||||
rawApi.createWorkspaceDirectory(workspaceId, directoryName, parentPath),
|
||||
|
||||
@@ -48,6 +48,7 @@ export default function ScriptsPage() {
|
||||
const createFolder = useScriptWorkspaceStore((s) => s.createFolder);
|
||||
const deleteScript = useScriptWorkspaceStore((s) => s.deleteScript);
|
||||
const deleteDirectory = useScriptWorkspaceStore((s) => s.deleteDirectory);
|
||||
const toggleScriptLock = useScriptWorkspaceStore((s) => s.toggleScriptLock);
|
||||
const openPublishDialog = useScriptWorkspaceStore((s) => s.openPublishDialog);
|
||||
const submitPublish = useScriptWorkspaceStore((s) => s.submitPublish);
|
||||
|
||||
@@ -292,6 +293,7 @@ export default function ScriptsPage() {
|
||||
closeContextMenu();
|
||||
}}
|
||||
onRemoveScript={(s) => void deleteScript(s)}
|
||||
onToggleLock={(s) => void toggleScriptLock(s)}
|
||||
onOpenCreateDialog={(parentPath, scriptType) =>
|
||||
openCreateDialog(parentPath, scriptType)}
|
||||
onOpenFolderDialog={(parentPath) => openFolderDialog(parentPath)}
|
||||
@@ -318,4 +320,4 @@ export default function ScriptsPage() {
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import Icon from "../../components/common/Icon";
|
||||
import type {
|
||||
ScriptItem,
|
||||
WorkspaceDirectory,
|
||||
} from "../../services/api";
|
||||
} from "~/services/api";
|
||||
|
||||
export type WorkspaceTreeTarget = {
|
||||
kind: "root" | "directory" | "file";
|
||||
@@ -148,16 +148,21 @@ function WorkspaceTreeItems({
|
||||
})
|
||||
: undefined}
|
||||
>
|
||||
<span className={`file-icon file-icon--${item.script_type}`}>
|
||||
<Icon name={scriptIcon(item)} size={17} />
|
||||
</span>
|
||||
<span className="script-row__copy">
|
||||
<strong title={item.script_name}>{item.script_name}</strong>
|
||||
<small>{formatTime(item.updated_at)}</small>
|
||||
</span>
|
||||
{item.visibility !== "private" && (
|
||||
<span className="visibility-dot" title="Workspace 可见" />
|
||||
)}
|
||||
<span className={`file-icon file-icon--${item.script_type}`}>
|
||||
<Icon name={scriptIcon(item)} size={17} />
|
||||
</span>
|
||||
<span className="script-row__copy">
|
||||
<strong title={item.script_name}>{item.script_name}</strong>
|
||||
<small>{formatTime(item.updated_at)}</small>
|
||||
</span>
|
||||
{item.is_locked && (
|
||||
<span className="lock-badge" title="已锁定">
|
||||
<Icon name="lock" size={12} />
|
||||
</span>
|
||||
)}
|
||||
{item.visibility !== "private" && (
|
||||
<span className="visibility-dot" title="Workspace 可见" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -86,6 +86,7 @@ type State = {
|
||||
createFolder: (name: string, parentPath: string) => Promise<void>;
|
||||
deleteScript: (script: ScriptItem) => Promise<void>;
|
||||
deleteDirectory: (path: string) => Promise<void>;
|
||||
toggleScriptLock: (script: ScriptItem) => Promise<void>;
|
||||
openPublishDialog: (script: ScriptItem) => void;
|
||||
submitPublish: (releaseNote: string, visibility: Visibility) => Promise<void>;
|
||||
|
||||
@@ -631,6 +632,28 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
|
||||
}
|
||||
},
|
||||
|
||||
toggleScriptLock: async (script) => {
|
||||
const api = requireApi();
|
||||
useUiStore.getState().closeContextMenu();
|
||||
try {
|
||||
const updated = await api.setScriptLock(script.script_id, !script.is_locked);
|
||||
set((state) => ({
|
||||
scripts: state.scripts.map((s) =>
|
||||
s.script_id === updated.script_id ? updated : s,
|
||||
),
|
||||
}));
|
||||
pushToast(
|
||||
"success",
|
||||
`${updated.script_name} 已${updated.is_locked ? "锁定" : "解锁"}`,
|
||||
);
|
||||
} catch (error) {
|
||||
pushToast(
|
||||
"error",
|
||||
error instanceof Error ? error.message : "锁定状态更新失败",
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
openPublishDialog: (script) => {
|
||||
useUiStore.getState().openPublishDialog(script);
|
||||
},
|
||||
|
||||
@@ -177,6 +177,7 @@ export type ScriptItem = {
|
||||
script_type: ScriptType;
|
||||
visibility: Visibility;
|
||||
status: string;
|
||||
is_locked: boolean;
|
||||
relative_path: string;
|
||||
jupyter_path: string;
|
||||
content_hash: string;
|
||||
@@ -385,6 +386,21 @@ export async function uploadScript(
|
||||
);
|
||||
}
|
||||
|
||||
export async function setScriptLock(
|
||||
workspaceId: string,
|
||||
scriptId: string,
|
||||
isLocked: boolean,
|
||||
): Promise<ScriptItem> {
|
||||
return apiRequest<ScriptItem>(
|
||||
`/api/v1/scripts/${encodeURIComponent(scriptId)}/lock`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ is_locked: isLocked }),
|
||||
},
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateScript(
|
||||
workspaceId: string,
|
||||
scriptId: string,
|
||||
@@ -1289,6 +1305,10 @@ export type WorkspaceBoundApi = {
|
||||
deleteScript: (
|
||||
scriptId: string,
|
||||
) => Promise<{ script_id: string; status: string; versions_preserved: boolean }>;
|
||||
setScriptLock: (
|
||||
scriptId: string,
|
||||
isLocked: boolean,
|
||||
) => Promise<ScriptItem>;
|
||||
listWorkspaceDirectories: () => Promise<WorkspaceDirectory[]>;
|
||||
createWorkspaceDirectory: (
|
||||
directoryName: string,
|
||||
|
||||
@@ -697,6 +697,14 @@ button {
|
||||
box-shadow: 0 0 0 3px rgb(29 189 124 / 10%);
|
||||
}
|
||||
|
||||
.lock-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #c79a3a;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.tree-group__empty {
|
||||
margin: 2px 0 7px 35px;
|
||||
color: #a5b0bc;
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { reactRouter } from "@react-router/dev/vite";
|
||||
import { defineConfig } from "vite";
|
||||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
base: '/',
|
||||
plugins: [reactRouter()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"~": path.resolve(__dirname, "./app"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 5173,
|
||||
|
||||
Reference in New Issue
Block a user