/* ============================================================
* 新手引导(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();
}
})();