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