Files
90CNServer/pvf/frontend/src/stores/editor.ts
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

1467 lines
54 KiB
TypeScript

import { assertMCPIdle } from "../mcpActivity";
import { defineStore } from "pinia";
import { computed, markRaw, nextTick, reactive, ref } from "vue";
import { Events } from "@wailsio/runtime";
import { ArchiveService, EditorService, FileGUIService } from "../../bindings/pvfine/services";
import type { ItemCreateRequest, ItemCreateResult, ShopEditRequest, ShopEditResult, WorldDropEditRequest, WorldDropEditResult } from "../../bindings/pvfine/services/models";
import { useFileGUIStore } from "./fileGUI";
import { createGUIModes } from "../gui/state";
import type { EditorAnnotation, FileMeta, TreeTag, ImageReference } from "../../bindings/pvfine/services/models";
import { useArchiveStore } from "./archive";
import { useExplorerStore } from "./explorer";
import { useScriptStore } from "./script";
import { startDebugTiming } from "./debugTiming";
import { hasLongLine } from "../editorWrapping";
import { useArchiveDiffStore } from "./archiveDiff";
export type EditorPaneId = string;
export type SplitOrientation = "columns" | "rows";
/** 归档差异对比专属特殊 Tab 索引常量 */
export const COMPARE_TAB_INDEX = -999;
export interface ArchiveSaveProgress {
phase: string;
done: number;
total: number;
unit: string;
}
export interface EditorTab {
index: number;
path: string;
title: string;
dataType: number;
size: number;
tags: TreeTag[];
editable: boolean;
original: string; // 打开时的文本(脏判定基准)
text: string; // 当前编辑器内容
modified: boolean; // 后端 overlay 状态
annotations: EditorAnnotation[];
annotationsHidden: boolean; // 当前打开期间临时隐藏此文件的标注
lineWrapping: boolean;
icon: ImageReference | null;
fieldImage: ImageReference | null;
loading: boolean;
loadError: string | null;
}
export interface EditorPaneState {
id: EditorPaneId;
tabIndexes: number[];
activeKey: number | null;
}
export interface DraggedEditorTab {
paneId: EditorPaneId;
index: number;
}
/** 待确认的关闭动作:目标标签仍含未保存的本地草稿。 */
export type PendingTabClose =
| { kind: "tab"; index: number; paneId: EditorPaneId }
| { kind: "others"; keepIndex: number }
| { kind: "all" };
export interface EditorLayoutPane {
kind: "pane";
paneId: EditorPaneId;
}
export interface EditorLayoutSplit {
kind: "split";
id: string;
orientation: SplitOrientation;
ratio: number;
first: EditorLayoutNode;
second: EditorLayoutNode;
}
export type EditorLayoutNode = EditorLayoutPane | EditorLayoutSplit;
interface LayoutReplacement {
node: EditorLayoutNode;
found: boolean;
destinationPaneId?: EditorPaneId;
}
/** 编辑器状态:全局文件内容 + 可递归分屏的多标签窗格 */
export const useEditorStore = defineStore("editor", () => {
const initialPaneId = "pane-1";
const tabs = ref<EditorTab[]>([]);
// 通用 GUI 的文件草稿独立于可见标签,仍参与保存和归档关闭保护。
const guiDrafts = ref<EditorTab[]>([]);
const allFiles = computed(() => [...tabs.value, ...guiDrafts.value]);
function getFileDraft(index: number | string): EditorTab | undefined {
return allFiles.value.find((file) => typeof index === "string" ? file.path === index : file.index === index);
}
const paneStates = reactive<Record<EditorPaneId, EditorPaneState>>({
[initialPaneId]: {
id: initialPaneId,
tabIndexes: [],
activeKey: null,
},
});
const layout = ref<EditorLayoutNode>({ kind: "pane", paneId: initialPaneId });
const activePaneId = ref<EditorPaneId>(initialPaneId);
const draggingTab = ref<DraggedEditorTab | null>(null);
const openingPaneId = computed<EditorPaneId | null>(() =>
Object.values(paneStates).find((pane) =>
tabs.value.some((tab) => tab.loading && pane.activeKey === tab.index)
)?.id ?? null
);
const saving = ref(false);
const archiveSaveStage = ref<"preparing" | "saving" | "saveAs" | "finishing" | null>(null);
const archiveSaveProgress = ref<ArchiveSaveProgress | null>(null);
Events.On("archive:save-progress", (event: any) => {
if (archiveSaveStage.value !== "saving" && archiveSaveStage.value !== "saveAs") return;
const data = event?.data ?? event;
if (typeof data?.phase !== "string" || !Number.isFinite(data.done) || !Number.isFinite(data.total)) return;
archiveSaveProgress.value = {
phase: data.phase,
done: Math.max(0, data.done),
total: Math.max(0, data.total),
unit: data.unit ?? "",
};
});
const guiApplying = ref(false);
const guiRefreshWarning = ref("");
const script = useScriptStore();
let paneSequence = 1;
let splitSequence = 0;
const panes = computed(() => {
const ids: EditorPaneId[] = [];
collectPaneIds(layout.value, ids);
return ids.map((id) => paneStates[id]).filter((pane): pane is EditorPaneState => !!pane);
});
const isSplit = computed(() => panes.value.length > 1);
const activePane = computed<EditorPaneState>(
() => paneStates[activePaneId.value] ?? panes.value[0] ?? paneStates[initialPaneId]
);
const activeKey = computed<number | null>({
get: () => activePane.value.activeKey,
set: (value) => {
activePane.value.activeKey = value;
},
});
const activeTab = computed(
() => tabs.value.find((tab) => tab.index === activePane.value.activeKey) ?? null
);
const guiDirtyCheckers = reactive(new Map<number, Set<() => boolean>>());
function registerGUIDirtyChecker(index: number, checker: () => boolean) {
let set = guiDirtyCheckers.get(index);
if (!set) {
set = new Set();
guiDirtyCheckers.set(index, set);
}
set.add(checker);
return () => {
const current = guiDirtyCheckers.get(index);
if (current) {
current.delete(checker);
if (current.size === 0) {
guiDirtyCheckers.delete(index);
}
}
};
}
function hasGUIDirty(index: number): boolean {
const checkers = guiDirtyCheckers.get(index);
if (!checkers || checkers.size === 0) return false;
for (const check of checkers) {
if (check()) return true;
}
return false;
}
function isTextDirty(tab: EditorTab): boolean {
return tab.editable && tab.text !== tab.original;
}
function isDirty(tab: EditorTab): boolean {
if (tab.index === COMPARE_TAB_INDEX) return useArchiveDiffStore().isDraftDirty;
if (!tab.editable) return false;
return isTextDirty(tab) || hasGUIDirty(tab.index);
}
function hasGUIOnlyDirty(tab: EditorTab): boolean {
return tab.editable && !isTextDirty(tab) && hasGUIDirty(tab.index);
}
const dirtyCount = computed(() => allFiles.value.filter(isDirty).length);
const pendingClose = ref<PendingTabClose | null>(null);
function collectPaneIds(node: EditorLayoutNode, result: EditorPaneId[]): void {
if (node.kind === "pane") {
result.push(node.paneId);
return;
}
collectPaneIds(node.first, result);
collectPaneIds(node.second, result);
}
function resolvePaneId(requested?: EditorPaneId): EditorPaneId {
if (requested && paneStates[requested] && panes.value.some((pane) => pane.id === requested)) {
return requested;
}
if (paneStates[activePaneId.value] && panes.value.some((pane) => pane.id === activePaneId.value)) {
return activePaneId.value;
}
return initialPaneId;
}
function activatePane(paneId: EditorPaneId): void {
if (!paneStates[paneId] || !panes.value.some((pane) => pane.id === paneId)) return;
activePaneId.value = paneId;
}
function beginTabDrag(paneId: EditorPaneId, index: number): void {
draggingTab.value = { paneId, index };
}
function endTabDrag(): void {
draggingTab.value = null;
}
function activateTab(paneId: EditorPaneId, index: number): void {
const pane = paneStates[paneId];
if (!pane || !pane.tabIndexes.includes(index)) return;
pane.activeKey = index;
activatePane(paneId);
}
function addTabToPane(paneId: EditorPaneId, index: number): void {
const pane = paneStates[paneId];
if (!pane) return;
if (!pane.tabIndexes.includes(index)) pane.tabIndexes.push(index);
pane.activeKey = index;
activatePane(paneId);
}
/** 打开文件(已在当前窗格则激活,已在其他窗格则复用内容并建立视图引用) */
async function openFile(index: number, requestedPaneId: EditorPaneId = activePaneId.value) {
const targetPaneId = resolvePaneId(requestedPaneId);
const targetPane = paneStates[targetPaneId];
if (targetPane.tabIndexes.includes(index)) {
script.showArchiveEditor();
activateTab(targetPaneId, index);
return;
}
const existing = tabs.value.find((tab) => tab.index === index);
if (existing) {
script.showArchiveEditor();
addTabToPane(targetPaneId, index);
return;
}
// 先占用标签名额并显示加载态;重复打开复用同一请求。
if (tabs.value.length >= 20) {
throw new Error("打开的标签过多,请先关闭一些(上限 20)");
}
const draft = guiDrafts.value.find((file) => file.index === index);
if (draft) {
guiDrafts.value = guiDrafts.value.filter((file) => file !== draft);
tabs.value.push(draft);
script.showArchiveEditor();
addTabToPane(targetPaneId, index);
if (!draft.loading && !draft.loadError) void hydrateTabMetadata(draft, draft.path, draft.text);
return;
}
const tab = createFileDraft(index);
tabs.value.push(tab);
script.showArchiveEditor();
addTabToPane(targetPaneId, index);
await loadTab(tab);
}
/** 只加载文件内容,不创建、激活或占用编辑器标签。 */
async function openGUIFile(index: number): Promise<EditorTab> {
let file = getFileDraft(index);
if (file?.loading) throw new Error("文件正在加载,请稍后再试");
if (file && !file.loadError) return file;
if (!file) {
file = createFileDraft(index);
guiDrafts.value.push(file);
} else {
file.loading = true;
file.loadError = null;
}
await loadTab(file);
if (!allFiles.value.includes(file)) throw new Error("归档已切换,已忽略旧技能");
if (file.loadError) throw new Error(file.loadError);
return file;
}
function createFileDraft(index: number): EditorTab {
const knownPath = useExplorerStore().getFilePath(index) ?? "";
return reactive<EditorTab>({
index,
path: knownPath,
title: knownPath.split("/").pop() || `文件 ${index}`,
dataType: 0,
size: 0,
tags: [],
editable: false,
original: "",
text: "",
modified: false,
annotations: [],
annotationsHidden: false,
lineWrapping: true,
icon: null,
fieldImage: null,
loading: true,
loadError: null,
});
}
async function loadTab(tab: EditorTab): Promise<void> {
const finish = startDebugTiming("editor.basic-request", `file=${tab.index} path=${tab.path}`);
let outcome = "finished";
try {
// 让 Vue 先提交加载态,再开始后端调用。
await nextTick();
if (!allFiles.value.includes(tab)) return;
const meta: FileMeta | null = await EditorService.GetFileBasic(tab.index);
// 关闭、重新打开或切换归档后,旧请求不能写入新标签。
if (!allFiles.value.includes(tab)) return;
if (!meta) throw new Error("文件不存在或无法读取");
Object.assign(tab, {
path: meta.path,
title: meta.path.split("/").pop() ?? meta.path,
dataType: meta.dataType,
size: meta.size,
tags: cleanTreeTags(meta.tags),
editable: meta.editable,
original: meta.text,
text: meta.text,
lineWrapping: !hasLongLine(meta.text),
modified: meta.modified,
annotations: cleanEditorAnnotations(meta.annotations),
icon: meta.icon ?? null,
fieldImage: meta.fieldImage ?? null,
});
void hydrateTabMetadata(tab, meta.path, meta.text);
} catch (error) {
outcome = "failed";
if (allFiles.value.includes(tab)) {
tab.loadError = error instanceof Error ? error.message : String(error);
}
} finally {
finish(outcome);
tab.loading = false;
}
}
async function hydrateTabMetadata(tab: EditorTab, path: string, baseText: string): Promise<void> {
await nextTick();
if (!tabs.value.includes(tab) || tab.path !== path) return;
const finish = startDebugTiming("editor.metadata-request", `file=${tab.index} path=${path}`);
let outcome = "finished";
try {
const meta: FileMeta | null = await EditorService.GetFile(tab.index);
const finishApply = startDebugTiming("editor.metadata-apply", `file=${tab.index} chars=${meta?.text.length ?? 0} annotations=${meta?.annotations?.length ?? 0}`);
try {
if (!meta || !tabs.value.includes(tab) || tab.path !== path || meta.path !== path) return;
tab.tags = cleanTreeTags(meta.tags);
tab.icon = meta.icon ?? null;
tab.fieldImage = meta.fieldImage ?? null;
if (tab.text === baseText) {
tab.annotations = cleanEditorAnnotations(meta.annotations);
}
} finally {
finishApply();
}
} catch {
outcome = "failed";
// Basic text stays usable if optional metadata enrichment fails.
} finally {
finish(outcome);
}
}
async function retryOpenFile(index: number): Promise<void> {
const tab = tabs.value.find((item) => item.index === index);
if (!tab || tab.loading || !tab.loadError) return;
tab.loading = true;
tab.loadError = null;
await loadTab(tab);
}
function closeTab(index: number, requestedPaneId: EditorPaneId = activePaneId.value): void {
const paneId = resolvePaneId(requestedPaneId);
const pane = paneStates[paneId];
if (!pane) return;
const tab = tabs.value.find((item) => item.index === index);
const tabIndex = pane.tabIndexes.indexOf(index);
if (tabIndex < 0) return;
pane.tabIndexes.splice(tabIndex, 1);
if (pane.activeKey === index) {
pane.activeKey = pane.tabIndexes[Math.min(tabIndex, pane.tabIndexes.length - 1)] ?? null;
}
const stillUsed = Object.values(paneStates).some((item) => item.tabIndexes.includes(index));
if (!stillUsed) {
const globalTabIndex = tabs.value.findIndex((tab) => tab.index === index);
if (globalTabIndex >= 0) tabs.value.splice(globalTabIndex, 1);
clearExplorerSelection(tab?.path);
if (index === COMPARE_TAB_INDEX) useArchiveDiffStore().close();
}
if (pane.tabIndexes.length === 0 && isSplit.value) {
closeSplit(paneId);
}
}
/** 关闭所有标签,并清理分屏中对这些标签的引用。 */
function closeAllTabs(): void {
closeTabsExcept(null);
}
/** 关闭除指定标签外的所有标签,保留该标签在已有窗格中的引用。 */
function closeOtherTabs(keepIndex: number): void {
closeTabsExcept(keepIndex);
}
function closeTabsExcept(keepIndex: number | null): void {
const removedIndexes = new Set(
tabs.value
.map((tab) => tab.index)
.filter((index) => keepIndex === null || index !== keepIndex)
);
if (removedIndexes.size === 0) return;
const removedPaths = tabs.value
.filter((tab) => removedIndexes.has(tab.index))
.map((tab) => tab.path);
for (const pane of Object.values(paneStates)) {
const oldActive = pane.activeKey;
pane.tabIndexes = pane.tabIndexes.filter((index) => !removedIndexes.has(index));
if (oldActive !== null && !removedIndexes.has(oldActive)) {
pane.activeKey = oldActive;
} else {
pane.activeKey = pane.tabIndexes[pane.tabIndexes.length - 1] ?? null;
}
}
tabs.value = tabs.value.filter((tab) => !removedIndexes.has(tab.index));
if (removedIndexes.has(COMPARE_TAB_INDEX)) useArchiveDiffStore().close();
clearExplorerSelection(removedPaths);
while (isSplit.value) {
const emptyPane = panes.value.find((pane) => pane.tabIndexes.length === 0);
if (!emptyPane) break;
closeSplit(emptyPane.id);
}
}
/** 把标签引用移动到另一个窗格,源窗格变空时默认自动收起。 */
function moveTab(
index: number,
requestedSourcePaneId: EditorPaneId,
requestedTargetPaneId: EditorPaneId,
closeEmptySource = true
): void {
const sourcePaneId = resolvePaneId(requestedSourcePaneId);
const targetPaneId = resolvePaneId(requestedTargetPaneId);
if (sourcePaneId === targetPaneId) {
activateTab(targetPaneId, index);
return;
}
const source = paneStates[sourcePaneId];
const target = paneStates[targetPaneId];
if (!source || !target) return;
const sourceIndex = source.tabIndexes.indexOf(index);
if (sourceIndex < 0) return;
source.tabIndexes.splice(sourceIndex, 1);
if (source.activeKey === index) {
source.activeKey = source.tabIndexes[Math.min(sourceIndex, source.tabIndexes.length - 1)] ?? null;
}
if (!target.tabIndexes.includes(index)) target.tabIndexes.push(index);
target.activeKey = index;
activePaneId.value = targetPaneId;
if (closeEmptySource && source.tabIndexes.length === 0 && isSplit.value) {
closeSplit(sourcePaneId);
if (paneStates[targetPaneId] && panes.value.some((pane) => pane.id === targetPaneId)) {
activePaneId.value = targetPaneId;
}
}
}
function nextPaneId(): EditorPaneId {
paneSequence += 1;
return `pane-${paneSequence}`;
}
function nextSplitId(): string {
splitSequence += 1;
return `split-${splitSequence}`;
}
function replacePane(
node: EditorLayoutNode,
targetPaneId: EditorPaneId,
replacement: EditorLayoutNode
): LayoutReplacement {
if (node.kind === "pane") {
return node.paneId === targetPaneId
? { node: replacement, found: true }
: { node, found: false };
}
const first = replacePane(node.first, targetPaneId, replacement);
if (first.found) return { node: { ...node, first: first.node }, found: true };
const second = replacePane(node.second, targetPaneId, replacement);
if (second.found) return { node: { ...node, second: second.node }, found: true };
return { node, found: false };
}
function createSplit(
targetPaneId: EditorPaneId,
orientation: SplitOrientation,
insertBefore: boolean,
cloneActiveTab: boolean
): EditorPaneId | null {
const targetPane = paneStates[targetPaneId];
if (!targetPane) return null;
const newPaneId = nextPaneId();
const targetNode: EditorLayoutPane = { kind: "pane", paneId: targetPaneId };
const newNode: EditorLayoutPane = { kind: "pane", paneId: newPaneId };
const replacement: EditorLayoutSplit = {
kind: "split",
id: nextSplitId(),
orientation,
ratio: 0.5,
first: insertBefore ? newNode : targetNode,
second: insertBefore ? targetNode : newNode,
};
const result = replacePane(layout.value, targetPaneId, replacement);
if (!result.found) return null;
const clonedTab = cloneActiveTab ? targetPane.activeKey : null;
paneStates[newPaneId] = {
id: newPaneId,
tabIndexes: clonedTab === null ? [] : [clonedTab],
activeKey: clonedTab,
};
layout.value = result.node;
return newPaneId;
}
function split(orientation: SplitOrientation, requestedPaneId: EditorPaneId = activePaneId.value): void {
const targetPaneId = resolvePaneId(requestedPaneId);
const newPaneId = createSplit(targetPaneId, orientation, false, true);
if (!newPaneId) return;
activePaneId.value = newPaneId;
}
function splitAndMoveTab(
index: number,
requestedSourcePaneId: EditorPaneId,
requestedTargetPaneId: EditorPaneId,
orientation: SplitOrientation,
insertBefore: boolean
): void {
const sourcePaneId = resolvePaneId(requestedSourcePaneId);
const targetPaneId = resolvePaneId(requestedTargetPaneId);
const source = paneStates[sourcePaneId];
if (!source || !source.tabIndexes.includes(index)) return;
const newPaneId = createSplit(targetPaneId, orientation, insertBefore, false);
if (!newPaneId) return;
moveTab(index, sourcePaneId, newPaneId, false);
activePaneId.value = newPaneId;
}
function removePane(node: EditorLayoutNode, targetPaneId: EditorPaneId): LayoutReplacement {
if (node.kind === "pane") return { node, found: false };
if (node.first.kind === "pane" && node.first.paneId === targetPaneId) {
return {
node: node.second,
found: true,
destinationPaneId: firstPaneId(node.second),
};
}
if (node.second.kind === "pane" && node.second.paneId === targetPaneId) {
return {
node: node.first,
found: true,
destinationPaneId: firstPaneId(node.first),
};
}
const first = removePane(node.first, targetPaneId);
if (first.found) {
return {
node: { ...node, first: first.node },
found: true,
destinationPaneId: first.destinationPaneId,
};
}
const second = removePane(node.second, targetPaneId);
if (second.found) {
return {
node: { ...node, second: second.node },
found: true,
destinationPaneId: second.destinationPaneId,
};
}
return { node, found: false };
}
function firstPaneId(node: EditorLayoutNode): EditorPaneId {
return node.kind === "pane" ? node.paneId : firstPaneId(node.first);
}
function mergePaneTabs(fromPaneId: EditorPaneId, toPaneId: EditorPaneId): void {
const from = paneStates[fromPaneId];
const to = paneStates[toPaneId];
if (!from || !to) return;
const fromActive = from.activeKey;
for (const index of from.tabIndexes) {
if (!to.tabIndexes.includes(index)) to.tabIndexes.push(index);
}
if (fromActive !== null && to.tabIndexes.includes(fromActive)) to.activeKey = fromActive;
}
/** 关闭指定窗格,将其标签并入剩余兄弟树的最近兄弟窗格。 */
function closeSplit(requestedPaneId: EditorPaneId = activePaneId.value): void {
const currentPaneId = resolvePaneId(requestedPaneId);
const currentPane = paneStates[currentPaneId];
const result = removePane(layout.value, currentPaneId);
if (!currentPane || !result.found) return;
const destinationPaneId = result.destinationPaneId ?? firstPaneId(result.node);
mergePaneTabs(currentPaneId, destinationPaneId);
layout.value = result.node;
delete paneStates[currentPaneId];
delete paneGUIModes[currentPaneId];
activePaneId.value = destinationPaneId;
}
function findSplit(node: EditorLayoutNode, splitId: string): EditorLayoutSplit | null {
if (node.kind === "pane") return null;
if (node.id === splitId) return node;
return findSplit(node.first, splitId) ?? findSplit(node.second, splitId);
}
function setSplitRatio(splitId: string, value: number): void {
const splitNode = findSplit(layout.value, splitId);
if (!splitNode || !Number.isFinite(value)) return;
splitNode.ratio = Math.min(0.8, Math.max(0.2, value));
}
/** 编辑器内容变化:只更新本地文本,写入 overlay 由保存动作显式触发。 */
function updateContent(index: number, text: string) {
if (guiApplying.value) return;
const tab = getFileDraft(index);
if (!tab || !tab.editable) return;
tab.text = text;
}
function toggleAnnotationsHidden(index: number): void {
const tab = tabs.value.find((item) => item.index === index);
if (tab) tab.annotationsHidden = !tab.annotationsHidden;
}
const paneGUIModes = reactive<Record<EditorPaneId, ReturnType<typeof createGUIModes>>>({});
function getGUIModes(paneId: EditorPaneId) {
if (!paneGUIModes[paneId]) {
paneGUIModes[paneId] = createGUIModes();
}
return paneGUIModes[paneId];
}
function setGUIMode(index: number, mode: "text" | "gui", requestedPaneId: EditorPaneId = activePaneId.value): void {
const paneId = resolvePaneId(requestedPaneId);
const modes = getGUIModes(paneId);
modes.set(index, mode);
}
function itemCreationDrafts() {
return allFiles.value.filter(isTextDirty).map(tab => ({ fileIndex: tab.index, path: tab.path, text: tab.text }));
}
async function createItem(request: ItemCreateRequest): Promise<ItemCreateResult> {
assertMCPIdle();
if (saving.value || guiApplying.value) throw new Error("正在保存,请稍后再试");
const gui = useFileGUIStore(), epoch = gui.epoch;
saving.value = true;
guiApplying.value = true;
try {
await nextTick();
const result = await FileGUIService.CreateItem({ ...request, drafts: itemCreationDrafts() });
if (!result) throw new Error("未收到物品创建结果");
if (gui.epoch !== epoch) throw new Error("归档已切换,已忽略旧界面的创建结果");
for (const file of result.files ?? []) {
const tab = getFileDraft(file.fileIndex);
if (tab?.path === file.path && (tab.text === file.beforeText || tab.text === file.text)) {
tab.text = file.text;
tab.original = file.text;
tab.modified = true;
}
}
const refreshed = await Promise.allSettled([
refreshBatchFiles((result.files ?? []).map(file => file.fileIndex)),
useArchiveStore().refreshInfo(),
]);
if (refreshed.some(r => r.status === 'rejected')) guiRefreshWarning.value = '物品已创建,部分界面刷新失败,可重新打开文件';
return result;
} finally { saving.value = false; guiApplying.value = false; }
}
/** 提交商店 GUI 编辑:校验草稿一致性与归档代次,成功后同步受影响标签的文本。 */
async function applyShopEdit(request: ShopEditRequest): Promise<ShopEditResult> {
if (saving.value) throw new Error("正在保存,请稍后再试");
const source = tabs.value.find((tab) => tab.index === request.fileIndex && tab.path === request.path);
if (!source || source.text !== request.text) throw new Error("商店草稿已变化,请关闭表单并重新打开");
const gui = useFileGUIStore();
const epoch = gui.epoch;
request = { ...request, drafts: allFiles.value.filter(isTextDirty).map((tab) => ({ fileIndex: tab.index, path: tab.path, text: tab.text })) };
saving.value = true;
guiApplying.value = true;
guiRefreshWarning.value = "";
try {
await nextTick();
const result = await FileGUIService.ApplyShopEdit(request);
if (!result) throw new Error("未收到商店编辑结果");
if (gui.epoch !== epoch) throw new Error("归档已切换,已忽略旧界面的编辑结果");
for (const file of result.files ?? []) {
const tab = tabs.value.find((item) => item.index === file.fileIndex && item.path === file.path);
if (!tab) continue;
if (tab.text === file.beforeText || tab.text === file.text) {
tab.text = file.text;
tab.original = file.text;
tab.modified = true;
}
}
const refreshes = await Promise.allSettled([refreshBatchFiles((result.files ?? []).map((file) => file.fileIndex)), useArchiveStore().refreshInfo()]);
if (refreshes.some((refresh) => refresh.status === "rejected")) {
guiRefreshWarning.value = "修改已应用,部分标签信息刷新失败,可重新打开相关文件";
}
return result;
} finally {
saving.value = false;
guiApplying.value = false;
}
}
/** 提交全局掉率 GUI 编辑:校验草稿一致性与归档代次,成功后同步受影响标签的文本。 */
async function applyWorldDropEdit(request: WorldDropEditRequest): Promise<WorldDropEditResult> {
if (saving.value) throw new Error("正在保存,请稍后再试");
const source = tabs.value.find((tab) => tab.index === request.fileIndex && tab.path === request.path);
if (!source || source.text !== request.text) throw new Error("全局掉率草稿已变化,请重新加载数据");
const gui = useFileGUIStore();
const epoch = gui.epoch;
saving.value = true;
guiApplying.value = true;
guiRefreshWarning.value = "";
try {
await nextTick();
const result = await FileGUIService.ApplyWorldDropEdit(request);
if (!result) throw new Error("未收到全局掉率编辑结果");
if (gui.epoch !== epoch) throw new Error("归档已切换,已忽略旧界面的编辑结果");
for (const file of result.files ?? []) {
const tab = tabs.value.find((item) => item.index === file.fileIndex && item.path === file.path);
if (!tab) continue;
if (tab.text === file.beforeText || tab.text === file.text) {
tab.text = file.text;
tab.original = file.text;
tab.modified = true;
}
}
const refreshes = await Promise.allSettled([
refreshBatchFiles((result.files ?? []).map((file) => file.fileIndex)),
useArchiveStore().refreshInfo(),
]);
if (refreshes.some((refresh) => refresh.status === "rejected")) {
guiRefreshWarning.value = "修改已应用,部分标签信息刷新失败,可重新打开相关文件";
}
return result;
} finally {
saving.value = false;
guiApplying.value = false;
}
}
/** 通过工具菜单或快捷入口打开全局掉率文件并切换到 GUI 模式 */
async function openWorldDrop(requestedPaneId?: EditorPaneId): Promise<boolean> {
const targetPaneId = resolvePaneId(requestedPaneId);
const nodes = await ArchiveService.ResolveFiles(["etc/worlddrop.etc", "etc/(r)worlddrop.etc"]);
const target = nodes?.find((n) => n && !n.isDir && n.fileIndex >= 0);
if (!target) {
throw new Error("归档中未找到全局掉率文件 etc/worlddrop.etc 或 etc/(r)worlddrop.etc");
}
await openFile(target.fileIndex, targetPaneId);
const tab = tabs.value.find((t) => t.index === target.fileIndex);
if (tab?.loadError) {
throw new Error(tab.loadError);
}
setGUIMode(target.fileIndex, "gui", targetPaneId);
return true;
}
async function openIndependentDrop(requestedPaneId?: EditorPaneId): Promise<boolean> {
const targetPaneId = resolvePaneId(requestedPaneId);
const nodes = await ArchiveService.ResolveFiles(["etc/independent_drop.etc"]);
const target = nodes?.find(node => node && !node.isDir && node.fileIndex >= 0);
if (!target) throw new Error("此归档未包含 etc/independent_drop.etc,可能不支持独立掉落");
await openFile(target.fileIndex, targetPaneId);
const tab = tabs.value.find(tab => tab.index === target.fileIndex);
if (!tab) return false;
if (tab.loadError) throw new Error(tab.loadError);
setGUIMode(target.fileIndex, "gui", targetPaneId);
return true;
}
/** 工具菜单与文件 GUI 共用同一标签,保留已打开文件的草稿。 */
async function openUpgrade(amplify = false, requestedPaneId?: EditorPaneId): Promise<boolean> {
const targetPaneId = resolvePaneId(requestedPaneId);
const path = amplify ? "etc/amplifyupgrade.etc" : "etc/upgrade.etc";
const nodes = await ArchiveService.ResolveFiles([path]);
const target = nodes?.find((node) => node && !node.isDir && node.fileIndex >= 0);
if (!target) throw new Error(`归档中未找到${amplify ? '增幅' : '强化'}文件 ${path}`);
await openFile(target.fileIndex, targetPaneId);
const tab = tabs.value.find((tab) => tab.index === target.fileIndex);
if (!tab) return false;
if (tab.loadError) throw new Error(tab.loadError);
setGUIMode(target.fileIndex, "gui", targetPaneId);
return true;
}
async function openForge(requestedPaneId?: EditorPaneId): Promise<boolean> {
const targetPaneId = resolvePaneId(requestedPaneId);
const path = "etc/upgrade_separate.etc";
const nodes = await ArchiveService.ResolveFiles([path]);
const target = nodes?.find((node) => node && !node.isDir && node.fileIndex >= 0);
if (!target) throw new Error(`归档中未找到锻造文件 ${path}`);
await openFile(target.fileIndex, targetPaneId);
const tab = tabs.value.find((tab) => tab.index === target.fileIndex);
if (!tab) return false;
if (tab.loadError) throw new Error(tab.loadError);
setGUIMode(target.fileIndex, "gui", targetPaneId);
return true;
}
async function openCeraShop(requestedPaneId?: EditorPaneId): Promise<boolean> {
const targetPaneId = resolvePaneId(requestedPaneId);
const gui = useFileGUIStore(), epoch = gui.epoch;
const paths = ["etc/cerashop.etc", "etc/(r)cerashop.etc"];
const nodes = await ArchiveService.ResolveFiles(paths);
if (epoch !== gui.epoch) return false;
const target = paths.map(path => nodes?.find(node => node && !node.isDir && node.fileIndex >= 0 && node.path.toLowerCase() === path)).find(node => !!node);
if (!target) throw new Error(`归档中未找到商城文件 ${paths.join(" 或 ")}`);
await openFile(target.fileIndex, targetPaneId);
if (epoch !== gui.epoch) return false;
const tab = tabs.value.find(tab => tab.index === target.fileIndex);
if (!tab) return false;
if (tab.loadError) throw new Error(tab.loadError);
setGUIMode(target.fileIndex, "gui", targetPaneId);
return true;
}
/** 打开或激活归档对比特殊 Tab */
function openCompareTab(requestedPaneId?: EditorPaneId): void {
const targetPaneId = resolvePaneId(requestedPaneId);
let tab = tabs.value.find((t) => t.index === COMPARE_TAB_INDEX);
if (!tab) {
tab = {
index: COMPARE_TAB_INDEX,
path: "__compare__",
title: "归档对比",
dataType: 0,
size: 0,
tags: [],
editable: false,
original: "",
text: "",
modified: false,
annotations: [],
annotationsHidden: false,
lineWrapping: false,
icon: null,
fieldImage: null,
loading: false,
loadError: null,
};
tabs.value.push(tab);
}
addTabToPane(targetPaneId, COMPARE_TAB_INDEX);
const pane = paneStates[targetPaneId];
if (pane) {
pane.activeKey = COMPARE_TAB_INDEX;
}
activePaneId.value = targetPaneId;
}
/** 把单个标签的本地文本写入后端 overlay,成功后重置脏基线。 */
async function saveTab(index: number): Promise<boolean> {
const tab = getFileDraft(index);
if (!tab || !isTextDirty(tab)) return false;
const text = tab.text;
await EditorService.SetText(tab.index, text);
const annotations = (await EditorService.GetAnnotations(tab.index)) ?? [];
const current = getFileDraft(tab.index);
if (current !== tab) return true;
// 等待期间用户继续输入时保留其草稿,下一次保存再写入。
if (current.text === text) {
current.original = text;
current.annotations = cleanEditorAnnotations(annotations);
}
await useExplorerStore().refreshTreeTags();
return true;
}
/** 保存指定文件,供文件 GUI 与通用 GUI 共用。 */
async function saveFileTab(index: number): Promise<boolean> {
assertMCPIdle();
if (saving.value) return false;
if (index === COMPARE_TAB_INDEX) return saveCompareDraft();
const tab = getFileDraft(index);
if (!tab || !tab.editable) return false;
if (tab && tab.editable && hasGUIDirty(tab.index)) {
throw new Error(`当前文件存在未应用的界面修改,请先在界面中点击【应用修改】后再保存`);
}
saving.value = true;
try {
const saved = await saveTab(index);
if (saved) await useArchiveStore().refreshInfo();
return saved;
} finally {
saving.value = false;
}
}
/** 保存指定窗格的活动标签(默认当前窗格)。无修改时为空操作。 */
async function saveActiveTab(requestedPaneId?: EditorPaneId): Promise<boolean> {
const paneId = resolvePaneId(requestedPaneId);
const index = paneStates[paneId]?.activeKey ?? null;
return index === null ? false : saveFileTab(index);
}
/** 捕获只读对比草稿,不应用表单、不改变脏基线。 */
function archiveDiffDrafts() {
const pending = allFiles.value.find(tab => hasGUIDirty(tab.index));
if (pending) throw new Error(`文件 ${pending.title || pending.path} 存在未应用的界面修改,请先点击【应用修改】后再比较`);
return allFiles.value.filter(isTextDirty).map(tab => ({ fileIndex: tab.index, path: tab.path, text: tab.text }));
}
/** 把所有本地有修改的标签写入 overlay(PVF 写盘前调用)。 */
async function saveAllDirty(): Promise<number> {
const indexes = allFiles.value.filter((tab) => isTextDirty(tab)).map((tab) => tab.index);
let saved = 0;
for (const index of indexes) {
if (await saveTab(index)) saved += 1;
}
if (await saveCompareDraft()) saved += 1;
if (saved > 0) await useArchiveStore().refreshInfo();
return saved;
}
async function saveCompareDraft(): Promise<boolean> {
if (!tabs.value.some(tab => tab.index === COMPARE_TAB_INDEX)) return false;
const diff = useArchiveDiffStore();
if (!diff.isDraftDirty) return false;
if (diff.merging || !await diff.mergeText(diff.draftPath, diff.draftText)) {
throw new Error("对比草稿尚未保存,请稍后重试");
}
return true;
}
/**
* 改写某个 `<表号::键名>` 占位符背后的显示文本。
* 只改字符串表(.str)的对应条目,脚本里的占位符保持不变。
*/
async function setPlaceholderText(
index: number,
tableIndex: number,
key: string,
text: string
): Promise<void> {
await EditorService.SetPlaceholderText(index, tableIndex, key, text);
const annotations = (await EditorService.GetAnnotations(index)) ?? [];
const current = tabs.value.find((item) => item.index === index);
if (current) {
current.annotations = cleanEditorAnnotations(annotations);
current.modified = true;
}
await useExplorerStore().refreshTreeTags();
await useArchiveStore().refreshInfo();
}
/** 保存到源文件 */
async function save() {
assertMCPIdle();
if (saving.value) throw new Error("正在保存,请稍后再试");
const archive = useArchiveStore();
const guiDirtyTab = allFiles.value.find((tab) => tab.editable && hasGUIDirty(tab.index));
if (guiDirtyTab) {
throw new Error(`文件 ${guiDirtyTab.title || guiDirtyTab.path} 存在未应用的界面修改,请先在界面中点击【应用修改】后再保存`);
}
saving.value = true;
archiveSaveStage.value = "preparing";
archiveSaveProgress.value = null;
try {
await nextTick();
await saveAllDirty();
archiveSaveStage.value = "saving";
const info = await EditorService.Save();
archiveSaveStage.value = "finishing";
archive.info = info;
// 保存成功后,文本与基准重置(overlay 已清空)
for (const tab of allFiles.value) {
tab.original = tab.text;
tab.modified = false;
}
return info;
} finally {
archiveSaveStage.value = null;
archiveSaveProgress.value = null;
saving.value = false;
}
}
/** 另存为新 PVF,返回保存路径(取消返回 null) */
async function saveAs() {
assertMCPIdle();
if (saving.value) throw new Error("正在保存,请稍后再试");
const archive = useArchiveStore();
const guiDirtyTab = allFiles.value.find((tab) => tab.editable && hasGUIDirty(tab.index));
if (guiDirtyTab) {
throw new Error(`文件 ${guiDirtyTab.title || guiDirtyTab.path} 存在未应用的界面修改,请先在界面中点击【应用修改】后再保存`);
}
saving.value = true;
archiveSaveStage.value = "preparing";
archiveSaveProgress.value = null;
try {
await nextTick();
await saveAllDirty();
archiveSaveStage.value = "saveAs";
const path = await EditorService.SaveAsDialog();
if (path) {
archiveSaveStage.value = "finishing";
await archive.refreshInfo();
}
return path || null;
} finally {
archiveSaveStage.value = null;
archiveSaveProgress.value = null;
saving.value = false;
}
}
function clearExplorerSelection(paths: string | string[] | undefined): void {
if (!paths) return;
const explorer = useExplorerStore();
const selection = explorer.selectedKey;
if (!selection) return;
const closedPaths = new Set(Array.isArray(paths) ? paths : [paths]);
if (closedPaths.has(selection)) explorer.selectedKey = null;
}
/** 关闭标签:存在未保存本地编辑时先请求确认。 */
function requestCloseTab(index: number, requestedPaneId: EditorPaneId = activePaneId.value): void {
if (pendingClose.value) return;
const tab = tabs.value.find((item) => item.index === index);
if (!tab) return;
if (isDirty(tab)) {
pendingClose.value = { kind: "tab", index, paneId: resolvePaneId(requestedPaneId) };
return;
}
closeTab(index, requestedPaneId);
}
/** 关闭其它标签:有待确认的脏标签时先请求确认。 */
function requestCloseOthers(keepIndex: number): void {
if (pendingClose.value) return;
const hasDirty = tabs.value.some((tab) => tab.index !== keepIndex && isDirty(tab));
if (hasDirty) {
pendingClose.value = { kind: "others", keepIndex };
return;
}
closeOtherTabs(keepIndex);
}
/** 关闭所有标签:有待确认的脏标签时先请求确认。 */
function requestCloseAll(): void {
if (pendingClose.value) return;
if (tabs.value.some((tab) => isDirty(tab))) {
pendingClose.value = { kind: "all" };
return;
}
closeAllTabs();
}
/** 确认丢弃未保存编辑并执行挂起的关闭动作。 */
function confirmPendingClose(): void {
const action = pendingClose.value;
if (!action) return;
pendingClose.value = null;
if (action.kind === "tab") closeTab(action.index, action.paneId);
else if (action.kind === "others") closeOtherTabs(action.keepIndex);
else closeAllTabs();
}
function cancelPendingClose(): void {
pendingClose.value = null;
}
async function refreshAnnotations() {
await Promise.all(
allFiles.value.filter((tab) => !tab.loading && !tab.loadError && tab.index >= 0 && tab.index !== COMPARE_TAB_INDEX).map(async (tab) => {
const text = tab.text;
const annotations = (await EditorService.GetAnnotationsForText(tab.index, text)) ?? [];
const current = allFiles.value.find((item) => item.index === tab.index);
if (!current || current.text !== text) return;
current.annotations = cleanEditorAnnotations(annotations);
})
);
}
/** 重新读取当前 renderer 生成的文本,但保留已修改标签的脏基线。 */
async function refreshRenderedText() {
await Promise.all(
allFiles.value.filter((tab) => !tab.loading && !tab.loadError && tab.index >= 0 && tab.index !== COMPARE_TAB_INDEX).map(async (tab) => {
const meta = await EditorService.GetFile(tab.index);
const current = allFiles.value.find((item) => item.index === tab.index);
if (!current || !meta) return;
current.path = meta.path;
current.title = meta.path.split("/").pop() ?? meta.path;
current.dataType = meta.dataType;
current.size = meta.size;
current.editable = meta.editable;
current.tags = cleanTreeTags(meta.tags);
current.icon = meta.icon ?? null;
current.fieldImage = meta.fieldImage ?? null;
current.modified = meta.modified;
// 本地有未保存编辑时保留编辑器内容,避免重载规则覆盖用户草稿。
if (!isDirty(current)) {
current.text = meta.text;
if (!meta.modified) current.original = meta.text;
}
if (isDirty(current)) {
const text = current.text;
const annotations = await EditorService.GetAnnotationsForText(current.index, text);
if (current.text === text && allFiles.value.includes(current)) current.annotations = cleanEditorAnnotations(annotations);
} else current.annotations = cleanEditorAnnotations(meta.annotations);
})
);
}
/** 批处理写入 overlay 后刷新已经打开的标签,但保留原始文本基准。 */
async function refreshBatchFiles(indexes: number[]) {
const uniqueIndexes = [...new Set(indexes)];
await Promise.all(
uniqueIndexes.map(async (index) => {
const current = allFiles.value.find((tab) => tab.index === index);
if (!current) return;
const meta = await EditorService.GetFile(index);
if (!meta) return;
current.path = meta.path;
current.modified = meta.modified;
current.tags = cleanTreeTags(meta.tags);
current.annotations = cleanEditorAnnotations(meta.annotations);
current.icon = meta.icon ?? null;
current.fieldImage = meta.fieldImage ?? null;
// 本地有未保存编辑时保留编辑器内容,让保存动作以用户文本为准。
if (!isDirty(current)) current.text = meta.text;
})
);
}
/** 文件表变化后按路径重新绑定标签,并刷新被自动修改的 lst 标签。 */
async function refreshAfterArchiveChange(
refreshPaths: string[] = [],
resetOriginal = false
): Promise<void> {
// 结构变更后旧索引已失效;标签中的本地文本保留,后续保存会按新索引写入。
if (allFiles.value.length === 0) return;
const visibleTabs = new Set(tabs.value);
const currentTabs = [...allFiles.value];
const nodes = (await ArchiveService.ResolveFiles(currentTabs.map((tab) => tab.path))) ?? [];
const normalize = (p: string) => p.replaceAll("\\", "/").replace(/^\/+|\/+$/g, "").toLowerCase();
const indexByPath = new Map(
nodes
.filter((node): node is NonNullable<typeof node> => !!node && !node.isDir)
.map((node) => [normalize(node.path), node])
);
const nextIndexByOld = new Map<number, number>();
const remainingTabs: EditorTab[] = [];
const pathsToRefresh = new Set(refreshPaths.map(normalize));
for (const tab of currentTabs) {
if (tab.index === COMPARE_TAB_INDEX || tab.index < 0) {
nextIndexByOld.set(tab.index, tab.index);
remainingTabs.push(tab);
continue;
}
const node = indexByPath.get(normalize(tab.path));
if (!node) continue;
nextIndexByOld.set(tab.index, node.fileIndex);
tab.index = node.fileIndex;
tab.path = node.path;
tab.title = node.path.split("/").pop() ?? node.path;
tab.size = node.size;
tab.dataType = node.dataType;
tab.tags = cleanTreeTags(node.tags);
tab.icon = node.icon ?? null;
tab.fieldImage = node.fieldImage ?? null;
remainingTabs.push(tab);
}
for (const pane of Object.values(paneStates)) {
const oldActive = pane.activeKey;
const nextIndexes = pane.tabIndexes
.map((index) => nextIndexByOld.get(index))
.filter((index): index is number => index !== undefined);
pane.tabIndexes.splice(0, pane.tabIndexes.length, ...nextIndexes);
pane.activeKey =
(oldActive === null ? null : nextIndexByOld.get(oldActive)) ??
pane.tabIndexes[pane.tabIndexes.length - 1] ??
null;
}
tabs.value = remainingTabs.filter((tab) => visibleTabs.has(tab));
guiDrafts.value = remainingTabs.filter((tab) => !visibleTabs.has(tab));
await Promise.all(
remainingTabs
.filter((tab) => tab.index >= 0 && pathsToRefresh.has(normalize(tab.path)))
.map(async (tab) => {
const meta = await EditorService.GetFile(tab.index);
if (!meta) return;
tab.path = meta.path;
tab.title = meta.path.split("/").pop() ?? meta.path;
tab.dataType = meta.dataType;
tab.size = meta.size;
tab.tags = cleanTreeTags(meta.tags);
tab.icon = meta.icon ?? null;
tab.fieldImage = meta.fieldImage ?? null;
if (resetOriginal) {
// 归档整体重载:索引已失效,直接采用新内容。
tab.text = meta.text;
tab.original = meta.text;
tab.modified = meta.modified;
} else {
tab.modified = meta.modified;
// 本地有未保存编辑时保留编辑器内容,不被后端结果覆盖。
if (!isDirty(tab)) tab.text = meta.text;
}
tab.annotations = cleanEditorAnnotations(meta.annotations);
})
);
while (isSplit.value) {
const emptyPane = panes.value.find((pane) => pane.tabIndexes.length === 0);
if (!emptyPane) break;
closeSplit(emptyPane.id);
}
}
/** Called only while the MCP preparation barrier holds and drafts are clean. */
async function syncMCPWorkspace(): Promise<void> {
await refreshAfterArchiveChange(allFiles.value.map(tab => tab.path), true);
}
async function refreshOpenTabTags(): Promise<void> {
const currentTabs = tabs.value.filter((tab) => !tab.loading && !tab.loadError && tab.index >= 0 && tab.index !== COMPARE_TAB_INDEX);
await Promise.all(
currentTabs.map(async (tab) => {
const meta = await EditorService.GetFile(tab.index).catch(() => null);
const current = tabs.value.find((item) => item.index === tab.index);
if (!current || !meta) return;
current.tags = cleanTreeTags(meta.tags);
current.icon = meta.icon ?? null;
current.fieldImage = meta.fieldImage ?? null;
current.annotations = cleanEditorAnnotations(meta.annotations);
})
);
}
Events.On("archive:reloaded", () => {
void refreshAfterArchiveChange(allFiles.value.map((tab) => tab.path), true);
});
Events.On("archive:opened", () => { guiDrafts.value = []; });
Events.On("archive:closed", () => {
guiDrafts.value = [];
pendingClose.value = null;
draggingTab.value = null;
guiDirtyCheckers.clear();
for (const modes of Object.values(paneGUIModes)) modes.reset();
closeAllTabs();
});
/**
* 归档对比合并专用刷新:强制用后端最新文本与元数据覆盖对应标签,
* 并重置 dirty 基线 (original = text),确保主编辑器与合入内容严格一致。
*/
async function refreshMergedFiles(paths: string[]): Promise<void> {
if (!paths || paths.length === 0 || allFiles.value.length === 0) return;
const normalize = (p: string) => p.replaceAll("\\", "/").replace(/^\/+|\/+$/g, "").toLowerCase();
const pathSet = new Set(paths.map(normalize));
const targetTabs = allFiles.value.filter((tab) => pathSet.has(normalize(tab.path)));
if (targetTabs.length === 0) return;
const nodes = (await ArchiveService.ResolveFiles(targetTabs.map((tab) => tab.path))) ?? [];
const nodeByPath = new Map(
nodes
.filter((node): node is NonNullable<typeof node> => !!node && !node.isDir)
.map((node) => [normalize(node.path), node])
);
await Promise.all(
targetTabs.map(async (tab) => {
const node = nodeByPath.get(normalize(tab.path));
if (!node) return;
tab.index = node.fileIndex;
const meta = await EditorService.GetFile(node.fileIndex);
if (!meta) return;
tab.path = meta.path;
tab.title = meta.path.split("/").pop() ?? meta.path;
tab.dataType = meta.dataType;
tab.size = meta.size;
tab.tags = cleanTreeTags(meta.tags);
tab.icon = meta.icon ?? null;
tab.fieldImage = meta.fieldImage ?? null;
tab.text = meta.text;
tab.original = meta.text;
tab.modified = meta.modified;
tab.annotations = cleanEditorAnnotations(meta.annotations);
})
);
}
// 脚本或批处理在别的窗口应用了变更时,本窗口的标签不会自己更新。事件是
// 广播的,所以这里同时覆盖同窗口(发起方已自行刷新,重复刷新是幂等的)
// 和独立脚本窗口发起的情况。
Events.On("archive:batch-applied", (event: any) => {
const data = event?.data ?? event;
const paths = Array.isArray(data?.paths)
? data.paths.filter((p: unknown): p is string => typeof p === "string")
: [];
if (data?.source === "archive-diff" || data?.source === "archive-diff-merge" || paths.length > 0) {
if (data?.structural) {
void refreshAfterArchiveChange(paths, true);
} else {
void refreshMergedFiles(paths);
}
return;
}
// 结构变更会让后续条目重新编号,必须按路径重新解析树与标签。
if (data?.structural) {
void refreshAfterArchiveChange([], false);
return;
}
const indexes = (data?.fileIndexes ?? []).filter(
(value: unknown): value is number => typeof value === "number",
);
if (indexes.length > 0) void refreshBatchFiles(indexes);
});
Events.On("archive:registrations-changed", (event: any) => {
const data = event?.data ?? event;
const indexes = (data?.fileIndexes ?? []).filter(
(value: unknown): value is number => typeof value === "number",
);
if (indexes.length > 0) void refreshBatchFiles(indexes);
});
Events.On("archive:index-ready", () => {
void refreshOpenTabTags();
});
Events.On("archive:index-updated", () => {
void refreshOpenTabTags();
});
return {
tabs,
getFileDraft,
openGUIFile,
itemCreationDrafts,
createItem,
panes,
layout,
activeKey,
activeTab,
dirtyCount,
activePaneId,
draggingTab,
isSplit,
opening: computed(() => tabs.value.some((tab) => tab.loading)),
openingPaneId,
saving,
archiveSaveStage,
archiveSaveProgress,
guiApplying,
guiRefreshWarning,
applyShopEdit,
applyWorldDropEdit,
openWorldDrop,
openIndependentDrop,
getGUIModes,
setGUIMode,
registerGUIDirtyChecker,
hasGUIDirty,
isTextDirty,
isDirty,
hasGUIOnlyDirty,
pendingClose,
openFile,
retryOpenFile,
activatePane,
beginTabDrag,
endTabDrag,
activateTab,
closeTab,
closeAllTabs,
closeOtherTabs,
requestCloseTab,
requestCloseOthers,
requestCloseAll,
confirmPendingClose,
cancelPendingClose,
moveTab,
split,
splitAndMoveTab,
closeSplit,
setSplitRatio,
updateContent,
toggleAnnotationsHidden,
setPlaceholderText,
saveTab,
saveFileTab,
archiveDiffDrafts,
openUpgrade,
openForge,
openCeraShop,
openCompareTab,
saveActiveTab,
saveAllDirty,
save,
saveAs,
refreshAnnotations,
refreshRenderedText,
refreshBatchFiles,
refreshAfterArchiveChange,
syncMCPWorkspace,
refreshMergedFiles,
};
});
function cleanTreeTags(tags: (TreeTag | null)[] | null | undefined): TreeTag[] {
return (tags ?? []).filter((tag): tag is TreeTag => !!tag);
}
/** 标注作为不可变快照使用,避免 Vue 为大文件建立深层代理。 */
function cleanEditorAnnotations(annotations: (EditorAnnotation | null)[] | null | undefined): EditorAnnotation[] {
return markRaw((annotations ?? []).filter((annotation): annotation is EditorAnnotation => !!annotation));
}