加入客服反馈和社区二维码
增加桌面端全站客服反馈悬浮入口。 悬停入口时展示 QQ 和微信社区二维码浮窗。 补充入口拖拽边界、点击跳转和移动端隐藏测试。 同步全站客服悬浮入口接入文档。
This commit is contained in:
@@ -34,6 +34,8 @@ Expo React Native 移动壳和 Tauri 桌面壳的工程结构、同源 WebView
|
||||
|
||||
图片画布生成类面板的模型泥点默认 JSON、运行时 override、后台“模型定价”页面和主站动态下发口径见 [【编辑器】模型定价配置管理方案-2026-06-22.md](./%E3%80%90%E7%BC%96%E8%BE%91%E5%99%A8%E3%80%91%E6%A8%A1%E5%9E%8B%E5%AE%9A%E4%BB%B7%E9%85%8D%E7%BD%AE%E7%AE%A1%E7%90%86%E6%96%B9%E6%A1%88-2026-06-22.md)。
|
||||
|
||||
桌面端全站客服悬浮入口的全局接入点、固定层级、拖拽边界和移动端隐藏口径见 [【前端架构】全站客服悬浮入口接入约定-2026-06-23.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91%E5%85%A8%E7%AB%99%E5%AE%A2%E6%9C%8D%E6%82%AC%E6%B5%AE%E5%85%A5%E5%8F%A3%E6%8E%A5%E5%85%A5%E7%BA%A6%E5%AE%9A-2026-06-23.md)。
|
||||
|
||||
桌面端 `/creation` 创作工具主页、顶级“草稿”入口替换为“项目”、最近项目、新建项目和陶泥儿精选素材瀑布流的落地计划见 [【玩法创作】创作主页与项目入口改版计划-2026-06-18.md](./%E3%80%90%E7%8E%A9%E6%B3%95%E5%88%9B%E4%BD%9C%E3%80%91%E5%88%9B%E4%BD%9C%E4%B8%BB%E9%A1%B5%E4%B8%8E%E9%A1%B9%E7%9B%AE%E5%85%A5%E5%8F%A3%E6%94%B9%E7%89%88%E8%AE%A1%E5%88%92-2026-06-18.md)。
|
||||
|
||||
本地通过 SSH alias 管理多台服务器、查看硬件 / systemd / HTTP 健康状态并执行受控服务启停的 egui 桌面工具见 [【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md](./technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md)。
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# 全站客服悬浮入口接入约定
|
||||
|
||||
日期:2026-06-23
|
||||
|
||||
## 背景
|
||||
|
||||
主站需要在桌面端每个页面提供稳定可见的客服入口,包括 `/editor/canvas` 这类全屏画布页面。入口使用陶泥儿客服形象图片,不进入具体业务页布局,避免被滚动、画布缩放或面板层级影响。
|
||||
|
||||
## 接入口
|
||||
|
||||
- 全局 React 入口为 `src/components/common/FloatingFeedbackEntry.tsx`。
|
||||
- 在 `src/main.tsx` 的 `RouteImageReadyGate` 内、当前路由组件之后渲染,覆盖主站 `resolveAppRoute` 管理的所有页面。
|
||||
- 图片资产放在 `public/branding/taonier-feedback-entry.png`,并通过 `BRAND_ASSETS.taonierFeedbackEntry` 引用。
|
||||
- 悬浮层样式使用 `.floating-feedback-entry`,通过 `position: fixed` 和最高层级保持在页面最上方。
|
||||
|
||||
## 交互约定
|
||||
|
||||
- 仅桌面端展示,媒体查询为 `(min-width: 1024px)`;移动端不渲染入口。
|
||||
- 初始位置靠近页面左下角,随窗口滚动保持固定。
|
||||
- 用户可以按住拖动,拖拽过程中实时夹在当前 viewport 内,保证图片完整可见,不被窗口边界遮挡。
|
||||
- 窗口尺寸变化后重新夹取当前位置,避免缩小窗口后入口跑出视口。
|
||||
- 点击入口使用新页面打开客服表单:
|
||||
|
||||
```text
|
||||
https://kcnz41bksl1c.feishu.cn/share/base/form/shrcn1p9WpuZMDxrI5ysLLJhw7k
|
||||
```
|
||||
|
||||
- 拖拽释放后短时间内的补发 click 会被吞掉,避免用户拖动入口时误打开表单。
|
||||
|
||||
## 验收口径
|
||||
|
||||
- 桌面端主站任意路由均能看到客服入口,画布页也可见。
|
||||
- 滚动页面时入口位置相对视口不变。
|
||||
- 拖到窗口四边时入口完整留在可见区域内。
|
||||
- 移动端视口不渲染入口。
|
||||
- 单击入口新开页面跳转到飞书表单。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
@@ -0,0 +1,193 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { FloatingFeedbackEntry } from './FloatingFeedbackEntry';
|
||||
import {
|
||||
clampFloatingFeedbackPosition,
|
||||
FLOATING_FEEDBACK_FORM_URL,
|
||||
resolveFloatingFeedbackContactPanelOffset,
|
||||
resolveInitialFloatingFeedbackPosition,
|
||||
} from './floatingFeedbackEntryModel';
|
||||
|
||||
function mockMatchMedia(matches: boolean) {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function mockViewport(width: number, height: number) {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: width,
|
||||
});
|
||||
Object.defineProperty(window, 'innerHeight', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: height,
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('desktop renders floating feedback entry near lower left', () => {
|
||||
mockMatchMedia(true);
|
||||
mockViewport(1440, 900);
|
||||
|
||||
render(<FloatingFeedbackEntry />);
|
||||
|
||||
const button = screen.getByRole('button', { name: '反馈点我' });
|
||||
const shell = button.closest('.floating-feedback-entry-shell') as HTMLElement;
|
||||
expect(button.className).toContain('floating-feedback-entry');
|
||||
expect(shell.style.left).toBe('24px');
|
||||
expect(shell.style.top).toBe('746.4px');
|
||||
expect(button.querySelector('img')?.getAttribute('src')).toBe(
|
||||
'/branding/taonier-feedback-entry.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('hover shows contact QR images and mouse leave hides them', () => {
|
||||
mockMatchMedia(true);
|
||||
mockViewport(1440, 900);
|
||||
|
||||
render(<FloatingFeedbackEntry />);
|
||||
|
||||
const button = screen.getByRole('button', { name: '反馈点我' });
|
||||
expect(document.querySelector('.floating-feedback-entry-contact-panel')).toBeNull();
|
||||
|
||||
fireEvent.pointerEnter(button);
|
||||
|
||||
const panel = document.querySelector('.floating-feedback-entry-contact-panel');
|
||||
expect(panel).not.toBeNull();
|
||||
expect(
|
||||
(panel as HTMLElement).style.getPropertyValue(
|
||||
'--floating-feedback-contact-offset',
|
||||
),
|
||||
).toBe('141.2px');
|
||||
const images = Array.from(panel?.querySelectorAll('img') ?? []);
|
||||
expect(images.map((image) => image.getAttribute('src'))).toEqual([
|
||||
'/branding/taonier-feedback-qq.png',
|
||||
'/branding/taonier-feedback-wechat.png',
|
||||
]);
|
||||
|
||||
fireEvent.pointerLeave(button);
|
||||
|
||||
expect(document.querySelector('.floating-feedback-entry-contact-panel')).toBeNull();
|
||||
});
|
||||
|
||||
test('mobile does not render feedback entry', () => {
|
||||
mockMatchMedia(false);
|
||||
mockViewport(390, 780);
|
||||
|
||||
render(<FloatingFeedbackEntry />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: '反馈点我' })).toBeNull();
|
||||
});
|
||||
|
||||
test('click opens feedback form in a new page', () => {
|
||||
mockMatchMedia(true);
|
||||
mockViewport(1440, 900);
|
||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null);
|
||||
|
||||
render(<FloatingFeedbackEntry />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '反馈点我' }));
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
FLOATING_FEEDBACK_FORM_URL,
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
});
|
||||
|
||||
test('hover contact panel does not block click opening feedback form', () => {
|
||||
mockMatchMedia(true);
|
||||
mockViewport(1440, 900);
|
||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null);
|
||||
|
||||
render(<FloatingFeedbackEntry />);
|
||||
|
||||
const button = screen.getByRole('button', { name: '反馈点我' });
|
||||
fireEvent.pointerEnter(button);
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(document.querySelector('.floating-feedback-entry-contact-panel')).not.toBeNull();
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
FLOATING_FEEDBACK_FORM_URL,
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
});
|
||||
|
||||
test('drag keeps entry fully inside the viewport and does not open form', () => {
|
||||
mockMatchMedia(true);
|
||||
mockViewport(360, 320);
|
||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null);
|
||||
|
||||
render(<FloatingFeedbackEntry />);
|
||||
|
||||
const button = screen.getByRole('button', { name: '反馈点我' });
|
||||
fireEvent.mouseEnter(button);
|
||||
expect(document.querySelector('.floating-feedback-entry-contact-panel')).not.toBeNull();
|
||||
fireEvent.mouseDown(button, {
|
||||
button: 0,
|
||||
clientX: 40,
|
||||
clientY: 220,
|
||||
});
|
||||
expect(document.querySelector('.floating-feedback-entry-contact-panel')).toBeNull();
|
||||
fireEvent.mouseMove(window, {
|
||||
clientX: 900,
|
||||
clientY: 900,
|
||||
});
|
||||
fireEvent.mouseUp(window, {
|
||||
clientX: 900,
|
||||
clientY: 900,
|
||||
});
|
||||
expect(document.querySelector('.floating-feedback-entry-contact-panel')).not.toBeNull();
|
||||
fireEvent.click(button);
|
||||
|
||||
const shell = button.closest('.floating-feedback-entry-shell') as HTMLElement;
|
||||
expect(shell.style.left).toBe('282.4px');
|
||||
expect(shell.style.top).toBe('242.4px');
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('floating feedback position helpers clamp to visible viewport', () => {
|
||||
expect(
|
||||
clampFloatingFeedbackPosition(
|
||||
{ left: -100, top: 1000 },
|
||||
{ width: 360, height: 320 },
|
||||
),
|
||||
).toEqual({ left: 20, top: 242.4 });
|
||||
expect(
|
||||
resolveInitialFloatingFeedbackPosition({ width: 1440, height: 900 }),
|
||||
).toEqual({ left: 24, top: 746.4 });
|
||||
expect(
|
||||
resolveFloatingFeedbackContactPanelOffset(
|
||||
{ left: 20, top: 100 },
|
||||
{ width: 1440 },
|
||||
),
|
||||
).toBe(145.2);
|
||||
expect(
|
||||
resolveFloatingFeedbackContactPanelOffset(
|
||||
{ left: 20, top: 100 },
|
||||
{ width: 360 },
|
||||
),
|
||||
).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,336 @@
|
||||
import {
|
||||
type CSSProperties,
|
||||
type MouseEvent,
|
||||
type PointerEvent,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { BRAND_ASSETS } from '../../uiAssets';
|
||||
import {
|
||||
clampFloatingFeedbackPosition,
|
||||
FLOATING_FEEDBACK_DESKTOP_QUERY,
|
||||
FLOATING_FEEDBACK_DRAG_CLICK_SUPPRESS_MS,
|
||||
FLOATING_FEEDBACK_DRAG_CLICK_THRESHOLD,
|
||||
FLOATING_FEEDBACK_FORM_URL,
|
||||
FLOATING_FEEDBACK_INITIAL_LEFT,
|
||||
FLOATING_FEEDBACK_MARGIN,
|
||||
type FloatingFeedbackPosition,
|
||||
resolveFloatingFeedbackContactPanelOffset,
|
||||
resolveInitialFloatingFeedbackPosition,
|
||||
} from './floatingFeedbackEntryModel';
|
||||
|
||||
type FloatingFeedbackDragState = {
|
||||
pointerId: number;
|
||||
source: 'mouse' | 'pointer';
|
||||
startClientX: number;
|
||||
startClientY: number;
|
||||
startLeft: number;
|
||||
startTop: number;
|
||||
hasMoved: boolean;
|
||||
};
|
||||
|
||||
function isSameFloatingFeedbackPointer(
|
||||
currentPointerId: number,
|
||||
nextPointerId: number,
|
||||
) {
|
||||
if (!Number.isFinite(currentPointerId) || !Number.isFinite(nextPointerId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return currentPointerId === nextPointerId;
|
||||
}
|
||||
|
||||
function shouldUseMouseDragFallback() {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.PointerEvent !== 'function'
|
||||
);
|
||||
}
|
||||
|
||||
function getViewportSize() {
|
||||
return {
|
||||
width: window.innerWidth || document.documentElement.clientWidth,
|
||||
height: window.innerHeight || document.documentElement.clientHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function getInitialDesktopVisibility() {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
typeof window.matchMedia !== 'function'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.matchMedia(FLOATING_FEEDBACK_DESKTOP_QUERY).matches;
|
||||
}
|
||||
|
||||
export function FloatingFeedbackEntry() {
|
||||
const [isDesktopVisible, setIsDesktopVisible] = useState(
|
||||
getInitialDesktopVisibility,
|
||||
);
|
||||
const [isPointerOverEntry, setIsPointerOverEntry] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [position, setPosition] = useState<FloatingFeedbackPosition>(() =>
|
||||
typeof window === 'undefined'
|
||||
? { left: FLOATING_FEEDBACK_INITIAL_LEFT, top: FLOATING_FEEDBACK_MARGIN }
|
||||
: resolveInitialFloatingFeedbackPosition(getViewportSize()),
|
||||
);
|
||||
const dragStateRef = useRef<FloatingFeedbackDragState | null>(null);
|
||||
const suppressClickUntilRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
typeof window.matchMedia !== 'function'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia(FLOATING_FEEDBACK_DESKTOP_QUERY);
|
||||
const updateVisibility = (event?: MediaQueryListEvent) => {
|
||||
setIsDesktopVisible(event?.matches ?? mediaQuery.matches);
|
||||
};
|
||||
|
||||
updateVisibility();
|
||||
|
||||
if (typeof mediaQuery.addEventListener === 'function') {
|
||||
mediaQuery.addEventListener('change', updateVisibility);
|
||||
return () => mediaQuery.removeEventListener('change', updateVisibility);
|
||||
}
|
||||
|
||||
mediaQuery.addListener(updateVisibility);
|
||||
return () => mediaQuery.removeListener(updateVisibility);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const clampToViewport = () => {
|
||||
setPosition((current) =>
|
||||
clampFloatingFeedbackPosition(current, getViewportSize()),
|
||||
);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', clampToViewport);
|
||||
return () => window.removeEventListener('resize', clampToViewport);
|
||||
}, []);
|
||||
|
||||
const updateDrag = (
|
||||
pointerId: number,
|
||||
source: FloatingFeedbackDragState['source'],
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
) => {
|
||||
const dragState = dragStateRef.current;
|
||||
if (
|
||||
!dragState ||
|
||||
dragState.source !== source ||
|
||||
!isSameFloatingFeedbackPointer(dragState.pointerId, pointerId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = clientX - dragState.startClientX;
|
||||
const deltaY = clientY - dragState.startClientY;
|
||||
if (
|
||||
!dragState.hasMoved &&
|
||||
Math.hypot(deltaX, deltaY) >= FLOATING_FEEDBACK_DRAG_CLICK_THRESHOLD
|
||||
) {
|
||||
dragState.hasMoved = true;
|
||||
}
|
||||
|
||||
setPosition(
|
||||
clampFloatingFeedbackPosition(
|
||||
{
|
||||
left: dragState.startLeft + deltaX,
|
||||
top: dragState.startTop + deltaY,
|
||||
},
|
||||
getViewportSize(),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const stopDragById = (
|
||||
pointerId: number,
|
||||
source: FloatingFeedbackDragState['source'],
|
||||
) => {
|
||||
const dragState = dragStateRef.current;
|
||||
if (
|
||||
!dragState ||
|
||||
dragState.source !== source ||
|
||||
!isSameFloatingFeedbackPointer(dragState.pointerId, pointerId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
suppressClickUntilRef.current = dragState.hasMoved
|
||||
? Date.now() + FLOATING_FEEDBACK_DRAG_CLICK_SUPPRESS_MS
|
||||
: 0;
|
||||
dragStateRef.current = null;
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerMove = (event: globalThis.PointerEvent) => {
|
||||
const dragState = dragStateRef.current;
|
||||
if (
|
||||
!dragState ||
|
||||
!isSameFloatingFeedbackPointer(dragState.pointerId, event.pointerId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateDrag(event.pointerId, 'pointer', event.clientX, event.clientY);
|
||||
};
|
||||
|
||||
const stopPointerDrag = (event: globalThis.PointerEvent) => {
|
||||
stopDragById(event.pointerId, 'pointer');
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', stopPointerDrag);
|
||||
window.addEventListener('pointercancel', stopPointerDrag);
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', stopPointerDrag);
|
||||
window.removeEventListener('pointercancel', stopPointerDrag);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (event: globalThis.MouseEvent) => {
|
||||
const dragState = dragStateRef.current;
|
||||
if (!dragState || dragState.source !== 'mouse') {
|
||||
return;
|
||||
}
|
||||
|
||||
updateDrag(Number.NaN, 'mouse', event.clientX, event.clientY);
|
||||
};
|
||||
|
||||
const stopMouseDrag = () => {
|
||||
stopDragById(Number.NaN, 'mouse');
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', stopMouseDrag);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', stopMouseDrag);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startPointerDrag = (event: PointerEvent<HTMLButtonElement>) => {
|
||||
if (event.button > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
setIsDragging(true);
|
||||
dragStateRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
source: 'pointer',
|
||||
startClientX: event.clientX,
|
||||
startClientY: event.clientY,
|
||||
startLeft: position.left,
|
||||
startTop: position.top,
|
||||
hasMoved: false,
|
||||
};
|
||||
};
|
||||
|
||||
const startMouseDrag = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!shouldUseMouseDragFallback() || event.button > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDragging(true);
|
||||
dragStateRef.current = {
|
||||
pointerId: Number.NaN,
|
||||
source: 'mouse',
|
||||
startClientX: event.clientX,
|
||||
startClientY: event.clientY,
|
||||
startLeft: position.left,
|
||||
startTop: position.top,
|
||||
hasMoved: false,
|
||||
};
|
||||
};
|
||||
|
||||
const openFeedbackForm = () => {
|
||||
if (suppressClickUntilRef.current >= Date.now()) {
|
||||
suppressClickUntilRef.current = 0;
|
||||
dragStateRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(FLOATING_FEEDBACK_FORM_URL, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
if (!isDesktopVisible || typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shouldShowContactPanel = isPointerOverEntry && !isDragging;
|
||||
const contactPanelOffset = resolveFloatingFeedbackContactPanelOffset(
|
||||
position,
|
||||
getViewportSize(),
|
||||
);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="floating-feedback-entry-shell"
|
||||
style={{
|
||||
left: `${position.left}px`,
|
||||
top: `${position.top}px`,
|
||||
}}
|
||||
>
|
||||
{shouldShowContactPanel ? (
|
||||
<div
|
||||
className="floating-feedback-entry-contact-panel"
|
||||
style={
|
||||
{
|
||||
'--floating-feedback-contact-offset': `${contactPanelOffset}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<img
|
||||
src={BRAND_ASSETS.taonierFeedbackQq}
|
||||
alt=""
|
||||
draggable={false}
|
||||
/>
|
||||
<img
|
||||
src={BRAND_ASSETS.taonierFeedbackWechat}
|
||||
alt=""
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="floating-feedback-entry"
|
||||
aria-label="反馈点我"
|
||||
onPointerEnter={() => setIsPointerOverEntry(true)}
|
||||
onPointerLeave={() => setIsPointerOverEntry(false)}
|
||||
onPointerDown={startPointerDrag}
|
||||
onMouseEnter={() => setIsPointerOverEntry(true)}
|
||||
onMouseLeave={() => setIsPointerOverEntry(false)}
|
||||
onMouseDown={startMouseDrag}
|
||||
onPointerMove={(event) =>
|
||||
updateDrag(event.pointerId, 'pointer', event.clientX, event.clientY)
|
||||
}
|
||||
onPointerUp={(event) => stopDragById(event.pointerId, 'pointer')}
|
||||
onPointerCancel={(event) => stopDragById(event.pointerId, 'pointer')}
|
||||
onClick={openFeedbackForm}
|
||||
>
|
||||
<img
|
||||
src={BRAND_ASSETS.taonierFeedbackEntry}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
/>
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
export const FLOATING_FEEDBACK_FORM_URL =
|
||||
'https://kcnz41bksl1c.feishu.cn/share/base/form/shrcn1p9WpuZMDxrI5ysLLJhw7k';
|
||||
|
||||
export const FLOATING_FEEDBACK_DESKTOP_QUERY = '(min-width: 1024px)';
|
||||
export const FLOATING_FEEDBACK_SIZE = 57.6;
|
||||
export const FLOATING_FEEDBACK_MARGIN = 20;
|
||||
export const FLOATING_FEEDBACK_INITIAL_LEFT = 24;
|
||||
export const FLOATING_FEEDBACK_INITIAL_BOTTOM = 96;
|
||||
export const FLOATING_FEEDBACK_DRAG_CLICK_THRESHOLD = 4;
|
||||
export const FLOATING_FEEDBACK_DRAG_CLICK_SUPPRESS_MS = 250;
|
||||
export const FLOATING_FEEDBACK_CONTACT_PANEL_WIDTH = 348;
|
||||
|
||||
export type FloatingFeedbackPosition = {
|
||||
left: number;
|
||||
top: number;
|
||||
};
|
||||
|
||||
function roundFloatingFeedbackCoordinate(value: number) {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
export function clampFloatingFeedbackPosition(
|
||||
position: FloatingFeedbackPosition,
|
||||
viewport: { width: number; height: number },
|
||||
) {
|
||||
const minLeft = FLOATING_FEEDBACK_MARGIN;
|
||||
const minTop = FLOATING_FEEDBACK_MARGIN;
|
||||
const maxLeft = Math.max(
|
||||
minLeft,
|
||||
viewport.width - FLOATING_FEEDBACK_SIZE - FLOATING_FEEDBACK_MARGIN,
|
||||
);
|
||||
const maxTop = Math.max(
|
||||
minTop,
|
||||
viewport.height - FLOATING_FEEDBACK_SIZE - FLOATING_FEEDBACK_MARGIN,
|
||||
);
|
||||
|
||||
return {
|
||||
left: roundFloatingFeedbackCoordinate(
|
||||
Math.min(maxLeft, Math.max(minLeft, position.left)),
|
||||
),
|
||||
top: roundFloatingFeedbackCoordinate(
|
||||
Math.min(maxTop, Math.max(minTop, position.top)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveInitialFloatingFeedbackPosition(viewport: {
|
||||
width: number;
|
||||
height: number;
|
||||
}) {
|
||||
return clampFloatingFeedbackPosition(
|
||||
{
|
||||
left: FLOATING_FEEDBACK_INITIAL_LEFT,
|
||||
top:
|
||||
viewport.height -
|
||||
FLOATING_FEEDBACK_SIZE -
|
||||
FLOATING_FEEDBACK_INITIAL_BOTTOM,
|
||||
},
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveFloatingFeedbackContactPanelOffset(
|
||||
position: FloatingFeedbackPosition,
|
||||
viewport: { width: number },
|
||||
) {
|
||||
const entryCenter = position.left + FLOATING_FEEDBACK_SIZE / 2;
|
||||
const minCenter = FLOATING_FEEDBACK_MARGIN + FLOATING_FEEDBACK_CONTACT_PANEL_WIDTH / 2;
|
||||
const maxCenter =
|
||||
viewport.width -
|
||||
FLOATING_FEEDBACK_MARGIN -
|
||||
FLOATING_FEEDBACK_CONTACT_PANEL_WIDTH / 2;
|
||||
|
||||
if (maxCenter < minCenter) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return roundFloatingFeedbackCoordinate(
|
||||
Math.min(maxCenter, Math.max(minCenter, entryCenter)) - entryCenter,
|
||||
);
|
||||
}
|
||||
@@ -1562,6 +1562,75 @@ html[data-mobile-keyboard-open='true'] #root {
|
||||
color: var(--platform-brand-logo-subtitle);
|
||||
}
|
||||
|
||||
.floating-feedback-entry-shell {
|
||||
position: fixed;
|
||||
z-index: 2147483647;
|
||||
width: 57.6px;
|
||||
height: 57.6px;
|
||||
}
|
||||
|
||||
.floating-feedback-entry-contact-panel {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 12px);
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
width: 348px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(214, 154, 111, 0.24);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 252, 248, 0.97);
|
||||
box-shadow: 0 18px 36px rgba(74, 34, 15, 0.18);
|
||||
pointer-events: none;
|
||||
transform: translateX(calc(-50% + var(--floating-feedback-contact-offset, 0px)));
|
||||
}
|
||||
|
||||
.floating-feedback-entry-contact-panel img {
|
||||
display: block;
|
||||
width: 156px;
|
||||
height: 156px;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.floating-feedback-entry {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: 57.6px;
|
||||
height: 57.6px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
filter: drop-shadow(0 0.7rem 1.05rem rgba(98, 48, 24, 0.24));
|
||||
}
|
||||
|
||||
.floating-feedback-entry:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.floating-feedback-entry:focus-visible {
|
||||
outline: 3px solid rgba(204, 117, 76, 0.72);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.floating-feedback-entry img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.platform-main-shell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
@@ -5,6 +5,7 @@ import './index.css';
|
||||
import {StrictMode, Suspense} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
|
||||
import {FloatingFeedbackEntry} from './components/common/FloatingFeedbackEntry';
|
||||
import {stabilizeMobileViewportKeyboardFocus} from './mobileViewportKeyboardFocus';
|
||||
import {lockMobileViewportZoom} from './mobileViewportZoomLock';
|
||||
import {resolveAppRoute} from './routing/appRoutes';
|
||||
@@ -54,5 +55,6 @@ root.render(
|
||||
<RouteComponent {...(route.componentProps ?? {})} />
|
||||
</RouteImageReadyGate>
|
||||
</Suspense>
|
||||
<FloatingFeedbackEntry />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -54,6 +54,9 @@ export const CUSTOM_WORLD_THEME_ICONS = {
|
||||
export const BRAND_ASSETS = {
|
||||
kuranGamesLogo: '/branding/kuran-games-logo.svg',
|
||||
taonierProductIp: '/branding/taonier-product-ip.png',
|
||||
taonierFeedbackEntry: '/branding/taonier-feedback-entry.png',
|
||||
taonierFeedbackQq: '/branding/taonier-feedback-qq.png',
|
||||
taonierFeedbackWechat: '/branding/taonier-feedback-wechat.png',
|
||||
} as const;
|
||||
|
||||
export const UI_CHROME = {
|
||||
|
||||
Reference in New Issue
Block a user