forked from EduCraft/curriculum-project-hub
feat(filelib-web): 刷新页面时保持树展开、选中节点、tab 与文件夹位置
This commit is contained in:
@@ -39,6 +39,14 @@
|
|||||||
let currentDir = $state("");
|
let currentDir = $state("");
|
||||||
let viewMode = $state<"list" | "grid">(loadViewMode());
|
let viewMode = $state<"list" | "grid">(loadViewMode());
|
||||||
|
|
||||||
|
function restoreCurrentDir(nodeId: string): string {
|
||||||
|
try {
|
||||||
|
return sessionStorage.getItem(`filelib.dir.${nodeId}`) ?? "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function loadViewMode(): "list" | "grid" {
|
function loadViewMode(): "list" | "grid" {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem("filelib.viewMode") === "grid" ? "grid" : "list";
|
return localStorage.getItem("filelib.viewMode") === "grid" ? "grid" : "list";
|
||||||
@@ -72,10 +80,17 @@
|
|||||||
void loadFiles();
|
void loadFiles();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 切换项目时回到根目录 —— 不同项目的目录结构互不相干。
|
// 切换项目时恢复对应项目的目录位置。
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void node.id;
|
void node.id;
|
||||||
currentDir = "";
|
currentDir = restoreCurrentDir(node.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// currentDir 变化时持久化。
|
||||||
|
$effect(() => {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(`filelib.dir.${node.id}`, currentDir);
|
||||||
|
} catch { /* ignore */ }
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
import { api } from "./api.js";
|
import { api } from "./api.js";
|
||||||
import { me, toastErr, toastOk } from "./stores.js";
|
import { me, toastErr, toastOk } from "./stores.js";
|
||||||
import { logout } from "./session.js";
|
import { logout } from "./session.js";
|
||||||
import { treeVersion, bumpTree, currentNode, selectedFilePath, clearSelectedFile, bumpFiles } from "./browser.js";
|
import { treeVersion, bumpTree, currentNode, breadcrumb, selectedFilePath, clearSelectedFile, bumpFiles, restoreNodeId } from "./browser.js";
|
||||||
import type { NodeChild } from "./types.js";
|
import type { NodeChild, NodeDetail, BreadcrumbEntry } from "./types.js";
|
||||||
import TreeNode from "./TreeNode.svelte";
|
import TreeNode from "./TreeNode.svelte";
|
||||||
import NodeDetailPanel from "./NodeDetailPanel.svelte";
|
import NodeDetailPanel from "./NodeDetailPanel.svelte";
|
||||||
import FileEditor from "./FileEditor.svelte";
|
import FileEditor from "./FileEditor.svelte";
|
||||||
@@ -35,7 +35,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(loadRoots);
|
onMount(async () => {
|
||||||
|
await loadRoots();
|
||||||
|
// 刷新后恢复之前选中的节点。
|
||||||
|
if (restoreNodeId) {
|
||||||
|
try {
|
||||||
|
const [detail, crumb] = await Promise.all([
|
||||||
|
api<{ node: NodeDetail }>(`/database/api/nodes/${restoreNodeId}`),
|
||||||
|
api<{ breadcrumb: BreadcrumbEntry[] }>(`/database/api/nodes/${restoreNodeId}/breadcrumb`),
|
||||||
|
]);
|
||||||
|
currentNode.set(detail.node);
|
||||||
|
breadcrumb.set(crumb.breadcrumb);
|
||||||
|
} catch {
|
||||||
|
// 节点已删除或无权访问,静默忽略。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void $treeVersion;
|
void $treeVersion;
|
||||||
void loadRoots();
|
void loadRoots();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { api } from "./api.js";
|
import { api } from "./api.js";
|
||||||
import { currentNode, breadcrumb, bumpTree, clearSelectedFile } from "./browser.js";
|
import { currentNode, breadcrumb, bumpTree, clearSelectedFile, activeTab, restoreTab } from "./browser.js";
|
||||||
import { toastOk, toastErr } from "./stores.js";
|
import { toastOk, toastErr } from "./stores.js";
|
||||||
import OverviewPanel from "./OverviewPanel.svelte";
|
import OverviewPanel from "./OverviewPanel.svelte";
|
||||||
import FilesPanel from "./FilesPanel.svelte";
|
import FilesPanel from "./FilesPanel.svelte";
|
||||||
@@ -10,7 +10,12 @@
|
|||||||
import Icon from "./Icon.svelte";
|
import Icon from "./Icon.svelte";
|
||||||
|
|
||||||
type Tab = "detail" | "files" | "history" | "grants";
|
type Tab = "detail" | "files" | "history" | "grants";
|
||||||
let tab = $state<Tab>("detail");
|
const validTabs: readonly Tab[] = ["detail", "files", "history", "grants"];
|
||||||
|
let tab = $state<Tab>((validTabs.includes(restoreTab as Tab) ? restoreTab as Tab : "detail"));
|
||||||
|
/** 跟踪上一次见到的 node id,用于判断是否真正切换了节点。 */
|
||||||
|
let prevNodeId: string | null = null;
|
||||||
|
/** 首次恢复时不重置 tab。 */
|
||||||
|
let restoredOnce = restoreTab !== null;
|
||||||
let showCreateChild = $state(false);
|
let showCreateChild = $state(false);
|
||||||
let newName = $state("");
|
let newName = $state("");
|
||||||
let newKind = $state<"FOLDER" | "PROJECT">("FOLDER");
|
let newKind = $state<"FOLDER" | "PROJECT">("FOLDER");
|
||||||
@@ -32,11 +37,27 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void node?.id;
|
const id = node?.id ?? null;
|
||||||
tab = "detail";
|
// node 还未加载或与上次相同时不做任何事。
|
||||||
|
if (id === null || id === prevNodeId) return;
|
||||||
|
prevNodeId = id;
|
||||||
|
if (restoredOnce) {
|
||||||
|
// 首次恢复(刷新后)保持 persisted tab,但要确保 tab 对当前节点有效。
|
||||||
|
restoredOnce = false;
|
||||||
|
const isProject = node?.kind === "PROJECT";
|
||||||
|
if ((tab === "files" || tab === "history") && !isProject) tab = "detail";
|
||||||
|
if (tab === "grants" && node?.role !== "MANAGE") tab = "detail";
|
||||||
|
} else {
|
||||||
|
tab = "detail";
|
||||||
|
}
|
||||||
clearSelectedFile();
|
clearSelectedFile();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// tab 变化时同步到持久化 store。
|
||||||
|
$effect(() => {
|
||||||
|
activeTab.set(tab);
|
||||||
|
});
|
||||||
|
|
||||||
async function createChild(): Promise<void> {
|
async function createChild(): Promise<void> {
|
||||||
const name = newName.trim();
|
const name = newName.trim();
|
||||||
if (name === "" || node === null) return;
|
if (name === "" || node === null) return;
|
||||||
|
|||||||
@@ -1,17 +1,62 @@
|
|||||||
import { writable } from "svelte/store";
|
import { writable, get } from "svelte/store";
|
||||||
import type { BreadcrumbEntry, NodeDetail } from "./types.js";
|
import type { BreadcrumbEntry, NodeDetail } from "./types.js";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "filelib.browser";
|
||||||
|
|
||||||
|
interface PersistedState {
|
||||||
|
expanded: string[];
|
||||||
|
currentNodeId: string | null;
|
||||||
|
tab: string | null;
|
||||||
|
selectedFilePath: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPersisted(): PersistedState {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw) return JSON.parse(raw) as PersistedState;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return { expanded: [], currentNodeId: null, tab: null, selectedFilePath: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePersisted(): void {
|
||||||
|
try {
|
||||||
|
const state: PersistedState = {
|
||||||
|
expanded: [...get(expanded)],
|
||||||
|
currentNodeId: get(currentNode)?.id ?? null,
|
||||||
|
tab: get(activeTab),
|
||||||
|
selectedFilePath: get(selectedFilePath),
|
||||||
|
};
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||||
|
} catch { /* sessionStorage 不可用时静默忽略 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const persisted = loadPersisted();
|
||||||
|
|
||||||
/** 树展开集合 / 当前选中节点 / 面包屑 / 树刷新计数。 */
|
/** 树展开集合 / 当前选中节点 / 面包屑 / 树刷新计数。 */
|
||||||
export const expanded = writable<Set<string>>(new Set());
|
export const expanded = writable<Set<string>>(new Set(persisted.expanded));
|
||||||
export const currentNode = writable<NodeDetail | null>(null);
|
export const currentNode = writable<NodeDetail | null>(null);
|
||||||
export const breadcrumb = writable<BreadcrumbEntry[]>([]);
|
export const breadcrumb = writable<BreadcrumbEntry[]>([]);
|
||||||
export const treeVersion = writable(0);
|
export const treeVersion = writable(0);
|
||||||
|
|
||||||
|
/** 刷新后需要恢复的节点 ID;LibraryView onMount 消费后清空。 */
|
||||||
|
export const restoreNodeId = persisted.currentNodeId;
|
||||||
|
/** 刷新后需要恢复的 tab;NodeDetailPanel 消费。 */
|
||||||
|
export const restoreTab = persisted.tab;
|
||||||
|
|
||||||
/** 右侧预览栏:当前选中文件路径(项目内);切换节点时清空。 */
|
/** 右侧预览栏:当前选中文件路径(项目内);切换节点时清空。 */
|
||||||
export const selectedFilePath = writable<string | null>(null);
|
export const selectedFilePath = writable<string | null>(persisted.selectedFilePath);
|
||||||
/** 文件列表刷新计数(编辑器保存/删除后 bump,列表随之重载)。 */
|
/** 文件列表刷新计数(编辑器保存/删除后 bump,列表随之重载)。 */
|
||||||
export const filesVersion = writable(0);
|
export const filesVersion = writable(0);
|
||||||
|
|
||||||
|
/** 当前激活的 tab(由 NodeDetailPanel 写入,持久化用)。 */
|
||||||
|
export const activeTab = writable<string | null>(persisted.tab);
|
||||||
|
|
||||||
|
// 订阅需要持久化的 store,变化时写 sessionStorage。
|
||||||
|
expanded.subscribe(() => savePersisted());
|
||||||
|
currentNode.subscribe(() => savePersisted());
|
||||||
|
selectedFilePath.subscribe(() => savePersisted());
|
||||||
|
activeTab.subscribe(() => savePersisted());
|
||||||
|
|
||||||
export function bumpTree(): void {
|
export function bumpTree(): void {
|
||||||
treeVersion.update((v) => v + 1);
|
treeVersion.update((v) => v + 1);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user