chore: docs 结构整改 + compose 双目录对齐(合并重建提交)

本提交为 .git 对象库损坏后的重建提交,内容等价于原先三个本地提交
(5e2fd4c2 / 57a53c98 / 5d7e1873)的累积结果,未做任何额外改动。

一、docs 结构整改(整改 #14)
根因:重构时新结构为 untracked 文件,执行 git stash(未带 -u)未纳入,
随后 git reset 拉回 HEAD 旧 tracked 树,导致旧树复活、新旧两棵目录
树并存于 docs/,共 791 文件、双分类体系冲突。

修复动作:
- b2 同名异主题文件改名迁移保全 9 个
- C 类 39 个孤立文件按主题正确归类
- A/B1 类 222 个重复文件删除(新结构已有内容副本)
- 9 个旧独有空目录删除
- 270 处内部引用按 verified 映射改写
- 整改记录 #14 登记于 04-运维文档/部署运维

结果:docs 791 → 569 文件,顶层仅规范 8 类 + 治理文件,单树恢复。
残留:约 20 处指向从未存在文件的陈旧死链,归入独立文档卫生任务。

二、compose 双目录对齐(消除踩坑 A)
- docker-compose.yml:nginx 前端挂载全部由根目录 frontend-*/dist
  改为 src/frontend-*/dist(h5 / agent / admin / terminal)
- docker-compose.dev.yml:dev 服务 build context 与卷同步改 src/
- 效果:本地 docker compose up 不再把根目录 stale dist 挂回,
  与线上一致,分叉隐患消除(已 docker compose config 校验通过)

防复发铁律:
- 重构须提交;仓库修复须 git stash -u 或先 commit
- 新结构须 git add 并提交,避免再次 untracked 复活
- H5 改动只动 src/frontend-h5/,禁改根目录遗留 frontend-*/
This commit is contained in:
Simon
2026-08-07 22:31:32 +08:00
parent 5a77a89ab1
commit facc04aa65
573 changed files with 129347 additions and 909 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

@@ -0,0 +1,174 @@
===== DIAG login-form-missing @ 00:48:26 =====
URL: http://localhost:5174/itdesk/
HTML_LEN: 2595
<!DOCTYPE html><html lang="zh-CN"><head>
<script type="module" src="/itdesk/@vite/client"></script>
<meta charset="UTF-8">
<!-- 移动端视口设置(适配企微 WebView -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<!-- CSP 安全策略 -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' https://res.wx.qq.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; connect-src 'self' https://qyapi.weixin.qq.com wss://* ws://localhost ws://127.0.0.1;">
<!-- 页面标题 -->
<title>智能IT支持服务台</title>
<!-- 首屏骨架屏样式 v0.5.2 强化版 -->
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #f7f8fa; }
#app-skeleton {
position: fixed; inset: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", sans-serif;
color: #969799;
z-index: 9999;
}
#app-skeleton .logo {
width: 64px; height: 64px; border-radius: 16px;
background: linear-gradient(135deg, #1989fa 0%, #1c64f2 100%);
margin-bottom: 20px;
box-shadow: 0 8px 24px rgba(25, 137, 250, 0.3);
animation: pulse 1.5s ease-in-out infinite;
}
#app-skeleton .title { font-size: 18px; font-weight: 600; color: #323233; margin-bottom: 8px; }
#app-skeleton .subtitle { font-size: 14px; color: #969799; margin-bottom: 28px; }
#app-skeleton .spinner {
width: 28px; height: 28px;
border: 3px solid #ebedf0;
border-top-color: #1989fa;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.7; transform: scale(0.95); } }
/* v0.5.2 强化:不再依赖 :empty 选择器(部分浏览器/Vue mount 太快会失效) */
/* 改用 body.app-loaded 类名,由 main.ts 挂载后主动添加 */
body.app-loaded #app-skeleton { display: none !important; }
</style>
</head>
<body>
<!-- Vue 应用挂载点 -->
<div id="app"></div>
<!-- 首屏骨架屏(JS 加载期间显示,挂载后自动隐藏) v0.5.2 -->
<div id="app-skeleton">
<div class="logo"></div>
<div class="title">智能IT支持服务台</div>
<div class="subtitle">正在加载...</div>
<div class="spinner"></div>
</div>
<!-- 入口脚本 -->
<script type="module" src="/itdesk/src/main.ts"></script>
<vite-error-overlay></vite-error-overlay></body></html>
===== DIAG fatal @ 00:48:26 =====
URL: http://localhost:5174/itdesk/
HTML_LEN: 2595
<!DOCTYPE html><html lang="zh-CN"><head>
<script type="module" src="/itdesk/@vite/client"></script>
<meta charset="UTF-8">
<!-- 移动端视口设置(适配企微 WebView -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<!-- CSP 安全策略 -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' https://res.wx.qq.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; connect-src 'self' https://qyapi.weixin.qq.com wss://* ws://localhost ws://127.0.0.1;">
<!-- 页面标题 -->
<title>智能IT支持服务台</title>
<!-- 首屏骨架屏样式 v0.5.2 强化版 -->
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #f7f8fa; }
#app-skeleton {
position: fixed; inset: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", sans-serif;
color: #969799;
z-index: 9999;
}
#app-skeleton .logo {
width: 64px; height: 64px; border-radius: 16px;
background: linear-gradient(135deg, #1989fa 0%, #1c64f2 100%);
margin-bottom: 20px;
box-shadow: 0 8px 24px rgba(25, 137, 250, 0.3);
animation: pulse 1.5s ease-in-out infinite;
}
#app-skeleton .title { font-size: 18px; font-weight: 600; color: #323233; margin-bottom: 8px; }
#app-skeleton .subtitle { font-size: 14px; color: #969799; margin-bottom: 28px; }
#app-skeleton .spinner {
width: 28px; height: 28px;
border: 3px solid #ebedf0;
border-top-color: #1989fa;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.7; transform: scale(0.95); } }
/* v0.5.2 强化:不再依赖 :empty 选择器(部分浏览器/Vue mount 太快会失效) */
/* 改用 body.app-loaded 类名,由 main.ts 挂载后主动添加 */
body.app-loaded #app-skeleton { display: none !important; }
</style>
</head>
<body>
<!-- Vue 应用挂载点 -->
<div id="app"></div>
<!-- 首屏骨架屏(JS 加载期间显示,挂载后自动隐藏) v0.5.2 -->
<div id="app-skeleton">
<div class="logo"></div>
<div class="title">智能IT支持服务台</div>
<div class="subtitle">正在加载...</div>
<div class="spinner"></div>
</div>
<!-- 入口脚本 -->
<script type="module" src="/itdesk/src/main.ts"></script>
<vite-error-overlay></vite-error-overlay></body></html>
===== DIAG fatal @ 00:51:07 =====
URL: http://localhost:5174/itdesk/login
HTML_LEN: 158173
<!DOCTYPE html><html lang="zh-CN" class="van-theme-light" data-theme="light"><head>
<script type="module" src="/itdesk/@vite/client"></script>
<meta charset="UTF-8">
<!-- 移动端视口设置(适配企微 WebView -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<!-- CSP 安全策略 -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' https://res.wx.qq.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; connect-src 'self' https://qyapi.weixin.qq.com wss://* ws://localhost ws://127.0.0.1;">
<!-- 页面标题 -->
<title>智能IT支持服务台</title>
<!-- 首屏骨架屏样式 v0.5.2 强化版 -->
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #f7f8fa; }
#app-skeleton {
position: fixed; inset: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", sans-serif;
color: #969799;
z-index: 9999;
}
#app-skeleton .logo {
width: 64px; height: 64px; border-radius: 16px;
background: linear-gradient(135deg, #1989fa 0%, #1c64f2 100%);
margin-bottom: 20px;
box-shadow: 0 8px 24px rgba(25, 137, 250, 0.3);
animation: pulse 1.5s ease-in-out infinite;
}
#app-skeleton .title { font-size: 18px; font-weight: 600; color: #323233; margin-bottom: 8px; }
#app-skeleton .subtitle { font-size: 14px; color: #969799; margin-bottom: 28px; }
#app-skeleton .spinner {
width: 28px; height: 28px;
border: 3px solid #ebedf0;
border-top-color: #1989fa;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.7; transform: scale(0.95); } }
/* v0.5.2 强化:不再依赖 :empty 选择器(部分浏览器/Vue mount 太快会失效) */
/* 改用 body.app-loaded 类名,由 main.ts 挂载后主动添加 */
body.app-loaded #app-skeleton { display: none !important; }
</style>
<style type="text/css" data-vite-dev-id="/app/node_modules/.pnpm/vant@4.10.0_vue@3.5.39_typescript@5.9.3_/node_modules/vant/es/style/base.css">:root,:host{--van-black: #000;--van-white: #fff;--van-gray-1: #f7f8fa;--van-gray-2: #f2f3f5;--van-gray-3: #ebedf0;--van-gray-4: #dcdee0;--van-gray-5: #c8c9cc;--van-gray-6: #969799;--van-gray-7: #646566;--van-gray-8: #323233;--van-red: #ee0a24;--van-blue: #1989fa;--van-orange: #ff976a;--van-orange-dark: #ed6a0c;--van-orange-light: #fffbe8;--van-green: #07c160;--van-gradient-red: linear-gradient(to right, #ff6034, #ee0a24);--van-gradient-orange: linear-gradient(to right, #ffd01e, #ff8917);--van-primary-color: var(--van-blue);--van-success-color: var(--van-green);--van-danger-color: var(--van-red);--van-warning-color: var(--van-orange);--van-text-col
@@ -0,0 +1,66 @@
{
"employee_id": "E2E_BROWSER",
"ws_open_after_login": 2,
"send_click_s": 1.257,
"send_ui_instant": true,
"ai_first_render_s": 2.27,
"final_text_len": 1469,
"final_has_duckula": true,
"final_has_real_content": true,
"len_samples": [
880,
880,
880,
880,
880,
880,
880,
880,
880,
880,
880,
880,
1469,
1469,
1469,
1469,
1469,
1469,
1469,
1469,
1469,
1469,
1469,
1469,
1469,
1469,
1469,
1469,
1469,
1469,
1469,
1469,
1469,
1469,
1469,
1469,
1469,
1469,
1469,
1469,
1469,
1469,
1469,
1469,
1469
],
"len_grew": true,
"ws_open_total": 2,
"ws_ai_chunk_frames": 343,
"typewriter_proven": true,
"console_errors": [
"Loading the font 'data:font/woff2;charset=utf-8;base64,d09GMgABAAAAAGNAAA0AAAAA6ngAAGLlAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP0ZGVE0cGh4GYACCWhEICoOqHILKFAuEDgABNgIkA4QUBCAFhQ4HllAbe7dFB2rYOIAxOG/nKOrEpKWbGbVlVHRZ9v816Tis0RbhPC4JZQk1ws72WlBGJJIsL3bc5Y/x5HdtBrzwoZQX/Ls/uAhsXMZIVk73Ds/ntvd3cezvhO1/2HExro3B2ID/4d7GxjXObZwqxy0gG8pQPDBFMAW980hTNIuhpqZleVwJlHSKR6WkDM3KECuz083Qu+8BCnabd+4tsemRGtBnHBAHxuTmuLWNUbd7fuSZA88fOBlzekCqfDPnV1BArpTKjp/r0AfE0+Lc97SXNa3ugaSqW2AfIo5Ghr2YAos8H+krfQ3L8DwA+F1V4Mecr9JV2ljSM/wUzQWAITlxoJSfEmfsi321rwHH2T...LQReO8YZ6mjq2e6QIdzdGLzR8ziEWSLVQd2NoiYGkRGaMXLCt0oq7fOWoCTc11KgkMttjj0jFJ6zIc7BafifCk0pZOhSbraAopcEpG4/kOwyNmF39Mt5ppTW7jRoB8WgPkOc51rC8oKZl62iKAamlu4DmuBkvCcCtjwcMLAlVzR32t10CwTyM6AbagGllb48gU5ACDzUmBCyJqxlxs8U4XAyTcTtjWMNGdH9GuwTBmjyI3YJFEgrovcQDR5kFZ0NMkmgTDZclc183uTcoBDNAaimIE2Cwu9OXklBGRkNvItMU7zXTlX5Zz/V8YIO2iK//xMnpu22WBNXRyG9vGQuJ0mGsc26/Aag5YQelZZs5Bi2ubsxHV/DzGXRt3P3KTOvQ4HG8aKpm9OyoHnIgzG/GkIEerHqEBaYCQ8UJPWbg8/vNFDyFvqkhIe6O6l0WordG9pHAAyyTWbhCGHCtBD7vDjzljqRT4+8E2POgLpACHDw+E5jqrtyanHw0AAA==' violates the following Content Security Policy directive: \"default-src 'self'\". Note that 'font-src' was not explicitly set, so 'default-src' is used as a fallback. The action has been blocked.",
"Loading the font 'http://at.alicdn.com/t/c/font_2553510_ciljc7axaw7.woff?t=1705587463221' violates the following Content Security Policy directive: \"default-src 'self'\". Note that 'font-src' was not explicitly set, so 'default-src' is used as a fallback. The action has been blocked.",
"Failed to load resource: the server responded with a status of 404 (Not Found)"
]
}
@@ -0,0 +1,456 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>打字机效果演示 — IT智能服务台</title>
<style>
/* ================================================================
整体布局 — 模拟手机端 H5 聊天界面
================================================================ */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f0f2f5;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
/* 手机框 */
.phone-frame {
width: 420px;
max-width: 100%;
height: 700px;
background: #ededed;
border-radius: 24px;
box-shadow: 0 8px 40px rgba(0,0,0,0.15);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 顶栏 */
.chat-header {
background: #fff;
padding: 12px 16px;
border-bottom: 1px solid #e0e0e0;
display: flex;
align-items: center;
gap: 10px;
}
.chat-header .avatar {
width: 36px; height: 36px;
background: #07C160;
border-radius: 6px;
display: flex; align-items: center; justify-content: center;
color: #fff; font-weight: bold; font-size: 14px;
}
.chat-header .title { font-size: 15px; font-weight: 600; }
.chat-header .subtitle { font-size: 12px; color: #999; }
/* 消息区 */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
/* 滚动条美化 */
.chat-messages::-webkit-scrollbar { width: 4px; }
.chat-messages::-webkit-scrollbar-thumb { background: #ccc; border-radius: 2px; }
/* 消息行 */
.msg-row { display: flex; flex-direction: column; }
.msg-row.employee { align-items: flex-end; }
.msg-row.ai { align-items: flex-start; }
.msg-row.system { align-items: center; }
/* 发送者名称 */
.msg-sender {
font-size: 11px;
color: #999;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 4px;
}
.msg-sender .ai-avatar {
width: 20px; height: 20px;
border-radius: 4px;
}
/* 消息气泡 */
.msg-bubble {
max-width: 80%;
padding: 10px 14px;
border-radius: 8px;
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
position: relative;
}
/* 员工气泡 — 蓝底白字靠右 */
.msg-row.employee .msg-bubble {
background: #1989fa;
color: #fff;
border-top-right-radius: 2px;
}
/* AI 气泡 — 绿底白字靠左 */
.msg-row.ai .msg-bubble {
background: #07C160;
color: #fff;
border-top-left-radius: 2px;
}
/* 系统消息 */
.msg-row.system .msg-bubble {
background: transparent;
color: #999;
font-size: 12px;
padding: 4px 8px;
}
/* ================================================================
打字机光标 — 闪烁动画
================================================================ */
.typewriter-cursor {
display: inline;
color: rgba(255,255,255,0.8);
font-weight: 400;
animation: cursor-blink 1s step-end infinite;
}
@keyframes cursor-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* 控制面板 */
.control-panel {
background: #fff;
border-top: 1px solid #e0e0e0;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.control-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
font-weight: 500;
}
.btn-primary { background: #1989fa; color: #fff; }
.btn-primary:hover { background: #1676d9; }
.btn-success { background: #07C160; color: #fff; }
.btn-success:hover { background: #06ad56; }
.btn-outline { background: #fff; color: #666; border: 1px solid #ddd; }
.btn-outline:hover { background: #f5f5f5; }
.btn-warning { background: #ff976a; color: #fff; }
.btn-warning:hover { background: #f08050; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.control-info {
font-size: 12px;
color: #999;
line-height: 1.6;
}
/* 高亮标记 */
.highlight-box {
background: #fffbe6;
border: 1px solid #ffe58f;
border-radius: 8px;
padding: 10px 14px;
font-size: 12px;
color: #8c6d00;
line-height: 1.6;
}
</style>
</head>
<body>
<div class="phone-frame">
<!-- 顶栏 -->
<div class="chat-header">
<div class="avatar">IT</div>
<div>
<div class="title">IT智能服务台</div>
<div class="subtitle">Duckula(达寇拉)在线</div>
</div>
</div>
<!-- 消息列表 -->
<div class="chat-messages" id="chatMessages">
<!-- 消息由 JS 动态插入 -->
</div>
<!-- 控制面板 -->
<div class="control-panel">
<div class="highlight-box" id="statusBox">
👆 上方聊天框展示了打字机效果在实际界面中的表现<br>
点击下方按钮查看不同场景:
</div>
<div class="control-buttons">
<button class="btn btn-success" id="btnDemo" onclick="runDemo()">
▶ 演示完整对话
</button>
<button class="btn btn-outline" id="btnSkip" onclick="skipAll()" disabled>
⏩ 跳过动画
</button>
<button class="btn btn-outline" id="btnReset" onclick="resetChat()">
🔄 重置
</button>
<button class="btn btn-warning" id="btnNoEffect" onclick="showNoEffect()">
❌ 无打字机(对比)
</button>
</div>
<div class="control-info" id="controlInfo">
当前:等待开始...
</div>
</div>
</div>
<script>
// ================================================================
// 打字机核心逻辑(与 useTypewriter.ts 完全一致的 JS 实现)
// ================================================================
class Typewriter {
constructor(speed = 25, speedUpThreshold = 300, speedUpBatch = 3) {
this.speed = speed;
this.speedUpThreshold = speedUpThreshold;
this.speedUpBatch = speedUpBatch;
}
/**
* 对目标元素执行打字机动画
* @param {HTMLElement} el 显示文本的元素
* @param {HTMLElement} cursorEl 光标元素
* @param {string} fullText 完整文本
* @returns {Promise} 动画完成时 resolve
*/
typeTo(el, cursorEl, fullText) {
return new Promise((resolve) => {
let index = 0;
el.textContent = '';
const typeNext = () => {
if (index >= fullText.length) {
// 完成:隐藏光标
if (cursorEl) cursorEl.style.display = 'none';
resolve();
return;
}
// 自适应速度:前 300 字逐字,超出后批量
const batchSize = index < this.speedUpThreshold ? 1 : this.speedUpBatch;
const end = Math.min(index + batchSize, fullText.length);
el.textContent += fullText.slice(index, end);
index = end;
this._timer = setTimeout(typeNext, this.speed);
};
// 初始微延迟让气泡先渲染
this._timer = setTimeout(typeNext, 80);
});
}
/** 立即完成 */
finish(el, cursorEl, fullText) {
clearTimeout(this._timer);
el.textContent = fullText;
if (cursorEl) cursorEl.style.display = 'none';
}
}
// ================================================================
// 全局状态
// ================================================================
const chatEl = document.getElementById('chatMessages');
const statusBox = document.getElementById('statusBox');
const controlInfo = document.getElementById('controlInfo');
const btnDemo = document.getElementById('btnDemo');
const btnSkip = document.getElementById('btnSkip');
const btnReset = document.getElementById('btnReset');
const btnNoEffect = document.getElementById('btnNoEffect');
const tw = new Typewriter();
let activeTypewriters = []; // 追踪所有活跃的打字机,用于 skipAll
// ================================================================
// 消息创建
// ================================================================
function createMessage(type, senderName, text, withTypewriter = true) {
const row = document.createElement('div');
row.className = `msg-row ${type}`;
// 发送者名称
if (type === 'ai' || type === 'employee') {
const sender = document.createElement('div');
sender.className = 'msg-sender';
if (type === 'ai') {
sender.innerHTML = `🦆 <span>${senderName}</span>`;
} else {
sender.textContent = senderName;
}
row.appendChild(sender);
}
// 消息气泡
const bubble = document.createElement('div');
bubble.className = 'msg-bubble';
if (type === 'system') {
bubble.textContent = text;
} else {
// 文本内容 + 光标 span
const textSpan = document.createElement('span');
textSpan.className = 'typewriter-text';
bubble.appendChild(textSpan);
const cursorSpan = document.createElement('span');
cursorSpan.className = 'typewriter-cursor';
cursorSpan.textContent = '|';
cursorSpan.style.display = 'none';
bubble.appendChild(cursorSpan);
// 打字机动画
if (withTypewriter && type === 'ai') {
cursorSpan.style.display = 'inline';
const promise = tw.typeTo(textSpan, cursorSpan, text);
activeTypewriters.push({ bubble, textSpan, cursorSpan, text, promise });
promise.then(() => {
activeTypewriters = activeTypewriters.filter(t => t.textSpan !== textSpan);
updateSkipButton();
});
} else {
// 无打字机:直接显示全部
textSpan.textContent = text;
}
}
row.appendChild(bubble);
chatEl.appendChild(row);
// 滚动到底部
chatEl.scrollTop = chatEl.scrollHeight;
updateSkipButton();
return row;
}
function updateSkipButton() {
btnSkip.disabled = activeTypewriters.length === 0;
}
function skipAll() {
activeTypewriters.forEach(t => {
tw.finish(t.textSpan, t.cursorSpan, t.text);
});
activeTypewriters = [];
updateSkipButton();
}
// ================================================================
// 演示场景
// ================================================================
async function runDemo() {
btnDemo.disabled = true;
btnNoEffect.disabled = true;
resetChat();
// 场景 1:员工发消息(无打字机效果 — 直接显示)
statusBox.innerHTML = '📌 <b>场景 1</b>:员工消息直接显示(无打字机效果)<br>员工消息靠右蓝底白字,一次性完整渲染';
controlInfo.textContent = '场景 1/4:员工发消息 → 直接显示';
createMessage('employee', '我(宋献)', '打印机连不上了,帮忙看看');
await sleep(800);
// 场景 2:AI 短回复(打字机效果明显)
statusBox.innerHTML = '📌 <b>场景 2</b>AI 短回复 — 打字机逐字显示<br>注意看 👈 绿色气泡中的文字一个一个字地"打"出来,<br>末尾有闪烁的 <span style="color:#07C160">|</span> 光标';
controlInfo.textContent = '场景 2/4AI 回复中...(逐字打字)';
createMessage('ai', 'Duckula(达寇拉)', '您好!打印机连接问题可能有几种原因:\n\n1. 检查打印机电源是否开启\n2. 检查 USB 或网络连接\n3. 尝试重启打印服务\n\n请先确认打印机电源灯是否亮起?');
await sleep(5000);
// 场景 3:员工回复
statusBox.innerHTML = '📌 <b>场景 3</b>:员工继续对话(正常显示)';
controlInfo.textContent = '场景 3/4:员工回复 → 直接显示';
createMessage('employee', '我(宋献)', '电源灯是亮的,但电脑显示离线');
await sleep(800);
// 场景 4:AI 长回复(超过 300 字会加速)
statusBox.innerHTML = '📌 <b>场景 4</b>AI 长回复 — 前 300 字逐字,之后加速<br>长消息会自动提速,避免等太久<br>按 <b>"⏩ 跳过动画"</b> 可以立即显示全部内容';
controlInfo.textContent = '场景 4/4AI 长回复中...(前300字逐字,之后批量加速)';
createMessage('ai', 'Duckula(达寇拉)',
'好的,电源灯亮说明打印机本身通电正常。电脑显示"离线"通常是因为以下原因:\n\n' +
'🔌 连接问题:如果是有线打印机,检查 USB 线是否插紧,尝试换个 USB 口。无线打印机需要确认电脑和打印机连接的是同一个 WiFi。\n\n' +
'🖨️ 驱动问题:打印机驱动程序可能损坏或过时。建议访问打印机品牌官网下载最新驱动重新安装。\n\n' +
'⚙️ 打印服务:按 Win+R 输入 services.msc,找到 Print Spooler 服务,右键"重新启动"。\n\n' +
'📋 打印队列:打开"设置 → 蓝牙和其他设备 → 打印机和扫描仪",选中你的打印机,点"打开打印队列",取消所有挂起的文档。\n\n' +
'按以上顺序排查,80% 的情况都能解决。需要我进一步指导哪一步吗?'
);
btnDemo.disabled = false;
btnNoEffect.disabled = false;
}
// ================================================================
// 对比模式:无打字机效果
// ================================================================
function showNoEffect() {
btnDemo.disabled = true;
btnNoEffect.disabled = true;
resetChat();
statusBox.innerHTML = '📌 <b>对比模式</b>AI 消息一次性完整渲染(<b>没有</b>打字机效果)<br>文字瞬间全部出现 — 这就是现在的行为';
controlInfo.textContent = '对比:无打字机 → 文字瞬间全部出现';
createMessage('employee', '我(宋献)', '打印机连不上了,帮忙看看');
// withTypewriter = false → 直接显示全部
createMessage('ai', 'Duckula(达寇拉)',
'您好!打印机连接问题可能有几种原因:\n\n1. 检查打印机电源是否开启\n2. 检查 USB 或网络连接\n3. 尝试重启打印服务\n\n请先确认打印机电源灯是否亮起?',
false // ← 关键参数:不启用打字机
);
btnDemo.disabled = false;
btnNoEffect.disabled = false;
}
function resetChat() {
// 清理活跃打字机
activeTypewriters.forEach(t => tw.finish(t.textSpan, t.cursorSpan, t.text));
activeTypewriters = [];
chatEl.innerHTML = '';
statusBox.innerHTML = '👆 上方聊天框展示了打字机效果在实际界面中的表现<br>点击下方按钮查看不同场景:';
controlInfo.textContent = '当前:等待开始...';
btnDemo.disabled = false;
btnNoEffect.disabled = false;
updateSkipButton();
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// 初始化
updateSkipButton();
</script>
</body>
</html>