/* ============================================================ * 新手引导(onboarding-guide.js) * 独立 IIFE,与 app.js / server.js 零耦合: * - 通过 DOM 事件(hashchange)+ 原生 fetch 感知业务状态 * - 引导进度从真实业务状态推导 + localStorage 记录行为标记 * 依赖:window.driver(Driver.js v1,加载于本文件之前) * ============================================================ */ (function () { 'use strict'; // ---------- 常量 ---------- var NS = 'qiwei-onboarding-v1'; var ENTRY_ID = 'onboarding-nav-entry'; var BADGE_ID = 'onboarding-progress-badge'; // ---------- 关卡定义 ---------- // steps[i].element: 选择器字符串 或 返回 Element 的函数 // steps[i].mode === 'panel': 不遮挡页面的悬浮说明卡(用于需要用户自由操作的步骤) var LEVELS = [ { id: 'welcome', title: '欢迎与开始', desc: '了解引导入口与两个关键页面', steps: [ { element: '#' + ENTRY_ID, title: '新手引导入口', description: '这是常驻入口,随时点它可以查看引导进度、继续未完成的步骤。' }, { element: '.nav-item[data-page="status"]', title: '连接与设置', description: '开通订阅、绑定企微账号都在这里完成。' }, { element: '.nav-item[data-page="agent"]', title: '客服工作台', description: '白名单、监听、音色训练都在这个页面,是核心工作区。' } ] }, { id: 'subscribe', title: '开通订阅', desc: '开通企微席位,解锁完整能力', autoWait: true, doneText: '我已完成,继续', steps: [ { page: 'status', element: '#btn-subscribe-action', title: '开通企微席位', description: '点击「开通订阅」按钮,会在新窗口打开 4 步引导流程页(开通订阅 → 扫码 → 验证码 → 完成)。跟着流程页走完后回到这里。' }, { mode: 'panel', element: function () { return document.querySelector('.status-page-layout') || document.querySelector('#content'); }, title: '确认订阅状态', description: '到订阅流程页完成「开通订阅 → 扫码 → 验证码 → 完成」。完成后点下方「我已完成,继续」;状态有约 2 分钟缓存,若未更新稍候再点一次即可。' } ] }, { id: 'account', title: '绑定企微账号', desc: '扫码登录,绑定你的企业微信', autoWait: true, doneText: '我已完成,继续', steps: [ { page: 'status', element: function () { var loginBtn = document.querySelector('#btn-start-login'); var addBtn = document.querySelector('#btn-add-account-page'); if (loginBtn && !addBtn) return loginBtn; // 全新用户:已进入登录页 return addBtn || document.querySelector('#btn-add-account'); }, title: '添加 / 绑定企微账号', description: '点击「添加账号」或「生成登录二维码」,用企业微信 App 扫码。若提示需要订阅,点「打开订阅与登录流程页」即可一并完成。' }, { mode: 'panel', element: function () { return document.querySelector('.login-page') || document.querySelector('.status-page-layout') || document.querySelector('#content'); }, title: '扫码并确认登录', description: '用手机企业微信扫码;出现 6 位验证码时直接在页面输入。完成后点「我已完成,继续」;若引导检测到还未登录,会提示你重新操作或选择其他任务。' } ] }, { id: 'allowlist', title: '配置白名单与监听', desc: '勾选允许 AI 服务的客户', autoWait: true, steps: [ { page: 'agent', element: '[data-agent-action="manage-allowlist"]', title: '打开白名单', description: '点击「白名单」,从企微联系人里勾选至少一位允许 AI 处理的客户。白名单决定 AI 会回复谁的消息,非常重要。' }, { element: '#allowlist-contact-list', title: '勾选联系人', description: '在列表里搜索姓名 / 备注 / 企业,勾选至少一位客户。找不到时可用「手工添加联系人 ID」。', timeout: 6000 }, { element: '#allowlist-save', title: '保存白名单', description: '点击「保存白名单」。保存后 AI 监听会自动保持开启。', timeout: 6000 }, { element: '[data-agent-intake-form]', title: '接入策略', description: '这是「接入策略」设置。默认「仅白名单」最稳妥:只有白名单客户 AI 才会处理。新手先保持默认即可。', timeout: 6000 } ] }, { id: 'activate', title: '启用自动回复', desc: '启动监听并开启高置信自动回复', autoWait: true, doneText: '已启用,继续', steps: [ { page: 'agent', element: '[data-agent-action="activate-onboarding"]', title: '完成设置并启动', description: '点击「完成设置并启动」。系统会启动消息监听,并让白名单私聊按高置信度自动回复;已确认客户群仍只生成待审核草稿。' } ] }, { id: 'voice', title: '音色训练', desc: '让 AI 用你的声音回消息(可选但推荐)', autoWait: true, steps: [ { page: 'agent', mode: 'panel', element: function () { return document.querySelector('.agent-page') || document.querySelector('#content'); }, title: '选择客户', description: '先在左侧会话列表选中任意一个客户(或新建会话)。之后引导会自动打开「初始化声音」弹窗,无需手动点击。' }, { prep: autoOpenVoiceModal, element: '#voice-profile-body', title: '录制你的声音', description: '点「开始录音」录一段 5~30 秒的本人语音(自然读一段话即可),或上传 m4a / wav 音频。', timeout: 9000 }, { element: '[data-voice-save]', title: '保存声音档案', description: '点击「保存」生成你的专属音色。之后 AI 就可以用你的声音合成语音回复客户。', timeout: 6000 } ] } ]; // 链式推荐顺序(适应"全新用户":先绑定,因为订阅卡在登录后才可见) var PRIORITY = ['welcome', 'account', 'subscribe', 'allowlist', 'activate', 'voice']; // ---------- 每页引导注册表 ---------- // 每个页面首次进入自动触发;steps.element 为选择器字符串或函数;元素缺失/超时则跳过该步 var PAGE_GUIDES = { overview: { title: '经营总览', steps: [ { element: '#overview-query', title: '经营诊断', description: '在这里输入经营问题(如「本周客户跟进情况如何」),一键发起 AI 诊断。' }, { element: '.kpi-tile', title: '核心指标卡', description: '客户、社群、客服等核心指标一屏掌握,点击卡片可跳转到对应功能页。' }, { element: '#overview-diagnosis-result', title: '诊断结果', description: '诊断结论、数据缺口和下一步行动都会展示在这里。' } ] }, analytics: { title: '数据看板', steps: [ { element: '.analytics-root', title: '全链路数据看板', description: '汇总 Agent 运行、客户价值、社群、交接与知识资产的只读统计。' }, { element: '.analytics-domain', title: '数据分区', description: '按业务域分区展示,向下滚动可查看每一块的明细图表。' }, { element: '#an-radar', title: '运营健康度', description: '首个图表是运营健康度雷达,帮助快速判断整体状态。' } ] }, agent: { title: '客服工作台', steps: [ { element: '.agent-commandbar', title: '顶部命令条', description: '账号状态、全局模式开关和常用操作都在这里。' }, { element: '.agent-mode-switch', title: '全局模式', description: '切换「待审核 / 高置信自动 / 全自动接管 / 全局暂停」,决定 AI 如何回复。' }, { element: '[data-agent-action="manage-allowlist"]', title: '白名单', description: '管理允许 AI 处理的客户白名单,白名单外客户不会收到 AI 回复。' }, { element: '.agent-conversation-list', title: '会话列表', description: '左侧选择客户会话,右侧进行回复、审核与语音发送。' }, { element: '#agent-reply-editor', title: '回复编辑器', description: '先选中左侧一个私聊会话,在这里编辑并发送回复。', timeout: 6000 }, { element: '[data-agent-action="voice-profile"]', title: '音色设置', description: '选中会话后可在这里录制/切换声音,让 AI 用你的音色发语音。', timeout: 6000 } ] }, knowledge: { title: '知识与任务', steps: [ { element: '.knowledge-tree-panel', title: '知识库目录', description: '左侧是本地知识库目录树,上传业务文档供 AI 回复时参考。' }, { element: '.knowledge-viewer', title: '内容预览区', description: '点击文件可预览;任务中心、会议纪要等也在这里展示。' } ] }, skills: { title: '数字员工能力', steps: [ { element: '#skill-search', title: '搜索技能', description: '按关键词搜索技能包与能力实例。' }, { element: '.skill-package-list', title: '技能包列表', description: '左侧浏览已接入的技能包,右侧查看能力详情。' } ] }, 'customer-ops': { title: '客户运营', steps: [ { element: '#customer-master-search', title: '客户搜索', description: '按姓名/手机号等搜索客户,进入客户主档。' }, { element: '.customer-master-detail', title: '客户详情', description: '画像、跟进任务、预警、房源推荐和时间线都在这里。', timeout: 6000 }, { element: '.optional-batch-ops', title: '批量运营工具', description: '展开后可批量导入客户、加好友、检查好友状态和自动建群。' } ] }, portraits: { title: '画像与标签', steps: [ { element: '#btn-pt-batch', title: '批量生成画像', description: '为指定客户批量生成/更新画像。' }, { element: '#btn-pt-tag', title: '打标签', description: '为客户添加标签,方便筛选与运营。' }, { element: '#pt-filter-keyword', title: '筛选画像', description: '按关键词、来源或标签筛选画像列表。' } ] }, transfers: { title: '客户交接', steps: [ { element: '#btn-new-transfer', title: '新建交接', description: '从旧顾问到新顾问生成交接包预览。' }, { element: '.handover-flow', title: '交接流程', description: '三步流程:生成预览 → 邀请新顾问 → 移除旧顾问并发送欢迎语。' } ] }, 'group-ops': { title: '社群运营', steps: [ { element: '.group-ops-tabs', title: '功能页签', description: '运营总览、今日工作台、群洞察、SOP、质检与自动化的入口。' }, { element: '[data-group-ops-view]', title: '切换视图', description: '点击页签可切换到对应的运营视图。' }, { element: '#group-ops-refresh', title: '刷新数据', description: '任务与质检数据可随时刷新。' } ] }, groups: { title: '客户群', steps: [ { element: '#btn-sync-groups', title: '扫描客户群', description: '从企微扫描群目录,按关键词自动识别客户群。' }, { element: '#sync-scope', title: '扫描范围', description: '选择扫描范围:自己创建的群 / 最近会话 / 全部。' }, { element: '#filter-keyword', title: '筛选群列表', description: '按关键词、状态筛选扫描结果,并人工确认。' } ] }, status: { title: '连接与设置', steps: [ { element: '.status-card', title: '状态总览', description: '账号在线、订阅、鉴权三张状态卡,一眼看清连接情况。' }, { element: '#btn-add-account-page', title: '添加账号', description: '可添加并切换多个企微账号。' }, { element: '.status-accounts-card', title: '已保存账号', description: '管理已保存的账号:切换、删除,离线时可恢复登录。' } ] } }; // ---------- 本地状态 ---------- var local = readLocal(); var running = false; var pageGuideRunning = false; // 每页引导进行中标志(与关卡引导 running 互斥) var explicitPageGuide = null; // 总览里主动点开某页引导(避免与自动触发重复) var tourDriver = null; var activePanel = null; var pendingStepDone = null; // 当前步骤的完成回调(供外部终止解析) var aborted = false; // 用户切页/切号等外部中断标记 var navigating = false; // 引导自身切页标志(区别于用户切页) function readLocal() { try { return JSON.parse(localStorage.getItem(NS) || '{}'); } catch (e) { return {}; } } function setLocal(patch) { try { local = Object.assign({}, local, patch); localStorage.setItem(NS, JSON.stringify(local)); } catch (e) { /* 忽略存储错误 */ } } function markDone(levelId) { var completed = Object.assign({}, local.completed || {}); completed[levelId] = Date.now(); setLocal({ completed: completed }); } // ---------- 基础工具 ---------- function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); } function fetchJson(path) { return fetch(path, { headers: { 'Accept': 'application/json' } }).then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }); } function escapeHtml(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); } function toast(msg) { var t = document.createElement('div'); t.className = 'onboarding-toast'; t.textContent = msg; document.body.appendChild(t); setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 3400); } function resolveTarget(target) { if (typeof target === 'function') return target(); return document.querySelector(target); } function waitForElement(target, timeout, interval) { timeout = timeout || 9000; interval = interval || 250; var node = resolveTarget(target); if (node) return Promise.resolve(node); return new Promise(function (resolve, reject) { var start = Date.now(); var timer = setInterval(function () { var el = resolveTarget(target); if (el) { clearInterval(timer); resolve(el); } else if (Date.now() - start > timeout) { clearInterval(timer); reject(new Error('元素未出现')); } }, interval); }); } function goto(page) { if ((location.hash.slice(1) || 'overview') === page) return Promise.resolve(); navigating = true; location.hash = '#' + page; return sleep(350).then(function () { navigating = false; }); } // ---------- 进度计算 ---------- function computeProgress() { return Promise.all([ fetchJson('/api/status'), fetchJson('/api/agent/status'), fetchJson('/api/agent/voice/status') ]).then(function (results) { var summary = (results[0] && results[0].summary) || {}; var agentData = (results[1] && results[1].data) || {}; var voiceData = (results[2] && results[2].data) || {}; var config = agentData.config || {}; var completed = local.completed || {}; return LEVELS.map(function (L) { var status; if (completed[L.id]) status = 'done'; else if (L.id === 'welcome') status = local.welcomeGuided ? 'done' : 'todo'; else if (L.id === 'subscribe') status = summary.subscribed === true ? 'done' : (summary.subscriptionPending ? 'pending' : 'todo'); else if (L.id === 'account') status = summary.online === true ? 'done' : (summary.loginPending ? 'pending' : 'todo'); else if (L.id === 'allowlist') status = Number(config.allowedSenderCount) > 0 ? 'done' : 'todo'; else if (L.id === 'activate') status = agentData.listener?.running === true && agentData.globalMode === 'auto' ? 'done' : 'todo'; else if (L.id === 'voice') status = voiceData.enrolled === true ? 'done' : 'todo'; else status = 'todo'; return { level: L, status: status }; }); }).catch(function () { // 网络/接口失败:仅凭行为标记推导 var completed = local.completed || {}; return LEVELS.map(function (L) { var status; if (completed[L.id]) status = 'done'; else if (L.id === 'welcome') status = local.welcomeGuided ? 'done' : 'todo'; else status = 'pending'; return { level: L, status: status }; }); }); } function isDone(prog, id) { var p = prog.find(function (x) { return x.level.id === id; }); return !!(p && p.status === 'done'); } function nextTodo(prog, excludeId) { var ordered = PRIORITY.map(function (id) { return prog.find(function (x) { return x.level.id === id; }); }); for (var i = 0; i < ordered.length; i++) { var p = ordered[i]; if (p && p.level.id !== excludeId && p.status === 'todo') { // 订阅关卡在未绑定时先引导绑定(订阅卡登录后才可见) if (p.level.id === 'subscribe') { var acc = prog.find(function (x) { return x.level.id === 'account'; }); if (acc && acc.status !== 'done') continue; } return p.level.id; } } return null; } function waitForLevelDone(levelId, timeout, notified) { if (timeout == null) timeout = 200000; return computeProgress().then(function check(p) { if (aborted) return false; if (isDone(p, levelId)) return true; if (timeout <= 0) return false; if (!notified) { toast('正在等待本步骤完成,请完成页面上的操作…'); notified = true; } return sleep(2500).then(function () { return waitForLevelDone(levelId, timeout - 2500, notified); }); }); } // ---------- Driver.js 封装 ---------- function driverFactory() { var g = window; if (typeof g.driver === 'function') return g.driver; if (g.driver && g.driver.js && typeof g.driver.js.driver === 'function') return g.driver.js.driver; return null; } function createDriver(extra) { var factory = driverFactory(); if (!factory) return null; var cfg = { animate: true, overlayColor: '#1a1a2e', overlayOpacity: 0.72, smoothScroll: true, allowClose: true, overlayClickBehavior: 'close', popoverClass: 'onboarding-driver-popover', stagePadding: 8, nextBtnText: '下一步', prevBtnText: '上一步', doneBtnText: '完成' }; if (extra) Object.assign(cfg, extra); return factory(cfg); } function closeDriver() { if (tourDriver) { try { tourDriver.destroy(); } catch (e) { /* 忽略 */ } tourDriver = null; } } // 外部中断:解析当前步骤为 'close',并提示 function stopGuide(message) { aborted = true; if (pendingStepDone) { var fn = pendingStepDone; pendingStepDone = null; fn('close'); } if (message) toast(message); } // 逐步高亮一个步骤;返回 'next' | 'prev' | 'close' function showDriverStep(el, step, ctx) { return new Promise(function (resolve) { closeDriver(); var settled = false; function done(res) { if (settled) return; settled = true; if (pendingStepDone === done) pendingStepDone = null; closeDriver(); resolve(res); } // 用户点击遮罩空白处 / Esc / 关闭按钮 → driver 销毁 → 统一解析为 'close' var d = createDriver({ onDestroyed: function () { done('close'); } }); if (!d) { resolve('next'); return; } tourDriver = d; pendingStepDone = done; var popover = { title: step.title, description: step.description, showButtons: [], onCloseClick: function () { done('close'); } }; if (ctx.last) { popover.showButtons = ['next']; popover.nextBtnText = ctx.doneText || '完成本步'; popover.onNextClick = function () { done('next'); }; } else { popover.showButtons = ['next']; popover.nextBtnText = '下一步'; popover.onNextClick = function () { done('next'); }; if (!ctx.first) { popover.showButtons.push('previous'); popover.prevBtnText = '上一步'; popover.onPrevClick = function () { done('prev'); }; } } try { d.highlight({ element: el, popover: popover }); } catch (e) { done('close'); } }); } // 不遮挡页面的说明卡;返回 'next' | 'prev' | 'close' function showPanelStep(step, ctx) { return new Promise(function (resolve) { closeDriver(); closePanel(); var settled = false; function done(res) { if (settled) return; settled = true; if (pendingStepDone === done) pendingStepDone = null; closePanel(); resolve(res); } var panel = document.createElement('div'); panel.className = 'onboarding-panel'; panel.innerHTML = '
' + escapeHtml(step.title) + '
' + '
' + escapeHtml(step.description) + '
' + '
' + (ctx.first && ctx.last ? '' : '') + '' + '
'; document.body.appendChild(panel); activePanel = panel; pendingStepDone = done; var prevBtn = panel.querySelector('[data-panel="prev"]'); if (prevBtn) prevBtn.addEventListener('click', function () { done('prev'); }); panel.querySelector('[data-panel="next"]').addEventListener('click', function () { done('next'); }); }); } function closePanel() { if (activePanel) { try { if (activePanel.parentNode) activePanel.parentNode.removeChild(activePanel); } catch (e) { /* 忽略 */ } activePanel = null; } } // 语音训练:若「初始化声音」弹窗未打开,先聚焦按钮再自动点击打开(视觉上有聚焦→弹窗过程) function autoOpenVoiceModal() { var existing = document.querySelector('#voice-profile-body'); if (existing && existing.isConnected) return Promise.resolve(); return waitForElement('[data-agent-action="voice-profile"]', 9000).then(function (btn) { var d = createDriver({ onDestroyed: function () {} }); if (d) { try { d.highlight({ element: btn, popover: { title: '正在为你打开声音设置…', description: '自动聚焦「初始化声音」按钮,稍候会打开声音档案弹窗。', showButtons: [] } }); } catch (e) { /* 忽略高亮失败 */ } } return sleep(1200).then(function () { if (d) { try { d.destroy(); } catch (e) {} } if (btn.isConnected) { try { btn.click(); } catch (e) { /* 忽略 */ } } return sleep(500); }); }).catch(function () { // 按钮不存在(未选中会话等):交给后续步骤的元素等待统一处理 return undefined; }); } // 各关卡未完成时的检测提示文案 var CHECK_MESSAGES = { subscribe: '还没检测到订阅已开通。请确认订阅流程页已走完(「开通订阅 → 扫码 → 验证码 → 完成」)。', account: '还没检测到账号登录完成。请完成扫码登录后重试。', allowlist: '还没检测到白名单已保存。请至少勾选一位联系人并保存。', activate: '还没检测到监听和高置信自动回复已启用。请点击「完成设置并启动」。', voice: '还没检测到声音档案已保存。请完成录音并点击「保存」。' }; // 完成后检测对话框:返回 'done' | 'restart' | 'other' | 'reset' | 'skip' function showStepCheckDialog(level) { return new Promise(function (resolve) { var message = CHECK_MESSAGES[level.id] || '还没检测到这一步已完成,请先完成页面上的操作。'; var ov = createOverlay('onboarding-check-overlay', '' + '
' + '

「' + escapeHtml(level.title) + '」还未完成

' + '

' + escapeHtml(message) + '

' + '

也可以从这里选择其他操作。

' + '
' + '' + '' + '' + '' + '' + '
' + '
', 'onboarding-welcome-overlay'); ov.querySelector('[data-check="check"]').addEventListener('click', function () { computeProgress().then(function (p) { if (isDone(p, level.id)) { closeOverlay(ov); resolve('done'); } else toast('仍未检测到完成,可稍后再检查或选择其他操作'); }); }); ov.querySelector('[data-check="restart"]').addEventListener('click', function () { closeOverlay(ov); resolve('restart'); }); ov.querySelector('[data-check="other"]').addEventListener('click', function () { closeOverlay(ov); resolve('other'); }); ov.querySelector('[data-check="reset"]').addEventListener('click', function () { closeOverlay(ov); resolve('reset'); }); ov.querySelector('[data-check="skip"]').addEventListener('click', function () { closeOverlay(ov); resolve('skip'); }); ov.addEventListener('click', function (e) { if (e.target === ov) { closeOverlay(ov); resolve('other'); } }); }); } // 从头开始:清空引导的行为标记(真实业务进度仍按实际状态推导),重新走欢迎引导 function resetGuideProgress() { setLocal({ welcomeSeen: true, welcomeGuided: false, completed: {} }); } // ---------- 自定义浮层(欢迎层 / 总览 / 重试) ---------- function createOverlay(id, innerHtml, className) { closeOverlayById(id); var ov = document.createElement('div'); ov.id = id; ov.className = className || ''; ov.innerHTML = innerHtml; document.body.appendChild(ov); return ov; } function closeOverlay(ov) { if (ov && ov.parentNode) ov.parentNode.removeChild(ov); } function closeOverlayById(id) { var el = document.getElementById(id); if (el) el.parentNode.removeChild(el); } function closeAllOverlays() { ['onboarding-welcome-overlay', 'onboarding-overview-overlay', 'onboarding-retry-overlay', 'onboarding-check-overlay', 'onboarding-confetti'].forEach(function (id) { closeOverlayById(id); }); } // ---------- 欢迎层 ---------- function showWelcome() { closeAllOverlays(); var levelsHtml = LEVELS.map(function (L, i) { return '
' + (i + 1) + '' + escapeHtml(L.title) + '
'; }).join(''); var ov = createOverlay('onboarding-welcome-overlay', '' + '
' + '
' + '' + '
' + '

欢迎使用企微智能助手

' + '

这是给你的上手指引。跟着步骤一步步完成开通订阅、绑定账号、训练 AI 与熟悉页面,全程无需人工指导。

' + '
' + levelsHtml + '
' + '
' + '' + '' + '
' + '

可随时从左侧「新手引导」入口重新进入。

' + '
', 'onboarding-welcome-overlay'); ov.querySelector('#onboarding-start-btn').addEventListener('click', function () { setLocal({ welcomeSeen: true }); closeOverlay(ov); refreshProgressBadge(); runLevel('welcome', { chain: true }); }); ov.querySelector('#onboarding-skip-btn').addEventListener('click', function () { setLocal({ welcomeSeen: true }); closeOverlay(ov); refreshProgressBadge(); toast('已跳过引导,可随时从左侧「新手引导」进入'); }); ov.addEventListener('click', function (e) { if (e.target === ov) { setLocal({ welcomeSeen: true }); closeOverlay(ov); refreshProgressBadge(); } }); } // ---------- 关卡总览 ---------- function levelStateText(status) { if (status === 'done') return '已完成'; if (status === 'pending') return '检测中…'; return '去完成 →'; } function openOverview() { if (running) { toast('引导进行中,请先完成或关闭当前步骤'); return; } closeDriver(); closePanel(); closeAllOverlays(); computeProgress().then(function (prog) { var done = prog.filter(function (p) { return p.status === 'done'; }).length; var total = prog.length; var firstTodo = prog.find(function (p) { return p.status === 'todo'; }); var items = prog.map(function (p, i) { return '
' + '' + (p.status === 'done' ? '✓' : (i + 1)) + '' + '
' + '
' + escapeHtml(p.level.title) + '
' + '
' + escapeHtml(p.level.desc) + '
' + '
' + '
' + levelStateText(p.status) + '
' + '
'; }).join(''); var pageSeen = local.pageSeen || {}; var pageItems = Object.keys(PAGE_GUIDES).map(function (pg) { var seen = !!pageSeen[pg]; return '
' + '' + (seen ? '✓' : '→') + '' + '
' + '
' + escapeHtml(PAGE_GUIDES[pg].title) + '
' + '
' + (seen ? '已看过,点击可重播' : '点击进入该页开始引导') + '
' + '
' + '
' + (seen ? '重播' : '进入 →') + '
' + '
'; }).join(''); var ov = createOverlay('onboarding-overview-overlay', '' + '
' + '
' + '
' + '
' + '

新手引导

已完成 ' + done + '/' + total + ' 步,跟着步骤一步步上手

' + '
' + '
' + '
' + items + '
' + '
' + '
页面引导进入各页面会自动开始,点击可重播
' + '
' + pageItems + '
' + '
' + '
' + '
' + '' + '' + '
' + '
', 'onboarding-welcome-overlay'); ov.querySelector('#onboarding-ov-close').addEventListener('click', function () { closeOverlay(ov); }); ov.querySelector('#onboarding-ov-start').addEventListener('click', function () { closeOverlay(ov); if (firstTodo) runLevel(firstTodo.level.id, { chain: true }); }); ov.querySelectorAll('.onboarding-level-item[data-run]').forEach(function (item) { item.addEventListener('click', function () { var id = item.getAttribute('data-run'); closeOverlay(ov); if (!running) runLevel(id, { chain: false }); }); }); ov.querySelectorAll('[data-page-guide]').forEach(function (item) { item.addEventListener('click', function () { var page = item.getAttribute('data-page-guide'); closeOverlay(ov); openPageGuideFromOverview(page); }); }); }); } // ---------- 重试 / 跳过对话框 ---------- function showRetryDialog(message) { return new Promise(function (resolve) { closeAllOverlays(); var ov = createOverlay('onboarding-retry-overlay', '' + '
' + '

这一步暂时无法继续

' + '

' + escapeHtml(message || '目标页面元素未出现,可能页面还没加载完,或需要先在页面里选中某项。') + '

' + '
' + '' + '' + '' + '
' + '
', 'onboarding-welcome-overlay'); ov.querySelector('[data-retry="retry"]').addEventListener('click', function () { closeOverlay(ov); resolve('retry'); }); ov.querySelector('[data-retry="skip"]').addEventListener('click', function () { closeOverlay(ov); resolve('skip'); }); ov.querySelector('[data-retry="stop"]').addEventListener('click', function () { closeOverlay(ov); resolve('stop'); }); ov.addEventListener('click', function (e) { if (e.target === ov) { closeOverlay(ov); resolve('stop'); } }); }); } // ---------- 关卡完成庆祝(CSS 撒花) ---------- function celebration() { closeOverlayById('onboarding-confetti'); var wrap = document.createElement('div'); wrap.className = 'onboarding-confetti'; wrap.id = 'onboarding-confetti'; document.body.appendChild(wrap); var colors = ['#fa8c16', '#52c41a', '#1890ff', '#faad14', '#eb2f96', '#722ed1']; for (var i = 0; i < 42; i++) { var p = document.createElement('span'); p.className = 'onboarding-confetti-piece'; p.style.left = (Math.random() * 100) + 'vw'; p.style.background = colors[i % colors.length]; p.style.setProperty('--cf-x', (Math.random() * 180 - 90).toFixed(0) + 'px'); p.style.setProperty('--cf-y', (Math.random() * 220 + 380).toFixed(0) + 'px'); p.style.setProperty('--cf-r', (Math.random() * 720 - 360).toFixed(0) + 'deg'); p.style.animationDelay = (Math.random() * 0.3).toFixed(2) + 's'; wrap.appendChild(p); } setTimeout(function () { closeOverlayById('onboarding-confetti'); }, 2300); toast('恭喜完成 🎉'); } // ---------- 关卡执行 ---------- function currentAccountId() { try { return localStorage.getItem('qiwei-current-account') || ''; } catch (e) { return ''; } } function runLevel(levelId, opts) { opts = opts || {}; if (running) { toast('已有引导进行中,请先完成或关闭'); return; } var level = LEVELS.find(function (l) { return l.id === levelId; }); if (!level) return; closeAllOverlays(); closePanel(); computeProgress().then(function (prog0) { // 订阅关卡在未绑定时先引导绑定(订阅卡登录后才可见) if (levelId === 'subscribe') { var acc0 = prog0.find(function (x) { return x.level.id === 'account'; }); if (acc0 && acc0.status !== 'done') { toast('请先完成「绑定企微账号」,绑定后订阅通常会自动完成'); runLevel('account', { chain: true }); return; } } if (isDone(prog0, levelId)) { markDone(levelId); if (opts.chain) { var n0 = nextTodo(prog0, levelId); if (n0) { runLevel(n0, { chain: true }); return; } } openOverview(); return; } running = true; aborted = false; runLevelSteps(level, { accountKey: currentAccountId() }).then(function (result) { if (result === 'completed') { markDone(levelId); if (level.id === 'welcome') setLocal({ welcomeGuided: true }); celebration(); if (opts.chain) { return computeProgress().then(function (prog3) { var n = nextTodo(prog3, levelId); if (n) { setTimeout(function () { runLevel(n, { chain: true }); }, 900); return; } setTimeout(openOverview, 0); }); } setTimeout(openOverview, 0); } else if (result === 'reset') { // 从头开始:清空行为标记后重新走欢迎引导(延迟到本关 finally 完成后启动) resetGuideProgress(); setTimeout(function () { runLevel('welcome', { chain: true }); }, 400); } else if (result === 'other') { setTimeout(openOverview, 0); } else { refreshProgressBadge(); } }).finally(function () { running = false; closeDriver(); closePanel(); refreshProgressBadge(); }); }); } function runLevelSteps(level, opts) { var idx = 0; var result = 'stopped'; var stop = false; function loop() { if (stop || aborted) return Promise.resolve(result); if (idx >= level.steps.length) { result = 'completed'; return Promise.resolve(result); } var i = idx; var stepDef = level.steps[i]; var isLast = i === level.steps.length - 1; var doneText = level.doneText || '完成本步'; var prep; if (stepDef.mode === 'panel') { prep = Promise.resolve().then(function () { if (stepDef.page) return goto(stepDef.page); }); } else { prep = Promise.resolve().then(function () { if (stepDef.page) return goto(stepDef.page); }).then(function () { if (stepDef.prep) return stepDef.prep(); }).then(function () { return waitForElement(stepDef.element, stepDef.timeout || 9000); }); } return prep.then(function (el) { // 账号切换检测:切号后停止本次引导 if (opts.accountKey && currentAccountId() !== opts.accountKey) { stop = true; toast('检测到账号切换,本次引导已停止'); return loop(); } if (aborted) { stop = true; return loop(); } var show = stepDef.mode === 'panel' ? showPanelStep(stepDef, { first: i === 0, last: isLast, doneText: doneText }) : showDriverStep(el, stepDef, { first: i === 0, last: isLast, doneText: doneText }); return show.then(function (res) { if (aborted) { stop = true; return loop(); } if (res === 'close') { stop = true; return loop(); } if (res === 'prev') { idx = i > 0 ? i - 1 : 0; return loop(); } // next:真实业务关卡,检测上一步是否完成;未完成则重新指引/提供其他出口 if (isLast && level.autoWait) { return waitForLevelDone(level.id, 15000).then(function (ok) { if (aborted) { stop = true; return loop(); } if (ok) { idx = level.steps.length; return loop(); } return showStepCheckDialog(level).then(function (action) { if (aborted) { stop = true; return loop(); } if (action === 'done') { idx = level.steps.length; return loop(); } if (action === 'restart') { idx = 0; return loop(); } if (action === 'reset') { stop = true; result = 'reset'; return loop(); } if (action === 'other') { stop = true; result = 'other'; return loop(); } if (action === 'skip') { idx = level.steps.length; return loop(); } return loop(); }); }); } idx = i + 1; return loop(); }); }).catch(function () { if (aborted) { stop = true; return loop(); } return showRetryDialog().then(function (d) { if (d === 'stop') { stop = true; return loop(); } if (d === 'skip') { idx = i + 1; return loop(); } return loop(); // retry:重跑当前步骤 }); }); } return loop(); } // ---------- 每页引导 ---------- function markPageSeen(page) { var pageSeen = Object.assign({}, local.pageSeen || {}); pageSeen[page] = true; setLocal({ pageSeen: pageSeen }); } // 运行某个页面的引导;元素缺失/超时的步骤自动跳过;全程一屏未展示则不标记已看 function runPageGuide(page) { var guide = PAGE_GUIDES[page]; if (!guide || running || pageGuideRunning) return; var idx = 0; var shown = 0; pageGuideRunning = true; aborted = false; function finish() { pageGuideRunning = false; explicitPageGuide = null; closeDriver(); closePanel(); if (shown > 0) markPageSeen(page); } function next() { if (aborted || running) { finish(); return; } if (idx >= guide.steps.length) { finish(); return; } var i = idx; var step = guide.steps[i]; var isLast = i === guide.steps.length - 1; var promise; if (step.mode === 'panel') { promise = showPanelStep(step, { first: i === 0, last: isLast, doneText: '完成' }); } else { promise = waitForElement(step.element, step.timeout || 5000).then(function (el) { shown++; return showDriverStep(el, step, { first: i === 0, last: isLast, doneText: '完成' }); }); } promise.then(function (res) { if (aborted || running) { finish(); return; } if (res === 'close') { finish(); return; } if (res === 'prev' && i > 0) { idx = i - 1; return next(); } idx = i + 1; next(); }).catch(function () { // 元素未出现:首步失败则中止(页面可能未就绪或需登录,不误标已看);后续步跳过继续 if (i === 0) { finish(); return; } idx = i + 1; next(); }); } next(); } // 用户进入页面后自动触发该页引导(仅首次;已在总览主动打开时跳过以免重复) function maybeAutoPageGuide() { var page = location.hash.slice(1) || 'overview'; if (explicitPageGuide) { if (explicitPageGuide === page) { explicitPageGuide = null; return; } explicitPageGuide = null; } if (running || pageGuideRunning || !local.welcomeSeen) return; if (!PAGE_GUIDES[page]) return; if (local.pageSeen && local.pageSeen[page]) return; runPageGuide(page); } // 从总览主动打开某页引导(重播,无论是否已看过) function openPageGuideFromOverview(page) { if (running || pageGuideRunning) { toast('引导进行中,请先完成或关闭'); return; } closeAllOverlays(); closePanel(); explicitPageGuide = page; if ((location.hash.slice(1) || 'overview') !== page) { location.hash = '#' + page; } setTimeout(function () { runPageGuide(page); }, 600); } // ---------- 侧边栏徽标 ---------- function refreshProgressBadge() { var badge = document.getElementById(BADGE_ID); if (!badge) return; computeProgress().then(function (prog) { var done = prog.filter(function (p) { return p.status === 'done'; }).length; var total = prog.length; badge.textContent = done + '/' + total; badge.hidden = false; badge.classList.toggle('is-done', done >= total); }).catch(function () { /* 失败保持现状 */ }); } // ---------- 启动 ---------- function bindEntry() { var entry = document.getElementById(ENTRY_ID); if (!entry) return; entry.addEventListener('click', function (e) { e.preventDefault(); openOverview(); }); } function boot() { bindEntry(); refreshProgressBadge(); setInterval(refreshProgressBadge, 30000); window.addEventListener('hashchange', function () { closeAllOverlays(); closePanel(); refreshProgressBadge(); // 用户手动切页(非引导自身导航)→ 终止本次关卡引导,避免 running 卡死 if (running && !navigating) { stopGuide('已切换页面,本次引导已停止,可重新开始'); return; } }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } else { boot(); } })();