|
|
@@ -0,0 +1,1043 @@
|
|
|
+/* ============================================================
|
|
|
+ * 新手引导(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: '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', '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 === '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: '还没检测到白名单已保存。请至少勾选一位联系人并保存。',
|
|
|
+ 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();
|
|
|
+ if (!local.welcomeSeen) {
|
|
|
+ setTimeout(showWelcome, 600);
|
|
|
+ } else {
|
|
|
+ // 已处理欢迎层:对初始页面按需自动触发每页引导
|
|
|
+ setTimeout(maybeAutoPageGuide, 800);
|
|
|
+ }
|
|
|
+ setInterval(refreshProgressBadge, 30000);
|
|
|
+ window.addEventListener('hashchange', function () {
|
|
|
+ closeAllOverlays();
|
|
|
+ closePanel();
|
|
|
+ refreshProgressBadge();
|
|
|
+ // 用户手动切页(非引导自身导航)→ 终止本次关卡引导,避免 running 卡死
|
|
|
+ if (running && !navigating) { stopGuide('已切换页面,本次引导已停止,可重新开始'); return; }
|
|
|
+ // 用户切页 → 400ms 后按需自动触发该页引导
|
|
|
+ if (!navigating) setTimeout(maybeAutoPageGuide, 400);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ if (document.readyState === 'loading') {
|
|
|
+ document.addEventListener('DOMContentLoaded', boot);
|
|
|
+ } else {
|
|
|
+ boot();
|
|
|
+ }
|
|
|
+})();
|