服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
1467 lines
54 KiB
TypeScript
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));
|
|
}
|