docs: 补充 AI 打字机输出、小程序灵感卡片、脚本主页布局等设计文档和计划

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 18:39:26 +08:00
parent 886f04046b
commit 6e5a379bef
12 changed files with 722 additions and 0 deletions
@@ -0,0 +1,131 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
export const useTypewriterStream = ({ interval = 18, step = 1 } = {}) => {
const [visibleText, setVisibleText] = useState('');
const [targetText, setTargetText] = useState('');
const [received, setReceived] = useState(false);
const [backendDone, setBackendDone] = useState(false);
const [failed, setFailed] = useState(false);
const visibleRef = useRef('');
const targetRef = useRef('');
const doneRef = useRef(false);
const failedRef = useRef(false);
const timerRef = useRef(null);
const waitersRef = useRef([]);
const stopTimer = useCallback(() => {
if (timerRef.current) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
const resolveWaiters = useCallback(() => {
if (!waitersRef.current.length) return;
const waiters = waitersRef.current;
waitersRef.current = [];
waiters.forEach(resolve => resolve(visibleRef.current));
}, []);
const isFullyRendered = useCallback(() => {
return doneRef.current && visibleRef.current.length >= targetRef.current.length;
}, []);
const tick = useCallback(() => {
if (visibleRef.current.length < targetRef.current.length) {
const nextLength = Math.min(targetRef.current.length, visibleRef.current.length + step);
const nextText = targetRef.current.slice(0, nextLength);
visibleRef.current = nextText;
setVisibleText(nextText);
return;
}
if (doneRef.current || failedRef.current) {
stopTimer();
if (isFullyRendered() || failedRef.current) resolveWaiters();
}
}, [isFullyRendered, resolveWaiters, step, stopTimer]);
const ensureTimer = useCallback(() => {
if (!timerRef.current) timerRef.current = window.setInterval(tick, interval);
}, [interval, tick]);
const reset = useCallback(() => {
stopTimer();
visibleRef.current = '';
targetRef.current = '';
doneRef.current = false;
failedRef.current = false;
setVisibleText('');
setTargetText('');
setReceived(false);
setBackendDone(false);
setFailed(false);
resolveWaiters();
}, [resolveWaiters, stopTimer]);
const push = useCallback((nextText = '') => {
const next = String(nextText || '');
if (next.length < visibleRef.current.length) reset();
targetRef.current = next;
setTargetText(next);
setReceived(true);
ensureTimer();
}, [ensureTimer, reset]);
const finish = useCallback((finalText) => {
if (typeof finalText === 'string') {
targetRef.current = finalText;
setTargetText(finalText);
}
doneRef.current = true;
setBackendDone(true);
if (targetRef.current.length > visibleRef.current.length) ensureTimer();
else {
stopTimer();
resolveWaiters();
}
}, [ensureTimer, resolveWaiters, stopTimer]);
const fail = useCallback((message) => {
failedRef.current = true;
doneRef.current = true;
setFailed(true);
setBackendDone(true);
if (!visibleRef.current && message) {
visibleRef.current = message;
targetRef.current = message;
setVisibleText(message);
setTargetText(message);
}
stopTimer();
resolveWaiters();
}, [resolveWaiters, stopTimer]);
const waitForDone = useCallback(() => {
if (isFullyRendered() || failedRef.current) {
return Promise.resolve(visibleRef.current);
}
return new Promise(resolve => {
waitersRef.current.push(resolve);
ensureTimer();
});
}, [ensureTimer, isFullyRendered]);
useEffect(() => stopTimer, [stopTimer]);
return useMemo(() => ({
visibleText,
targetText,
isWaiting: !received && !backendDone && !failed,
isStreaming: received && (visibleText.length < targetText.length || !backendDone),
isDraining: backendDone && visibleText.length < targetText.length,
isDone: backendDone && visibleText.length >= targetText.length && !failed,
reset,
push,
finish,
waitForDone,
fail
}), [backendDone, failed, finish, push, received, reset, targetText, visibleText, waitForDone, fail]);
};
export default useTypewriterStream;