fix: 修复Pio看板娘交互与贴图切换

This commit is contained in:
sunlei 2026-07-06 17:28:39 +08:00
parent 0cdc68a79f
commit e36fada219
6 changed files with 222 additions and 4 deletions

View File

@ -42,7 +42,7 @@ pnpm exec playwright test e2e/argon-parity/baseline.spec.ts --project=chromium
- 左栏 overview sticky/relative 切换用 no-headroom 回归用例固定,解除 fixed 后不得重放卡片入场动画或产生缩放闪烁。
- 左栏文章目录/站点概览切换必须保留 Bootstrap tab fade 节奏,采用 active/show 分帧保持 Argon 手感;回归用例需要断言切换中 opacity 处于 0 到 1 之间,而不是只检查最终显隐。
- Argon motion、滚动几何、延迟、RAF 调度、DOM id/selector、hash anchor 和跨组件 ref 必须从 `src/factories/blogAnimationFactory.ts``src/factories/blogDomFactory.ts` 取值;组件和 Hook 不再散写行为层 id、裸 `requestAnimationFrame`、裸 `setTimeout` 或重复 timing。
- Live2D 使用旧 WordPress 站点同款 Cubism2 MOC 链路,静态运行时固定在 `public/live2d/wordpress-moc/live2d.min.js``BlogLive2D` 只在桌面端创建页面级单例 canvas写入 `window.LAppDefine.MODELS = [['/api/blog/live2d/pio/moc/index.json']]` 并调用一次 `window.InitLive2D()`hash 路由切换必须复用该实例。线上 MinIO Pio 公共根只允许 `catalog.json`、`moc/`、`moc3/` 三个入口,`moc/` 是当前 WordPress 同款源,`moc3/` 只保留重建模型资产;前端不得再接入自研 `KtPioLive2D`、manifest runtime、canvas transform fallback、pointer parallax fallback、`live2d-widgets` 或随机 CDN 模型。
- Live2D 使用旧 WordPress 站点同款 Cubism2 MOC 链路,静态运行时固定在 `public/live2d/wordpress-moc/live2d.min.js``BlogLive2D` 只在桌面端创建页面级单例 canvas写入 `window.LAppDefine.MODELS = [['/api/blog/live2d/pio/moc/index.json']]` 并调用一次 `window.InitLive2D()`hash 路由切换必须复用该实例。鼠标视线追踪必须通过页面级 `mousemove` 写入旧 runtime 的 `dragMgr` 目标点,不限制在模型 canvas 内,也不能伪造 tap 事件;服装按钮必须先读取模型 `textures` 数量,单贴图时只提示不调用旧 runtime 的破坏式切换。线上 MinIO Pio 公共根只允许 `catalog.json`、`moc/`、`moc3/` 三个入口,`moc/` 是当前 WordPress 同款源,`index.json`、`manifest.json`、`textures/manifest.json` 的贴图计数必须一致;`moc3/` 只保留重建模型资产;前端不得再接入自研 `KtPioLive2D`、manifest runtime、canvas transform fallback、`live2d-widgets` 或随机 CDN 模型。
- Modal 不强求一比一复刻线上 Argon 动画,打开/关闭 motion 与 `centered` 居中定位交给 antdv-next外层按 `packages/@core/ui-kit/popup-ui/src/modal/modal.vue` 保留 Header/Content/Footer 三段能力、`p-0`、`max-height`、纵向 flex、Content `min-h-40` 滚动和 `px-5 py-4`/`p-3`/`p-2` 间距,但无 footer slot 时不渲染 footer不搬 draggable/fullscreen/loading/footer 按钮等复杂能力,自有颜色只守 Blog 主题色和暗色可读性。
- Admin 文章预览通过 `VITE_KT_BLOG_WEB_BASE_URL` 打开公开 Blog Web 路由,本地默认 `http://127.0.0.1:5173/#/post/<slug>?adminPreview=1&articleId=<id>`

View File

@ -73,6 +73,26 @@ async function verifyDesktopWidgetParity({ page }: { page: Page }) {
await expect(page.locator('.waifu-tool')).toHaveCSS('opacity', '0');
await widget.hover();
await expect(page.locator('.waifu-tool')).toHaveCSS('opacity', '1');
await page.evaluate(() => {
const state = window as unknown as {
__ktDragPoints: Array<[number, number]>;
dragMgr?: { setPoint: (x: number, y: number) => void };
};
state.__ktDragPoints = [];
const originalSetPoint = state.dragMgr?.setPoint?.bind(state.dragMgr);
if (state.dragMgr && originalSetPoint) {
state.dragMgr.setPoint = (x: number, y: number) => {
state.__ktDragPoints.push([x, y]);
originalSetPoint(x, y);
};
}
});
await page.mouse.move(1180, 120);
await expect
.poll(() =>
page.evaluate(() => (window as unknown as { __ktDragPoints?: Array<[number, number]> }).__ktDragPoints?.length ?? 0),
)
.toBeGreaterThan(0);
await page.evaluate(() => {
const state = window as unknown as { __ktTextureClicks: number };

View File

@ -15,6 +15,9 @@ describe('mountWordPressLive2DRuntime', () => {
delete window.InitLive2D;
delete window.LAppDefine;
delete window.LAppLive2DManager;
delete window.dragMgr;
delete window.transformViewX;
delete window.transformViewY;
vi.restoreAllMocks();
});
@ -121,6 +124,46 @@ describe('mountWordPressLive2DRuntime', () => {
expect(tapEvent?.call(manager, 0.9, 0.9)).toBe(false);
expect(originalTapEvent).toHaveBeenCalledWith(0.9, 0.9);
});
it('tracks page-level pointer movement instead of limiting look-at to the widget canvas', async () => {
const setPoint = vi.fn();
const originalTapEvent = vi.fn();
const originalAppendChild = document.body.appendChild.bind(document.body);
vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
const element = originalAppendChild(node);
if (element instanceof HTMLScriptElement) {
window.InitLive2D = vi.fn(() => {
window.dragMgr = { setPoint };
window.transformViewX = vi.fn((deviceX: number) => deviceX / 100);
window.transformViewY = vi.fn((deviceY: number) => deviceY / 100);
});
window.LAppLive2DManager = function LAppLive2DManager() {};
window.LAppLive2DManager.prototype.tapEvent = originalTapEvent;
element.onload?.(new Event('load'));
}
return element;
});
await mountWordPressLive2DRuntime();
const canvas = document.querySelector<HTMLCanvasElement>('canvas#live2d');
expect(canvas).not.toBeNull();
vi.spyOn(canvas!, 'getBoundingClientRect').mockReturnValue({
bottom: 768,
height: 250,
left: 0,
right: 280,
top: 518,
width: 280,
x: 0,
y: 518,
toJSON: () => ({}),
});
window.dispatchEvent(new MouseEvent('mousemove', { clientX: 960, clientY: 260 }));
expect(setPoint).toHaveBeenCalledWith(9.6, -2.58);
expect(originalTapEvent).not.toHaveBeenCalled();
});
});
describe('BlogLive2D', () => {
@ -146,7 +189,10 @@ describe('BlogLive2D', () => {
it('mounts the WordPress Cubism2 MOC runtime in a WordPress-style widget shell', async () => {
vi.stubGlobal('innerWidth', 1280);
const fetchMock = vi.fn();
const fetchMock = vi.fn().mockResolvedValue({
json: () => Promise.resolve({ textures: ['textures/default-costume.png', 'textures/witch-costume.png'] }),
ok: true,
});
vi.stubGlobal('fetch', fetchMock);
const requestFrame = vi.spyOn(window, 'requestAnimationFrame');
const initLive2D = vi.fn();
@ -190,7 +236,7 @@ describe('BlogLive2D', () => {
expect(window.LAppDefine?.canvasSize).toEqual({ width: 280, height: 250 });
expect(window.LAppDefine?.IS_START_TEXURE_CHANGE).toBe(true);
expect(window.LAppDefine?.TEXURE_BUTTON_ID).toBe('live2d-texture-button');
expect(fetchMock).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledWith(DEFAULT_WORDPRESS_LIVE2D_MODEL_ENTRY, { credentials: 'same-origin' });
expect(initLive2D).toHaveBeenCalledTimes(1);
expect(canvas?.style.transform).toBe('');
expect(requestFrame).not.toHaveBeenCalled();
@ -198,6 +244,39 @@ describe('BlogLive2D', () => {
wrapper.unmount();
});
it('does not trigger the destructive legacy texture reload when only one texture is available', async () => {
vi.stubGlobal('innerWidth', 1280);
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
json: () => Promise.resolve({ textures: ['textures/default-costume.png'] }),
ok: true,
}),
);
const originalAppendChild = document.body.appendChild.bind(document.body);
vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
const element = originalAppendChild(node);
if (element instanceof HTMLScriptElement) {
window.InitLive2D = vi.fn();
window.LAppLive2DManager = function LAppLive2DManager() {};
window.LAppLive2DManager.prototype.tapEvent = vi.fn();
element.onload?.(new Event('load'));
}
return element;
});
mount(BlogLive2D);
await flushPromises();
const hiddenTextureButton = document.getElementById('live2d-texture-button') as HTMLButtonElement;
const hiddenClick = vi.fn();
hiddenTextureButton.addEventListener('click', hiddenClick);
document.querySelector<HTMLElement>('.waifu-tool .fui-eye')?.click();
expect(hiddenClick).not.toHaveBeenCalled();
expect(document.querySelector('.waifu-tips')?.textContent).toContain('只有一套');
});
it('reuses the page-level WordPress runtime after route component remounts', async () => {
vi.stubGlobal('innerWidth', 1280);
const initLive2D = vi.fn();

View File

@ -19,6 +19,7 @@ let runtimeScriptPromise: Promise<void> | null = null;
let runtimeMountPromise: Promise<WordPressLive2DRuntimeHandle> | null = null;
let mountedRuntimeCanvas: HTMLCanvasElement | null = null;
let mountedWidgetController: WordPressWidgetControllerHandle | null = null;
let mountedPagePointerBridge: WordPressPagePointerBridgeHandle | null = null;
export interface WordPressLive2DSettings {
AUDIO_ID: string;
@ -72,12 +73,26 @@ export interface WordPressLive2DRuntimeHandle {
declare global {
interface Window {
dragMgr?: WordPressRuntimeTargetPoint;
InitLive2D?: () => void;
LAppDefine?: WordPressLive2DSettings;
LAppLive2DManager?: WordPressLive2DManagerConstructor;
transformViewX?: (deviceX: number) => number;
transformViewY?: (deviceY: number) => number;
}
}
interface WordPressRuntimeTargetPoint {
setPoint: (x: number, y: number) => void;
}
interface WordPressPagePointerBridgeHandle {
/**
* Removes document-level pointer tracking installed for the legacy WordPress runtime.
*/
destroy(): void;
}
interface WordPressLive2DManagerConstructor {
prototype: WordPressLive2DManagerPrototype;
}
@ -168,6 +183,8 @@ export async function mountWordPressLive2DRuntime(
mountedRuntimeCanvas = null;
mountedWidgetController?.destroy();
mountedWidgetController = null;
mountedPagePointerBridge?.destroy();
mountedPagePointerBridge = null;
}
if (mountedRuntimeCanvas?.isConnected) {
@ -245,11 +262,99 @@ async function initializeWordPressRuntime(
window.InitLive2D();
mountedRuntimeCanvas = shell.canvas;
mountedPagePointerBridge?.destroy();
mountedPagePointerBridge = mountWordPressPagePointerBridge(shell.canvas);
const textureCount = await resolveWordPressModelTextureCount(settings);
mountedWidgetController?.destroy();
mountedWidgetController = mountWordPressWidgetController(shell);
mountedWidgetController = mountWordPressWidgetController(shell, { textureCount });
return createWordPressRuntimeHandle();
}
/**
* Mirrors the WordPress plugin's page-wide mouse tracking without emitting synthetic tap events.
* @param canvas Legacy runtime canvas used as the coordinate origin for model-view transforms.
* @returns Cleanup handle for replacing a disconnected widget shell.
*/
function mountWordPressPagePointerBridge(canvas: HTMLCanvasElement): WordPressPagePointerBridgeHandle {
const handleMouseMove = (event: MouseEvent) => {
if (!canvas.isConnected || typeof window.dragMgr?.setPoint !== 'function') {
return;
}
const rect = canvas.getBoundingClientRect();
const deviceX = event.clientX - rect.left;
const deviceY = event.clientY - rect.top;
window.dragMgr.setPoint(resolveWordPressViewX(canvas, deviceX), resolveWordPressViewY(canvas, deviceY));
};
const handleMouseOut = (event: MouseEvent) => {
if (!event.relatedTarget && typeof window.dragMgr?.setPoint === 'function') {
window.dragMgr.setPoint(0, 0);
}
};
window.addEventListener('mousemove', handleMouseMove, { passive: true });
window.addEventListener('mouseout', handleMouseOut, { passive: true });
return {
destroy() {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseout', handleMouseOut);
},
};
}
/**
* Converts a page-level pointer x offset with the legacy runtime transform when available.
* @param canvas Runtime canvas used for fallback normalization before the script exposes helpers.
* @param deviceX Pointer x offset relative to the canvas left edge.
* @returns Model-view x coordinate consumed by `L2DTargetPoint`.
*/
function resolveWordPressViewX(canvas: HTMLCanvasElement, deviceX: number): number {
if (typeof window.transformViewX === 'function') {
return window.transformViewX(deviceX);
}
return (deviceX - canvas.width / 2) * (2 / canvas.width);
}
/**
* Converts a page-level pointer y offset with the legacy runtime transform when available.
* @param canvas Runtime canvas used for fallback normalization before the script exposes helpers.
* @param deviceY Pointer y offset relative to the canvas top edge.
* @returns Model-view y coordinate consumed by `L2DTargetPoint`.
*/
function resolveWordPressViewY(canvas: HTMLCanvasElement, deviceY: number): number {
if (typeof window.transformViewY === 'function') {
return window.transformViewY(deviceY);
}
return (deviceY - canvas.height / 2) * (-2 / canvas.width);
}
/**
* Reads the active WordPress MOC model JSON so the toolbar can avoid destructive single-texture reloads.
* @param settings Runtime settings whose first model entry points at the active Pio `index.json`.
* @returns Number of usable texture entries, or `undefined` when the model JSON cannot be inspected.
*/
async function resolveWordPressModelTextureCount(settings: WordPressLive2DSettings): Promise<number | undefined> {
const modelEntry = settings.MODELS[0]?.[0];
if (!modelEntry || typeof window.fetch !== 'function') {
return undefined;
}
try {
const response = await window.fetch(modelEntry, { credentials: 'same-origin' });
if (!response.ok) {
return undefined;
}
const modelJson = (await response.json()) as { textures?: unknown };
if (!Array.isArray(modelJson.textures)) {
return undefined;
}
return modelJson.textures.filter((texture) => typeof texture === 'string' && texture.trim().length > 0).length;
} catch {
return undefined;
}
}
/**
* Creates the WordPress plugin toolbar with the original `fui-*` class contract.
* @returns Toolbar element whose spans carry action metadata for the local controller.

View File

@ -23,13 +23,22 @@ export interface WordPressWidgetControllerHandle {
destroy(): void;
}
export interface WordPressWidgetControllerOptions {
/**
* Number of texture entries declared by the active WordPress model JSON; single-texture models must not reload.
*/
textureCount?: number;
}
/**
* Installs WordPress live-2d plugin toolbar behavior on the DOM shell that hosts the legacy runtime.
* @param elements Stable DOM nodes created by the runtime bridge for the Pio widget.
* @param options Runtime metadata used to protect legacy actions whose behavior depends on model assets.
* @returns Cleanup handle for route/test teardown when the shell is rebuilt.
*/
export function mountWordPressWidgetController(
elements: WordPressWidgetControllerElements,
options: WordPressWidgetControllerOptions = {},
): WordPressWidgetControllerHandle {
let hideTipsTimer = 0;
let closeTimer = 0;
@ -108,6 +117,10 @@ export function mountWordPressWidgetController(
saveCanvas();
break;
case 'texture':
if (typeof options.textureCount === 'number' && options.textureCount < 2) {
showMessage(WORDPRESS_WAIFU_MESSAGES.singleTexture);
break;
}
elements.textureButton.click();
showMessage(WORDPRESS_WAIFU_MESSAGES.texture);
break;

View File

@ -6,6 +6,7 @@ export const WORDPRESS_WAIFU_MESSAGES = {
copy: '复制成功,记得带上出处哦。',
model: '当前只启用 Pio 模型。',
photo: '已尝试保存当前画面。',
singleTexture: '当前只有一套服装。',
texture: '正在切换 Pio 的服装。',
touch: ['哇,你碰到我啦。', '今天也要好好经营小店。', '有新的灵感了吗?'],
welcome: '欢迎来到 KwiTsukasa 的小站。',