071faa482c
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口 完成项目 TypeScript/Prettier 与 Rust 全量格式化 修复 Pingora expected executable 门禁的空白敏感误报 同步开发运维文档与 AGC skill pack 格式化忽略规则
731 lines
20 KiB
TypeScript
731 lines
20 KiB
TypeScript
import './style.css';
|
||
|
||
import type {
|
||
AttachmentLoader,
|
||
BoundingBoxAttachment,
|
||
ClippingAttachment,
|
||
MeshAttachment,
|
||
PathAttachment,
|
||
PointAttachment,
|
||
Sequence,
|
||
Skin,
|
||
} from '@esotericsoftware/spine-webgl';
|
||
import {
|
||
AnimationState,
|
||
AnimationStateData,
|
||
GLTexture,
|
||
ManagedWebGLRenderingContext,
|
||
Physics,
|
||
RegionAttachment,
|
||
ResizeMode,
|
||
SceneRenderer,
|
||
Skeleton,
|
||
SkeletonJson,
|
||
TextureRegion,
|
||
} from '@esotericsoftware/spine-webgl';
|
||
import type { JSZipObject } from 'jszip';
|
||
import JSZip from 'jszip';
|
||
|
||
const IMAGE_EXTENSIONS = ['png', 'webp', 'jpg', 'jpeg'];
|
||
const SKELETON_FILE_NAME = 'skeleton.json';
|
||
|
||
type Tone = 'info' | 'success' | 'error';
|
||
|
||
type JsonRecord = Record<string, unknown>;
|
||
|
||
type SkeletonCandidate = {
|
||
path: string;
|
||
baseDir: string;
|
||
json: JsonRecord;
|
||
};
|
||
|
||
type RegionExportInfo = {
|
||
paths: string[];
|
||
attachmentCount: number;
|
||
unsupportedTypes: string[];
|
||
};
|
||
|
||
type LoadedFrame = {
|
||
zipPath: string;
|
||
image: HTMLImageElement | ImageBitmap;
|
||
width: number;
|
||
height: number;
|
||
};
|
||
|
||
type ActiveSkeleton = {
|
||
skeleton: Skeleton;
|
||
state: AnimationState;
|
||
textures: GLTexture[];
|
||
frames: LoadedFrame[];
|
||
};
|
||
|
||
const zipInput = queryElement<HTMLInputElement>('#zipInput');
|
||
const dropZone = queryElement<HTMLElement>('#dropZone');
|
||
const previewCanvas = queryElement<HTMLCanvasElement>('#previewCanvas');
|
||
const emptyState = queryElement<HTMLElement>('#emptyState');
|
||
const statusBox = queryElement<HTMLElement>('#statusBox');
|
||
const skeletonSelect = queryElement<HTMLSelectElement>('#skeletonSelect');
|
||
const summaryList = queryElement<HTMLDListElement>('#summaryList');
|
||
const logList = queryElement<HTMLUListElement>('#logList');
|
||
|
||
let activeZip: JSZip | null = null;
|
||
let activeCandidates: SkeletonCandidate[] = [];
|
||
let activePreview: SpinePreview | null = null;
|
||
|
||
function queryElement<T extends Element>(selector: string) {
|
||
const element = document.querySelector<T>(selector);
|
||
if (!element) {
|
||
throw new Error(`Missing element: ${selector}`);
|
||
}
|
||
return element;
|
||
}
|
||
|
||
function isRecord(value: unknown): value is JsonRecord {
|
||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||
}
|
||
|
||
function normalizeZipPath(path: string) {
|
||
const parts: string[] = [];
|
||
for (const part of path.replace(/\\/gu, '/').split('/')) {
|
||
if (!part || part === '.') {
|
||
continue;
|
||
}
|
||
if (part === '..') {
|
||
parts.pop();
|
||
continue;
|
||
}
|
||
parts.push(part);
|
||
}
|
||
return parts.join('/');
|
||
}
|
||
|
||
function joinZipPath(...parts: string[]) {
|
||
return normalizeZipPath(parts.join('/'));
|
||
}
|
||
|
||
function getBaseDir(path: string) {
|
||
const normalized = normalizeZipPath(path);
|
||
const slashIndex = normalized.lastIndexOf('/');
|
||
return slashIndex >= 0 ? normalized.slice(0, slashIndex) : '';
|
||
}
|
||
|
||
function stripExtension(path: string) {
|
||
return path.replace(/\.[^.\\/]+$/u, '');
|
||
}
|
||
|
||
function hasImageExtension(path: string) {
|
||
const extension = path.split('.').pop()?.toLowerCase();
|
||
return extension ? IMAGE_EXTENSIONS.includes(extension) : false;
|
||
}
|
||
|
||
function toFrameKey(path: string) {
|
||
return stripExtension(normalizeZipPath(path)).toLowerCase();
|
||
}
|
||
|
||
function setStatus(tone: Tone, message: string) {
|
||
statusBox.dataset.tone = tone;
|
||
statusBox.textContent = message;
|
||
}
|
||
|
||
function clearLogs() {
|
||
logList.replaceChildren();
|
||
}
|
||
|
||
function addLog(message: string, tone: Tone = 'info') {
|
||
const item = document.createElement('li');
|
||
item.dataset.tone = tone;
|
||
item.textContent = message;
|
||
logList.prepend(item);
|
||
}
|
||
|
||
function renderSummary(rows: Array<[string, string]>) {
|
||
const fragment = document.createDocumentFragment();
|
||
for (const [label, value] of rows) {
|
||
const term = document.createElement('dt');
|
||
term.textContent = label;
|
||
const description = document.createElement('dd');
|
||
description.textContent = value;
|
||
fragment.append(term, description);
|
||
}
|
||
summaryList.replaceChildren(fragment);
|
||
}
|
||
|
||
function getSkeletonImagesPath(json: JsonRecord) {
|
||
const skeleton = isRecord(json.skeleton) ? json.skeleton : null;
|
||
return typeof skeleton?.images === 'string' ? skeleton.images : './';
|
||
}
|
||
|
||
function collectRegionExportInfo(json: JsonRecord): RegionExportInfo {
|
||
const paths = new Set<string>();
|
||
const unsupportedTypes = new Set<string>();
|
||
let attachmentCount = 0;
|
||
const skins = Array.isArray(json.skins) ? json.skins : [];
|
||
|
||
for (const skin of skins) {
|
||
if (!isRecord(skin) || !isRecord(skin.attachments)) {
|
||
continue;
|
||
}
|
||
for (const slotAttachments of Object.values(skin.attachments)) {
|
||
if (!isRecord(slotAttachments)) {
|
||
continue;
|
||
}
|
||
for (const [attachmentName, attachmentValue] of Object.entries(
|
||
slotAttachments,
|
||
)) {
|
||
if (!isRecord(attachmentValue)) {
|
||
continue;
|
||
}
|
||
attachmentCount += 1;
|
||
const attachmentType =
|
||
typeof attachmentValue.type === 'string'
|
||
? attachmentValue.type
|
||
: 'region';
|
||
if (attachmentType !== 'region') {
|
||
unsupportedTypes.add(attachmentType);
|
||
continue;
|
||
}
|
||
const path =
|
||
typeof attachmentValue.path === 'string'
|
||
? attachmentValue.path
|
||
: attachmentName;
|
||
paths.add(path);
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
paths: [...paths],
|
||
attachmentCount,
|
||
unsupportedTypes: [...unsupportedTypes],
|
||
};
|
||
}
|
||
|
||
function collectAnimationNames(json: JsonRecord) {
|
||
return isRecord(json.animations) ? Object.keys(json.animations) : [];
|
||
}
|
||
|
||
function resolveImageEntry({
|
||
zip,
|
||
imageRoot,
|
||
attachmentPath,
|
||
}: {
|
||
zip: JSZip;
|
||
imageRoot: string;
|
||
attachmentPath: string;
|
||
}) {
|
||
const normalizedRoot = normalizeZipPath(imageRoot);
|
||
const normalizedAttachment = normalizeZipPath(attachmentPath);
|
||
const directCandidates = hasImageExtension(normalizedAttachment)
|
||
? [joinZipPath(normalizedRoot, normalizedAttachment)]
|
||
: IMAGE_EXTENSIONS.map((extension) =>
|
||
joinZipPath(normalizedRoot, `${normalizedAttachment}.${extension}`),
|
||
);
|
||
const filesByLowerPath = new Map<string, JSZipObject>();
|
||
|
||
zip.forEach((path, entry) => {
|
||
if (!entry.dir) {
|
||
filesByLowerPath.set(normalizeZipPath(path).toLowerCase(), entry);
|
||
}
|
||
});
|
||
|
||
for (const candidate of directCandidates) {
|
||
const entry = filesByLowerPath.get(candidate.toLowerCase());
|
||
if (entry) {
|
||
return entry;
|
||
}
|
||
}
|
||
|
||
const rootPrefix = normalizedRoot ? `${normalizedRoot}/` : '';
|
||
const expectedKey = toFrameKey(normalizedAttachment);
|
||
let fallback: JSZipObject | null = null;
|
||
zip.forEach((path, entry) => {
|
||
if (fallback || entry.dir) {
|
||
return;
|
||
}
|
||
const normalizedPath = normalizeZipPath(path);
|
||
if (!normalizedPath.startsWith(rootPrefix)) {
|
||
return;
|
||
}
|
||
const relativePath = normalizedPath.slice(rootPrefix.length);
|
||
if (toFrameKey(relativePath) === expectedKey) {
|
||
fallback = entry;
|
||
}
|
||
});
|
||
return fallback;
|
||
}
|
||
|
||
async function loadImageFromZipEntry(entry: JSZipObject): Promise<LoadedFrame> {
|
||
const blob = await entry.async('blob');
|
||
if (typeof createImageBitmap === 'function') {
|
||
try {
|
||
const bitmap = await createImageBitmap(blob);
|
||
return {
|
||
zipPath: normalizeZipPath(entry.name),
|
||
image: bitmap,
|
||
width: bitmap.width,
|
||
height: bitmap.height,
|
||
};
|
||
} catch {
|
||
// Some browsers reject SVG or unusual PNG chunks here; HTMLImageElement is the safer fallback.
|
||
}
|
||
}
|
||
|
||
const image = new Image();
|
||
const objectUrl = URL.createObjectURL(blob);
|
||
try {
|
||
await new Promise<void>((resolve, reject) => {
|
||
image.onload = () => resolve();
|
||
image.onerror = () => reject(new Error(`图片解码失败:${entry.name}`));
|
||
image.src = objectUrl;
|
||
});
|
||
} finally {
|
||
URL.revokeObjectURL(objectUrl);
|
||
}
|
||
|
||
return {
|
||
zipPath: normalizeZipPath(entry.name),
|
||
image,
|
||
width: image.naturalWidth || image.width,
|
||
height: image.naturalHeight || image.height,
|
||
};
|
||
}
|
||
|
||
async function loadFramesForCandidate(
|
||
zip: JSZip,
|
||
candidate: SkeletonCandidate,
|
||
) {
|
||
const exportInfo = collectRegionExportInfo(candidate.json);
|
||
const imageRoot = joinZipPath(
|
||
candidate.baseDir,
|
||
getSkeletonImagesPath(candidate.json),
|
||
);
|
||
const frames = new Map<string, LoadedFrame>();
|
||
const missingFrames: string[] = [];
|
||
|
||
for (const attachmentPath of exportInfo.paths) {
|
||
const frameKey = toFrameKey(attachmentPath);
|
||
if (frames.has(frameKey)) {
|
||
continue;
|
||
}
|
||
const entry = resolveImageEntry({ zip, imageRoot, attachmentPath });
|
||
if (!entry) {
|
||
missingFrames.push(attachmentPath);
|
||
continue;
|
||
}
|
||
frames.set(frameKey, await loadImageFromZipEntry(entry));
|
||
}
|
||
|
||
return { exportInfo, imageRoot, frames, missingFrames };
|
||
}
|
||
|
||
async function discoverSkeletonCandidates(zip: JSZip) {
|
||
const candidates: SkeletonCandidate[] = [];
|
||
const invalidJsonPaths: string[] = [];
|
||
const entries: JSZipObject[] = [];
|
||
|
||
zip.forEach((path, entry) => {
|
||
if (entry.dir) {
|
||
return;
|
||
}
|
||
const normalizedPath = normalizeZipPath(path);
|
||
if (normalizedPath.split('/').pop() === SKELETON_FILE_NAME) {
|
||
entries.push(entry);
|
||
}
|
||
});
|
||
|
||
for (const entry of entries) {
|
||
try {
|
||
const text = await entry.async('text');
|
||
const json = JSON.parse(text) as unknown;
|
||
if (!isRecord(json)) {
|
||
invalidJsonPaths.push(entry.name);
|
||
continue;
|
||
}
|
||
candidates.push({
|
||
path: normalizeZipPath(entry.name),
|
||
baseDir: getBaseDir(entry.name),
|
||
json,
|
||
});
|
||
} catch {
|
||
invalidJsonPaths.push(entry.name);
|
||
}
|
||
}
|
||
|
||
return { candidates, invalidJsonPaths };
|
||
}
|
||
|
||
function populateSkeletonSelect(candidates: SkeletonCandidate[]) {
|
||
const fragment = document.createDocumentFragment();
|
||
for (const [index, candidate] of candidates.entries()) {
|
||
const option = document.createElement('option');
|
||
option.value = String(index);
|
||
option.textContent = candidate.path;
|
||
fragment.append(option);
|
||
}
|
||
skeletonSelect.replaceChildren(fragment);
|
||
skeletonSelect.disabled = candidates.length <= 1;
|
||
}
|
||
|
||
function closeLoadedFrames(frames: LoadedFrame[]) {
|
||
for (const frame of frames) {
|
||
if ('close' in frame.image && typeof frame.image.close === 'function') {
|
||
frame.image.close();
|
||
}
|
||
}
|
||
}
|
||
|
||
class LooseFrameAttachmentLoader implements AttachmentLoader {
|
||
readonly textures: GLTexture[] = [];
|
||
|
||
constructor(
|
||
private readonly context: ManagedWebGLRenderingContext,
|
||
private readonly frames: Map<string, LoadedFrame>,
|
||
) {}
|
||
|
||
newRegionAttachment(
|
||
skin: Skin,
|
||
placeholder: string,
|
||
name: string,
|
||
path: string,
|
||
sequence: Sequence,
|
||
) {
|
||
for (let index = 0; index < sequence.regions.length; index += 1) {
|
||
const sequencePath = sequence.hasPathSuffix()
|
||
? sequence.getPath(path, index)
|
||
: path;
|
||
sequence.regions[index] = this.createRegion(sequencePath);
|
||
}
|
||
return new RegionAttachment(name, sequence);
|
||
}
|
||
|
||
newMeshAttachment(
|
||
skin: Skin,
|
||
placeholder: string,
|
||
name: string,
|
||
_path: string,
|
||
_sequence: Sequence,
|
||
): MeshAttachment {
|
||
throw new Error(`暂不支持 mesh attachment:${name}`);
|
||
}
|
||
|
||
newBoundingBoxAttachment(
|
||
skin: Skin,
|
||
placeholder: string,
|
||
name: string,
|
||
): BoundingBoxAttachment {
|
||
throw new Error(`暂不支持 boundingbox attachment:${name}`);
|
||
}
|
||
|
||
newPathAttachment(
|
||
skin: Skin,
|
||
placeholder: string,
|
||
name: string,
|
||
): PathAttachment {
|
||
throw new Error(`暂不支持 path attachment:${name}`);
|
||
}
|
||
|
||
newPointAttachment(
|
||
skin: Skin,
|
||
placeholder: string,
|
||
name: string,
|
||
): PointAttachment {
|
||
throw new Error(`暂不支持 point attachment:${name}`);
|
||
}
|
||
|
||
newClippingAttachment(
|
||
skin: Skin,
|
||
placeholder: string,
|
||
name: string,
|
||
): ClippingAttachment {
|
||
throw new Error(`暂不支持 clipping attachment:${name}`);
|
||
}
|
||
|
||
private createRegion(path: string) {
|
||
const frame = this.frames.get(toFrameKey(path));
|
||
if (!frame) {
|
||
throw new Error(`找不到贴图帧:${path}`);
|
||
}
|
||
|
||
const texture = new GLTexture(this.context, frame.image, false);
|
||
this.textures.push(texture);
|
||
|
||
const region = new TextureRegion();
|
||
region.texture = texture;
|
||
region.u = 0;
|
||
region.v = 0;
|
||
region.u2 = 1;
|
||
region.v2 = 1;
|
||
region.width = frame.width;
|
||
region.height = frame.height;
|
||
region.originalWidth = frame.width;
|
||
region.originalHeight = frame.height;
|
||
region.offsetX = 0;
|
||
region.offsetY = 0;
|
||
region.degrees = 0;
|
||
return region;
|
||
}
|
||
}
|
||
|
||
class SpinePreview {
|
||
private readonly context: ManagedWebGLRenderingContext;
|
||
private readonly renderer: SceneRenderer;
|
||
private activeSkeleton: ActiveSkeleton | null = null;
|
||
private frameHandle: number | null = null;
|
||
private lastFrameMs = performance.now();
|
||
|
||
constructor(private readonly canvas: HTMLCanvasElement) {
|
||
this.context = new ManagedWebGLRenderingContext(canvas, {
|
||
alpha: true,
|
||
premultipliedAlpha: false,
|
||
});
|
||
this.renderer = new SceneRenderer(canvas, this.context);
|
||
this.startLoop();
|
||
}
|
||
|
||
load({
|
||
json,
|
||
frames,
|
||
}: {
|
||
json: JsonRecord;
|
||
frames: Map<string, LoadedFrame>;
|
||
}) {
|
||
this.clearActiveSkeleton();
|
||
const loader = new LooseFrameAttachmentLoader(this.context, frames);
|
||
const skeletonData = new SkeletonJson(loader).readSkeletonData(json);
|
||
const skeleton = new Skeleton(skeletonData);
|
||
if (skeletonData.defaultSkin) {
|
||
skeleton.setSkin(skeletonData.defaultSkin);
|
||
}
|
||
skeleton.setupPose();
|
||
|
||
const state = new AnimationState(new AnimationStateData(skeletonData));
|
||
const firstAnimation = skeletonData.animations[0];
|
||
if (firstAnimation) {
|
||
state.setAnimation(0, firstAnimation, true);
|
||
state.apply(skeleton);
|
||
}
|
||
skeleton.updateWorldTransform(Physics.none);
|
||
|
||
this.activeSkeleton = {
|
||
skeleton,
|
||
state,
|
||
textures: loader.textures,
|
||
frames: [...frames.values()],
|
||
};
|
||
this.fitCamera();
|
||
}
|
||
|
||
dispose() {
|
||
this.clearActiveSkeleton();
|
||
if (this.frameHandle !== null) {
|
||
cancelAnimationFrame(this.frameHandle);
|
||
this.frameHandle = null;
|
||
}
|
||
this.renderer.dispose();
|
||
this.context.dispose();
|
||
}
|
||
|
||
private clearActiveSkeleton() {
|
||
if (!this.activeSkeleton) {
|
||
return;
|
||
}
|
||
for (const texture of this.activeSkeleton.textures) {
|
||
texture.dispose();
|
||
}
|
||
closeLoadedFrames(this.activeSkeleton.frames);
|
||
this.activeSkeleton = null;
|
||
}
|
||
|
||
private startLoop() {
|
||
const tick = (timestamp: number) => {
|
||
const delta = Math.min((timestamp - this.lastFrameMs) / 1000, 0.064);
|
||
this.lastFrameMs = timestamp;
|
||
this.render(delta);
|
||
this.frameHandle = requestAnimationFrame(tick);
|
||
};
|
||
this.frameHandle = requestAnimationFrame(tick);
|
||
}
|
||
|
||
private render(delta: number) {
|
||
const gl = this.context.gl;
|
||
this.fitCamera();
|
||
gl.clearColor(0.04, 0.05, 0.07, 0);
|
||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||
|
||
const activeSkeleton = this.activeSkeleton;
|
||
if (!activeSkeleton) {
|
||
return;
|
||
}
|
||
|
||
activeSkeleton.state.update(delta);
|
||
activeSkeleton.state.apply(activeSkeleton.skeleton);
|
||
activeSkeleton.skeleton.update(delta);
|
||
activeSkeleton.skeleton.updateWorldTransform(Physics.none);
|
||
|
||
this.renderer.begin();
|
||
this.renderer.drawSkeleton(activeSkeleton.skeleton);
|
||
this.renderer.end();
|
||
}
|
||
|
||
private fitCamera() {
|
||
const activeSkeleton = this.activeSkeleton;
|
||
const cssWidth = Math.max(1, this.canvas.clientWidth || this.canvas.width);
|
||
const cssHeight = Math.max(
|
||
1,
|
||
this.canvas.clientHeight || this.canvas.height,
|
||
);
|
||
|
||
if (!activeSkeleton) {
|
||
this.renderer.camera.position.set(0, 0, 0);
|
||
this.renderer.camera.setViewport(cssWidth, cssHeight);
|
||
this.renderer.resize(ResizeMode.Stretch);
|
||
return;
|
||
}
|
||
|
||
const bounds = activeSkeleton.skeleton.getBoundsRect();
|
||
const fallbackWidth = activeSkeleton.skeleton.data.width || cssWidth;
|
||
const fallbackHeight = activeSkeleton.skeleton.data.height || cssHeight;
|
||
const boundsWidth = bounds.width > 0 ? bounds.width : fallbackWidth;
|
||
const boundsHeight = bounds.height > 0 ? bounds.height : fallbackHeight;
|
||
const centerX = bounds.x + boundsWidth / 2;
|
||
const centerY = bounds.y + boundsHeight / 2;
|
||
let viewportWidth = Math.max(1, boundsWidth * 1.18);
|
||
let viewportHeight = Math.max(1, boundsHeight * 1.18);
|
||
const canvasRatio = cssWidth / cssHeight;
|
||
const viewportRatio = viewportWidth / viewportHeight;
|
||
|
||
if (viewportRatio > canvasRatio) {
|
||
viewportHeight = viewportWidth / canvasRatio;
|
||
} else {
|
||
viewportWidth = viewportHeight * canvasRatio;
|
||
}
|
||
|
||
this.renderer.camera.position.set(centerX, centerY, 0);
|
||
this.renderer.camera.setViewport(viewportWidth, viewportHeight);
|
||
this.renderer.resize(ResizeMode.Stretch);
|
||
}
|
||
}
|
||
|
||
async function loadCandidate(index: number) {
|
||
if (!activeZip) {
|
||
return;
|
||
}
|
||
const candidate = activeCandidates[index];
|
||
if (!candidate) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setStatus('info', '正在加载 Spine JSON');
|
||
const { exportInfo, imageRoot, frames, missingFrames } =
|
||
await loadFramesForCandidate(activeZip, candidate);
|
||
const animationNames = collectAnimationNames(candidate.json);
|
||
|
||
if (missingFrames.length) {
|
||
throw new Error(`缺少贴图帧:${missingFrames.join(', ')}`);
|
||
}
|
||
if (!exportInfo.paths.length) {
|
||
throw new Error('未找到 region attachment');
|
||
}
|
||
if (exportInfo.unsupportedTypes.length) {
|
||
addLog(
|
||
`发现暂不支持的 attachment:${exportInfo.unsupportedTypes.join(', ')}`,
|
||
'error',
|
||
);
|
||
}
|
||
|
||
if (!activePreview) {
|
||
activePreview = new SpinePreview(previewCanvas);
|
||
}
|
||
activePreview.load({ json: candidate.json, frames });
|
||
emptyState.hidden = true;
|
||
|
||
renderSummary([
|
||
['JSON', candidate.path],
|
||
['贴图目录', imageRoot || '.'],
|
||
['动画', animationNames.join(', ') || '-'],
|
||
['Region', String(exportInfo.attachmentCount)],
|
||
['帧数', String(frames.size)],
|
||
]);
|
||
setStatus('success', '导出包可被 Spine JS runtime 解析并渲染');
|
||
addLog(`已渲染:${candidate.path}`, 'success');
|
||
} catch (error) {
|
||
setStatus('error', error instanceof Error ? error.message : '加载失败');
|
||
addLog(error instanceof Error ? error.message : '加载失败', 'error');
|
||
emptyState.hidden = false;
|
||
}
|
||
}
|
||
|
||
async function loadZipFile(file: File) {
|
||
clearLogs();
|
||
renderSummary([]);
|
||
setStatus('info', '正在读取 ZIP');
|
||
emptyState.hidden = false;
|
||
emptyState.textContent = '正在读取 ZIP';
|
||
|
||
try {
|
||
const zip = await JSZip.loadAsync(file);
|
||
const { candidates, invalidJsonPaths } =
|
||
await discoverSkeletonCandidates(zip);
|
||
activeZip = zip;
|
||
activeCandidates = candidates;
|
||
populateSkeletonSelect(candidates);
|
||
|
||
for (const invalidPath of invalidJsonPaths) {
|
||
addLog(`JSON 解析失败:${invalidPath}`, 'error');
|
||
}
|
||
|
||
if (!candidates.length) {
|
||
throw new Error('ZIP 中没有 skeleton.json');
|
||
}
|
||
|
||
setStatus('info', `找到 ${candidates.length} 个 skeleton.json`);
|
||
addLog(`已读取:${file.name}`);
|
||
skeletonSelect.value = '0';
|
||
await loadCandidate(0);
|
||
} catch (error) {
|
||
activeZip = null;
|
||
activeCandidates = [];
|
||
populateSkeletonSelect([]);
|
||
setStatus('error', error instanceof Error ? error.message : 'ZIP 读取失败');
|
||
emptyState.textContent = '拖入 ZIP 开始验证';
|
||
addLog(error instanceof Error ? error.message : 'ZIP 读取失败', 'error');
|
||
}
|
||
}
|
||
|
||
zipInput.addEventListener('change', () => {
|
||
const file = zipInput.files?.[0];
|
||
if (file) {
|
||
void loadZipFile(file);
|
||
}
|
||
});
|
||
|
||
skeletonSelect.addEventListener('change', () => {
|
||
void loadCandidate(Number(skeletonSelect.value));
|
||
});
|
||
|
||
for (const eventName of ['dragenter', 'dragover']) {
|
||
dropZone.addEventListener(eventName, (event) => {
|
||
event.preventDefault();
|
||
dropZone.dataset.dragging = 'true';
|
||
});
|
||
}
|
||
|
||
for (const eventName of ['dragleave', 'drop']) {
|
||
dropZone.addEventListener(eventName, (event) => {
|
||
event.preventDefault();
|
||
dropZone.dataset.dragging = 'false';
|
||
});
|
||
}
|
||
|
||
dropZone.addEventListener('drop', (event) => {
|
||
const file = event.dataTransfer?.files[0];
|
||
if (file) {
|
||
void loadZipFile(file);
|
||
}
|
||
});
|
||
|
||
window.addEventListener('beforeunload', () => {
|
||
activePreview?.dispose();
|
||
});
|