DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

+309
View File
@@ -0,0 +1,309 @@
import { computed, ref, watch } from "vue";
import { defineStore } from "pinia";
import { Events } from "@wailsio/runtime";
import { ArchiveService } from "../../bindings/pvfine/services";
import type {
AdvancedSearchDetail,
AdvancedSearchHit,
AdvancedSearchIndexStatus,
} from "../../bindings/pvfine/services/models";
import { useArchiveStore } from "./archive";
export type AdvancedSearchMode = "binary" | "string" | "quick";
export type AdvancedStringMatch = "text" | "regex";
export interface AdvancedSearchItem {
key: string;
name: string;
path: string;
size: number;
dataType: number;
fileIndex: number;
details: AdvancedSearchDetail[];
}
/** 高级搜索状态:弹窗隐藏时仍保留当前归档的查询与结果。 */
export const useAdvancedSearchStore = defineStore("advancedSearch", () => {
const archive = useArchiveStore();
const visible = ref(false);
const mode = ref<AdvancedSearchMode>("quick");
const exactMatch = ref(false);
const stringMatch = ref<AdvancedStringMatch>("text");
const query = ref("");
const scopePath = ref("");
const hits = ref<AdvancedSearchItem[]>([]);
const nextCursor = ref(-1);
const searching = ref(false);
const stale = ref(false);
const error = ref("");
const indexStatus = ref<AdvancedSearchIndexStatus>({
state: "idle",
stage: "",
done: 0,
total: 0,
error: "",
});
let requestId = 0;
let retryAfterIndex = false;
const regexEnabled = computed(() => mode.value === "string" && stringMatch.value === "regex");
const hasResults = computed(() => hits.value.length > 0);
function eventData(event: any): any {
return event?.data ?? event;
}
function readIndexStatus(data: any): AdvancedSearchIndexStatus {
return {
state: String(data?.state ?? "idle"),
stage: String(data?.stage ?? ""),
done: Number(data?.done ?? 0),
total: Number(data?.total ?? 0),
error: String(data?.error ?? ""),
};
}
function errorMessage(value: any): string {
return String(value?.message ?? value ?? "高级搜索失败");
}
function toItem(hit: AdvancedSearchHit): AdvancedSearchItem {
return {
key: `${hit.fileIndex}:${hit.path}:${hit.name ?? ""}:${(hit.details ?? []).filter((detail) => detail?.kind === "id").map((detail) => detail?.value).join(",")}`,
name: hit.name ?? "",
path: hit.path,
size: hit.size,
dataType: hit.dataType,
fileIndex: hit.fileIndex,
details: (hit.details ?? []).filter(
(detail): detail is AdvancedSearchDetail => !!detail
),
};
}
function open() {
visible.value = true;
}
function close() {
visible.value = false;
}
function clear() {
requestId++;
query.value = "";
hits.value = [];
nextCursor.value = -1;
searching.value = false;
stale.value = false;
error.value = "";
retryAfterIndex = false;
}
function resetForArchive() {
requestId++;
visible.value = false;
mode.value = "quick";
exactMatch.value = false;
stringMatch.value = "text";
scopePath.value = "";
clear();
indexStatus.value = readIndexStatus(null);
}
async function search() {
if (searching.value) return;
const request = ++requestId;
error.value = "";
stale.value = false;
hits.value = [];
nextCursor.value = -1;
retryAfterIndex = false;
if (!query.value.trim()) {
searching.value = false;
return;
}
if (!archive.open) {
error.value = "请先打开归档文件";
return;
}
if (regexEnabled.value) {
try {
// 这是前端即时反馈;最终语法仍由 Go RE2 后端校验。
new RegExp(query.value);
} catch (value: any) {
error.value = `正则表达式无效: ${value?.message ?? value}`;
return;
}
}
searching.value = true;
try {
const result = await ArchiveService.AdvancedSearch(
mode.value === "quick" && exactMatch.value ? "quick-exact" : mode.value,
query.value,
scopePath.value,
regexEnabled.value,
0,
200
);
if (request !== requestId) return;
const page = (result?.hits ?? [])
.filter((hit): hit is AdvancedSearchHit => !!hit)
.map(toItem);
hits.value = page;
nextCursor.value = result?.nextCursor ?? -1;
} catch (value) {
if (request !== requestId) return;
const message = errorMessage(value);
error.value = message;
retryAfterIndex = message.includes("索引正在构建");
} finally {
if (request === requestId) searching.value = false;
}
}
async function cancel() {
requestId++;
retryAfterIndex = false;
try {
await ArchiveService.CancelAdvancedSearch();
nextCursor.value = -1;
indexStatus.value = readIndexStatus(null);
stale.value = hits.value.length > 0;
} catch (value) {
error.value = errorMessage(value);
} finally {
searching.value = false;
}
}
async function loadMore() {
if (searching.value || stale.value || nextCursor.value < 0 || !query.value.trim()) return;
const request = requestId;
searching.value = true;
error.value = "";
try {
const result = await ArchiveService.AdvancedSearch(
mode.value === "quick" && exactMatch.value ? "quick-exact" : mode.value,
query.value,
scopePath.value,
regexEnabled.value,
nextCursor.value,
200
);
if (request !== requestId) return;
const page = (result?.hits ?? [])
.filter((hit): hit is AdvancedSearchHit => !!hit)
.map(toItem);
hits.value.push(...page);
nextCursor.value = result?.nextCursor ?? -1;
} catch (value) {
if (request === requestId) error.value = errorMessage(value);
} finally {
if (request === requestId) searching.value = false;
}
}
/** 加载剩余分页,调用方在成功后再一次性使用完整结果。 */
async function loadAll(): Promise<AdvancedSearchItem[] | null> {
if (searching.value || !query.value.trim()) return [...hits.value];
if (stale.value) return null;
const request = requestId;
searching.value = true;
error.value = "";
try {
let cursor = nextCursor.value;
while (cursor >= 0) {
const result = await ArchiveService.AdvancedSearch(
mode.value === "quick" && exactMatch.value ? "quick-exact" : mode.value,
query.value,
scopePath.value,
regexEnabled.value,
cursor,
200
);
if (request !== requestId || stale.value) return null;
const page = (result?.hits ?? [])
.filter((hit): hit is AdvancedSearchHit => !!hit)
.map(toItem);
hits.value.push(...page);
const next = result?.nextCursor ?? -1;
nextCursor.value = next;
if (next === cursor) break;
cursor = next;
}
return [...hits.value];
} catch (value) {
if (request === requestId) error.value = errorMessage(value);
throw value;
} finally {
if (request === requestId) searching.value = false;
}
}
function markStale() {
requestId++;
searching.value = false;
nextCursor.value = -1;
indexStatus.value = readIndexStatus(null);
if (query.value.trim() || hits.value.length > 0) stale.value = true;
}
watch([mode, query, scopePath, stringMatch, exactMatch], () => {
requestId++;
searching.value = false;
retryAfterIndex = false;
nextCursor.value = -1;
if (hits.value.length > 0) stale.value = true;
}, { flush: "sync" });
Events.On("archive:index-ready", () => {
if (mode.value === "quick" && retryAfterIndex && visible.value) {
retryAfterIndex = false;
void search();
}
});
Events.On("archive:opened", () => resetForArchive());
Events.On("archive:closed", () => resetForArchive());
Events.On("archive:advanced-search-stale", () => markStale());
Events.On("archive:advanced-index-progress", (event: any) => {
indexStatus.value = readIndexStatus(eventData(event));
});
Events.On("archive:advanced-index-ready", (event: any) => {
indexStatus.value = readIndexStatus(eventData(event));
if (retryAfterIndex && visible.value) {
retryAfterIndex = false;
void search();
}
});
Events.On("archive:advanced-index-error", (event: any) => {
indexStatus.value = readIndexStatus(eventData(event));
});
return {
visible,
mode,
stringMatch,
exactMatch,
query,
scopePath,
hits,
nextCursor,
searching,
stale,
error,
indexStatus,
regexEnabled,
hasResults,
open,
close,
clear,
search,
cancel,
loadMore,
loadAll,
};
});
+371
View File
@@ -0,0 +1,371 @@
import { assertMCPIdle } from "../mcpActivity";
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { Events } from "@wailsio/runtime";
import { ArchiveService, EditorService } from "../../bindings/pvfine/services";
import type {
ArchiveInfo,
FileRegistration,
FileRegistrationOptions,
IndexHashRegistrationResult,
IndexHashTarget,
IndexStatus,
} from "../../bindings/pvfine/services/models";
import type { StringTableIndexStats } from "../../bindings/pvfine/internal/pvf/models";
const recentArchivesKey = "pvfine.recentArchives";
const maxRecentArchives = 8;
/** 归档全局状态:打开/关闭/统计/解包进度 */
export const useArchiveStore = defineStore("archive", () => {
const recentArchives = ref<string[]>(readRecentArchives());
const info = ref<ArchiveInfo | null>(null);
const loading = ref(false);
const loadError = ref("");
const stringTableIndex = ref<StringTableIndexStats>(readStringTableIndex(null));
const indexStatus = ref<IndexStatus>({
state: "idle",
stage: "",
done: 0,
total: 0,
skipped: 0,
error: "",
refreshing: false,
refreshError: "",
cacheHit: false,
openDurationMs: 0,
buildDurationMs: 0,
});
// 解包状态
const unpacking = ref(false);
const unpackProgress = ref({ done: 0, total: 0 });
const unpackMessage = ref("");
const open = computed(() => !!info.value && info.value.path !== "");
const modifiedCount = computed(() => info.value?.modifiedCount ?? 0);
const indexing = computed(() => open.value && indexStatus.value.state === "building");
const refreshingIndex = computed(() => open.value && indexStatus.value.refreshing);
const indexReady = computed(() => open.value && indexStatus.value.state === "ready");
let indexPollTimer: number | undefined;
let indexPollBusy = false;
let indexStatusRevision = 0;
function readRecentArchives(): string[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(recentArchivesKey);
if (!raw) return [];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
const unique: string[] = [];
for (const path of parsed) {
if (typeof path !== "string" || path.trim() === "" || unique.includes(path)) continue;
unique.push(path);
if (unique.length >= maxRecentArchives) break;
}
return unique;
} catch {
return [];
}
}
function persistRecentArchives(): void {
try {
window.localStorage.setItem(recentArchivesKey, JSON.stringify(recentArchives.value));
} catch {
// 本地存储不可用时仍保留本次运行内的记录。
}
}
function rememberArchive(path: string): void {
if (!path.trim()) return;
recentArchives.value = [
path,
...recentArchives.value.filter((item) => item !== path),
].slice(0, maxRecentArchives);
persistRecentArchives();
}
function clearRecentArchives(): void {
recentArchives.value = [];
persistRecentArchives();
}
function removeRecentArchive(path: string): void {
recentArchives.value = recentArchives.value.filter((item) => item !== path);
persistRecentArchives();
}
function readIndexStatus(data: any): IndexStatus {
return {
state: String(data?.state ?? "idle"),
stage: String(data?.stage ?? ""),
done: Number(data?.done ?? 0),
total: Number(data?.total ?? 0),
skipped: Number(data?.skipped ?? 0),
error: String(data?.error ?? ""),
refreshing: Boolean(data?.refreshing ?? false),
refreshError: String(data?.refreshError ?? ""),
cacheHit: Boolean(data?.cacheHit ?? false),
openDurationMs: Number(data?.openDurationMs ?? 0),
buildDurationMs: Number(data?.buildDurationMs ?? 0),
stringTableIndex: data?.stringTableIndex ?? null,
};
}
function readStringTableIndex(data: any): StringTableIndexStats {
return {
revision: Number(data?.revision ?? 0),
state: String(data?.state ?? "idle"),
activeBuilds: Number(data?.activeBuilds ?? 0),
mappingBuilds: Number(data?.mappingBuilds ?? 0),
mappingCount: Number(data?.mappingCount ?? 0),
tableBuilds: Number(data?.tableBuilds ?? 0),
failedTables: Number(data?.failedTables ?? 0),
entries: Number(data?.entries ?? 0),
bytes: Number(data?.bytes ?? 0),
mappingDurationMs: Number(data?.mappingDurationMs ?? 0),
tableDurationMs: Number(data?.tableDurationMs ?? 0),
buildDurationMs: Number(data?.buildDurationMs ?? 0),
};
}
function eventData(event: any): any {
return event?.data ?? event;
}
function stopIndexPolling() {
indexStatusRevision++;
window.clearInterval(indexPollTimer);
indexPollTimer = undefined;
}
function applyIndexStatus(data: unknown) {
indexStatusRevision++;
const status = readIndexStatus(data);
indexStatus.value = status;
if (status.stringTableIndex && status.stringTableIndex.revision >= stringTableIndex.value.revision) {
stringTableIndex.value = readStringTableIndex(status.stringTableIndex);
}
if (open.value && (status.state === "building" || status.refreshing)) {
if (indexPollTimer === undefined) startIndexPolling();
} else {
stopIndexPolling();
}
}
async function refreshIndexStatus() {
if (!open.value || indexPollBusy) return;
indexPollBusy = true;
const revision = indexStatusRevision;
try {
const status = readIndexStatus(await ArchiveService.IndexStatus());
// Events or a different archive may have superseded this request.
if (revision === indexStatusRevision && open.value) applyIndexStatus(status);
} catch {
// Event delivery remains the primary path; a transient poll failure is harmless.
} finally {
indexPollBusy = false;
}
}
function startIndexPolling() {
stopIndexPolling();
indexPollTimer = window.setInterval(() => void refreshIndexStatus(), 250);
void refreshIndexStatus();
}
function applyOpenedInfo(res: ArchiveInfo): ArchiveInfo {
if (info.value?.path !== res.path) stringTableIndex.value = readStringTableIndex(null);
info.value = res;
rememberArchive(res.path);
applyIndexStatus({ state: "building", stage: "preparing" });
return res;
}
async function openPath(path: string): Promise<ArchiveInfo | null> {
assertMCPIdle();
if (!path.trim()) return null;
loading.value = true;
loadError.value = "";
try {
const res = await ArchiveService.Open(path);
if (!res) return null;
return applyOpenedInfo(res);
} catch (e: any) {
loadError.value = String(e?.message ?? e);
throw e;
} finally {
loading.value = false;
}
}
async function openDialog() {
assertMCPIdle();
loading.value = true;
loadError.value = "";
try {
const res = await ArchiveService.OpenDialog();
if (res) {
applyOpenedInfo(res);
}
} catch (e: any) {
loadError.value = String(e?.message ?? e);
throw e;
} finally {
loading.value = false;
}
}
async function close() {
assertMCPIdle();
await ArchiveService.Close();
stopIndexPolling();
info.value = null;
stringTableIndex.value = readStringTableIndex(null);
}
async function refreshInfo() {
info.value = await ArchiveService.Info();
}
async function rebuildSearchIndex(): Promise<IndexStatus> {
const revision = indexStatusRevision;
const status = readIndexStatus(await ArchiveService.RebuildSearchIndex());
if (revision === indexStatusRevision) applyIndexStatus(status);
return indexStatus.value;
}
async function setTextEncoding(encoding: string): Promise<void> {
info.value = await ArchiveService.SetTextEncoding(encoding);
}
async function listRegistrationOptions(fileIndex: number): Promise<FileRegistrationOptions | null> {
return (await ArchiveService.ListRegistrationOptions(fileIndex)) ?? null;
}
async function registerFileToList(
fileIndex: number,
listPath: string,
id: string,
): Promise<FileRegistration | null> {
return (await ArchiveService.RegisterFileToList(fileIndex, listPath, id)) ?? null;
}
async function indexHashTargets(): Promise<IndexHashTarget[]> {
return ((await ArchiveService.IndexHashTargets()) ?? []).filter(
(target): target is IndexHashTarget => !!target,
);
}
async function registerMissingIndexHashes(
listPath: string,
ids: string[],
): Promise<IndexHashRegistrationResult | null> {
return (await ArchiveService.RegisterMissingIndexHashes(listPath, ids)) ?? null;
}
async function unpackDialog(): Promise<boolean> {
unpackMessage.value = "";
const started = await EditorService.UnpackDialog();
unpacking.value = started;
return started;
}
function cancelUnpack() {
EditorService.CancelUnpack();
}
// 后端事件
Events.On("archive:opened", (event: any) => {
stringTableIndex.value = readStringTableIndex(null);
const data = eventData(event);
applyOpenedInfo(data);
loading.value = false;
});
Events.On("archive:closed", () => {
stopIndexPolling();
info.value = null;
stringTableIndex.value = readStringTableIndex(null);
applyIndexStatus(null);
});
Events.On("archive:string-table-index", (event: any) => {
const data = eventData(event);
if (open.value && data?.archivePath === info.value?.path && data?.stats &&
(data.kind === "snapshot" || Number(data.stats.revision ?? 0) >= stringTableIndex.value.revision)) {
stringTableIndex.value = readStringTableIndex(data.stats);
}
});
Events.On("archive:index-progress", (event: any) => {
applyIndexStatus(eventData(event));
});
Events.On("archive:index-ready", (event: any) => {
applyIndexStatus(eventData(event));
});
Events.On("archive:index-error", (event: any) => {
applyIndexStatus(eventData(event));
});
Events.On("archive:saved", (event: any) => {
info.value = eventData(event);
});
Events.On("archive:changed", (event: any) => {
const data = eventData(event);
if (data?.path) info.value = data;
});
Events.On("archive:batch-applied", () => {
void refreshInfo();
});
Events.On("archive:reloaded", (event: any) => {
stringTableIndex.value = readStringTableIndex(null);
const data = eventData(event);
if (data?.path) info.value = data;
else void refreshInfo();
});
Events.On("unpack:progress", (event: any) => {
const data = eventData(event);
unpackProgress.value = { done: data.done ?? 0, total: data.total ?? 0 };
});
Events.On("unpack:done", (event: any) => {
const data = eventData(event);
unpacking.value = false;
unpackMessage.value = data?.message ?? "";
});
Events.On("archive:open-path", (event: any) => {
const path = typeof event === "string" ? event : event?.data;
if (typeof path === "string" && path.trim()) {
void openPath(path.trim());
}
});
return {
recentArchives,
info,
loading,
loadError,
open,
modifiedCount,
indexStatus,
stringTableIndex,
indexing,
refreshingIndex,
indexReady,
unpacking,
unpackProgress,
unpackMessage,
openDialog,
openPath,
clearRecentArchives,
removeRecentArchive,
close,
refreshInfo,
rebuildSearchIndex,
setTextEncoding,
listRegistrationOptions,
registerFileToList,
indexHashTargets,
registerMissingIndexHashes,
unpackDialog,
cancelUnpack,
};
});
+950
View File
@@ -0,0 +1,950 @@
import { computed, ref } from "vue";
import { defineStore } from "pinia";
import { Events } from "@wailsio/runtime";
import type { TreeOption } from "naive-ui";
import { ArchiveDiffService } from "../../bindings/pvfine/services";
import type { ArchiveDiffStatus, ArchiveDiffFile, ArchiveDiffNode } from "../../bindings/pvfine/services/models";
import { useEditorStore } from "./editor";
import { diffOperation, orientArchiveDiff } from "../archiveDiff";
export type DiffTreeNode = TreeOption & { node: ArchiveDiffNode; children?: DiffTreeNode[] };
const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error);
function treeNodes(nodes: ArchiveDiffNode[]): DiffTreeNode[] {
return nodes.map(node => ({
key: node.path.toLowerCase(),
label: node.name,
isLeaf: !node.isDir,
node,
selectable: !node.isDir,
}));
}
/**
* 递归收集节点(包括目录及其所有子孙目录)下所有的叶子文件路径
* @author flobit
*/
export function collectLeafPaths(node: DiffTreeNode): string[] {
const leaves: string[] = [];
function dfs(curr: DiffTreeNode) {
if (!curr.node.isDir) {
if (curr.node.path) {
leaves.push(curr.node.path);
}
return;
}
if (curr.children && curr.children.length > 0) {
for (const child of curr.children) {
dfs(child as DiffTreeNode);
}
}
}
dfs(node);
return leaves;
}
/**
* 根据路径深度优先查找目录或文件节点
* @author flobit
*/
export function findNodeByPath(nodes: DiffTreeNode[], targetPath: string): DiffTreeNode | null {
const target = targetPath.toLowerCase().replace(/\/+$/, "");
for (const node of nodes) {
const current = (node.node?.path || "").toLowerCase().replace(/\/+$/, "");
if (current === target) return node;
if (node.children && node.children.length > 0) {
const found = findNodeByPath(node.children as DiffTreeNode[], targetPath);
if (found) return found;
}
}
return null;
}
/**
* 保留已加载的层级,但重新读取内容,避免保留已消失或被筛选掉的差异。
*/
export async function preserveTreeExpansion(
newNodes: DiffTreeNode[],
oldNodes: DiffTreeNode[],
load: (path: string) => Promise<DiffTreeNode[]>,
): Promise<void> {
await Promise.all(newNodes.map(async newNode => {
const oldNode = findNodeByPath(oldNodes, newNode.node.path);
if (newNode.node.isDir && oldNode?.children) {
newNode.children = await load(newNode.node.path);
await preserveTreeExpansion(newNode.children, oldNode.children, load);
}
}));
}
/**
* 递归收集树节点列表中所有符合条件的叶子文件节点,按树的自然深度优先序排列
* @author flobit
*/
export function collectLeavesFromTree(roots: DiffTreeNode[], op = "all", reverse = false, query = ""): ArchiveDiffNode[] {
const leaves: ArchiveDiffNode[] = [];
const q = query.trim().toLowerCase();
function walk(curr: DiffTreeNode) {
if (!curr.node.isDir) {
const currentOp = diffOperation(curr.node.operation, reverse);
if (op !== "all" && op && currentOp !== op) return;
if (q && !curr.node.path.toLowerCase().includes(q)) return;
leaves.push(curr.node);
return;
}
if (curr.children && curr.children.length > 0) {
for (const c of curr.children) walk(c as DiffTreeNode);
}
}
for (const r of roots) walk(r);
return leaves;
}
export const useArchiveDiffStore = defineStore("archiveDiff", () => {
const visible = ref(false);
const otherPath = ref("");
const reverse = ref(false);
const status = ref<ArchiveDiffStatus | null>(null);
const tree = ref<DiffTreeNode[]>([]);
const file = ref<ArchiveDiffFile | null>(null);
const activeNode = ref<ArchiveDiffNode | null>(null);
const selectedKeys = ref<Array<string | number>>([]);
const expandedKeys = ref<Array<string | number>>([]);
const starting = ref(false);
const selecting = ref(false);
const fileLoading = ref(false);
const rootLoading = ref(false);
const error = ref("");
const fileError = ref("");
let epoch = 0;
let fileEpoch = 0;
let viewEpoch = 0;
let treeSeq = 0;
let flatSeq = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
let pendingStart: Promise<ArchiveDiffStatus | null> | undefined;
// 视图模式与列表检索模式
const viewMode = ref<"side-by-side" | "unified">("side-by-side");
const listMode = ref<"tree" | "flat">("tree");
const filterOperation = ref<string>("all");
const searchQuery = ref<string>("");
const flatFiles = ref<ArchiveDiffNode[]>([]);
const flatLoading = ref(false);
const lineWrapping = ref(false);
// 多选勾选集合与合并状态
const selectedPaths = ref<Set<string>>(new Set());
const merging = ref(false);
// 双栏编辑器内草稿与切换保存保护
const draftPath = ref("");
const draftText = ref("");
const originalDraftText = ref("");
const isDraftDirty = computed(() => !!draftPath.value && draftText.value !== originalDraftText.value);
const pendingSwitchNode = ref<ArchiveDiffNode | null>(null);
const showDraftConfirmModal = ref(false);
const busy = computed(() => starting.value || rootLoading.value || ["loading", "comparing", "classifying", "verifying", "sorting", "building", "sorting-tree"].includes(status.value?.state ?? ""));
const displayedFile = computed(() => orientArchiveDiff(file.value, reverse.value));
const counts = computed(() => ({
added: (reverse.value ? status.value?.deleted : status.value?.added) ?? 0,
modified: status.value?.modified ?? 0,
deleted: (reverse.value ? status.value?.added : status.value?.deleted) ?? 0,
}));
const totalCount = computed(() => counts.value.added + counts.value.modified + counts.value.deleted);
function getBackendOperation(op: string): string {
if (op === "all" || !op) return "all";
if (!reverse.value) return op;
if (op === "add") return "delete";
if (op === "delete") return "add";
return op;
}
function setDraft(path: string, text: string, originalText: string) {
draftPath.value = path;
draftText.value = text;
originalDraftText.value = originalText;
}
function updateDraftText(text: string) {
draftText.value = text;
}
function discardDraft() {
draftText.value = originalDraftText.value;
draftPath.value = "";
originalDraftText.value = "";
}
const dirSelectedCount = computed(() => {
const counts = new Map<string, number>();
for (const raw of selectedPaths.value) {
let p = raw.toLowerCase();
while (true) {
const idx = p.lastIndexOf("/");
if (idx <= 0) break;
p = p.substring(0, idx);
counts.set(p, (counts.get(p) ?? 0) + 1);
}
}
return counts;
});
function isSelected(path: string): boolean {
return selectedPaths.value.has(path.toLowerCase());
}
function getDirLeafPaths(path: string, targetNode?: DiffTreeNode): string[] {
if (!searchQuery.value.trim()) return [];
const node = targetNode ?? findNodeByPath(searchedTreeNodes.value, path);
if (node && node.node.isDir && node.children && node.children.length > 0) {
return collectLeafPaths(node);
}
return [];
}
function getDirSelectionState(path: string, count: number, targetNode?: DiffTreeNode): { checked: boolean; indeterminate: boolean } {
if (count <= 0) return { checked: false, indeterminate: false };
const leaves = getDirLeafPaths(path, targetNode);
if (leaves.length > 0) {
let selected = 0;
for (const p of leaves) {
if (selectedPaths.value.has(p.toLowerCase())) {
selected++;
}
}
return {
checked: selected === leaves.length,
indeterminate: selected > 0 && selected < leaves.length,
};
}
const c = dirSelectedCount.value.get(path.toLowerCase()) ?? 0;
return {
checked: c >= count,
indeterminate: c > 0 && c < count,
};
}
function isDirSelected(path: string, count: number, targetNode?: DiffTreeNode): boolean {
return getDirSelectionState(path, count, targetNode).checked;
}
function isDirIndeterminate(path: string, count: number, targetNode?: DiffTreeNode): boolean {
return getDirSelectionState(path, count, targetNode).indeterminate;
}
function toggleSelect(path: string) {
const lower = path.toLowerCase();
const next = new Set(selectedPaths.value);
if (next.has(lower)) {
next.delete(lower);
} else {
next.add(lower);
}
selectedPaths.value = next;
}
async function getMatchingFilesUnderDir(dirPath: string, targetNode?: DiffTreeNode): Promise<string[]> {
const q = searchQuery.value.trim().toLowerCase();
// 1. 若处于关键字搜索过滤状态,优先从当前展示的过滤树节点收集所有可见叶子文件,避免异步竞态与隐藏文件泄漏
if (q) {
const leaves = getDirLeafPaths(dirPath, targetNode);
if (leaves.length > 0) {
return leaves;
}
}
// 2. 否则通过后端接口拉取匹配文件,并在前端对关键字与目录路径进行双重过滤防线
const id = status.value?.id;
if (!id) return [];
const token = epoch;
const viewToken = viewEpoch;
const op = getBackendOperation(filterOperation.value);
const res = await ArchiveDiffService.FlatFiles(id, searchQuery.value, op);
if (token !== epoch || viewToken !== viewEpoch) return [];
const normalizedDir = dirPath ? dirPath.toLowerCase().replace(/\/+$/, "") + "/" : "";
return (res ?? [])
.filter((f) => {
if (f.isDir) return false;
const lowerPath = f.path.toLowerCase();
if (normalizedDir && !lowerPath.startsWith(normalizedDir)) return false;
if (q && !lowerPath.includes(q)) return false;
return true;
})
.map((f) => f.path);
}
async function toggleSelectDir(path: string, _totalCount?: number, targetNode?: DiffTreeNode) {
const matching = await getMatchingFilesUnderDir(path, targetNode);
if (matching.length === 0) return;
const next = new Set(selectedPaths.value);
const allSelected = matching.every((p) => next.has(p.toLowerCase()));
if (allSelected) {
for (const p of matching) {
next.delete(p.toLowerCase());
}
} else {
for (const p of matching) {
next.add(p.toLowerCase());
}
}
selectedPaths.value = next;
}
function selectAll(paths: string[]) {
const next = new Set(selectedPaths.value);
for (const p of paths) next.add(p.toLowerCase());
selectedPaths.value = next;
}
function deselectPaths(paths: string[]): void {
const next = new Set(selectedPaths.value);
for (const p of paths) {
next.delete(p.toLowerCase());
}
selectedPaths.value = next;
}
function deselectAll(paths: string[]): void {
deselectPaths(paths);
}
function clearSelection() {
selectedPaths.value = new Set();
}
function buildTreeFromFiles(files: ArchiveDiffNode[]): { tree: DiffTreeNode[]; dirKeys: string[] } {
const root: DiffTreeNode[] = [];
const dirMap = new Map<string, DiffTreeNode>();
const dirKeys: string[] = [];
for (const file of files) {
const parts = file.path.split("/");
let currentPath = "";
let parentChildren = root;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
currentPath = currentPath ? `${currentPath}/${part}` : part;
const lowerPath = currentPath.toLowerCase();
let dirNode = dirMap.get(lowerPath);
if (!dirNode) {
dirNode = {
key: lowerPath,
label: part,
isLeaf: false,
selectable: false,
children: [],
node: {
name: part,
path: currentPath,
isDir: true,
operation: "modify",
count: 0,
},
};
dirMap.set(lowerPath, dirNode);
dirKeys.push(lowerPath);
parentChildren.push(dirNode);
}
dirNode.node.count++;
parentChildren = dirNode.children!;
}
const fileName = parts[parts.length - 1];
parentChildren.push({
key: file.path.toLowerCase(),
label: fileName,
isLeaf: true,
selectable: true,
node: file,
});
}
return { tree: root, dirKeys };
}
const searchedTreeNodes = ref<DiffTreeNode[]>([]);
const savedExpandedKeys = ref<Array<string | number> | null>(null);
let searchTreeSeq = 0;
async function searchTree(query: string, silent = false) {
const q = query.trim();
if (!q) {
clearSearch();
return;
}
const id = status.value?.id;
if (!id) return;
if (savedExpandedKeys.value === null) {
savedExpandedKeys.value = [...expandedKeys.value];
}
const currentSeq = ++searchTreeSeq;
const token = epoch;
const viewToken = viewEpoch;
try {
const op = getBackendOperation(filterOperation.value);
const res = await ArchiveDiffService.FlatFiles(id, q, op);
if (currentSeq !== searchTreeSeq || token !== epoch || viewToken !== viewEpoch) return;
const files = (res ?? []).filter((f) => !f.isDir);
const limited = files.slice(0, 1000);
const { tree: resultTree, dirKeys } = buildTreeFromFiles(limited);
searchedTreeNodes.value = resultTree;
if (!silent) {
expandedKeys.value = dirKeys;
} else {
const mergedKeys = new Set([...expandedKeys.value, ...dirKeys]);
expandedKeys.value = Array.from(mergedKeys);
}
} catch {
if (currentSeq === searchTreeSeq && token === epoch && viewToken === viewEpoch) {
searchedTreeNodes.value = [];
}
}
}
function clearSearch() {
searchTreeSeq++;
searchQuery.value = "";
searchedTreeNodes.value = [];
if (savedExpandedKeys.value !== null) {
expandedKeys.value = savedExpandedKeys.value;
savedExpandedKeys.value = null;
}
if (listMode.value === "flat") {
void loadFlatFiles();
}
}
function collapseAll() {
expandedKeys.value = [];
savedExpandedKeys.value = [];
}
const displayedTree = computed<TreeOption[]>(() => {
if (searchQuery.value.trim()) {
return searchedTreeNodes.value;
}
return tree.value;
});
function clearResult() {
viewEpoch++;
treeSeq++;
flatSeq++;
searchTreeSeq++;
flatLoading.value = false;
merging.value = false;
rootLoading.value = false;
clearTimeout(timer);
status.value = null;
tree.value = [];
searchedTreeNodes.value = [];
savedExpandedKeys.value = null;
file.value = null;
activeNode.value = null;
selectedKeys.value = [];
expandedKeys.value = [];
selectedPaths.value = new Set();
flatFiles.value = [];
discardDraft();
showDraftConfirmModal.value = false;
pendingSwitchNode.value = null;
fileEpoch++;
fileLoading.value = false;
fileError.value = "";
}
function open() { visible.value = true; }
function close() {
epoch++;
visible.value = false;
starting.value = false;
selecting.value = false;
clearResult();
otherPath.value = "";
reverse.value = false;
filterOperation.value = "all";
error.value = "";
void ArchiveDiffService.Release().catch(() => {});
}
async function selectArchive() {
visible.value = true;
const token = epoch;
selecting.value = true;
try {
const path = await ArchiveDiffService.SelectArchive();
if (token !== epoch || !path) return;
otherPath.value = path;
selecting.value = false;
await refresh();
} catch (err) {
if (token === epoch) error.value = errorMessage(err);
} finally { if (token === epoch) selecting.value = false; }
}
async function refresh() {
if (!otherPath.value || busy.value) return;
let drafts;
try { drafts = useEditorStore().archiveDiffDrafts(); }
catch (err) { error.value = errorMessage(err); return; }
const token = ++epoch;
clearResult();
starting.value = true;
error.value = "";
try {
// Start replaces the backend session; serialize outstanding start calls.
if (pendingStart) await pendingStart.catch(() => {});
if (token !== epoch) return;
const request = ArchiveDiffService.Start({ path: otherPath.value, drafts });
pendingStart = request;
const result = await request;
if (token !== epoch) {
if (result) await ArchiveDiffService.Cancel(result.id);
return;
}
if (!result) throw new Error("未收到对比会话");
status.value = result;
starting.value = false;
await poll(token, result.id);
} catch (err) {
if (token === epoch) { error.value = errorMessage(err); clearResult(); }
} finally { if (token === epoch) starting.value = false; }
}
async function loadRootTree() {
const id = status.value?.id;
if (!id) return;
const token = epoch;
rootLoading.value = true;
try {
await refreshTreePreservingExpansion();
} catch (err) {
if (token === epoch) error.value = errorMessage(err);
} finally {
if (token === epoch) rootLoading.value = false;
}
}
async function poll(token: number, id: string) {
try {
const result = await ArchiveDiffService.Status(id);
if (token !== epoch) return;
if (!result) throw new Error("对比会话已关闭");
status.value = result;
if (result.state === "ready") {
await loadRootTree();
if (token !== epoch) return;
if (listMode.value === "flat") {
void loadFlatFiles();
} else if (searchQuery.value.trim()) {
void searchTree(searchQuery.value);
} else {
void loadFlatFiles(true);
}
} else if (result.state === "error") {
error.value = result.error;
} else if (result.state !== "cancelled") {
timer = setTimeout(() => void poll(token, id), 250);
}
} catch (err) { if (token === epoch) { error.value = errorMessage(err); clearResult(); } }
}
async function loadChildren(option: TreeOption) {
const target = option as DiffTreeNode;
const token = epoch;
const viewToken = viewEpoch;
const requestSeq = treeSeq;
const id = status.value?.id;
if (!id || target.children) return;
try {
const op = getBackendOperation(filterOperation.value);
const nodes = await ArchiveDiffService.FilteredChildren(id, target.node.path, op);
if (token !== epoch || viewToken !== viewEpoch || requestSeq !== treeSeq) return;
target.children = treeNodes(nodes ?? []);
tree.value = [...tree.value];
} catch (err) { if (token === epoch) error.value = errorMessage(err); }
}
/**
* 平滑拉取根节点并保留现有所有已展开子树,避免抖动。
* @author flobit
*/
async function refreshTreePreservingExpansion(): Promise<void> {
const id = status.value?.id;
if (!id) return;
const token = epoch;
const viewToken = viewEpoch;
const seq = ++treeSeq;
const op = getBackendOperation(filterOperation.value);
try {
const nodes = await ArchiveDiffService.FilteredChildren(id, "", op);
if (token !== epoch || viewToken !== viewEpoch || seq !== treeSeq) return;
const newRoots = treeNodes(nodes ?? []);
await preserveTreeExpansion(newRoots, tree.value, async path => {
if (token !== epoch || viewToken !== viewEpoch || seq !== treeSeq) return [];
return treeNodes(await ArchiveDiffService.FilteredChildren(id, path, op) ?? []);
});
if (token !== epoch || viewToken !== viewEpoch || seq !== treeSeq) return;
tree.value = newRoots;
} catch (err) {
if (token === epoch && viewToken === viewEpoch && seq === treeSeq) error.value = errorMessage(err);
}
}
/**
* 刷新当前差异视图(平铺列表或目录树)。
* 仅重读已加载层级,保留展开状态,同时刷新所有祖先的统计。
* @author flobit
*/
async function refreshCurrentView(_savedPath?: string): Promise<void> {
await Promise.all([
refreshTreePreservingExpansion(),
loadFlatFiles(true),
listMode.value === "tree" && searchQuery.value.trim()
? searchTree(searchQuery.value, true) : Promise.resolve(),
]);
}
async function setFilterOperation(op: string) {
if (filterOperation.value === op) return;
filterOperation.value = op;
viewEpoch++;
expandedKeys.value = [];
await refreshCurrentView();
}
async function toggleReverse() {
reverse.value = !reverse.value;
viewEpoch++;
expandedKeys.value = [];
await refreshCurrentView();
}
async function loadFlatFiles(silent = false) {
const id = status.value?.id;
if (!id) {
flatFiles.value = [];
return;
}
if (!silent) {
flatLoading.value = true;
}
const token = epoch;
const seq = ++flatSeq;
try {
// 保持拉取全量差异文件底表,前端响应式过滤检索,跨树形与平铺模式状态绝对同步
const res = await ArchiveDiffService.FlatFiles(id, "", "");
if (token === epoch && seq === flatSeq) flatFiles.value = res ?? [];
} catch (err) {
if (token === epoch && seq === flatSeq) error.value = errorMessage(err);
} finally {
if (token === epoch && seq === flatSeq) {
flatLoading.value = false;
}
}
}
async function openFile(node: ArchiveDiffNode, force = false): Promise<boolean> {
if (node.isDir) return false;
if (!force && isDraftDirty.value && draftPath.value !== node.path) {
pendingSwitchNode.value = node;
showDraftConfirmModal.value = true;
return false;
}
selectedKeys.value = [node.path.toLowerCase()];
await openFileDirect(node);
return true;
}
/**
* 当前过滤与搜索条件下所有有序的差异文件列表
* 无论在树形模式还是平铺模式下均保持全局一致的路径排序
* @author flobit
*/
const navigableFiles = computed<ArchiveDiffNode[]>(() => {
const q = searchQuery.value.trim().toLowerCase();
const op = filterOperation.value;
// 1. 若平铺全量底表已有数据,直接按操作过滤和搜索词过滤
if (flatFiles.value.length > 0) {
return flatFiles.value.filter((node) => {
if (node.isDir) return false;
const currentOp = diffOperation(node.operation, reverse.value);
if (op !== "all" && op && currentOp !== op) return false;
if (q && !node.path.toLowerCase().includes(q)) return false;
return true;
});
}
// 2. 否则若处于搜索树模式,从 searchedTreeNodes 收集所有匹配叶子节点
if (q && searchedTreeNodes.value.length > 0) {
return collectLeavesFromTree(searchedTreeNodes.value, op, reverse.value, q);
}
// 3. 兜底:若平铺尚未拉取,从当前 tree 收集已知叶子
return collectLeavesFromTree(tree.value, op, reverse.value, q);
});
const currentFileIndex = computed(() => {
const currentPath = (file.value?.path || activeNode.value?.path || "").toLowerCase();
if (!currentPath || navigableFiles.value.length === 0) return -1;
return navigableFiles.value.findIndex(
(node) => node.path.toLowerCase() === currentPath
);
});
const hasPreviousFile = computed(() => currentFileIndex.value > 0);
const hasNextFile = computed(() => {
const total = navigableFiles.value.length;
if (total === 0) return false;
const idx = currentFileIndex.value;
return idx < total - 1;
});
const fileNavIndicator = computed(() => {
const total = navigableFiles.value.length;
if (total === 0) return "";
const current = currentFileIndex.value;
if (current < 0) return `0 / ${total}`;
return `${current + 1} / ${total}`;
});
/**
* 导航到指定差异文件,并在确认切换成功后自动展开该文件所在的祖先目录
* @author flobit
*/
async function navigateToDiffFile(targetNode: ArchiveDiffNode): Promise<boolean> {
// 1. 先尝试切换文件(若存在脏草稿会被拦截弹出弹窗,返回 false)
const success = await openFile(targetNode);
if (!success) return false;
// 2. 确认切换成功后,再展开其祖先目录,确保用户取消弹窗时零副作用
const normalized = targetNode.path.replaceAll("\\", "/").replace(/^\/+/, "");
const parts = normalized.split("/");
if (parts.length > 1) {
let currentDir = "";
const nextExpanded = new Set(expandedKeys.value.map(String));
for (let i = 0; i < parts.length - 1; i++) {
currentDir = currentDir ? `${currentDir}/${parts[i]}` : parts[i];
nextExpanded.add(currentDir.toLowerCase());
}
expandedKeys.value = Array.from(nextExpanded);
}
return true;
}
async function openPreviousFile(): Promise<boolean> {
if (!hasPreviousFile.value) return false;
const target = navigableFiles.value[currentFileIndex.value - 1];
if (!target) return false;
return await navigateToDiffFile(target);
}
async function openNextFile(): Promise<boolean> {
if (!hasNextFile.value) return false;
const nextIdx = currentFileIndex.value < 0 ? 0 : currentFileIndex.value + 1;
const target = navigableFiles.value[nextIdx];
if (!target) return false;
return await navigateToDiffFile(target);
}
async function openFileDirect(node: ArchiveDiffNode, silent = false) {
const id = status.value?.id;
if (!id || node.isDir) return;
activeNode.value = node;
const token = ++fileEpoch;
const sessionEpoch = epoch;
const previousDraftPath = draftPath.value;
const previousDraftText = draftText.value;
if (!silent) {
fileLoading.value = true;
file.value = null;
}
fileError.value = "";
try {
const result = await ArchiveDiffService.FileDiff(id, node.path);
if (token === fileEpoch && sessionEpoch === epoch) {
file.value = result;
// 方案 B(工作台物理侧锚定):可编辑底稿始终严格绑定先选中的本地工作区归档文本(result.afterText),彻底杜绝覆盖本地内容
const baseTxt = result?.afterText ?? "";
if (!silent || (draftPath.value === previousDraftPath && draftText.value === previousDraftText)) {
setDraft(node.path, baseTxt, baseTxt);
}
}
} catch (err) {
if (token === fileEpoch && sessionEpoch === epoch) fileError.value = errorMessage(err);
} finally {
if (!silent && token === fileEpoch && sessionEpoch === epoch) {
fileLoading.value = false;
}
}
}
async function confirmDraftSaveAndSwitch(): Promise<void> {
if (draftPath.value && isDraftDirty.value) {
if (!await mergeText(draftPath.value, draftText.value)) return;
if (isDraftDirty.value) return;
}
showDraftConfirmModal.value = false;
const nextNode = pendingSwitchNode.value;
pendingSwitchNode.value = null;
if (nextNode) {
await openFile(nextNode, true);
}
}
function confirmDraftDiscardAndSwitch(): void {
discardDraft();
showDraftConfirmModal.value = false;
const nextNode = pendingSwitchNode.value;
pendingSwitchNode.value = null;
if (nextNode) {
void openFile(nextNode, true);
}
}
function cancelDraftSwitch(): void {
pendingSwitchNode.value = null;
showDraftConfirmModal.value = false;
}
async function mergeSelected() {
const id = status.value?.id;
const paths = Array.from(selectedPaths.value);
if (!id || paths.length === 0 || merging.value) return null;
const token = epoch;
merging.value = true;
try {
const res = await ArchiveDiffService.MergeFiles(id, paths);
if (token !== epoch) return res;
if (res && res.mergedPaths && res.mergedPaths.length > 0) {
await useEditorStore().refreshMergedFiles(res.mergedPaths);
if (token !== epoch) return res;
deselectPaths(res.mergedPaths);
if (activeNode.value && res.mergedPaths.includes(activeNode.value.path)) {
await openFileDirect(activeNode.value, true);
}
if (token !== epoch) return res;
await refreshCurrentView();
await refreshStatus(token, id);
}
return res;
} finally {
if (token === epoch) merging.value = false;
}
}
async function mergeSingleFile(path: string): Promise<boolean> {
const id = status.value?.id;
if (!id || !path || merging.value) return false;
const token = epoch;
merging.value = true;
try {
const res = await ArchiveDiffService.MergeFiles(id, [path]);
if (token !== epoch) return false;
if (res && res.successCount > 0) {
await useEditorStore().refreshMergedFiles([path]);
if (token !== epoch) return false;
deselectPaths([path]);
if (activeNode.value && activeNode.value.path === path) {
await openFileDirect(activeNode.value, true);
}
if (token !== epoch) return false;
await refreshCurrentView(path);
await refreshStatus(token, id);
return true;
}
return false;
} finally {
if (token === epoch) merging.value = false;
}
}
async function mergeText(path: string, text: string): Promise<boolean> {
const id = status.value?.id;
if (!id || !path || merging.value) return false;
const token = epoch;
merging.value = true;
try {
await ArchiveDiffService.MergeText(id, path, text);
if (token !== epoch) return false;
await useEditorStore().refreshMergedFiles([path]);
if (token !== epoch) return false;
if (draftPath.value === path) originalDraftText.value = text;
if (activeNode.value?.path === path && !isDraftDirty.value) {
await openFileDirect(activeNode.value, true);
}
if (token !== epoch) return false;
await refreshCurrentView(path);
await refreshStatus(token, id);
return true;
} finally {
if (token === epoch) merging.value = false;
}
}
async function refreshStatus(token: number, id: string) {
if (token !== epoch) return;
const result = await ArchiveDiffService.Status(id);
if (token === epoch && result) status.value = result;
}
async function cancel() {
starting.value = false;
merging.value = false;
rootLoading.value = false;
flatLoading.value = false;
const id = status.value?.id;
const token = ++epoch;
fileEpoch++;
fileLoading.value = false;
clearTimeout(timer);
if (id) {
if (status.value) status.value = { ...status.value, state: "cancelled" };
try { await ArchiveDiffService.Cancel(id); }
catch (err) { if (token === epoch) error.value = errorMessage(err); }
}
}
async function onArchiveReloaded() {
if (otherPath.value) {
const cancelled = cancel();
const token = epoch;
await cancelled;
if (token !== epoch) return;
await refresh();
} else {
clearResult();
}
}
Events.On("archive:opened", close);
Events.On("archive:closed", close);
Events.On("archive:reloaded", () => {
void onArchiveReloaded();
});
return {
visible, otherPath, reverse, status, tree, file, activeNode, displayedFile, selectedKeys, expandedKeys,
starting, selecting, busy, fileLoading, rootLoading, error, fileError, counts, totalCount,
viewMode, listMode, filterOperation, searchQuery, flatFiles, flatLoading, displayedTree,
lineWrapping,
selectedPaths, merging, draftPath, draftText, originalDraftText, isDraftDirty,
showDraftConfirmModal, pendingSwitchNode,
open, close, selectArchive, refresh, loadRootTree, loadChildren, openFile, openFileDirect, cancel,
toggleSelect, selectAll, deselectAll, clearSelection, isSelected, isDirSelected, isDirIndeterminate, getDirSelectionState,
toggleSelectDir, getMatchingFilesUnderDir, searchTree, clearSearch, collapseAll, setFilterOperation, toggleReverse,
loadFlatFiles, refreshCurrentView, mergeSelected, mergeSingleFile, mergeText,
setDraft, updateDraftText, discardDraft,
navigableFiles, currentFileIndex, hasPreviousFile, hasNextFile, fileNavIndicator,
openPreviousFile, openNextFile,
confirmDraftSaveAndSwitch, confirmDraftDiscardAndSwitch, cancelDraftSwitch,
};
});
+180
View File
@@ -0,0 +1,180 @@
import { defineStore } from "pinia";
import { ref, watch } from "vue";
import { AutosaveService } from "../../bindings/pvfine/services";
import type { AutosaveStatus, RecoveryInfo } from "../../bindings/pvfine/services/models";
import { hasArchiveChanges } from "../unsaved";
import { useArchiveStore } from "./archive";
import { useEditorStore } from "./editor";
import { useSettingsStore } from "./settings";
import { useVersionStore } from "./version";
/** 定时缓存服务最小时钟间隔(秒),与后端设置校验保持一致。 */
const minTimerSeconds = 30;
/** 定时把未保存的工作区另存到备份缓存,并负责启动时的恢复提示。 */
export const useAutosaveStore = defineStore("autosave", () => {
const archive = useArchiveStore();
const editor = useEditorStore();
const settings = useSettingsStore();
const version = useVersionStore();
const status = ref<AutosaveStatus | null>(null);
const running = ref(false);
const restoring = ref(false);
const lastSnapshotAt = ref(0);
const lastError = ref("");
const pendingRecovery = ref<RecoveryInfo | null>(null);
const checked = ref(false);
let timer: number | undefined;
function stopTimer(): void {
if (timer !== undefined) window.clearInterval(timer);
timer = undefined;
}
function restartTimer(): void {
stopTimer();
if (!settings.autosaveEnabled) return;
const seconds = Math.max(minTimerSeconds, settings.autosaveIntervalSeconds);
timer = window.setInterval(() => void tick(), seconds * 1000);
}
/** 一次定时快照:先把编辑器草稿写进 overlay,再让后端另存缓存。 */
async function tick(): Promise<void> {
if (running.value || !settings.autosaveEnabled || !archive.open) return;
if (editor.saving || archive.loading || version.status.loading) return;
if (!hasArchiveChanges()) return;
running.value = true;
try {
if (editor.dirtyCount > 0) await editor.saveAllDirty();
if (!hasArchiveChanges()) return;
await AutosaveService.CreateSnapshot();
lastSnapshotAt.value = Date.now();
lastError.value = "";
void refreshStatus();
} catch (error: any) {
// 缓存失败不能打断编辑:记录后在设置页展示。
lastError.value = String(error?.message ?? error);
} finally {
running.value = false;
}
}
async function refreshStatus(): Promise<AutosaveStatus | null> {
try {
status.value = await AutosaveService.Status();
// 后端记录的错误是权威值:这里同步覆盖,避免旧的失败信息一直挂在设置页。
lastError.value = status.value?.lastError ?? "";
} catch (error) {
console.error("load autosave status failed", error);
}
return status.value;
}
/** 查询待恢复备份。启动阶段只查询一次,重复调用直接复用结果。 */
async function checkRecovery(): Promise<RecoveryInfo | null> {
if (checked.value) return pendingRecovery.value;
checked.value = true;
try {
pendingRecovery.value = await AutosaveService.PendingRecovery();
} catch (error) {
console.error("check autosave recovery failed", error);
pendingRecovery.value = null;
}
return pendingRecovery.value;
}
/**
* 恢复备份:成功后工作区仍是“未保存”状态,保存才会写回原文件。
* 打开原文件与重放内容都发生在后端,大归档耗时较长,调用方应展示加载态。
*/
async function restore(): Promise<boolean> {
if (restoring.value) return false;
restoring.value = true;
try {
const info = await AutosaveService.Restore();
pendingRecovery.value = null;
// 后端广播了 archive:opened,但事件到达顺序不确定:先落一次本地状态,
// 让工具栏与状态栏立刻反映新的工作区。
if (info) archive.info = info;
void refreshStatus();
return true;
} catch (error: any) {
lastError.value = String(error?.message ?? error);
throw error;
} finally {
restoring.value = false;
}
}
/** 选择缓存文件位置;返回空字符串表示用户取消。 */
async function chooseCachePath(): Promise<string> {
const path = await AutosaveService.ChooseCachePath();
return typeof path === "string" ? path : "";
}
/** 丢弃备份缓存(恢复提示里的“丢弃备份”、设置页的“删除备份”)。 */
async function discard(): Promise<boolean> {
try {
const removed = await AutosaveService.Discard();
pendingRecovery.value = null;
void refreshStatus();
return removed !== false;
} catch (error: any) {
lastError.value = String(error?.message ?? error);
throw error;
}
}
/** 退出时确认放弃未保存修改:与其配套的缓存也没有保护对象了。 */
async function discardForWorkspace(): Promise<void> {
try {
await AutosaveService.DropForWorkspace();
pendingRecovery.value = null;
void refreshStatus();
} catch (error) {
// 退出流程不能被缓存清理阻塞。
console.error("drop autosave backup failed", error);
}
}
/** 启动时调用:刷新缓存状态、启动定时器、检查是否有待恢复的备份。 */
async function initialize(): Promise<void> {
await refreshStatus();
restartTimer();
await checkRecovery();
}
watch(
() => settings.autosaveEnabled,
(enabled, previous) => {
restartTimer();
// 刚打开开关时不必等一个完整周期。
if (enabled && previous === false) void tick();
}
);
watch(
() => settings.autosaveIntervalSeconds,
() => restartTimer()
);
return {
status,
running,
restoring,
lastSnapshotAt,
lastError,
pendingRecovery,
checked,
initialize,
tick,
refreshStatus,
checkRecovery,
restore,
chooseCachePath,
discard,
discardForWorkspace,
restartTimer,
stopTimer,
};
});
+363
View File
@@ -0,0 +1,363 @@
import { computed, ref, watch } from "vue";
import { defineStore } from "pinia";
import { Events } from "@wailsio/runtime";
import { BatchService } from "../../bindings/pvfine/services";
import type {
BatchApplyResult,
BatchFilePreview,
BatchPreviewPage,
StructuredOperation,
} from "../../bindings/pvfine/services/models";
export type BatchMode = "text" | "structured";
export interface BatchTextForm {
find: string;
replacement: string;
regex: boolean;
}
export interface BatchOperationForm extends StructuredOperation {
id: number;
}
/** 批处理弹窗状态与当前会话内的预览计划。 */
export const useBatchStore = defineStore("batch", () => {
const visible = ref(false);
const mode = ref<BatchMode>("text");
const scopeLabel = ref("");
const scopePaths = ref<string[]>([]);
const text = ref<BatchTextForm>({ find: "", replacement: "", regex: false });
let operationID = 1;
let requestID = 0;
let planFingerprint = "";
let selectionMode: "all" | "none" | "some" = "all";
let excludedIndexes = new Set<number>();
const operations = ref<BatchOperationForm[]>([newOperation()]);
const rows = ref<BatchFilePreview[]>([]);
const planId = ref("");
const nextCursor = ref(-1);
const requestedFiles = ref(0);
const matchedFiles = ref(0);
const matchedOccurrences = ref(0);
const changedFiles = ref(0);
const selectedIndexes = ref<Set<number>>(new Set());
const loading = ref(false);
const applying = ref(false);
const error = ref("");
const stale = ref(false);
const hasPreview = computed(() => !!planId.value && !stale.value);
const selectedCount = computed(() => selectedIndexes.value.size);
const canApply = computed(
() =>
hasPreview.value &&
!loading.value &&
!applying.value &&
(selectedCount.value > 0 || (selectionMode === "all" && changedFiles.value > 0))
);
const requestFingerprint = computed(() =>
JSON.stringify({
mode: mode.value,
text: text.value,
operations: operations.value,
paths: scopePaths.value,
})
);
watch(requestFingerprint, (fingerprint) => {
if (planId.value && planFingerprint && fingerprint !== planFingerprint) stale.value = true;
});
function newOperation(): BatchOperationForm {
return {
id: operationID++,
kind: "set",
section: "",
tokenIndex: -1,
value: "",
createIfMissing: false,
operator: "+",
operand: "",
operandEnd: "",
anchorSection: "",
hasEndTag: false,
};
}
function normalizePaths(paths: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const rawPath of paths) {
const path = rawPath.replaceAll("\\", "/").replace(/^\/+|\/+$/g, "").trim();
if (!path || seen.has(path)) continue;
seen.add(path);
result.push(path);
}
return result;
}
function resetPreview(): void {
requestID++;
rows.value = [];
planId.value = "";
nextCursor.value = -1;
requestedFiles.value = 0;
matchedFiles.value = 0;
matchedOccurrences.value = 0;
changedFiles.value = 0;
selectedIndexes.value = new Set();
selectionMode = "all";
excludedIndexes = new Set();
error.value = "";
stale.value = false;
planFingerprint = "";
}
function open(paths: string[], label: string): void {
scopePaths.value = normalizePaths(paths);
scopeLabel.value = label;
mode.value = "text";
text.value = { find: "", replacement: "", regex: false };
operations.value = [newOperation()];
resetPreview();
visible.value = scopePaths.value.length > 0;
if (scopePaths.value.length === 0) error.value = "没有可处理的文件";
}
function close(): void {
if (loading.value || applying.value) return;
visible.value = false;
}
function addOperation(initial?: Partial<StructuredOperation>): BatchOperationForm {
const base = newOperation();
const op: BatchOperationForm = {
...base,
...initial,
id: base.id,
};
operations.value.push(op);
return op;
}
function removeOperation(id: number): void {
if (operations.value.length <= 1) return;
const index = operations.value.findIndex((operation) => operation.id === id);
if (index >= 0) operations.value.splice(index, 1);
}
function moveOperation(id: number, direction: -1 | 1): void {
const index = operations.value.findIndex((operation) => operation.id === id);
const next = index + direction;
if (index < 0 || next < 0 || next >= operations.value.length) return;
const [operation] = operations.value.splice(index, 1);
operations.value.splice(next, 0, operation);
}
function applyPage(page: BatchPreviewPage, append: boolean): void {
const pageRows = (page.rows ?? []).filter(
(row): row is BatchFilePreview => !!row
);
if (append) rows.value.push(...pageRows);
else rows.value = pageRows;
for (const row of pageRows) {
if (
row.status === "changed" &&
row.fileIndex >= 0 &&
selectionMode === "all" &&
!excludedIndexes.has(row.fileIndex)
) {
const next = new Set(selectedIndexes.value);
next.add(row.fileIndex);
selectedIndexes.value = next;
}
}
planId.value = page.planId;
nextCursor.value = page.nextCursor;
requestedFiles.value = page.requestedFiles;
matchedFiles.value = page.matchedFiles;
matchedOccurrences.value = page.matchedOccurrences;
changedFiles.value = page.changedFiles;
}
function requestPayload() {
return {
mode: mode.value,
paths: [...scopePaths.value],
text:
mode.value === "text"
? {
find: text.value.find,
replacement: text.value.replacement,
regex: text.value.regex,
}
: null,
operations:
mode.value === "structured"
? operations.value.map(({ id: _id, ...operation }) => operation)
: [],
};
}
async function preview(): Promise<void> {
if (loading.value || applying.value) return;
const request = ++requestID;
loading.value = true;
error.value = "";
stale.value = false;
rows.value = [];
planId.value = "";
nextCursor.value = -1;
planFingerprint = "";
selectedIndexes.value = new Set();
selectionMode = "all";
excludedIndexes = new Set();
try {
const page = await BatchService.Preview(requestPayload());
if (request !== requestID || !page) return;
applyPage(page, false);
planFingerprint = requestFingerprint.value;
} catch (value: any) {
if (request === requestID) error.value = errorMessage(value);
} finally {
if (request === requestID) loading.value = false;
}
}
async function loadMore(): Promise<boolean> {
if (loading.value || !planId.value || nextCursor.value < 0 || stale.value) return false;
const request = requestID;
const cursor = nextCursor.value;
loading.value = true;
try {
const page = await BatchService.PreviewPage(planId.value, cursor, 100);
if (request !== requestID || !page) return false;
applyPage(page, true);
return nextCursor.value !== cursor;
} catch (value: any) {
if (request === requestID) error.value = errorMessage(value);
return false;
} finally {
if (request === requestID) loading.value = false;
}
}
async function loadAll(): Promise<void> {
while (nextCursor.value >= 0 && !stale.value) {
const progressed = await loadMore();
if (!progressed) {
if (error.value) throw new Error(error.value);
break;
}
}
}
function toggleSelected(fileIndex: number): void {
const next = new Set(selectedIndexes.value);
if (selectionMode === "all") {
if (next.has(fileIndex)) {
next.delete(fileIndex);
excludedIndexes.add(fileIndex);
} else {
next.add(fileIndex);
excludedIndexes.delete(fileIndex);
}
} else if (next.has(fileIndex)) {
next.delete(fileIndex);
} else {
next.add(fileIndex);
}
selectedIndexes.value = next;
if (selectionMode === "none") selectionMode = "some";
}
function selectAll(): void {
selectionMode = "all";
excludedIndexes = new Set();
const next = new Set<number>();
for (const row of rows.value) {
if (row.status === "changed" && row.fileIndex >= 0) next.add(row.fileIndex);
}
selectedIndexes.value = next;
}
function selectNone(): void {
selectionMode = "none";
excludedIndexes = new Set();
selectedIndexes.value = new Set();
}
async function apply(): Promise<BatchApplyResult> {
if (!canApply.value) throw new Error("没有选中的变更文件");
applying.value = true;
error.value = "";
try {
await loadAll();
if (stale.value || !planId.value) throw new Error("批处理预览已过期,请重新预览");
return await BatchService.Apply(planId.value, [...selectedIndexes.value]);
} catch (value: any) {
const message = errorMessage(value);
if (message.includes("预览已过期")) stale.value = true;
error.value = message;
throw value;
} finally {
applying.value = false;
}
}
function errorMessage(value: any): string {
return String(value?.message ?? value ?? "批处理失败");
}
function markStale(): void {
if (planId.value) stale.value = true;
}
Events.On("archive:advanced-search-stale", markStale);
Events.On("archive:batch-applied", markStale);
Events.On("archive:opened", () => {
visible.value = false;
resetPreview();
});
Events.On("archive:closed", () => {
visible.value = false;
resetPreview();
});
return {
visible,
mode,
scopeLabel,
scopePaths,
text,
operations,
rows,
planId,
nextCursor,
requestedFiles,
matchedFiles,
matchedOccurrences,
changedFiles,
selectedIndexes,
loading,
applying,
error,
stale,
hasPreview,
selectedCount,
canApply,
open,
close,
addOperation,
removeOperation,
moveOperation,
preview,
loadMore,
loadAll,
toggleSelected,
selectAll,
selectNone,
apply,
resetPreview,
};
});
+808
View File
@@ -0,0 +1,808 @@
import { computed, ref } from "vue";
import { defineStore } from "pinia";
import { ArchiveService, BookmarkService } from "../../bindings/pvfine/services";
import type {
BookmarkBook,
BookmarkBookFile,
BookmarkDocument,
BookmarkEntry as StoredBookmarkEntry,
BookmarkGroup as StoredBookmarkGroup,
TreeNode,
} from "../../bindings/pvfine/services/models";
import { Events } from "@wailsio/runtime";
export const builtinBookmarkBookID = "builtin";
export interface BookmarkEntry {
path: string;
name: string;
fileIndex: number;
}
export interface BookmarkGroup {
id: string;
name: string;
groups: BookmarkGroup[];
entries: BookmarkEntry[];
}
export interface BookmarkBookState {
id: string;
name: string;
builtin: boolean;
editable: boolean;
groups: BookmarkGroup[];
entries: BookmarkEntry[];
}
export interface BookmarkInput {
path: string;
name?: string;
fileIndex?: number;
}
const bookmarkDocumentVersion = 1;
/** 书签簿状态:路径跨 PVF 持久化,fileIndex 按当前归档动态解析。 */
export const useBookmarkStore = defineStore("bookmarks", () => {
const books = ref<BookmarkBookState[]>([]);
const activeBookId = ref(builtinBookmarkBookID);
const selectedGroupId = ref("");
const loaded = ref(false);
const resolving = ref(false);
const saving = ref(false);
const saveError = ref("");
const loadError = ref("");
const sessionId = ref(0);
let loadPromise: Promise<void> | null = null;
let saveChain: Promise<void> = Promise.resolve();
let mutationVersion = 0;
let savedVersion = 0;
let archivePath = "";
let resolveRequest = 0;
let bookSequence = 1;
let groupSequence = 1;
const activeBook = computed(
() => books.value.find((book) => book.id === activeBookId.value) ?? books.value[0] ?? null
);
const activeGroup = computed(() =>
activeBook.value ? findGroup(activeBook.value.groups, selectedGroupId.value) : null
);
const activeGroupId = computed(() => activeGroup.value?.id ?? "");
const activeBookEditable = computed(() => !!activeBook.value?.editable);
const dirty = computed(() => mutationVersion !== savedVersion);
const hasCustomBooks = computed(() => books.value.some((book) => !book.builtin));
function normalizePath(path: string): string {
return path.replaceAll("\\", "/").replace(/^\/+|\/+$/g, "");
}
function basename(path: string): string {
const normalized = normalizePath(path);
return normalized.slice(normalized.lastIndexOf("/") + 1) || normalized;
}
function newBookID(): string {
const ids = new Set(books.value.map((book) => book.id));
let id = "";
do {
id = `bookmark-${bookSequence++}`;
} while (ids.has(id));
return id;
}
function newGroupID(reservedIDs = new Set<string>()): string {
const ids = new Set(reservedIDs);
for (const book of books.value) collectGroupIDs(book.groups, ids);
let id = "";
do {
id = `bookmark-group-${groupSequence++}`;
} while (ids.has(id));
return id;
}
function collectGroupIDs(groups: BookmarkGroup[], result: Set<string>): void {
for (const group of groups) {
result.add(group.id);
collectGroupIDs(group.groups, result);
}
}
function uniqueBookName(base: string): string {
const normalized = base.trim() || "书签簿";
const names = new Set(books.value.map((book) => book.name));
if (!names.has(normalized)) return normalized;
let number = 2;
let next = `${normalized} ${number}`;
while (names.has(next)) next = `${normalized} ${++number}`;
return next;
}
function uniqueGroupName(parent: BookmarkGroup | null, base: string, ignoreID = ""): string {
const normalized = base.trim() || "分组";
const groups = parent?.groups ?? activeBook.value?.groups ?? [];
const names = new Set(groups.filter((group) => group.id !== ignoreID).map((group) => group.name));
if (!names.has(normalized)) return normalized;
let number = 2;
let next = `${normalized} ${number}`;
while (names.has(next)) next = `${normalized} ${++number}`;
return next;
}
function createBook(requestedName?: string): BookmarkBookState {
assertLoaded();
const name = uniqueBookName(requestedName?.trim() || `书签簿 ${bookSequence}`);
const book: BookmarkBookState = {
id: newBookID(),
name,
builtin: false,
editable: true,
groups: [],
entries: [],
};
books.value.push(book);
activeBookId.value = book.id;
selectedGroupId.value = "";
markDirty();
void persist();
return book;
}
function renameBook(id: string, requestedName: string): void {
assertLoaded();
const book = getBook(id);
if (!book || !book.editable) throw new Error("当前书签簿不可编辑");
const name = requestedName.trim();
if (!name) throw new Error("书签簿名称不能为空");
if (books.value.some((item) => item.id !== id && item.name === name)) {
throw new Error("书签簿名称不能重复");
}
if (book.name === name) return;
book.name = name;
markDirty();
void persist();
}
function deleteBook(id: string): boolean {
assertLoaded();
const book = getBook(id);
if (!book || !book.editable || book.builtin) return false;
const index = books.value.findIndex((item) => item.id === id);
if (index < 0) return false;
books.value.splice(index, 1);
if (activeBookId.value === id) {
activeBookId.value = books.value[index]?.id ?? books.value[index - 1]?.id ?? builtinBookmarkBookID;
selectedGroupId.value = "";
}
markDirty();
void persist();
return true;
}
function copyBook(sourceID: string): BookmarkBookState | null {
assertLoaded();
const source = getBook(sourceID);
if (!source) return null;
const previousGroupID = selectedGroupId.value;
const copiedGroupIDs = new Map<string, string>();
const reservedGroupIDs = new Set<string>();
const copy: BookmarkBookState = {
id: newBookID(),
name: uniqueBookName(`${source.name}(副本)`),
builtin: false,
editable: true,
groups: cloneGroupsWithNewIDs(source.groups, reservedGroupIDs, copiedGroupIDs),
entries: cloneEntries(source.entries),
};
books.value.push(copy);
activeBookId.value = copy.id;
selectedGroupId.value = copiedGroupIDs.get(previousGroupID) ?? "";
markDirty();
void persist();
return copy;
}
function createGroup(parentID = "", requestedName?: string): BookmarkGroup {
assertLoaded();
const book = activeBook.value;
assertEditable(book);
const parent = parentID ? findGroup(book.groups, parentID) : null;
if (parentID && !parent) throw new Error("目标分组不存在");
const group: BookmarkGroup = {
id: newGroupID(),
name: uniqueGroupName(parent, requestedName?.trim() || `分组 ${Math.max(1, groupSequence - 1)}`),
groups: [],
entries: [],
};
(parent?.groups ?? book.groups).push(group);
selectedGroupId.value = group.id;
markDirty();
void persist();
return group;
}
function renameGroup(id: string, requestedName: string): void {
assertLoaded();
const book = activeBook.value;
assertEditable(book);
const located = findGroupWithParent(book?.groups ?? [], id);
if (!located) throw new Error("分组不存在");
const name = requestedName.trim();
if (!name) throw new Error("分组名称不能为空");
const unique = uniqueGroupName(located.parent, name, id);
if (unique !== name) throw new Error("同级分组名称不能重复");
located.group.name = name;
markDirty();
void persist();
}
function deleteGroup(id: string): boolean {
assertLoaded();
const book = activeBook.value;
assertEditable(book);
if (!book) return false;
const located = findGroupWithParent(book.groups, id);
if (!located) return false;
const destinationGroups = located.parent?.groups ?? book.groups;
const index = destinationGroups.findIndex((group) => group.id === id);
if (index < 0) return false;
const destination = located.parent?.entries ?? book.entries;
appendUniqueEntries(destination, located.group.entries);
const existingGroupNames = new Set(
destinationGroups.filter((group) => group.id !== id).map((group) => group.name)
);
const promotedGroups = located.group.groups.map((child) => {
child.name = uniqueNameFromSet(existingGroupNames, child.name);
existingGroupNames.add(child.name);
return child;
});
destinationGroups.splice(index, 1, ...promotedGroups);
if (selectedGroupId.value === id || isDescendantGroup(located.group, selectedGroupId.value)) {
selectedGroupId.value = located.parent?.id ?? "";
}
markDirty();
void persist();
return true;
}
function moveEntry(path: string, targetGroupID = "", sourceGroupID = ""): boolean {
assertLoaded();
const book = activeBook.value;
assertEditable(book);
if (!book) return false;
const normalized = normalizePath(path);
const target = targetGroupID ? findGroup(book.groups, targetGroupID) : null;
if (targetGroupID && !target) throw new Error("目标分组不存在");
const source = sourceGroupID ? findGroup(book.groups, sourceGroupID) : null;
if (sourceGroupID && !source) throw new Error("来源分组不存在");
const sourceEntries = source?.entries ?? book.entries;
const index = sourceEntries.findIndex((entry) => entry.path === normalized);
if (index < 0) return false;
const [entry] = sourceEntries.splice(index, 1);
const destination = target?.entries ?? book.entries;
if (!destination.some((item) => item.path === normalized)) destination.push(entry);
markDirty();
void persist();
return true;
}
function addEntries(entries: BookmarkInput[], targetGroupID = selectedGroupId.value): { added: number; skipped: number } {
assertLoaded();
const book = activeBook.value;
assertEditable(book);
if (!book) return { added: 0, skipped: entries.length };
const target = targetGroupID ? findGroup(book.groups, targetGroupID) : null;
if (targetGroupID && !target) throw new Error("目标分组不存在");
const destination = target?.entries ?? book.entries;
const existing = new Set(destination.map((entry) => entry.path));
let added = 0;
let skipped = 0;
for (const item of entries) {
const path = normalizePath(item.path);
if (!path || existing.has(path)) {
skipped++;
continue;
}
destination.push({
path,
name: item.name?.trim() || basename(path),
fileIndex: item.fileIndex ?? -1,
});
existing.add(path);
added++;
}
if (added > 0) {
markDirty();
void persist();
}
return { added, skipped };
}
function removeEntry(path: string, groupID = ""): boolean {
assertLoaded();
const book = activeBook.value;
assertEditable(book);
if (!book) return false;
const group = groupID ? findGroup(book.groups, groupID) : null;
if (groupID && !group) throw new Error("来源分组不存在");
const entries = group?.entries ?? book.entries;
const index = entries.findIndex((entry) => entry.path === normalizePath(path));
if (index < 0) return false;
entries.splice(index, 1);
markDirty();
void persist();
return true;
}
function renameEntry(path: string, name: string, groupID = ""): void {
assertLoaded();
const book = activeBook.value;
assertEditable(book);
if (!book) return;
const group = groupID ? findGroup(book.groups, groupID) : null;
if (groupID && !group) throw new Error("来源分组不存在");
const entry = (group?.entries ?? book.entries).find((item) => item.path === normalizePath(path));
if (!entry) throw new Error("书签不存在");
const normalized = name.trim();
if (!normalized) throw new Error("书签名称不能为空");
if (entry.name === normalized) return;
entry.name = normalized;
markDirty();
void persist();
}
function setActiveBook(id: string): void {
if (!loaded.value) return;
if (!getBook(id) || activeBookId.value === id) return;
activeBookId.value = id;
selectedGroupId.value = "";
markDirty();
void persist();
}
function selectGroup(id: string): void {
if (!id || !activeBook.value || findGroup(activeBook.value.groups, id)) {
selectedGroupId.value = id;
}
}
function getBook(id: string): BookmarkBookState | undefined {
return books.value.find((book) => book.id === id);
}
function findEntry(path: string, book = activeBook.value): { entry: BookmarkEntry; groupID: string } | null {
if (!book) return null;
const normalized = normalizePath(path);
const rootEntry = book.entries.find((entry) => entry.path === normalized);
if (rootEntry) return { entry: rootEntry, groupID: "" };
return findEntryInGroups(book.groups, normalized);
}
function findEntryInGroups(groups: BookmarkGroup[], path: string): { entry: BookmarkEntry; groupID: string } | null {
for (const group of groups) {
const entry = group.entries.find((item) => item.path === path);
if (entry) return { entry, groupID: group.id };
const nested = findEntryInGroups(group.groups, path);
if (nested) return nested;
}
return null;
}
function isBookmarked(path: string, book = activeBook.value): boolean {
return !!findEntry(path, book);
}
function isBookmarkedInGroup(
path: string,
groupID = selectedGroupId.value,
book = activeBook.value
): boolean {
if (!book) return false;
const normalized = normalizePath(path);
if (groupID) {
const group = findGroup(book.groups, groupID);
return !!group?.entries.some((entry) => entry.path === normalized);
}
return book.entries.some((entry) => entry.path === normalized);
}
function assertEditable(book: BookmarkBookState | null | undefined): asserts book is BookmarkBookState {
if (!book) throw new Error("尚未加载书签簿");
if (!book.editable) throw new Error("当前书签簿不可编辑");
}
function assertLoaded(): void {
if (!loaded.value) throw new Error("书签簿正在加载");
}
function findGroup(groups: BookmarkGroup[], id: string): BookmarkGroup | null {
if (!id) return null;
for (const group of groups) {
if (group.id === id) return group;
const nested = findGroup(group.groups, id);
if (nested) return nested;
}
return null;
}
function findGroupWithParent(
groups: BookmarkGroup[],
id: string,
parent: BookmarkGroup | null = null
): { group: BookmarkGroup; parent: BookmarkGroup | null } | null {
for (const group of groups) {
if (group.id === id) return { group, parent };
const nested = findGroupWithParent(group.groups, id, group);
if (nested) return nested;
}
return null;
}
function isDescendantGroup(group: BookmarkGroup, id: string): boolean {
return !!findGroup(group.groups, id);
}
function cloneEntries(entries: BookmarkEntry[]): BookmarkEntry[] {
return entries.map((entry) => ({ ...entry }));
}
function cloneGroups(groups: BookmarkGroup[]): BookmarkGroup[] {
return groups.map((group) => ({
id: group.id,
name: group.name,
groups: cloneGroups(group.groups),
entries: cloneEntries(group.entries),
}));
}
function cloneGroupsWithNewIDs(
groups: BookmarkGroup[],
reservedIDs: Set<string>,
copiedIDs: Map<string, string>
): BookmarkGroup[] {
return groups.map((group) => {
const id = newGroupID(reservedIDs);
reservedIDs.add(id);
copiedIDs.set(group.id, id);
return {
id,
name: group.name,
groups: cloneGroupsWithNewIDs(group.groups, reservedIDs, copiedIDs),
entries: cloneEntries(group.entries),
};
});
}
function appendUniqueEntries(destination: BookmarkEntry[], incoming: BookmarkEntry[]): void {
const paths = new Set(destination.map((entry) => entry.path));
for (const entry of incoming) {
if (paths.has(entry.path)) continue;
destination.push(entry);
paths.add(entry.path);
}
}
function uniqueNameFromSet(names: Set<string>, base: string): string {
const normalized = base.trim() || "分组";
if (!names.has(normalized)) return normalized;
let number = 2;
let next = `${normalized} ${number}`;
while (names.has(next)) next = `${normalized} ${++number}`;
return next;
}
function fromServiceBook(book: BookmarkBook): BookmarkBookState {
return {
id: String(book.id ?? "").trim(),
name: String(book.name ?? "").trim(),
builtin: !!book.builtin,
editable: !!book.editable,
groups: (book.groups ?? []).filter(Boolean).map(fromServiceGroup),
entries: (book.entries ?? []).filter(Boolean).map((entry) => fromServiceEntry(entry)),
};
}
function fromServiceGroup(group: StoredBookmarkGroup): BookmarkGroup {
return {
id: String(group.id ?? "").trim(),
name: String(group.name ?? "").trim(),
groups: (group.groups ?? []).filter(Boolean).map(fromServiceGroup),
entries: (group.entries ?? []).filter(Boolean).map((entry) => fromServiceEntry(entry)),
};
}
function fromServiceEntry(entry: StoredBookmarkEntry): BookmarkEntry {
const path = normalizePath(String(entry.path ?? ""));
return {
path,
name: String(entry.name ?? "").trim() || basename(path),
fileIndex: -1,
};
}
function toServiceBook(book: BookmarkBookState): BookmarkBook {
return {
id: book.id,
name: book.name,
builtin: book.builtin,
editable: book.editable,
groups: book.groups.map(toServiceGroup),
entries: book.entries.map(toServiceEntry),
};
}
function toServiceGroup(group: BookmarkGroup): StoredBookmarkGroup {
return {
id: group.id,
name: group.name,
groups: group.groups.map(toServiceGroup),
entries: group.entries.map(toServiceEntry),
};
}
function toServiceEntry(entry: BookmarkEntry): StoredBookmarkEntry {
return { path: normalizePath(entry.path), name: entry.name.trim() || basename(entry.path) };
}
function toDocument(): BookmarkDocument {
return {
version: bookmarkDocumentVersion,
activeBookId: activeBookId.value,
books: books.value.map(toServiceBook),
};
}
function toBookFile(book: BookmarkBookState): BookmarkBookFile {
return {
version: bookmarkDocumentVersion,
name: book.name,
groups: book.groups.map(toServiceGroup),
entries: book.entries.map(toServiceEntry),
};
}
function applyDocument(document: BookmarkDocument): void {
const nextBooks = (document.books ?? [])
.filter(Boolean)
.map(fromServiceBook)
.filter((book) => book.id && book.name);
books.value = nextBooks;
activeBookId.value = nextBooks.some((book) => book.id === document.activeBookId)
? document.activeBookId
: builtinBookmarkBookID;
if (!nextBooks.some((book) => book.id === activeBookId.value)) {
activeBookId.value = nextBooks[0]?.id ?? builtinBookmarkBookID;
}
selectedGroupId.value = "";
recomputeCounters();
mutationVersion++;
savedVersion = mutationVersion;
saveError.value = "";
}
function recomputeCounters(): void {
let maxBook = 0;
let maxGroup = 0;
for (const book of books.value) {
const bookMatch = /^bookmark-(\d+)$/.exec(book.id);
if (bookMatch) maxBook = Math.max(maxBook, Number(bookMatch[1]));
const groupIDs = new Set<string>();
collectGroupIDs(book.groups, groupIDs);
for (const id of groupIDs) {
const groupMatch = /^bookmark-group-(\d+)$/.exec(id);
if (groupMatch) maxGroup = Math.max(maxGroup, Number(groupMatch[1]));
}
}
bookSequence = maxBook + 1;
groupSequence = maxGroup + 1;
}
function resetInMemory(): void {
books.value = [];
activeBookId.value = builtinBookmarkBookID;
selectedGroupId.value = "";
recomputeCounters();
mutationVersion++;
savedVersion = mutationVersion;
}
async function load(): Promise<void> {
if (loaded.value) return;
if (loadPromise) return loadPromise;
loadPromise = (async () => {
try {
applyDocument(await BookmarkService.LoadBookmarks());
loadError.value = "";
} catch (error: any) {
loadError.value = String(error?.message ?? error);
console.error("load bookmarks failed", error);
resetInMemory();
} finally {
loaded.value = true;
loadPromise = null;
}
})();
return loadPromise;
}
function markDirty(): void {
mutationVersion++;
saveError.value = "";
}
async function persist(): Promise<void> {
if (!loaded.value) return;
const version = mutationVersion;
const snapshot = toDocument();
saveChain = saveChain
.catch(() => undefined)
.then(async () => {
saving.value = true;
try {
await BookmarkService.SaveBookmarks(snapshot);
savedVersion = Math.max(savedVersion, version);
if (version === mutationVersion) saveError.value = "";
} catch (error: any) {
saveError.value = String(error?.message ?? error);
throw error;
} finally {
saving.value = false;
}
});
try {
await saveChain;
} catch {
// UI exposes saveError and retry; mutations remain in memory.
}
}
async function retrySave(): Promise<void> {
if (!dirty.value) return;
await persist();
}
async function importBook(): Promise<BookmarkBookState | null> {
await load();
const imported = await BookmarkService.ImportBookmarkBookDialog();
if (!imported) return null;
const importedName = String(imported.name ?? "").trim() || "导入书签簿";
const book: BookmarkBookState = {
id: newBookID(),
name: uniqueBookName(importedName),
builtin: false,
editable: true,
groups: regenerateImportedGroups(imported.groups ?? []),
entries: (imported.entries ?? []).filter(Boolean).map(fromServiceEntry),
};
books.value.push(book);
activeBookId.value = book.id;
selectedGroupId.value = "";
markDirty();
await persist();
if (archivePath) void resolveEntries(archivePath);
return book;
}
function regenerateImportedGroups(groups: StoredBookmarkGroup[]): BookmarkGroup[] {
const reservedIDs = new Set<string>();
return cloneGroupsWithNewIDs(
groups.filter(Boolean).map(fromServiceGroup),
reservedIDs,
new Map<string, string>()
);
}
async function exportActiveBook(): Promise<string | null> {
await load();
const book = activeBook.value;
if (!book) return null;
const path = await BookmarkService.ExportBookmarkBookDialog(toBookFile(book));
return path || null;
}
async function resolveEntries(path: string, request = resolveRequest): Promise<void> {
if (!path || request !== resolveRequest || !loaded.value) return;
const allEntries = books.value.flatMap((book) => flattenEntries(book));
const paths = [...new Set(allEntries.map((entry) => normalizePath(entry.path)).filter(Boolean))];
for (const entry of allEntries) entry.fileIndex = -1;
if (paths.length === 0) return;
resolving.value = true;
try {
const nodes = (await ArchiveService.ResolveFiles(paths)) ?? [];
if (request !== resolveRequest || archivePath !== path) return;
const byPath = new Map(
nodes
.filter((node): node is TreeNode => !!node && !node.isDir && node.fileIndex >= 0)
.map((node) => [normalizePath(node.path), node.fileIndex])
);
for (const entry of allEntries) entry.fileIndex = byPath.get(normalizePath(entry.path)) ?? -1;
} catch (error) {
console.error("resolve bookmark entries failed", error);
} finally {
if (request === resolveRequest) resolving.value = false;
}
}
function flattenEntries(book: BookmarkBookState): BookmarkEntry[] {
const result = [...book.entries];
const append = (groups: BookmarkGroup[]) => {
for (const group of groups) {
result.push(...group.entries);
append(group.groups);
}
};
append(book.groups);
return result;
}
function onArchiveOpened(event: any): void {
const path = String(event?.data?.path ?? event?.path ?? "");
archivePath = path;
sessionId.value++;
const request = ++resolveRequest;
void load().then(() => resolveEntries(path, request));
}
function onArchiveChanged(event: any): void {
const path = String(event?.data?.path ?? event?.path ?? "");
if (path && path === archivePath) void resolveEntries(path);
else if (!path && archivePath) void resolveEntries(archivePath);
}
function onArchiveClosed(): void {
archivePath = "";
resolveRequest++;
resolving.value = false;
sessionId.value++;
for (const book of books.value) {
for (const entry of flattenEntries(book)) entry.fileIndex = -1;
}
}
Events.On("archive:opened", onArchiveOpened);
Events.On("archive:changed", onArchiveChanged);
Events.On("archive:reloaded", onArchiveChanged);
Events.On("archive:closed", onArchiveClosed);
return {
books,
activeBookId,
activeBook,
activeGroup,
activeGroupId,
activeBookEditable,
selectedGroupId,
loaded,
resolving,
saving,
saveError,
loadError,
sessionId,
dirty,
hasCustomBooks,
load,
retrySave,
createBook,
renameBook,
deleteBook,
copyBook,
createGroup,
renameGroup,
deleteGroup,
moveEntry,
addEntries,
removeEntry,
renameEntry,
setActiveBook,
selectGroup,
findEntry,
isBookmarked,
isBookmarkedInGroup,
importBook,
exportActiveBook,
resolveEntries,
normalizePath,
};
});
+15
View File
@@ -0,0 +1,15 @@
import { useLogStore } from "./logs";
import { developmentLoggingEnabled } from "../loggingEnvironment";
const finishDisabledDebugTiming = () => {};
export function startDebugTiming(operation: string, detail: string): (outcome?: string) => void {
if (!developmentLoggingEnabled()) return finishDisabledDebugTiming;
const logs = useLogStore();
const started = performance.now();
logs.add("DEBUG", "performance", `${operation} started ${detail}`);
return (outcome = "finished") => {
logs.add("DEBUG", "performance",
`${operation} ${outcome} ${detail} elapsed=${(performance.now() - started).toFixed(2)}ms`);
};
}
+244
View File
@@ -0,0 +1,244 @@
import { computed, ref } from "vue";
import { defineStore } from "pinia";
import { DropService } from "../../bindings/pvfine/services";
import type {
DropRateApplyRequest,
DropRateDocument,
DropRateGroup,
DropRateSection,
} from "../../bindings/pvfine/services/models";
import { useArchiveStore } from "./archive";
export const DROP_RATE_LABELS = ["白云", "蓝天", "稀有", "神器", "史诗"] as const;
export interface DropRateGroupForm {
rates: Array<number | null>;
}
export interface DropRateSectionForm {
key: string;
groups: DropRateGroupForm[];
}
export const DROP_RATE_SECTION_META: Record<
string,
{ title: string; groupLabels: string[]; path: string }
> = {
hell: {
title: "深渊",
groupLabels: ["困难", "非常困难"],
path: "etc/itemdropinfo_monster_hell.etc",
},
flip: {
title: "翻牌",
groupLabels: ["困难"],
path: "etc/itemdropinfo_clearreward.etc",
},
monster: {
title: "小怪",
groupLabels: ["普通", "冒险", "王者", "地狱"],
path: "etc/itemdropinfo_monseter.etc",
},
elite: {
title: "精英",
groupLabels: ["普通", "冒险", "王者", "地狱"],
path: "etc/itemdropinfo_monseter_extra.etc",
},
};
export function percentToBasisPoints(value: number | null): number | null {
if (value === null || !Number.isFinite(value)) return null;
if (value < 0 || value > 100) return null;
const scaled = Math.round(value * 100);
if (Math.abs(value * 100 - scaled) > 1e-7) return null;
return scaled;
}
function ratesToBasisPoints(rates: Array<number | null>): number[] | null {
const values = rates.map(percentToBasisPoints);
if (values.some((value) => value === null)) return null;
return values as number[];
}
export function groupTotal(group: DropRateGroupForm): number | null {
const values = ratesToBasisPoints(group.rates);
if (!values) return null;
return values.reduce((sum, value) => sum + value, 0) / 100;
}
export function validateDropRateSections(sections: DropRateSectionForm[]): string {
const seen = new Set<string>();
for (const section of sections) {
const meta = DROP_RATE_SECTION_META[section.key];
if (!meta) return `未知掉率类型: ${section.key}`;
if (seen.has(section.key)) return `掉率类型重复: ${section.key}`;
seen.add(section.key);
if (section.groups.length !== meta.groupLabels.length) {
return `${meta.title}的分组数量不正确`;
}
for (let groupIndex = 0; groupIndex < section.groups.length; groupIndex++) {
const group = section.groups[groupIndex];
if (group.rates.length !== DROP_RATE_LABELS.length) {
return `${meta.title} ${meta.groupLabels[groupIndex]}的概率数量不正确`;
}
const values = ratesToBasisPoints(group.rates);
if (!values) {
return `${meta.title} ${meta.groupLabels[groupIndex]}包含无效概率(最多两位小数)`;
}
const sum = values.reduce((total, value) => total + value, 0);
if (sum !== 10000) {
return `${meta.title} ${meta.groupLabels[groupIndex]}的总和必须为 100.00%`;
}
}
}
if (seen.size !== Object.keys(DROP_RATE_SECTION_META).length) {
return "掉率数据不完整";
}
return "";
}
function formFromDocument(document: DropRateDocument): DropRateSectionForm[] {
return (document.sections ?? [])
.filter((section): section is DropRateSection => !!section)
.map((section) => ({
key: section.key,
groups: (section.groups ?? [])
.filter((group): group is DropRateGroup => !!group)
.map((group) => ({
rates: (group.rates ?? []).map((rate) => rate / 100),
})),
}));
}
function formFingerprint(sections: DropRateSectionForm[]): string {
return JSON.stringify(
sections.map((section) => ({
key: section.key,
groups: section.groups.map((group) => group.rates),
})),
);
}
function requestFromForm(revision: number, sections: DropRateSectionForm[]): DropRateApplyRequest {
return {
revision,
sections: sections.map((section) => ({
key: section.key,
groups: section.groups.map((group) => ({
rates: group.rates.map((rate) => percentToBasisPoints(rate) ?? 0),
})),
})),
};
}
export const useDropRateStore = defineStore("dropRate", () => {
const archive = useArchiveStore();
const visible = ref(false);
const loading = ref(false);
const applying = ref(false);
const error = ref("");
const revision = ref(0);
const pvfVersion = ref("");
const sections = ref<DropRateSectionForm[]>([]);
const baseline = ref("");
const supported = computed(
() =>
archive.open &&
["standard", "alternate", "tw", "paged110", "paged100cn", "paged115cn"].includes(archive.info?.format ?? ""),
);
const dirty = computed(() => formFingerprint(sections.value) !== baseline.value);
const validationError = computed(() => validateDropRateSections(sections.value));
const canApply = computed(
() =>
visible.value &&
!loading.value &&
!applying.value &&
sections.value.length > 0 &&
!validationError.value,
);
function reset(): void {
revision.value = 0;
pvfVersion.value = "";
sections.value = [];
baseline.value = "";
error.value = "";
}
async function load(): Promise<boolean> {
if (!supported.value) {
error.value = "基础掉率编辑器仅支持已打开的 90US/90CN/TW/110US/100CN/115CN PVF";
return false;
}
loading.value = true;
error.value = "";
try {
const document = await DropService.Read();
if (!document) throw new Error("后端没有返回掉率数据");
const next = formFromDocument(document);
sections.value = next;
revision.value = document.revision;
pvfVersion.value = document.pvfVersion;
baseline.value = formFingerprint(next);
return true;
} catch (value: any) {
error.value = String(value?.message ?? value ?? "读取掉率失败");
return false;
} finally {
loading.value = false;
}
}
async function open(): Promise<boolean> {
if (!supported.value || loading.value || applying.value) return false;
visible.value = true;
const loaded = await load();
if (!loaded) visible.value = false;
return loaded;
}
function close(): void {
if (loading.value || applying.value) return;
visible.value = false;
}
async function apply(): Promise<boolean> {
if (!canApply.value) {
error.value = validationError.value || "当前不能应用掉率修改";
return false;
}
applying.value = true;
error.value = "";
try {
const result = await DropService.Apply(requestFromForm(revision.value, sections.value));
revision.value = result.revision;
baseline.value = formFingerprint(sections.value);
return true;
} catch (value: any) {
error.value = String(value?.message ?? value ?? "应用掉率失败");
return false;
} finally {
applying.value = false;
}
}
return {
visible,
loading,
applying,
error,
revision,
pvfVersion,
sections,
supported,
dirty,
validationError,
canApply,
load,
open,
close,
apply,
reset,
};
});
File diff suppressed because it is too large. Load diff
+425
View File
@@ -0,0 +1,425 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { Events } from "@wailsio/runtime";
import { ArchiveService } from "../../bindings/pvfine/services";
import type {
SearchHit,
TreeAnnotation,
TreeNode,
TreeTag,
ImageReference,
} from "../../bindings/pvfine/services/models";
import { useArchiveStore } from "./archive";
import { startDebugTiming } from "./debugTiming";
export interface TreeItem {
key: string; // 归档内路径
label: string; // 显示名(目录/文件最后一段)
isDir: boolean;
isLeaf: boolean;
children: TreeItem[] | null; // null = 未加载
fileIndex: number; // 目录为 -1
size: number;
dataType: number;
childCount: number;
changeKind: string;
tags: TreeTag[];
annotations: TreeAnnotation[];
icon: ImageReference | null;
fieldImage: ImageReference | null;
}
export interface SearchItem {
key: string;
label: string;
path: string;
id: string;
category: string;
fileIndex: number;
rarity: number;
size: number;
dataType: number;
changeKind: string;
annotations: TreeAnnotation[];
pathAnnotations: Record<string, TreeAnnotation[]>;
icon: ImageReference | null;
fieldImage: ImageReference | null;
}
export interface RevealRequest {
path: string;
nonce: number;
}
/** 资源管理器状态:懒加载树 + 搜索 */
export const useExplorerStore = defineStore("explorer", () => {
const archive = useArchiveStore();
const roots = ref<TreeItem[]>([]);
const expanded = ref<Set<string>>(new Set([""]));
const selectedKey = ref<string | null>(null);
// 仅由显式的“定位”操作写入;文件树的滚动定位只响应这个请求,选中态本身不触发滚动。
const revealRequest = ref<RevealRequest | null>(null);
let revealNonce = 0;
const itemsByKey = new Map<string, TreeItem>();
// 搜索状态
const query = ref("");
const hits = ref<SearchItem[]>([]);
const nextCursor = ref(-1);
const searching = ref(false);
const mode = ref<"tree" | "search">("tree");
const exactMatch = ref(false);
const revision = ref(0);
let searchRequest = 0;
let refreshTimer: number | undefined;
let treeRefreshRequest = 0;
function toTreeItem(n: TreeNode): TreeItem {
return {
key: n.path,
label: n.name,
isDir: n.isDir,
isLeaf: !n.isDir,
children: n.isDir ? null : undefined!,
fileIndex: n.fileIndex,
size: n.size,
dataType: n.dataType,
childCount: n.childCount,
changeKind: n.changeKind ?? "",
tags: (n.tags ?? []).filter((tag): tag is TreeTag => !!tag),
annotations: cleanAnnotations(n.annotations),
icon: n.icon ?? null,
fieldImage: n.fieldImage ?? null,
};
}
function registerItems(items: TreeItem[]) {
for (const item of items) itemsByKey.set(item.key, item);
}
function toSearchItem(n: SearchHit): SearchItem {
const fallback = n.path.slice(n.path.lastIndexOf("/") + 1);
return {
key: `${n.fileIndex}:${n.category}:${n.id}:${n.path}`,
label: n.name || fallback,
path: n.path,
id: n.id,
category: n.category,
fileIndex: n.fileIndex,
rarity: n.rarity,
size: n.size,
dataType: n.dataType,
changeKind: n.changeKind ?? "",
annotations: cleanAnnotations(n.annotations),
pathAnnotations: Object.fromEntries(
Object.entries(n.pathAnnotations ?? {}).map(([path, annotations]) => [
path,
cleanAnnotations(annotations),
])
),
icon: n.icon ?? null,
fieldImage: n.fieldImage ?? null,
};
}
/** 加载根节点(归档打开后调用) */
async function loadRoots() {
const finish = startDebugTiming("explorer.roots", "");
try {
const nodes = (await ArchiveService.ListChildren("")) ?? [];
const nextRoots = nodes.filter((n): n is TreeNode => !!n).map(toTreeItem);
roots.value = nextRoots;
itemsByKey.clear();
registerItems(roots.value);
revision.value++;
} finally {
finish();
}
}
/** 重新加载资源树;搜索模式会等待新的语义索引完成后自动恢复。 */
async function reload(): Promise<void> {
const restoreSearch = mode.value === "search" && query.value.trim() !== "";
const currentQuery = query.value;
selectedKey.value = null;
revealRequest.value = null;
await loadRoots();
if (!restoreSearch) {
clearSearch();
return;
}
const request = ++searchRequest;
hits.value = [];
nextCursor.value = -1;
mode.value = "search";
searching.value = true;
try {
const status = await ArchiveService.IndexStatus();
if (request !== searchRequest || !archive.open) return;
if (status?.state === "ready") {
await search(currentQuery);
}
} finally {
if (request === searchRequest && mode.value === "search" && hits.value.length === 0) {
const status = await ArchiveService.IndexStatus().catch(() => null);
if (status?.state !== "building") searching.value = false;
}
}
}
/** n-tree onLoad:展开目录时加载其子节点 */
async function loadChildren(node: TreeItem): Promise<void> {
if (!node.isDir || node.children) return;
const finish = startDebugTiming("explorer.children", `path=${node.key}`);
try {
const nodes = (await ArchiveService.ListChildren(node.key)) ?? [];
const children = nodes.filter((n): n is TreeNode => !!n).map(toTreeItem);
node.children = children;
registerItems(node.children);
} finally {
finish();
}
}
/** 加载目标文件的父目录,并将其设为资源树当前选中项并滚动定位。 */
async function revealPath(path: string): Promise<boolean> {
if (!archive.open) return false;
const parts = normalizePath(path).split("/").filter(Boolean);
if (parts.length === 0) return false;
if (roots.value.length === 0) await loadRoots();
let items = roots.value;
let node: TreeItem | undefined;
for (let index = 0; index < parts.length; index++) {
const key = parts.slice(0, index + 1).join("/");
node = items.find((item) => item.key === key);
if (!node) return false;
if (index === parts.length - 1) break;
if (!node.isDir) return false;
await loadChildren(node);
if (!node.children) return false;
items = node.children;
}
if (!node || node.isDir) return false;
selectedKey.value = node.key;
revealRequest.value = { path: node.key, nonce: ++revealNonce };
return true;
}
/** 仅选中节点,不展开、不滚动。 */
function selectPath(path: string | null): void {
selectedKey.value = path;
}
/** 取消当前选中。 */
function clearSelection(): void {
selectedKey.value = null;
}
/** 文件树消费完一次定位请求后回调,避免重复滚动。 */
function consumeRevealRequest(): void {
revealRequest.value = null;
}
/** 已加载目录/搜索结果中的文件名,用于内容返回前的标签占位。 */
function getFilePath(index: number): string | undefined {
for (const item of itemsByKey.values()) {
if (!item.isDir && item.fileIndex === index) return item.key;
}
return hits.value.find((item) => item.fileIndex === index)?.path;
}
function getItem(path: string): TreeItem | undefined {
return itemsByKey.get(path);
}
function reset() {
searchRequest++;
treeRefreshRequest++;
window.clearTimeout(refreshTimer);
roots.value = [];
itemsByKey.clear();
selectedKey.value = null;
revealRequest.value = null;
expanded.value = new Set([""]);
clearSearch();
mode.value = "tree";
}
/** 刷新已加载节点的路径标注和可用的索引标签。 */
async function refreshTreeTags() {
if (!archive.open) return;
const request = ++treeRefreshRequest;
const loadedDirectories = Array.from(itemsByKey.values())
.filter((item) => item.isDir && item.children !== null)
.map((item) => item.key);
const paths = ["", ...loadedDirectories];
const nodeLists = await Promise.all(
paths.map(async (path) => (await ArchiveService.ListChildren(path)) ?? [])
);
if (request !== treeRefreshRequest || !archive.open) return;
const tagsByFile = new Map<number, TreeTag[]>();
const annotationsByPath = new Map<string, TreeAnnotation[]>();
const changeKindsByPath = new Map<string, string>();
const nodesByPath = new Map<string, TreeNode>();
for (const nodes of nodeLists) {
for (const node of nodes) {
if (!node) continue;
nodesByPath.set(node.path, node);
annotationsByPath.set(node.path, cleanAnnotations(node.annotations));
if (!node.isDir) changeKindsByPath.set(node.path, node.changeKind ?? "");
if (archive.indexReady && !node.isDir && node.fileIndex >= 0) {
tagsByFile.set(
node.fileIndex,
(node.tags ?? []).filter((tag): tag is TreeTag => !!tag)
);
}
}
}
for (const item of itemsByKey.values()) {
item.annotations = annotationsByPath.get(item.key) ?? item.annotations;
if (!item.isDir) {
item.changeKind = changeKindsByPath.get(item.key) ?? item.changeKind;
const tags = tagsByFile.get(item.fileIndex);
if (tags) item.tags = tags;
const node = nodesByPath.get(item.key);
if (node) {
item.icon = node.icon ?? null;
item.fieldImage = node.fieldImage ?? null;
}
}
}
}
async function refreshAnnotations() {
await refreshTreeTags();
if (mode.value === "search" && query.value.trim() && archive.indexReady) {
await search(query.value);
}
}
/** 执行新搜索(从第一页开始) */
async function search(q: string) {
const request = ++searchRequest;
query.value = q;
hits.value = [];
nextCursor.value = -1;
if (!q.trim()) {
searching.value = false;
mode.value = "tree";
return;
}
mode.value = "search";
if (!archive.indexReady) return;
await loadMore(request);
}
/** 切换路径、名称和 id 的精确匹配模式。 */
async function setExactMatch(value: boolean): Promise<void> {
if (exactMatch.value === value) return;
exactMatch.value = value;
if (query.value.trim()) await search(query.value);
}
/** 加载下一页搜索结果 */
async function loadMore(request = searchRequest) {
if (!archive.indexReady || request !== searchRequest) return;
if (nextCursor.value === -1 && hits.value.length > 0) return;
searching.value = true;
try {
const res = exactMatch.value
? await ArchiveService.SearchExact(query.value, Math.max(nextCursor.value, 0), 200)
: await ArchiveService.Search(query.value, Math.max(nextCursor.value, 0), 200);
if (request !== searchRequest) return;
const page = (res?.hits ?? [])
.filter((n): n is SearchHit => !!n)
.map(toSearchItem);
hits.value.push(...page);
nextCursor.value = res?.nextCursor ?? -1;
} finally {
if (request === searchRequest) searching.value = false;
}
}
function clearSearch() {
searchRequest++;
window.clearTimeout(refreshTimer);
query.value = "";
hits.value = [];
nextCursor.value = -1;
mode.value = "tree";
}
Events.On("archive:index-updated", () => {
void refreshTreeTags();
if (mode.value !== "search" || !query.value.trim() || !archive.indexReady) return;
window.clearTimeout(refreshTimer);
refreshTimer = window.setTimeout(() => void search(query.value), 0);
});
// 结构变更(脚本窗口或批处理在别处增删条目)后目录树必须重载;事件是
// 广播的,因此独立脚本窗口的变更也能反映到这个窗口。
Events.On("archive:batch-applied", (event: any) => {
const data = event?.data ?? event;
if (data?.structural) void reload();
});
Events.On("archive:index-ready", () => {
void refreshTreeTags();
if (mode.value === "search" && query.value.trim()) {
void search(query.value);
}
});
Events.On("archive:file-index-ready", () => {
if (archive.open) void loadRoots().catch(() => {});
});
Events.On("archive:reloaded", () => {
reset();
void loadRoots();
});
return {
roots,
expanded,
selectedKey,
revealRequest,
query,
hits,
nextCursor,
searching,
exactMatch,
mode,
revision,
toTreeItem,
toSearchItem,
getItem,
getFilePath,
loadRoots,
reload,
loadChildren,
revealPath,
selectPath,
clearSelection,
consumeRevealRequest,
refreshTreeTags,
refreshAnnotations,
reset,
search,
setExactMatch,
loadMore,
clearSearch,
};
});
function cleanAnnotations(
annotations: (TreeAnnotation | null)[] | null | undefined
): TreeAnnotation[] {
return (annotations ?? []).filter(
(annotation): annotation is TreeAnnotation => !!annotation
);
}
function normalizePath(path: string): string {
return path.replaceAll("\\", "/").replace(/^\/+|\/+$/g, "");
}
+236
View File
@@ -0,0 +1,236 @@
import { computed, ref, watch } from "vue";
import { defineStore } from "pinia";
import { Events } from "@wailsio/runtime";
import { ExportService } from "../../bindings/pvfine/services";
import type { ExportPreview, ExportRequest } from "../../bindings/pvfine/services/models";
import type { Descriptor } from "../../bindings/pvfine/internal/mod/models";
import { exportFileKey } from "../utils/export-tree";
export type ExportSource = Pick<ExportRequest, "source" | "scopes" | "commitId" | "diffId" | "reverse">;
export function modNameError(name: string): string {
if (!name || name !== name.trim() || /^[._]/.test(name)) return "名称不能为空、包含首尾空格或以 . / _ 开头";
if (name.length > 255) return "名称过长";
if (/[<>:"/\\|?*\x00-\x1f\x7f]/.test(name) || /[. ]$/.test(name)) return "名称包含非法字符";
if (/^(CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|COM[1-9¹²³]|LPT[1-9¹²³])(\.|$)/i.test(name)) return "不能使用 Windows 保留名称";
return "";
}
export function modVersionError(version: string): string {
if (version !== version.trim() || new TextEncoder().encode(version).length > 128) return "版本号不能包含首尾空格或超过 128 字节";
if (/[\x00-\x1f\x7f]/.test(version)) return "版本号不能包含控制字符";
return "";
}
export function modIDError(id: string): string {
return /^[a-z0-9_-]{1,64}$/.test(id) ? "" : "ID 仅支持小写字母、数字、-、_,长度为 1–64";
}
export const useExportStore = defineStore("export", () => {
const visible = ref(false);
const title = ref("");
const mode = ref<"direct" | "mod">("direct");
const format = ref("110USextend");
const name = ref("");
const version = ref("1.0");
const modId = ref("");
const includeDependencies = ref(false);
const mergePath = ref("");
const versionNotice = ref("");
const packageName = computed(() => `${name.value}-${version.value || (format.value === "115CN-mod" ? "1.0.0" : "1.0")}${format.value === "115CN-mod" ? ".zip" : "/"}`);
const selectingMerge = ref(false);
const formats = ref<Descriptor[]>([]);
const preview = ref<ExportPreview | null>(null);
const preparing = ref(false);
const running = ref(false);
const error = ref("");
const source = ref<ExportSource | null>(null);
const excludedPaths = ref<string[]>([]);
const selectedFiles = computed(() => {
const excluded = new Set(excludedPaths.value);
return (preview.value?.files ?? []).filter((file) => !excluded.has(file.path));
});
const selectedCount = computed(() => selectedFiles.value.filter((file) => !file.required).length);
const selectedDependencyCount = computed(() => selectedFiles.value.reduce((count, file) => count + file.dependencyCount, 0));
const checkedKeys = computed(() => selectedFiles.value.map((file) => exportFileKey(file.path)));
const nameError = computed(() => mode.value === "mod" ? modNameError(name.value) : "");
const versionError = computed(() => mode.value === "mod" ? modVersionError(version.value) : "");
const idError = computed(() => mode.value === "mod" && format.value === "115CN-mod" ? modIDError(modId.value) : "");
const canExport = computed(() => visible.value && !!preview.value && selectedCount.value > 0 && !preparing.value && !running.value && !selectingMerge.value && !nameError.value && !versionError.value && !idError.value);
let generation = 0;
let activePreviewID = "";
let queue = Promise.resolve();
let complete: ((path: string) => void) | null = null;
function releasePreview(): void {
const id = activePreviewID || preview.value?.id;
activePreviewID = "";
preview.value = null;
if (id) void ExportService.Release(id).catch(() => {});
}
function refresh(): Promise<void> {
const token = ++generation;
const session = complete;
// Keep the backend snapshot until Prepare replaces it, allowing name or
// version edits to reuse content without decoding files again.
preview.value = null;
error.value = "";
preparing.value = visible.value && !!source.value && !nameError.value && !versionError.value && !idError.value;
if (!preparing.value) return Promise.resolve();
const request: ExportRequest = {
...source.value!, mode: mode.value, format: format.value,
name: name.value, version: version.value, modId: modId.value, includeDependencies: mode.value === "mod" && includeDependencies.value,
mergePath: mode.value === "mod" ? mergePath.value : "",
};
// Serialize preparations so an older backend request cannot replace the
// latest preview, even when users change options quickly.
queue = queue.catch(() => {}).then(async () => {
if (token !== generation || !visible.value) return;
try {
const next = await ExportService.Prepare(request);
if (!visible.value || complete !== session) {
if (next?.id) await ExportService.Release(next.id);
return;
}
activePreviewID = next?.id ?? "";
if (token !== generation) {
if (!preparing.value) releasePreview();
return;
}
preview.value = next;
} catch (value: any) {
if (token === generation) error.value = String(value?.message ?? value);
} finally {
if (token === generation) preparing.value = false;
}
});
return queue;
}
function open(request: ExportSource, label: string): Promise<string> {
if (visible.value) return Promise.resolve("");
generation++;
mode.value = "direct";
format.value = "110USextend";
name.value = "";
version.value = "1.0";
modId.value = `pvfine-${crypto.randomUUID()}`;
includeDependencies.value = false;
mergePath.value = "";
versionNotice.value = "";
excludedPaths.value = [];
source.value = { ...request, scopes: [...(request.scopes ?? [])] };
title.value = label;
error.value = "";
formats.value = [];
visible.value = true;
const result = new Promise<string>((resolve) => { complete = resolve; });
const session = complete;
void ExportService.Formats().then((items) => {
if (visible.value && complete === session) {
formats.value = (items ?? []).filter((item) => item.canWrite);
}
}).catch((value: any) => {
if (visible.value && complete === session) error.value = String(value?.message ?? value);
});
void refresh();
return result;
}
async function selectMerge(): Promise<void> {
if (running.value || selectingMerge.value) return;
const session = complete;
const selectedFormat = format.value;
selectingMerge.value = true;
try {
const selected = await ExportService.SelectMergeDialog(selectedFormat);
if (selected && visible.value && complete === session && format.value === selectedFormat && mode.value === "mod") {
excludedPaths.value = [];
mergePath.value = selected.path;
name.value = selected.name;
version.value = selected.version;
versionNotice.value = selected.versionNotice;
}
} catch (value: any) {
if (visible.value && complete === session) error.value = String(value?.message ?? value);
} finally {
selectingMerge.value = false;
}
}
function finish(path: string): void {
generation++;
releasePreview();
visible.value = false;
preparing.value = false;
source.value = null;
const resolve = complete;
complete = null;
resolve?.(path);
}
function close(): void {
if (!running.value) finish("");
}
async function execute(): Promise<void> {
if (!canExport.value || !preview.value) return;
running.value = true;
error.value = "";
try {
const current = preview.value;
const paths = new Set((current.files ?? []).map((file) => file.path));
const path = await ExportService.ExecuteSelectionDialog(current.id, excludedPaths.value.filter((p) => paths.has(p)));
if (path) finish(path);
} catch (value: any) {
const text = String(value?.message ?? value);
if (!text.toLowerCase().includes("cancel")) {
error.value = text;
if (text.includes("过期")) invalidate(true);
}
} finally {
running.value = false;
}
}
function invalidate(force = false): void {
// Archive comparisons export a fixed snapshot, including captured drafts.
if (!force && source.value?.source === "archive-diff") return;
if (!visible.value || (running.value && !force)) return;
generation++;
releasePreview();
preparing.value = false;
error.value = "归档或版本已变化,请重新准备导出";
}
function selectFiles(keys: Array<string | number>): void {
if (running.value || preparing.value || !preview.value) return;
const files = preview.value.files ?? [];
const checked = new Set(keys);
const visiblePaths = new Set(files.map((file) => file.path));
excludedPaths.value = [
...excludedPaths.value.filter((path) => !visiblePaths.has(path)),
...files.filter((file) => !file.required && !checked.has(exportFileKey(file.path))).map((file) => file.path),
];
}
watch([mode, format, name, version, includeDependencies, mergePath], () => {
if (visible.value && !running.value) void refresh();
});
watch(format, () => { mergePath.value = ""; });
watch(modId, () => {
if (visible.value && !running.value && mode.value === "mod" && format.value === "115CN-mod") void refresh();
});
Events.On("archive:opened", close);
Events.On("archive:closed", close);
Events.On("archive:changed", () => invalidate());
Events.On("archive:reloaded", () => invalidate(source.value?.source === "archive-diff"));
Events.On("version:changed", () => invalidate());
Events.On("version:committed", () => invalidate());
return {
visible, title, mode, format, name, version, includeDependencies, formats, preview,
modId, idError, preparing, running, error, nameError, versionError, canExport, open, close, refresh, execute, invalidate,
mergePath, versionNotice, packageName, selectingMerge, selectMerge, excludedPaths, selectedCount, selectedDependencyCount, checkedKeys, selectFiles,
};
});
+20
View File
@@ -0,0 +1,20 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { Events } from "@wailsio/runtime";
/** Epoch also changes when the same archive path is reopened. */
export const useFileGUIStore = defineStore("fileGUI", () => {
const epoch = ref(0);
const revision = ref(0);
const ceraShopNameMode = ref<"product" | "item">("product");
// Keep the tree selection outside the modal, which is unmounted on close.
const skillTreeSelection = ref<{ job: string | null; kind: string; branch: string | null }>({ job: null, kind: "sp", branch: null });
const skillTreeCellSelections = ref<Record<string, number>>({});
for (const event of ["archive:opened", "archive:closed"]) {
Events.On(event, () => { epoch.value++; revision.value++; skillTreeCellSelections.value = {}; });
}
for (const event of ["archive:changed", "archive:reloaded", "archive:batch-applied", "archive:index-ready", "archive:index-updated", "archive:registrations-changed"]) {
Events.On(event, () => { revision.value++; });
}
return { epoch, revision, ceraShopNameMode, skillTreeSelection, skillTreeCellSelections };
});
+478
View File
@@ -0,0 +1,478 @@
import { computed, ref, watch } from "vue";
import { defineStore } from "pinia";
import { Events } from "@wailsio/runtime";
import { ArchiveService, FileSetService } from "../../bindings/pvfine/services";
import type {
FileSetDocument,
StoredFileSet,
StoredFileSetEntry,
TreeNode,
ImageReference,
} from "../../bindings/pvfine/services/models";
export interface FileSetEntry {
fileIndex: number; // -1 = 当前归档中不存在或尚未解析
path: string;
name: string;
ids: string[];
size: number;
dataType: number;
icon: ImageReference | null;
fieldImage: ImageReference | null;
}
export interface FileSet {
id: string;
name: string;
entries: FileSetEntry[];
}
const fileSetDocumentVersion = 1;
/** 文件集状态:路径跨 PVF 持久化,文件索引按当前归档动态解析。 */
export const useFileSetStore = defineStore("fileSets", () => {
const fileSets = ref<FileSet[]>([createFileSet("default", "默认文件集")]);
const activeSetId = ref("default");
const visible = ref(true);
const sessionId = ref(0);
const loaded = ref(false);
const resolving = ref(false);
const saving = ref(false);
const dirty = ref(false);
const loadError = ref("");
let nextSetId = 1;
let nextSetNumber = 2;
let mutationVersion = 0;
let loadPromise: Promise<void> | null = null;
let archivePath = "";
let resolveRequest = 0;
const activeSet = computed(() =>
fileSets.value.find((fileSet) => fileSet.id === activeSetId.value) ?? fileSets.value[0]
);
function createFileSet(id: string, name: string, entries: FileSetEntry[] = []): FileSet {
return { id, name, entries };
}
function markDirty(): void {
mutationVersion++;
dirty.value = true;
}
function assertName(name: string, currentId?: string): string {
const normalized = name.trim();
if (!normalized) throw new Error("文件集名称不能为空");
if (
fileSets.value.some(
(fileSet) => fileSet.id !== currentId && fileSet.name === normalized
)
) {
throw new Error("文件集名称不能重复");
}
return normalized;
}
function createSet(requestedName?: string): FileSet {
let name = requestedName?.trim();
if (!name) {
name = `文件集 ${nextSetNumber++}`;
while (fileSets.value.some((fileSet) => fileSet.name === name)) {
name = `文件集 ${nextSetNumber++}`;
}
}
name = assertName(name);
const fileSet = createFileSet(`set-${nextSetId++}`, name);
fileSets.value.push(fileSet);
activeSetId.value = fileSet.id;
markDirty();
return fileSet;
}
function renameSet(id: string, name: string): void {
const fileSet = fileSets.value.find((item) => item.id === id);
if (!fileSet) return;
const normalized = assertName(name, id);
if (fileSet.name === normalized) return;
fileSet.name = normalized;
markDirty();
}
function deleteSet(id: string): boolean {
const index = fileSets.value.findIndex((fileSet) => fileSet.id === id);
if (index < 0) return false;
fileSets.value.splice(index, 1);
if (activeSetId.value === id) {
const next = fileSets.value[index] ?? fileSets.value[index - 1];
activeSetId.value = next?.id ?? "";
}
markDirty();
return true;
}
function normalizePath(path: string): string {
return path.replaceAll("\\", "/").replace(/^\/+|\/+$/g, "");
}
function addEntries(entries: FileSetEntry[]): { added: number; skipped: number } {
let target = activeSet.value;
if (!target) {
const id = fileSets.value.length === 0 ? "default" : `set-${nextSetId++}`;
const name = fileSets.value.length === 0 ? "默认文件集" : `文件集 ${nextSetNumber++}`;
target = createFileSet(id, name);
fileSets.value.push(target);
activeSetId.value = target.id;
}
const existing = new Map(target.entries.map((entry) => [normalizePath(entry.path), entry]));
let added = 0;
let skipped = 0;
let changed = false;
for (const entry of entries) {
const path = normalizePath(entry.path);
if (entry.fileIndex < 0 || !path) {
skipped++;
continue;
}
const existingEntry = existing.get(path);
if (existingEntry) {
existingEntry.fileIndex = entry.fileIndex;
existingEntry.name = entry.name;
existingEntry.size = entry.size;
existingEntry.dataType = entry.dataType;
existingEntry.ids = [...new Set(entry.ids ?? [])];
existingEntry.icon = entry.icon ?? null;
existingEntry.fieldImage = entry.fieldImage ?? null;
skipped++;
changed = true;
continue;
}
const nextEntry = {
...entry,
path,
ids: [...new Set(entry.ids ?? [])],
};
target.entries.push(nextEntry);
existing.set(path, nextEntry);
added++;
changed = true;
}
if (changed) markDirty();
return { added, skipped };
}
function removeEntry(path: string): void {
const target = activeSet.value;
if (!target) return;
const normalizedPath = normalizePath(path);
const index = target.entries.findIndex(
(entry) => normalizePath(entry.path) === normalizedPath
);
if (index >= 0) {
target.entries.splice(index, 1);
markDirty();
}
}
function clearActive(): number {
const target = activeSet.value;
if (!target) return 0;
const count = target.entries.length;
if (count > 0) {
target.entries.splice(0, count);
markDirty();
}
return count;
}
watch(activeSetId, (next, previous) => {
if (loaded.value && next !== previous) markDirty();
});
async function load(): Promise<void> {
if (loaded.value) return;
if (loadPromise) return loadPromise;
loadPromise = (async () => {
try {
const document = await FileSetService.LoadFileSets();
applyDocument(document);
loadError.value = "";
} catch (error: any) {
loadError.value = String(error?.message ?? error);
console.error("load file sets failed", error);
resetInMemory();
} finally {
loaded.value = true;
loadPromise = null;
}
})();
return loadPromise;
}
/**
* 脚本应用文件集变更后重新读取磁盘内容。本地有未保存改动时不覆盖,
* 改为提示冲突:否则脚本的写入会被下一次保存反向覆盖,或直接丢掉用户的编辑。
*/
async function reload(): Promise<void> {
if (!loaded.value) {
await load();
return;
}
if (dirty.value) {
loadError.value = "脚本已修改文件集,但本地有未保存的改动,未自动刷新,请先保存或放弃";
return;
}
try {
const document = await FileSetService.LoadFileSets();
applyDocument(document, true);
loadError.value = "";
} catch (error: any) {
loadError.value = String(error?.message ?? error);
console.error("reload file sets failed", error);
}
}
async function save(): Promise<void> {
if (saving.value) return;
await load();
const version = mutationVersion;
saving.value = true;
try {
await FileSetService.SaveFileSets(toDocument());
if (version === mutationVersion) dirty.value = false;
} finally {
saving.value = false;
}
}
/**
* 用磁盘文档替换内存状态。keepActive 为 true 时(脚本写入后的重载)保留
* 用户当前选中的文件集,避免刷新把视图切回默认集。
*
* 保留 id "default" 的记录就是内置「默认文件集」本身:名字和内容都以磁盘
* 为准,不再改名成「默认文件集(已保存)」。这样侧边栏显示的名字和
* 脚本 pvf.fileset(name) 取到的名字始终是同一个。
*/
function applyDocument(document: FileSetDocument, keepActive = false): void {
const previousActive = activeSetId.value;
const storedSets = (document.fileSets ?? []).filter(
(fileSet): fileSet is StoredFileSet => !!fileSet
);
const nextSets = storedSets
.map(fromStoredFileSet)
.filter((fileSet): fileSet is FileSet => !!fileSet);
let defaultSet = createFileSet("default", "默认文件集");
const persistedDefault = nextSets.find((fileSet) => fileSet.id === "default");
if (persistedDefault) {
nextSets.splice(nextSets.indexOf(persistedDefault), 1);
// 名为「默认文件集」的空记录等同于未保存的内置默认集,不必单独保留。
if (persistedDefault.entries.length > 0 || persistedDefault.name !== "默认文件集") {
defaultSet = persistedDefault;
}
}
fileSets.value = [defaultSet, ...nextSets];
const survived =
keepActive && fileSets.value.some((fileSet) => fileSet.id === previousActive);
activeSetId.value = survived ? previousActive : "default";
recomputeCounters();
dirty.value = false;
mutationVersion++;
}
function resetInMemory(): void {
fileSets.value = [createFileSet("default", "默认文件集")];
activeSetId.value = "default";
recomputeCounters();
dirty.value = false;
mutationVersion++;
}
function toDocument(): FileSetDocument {
const persistedSets = fileSets.value.filter(
(fileSet) =>
fileSet.id !== "default" ||
fileSet.name !== "默认文件集" ||
fileSet.entries.length > 0
);
return {
version: fileSetDocumentVersion,
activeSetId: persistedSets.some((fileSet) => fileSet.id === activeSetId.value)
? activeSetId.value
: "",
fileSets: persistedSets.map((fileSet) => ({
id: fileSet.id,
name: fileSet.name,
entries: fileSet.entries.map((entry) => ({
path: normalizePath(entry.path),
name: entry.name,
ids: [...new Set(entry.ids ?? [])],
icon: entry.icon ?? null,
fieldImage: entry.fieldImage ?? null,
size: entry.size,
dataType: entry.dataType,
})),
})),
};
}
function fromStoredFileSet(fileSet: StoredFileSet): FileSet | null {
const id = String(fileSet.id ?? "").trim();
const name = String(fileSet.name ?? "").trim();
if (!id || !name) return null;
const entries = (fileSet.entries ?? [])
.filter((entry): entry is StoredFileSetEntry => !!entry)
.map((entry) => {
const path = normalizePath(String(entry.path ?? ""));
return {
fileIndex: -1,
path,
name: String(entry.name ?? "") || path.split("/").pop() || path,
ids: [...new Set(entry.ids ?? [])],
size: Number(entry.size ?? 0),
dataType: Number(entry.dataType ?? 0),
icon: null,
fieldImage: null,
};
})
.filter((entry) => !!entry.path);
return createFileSet(id, name, dedupeEntries(entries));
}
function dedupeEntries(entries: FileSetEntry[]): FileSetEntry[] {
const seen = new Set<string>();
return entries.filter((entry) => {
if (seen.has(entry.path)) return false;
seen.add(entry.path);
return true;
});
}
function recomputeCounters(): void {
let maxID = 0;
let maxNumber = 1;
for (const fileSet of fileSets.value) {
const idMatch = /^set-(\d+)$/.exec(fileSet.id);
if (idMatch) maxID = Math.max(maxID, Number(idMatch[1]));
const nameMatch = /^文件集 (\d+)$/.exec(fileSet.name);
if (nameMatch) maxNumber = Math.max(maxNumber, Number(nameMatch[1]));
}
nextSetId = maxID + 1;
nextSetNumber = Math.max(2, maxNumber + 1);
}
async function onArchiveOpened(event: any): Promise<void> {
const path = String(event?.data?.path ?? event?.path ?? "");
archivePath = path;
sessionId.value++;
const request = ++resolveRequest;
for (const fileSet of fileSets.value) {
for (const entry of fileSet.entries) {
entry.fileIndex = -1;
entry.icon = null;
entry.fieldImage = null;
}
}
await load();
if (request !== resolveRequest || archivePath !== path) return;
await resolveEntries(path, request);
}
function onArchiveClosed(): void {
archivePath = "";
resolveRequest++;
resolving.value = false;
sessionId.value++;
for (const fileSet of fileSets.value) {
for (const entry of fileSet.entries) {
entry.fileIndex = -1;
entry.icon = null;
entry.fieldImage = null;
}
}
}
async function resolveEntries(path: string, request = resolveRequest): Promise<void> {
if (!path || request !== resolveRequest) return;
const entries = fileSets.value.flatMap((fileSet) => fileSet.entries);
const paths = [...new Set(entries.map((entry) => normalizePath(entry.path)).filter(Boolean))];
if (paths.length === 0) return;
resolving.value = true;
let nodes: (TreeNode | null)[] | null;
try {
nodes = await ArchiveService.ResolveFiles(paths);
} catch (error) {
console.error("resolve file set entries failed", error);
if (request === resolveRequest) resolving.value = false;
return;
}
if (request !== resolveRequest || archivePath !== path) return;
const byPath = new Map(
(nodes ?? [])
.filter((node): node is TreeNode => !!node && !node.isDir && node.fileIndex >= 0)
.map((node) => [normalizePath(node.path), node])
);
for (const entry of entries) {
const node = byPath.get(normalizePath(entry.path));
entry.fileIndex = node?.fileIndex ?? -1;
if (node) {
entry.size = node.size;
entry.dataType = node.dataType;
const ids = [...new Set((node.tags ?? []).map((tag) => tag.id).filter(Boolean))];
const names = [
...new Set((node.tags ?? []).map((tag) => tag.name.trim()).filter(Boolean)),
];
entry.ids = ids;
entry.name = names.join(" / ") || node.name || entry.path.split("/").pop() || entry.path;
entry.icon = node.icon ?? null;
entry.fieldImage = node.fieldImage ?? null;
} else {
entry.icon = null;
entry.fieldImage = null;
}
}
if (request === resolveRequest) resolving.value = false;
}
Events.On("archive:opened", (event: any) => {
void onArchiveOpened(event);
});
Events.On("archive:changed", (event: any) => {
const path = String(event?.data?.path ?? event?.path ?? "");
if (path && path === archivePath) void resolveEntries(path);
});
Events.On("archive:index-ready", () => {
if (archivePath) void resolveEntries(archivePath);
});
Events.On("archive:index-updated", () => {
if (archivePath) void resolveEntries(archivePath);
});
Events.On("archive:closed", onArchiveClosed);
// 脚本应用文件集变更后 file-sets.json 已改写,这份内存副本必须重载;
// 否则用户看到的仍是旧内容,下次保存还会把脚本的改动覆盖回去。
Events.On("fileset:changed", () => {
void reload();
});
return {
fileSets,
activeSetId,
activeSet,
visible,
sessionId,
loaded,
resolving,
saving,
dirty,
loadError,
load,
reload,
save,
createSet,
renameSet,
deleteSet,
addEntries,
removeEntry,
clearActive,
};
});
+91
View File
@@ -0,0 +1,91 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { Events } from "@wailsio/runtime";
import { ImageService } from "../../bindings/pvfine/services";
import { createImageLoader } from "../utils/image-loader";
import type { ImageIndexStatus } from "../../bindings/pvfine/services/models";
const idleStatus: ImageIndexStatus = {
state: "idle",
stage: "idle",
directory: "",
done: 0,
total: 0,
npkFiles: 0,
imgFiles: 0,
imageCount: 0,
skipped: 0,
duplicates: 0,
error: "",
generation: 0,
buildDurationMs: 0,
};
export const useImageStore = defineStore("images", () => {
const status = ref<ImageIndexStatus>({ ...idleStatus });
const revision = ref(0);
const loader = createImageLoader(
() => status.value.generation,
async (references, generation) => (await ImageService.GetImages(references, generation)) ?? [],
(error) => console.warn("load PVF images failed", error),
);
let initialized = false;
function eventStatus(event: any): ImageIndexStatus | null {
const value = event?.data ?? event;
if (!value || typeof value !== "object" || typeof value.state !== "string") return null;
return value as ImageIndexStatus;
}
function acceptStatus(next: ImageIndexStatus | null): void {
if (!next) return;
if (next.generation !== status.value.generation) {
loader.reset();
revision.value++;
}
status.value = next;
if (next.state === "ready" || next.state === "error") {
revision.value++;
loader.reset();
}
}
async function initialize(): Promise<ImageIndexStatus> {
if (initialized) return status.value;
initialized = true;
try {
acceptStatus(await ImageService.Initialize());
} catch (error) {
initialized = false;
console.error("initialize image index failed", error);
}
return status.value;
}
async function selectDirectory(): Promise<ImageIndexStatus> {
const next = await ImageService.SelectNPKDirectory();
acceptStatus(next);
return status.value;
}
async function rebuild(): Promise<ImageIndexStatus> {
const next = await ImageService.RebuildIndex();
acceptStatus(next);
return status.value;
}
const loadImage = loader.load;
Events.On("image:index-progress", (event: any) => acceptStatus(eventStatus(event)));
Events.On("image:index-ready", (event: any) => acceptStatus(eventStatus(event)));
Events.On("image:index-error", (event: any) => acceptStatus(eventStatus(event)));
return {
status,
revision,
initialize,
selectDirectory,
rebuild,
loadImage,
};
});
+46
View File
@@ -0,0 +1,46 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import type { ImportPreview } from "../../bindings/pvfine/services/models";
export type ImportMode = "text" | "raw";
/** 文件导入弹窗状态,供工具栏和资源管理器共享。 */
export const useImportStore = defineStore("import", () => {
const visible = ref(false);
const targetDir = ref("");
const mode = ref<ImportMode>("text");
const sourcePaths = ref<string[]>([]);
const preview = ref<ImportPreview | null>(null);
const running = ref(false);
const error = ref("");
function open(target = ""): void {
if (running.value) return;
targetDir.value = target.replaceAll("\\", "/").replace(/^\/+|\/+$/g, "");
mode.value = "text";
sourcePaths.value = [];
preview.value = null;
error.value = "";
visible.value = true;
}
function close(): void {
if (running.value) return;
visible.value = false;
sourcePaths.value = [];
preview.value = null;
error.value = "";
}
return {
visible,
targetDir,
mode,
sourcePaths,
preview,
running,
error,
open,
close,
};
});
+113
View File
@@ -0,0 +1,113 @@
import { computed, ref, shallowRef } from "vue";
import { defineStore } from "pinia";
import { developmentLoggingEnabled } from "../loggingEnvironment";
export const logLevels = ["DEBUG", "INFO", "WARN", "ERROR"] as const;
export type LogLevel = typeof logLevels[number];
export interface LogEntry {
id: string;
timestamp: string;
level: LogLevel;
source: string;
message: string;
backendID?: number;
}
export const logCapacity = 2000;
export function formatLogValue(value: unknown): string {
if (value instanceof Error) return value.stack || value.message;
if (typeof value === "string") return value;
try {
return JSON.stringify(value) ?? String(value);
} catch {
return String(value);
}
}
export const useLogStore = defineStore("logs", () => {
// 日志条目只追加、不编辑,以快照发布,避免为历史记录创建深层响应式代理。
const entries = shallowRef<LogEntry[]>([]);
const expanded = ref(false);
const levels = ref<LogLevel[]>(["INFO", "WARN", "ERROR"]);
const follow = ref(true);
let sequence = 0;
let clearedBackendID = 0;
let latestBackendID = 0;
let pendingBackendLogs: unknown[] = [];
let backendLogTimer: ReturnType<typeof setTimeout> | undefined;
const seenBackendIDs = new Set<number>();
const filtered = computed(() => entries.value.filter((entry) => levels.value.includes(entry.level)));
const errorCount = computed(() => entries.value.filter((entry) => entry.level === "ERROR").length);
function publish(next: LogEntry[]) {
const excess = Math.max(0, next.length - logCapacity);
for (let index = 0; index < excess; index++) {
const id = next[index].backendID;
if (id !== undefined) seenBackendIDs.delete(id);
}
entries.value = excess ? next.slice(excess) : next;
}
function add(level: LogLevel, source: string, ...values: unknown[]) {
if (level === "DEBUG" && !developmentLoggingEnabled()) return;
publish([...entries.value, {
id: `frontend-${++sequence}`, timestamp: new Date().toISOString(), level, source,
message: values.map(formatLogValue).join(" "),
}]);
}
function ingest(data: unknown) {
const batch = Array.isArray(data) ? data : [data];
const development = developmentLoggingEnabled();
const next = entries.value.slice();
for (const value of batch) {
if (!value || typeof value !== "object") continue;
const item = value as Record<string, unknown>;
const id = Number(item.id);
if (!Number.isSafeInteger(id) || id <= clearedBackendID || seenBackendIDs.has(id) ||
typeof item.message !== "string" || typeof item.timestamp !== "string") continue;
const rawLevel = String(item.level).toUpperCase();
const level: LogLevel = rawLevel === "ERR" ? "ERROR" :
logLevels.includes(rawLevel as LogLevel) ? rawLevel as LogLevel : "INFO";
const source = String(item.source ?? "Go");
if (!development && (level === "DEBUG" || source === "Go" && level !== "WARN" && level !== "ERROR")) continue;
seenBackendIDs.add(id);
latestBackendID = Math.max(latestBackendID, id);
next.push({
id: `backend-${id}`, backendID: id, timestamp: item.timestamp,
level, source, message: item.message,
});
}
if (next.length === entries.value.length) return;
next.sort((a, b) => {
if (a.backendID !== undefined && b.backendID !== undefined) return a.backendID - b.backendID;
return Date.parse(a.timestamp) - Date.parse(b.timestamp);
});
publish(next);
}
function flushBackendLogs() {
if (backendLogTimer !== undefined) clearTimeout(backendLogTimer);
backendLogTimer = undefined;
const batch = pendingBackendLogs;
pendingBackendLogs = [];
if (batch.length) ingest(batch);
}
/** 同一批 Wails 事件只排序、裁剪和通知界面一次,避免日志阻塞文件响应。 */
function enqueueBackendLogs(data: unknown) {
if (Array.isArray(data)) pendingBackendLogs.push(...data);
else pendingBackendLogs.push(data);
if (backendLogTimer === undefined) backendLogTimer = setTimeout(flushBackendLogs, 16);
}
function clear() {
// 先更新已接收日志的 ID 水位,延迟到达的历史不能恢复清空前的记录。
flushBackendLogs();
clearedBackendID = latestBackendID;
entries.value = [];
seenBackendIDs.clear();
}
return { entries, expanded, levels, follow, filtered, errorCount, add, ingest, enqueueBackendLogs, clear };
});
+17
View File
@@ -0,0 +1,17 @@
import { defineStore } from "pinia";
import { ref } from "vue";
export interface RuleEditorTarget {
category: "annotations" | "rendering";
path: string;
section: string;
directory: boolean;
}
export const useRuleEditorStore = defineStore("ruleEditor", () => {
const target = ref<RuleEditorTarget | null>(null);
const visible = ref(false);
function open(value: RuleEditorTarget) {
target.value = value;
visible.value = true;
}
return { target, visible, open };
});
+698
View File
@@ -0,0 +1,698 @@
import { assertMCPIdle } from "../mcpActivity";
import { computed, ref, watch } from "vue";
import { defineStore } from "pinia";
import { Events } from "@wailsio/runtime";
import { MCPService, ScriptService, ScriptWindowService } from "../../bindings/pvfine/services";
import type {
ScriptApplyResult,
ScriptCompileResult,
ScriptDiagnostic,
ScriptFile,
ScriptFilePreview,
ScriptFileSetPreview,
ScriptLog,
ScriptPreviewPage,
ScriptRunResult,
} from "../../bindings/pvfine/services/models";
import { useArchiveStore } from "./archive";
export const defaultScriptName = "untitled.pvf.js";
export const defaultScriptSource = `const minimumPrice = 10;
for (const file of pvf.glob("equipment/**/*.equ")) {
\tconst document = file.parse();
\tconst price = document.section("price");
\tif (!price) continue;
\tconst currentPrice = price.get();
\tif (typeof currentPrice !== "number" || currentPrice >= minimumPrice) continue;
\tprice.set(minimumPrice);
\tfile.write(document);
}
`;
/** 脚本工作区状态、运行日志和当前预览计划。 */
export const useScriptStore = defineStore("script", () => {
const archive = useArchiveStore();
const workspaceVisible = ref(false);
// 工作区已分离到独立窗口时为 true:主窗口不再内嵌脚本界面,工具栏按钮
// 改为聚焦那个窗口。
const workspaceDetached = ref(false);
// 独立窗口里的未保存状态。主窗口有自己的一份 Pinia store,拿不到对方的
// dirty,只能由脚本窗口主动上报。
const detachedDirty = ref(false);
const files = ref<ScriptFile[]>([]);
const directory = ref("");
const currentName = ref(defaultScriptName);
const source = ref(defaultScriptSource);
const savedSource = ref(defaultScriptSource);
const loadingFiles = ref(false);
const loadingScript = ref(false);
const saving = ref(false);
const running = ref(false);
const stopping = ref(false);
const applying = ref(false);
const refreshing = ref(false);
const error = ref("");
const stale = ref(false);
const compileResult = ref<ScriptCompileResult | null>(null);
const runResult = ref<ScriptRunResult | null>(null);
const runOrigin = ref<"ui" | "mcp">("ui");
const logs = ref<ScriptLog[]>([]);
const progress = ref({ done: 0, total: 0, message: "", currentPath: "" });
const rows = ref<ScriptFilePreview[]>([]);
// 文件集变更不是归档 diff,单独展示;但应用时和归档变更一起勾选。
const fileSetRows = ref<ScriptFileSetPreview[]>([]);
const fileSetSelectKey = ref("");
const planId = ref("");
const nextCursor = ref(-1);
const scannedFiles = ref(0);
const modifiedFiles = ref(0);
// Path filter applied by the backend so paging and the matched total stay
// consistent; never filter rows.value locally or later pages get lost.
const filter = ref("");
const matchedFiles = ref(0);
const selectedKeys = ref<Set<string>>(new Set());
let selectionMode: "all" | "none" | "some" = "all";
const excludedKeys = new Set<string>();
let runRequest = 0;
let filterTimer: ReturnType<typeof setTimeout> | null = null;
const dirty = computed(() => source.value !== savedSource.value);
const hasPreview = computed(() => !!planId.value && !stale.value);
const filtered = computed(() => filter.value.trim() !== "");
const selectedCount = computed(() => {
// Read the reactive set in both modes so toggling one loaded row also
// refreshes the total count while the all-pages mode uses exclusions.
const loadedSelectedCount = selectedKeys.value.size;
if (selectionMode === "all") {
// 归档行按路径筛选,"全选"意味着所有匹配项减去被排除的那些。
// 文件集那一组不受路径筛选影响,单独按是否被排除计数:把它和归档
// 行混在一起算会因为 excludedKeys 同时含有两类 key 而重复扣减。
const fileSetKey = fileSetSelectKey.value;
const fileSetSelected = fileSetKey && !excludedKeys.has(fileSetKey) ? 1 : 0;
let archiveExcluded = 0;
for (const key of excludedKeys) {
if (key !== fileSetKey) archiveExcluded++;
}
return Math.max(0, matchedFiles.value - archiveExcluded + fileSetSelected);
}
return loadedSelectedCount;
});
const canRun = computed(
() => archive.open && !running.value && !saving.value && source.value.trim() !== "",
);
const canApply = computed(
() => hasPreview.value && !running.value && !applying.value && selectedCount.value > 0,
);
const diagnostics = computed(() =>
(compileResult.value?.diagnostics ?? []).filter(
(item): item is ScriptDiagnostic => !!item,
),
);
function eventData(event: any): any {
return event?.data ?? event;
}
function errorMessage(value: any): string {
return String(value?.message ?? value ?? "脚本操作失败");
}
function resetPreview(): void {
runRequest++;
if (filterTimer) {
clearTimeout(filterTimer);
filterTimer = null;
}
rows.value = [];
fileSetRows.value = [];
fileSetSelectKey.value = "";
planId.value = "";
nextCursor.value = -1;
scannedFiles.value = 0;
modifiedFiles.value = 0;
filter.value = "";
matchedFiles.value = 0;
selectedKeys.value = new Set();
selectionMode = "all";
excludedKeys.clear();
stale.value = false;
runResult.value = null;
progress.value = { done: 0, total: 0, message: "", currentPath: "" };
}
/** 重新从第一页拉取预览;筛选变化后必须重取,否则只筛已加载的行。 */
async function reloadPreview(): Promise<void> {
if (!planId.value || !hasPreview.value) return;
const request = runRequest;
try {
const page = await ScriptService.PreviewPage(planId.value, filter.value, 0, 100);
if (request !== runRequest) return;
if (page) applyPage(page, false);
} catch (value: any) {
stale.value = true;
error.value = errorMessage(value);
}
}
/**
* 修改文件路径筛选。筛选由后端执行,因此这里要重取第一页,
* 并把选择重置为“当前筛选结果全选”,避免隐藏的旧排除项继续生效。
*/
function setFilter(value: string): void {
if (filter.value === value) return;
filter.value = value;
selectionMode = "all";
excludedKeys.clear();
selectedKeys.value = new Set();
if (filterTimer) clearTimeout(filterTimer);
filterTimer = setTimeout(() => {
filterTimer = null;
void reloadPreview();
}, 200);
}
function clearFilter(): void {
if (filterTimer) {
clearTimeout(filterTimer);
filterTimer = null;
}
setFilter("");
}
function resetForNewSource(): void {
compileResult.value = null;
error.value = "";
resetPreview();
}
/** 把当前脚本内容交付给独立窗口,并让主窗口回到归档编辑。 */
async function detachWorkspace(): Promise<void> {
if (!archive.open || running.value) return;
error.value = "";
try {
await ScriptWindowService.OpenScriptWindow(sessionSnapshot());
workspaceDetached.value = true;
workspaceVisible.value = false;
} catch (value: any) {
error.value = errorMessage(value);
throw value;
}
}
/** 聚焦已分离的脚本窗口;窗口已不在时退回内嵌模式。 */
async function focusScriptWindow(): Promise<void> {
try {
const focused = await ScriptWindowService.FocusScriptWindow();
if (!focused) {
workspaceDetached.value = false;
showWorkspace();
}
} catch (value: any) {
error.value = errorMessage(value);
}
}
function sessionSnapshot(): { name: string; source: string; savedSource: string } {
return {
name: currentName.value,
source: source.value,
savedSource: savedSource.value,
};
}
/** 独立窗口关闭后接收交回的内容,供主窗口下次进入工作区时恢复。 */
function adoptHandedBackSession(session: { name?: string; source?: string; savedSource?: string } | null): void {
workspaceDetached.value = false;
detachedDirty.value = false;
if (!session) return;
if (typeof session.name === "string" && session.name !== "") currentName.value = session.name;
if (typeof session.source !== "string") return;
source.value = session.source;
// savedSource 决定"未保存"标记,必须一起带回来。
savedSource.value =
typeof session.savedSource === "string" ? session.savedSource : session.source;
resetForNewSource();
}
// 脚本窗口的 Pinia store 是独立的,运行日志、预览计划、源码都在那边,
// 主窗口这份不再有意义;同步拉取暂存内容即可。
async function syncFromDetachedWindow(): Promise<void> {
try {
const session = await ScriptWindowService.LoadScriptSession();
adoptHandedBackSession(session);
} catch (value: any) {
error.value = errorMessage(value);
}
}
function showWorkspace(): void {
if (!archive.open) return;
// 已分离时按钮语义是聚焦独立窗口,不在主窗口内嵌打开。
if (workspaceDetached.value) {
void focusScriptWindow();
return;
}
workspaceVisible.value = true;
void refreshFiles();
}
function hideWorkspace(): void {
if (running.value) return;
workspaceVisible.value = false;
}
// 外部文件入口(资源管理器、文件集、书签等)打开归档文件时,
// 允许直接切回归档编辑,即使脚本仍在后台运行。
function showArchiveEditor(): void {
workspaceVisible.value = false;
}
async function refreshFiles(): Promise<void> {
if (loadingFiles.value) return;
loadingFiles.value = true;
error.value = "";
try {
const [result, path] = await Promise.all([
ScriptService.ListScripts(),
ScriptService.ScriptDirectory(),
]);
files.value = (result ?? []).filter((item): item is ScriptFile => !!item);
directory.value = path;
} catch (value: any) {
error.value = errorMessage(value);
} finally {
loadingFiles.value = false;
}
}
function newScript(): void {
currentName.value = defaultScriptName;
source.value = defaultScriptSource;
savedSource.value = defaultScriptSource;
resetForNewSource();
}
async function loadScript(name: string): Promise<void> {
if (loadingScript.value || running.value) return;
loadingScript.value = true;
error.value = "";
try {
const text = await ScriptService.LoadScript(name);
currentName.value = name;
source.value = text;
savedSource.value = text;
resetForNewSource();
} catch (value: any) {
error.value = errorMessage(value);
} finally {
loadingScript.value = false;
}
}
function updateSource(text: string): void {
source.value = text;
compileResult.value = null;
if (planId.value) stale.value = true;
}
function clearLogs(): void {
logs.value = [];
}
async function saveScript(name = currentName.value): Promise<void> {
if (saving.value) return;
saving.value = true;
error.value = "";
try {
let nextName = name.trim() || defaultScriptName;
if (!nextName.toLowerCase().endsWith(".pvf.js")) nextName += ".pvf.js";
await ScriptService.SaveScript(nextName, source.value);
currentName.value = nextName;
savedSource.value = source.value;
await refreshFiles();
} catch (value: any) {
error.value = errorMessage(value);
throw value;
} finally {
saving.value = false;
}
}
async function openDirectory(): Promise<void> {
try {
await ScriptService.OpenScriptDirectory();
} catch (value: any) {
error.value = errorMessage(value);
throw value;
}
}
async function compile(): Promise<ScriptCompileResult> {
error.value = "";
const result = await ScriptService.Compile(source.value);
compileResult.value = result;
return result;
}
function applyPage(page: ScriptPreviewPage, append: boolean): void {
const pageRows = (page.rows ?? []).filter(
(item): item is ScriptFilePreview => !!item,
);
if (append) rows.value.push(...pageRows);
else rows.value = pageRows;
// 文件集行只随第一页返回,翻页时不要覆盖已有内容。
if (!append) {
fileSetRows.value = (page.fileSetRows ?? []).filter(
(item): item is ScriptFileSetPreview => !!item,
);
fileSetSelectKey.value = page.fileSetSelectKey ?? "";
}
const selected = new Set(selectedKeys.value);
for (const row of pageRows) {
if (
isSelectable(row) &&
selectionMode === "all" &&
!excludedKeys.has(row.changeKey)
) {
selected.add(row.changeKey);
}
}
if (
!append &&
fileSetSelectKey.value &&
selectionMode === "all" &&
!excludedKeys.has(fileSetSelectKey.value)
) {
selected.add(fileSetSelectKey.value);
}
selectedKeys.value = selected;
planId.value = page.planId;
nextCursor.value = page.nextCursor;
scannedFiles.value = page.scannedFiles;
modifiedFiles.value = page.modifiedFiles;
matchedFiles.value = page.matchedFiles ?? page.modifiedFiles ?? 0;
}
/** 只有有实际变更的行可以勾选:新建、删除和已修改。 */
function isSelectable(row: ScriptFilePreview): boolean {
return (
row.status === "changed" || row.status === "added" || row.status === "deleted"
);
}
async function run(): Promise<ScriptRunResult> {
assertMCPIdle();
runOrigin.value = "ui";
if (!canRun.value) throw new Error("当前无法运行脚本");
const request = ++runRequest;
running.value = true;
stopping.value = false;
error.value = "";
compileResult.value = null;
rows.value = [];
fileSetRows.value = [];
fileSetSelectKey.value = "";
planId.value = "";
nextCursor.value = -1;
filter.value = "";
matchedFiles.value = 0;
selectedKeys.value = new Set();
selectionMode = "all";
excludedKeys.clear();
stale.value = false;
runResult.value = null;
logs.value = [];
progress.value = { done: 0, total: 0, message: "准备运行…", currentPath: "" };
try {
const result = await ScriptService.Run({ name: currentName.value, source: source.value });
if (request !== runRequest) return result;
runResult.value = result;
if (result.logs) logs.value = result.logs.filter((item): item is ScriptLog => !!item);
if (result.status === "completed" && result.planId) {
const page = await ScriptService.PreviewPage(result.planId, "", 0, 100);
if (page) applyPage(page, false);
} else if (result.error) {
error.value = result.error.message;
}
return result;
} catch (value: any) {
if (request === runRequest) error.value = errorMessage(value);
throw value;
} finally {
if (request === runRequest) {
running.value = false;
stopping.value = false;
}
}
}
async function cancel(): Promise<void> {
if (!running.value) return;
stopping.value = true;
try {
await ScriptService.Cancel();
} catch (value: any) {
stopping.value = false;
error.value = errorMessage(value);
}
}
async function loadMore(): Promise<void> {
if (!hasPreview.value || nextCursor.value < 0 || !planId.value) return;
try {
const page = await ScriptService.PreviewPage(planId.value, filter.value, nextCursor.value, 100);
if (page) applyPage(page, true);
} catch (value: any) {
stale.value = true;
error.value = errorMessage(value);
}
}
function selectAll(): void {
selectionMode = "all";
excludedKeys.clear();
const selected = rows.value
.filter((row) => isSelectable(row))
.map((row) => row.changeKey);
if (fileSetSelectKey.value) selected.push(fileSetSelectKey.value);
selectedKeys.value = new Set(selected);
}
function clearSelection(): void {
selectionMode = "none";
excludedKeys.clear();
selectedKeys.value = new Set();
}
/** 切换单行;只影响当前筛选结果内的这一行。 */
function toggleSelected(changeKey: string): void {
const selected = new Set(selectedKeys.value);
if (selectionMode === "all") {
if (selected.has(changeKey)) {
selected.delete(changeKey);
excludedKeys.add(changeKey);
} else {
selected.add(changeKey);
excludedKeys.delete(changeKey);
}
} else if (selected.has(changeKey)) {
selected.delete(changeKey);
} else {
selected.add(changeKey);
}
if (selectionMode !== "all") selectionMode = selected.size > 0 ? "some" : "none";
selectedKeys.value = selected;
}
async function apply(): Promise<ScriptApplyResult> {
assertMCPIdle();
if (!canApply.value || !planId.value) throw new Error("没有选中的脚本变更");
applying.value = true;
error.value = "";
try {
const planID = planId.value;
// In "all" mode the intent is every row matching the current filter,
// including pages never fetched. Ask the backend for that exact set so a
// filter cannot silently shrink what gets applied.
let keys: string[];
if (selectionMode === "all") {
const all = await ScriptService.SelectableChangeKeys(planID, filter.value);
keys = (all ?? []).filter((key) => !excludedKeys.has(key));
} else {
keys = [...selectedKeys.value];
}
if (keys.length === 0) throw new Error("没有选中的脚本变更");
if (!hasPreview.value) throw new Error("脚本预览已过期,请重新运行");
const result = await ScriptService.Apply(planID, keys);
runResult.value = runResult.value
? { ...runResult.value, modifiedFiles: result.modifiedCount }
: runResult.value;
resetPreview();
return result;
} catch (value: any) {
error.value = errorMessage(value);
if (error.value.includes("预览已过期")) stale.value = true;
throw value;
} finally {
applying.value = false;
}
}
async function discard(): Promise<void> {
const id = planId.value;
resetPreview();
if (id) await ScriptService.Discard(id);
}
function markStale(): void {
if (planId.value) stale.value = true;
}
function handleState(event: any): void {
const data = eventData(event);
if (!data?.runId || (runResult.value?.runId && data.runId !== runResult.value.runId)) return;
if (data.status === "running") running.value = true;
if (["completed", "failed", "cancelled"].includes(data.status)) running.value = false;
}
function handleLog(event: any): void {
const data = eventData(event);
if (!data?.runId) return;
if (runResult.value?.runId && data.runId !== runResult.value.runId) return;
if (!runResult.value) runResult.value = { runId: data.runId, status: "running", scannedFiles: 0, modifiedFiles: 0, fileSetChanges: 0, durationMs: 0, logs: [] };
logs.value.push({ level: String(data.level ?? "info"), message: String(data.message ?? "") });
}
function handleProgress(event: any): void {
const data = eventData(event);
if (!data?.runId) return;
if (runResult.value?.runId && data.runId !== runResult.value.runId) return;
progress.value = {
done: Number(data.done ?? 0),
total: Number(data.total ?? 0),
message: String(data.message ?? ""),
currentPath: String(data.currentPath ?? ""),
};
}
async function adoptMCPResult(result: ScriptRunResult) {
runOrigin.value = "mcp";
if (!result?.runId) return;
resetPreview();
stale.value = false;
runResult.value = result;
logs.value = (result.logs ?? []).filter((item): item is ScriptLog => !!item);
if (result.planId) {
try {
const page = await ScriptService.PreviewPage(result.planId, "", 0, 100);
if (page) applyPage(page, false);
} catch (value: any) { error.value = errorMessage(value); }
}
}
Events.On("mcp:script-cleared", (event: any) => { if (eventData(event)?.planId === planId.value) resetPreview(); });
Events.On("mcp:script-result", (event: any) => { void adoptMCPResult(eventData(event)); });
void MCPService.CurrentScriptResult().then(result => { if (result) void adoptMCPResult(result); }).catch(() => {});
Events.On("script:state", handleState);
Events.On("script:log", handleLog);
Events.On("script:progress", handleProgress);
Events.On("archive:advanced-search-stale", markStale);
Events.On("archive:batch-applied", markStale);
Events.On("archive:script-applied", markStale);
Events.On("archive:reloaded", markStale);
Events.On("archive:opened", () => markStale());
Events.On("archive:closed", () => {
markStale();
workspaceVisible.value = false;
// 归档关掉后独立脚本窗口会自行收起(它没有打开归档的入口),复位的
// 责任在这里:否则工具栏按钮会一直指向一个正在关闭的窗口。
workspaceDetached.value = false;
detachedDirty.value = false;
});
// 独立窗口关闭:拉回它交出的脚本内容,主窗口停在归档编辑。
Events.On("script-window:closed", () => {
void syncFromDetachedWindow();
});
// 独立窗口上报未保存状态,主窗口的退出确认需要知道它。
Events.On("script-window:dirty", (event: any) => {
const data = eventData(event);
detachedDirty.value = Boolean(data?.dirty);
});
watch(
() => archive.open,
(isOpen) => {
if (!isOpen) {
workspaceVisible.value = false;
}
},
{ immediate: true },
);
return {
workspaceVisible,
workspaceDetached,
detachedDirty,
files,
directory,
currentName,
source,
savedSource,
loadingFiles,
loadingScript,
saving,
running,
stopping,
applying,
refreshing,
error,
dirty,
stale,
compileResult,
diagnostics,
runResult,
runOrigin,
logs,
progress,
rows,
fileSetRows,
fileSetSelectKey,
planId,
nextCursor,
scannedFiles,
modifiedFiles,
filter,
matchedFiles,
filtered,
selectedKeys,
isSelectable,
selectedCount,
hasPreview,
canRun,
canApply,
showWorkspace,
hideWorkspace,
showArchiveEditor,
detachWorkspace,
focusScriptWindow,
adoptHandedBackSession,
syncFromDetachedWindow,
refreshFiles,
newScript,
loadScript,
updateSource,
clearLogs,
saveScript,
openDirectory,
compile,
run,
cancel,
loadMore,
setFilter,
clearFilter,
toggleSelected,
selectAll,
clearSelection,
apply,
discard,
};
});
+320
View File
@@ -0,0 +1,320 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { SettingsService } from "../../bindings/pvfine/services";
import { Events } from "@wailsio/runtime";
import type { AppSettings } from "../../bindings/pvfine/services/models";
import type { ThemeMode } from "../theme";
import type { ShortcutOverrides } from "../shortcuts";
export type AnnotationTagPlacement = "after-target" | "line-end" | "hidden";
export type ExplorerOpenMode = "single-click" | "double-click";
export type SettingsTab = "general" | "editor" | "npk" | "system" | "shortcuts" | "about" | "mcp";
export type { ThemeMode } from "../theme";
export const defaultMaxEditableSizeMB = 8;
export const maxEditableSizeMB = 1024;
export const defaultAutosaveIntervalSeconds = 300;
export const minAutosaveIntervalMinutes = 1;
export const maxAutosaveIntervalMinutes = 120;
const defaultSettings: AppSettings = {
cacheLimitGiB: 10,
annotationTagPlacement: "after-target",
explorerOpenMode: "single-click",
vimMode: false,
backupSourceOnSave: true,
npkDirectory: "",
theme: "dark",
autosaveEnabled: false,
autosavePath: "",
autosaveIntervalSeconds: defaultAutosaveIntervalSeconds,
shortcutOverrides: {},
maxEditableSizeMB: defaultMaxEditableSizeMB,
};
export const useSettingsStore = defineStore("settings", () => {
const visible = ref(false);
const activeTab = ref<SettingsTab>("general");
const loaded = ref(false);
const saving = ref(false);
const annotationTagPlacement = ref<AnnotationTagPlacement>("after-target");
const explorerOpenMode = ref<ExplorerOpenMode>("single-click");
const vimMode = ref(false);
const backupSourceOnSave = ref(true);
const npkDirectory = ref("");
const themeMode = ref<ThemeMode>("dark");
const autosaveEnabled = ref(false);
const autosavePath = ref("");
const autosaveIntervalSeconds = ref(defaultAutosaveIntervalSeconds);
const shortcutOverrides = ref<ShortcutOverrides>({});
const maxEditableSizeMB = ref(defaultMaxEditableSizeMB);
const cacheLimitGiB = ref(10);
/** 当前设置的全量快照,避免每次保存都手写所有字段。 */
function currentSettings(overrides: Partial<AppSettings> = {}): AppSettings {
return {
annotationTagPlacement: annotationTagPlacement.value,
explorerOpenMode: explorerOpenMode.value,
vimMode: vimMode.value,
backupSourceOnSave: backupSourceOnSave.value,
npkDirectory: npkDirectory.value,
theme: themeMode.value,
autosaveEnabled: autosaveEnabled.value,
autosavePath: autosavePath.value,
autosaveIntervalSeconds: autosaveIntervalSeconds.value,
shortcutOverrides: shortcutOverrides.value,
maxEditableSizeMB: maxEditableSizeMB.value,
cacheLimitGiB: cacheLimitGiB.value,
...overrides,
};
}
async function load() {
if (loaded.value) return;
try {
const settings = await SettingsService.GetSettings();
annotationTagPlacement.value = normalizePlacement(settings.annotationTagPlacement);
explorerOpenMode.value = normalizeExplorerOpenMode(settings.explorerOpenMode);
vimMode.value = normalizeVimMode(settings.vimMode);
backupSourceOnSave.value = normalizeBackupSourceOnSave(settings.backupSourceOnSave);
npkDirectory.value = normalizeNPKDirectory(settings.npkDirectory);
themeMode.value = normalizeThemeMode(settings.theme);
autosaveEnabled.value = normalizeAutosaveEnabled(settings.autosaveEnabled);
autosavePath.value = normalizeAutosavePath(settings.autosavePath);
autosaveIntervalSeconds.value = normalizeAutosaveInterval(settings.autosaveIntervalSeconds);
shortcutOverrides.value = normalizeShortcutOverrides(settings.shortcutOverrides);
maxEditableSizeMB.value = normalizeMaxEditableSizeMB(settings.maxEditableSizeMB);
cacheLimitGiB.value = settings.cacheLimitGiB ?? 10;
} catch (error) {
console.error("load settings failed", error);
annotationTagPlacement.value = "after-target";
explorerOpenMode.value = "single-click";
vimMode.value = false;
backupSourceOnSave.value = true;
npkDirectory.value = "";
themeMode.value = "dark";
autosaveEnabled.value = false;
autosavePath.value = "";
autosaveIntervalSeconds.value = defaultAutosaveIntervalSeconds;
shortcutOverrides.value = {};
maxEditableSizeMB.value = defaultMaxEditableSizeMB;
} finally {
loaded.value = true;
}
}
async function savePlacement(value: AnnotationTagPlacement) {
await saveSettings(currentSettings({ annotationTagPlacement: value }));
}
async function saveExplorerOpenMode(value: ExplorerOpenMode) {
await saveSettings(currentSettings({ explorerOpenMode: value }));
}
async function saveVimMode(value: boolean) {
await saveSettings(currentSettings({ vimMode: value }));
}
async function saveBackupSourceOnSave(value: boolean) {
await saveSettings(currentSettings({ backupSourceOnSave: value }));
}
async function saveThemeMode(value: ThemeMode) {
await saveSettings(currentSettings({ theme: value }));
}
async function saveAutosaveEnabled(value: boolean) {
await saveSettings(currentSettings({ autosaveEnabled: value }));
}
async function saveAutosavePath(value: string) {
await saveSettings(currentSettings({ autosavePath: value }));
}
async function saveAutosaveInterval(seconds: number) {
await saveSettings(currentSettings({ autosaveIntervalSeconds: seconds }));
}
async function saveCacheLimitGiB(value: number) {
await saveSettings(currentSettings({ cacheLimitGiB: value }));
}
async function saveMaxEditableSizeMB(value: number) {
await saveSettings(currentSettings({ maxEditableSizeMB: value }));
}
async function updateShortcutOverrides(overrides: ShortcutOverrides) {
saving.value = true;
try {
const updated = await SettingsService.UpdateShortcutOverrides(overrides);
shortcutOverrides.value = normalizeShortcutOverrides(updated.shortcutOverrides);
await Events.Emit("settings:shortcuts-changed");
} finally {
saving.value = false;
}
}
async function reloadShortcuts() {
try {
const latest = await SettingsService.GetSettings();
shortcutOverrides.value = normalizeShortcutOverrides(latest.shortcutOverrides);
} catch (error) {
console.error("reload shortcuts failed", error);
}
}
Events.On("settings:shortcuts-changed", () => {
void reloadShortcuts();
});
async function saveSettings(next: AppSettings) {
const previousPlacement = annotationTagPlacement.value;
const previousExplorerOpenMode = explorerOpenMode.value;
const previousVimMode = vimMode.value;
const previousBackupSourceOnSave = backupSourceOnSave.value;
const previousNPKDirectory = npkDirectory.value;
const previousThemeMode = themeMode.value;
const previousAutosaveEnabled = autosaveEnabled.value;
const previousAutosavePath = autosavePath.value;
const previousAutosaveInterval = autosaveIntervalSeconds.value;
const previousShortcutOverrides = shortcutOverrides.value;
const previousCacheLimitGiB = cacheLimitGiB.value;
const previousMaxEditableSizeMB = maxEditableSizeMB.value;
annotationTagPlacement.value = normalizePlacement(next.annotationTagPlacement);
explorerOpenMode.value = normalizeExplorerOpenMode(next.explorerOpenMode);
vimMode.value = normalizeVimMode(next.vimMode);
backupSourceOnSave.value = normalizeBackupSourceOnSave(next.backupSourceOnSave);
npkDirectory.value = normalizeNPKDirectory(next.npkDirectory);
themeMode.value = normalizeThemeMode(next.theme);
autosaveEnabled.value = normalizeAutosaveEnabled(next.autosaveEnabled);
autosavePath.value = normalizeAutosavePath(next.autosavePath);
autosaveIntervalSeconds.value = normalizeAutosaveInterval(next.autosaveIntervalSeconds);
shortcutOverrides.value = normalizeShortcutOverrides(next.shortcutOverrides);
maxEditableSizeMB.value = normalizeMaxEditableSizeMB(next.maxEditableSizeMB);
cacheLimitGiB.value = next.cacheLimitGiB;
saving.value = true;
try {
await SettingsService.SaveSettings({
...defaultSettings,
...next,
});
} catch (error) {
annotationTagPlacement.value = previousPlacement;
explorerOpenMode.value = previousExplorerOpenMode;
vimMode.value = previousVimMode;
backupSourceOnSave.value = previousBackupSourceOnSave;
npkDirectory.value = previousNPKDirectory;
themeMode.value = previousThemeMode;
autosaveEnabled.value = previousAutosaveEnabled;
autosavePath.value = previousAutosavePath;
autosaveIntervalSeconds.value = previousAutosaveInterval;
shortcutOverrides.value = previousShortcutOverrides;
maxEditableSizeMB.value = previousMaxEditableSizeMB;
cacheLimitGiB.value = previousCacheLimitGiB;
throw error;
} finally {
saving.value = false;
}
}
function open(tab?: SettingsTab) {
if (tab && typeof tab === "string") {
activeTab.value = tab;
} else {
activeTab.value = "general";
}
visible.value = true;
void load();
}
return {
visible,
activeTab,
loaded,
saving,
annotationTagPlacement,
explorerOpenMode,
vimMode,
backupSourceOnSave,
npkDirectory,
themeMode,
autosaveEnabled,
autosavePath,
autosaveIntervalSeconds,
shortcutOverrides,
maxEditableSizeMB,
saveMaxEditableSizeMB,
cacheLimitGiB,
saveCacheLimitGiB,
load,
savePlacement,
saveExplorerOpenMode,
saveVimMode,
saveBackupSourceOnSave,
saveThemeMode,
saveAutosaveEnabled,
saveAutosavePath,
saveAutosaveInterval,
updateShortcutOverrides,
open,
};
});
function normalizePlacement(value: string): AnnotationTagPlacement {
if (value === "line-end" || value === "hidden") return value;
return "after-target";
}
function normalizeExplorerOpenMode(value: string): ExplorerOpenMode {
return value === "double-click" ? value : "single-click";
}
function normalizeVimMode(value: boolean): boolean {
return value === true;
}
function normalizeBackupSourceOnSave(value: boolean): boolean {
return value !== false;
}
function normalizeNPKDirectory(value: string | null | undefined): string {
return typeof value === "string" ? value.trim() : "";
}
function normalizeThemeMode(value: string | null | undefined): ThemeMode {
if (value === "light" || value === "system") return value;
return "dark";
}
function normalizeAutosaveEnabled(value: boolean): boolean {
return value === true;
}
function normalizeAutosavePath(value: string | null | undefined): string {
return typeof value === "string" ? value.trim() : "";
}
function normalizeAutosaveInterval(value: number | null | undefined): number {
const seconds = Number(value ?? 0);
if (!Number.isFinite(seconds) || seconds <= 0) return defaultAutosaveIntervalSeconds;
const min = minAutosaveIntervalMinutes * 60;
const max = maxAutosaveIntervalMinutes * 60;
return Math.min(max, Math.max(min, Math.round(seconds)));
}
function normalizeShortcutOverrides(value: Record<string, string | undefined> | null | undefined): ShortcutOverrides {
if (!value || typeof value !== "object") return {};
const result: ShortcutOverrides = {};
for (const [command, binding] of Object.entries(value)) {
if (typeof binding === "string") result[command] = binding;
}
return result;
}
function normalizeMaxEditableSizeMB(value: number | null | undefined): number {
if (!Number.isInteger(value) || Number(value) < 1 || Number(value) > maxEditableSizeMB) {
return defaultMaxEditableSizeMB;
}
return Number(value);
}
+30
View File
@@ -0,0 +1,30 @@
import { defineStore } from "pinia";
import { ref } from "vue";
export type SidebarPanel = "filesets" | "bookmarks";
/** 右侧工作区侧栏状态。 */
export const useSidebarStore = defineStore("sidebar", () => {
const visible = ref(true);
const activePanel = ref<SidebarPanel>("filesets");
function show(panel?: SidebarPanel): void {
if (panel) activePanel.value = panel;
visible.value = true;
}
/** 仅切换当前面板,不改变侧栏的展开状态。 */
function setPanel(panel: SidebarPanel): void {
activePanel.value = panel;
}
function toggle(): void {
visible.value = !visible.value;
}
function close(): void {
visible.value = false;
}
return { visible, activePanel, show, setPanel, toggle, close };
});
+466
View File
@@ -0,0 +1,466 @@
import { computed, ref } from "vue";
import { defineStore } from "pinia";
import { Events } from "@wailsio/runtime";
import { VersionService } from "../../bindings/pvfine/services";
import type {
VersionChange,
VersionCommit,
VersionFileDiff,
VersionStatus,
} from "../../bindings/pvfine/services/models";
import { useExportStore } from "./export";
const emptyStatus = (): VersionStatus => ({
enabled: false,
loading: false,
repositoryPath: "",
branch: "main",
headId: "",
headMessage: "",
changedFiles: 0,
pendingChangeSets: 0,
needsSave: false,
viewCommitId: "",
error: "",
});
/** 本地逻辑文件版本库状态与版本历史。 */
export const useVersionStore = defineStore("version", () => {
const visible = ref(false);
const status = ref<VersionStatus>(emptyStatus());
const changes = ref<VersionChange[]>([]);
const history = ref<VersionCommit[]>([]);
const expandedCommitID = ref("");
const commitChanges = ref<VersionChange[]>([]);
const commitMessage = ref("");
const loading = ref(false);
const committing = ref(false);
const exporting = ref(false);
const exportingCommitID = ref("");
const error = ref("");
let refreshToken = 0;
let repoEpoch = 0;
let commitChangesGeneration = 0;
const commitChangesCache = new Map<string, VersionChange[]>();
const inFlightCommitRequests = new Map<string, Promise<VersionChange[]>>();
const enabled = computed(() => status.value.enabled);
const busy = computed(
() => loading.value || committing.value || exporting.value || status.value.loading
);
const canCommit = computed(
() =>
enabled.value &&
!status.value.loading &&
!loading.value &&
!committing.value &&
!exporting.value &&
status.value.changedFiles > 0 &&
!!commitMessage.value.trim()
);
function clearVersionData(): void {
repoEpoch++;
commitChangesGeneration++;
changes.value = [];
history.value = [];
expandedCommitID.value = "";
commitChanges.value = [];
commitChangesCache.clear();
inFlightCommitRequests.clear();
}
function errorMessage(value: any): string {
return String(value?.message ?? value ?? "版本操作失败");
}
function applyStatus(value: VersionStatus | null | undefined): void {
if (!value) return;
status.value = { ...emptyStatus(), ...value };
}
async function refreshLists(includeHistory = true): Promise<void> {
const request = ++refreshToken;
try {
const changePage = await VersionService.ListChanges(0, 500);
if (request !== refreshToken) return;
changes.value = (changePage?.changes ?? []).filter(
(item): item is VersionChange => !!item
);
if (!includeHistory) return;
const historyPage = await VersionService.History(0, 100);
if (request !== refreshToken) return;
history.value = (historyPage?.commits ?? []).filter(
(item): item is VersionCommit => !!item
);
if (!history.value.some((item) => item.id === expandedCommitID.value)) {
expandedCommitID.value = "";
commitChanges.value = [];
}
} catch (value: any) {
if (request === refreshToken) error.value = errorMessage(value);
}
}
async function refreshStatusOnly(): Promise<void> {
const request = ++refreshToken;
try {
const next = await VersionService.Status();
if (request === refreshToken) applyStatus(next);
} catch (value: any) {
if (request === refreshToken) error.value = errorMessage(value);
}
}
async function refresh(): Promise<void> {
const request = ++refreshToken;
loading.value = true;
try {
const next = await VersionService.Status();
if (request !== refreshToken) return;
applyStatus(next);
if (status.value.loading) {
clearVersionData();
commitMessage.value = "";
return;
}
if (!status.value.enabled) {
clearVersionData();
return;
}
const [changePage, historyPage] = await Promise.all([
VersionService.ListChanges(0, 500),
VersionService.History(0, 100),
]);
if (request !== refreshToken) return;
changes.value = (changePage?.changes ?? []).filter(
(item): item is VersionChange => !!item
);
history.value = (historyPage?.commits ?? []).filter(
(item): item is VersionCommit => !!item
);
if (!history.value.some((item) => item.id === expandedCommitID.value)) {
expandedCommitID.value = "";
commitChanges.value = [];
}
} catch (value: any) {
if (request === refreshToken) error.value = errorMessage(value);
} finally {
if (request === refreshToken) loading.value = false;
}
}
function open(): void {
visible.value = true;
error.value = "";
void refresh();
}
function close(): void {
if (loading.value || committing.value || exporting.value) return;
visible.value = false;
}
async function initialize(): Promise<void> {
loading.value = true;
error.value = "";
try {
await VersionService.Initialize();
await refresh();
} catch (value: any) {
error.value = errorMessage(value);
throw value;
} finally {
loading.value = false;
}
}
async function commit(): Promise<VersionCommit | null> {
if (!canCommit.value) throw new Error("请输入提交说明并确认存在工作区变更");
committing.value = true;
error.value = "";
try {
const result = await VersionService.Commit(commitMessage.value.trim());
commitMessage.value = "";
repoEpoch++;
commitChangesGeneration++;
commitChangesCache.clear();
inFlightCommitRequests.clear();
await refresh();
return result;
} catch (value: any) {
error.value = errorMessage(value);
throw value;
} finally {
committing.value = false;
}
}
async function undo(): Promise<void> {
loading.value = true;
error.value = "";
try {
const next = await VersionService.Undo();
applyStatus(next);
await refreshLists(false);
} catch (value: any) {
error.value = errorMessage(value);
throw value;
} finally {
loading.value = false;
}
}
async function discard(): Promise<void> {
loading.value = true;
error.value = "";
try {
const next = await VersionService.Discard();
applyStatus(next);
await refreshLists(false);
} catch (value: any) {
error.value = errorMessage(value);
throw value;
} finally {
loading.value = false;
}
}
async function checkout(commit: VersionCommit): Promise<void> {
loading.value = true;
error.value = "";
try {
const next = await VersionService.Checkout(commit.id);
applyStatus(next);
await refreshLists(false);
} catch (value: any) {
error.value = errorMessage(value);
throw value;
} finally {
loading.value = false;
}
}
async function remove(): Promise<void> {
loading.value = true;
error.value = "";
try {
const next = await VersionService.Remove();
applyStatus(next);
repoEpoch++;
commitChangesGeneration++;
commitChangesCache.clear();
inFlightCommitRequests.clear();
changes.value = [];
history.value = [];
expandedCommitID.value = "";
commitChanges.value = [];
commitMessage.value = "";
} catch (value: any) {
error.value = errorMessage(value);
throw value;
} finally {
loading.value = false;
}
}
async function exportCommit(commit: VersionCommit): Promise<string> {
if (exporting.value) return "";
exporting.value = true;
exportingCommitID.value = commit.id;
error.value = "";
try {
return await useExportStore().open({ source: "commit", scopes: [], commitId: commit.id }, "导出版本修改文件");
} catch (value: any) {
error.value = errorMessage(value);
throw value;
} finally {
exporting.value = false;
exportingCommitID.value = "";
}
}
async function exportWorking(): Promise<string> {
if (busy.value || !status.value.changedFiles) return "";
exporting.value = true;
exportingCommitID.value = "";
error.value = "";
try {
return await useExportStore().open({ source: "working", scopes: [], commitId: "" }, "导出当前改动");
} finally {
exporting.value = false;
}
}
async function restorePath(path: string): Promise<void> {
loading.value = true;
error.value = "";
try {
const next = await VersionService.RestorePath(path);
applyStatus(next);
await refreshLists(false);
} catch (value: any) {
error.value = errorMessage(value);
throw value;
} finally {
loading.value = false;
}
}
async function diffWorking(path: string): Promise<VersionFileDiff | null> {
error.value = "";
try {
return (await VersionService.DiffWorking(path)) ?? null;
} catch (value: any) {
error.value = errorMessage(value);
throw value;
}
}
async function diffCommit(
commitID: string,
path: string
): Promise<VersionFileDiff | null> {
error.value = "";
try {
return (await VersionService.Diff(commitID, path)) ?? null;
} catch (value: any) {
error.value = errorMessage(value);
throw value;
}
}
async function loadCommitChanges(commitID: string): Promise<VersionChange[]> {
const epoch = repoEpoch;
const generation = ++commitChangesGeneration;
const cached = commitChangesCache.get(commitID);
if (cached) {
if (epoch === repoEpoch && generation === commitChangesGeneration) {
expandedCommitID.value = commitID;
commitChanges.value = cached;
}
return cached;
}
const inFlight = inFlightCommitRequests.get(commitID);
if (inFlight) {
const items = await inFlight;
if (epoch === repoEpoch && generation === commitChangesGeneration) {
expandedCommitID.value = commitID;
commitChanges.value = items;
}
return items;
}
error.value = "";
const promise = (async () => {
try {
const page = await VersionService.ListCommitChanges(commitID, 0, 500);
const items = (page?.changes ?? []).filter(
(item): item is VersionChange => !!item
);
if (epoch === repoEpoch) {
commitChangesCache.set(commitID, items);
}
return items;
} catch (value: any) {
if (epoch === repoEpoch && generation === commitChangesGeneration) {
error.value = errorMessage(value);
}
throw value;
} finally {
inFlightCommitRequests.delete(commitID);
}
})();
inFlightCommitRequests.set(commitID, promise);
const result = await promise;
if (epoch === repoEpoch && generation === commitChangesGeneration) {
expandedCommitID.value = commitID;
commitChanges.value = result;
}
return result;
}
async function toggleCommitChanges(commit: VersionCommit): Promise<void> {
if (expandedCommitID.value === commit.id) {
expandedCommitID.value = "";
commitChanges.value = [];
return;
}
await loadCommitChanges(commit.id);
}
function eventData(event: any): any {
return event?.data ?? event;
}
Events.On("version:changed", (event: any) => {
const data = eventData(event);
if (data?.status) applyStatus(data.status);
if (data?.status && !data.status.enabled) {
clearVersionData();
commitMessage.value = "";
return;
}
if (data?.status?.loading) {
clearVersionData();
commitMessage.value = "";
return;
}
if (data?.reason === "loaded") {
void refresh();
return;
}
if (Array.isArray(data?.changes)) {
changes.value = data.changes.filter(
(item: VersionChange | null | undefined): item is VersionChange => !!item
);
} else {
void refreshLists(false);
}
});
Events.On("archive:opened", () => void refresh());
Events.On("archive:closed", () => {
refreshToken++;
status.value = emptyStatus();
clearVersionData();
error.value = "";
});
Events.On("archive:saved", () => void refreshStatusOnly());
return {
visible,
status,
changes,
history,
expandedCommitID,
commitChanges,
commitMessage,
loading,
committing,
exporting,
exportingCommitID,
error,
enabled,
busy,
canCommit,
open,
close,
refresh,
initialize,
commit,
undo,
discard,
checkout,
remove,
exportCommit,
exportWorking,
toggleCommitChanges,
restorePath,
diffWorking,
diffCommit,
loadCommitChanges,
};
});