| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052 |
- /* ============================================================
- * 新手引导(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 =
- '<div class="onboarding-panel-title">' + escapeHtml(step.title) + '</div>' +
- '<div class="onboarding-panel-desc">' + escapeHtml(step.description) + '</div>' +
- '<div class="onboarding-panel-actions">' +
- (ctx.first && ctx.last ? '' : '<button type="button" class="btn btn-secondary" data-panel="prev">上一步</button>') +
- '<button type="button" class="btn" data-panel="next">' + escapeHtml(ctx.doneText || '下一步') + '</button>' +
- '</div>';
- 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', '' +
- '<div class="onboarding-welcome-card" style="max-width:440px;text-align:left">' +
- '<h2 style="font-size:18px;margin:0 0 8px">「' + escapeHtml(level.title) + '」还未完成</h2>' +
- '<p style="color:var(--text-secondary);font-size:13px;line-height:1.7;margin:0 0 6px">' + escapeHtml(message) + '</p>' +
- '<p style="color:var(--text-muted);font-size:12px;margin:0 0 18px">也可以从这里选择其他操作。</p>' +
- '<div class="onboarding-check-actions">' +
- '<button type="button" class="btn" data-check="check">我已完成,再检查</button>' +
- '<button type="button" class="btn btn-secondary" data-check="restart">重新指引</button>' +
- '<button type="button" class="btn btn-secondary" data-check="other">其他任务</button>' +
- '<button type="button" class="btn btn-secondary" data-check="reset">从头开始</button>' +
- '<button type="button" class="btn btn-secondary" data-check="skip">跳过本步</button>' +
- '</div>' +
- '</div>', '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 '<div class="onboarding-welcome-level"><span class="onboarding-wl-num">' + (i + 1) + '</span><span>' + escapeHtml(L.title) + '</span></div>';
- }).join('');
- var ov = createOverlay('onboarding-welcome-overlay', '' +
- '<div class="onboarding-welcome-card">' +
- '<div class="onboarding-welcome-flag">' +
- '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M7 2h9l1 3h2a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h2l1-3zm1.5 2l-.7 2h8.4l-.7-2h-7zm.5 8.5h6v-1.5H9v1.5zm0 4h4v-1.5H9v1.5z"/></svg>' +
- '</div>' +
- '<h2>欢迎使用企微智能助手</h2>' +
- '<p class="onboarding-welcome-sub">这是给你的上手指引。跟着步骤一步步完成开通订阅、绑定账号、训练 AI 与熟悉页面,全程无需人工指导。</p>' +
- '<div class="onboarding-welcome-levels">' + levelsHtml + '</div>' +
- '<div class="onboarding-welcome-actions">' +
- '<button type="button" class="btn" id="onboarding-start-btn">开始引导</button>' +
- '<button type="button" class="btn btn-secondary" id="onboarding-skip-btn">跳过引导</button>' +
- '</div>' +
- '<p class="onboarding-welcome-tip">可随时从左侧「新手引导」入口重新进入。</p>' +
- '</div>', '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 '<span style="color:var(--success);font-size:12px">已完成</span>';
- if (status === 'pending') return '<span style="color:var(--text-muted);font-size:12px">检测中…</span>';
- return '<span style="color:var(--brand);font-size:12px">去完成 →</span>';
- }
- 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 '<div class="onboarding-level-item is-' + p.status + '" data-run="' + p.level.id + '">' +
- '<span class="onboarding-level-state">' + (p.status === 'done' ? '✓' : (i + 1)) + '</span>' +
- '<div class="onboarding-level-main">' +
- '<div class="onboarding-level-title">' + escapeHtml(p.level.title) + '</div>' +
- '<div class="onboarding-level-desc">' + escapeHtml(p.level.desc) + '</div>' +
- '</div>' +
- '<div class="onboarding-level-cta">' + levelStateText(p.status) + '</div>' +
- '</div>';
- }).join('');
- var pageSeen = local.pageSeen || {};
- var pageItems = Object.keys(PAGE_GUIDES).map(function (pg) {
- var seen = !!pageSeen[pg];
- return '<div class="onboarding-level-item onboarding-page-guide-item ' + (seen ? 'is-done' : 'is-todo') + '" data-page-guide="' + pg + '">' +
- '<span class="onboarding-level-state">' + (seen ? '✓' : '→') + '</span>' +
- '<div class="onboarding-level-main">' +
- '<div class="onboarding-level-title">' + escapeHtml(PAGE_GUIDES[pg].title) + '</div>' +
- '<div class="onboarding-level-desc">' + (seen ? '已看过,点击可重播' : '点击进入该页开始引导') + '</div>' +
- '</div>' +
- '<div class="onboarding-level-cta"><span style="color:' + (seen ? 'var(--success)' : 'var(--brand)') + ';font-size:12px">' + (seen ? '重播' : '进入 →') + '</span></div>' +
- '</div>';
- }).join('');
- var ov = createOverlay('onboarding-overview-overlay', '' +
- '<div class="onboarding-welcome-card onboarding-overview-card" style="max-width:620px;text-align:left">' +
- '<div class="onboarding-overview-body">' +
- '<div class="onboarding-overview-head">' +
- '<div class="onboarding-ov-flag"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M7 2h9l1 3h2a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h2l1-3zm1.5 2l-.7 2h8.4l-.7-2h-7zm.5 8.5h6v-1.5H9v1.5zm0 4h4v-1.5H9v1.5z"/></svg></div>' +
- '<div><h3>新手引导</h3><p>已完成 ' + done + '/' + total + ' 步,跟着步骤一步步上手</p></div>' +
- '</div>' +
- '<div class="onboarding-progress-track"><div class="onboarding-progress-fill" style="width:' + (total ? (done / total * 100).toFixed(1) : 0) + '%"></div></div>' +
- '<div class="onboarding-level-list">' + items + '</div>' +
- '<div class="onboarding-page-guides">' +
- '<div class="onboarding-page-guides-head">页面引导<small>进入各页面会自动开始,点击可重播</small></div>' +
- '<div class="onboarding-level-list onboarding-page-guides-list">' + pageItems + '</div>' +
- '</div>' +
- '</div>' +
- '<div class="onboarding-overview-foot">' +
- '<button type="button" class="btn btn-secondary" id="onboarding-ov-close">关闭</button>' +
- '<button type="button" class="btn" id="onboarding-ov-start" ' + (firstTodo ? '' : 'disabled') + '>' + (firstTodo ? '继续引导' : '全部完成 🎉') + '</button>' +
- '</div>' +
- '</div>', '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', '' +
- '<div class="onboarding-welcome-card" style="max-width:420px;text-align:left">' +
- '<h2 style="font-size:18px;margin:0 0 10px">这一步暂时无法继续</h2>' +
- '<p style="color:var(--text-secondary);font-size:13px;line-height:1.7;margin:0 0 18px">' + escapeHtml(message || '目标页面元素未出现,可能页面还没加载完,或需要先在页面里选中某项。') + '</p>' +
- '<div class="onboarding-welcome-actions" style="flex-direction:column;align-items:stretch">' +
- '<button type="button" class="btn" data-retry="retry">重试</button>' +
- '<button type="button" class="btn btn-secondary" data-retry="skip">跳过此步</button>' +
- '<button type="button" class="btn btn-secondary" data-retry="stop">停止引导</button>' +
- '</div>' +
- '</div>', '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();
- }
- })();
|