feat: 更新光标组件和场景交互

- 在 Cursor 组件中添加禁用状态,增强用户交互体验。
- 更新多个场景以使用新的光标控制逻辑,确保光标的启用状态与展品访问状态相匹配。
- 在信件组件中添加输入校验功能,提升用户填写体验。
- 调整文本内容和引导对话,确保信息准确性和一致性。
This commit is contained in:
zhongyidian committed 2026-01-14 18:40:52 +08:00
1 parent a6570efa19
commit d7ff7d3a45
25 files changed
+769 -158

No files matched your search

+2
View File
@@ -2,6 +2,7 @@ export { useDragAndDrop } from "./useDragAndDrop";
export { usePageScale } from "./usePageScale";
export { useTouch } from "./useTouch";
export { useZoomAndDrag } from "./useZoomAndDrag";
export { useCursorController } from "./useCursorController";
export type {
UseTouchOptions,
UseTouchReturn,
@@ -12,3 +13,4 @@ export type {
UseZoomAndDragOptions,
UseZoomAndDragReturn,
} from "./useZoomAndDrag";
export type { CursorControllerConfig } from "./useCursorController";
+332
View File
@@ -0,0 +1,332 @@
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
/** Cursor 解锁规则 */
export interface CursorUnlockRule {
/** cursor ID */
id: string | number;
/** 依赖的展品 ID 列表,当这些展品都被访问后,自动解锁此 cursor */
dependsOn?: (string | number)[];
/** 初始是否启用(如果未配置 dependsOn,则使用此值) */
initialEnabled?: boolean;
}
/** Cursor 控制器配置 */
export interface CursorControllerConfig {
/** 方式1:使用解锁规则(推荐,更灵活) */
unlockRules?: CursorUnlockRule[];
/** 方式2:使用依赖映射(简化配置,格式:{ cursorId: [依赖的展品Id列表] }) */
dependencies?: Record<string | number, (string | number)[]>;
/** 已访问的展品 ID(支持 Set 或数组,内部会自动处理引用问题) */
visited?: Set<string | number> | (string | number)[];
/** 初始启用的 cursor ID 列表(向后兼容,如果没有配置 unlockRules 或 dependencies 则使用此值) */
initialEnabled?: (string | number)[];
/** cursor 完成时的回调函数 */
onCursorComplete?: (id: string | number) => void;
}
/**
* Cursor 控制器 Hook(混合方案)
* 支持自动解锁和手动控制,提供两种配置方式
*
* @example
* ```tsx
* // 方式1:使用解锁规则
* const { isEnabled } = useCursorController({
* unlockRules: [
* { id: 1, initialEnabled: true }, // cursor 1 初始启用
* { id: 2, dependsOn: [1] }, // cursor 2 依赖展品1
* { id: 3, dependsOn: [1, 2] }, // cursor 3 依赖展品1和2
* ],
* visited: visited,
* });
*
* // 方式2:使用依赖映射(更简洁)
* const { isEnabled } = useCursorController({
* initialEnabled: [1],
* dependencies: {
* 2: [1], // cursor 2 依赖展品 1
* 3: [1, 2], // cursor 3 依赖展品 1 和 2
* },
* visited: visited, // 可以传入 Set 或数组,内部会自动处理
* });
*
* // 方式3:向后兼容(手动控制)
* const { enable, isEnabled } = useCursorController({
* initialEnabled: [1],
* });
* enable(2);
*
* // 在组件中使用
* <Cursor disabled={!isEnabled(1)} />
* ```
*/
export const useCursorController = (config: CursorControllerConfig = {}) => {
const {
unlockRules = [],
dependencies = {},
visited,
initialEnabled = [],
} = config;
// 手动覆盖的状态(优先级高于自动解锁)
const [manualOverrides, setManualOverrides] = useState<
Map<string | number, boolean>
>(new Map());
// 将 visited(Set 或数组)序列化为稳定的 key,用于比较内容变化
// 无论传入 Set 还是数组,都转换为数组再序列化,避免引用问题
const visitedKey = useMemo(() => {
if (!visited) return "";
const visitedArray = Array.isArray(visited) ? visited : Array.from(visited);
if (visitedArray.length === 0) return "";
const sorted = [...visitedArray].sort((a, b) => Number(a) - Number(b));
return sorted.join(",");
}, [visited]);
// 使用 ref 存储上一次的 key,避免不必要的重新计算
const prevKeyRef = useRef<string>("");
// 使用 ref 存储最新的 visited,避免在依赖中使用对象引用
const visitedRef = useRef<Set<string | number>>(new Set());
// 将 visited(Set 或数组)转换为 Set,并更新 ref
// 只在内容变化时更新,避免引用问题
useMemo(() => {
if (!visited) {
visitedRef.current = new Set<string | number>();
return;
}
const newSet = Array.isArray(visited) ? new Set(visited) : visited;
visitedRef.current = newSet;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visitedKey]); // 使用 visitedKey 而不是 visited 对象,避免引用问题
// 计算初始启用的 cursor
const getInitialEnabled = useCallback(() => {
const enabled = new Set<string | number>();
const currentVisitedSet = visitedRef.current; // 使用 ref 中的最新值
// 优先使用 unlockRules
if (unlockRules.length > 0) {
unlockRules.forEach((rule) => {
if (rule.dependsOn && rule.dependsOn.length > 0) {
// 检查依赖是否都满足
const allDepsMet = rule.dependsOn.every((dep) =>
currentVisitedSet.has(dep)
);
if (allDepsMet) {
enabled.add(rule.id);
}
} else if (rule.initialEnabled) {
enabled.add(rule.id);
}
});
return enabled;
}
// 其次使用 dependencies
if (Object.keys(dependencies).length > 0) {
Object.entries(dependencies).forEach(([cursorId, deps]) => {
const id = isNaN(Number(cursorId)) ? cursorId : Number(cursorId);
if (
deps.length === 0 ||
deps.every((dep) => currentVisitedSet.has(dep))
) {
enabled.add(id);
}
});
initialEnabled.forEach((id) => enabled.add(id));
return enabled;
}
// 最后使用 initialEnabled(向后兼容)
initialEnabled.forEach((id) => enabled.add(id));
return enabled;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [unlockRules, dependencies, visitedKey, initialEnabled]);
// 将 manualOverrides 序列化为 key
const manualOverridesKey = useMemo(() => {
if (manualOverrides.size === 0) return "";
const sorted = Array.from(manualOverrides.entries())
.sort(([a], [b]) => Number(a) - Number(b))
.map(([id, enabled]) => `${id}:${enabled}`)
.join(",");
return sorted;
}, [manualOverrides]);
// 根据解锁规则和 visited 状态计算应该启用的 cursor
const calculateEnabledCursors = useCallback(() => {
const enabled = new Set<string | number>();
// 使用 ref 中的最新值,避免依赖问题
const currentVisitedSet = visitedRef.current;
// 优先使用 unlockRules
if (unlockRules.length > 0) {
unlockRules.forEach((rule) => {
// 手动覆盖优先
if (manualOverrides.has(rule.id)) {
if (manualOverrides.get(rule.id)) {
enabled.add(rule.id);
}
return;
}
// 自动解锁逻辑
if (rule.dependsOn && rule.dependsOn.length > 0) {
const allDepsMet = rule.dependsOn.every((dep) =>
currentVisitedSet.has(dep)
);
if (allDepsMet) {
enabled.add(rule.id);
}
} else if (rule.initialEnabled) {
enabled.add(rule.id);
}
});
return enabled;
}
// 其次使用 dependencies
if (Object.keys(dependencies).length > 0) {
const allCursorIds = new Set([
...initialEnabled,
...Object.keys(dependencies).map((key) =>
isNaN(Number(key)) ? key : Number(key)
),
]);
allCursorIds.forEach((id) => {
// 手动覆盖优先
if (manualOverrides.has(id)) {
if (manualOverrides.get(id)) {
enabled.add(id);
}
return;
}
// 自动解锁逻辑
const deps = dependencies[id];
if (deps && deps.length > 0) {
const allDepsMet = deps.every((dep) => currentVisitedSet.has(dep));
if (allDepsMet) {
enabled.add(id);
}
} else if (initialEnabled.includes(id)) {
enabled.add(id);
}
});
return enabled;
}
// 最后使用 initialEnabled(向后兼容)
initialEnabled.forEach((id) => {
if (manualOverrides.has(id)) {
if (manualOverrides.get(id)) {
enabled.add(id);
}
} else {
enabled.add(id);
}
});
return enabled;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
unlockRules,
dependencies,
visitedKey,
initialEnabled,
manualOverridesKey,
]);
const [enabledCursors, setEnabledCursors] =
useState<Set<string | number>>(getInitialEnabled);
// 当 visited 或 manualOverrides 内容变化时,自动重新计算
useEffect(() => {
const currentKey = `${visitedKey}|${manualOverridesKey}`;
// 只在 key 真正变化时才重新计算
if (prevKeyRef.current !== currentKey) {
prevKeyRef.current = currentKey;
setEnabledCursors(calculateEnabledCursors());
}
}, [visitedKey, manualOverridesKey, calculateEnabledCursors]);
// 激活 cursor(手动控制,会覆盖自动解锁)
const enable = useCallback((ids: string | number | (string | number)[]) => {
setManualOverrides((prev) => {
const newMap = new Map(prev);
const idsArray = Array.isArray(ids) ? ids : [ids];
idsArray.forEach((id) => {
newMap.set(id, true);
});
return newMap;
});
setEnabledCursors((prev) => {
const newSet = new Set(prev);
const idsArray = Array.isArray(ids) ? ids : [ids];
idsArray.forEach((id) => {
newSet.add(id);
});
return newSet;
});
}, []);
// 禁用 cursor
const disable = useCallback((ids: string | number | (string | number)[]) => {
setManualOverrides((prev) => {
const newMap = new Map(prev);
const idsArray = Array.isArray(ids) ? ids : [ids];
idsArray.forEach((id) => {
newMap.set(id, false);
});
return newMap;
});
setEnabledCursors((prev) => {
const newSet = new Set(prev);
const idsArray = Array.isArray(ids) ? ids : [ids];
idsArray.forEach((id) => {
newSet.delete(id);
});
return newSet;
});
}, []);
// 检查是否启用
const isEnabled = useCallback(
(id: string | number) => {
return enabledCursors.has(id);
},
[enabledCursors]
);
// 获取所有启用的 cursor
const getEnabledCursors = useCallback(() => {
return new Set(enabledCursors);
}, [enabledCursors]);
// 清除手动覆盖,恢复自动解锁
const clearManualOverride = useCallback((id: string | number) => {
setManualOverrides((prev) => {
const newMap = new Map(prev);
newMap.delete(id);
return newMap;
});
}, []);
// 重置状态
const reset = useCallback(() => {
setManualOverrides(new Map());
setEnabledCursors(getInitialEnabled());
}, [getInitialEnabled]);
return {
enable,
disable,
isEnabled,
getEnabledCursors,
clearManualOverride,
reset,
};
};