feat: complete guided tutorial with mobile flow and ui polish

This commit is contained in:
JOJO 2026-04-07 17:03:37 +08:00
parent e804ef52d2
commit 692a12affd
11 changed files with 1074 additions and 28 deletions

View File

@ -124,6 +124,7 @@
<button
type="button"
class="conversation-ribbon__selector"
data-tutorial="header-model-selector"
:class="{ open: headerMenuOpen }"
@click.stop="toggleHeaderMenu"
:disabled="!isConnected"
@ -146,7 +147,7 @@
class="model-mode-dropdown"
ref="headerMenu"
>
<div class="dropdown-column">
<div class="dropdown-column" data-tutorial="header-model-options">
<div class="dropdown-title">模型</div>
<button
v-for="option in modelOptions"
@ -163,7 +164,7 @@
</button>
</div>
<div class="dropdown-column">
<div class="dropdown-column" data-tutorial="header-runmode-options">
<div class="dropdown-title">运行模式</div>
<button
v-for="option in headerRunModeOptions"
@ -350,6 +351,7 @@
/>
</transition>
<SubAgentActivityDialog />
<TutorialOverlay />
<div
v-if="isMobileViewport"
@ -358,12 +360,13 @@
ref="mobilePanelTrigger"
>
<div class="mobile-panel-topbar">
<button type="button" class="mobile-panel-fab" aria-label="切换工作区" @click="toggleMobileOverlayMenu">
<button type="button" class="mobile-panel-fab" data-tutorial="mobile-menu-trigger" aria-label="切换工作区" @click="toggleMobileOverlayMenu">
<img :src="mobilePanelIcon" alt="" aria-hidden="true" />
</button>
<button
type="button"
class="mobile-topbar-selector"
data-tutorial="header-model-selector-mobile"
:class="{ open: headerMenuOpen }"
@click.stop="toggleHeaderMenu"
:disabled="!isConnected"
@ -383,6 +386,7 @@
<button
type="button"
class="dropdown-item mobile-menu-item"
data-tutorial="mobile-menu-conversation"
aria-label="对话记录"
@click="openMobileOverlay('conversation')"
>
@ -403,6 +407,7 @@
<button
type="button"
class="dropdown-item mobile-menu-item"
data-tutorial="mobile-menu-workspace"
aria-label="工作文件"
@click="openMobileOverlay('workspace')"
>
@ -412,6 +417,7 @@
<button
type="button"
class="dropdown-item mobile-menu-item mobile-menu-item--personal"
data-tutorial="mobile-menu-personal"
aria-label="个人空间"
@click="handleMobilePersonalClick"
>
@ -421,6 +427,7 @@
<button
type="button"
class="dropdown-item mobile-menu-item"
data-tutorial="mobile-menu-new-chat"
aria-label="新建对话"
@click="createNewConversation"
>
@ -445,7 +452,7 @@
class="model-mode-dropdown model-mode-dropdown--mobile"
ref="headerMenu"
>
<div class="dropdown-column">
<div class="dropdown-column" data-tutorial="header-model-options">
<div class="dropdown-title">模型</div>
<button
v-for="option in modelOptions"
@ -461,7 +468,7 @@
</button>
</div>
<div class="dropdown-column">
<div class="dropdown-column" data-tutorial="header-runmode-options">
<div class="dropdown-title">运行模式</div>
<button
v-for="option in headerRunModeOptions"
@ -518,7 +525,7 @@
@click.self="closeMobileOverlay"
>
<div class="mobile-panel-sheet mobile-panel-sheet--workspace">
<button type="button" class="mobile-overlay-close" aria-label="关闭面板" @click="closeMobileOverlay">
<button type="button" class="mobile-overlay-close" data-tutorial="mobile-workspace-close" aria-label="关闭面板" @click="closeMobileOverlay">
×
</button>
<LeftPanel

View File

@ -9,6 +9,7 @@ import AppShell from '../components/shell/AppShell.vue';
import ImagePicker from '../components/overlay/ImagePicker.vue';
import ConversationReviewDialog from '../components/overlay/ConversationReviewDialog.vue';
import SubAgentActivityDialog from '../components/overlay/SubAgentActivityDialog.vue';
import TutorialOverlay from '../components/overlay/TutorialOverlay.vue';
export const appComponents = {
ChatArea,
@ -21,5 +22,6 @@ export const appComponents = {
AppShell,
ImagePicker,
ConversationReviewDialog,
SubAgentActivityDialog
SubAgentActivityDialog,
TutorialOverlay
};

View File

@ -28,6 +28,7 @@
<button
type="button"
class="stadium-btn add-btn"
data-tutorial="quick-menu-open"
@click.stop="$emit('toggle-quick-menu')"
:disabled="!isConnected"
>

View File

@ -4,6 +4,7 @@
<button
type="button"
class="menu-entry"
data-tutorial="quick-upload"
@click="$emit('quick-upload')"
:disabled="!isConnected || uploading"
>
@ -12,6 +13,7 @@
<button
type="button"
class="menu-entry"
data-tutorial="quick-review"
@click.stop="$emit('open-review')"
:disabled="!isConnected || streamingMessage"
>
@ -21,6 +23,7 @@
v-if="['qwen3-vl-plus', 'kimi-k2.5'].includes(currentModelKey)"
type="button"
class="menu-entry"
data-tutorial="quick-send-image"
@click.stop="$emit('pick-images')"
:disabled="!isConnected || streamingMessage"
>
@ -30,6 +33,7 @@
v-if="['qwen3-vl-plus', 'kimi-k2.5'].includes(currentModelKey)"
type="button"
class="menu-entry"
data-tutorial="quick-send-video"
@click.stop="$emit('pick-video')"
:disabled="!isConnected || streamingMessage"
>
@ -38,6 +42,7 @@
<button
type="button"
class="menu-entry has-submenu"
data-tutorial="quick-tool-menu"
@click.stop="$emit('toggle-tool-menu')"
:disabled="!isConnected"
>
@ -47,6 +52,7 @@
<button
type="button"
class="menu-entry has-submenu"
data-tutorial="quick-settings-menu"
@click.stop="$emit('toggle-settings')"
:disabled="!isConnected"
>
@ -100,6 +106,7 @@
<button
type="button"
class="menu-entry submenu-entry"
data-tutorial="settings-token-panel"
@click="$emit('toggle-token-panel')"
:disabled="!currentConversationId"
>

View File

@ -0,0 +1,456 @@
<template>
<div v-if="running" class="tutorial-overlay" aria-live="polite" @wheel="handleWheel">
<div class="tutorial-click-blocker" @click.stop></div>
<div v-if="stepReady && maskVisible" class="tutorial-mask" :style="[maskTopStyle, maskInteractivityStyle]" @click.stop></div>
<div v-if="stepReady && maskVisible" class="tutorial-mask" :style="[maskLeftStyle, maskInteractivityStyle]" @click.stop></div>
<div v-if="stepReady && maskVisible" class="tutorial-mask" :style="[maskRightStyle, maskInteractivityStyle]" @click.stop></div>
<div v-if="stepReady && maskVisible" class="tutorial-mask" :style="[maskBottomStyle, maskInteractivityStyle]" @click.stop></div>
<div v-if="stepReady && maskVisible" class="tutorial-highlight" :style="highlightStyle" aria-hidden="true"></div>
<span
v-if="clickEffect"
class="tutorial-click-effect"
:style="{ left: `${clickEffect.x - 9}px`, top: `${clickEffect.y - 9}px` }"
aria-hidden="true"
></span>
<section
ref="panelRef"
class="tutorial-popover"
:class="[`placement-${resolvedPlacement}`, { 'is-hidden': !stepReady }]"
:style="popoverStyle"
@click.stop
>
<header class="tutorial-popover__header">
<span class="tutorial-popover__step">{{ visibleStepIndex }} / {{ totalVisibleSteps }}</span>
<button type="button" class="tutorial-popover__close" @click="handleExit">退出</button>
</header>
<h3 class="tutorial-popover__title">{{ step?.title || '新手教程' }}</h3>
<p class="tutorial-popover__desc">{{ step?.description || '' }}</p>
<p v-if="isWaitingTarget" class="tutorial-popover__warn">未找到目标元素可稍后重试或跳过当前步骤</p>
<p v-else-if="isMustClick" class="tutorial-popover__hint">请点击高亮目标继续</p>
<p v-if="showPersonalScrollHint" class="tutorial-popover__hint">提示个人空间内容可上下滚动查看</p>
<footer class="tutorial-popover__actions">
<button type="button" class="tutorial-btn" :disabled="nextDisabled" @click="goNext">
{{ nextLabel }}
</button>
</footer>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useTutorialStore, type TutorialPlacement } from '@/stores/tutorial';
interface RectLike {
top: number;
left: number;
width: number;
height: number;
right: number;
bottom: number;
}
defineOptions({ name: 'TutorialOverlay' });
const GAP = 14;
const HIGHLIGHT_PADDING = 2;
const tutorialStore = useTutorialStore();
const { running, activeStep: step, visibleStepIndex, totalVisibleSteps } = storeToRefs(tutorialStore);
const panelRef = ref<HTMLElement | null>(null);
const targetRect = ref<RectLike | null>(null);
const popoverTop = ref(120);
const popoverLeft = ref(120);
const resolvedPlacement = ref<TutorialPlacement>('center');
const clickEffect = ref<{ x: number; y: number } | null>(null);
let refreshTimer: number | null = null;
let stepSettleTimer: number | null = null;
const stepReady = ref(true);
const isMustClick = computed(() => step.value?.mode === 'must_click');
const isLastStep = computed(() => step.value?.id === 'done');
const isWaitingTarget = computed(() => Boolean(step.value?.target) && !targetRect.value);
const maskVisible = computed(() => Boolean(targetRect.value));
const nextDisabled = computed(() => isMustClick.value && !!targetRect.value);
const isPersonalTutorialPhase = computed(() => {
const id = step.value?.id || '';
return id === 'personal-overview' || id === 'open-personal-space' || id === 'close-personal-space' || id.startsWith('tab-') || id.startsWith('page-');
});
const showPersonalScrollHint = computed(() => isPersonalTutorialPhase.value);
const nextLabel = computed(() => {
if (isLastStep.value) return '完成';
if (isMustClick.value) return targetRect.value ? '请先点击高亮目标' : '下一步(跳过)';
return '下一步';
});
const maskInteractivityStyle = computed(() => ({ pointerEvents: 'auto' }));
const clampedRect = computed<RectLike | null>(() => {
if (!targetRect.value) return null;
const top = Math.max(0, targetRect.value.top - HIGHLIGHT_PADDING);
const left = Math.max(0, targetRect.value.left - HIGHLIGHT_PADDING);
const width = Math.max(12, targetRect.value.width + HIGHLIGHT_PADDING * 2);
const height = Math.max(12, targetRect.value.height + HIGHLIGHT_PADDING * 2);
return { top, left, width, height, right: left + width, bottom: top + height };
});
const maskTopStyle = computed(() => {
const rect = clampedRect.value;
if (!rect) return {};
return { top: '0px', left: '0px', width: '100vw', height: `${Math.max(0, rect.top)}px` };
});
const maskLeftStyle = computed(() => {
const rect = clampedRect.value;
if (!rect) return {};
return { top: `${rect.top}px`, left: '0px', width: `${Math.max(0, rect.left)}px`, height: `${rect.height}px` };
});
const maskRightStyle = computed(() => {
const rect = clampedRect.value;
if (!rect) return {};
return {
top: `${rect.top}px`,
left: `${rect.right}px`,
width: `${Math.max(0, window.innerWidth - rect.right)}px`,
height: `${rect.height}px`
};
});
const maskBottomStyle = computed(() => {
const rect = clampedRect.value;
if (!rect) return {};
return {
top: `${rect.bottom}px`,
left: '0px',
width: '100vw',
height: `${Math.max(0, window.innerHeight - rect.bottom)}px`
};
});
const highlightStyle = computed(() => {
const rect = clampedRect.value;
if (!rect) return {};
return {
top: `${rect.top}px`,
left: `${rect.left}px`,
width: `${rect.width}px`,
height: `${rect.height}px`
};
});
const popoverStyle = computed(() => ({
top: `${popoverTop.value}px`,
left: `${popoverLeft.value}px`
}));
const getCurrentTarget = () => {
if (!step.value?.target) {
tutorialStore.setActiveSelector(null);
return null;
}
const candidates = Array.from(document.querySelectorAll(step.value.target)) as HTMLElement[];
const vw = window.innerWidth;
const vh = window.innerHeight;
const scored = candidates
.map((node) => {
const rect = node.getBoundingClientRect();
const style = window.getComputedStyle(node);
const area = Math.max(0, rect.width) * Math.max(0, rect.height);
const visibleWidth = Math.max(0, Math.min(rect.right, vw) - Math.max(rect.left, 0));
const visibleHeight = Math.max(0, Math.min(rect.bottom, vh) - Math.max(rect.top, 0));
const visibleArea = visibleWidth * visibleHeight;
return {
node,
rect,
area,
visibleArea,
display: style.display,
visibility: style.visibility,
opacity: Number(style.opacity || '1')
};
})
.filter((item) => {
if (item.area <= 0) return false;
if (item.visibleArea <= 0) return false;
if (item.display === 'none' || item.visibility === 'hidden') return false;
return item.opacity > 0.01;
})
.sort((a, b) => {
if (b.visibleArea !== a.visibleArea) return b.visibleArea - a.visibleArea;
return a.area - b.area;
});
const el = (scored[0]?.node || null) as HTMLElement | null;
tutorialStore.setActiveSelector(step.value.target);
return el;
};
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
const placePopover = (rect: RectLike | null) => {
const panel = panelRef.value;
if (!panel) return;
const panelWidth = Math.max(280, panel.offsetWidth || 320);
const panelHeight = Math.max(170, panel.offsetHeight || 200);
const vw = window.innerWidth;
const vh = window.innerHeight;
const edge = 16;
if (!rect || step.value?.placement === 'center') {
resolvedPlacement.value = 'center';
popoverLeft.value = clamp((vw - panelWidth) / 2, edge, Math.max(edge, vw - panelWidth - edge));
popoverTop.value = clamp((vh - panelHeight) / 2, edge, Math.max(edge, vh - panelHeight - edge));
return;
}
const preferred = step.value?.placement || 'auto';
const spaces = {
top: rect.top,
right: vw - rect.right,
bottom: vh - rect.bottom,
left: rect.left
};
const placements: Array<'top' | 'right' | 'bottom' | 'left'> =
preferred === 'auto'
? (['right', 'bottom', 'left', 'top'] as Array<'top' | 'right' | 'bottom' | 'left'>).sort((a, b) => spaces[b] - spaces[a])
: [preferred as 'top' | 'right' | 'bottom' | 'left', 'right', 'bottom', 'left', 'top'];
let best: { p: 'top' | 'right' | 'bottom' | 'left'; left: number; top: number; overlap: number } | null = null;
for (const p of Array.from(new Set(placements))) {
let top = 0;
let left = 0;
if (p === 'right') {
left = rect.right + GAP;
top = rect.top + rect.height / 2 - panelHeight / 2;
} else if (p === 'left') {
left = rect.left - panelWidth - GAP;
top = rect.top + rect.height / 2 - panelHeight / 2;
} else if (p === 'top') {
left = rect.left + rect.width / 2 - panelWidth / 2;
top = rect.top - panelHeight - GAP;
} else {
left = rect.left + rect.width / 2 - panelWidth / 2;
top = rect.bottom + GAP;
}
const clampedLeft = clamp(left, edge, Math.max(edge, vw - panelWidth - edge));
const clampedTop = clamp(top, edge, Math.max(edge, vh - panelHeight - edge));
const fits = left >= edge && top >= edge && left + panelWidth <= vw - edge && top + panelHeight <= vh - edge;
const overlapX = Math.max(0, Math.min(clampedLeft + panelWidth, rect.right) - Math.max(clampedLeft, rect.left));
const overlapY = Math.max(0, Math.min(clampedTop + panelHeight, rect.bottom) - Math.max(clampedTop, rect.top));
const overlap = overlapX * overlapY;
if (fits) {
resolvedPlacement.value = p;
popoverLeft.value = left;
popoverTop.value = top;
return;
}
if (!best || overlap < best.overlap) {
best = { p, left: clampedLeft, top: clampedTop, overlap };
}
}
if (best) {
resolvedPlacement.value = best.p;
popoverLeft.value = best.left;
popoverTop.value = best.top;
return;
}
resolvedPlacement.value = 'center';
popoverLeft.value = clamp((vw - panelWidth) / 2, edge, Math.max(edge, vw - panelWidth - edge));
popoverTop.value = clamp((vh - panelHeight) / 2, edge, Math.max(edge, vh - panelHeight - edge));
};
const updateRect = () => {
if (!running.value) {
targetRect.value = null;
return;
}
const el = getCurrentTarget();
if (!el) {
targetRect.value = null;
resolvedPlacement.value = step.value?.placement || 'center';
placePopover(null);
return;
}
const rect = el.getBoundingClientRect();
targetRect.value =
rect.width > 0 && rect.height > 0
? { top: rect.top, left: rect.left, width: rect.width, height: rect.height, right: rect.right, bottom: rect.bottom }
: null;
placePopover(targetRect.value);
};
const scheduleRefresh = () => {
window.setTimeout(updateRect, 30);
};
const beginStepTransition = () => {
stepReady.value = false;
targetRect.value = null;
popoverLeft.value = -9999;
popoverTop.value = -9999;
if (stepSettleTimer) {
window.clearTimeout(stepSettleTimer);
stepSettleTimer = null;
}
};
const handleCaptureClick = (event: MouseEvent) => {
if (!running.value || !isMustClick.value || !step.value?.target) return;
const clicked = event.target as HTMLElement | null;
if (!clicked) return;
if (!clicked.closest(step.value.target)) return;
window.setTimeout(() => tutorialStore.goNext(), 280);
};
const handleWheel = (event: WheelEvent) => {
if (!isPersonalTutorialPhase.value) {
event.preventDefault();
return;
}
const scrollTarget =
(document.querySelector('.personalization-content') as HTMLElement | null) ||
(document.querySelector('[data-tutorial="personal-content-shell"]') as HTMLElement | null);
if (!scrollTarget) {
event.preventDefault();
return;
}
scrollTarget.scrollBy({ top: event.deltaY, left: 0, behavior: 'auto' });
event.preventDefault();
};
const playClickEffect = (targetEl: HTMLElement | null) => {
if (!targetEl) return;
const rect = targetEl.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
clickEffect.value = { x, y };
window.setTimeout(() => {
clickEffect.value = null;
}, 460);
};
const shouldScrollBeforeAutoClick = () => {
if (!step.value) return false;
const isTabStep = step.value.id.startsWith('tab-');
if (isTabStep) return true;
const isMobile = window.innerWidth <= 768;
return isMobile && step.value.id === 'mobile-menu-personal';
};
const triggerAutoClick = () => {
if (step.value?.autoOutsideClick) {
const menu = document.querySelector('.model-mode-dropdown') as HTMLElement | null;
const targetEl = menu || (document.body as HTMLElement);
playClickEffect(targetEl);
window.setTimeout(() => {
const event = new MouseEvent('click', { bubbles: true, cancelable: true, clientX: 12, clientY: 12 });
document.body.dispatchEvent(event);
}, 420);
return true;
}
if (!step.value?.autoClick || !step.value?.target) return false;
const el = getCurrentTarget();
if (!el) return false;
const shouldScroll = shouldScrollBeforeAutoClick();
if (shouldScroll) {
const isMobile = window.innerWidth <= 768;
el.scrollIntoView({
behavior: 'smooth',
block: isMobile ? 'nearest' : 'center',
inline: isMobile ? 'center' : 'nearest'
});
}
window.setTimeout(
() => {
playClickEffect(el);
},
shouldScroll ? 220 : 0
);
window.setTimeout(() => {
(el as HTMLElement).click();
}, shouldScroll ? 640 : 420);
return true;
};
const goNext = () => {
if (nextDisabled.value) return;
beginStepTransition();
if (step.value?.id === 'done') {
tutorialStore.finishTutorial();
return;
}
if (!isMustClick.value) {
const clicked = triggerAutoClick();
if (clicked) {
window.setTimeout(() => tutorialStore.goNext(), 700);
return;
}
}
tutorialStore.goNext();
};
const handleExit = () => tutorialStore.exitTutorial();
watch(
() => [running.value, step.value?.id],
async ([isRunning, stepId], [prevRunning, prevStepId]) => {
if (!running.value) {
targetRect.value = null;
stepReady.value = true;
return;
}
const isStepSwitch = Boolean(prevRunning) && Boolean(prevStepId) && prevStepId !== stepId;
if (isStepSwitch) {
stepReady.value = false;
if (stepSettleTimer) {
window.clearTimeout(stepSettleTimer);
}
const nextStep = step.value;
const settleDelay =
nextStep?.mode === 'must_click' || nextStep?.autoClick || nextStep?.autoOutsideClick ? 500 : 0;
stepSettleTimer = window.setTimeout(async () => {
await nextTick();
updateRect();
await nextTick();
updateRect();
stepReady.value = true;
}, settleDelay);
return;
}
await nextTick();
updateRect();
await nextTick();
updateRect();
stepReady.value = true;
}
);
onMounted(() => {
window.addEventListener('click', handleCaptureClick, true);
window.addEventListener('resize', scheduleRefresh);
window.addEventListener('scroll', scheduleRefresh, true);
refreshTimer = window.setInterval(updateRect, 260);
});
onBeforeUnmount(() => {
window.removeEventListener('click', handleCaptureClick, true);
window.removeEventListener('resize', scheduleRefresh);
window.removeEventListener('scroll', scheduleRefresh, true);
if (refreshTimer) {
window.clearInterval(refreshTimer);
refreshTimer = null;
}
if (stepSettleTimer) {
window.clearTimeout(stepSettleTimer);
stepSettleTimer = null;
}
});
</script>

View File

@ -14,6 +14,7 @@
<button
type="button"
class="mode-indicator"
data-tutorial="run-mode-indicator"
:class="modeIndicatorClass"
:title="modeIndicatorTitle"
@click="$emit('toggle-thinking-mode')"
@ -27,7 +28,12 @@
></span>
</transition>
</button>
<span class="connection-dot" :class="{ active: isConnected }" :title="isConnected ? '已连接' : '未连接'"></span>
<span
class="connection-dot"
data-tutorial="connection-indicator"
:class="{ active: isConnected }"
:title="isConnected ? '已连接' : '未连接'"
></span>
</div>
</div>
</div>
@ -36,11 +42,11 @@
<div class="sidebar-panel-card">
<div class="sidebar-header">
<div class="panel-menu-wrapper" ref="panelMenuWrapper">
<button class="sidebar-view-toggle" @click.stop="$emit('toggle-panel-menu')" title="切换侧边栏">
<button class="sidebar-view-toggle" data-tutorial="panel-menu-toggle" @click.stop="$emit('toggle-panel-menu')" title="切换侧边栏">
<span class="icon icon-md" :style="iconStyle('menu')" aria-hidden="true"></span>
</button>
<transition name="fade">
<div class="panel-menu" v-if="panelMenuOpen">
<div class="panel-menu" data-tutorial="panel-menu" v-if="panelMenuOpen">
<button
type="button"
:class="{ active: panelMode === 'files' }"

View File

@ -10,7 +10,7 @@
@touchend.self.prevent="personalization.handleOverlayPressEnd"
@touchcancel.self="personalization.handleOverlayPressCancel"
>
<div class="personal-page-card">
<div class="personal-page-card" data-tutorial="personal-card">
<div class="personal-page-header">
<div>
<h2>个人空间</h2>
@ -18,7 +18,7 @@
</div>
<div class="personal-page-actions">
<button type="button" class="personal-page-logout" @click="personalization.logout()">退出登录</button>
<button type="button" class="personal-page-close" @click="personalization.closeDrawer()">返回工作区</button>
<button type="button" class="personal-page-close" data-tutorial="personal-close" @click="personalization.closeDrawer()">返回工作区</button>
</div>
</div>
<div class="personalization-body" v-if="!loading">
@ -30,6 +30,7 @@
:key="tab.id"
type="button"
class="personal-tab-button"
:data-tutorial="`personal-tab-${tab.id}`"
:class="{ active: activeTab === tab.id }"
:aria-pressed="activeTab === tab.id"
@click.prevent="setActiveTab(tab.id)"
@ -38,14 +39,14 @@
<small v-if="tab.description" class="personal-tab-desc">{{ tab.description }}</small>
</button>
</nav>
<div class="personalization-content-shell">
<div class="personalization-content-shell" data-tutorial="personal-content-shell">
<div
class="personalization-content"
@touchstart.passive="handleSwipeStart"
@touchend.passive="handleSwipeEnd"
>
<transition name="personal-page-vertical" mode="out-in">
<section v-if="activeTab === 'preferences'" key="preferences" class="personal-page personal-page-pref">
<section v-if="activeTab === 'preferences'" key="preferences" class="personal-page personal-page-pref" data-tutorial="personal-page-preferences">
<div class="personal-toggle-row">
<label class="personal-toggle">
<span class="toggle-text">
@ -183,7 +184,7 @@
</div>
</div>
</section>
<section v-else-if="activeTab === 'image'" key="image" class="personal-page behavior-page">
<section v-else-if="activeTab === 'image'" key="image" class="personal-page behavior-page" data-tutorial="personal-page-image">
<div class="behavior-section">
<div class="behavior-field">
<div class="behavior-field-header">
@ -225,7 +226,7 @@
</div>
</div>
</section>
<section v-else-if="activeTab === 'model'" key="model" class="personal-page behavior-page">
<section v-else-if="activeTab === 'model'" key="model" class="personal-page behavior-page" data-tutorial="personal-page-model">
<div class="behavior-section">
<div class="behavior-field">
<div class="behavior-field-header">
@ -331,7 +332,7 @@
</div>
</div>
</section>
<section v-else-if="activeTab === 'usage'" key="usage" class="personal-page usage-summary-page">
<section v-else-if="activeTab === 'usage'" key="usage" class="personal-page usage-summary-page" data-tutorial="personal-page-usage">
<div class="usage-summary-card">
<div class="usage-summary-header">
<div>
@ -380,7 +381,7 @@
</div>
</div>
</section>
<section v-else-if="activeTab === 'app-update'" key="app-update" class="personal-page app-update-page">
<section v-else-if="activeTab === 'app-update'" key="app-update" class="personal-page app-update-page" data-tutorial="personal-page-app-update">
<div class="usage-summary-card">
<div class="usage-summary-header">
<div>
@ -435,7 +436,7 @@
</div>
</div>
</section>
<section v-else-if="activeTab === 'behavior'" key="behavior" class="personal-page behavior-page">
<section v-else-if="activeTab === 'behavior'" key="behavior" class="personal-page behavior-page" data-tutorial="personal-page-behavior">
<div class="behavior-section">
<div class="behavior-field">
<div class="behavior-field-header">
@ -788,7 +789,7 @@
</div>
</div>
</section>
<section v-else-if="activeTab === 'skills'" key="skills" class="personal-page behavior-page">
<section v-else-if="activeTab === 'skills'" key="skills" class="personal-page behavior-page" data-tutorial="personal-page-skills">
<div class="behavior-section">
<div class="behavior-field">
<div class="behavior-field-header">
@ -828,7 +829,7 @@
</div>
</div>
</section>
<section v-else-if="activeTab === 'theme'" key="theme" class="personal-page theme-page">
<section v-else-if="activeTab === 'theme'" key="theme" class="personal-page theme-page" data-tutorial="personal-page-theme">
<div class="theme-section">
<div class="theme-header">
<div>
@ -867,6 +868,45 @@
<p class="theme-note">虚拟显示器保持原样仅主界面侧边栏与个人空间配色随主题改变</p>
</div>
</section>
<section v-else-if="activeTab === 'tutorial'" key="tutorial" class="personal-page tutorial-page">
<div class="tutorial-intro-content">
<div class="tutorial-intro-header">
<p class="tutorial-intro-eyebrow">功能引导</p>
<h3>新手教程</h3>
<p class="tutorial-intro-desc">
通过高亮 + 悬浮说明窗快速了解主要功能入口和常用设置
</p>
</div>
<div class="tutorial-intro-features">
<div class="tutorial-feature-item">
<div>
<h4>主界面导览</h4>
<p>认识侧边栏工作区模型与模式选择</p>
</div>
</div>
<div class="tutorial-feature-item">
<div>
<h4>快捷菜单</h4>
<p>学习上传文件对话回顾用量统计等高频功能</p>
</div>
</div>
<div class="tutorial-feature-item">
<div>
<h4>个人空间</h4>
<p>了解模型偏好主题Skills图片压缩等配置</p>
</div>
</div>
</div>
<div class="tutorial-intro-actions">
<button type="button" class="tutorial-start-button" @click="startTutorial">
开始新手教程
</button>
<p class="tutorial-intro-hint">教程开始后会自动关闭个人空间您可随时退出或跳过当前步骤</p>
</div>
</div>
</section>
<section v-else-if="activeTab === 'admin-monitor'" key="admin-monitor" class="personal-page admin-monitor-page">
<div class="admin-monitor-panel">
<div class="admin-monitor-heading">
@ -935,6 +975,7 @@ import { usePersonalizationStore, type BlockDisplayMode } from '@/stores/persona
import { useResourceStore } from '@/stores/resource';
import { useUiStore } from '@/stores/ui';
import { usePolicyStore } from '@/stores/policy';
import { useTutorialStore } from '@/stores/tutorial';
import { formatTokenCount } from '@/utils/formatters';
import { useTheme } from '@/utils/theme';
import type { ThemeKey } from '@/utils/theme';
@ -944,6 +985,7 @@ defineOptions({ name: 'PersonalizationDrawer' });
const personalization = usePersonalizationStore();
const resourceStore = useResourceStore();
const uiStore = useUiStore();
const tutorialStore = useTutorialStore();
const {
visible,
loading,
@ -970,7 +1012,8 @@ const baseTabs = [
{ id: 'behavior', label: '模型行为', description: '工具提示与界面表现' },
{ id: 'skills', label: 'Skills', description: '可用技能开关' },
{ id: 'image', label: '图片压缩', description: '发送图片的尺寸策略' },
{ id: 'theme', label: '主题切换', description: '浅色 / 深色 / Claude' }
{ id: 'theme', label: '主题切换', description: '浅色 / 深色 / Claude' },
{ id: 'tutorial', label: '新手教程', description: '功能引导与使用说明' }
] as const;
type PersonalTab =
@ -982,6 +1025,7 @@ type PersonalTab =
| 'skills'
| 'image'
| 'theme'
| 'tutorial'
| 'admin-monitor';
const isAdmin = computed(() => (resourceStore.usageQuota.role || '').toLowerCase() === 'admin');
@ -1257,6 +1301,13 @@ const setActiveTab = (tab: PersonalTab) => {
activeTab.value = tab;
};
const startTutorial = () => {
personalization.closeDrawer();
window.setTimeout(() => {
tutorialStore.startTutorial();
}, 320);
};
const shiftTab = (direction: 1 | -1) => {
const tabs = personalTabs.value;
const currentIndex = tabs.findIndex((tab) => tab.id === activeTab.value);

View File

@ -10,7 +10,7 @@
@click="$emit('toggle')"
>
<span class="sr-only">展开对话记录</span>
<span class="chat-icon" aria-hidden="true">
<span class="chat-icon" data-tutorial="conversation-menu" aria-hidden="true">
<slot name="collapsed-chat-icon">
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
@ -32,7 +32,7 @@
@click="$emit('create')"
>
<span class="sr-only">新建对话</span>
<span class="pencil-icon" aria-hidden="true">
<span class="pencil-icon" data-tutorial="quick-new-conversation" aria-hidden="true">
<slot name="collapsed-create-icon">
<svg
xmlns="http://www.w3.org/2000/svg"
@ -62,7 +62,7 @@
@click="$emit('toggle-workspace')"
>
<span class="sr-only">{{ workspaceCollapsed ? '显示工作区' : '隐藏工作区' }}</span>
<span class="icon icon-md" aria-hidden="true" :style="iconStyle('layers')"></span>
<span class="icon icon-md" data-tutorial="workspace-toggle" aria-hidden="true" :style="iconStyle('layers')"></span>
</button>
<button
type="button"
@ -73,7 +73,7 @@
:title="displayMode === 'monitor' ? '退出显示器' : '虚拟显示器'"
>
<span class="sr-only">{{ displayMode === 'monitor' ? '退出显示器' : '虚拟显示器' }}</span>
<span class="icon icon-md" aria-hidden="true" :style="iconStyle('monitor')"></span>
<span class="icon icon-md" data-tutorial="monitor-toggle" aria-hidden="true" :style="iconStyle('monitor')"></span>
</button>
</div>
</template>
@ -96,6 +96,7 @@
<button
v-if="resolvedShowCollapseButton"
class="toggle-sidebar-btn"
data-tutorial="conversation-collapse"
:class="{ 'icon-only': monitorModeActive }"
:title="monitorModeActive ? (collapsed ? '展开对话记录' : '折叠对话记录') : undefined"
@click="$emit('toggle')"
@ -215,6 +216,7 @@
<button
type="button"
class="personal-page-btn"
data-tutorial="open-personal-space"
:class="{ 'icon-only': collapsed }"
title="个人页面"
@click="$emit('personal')"

View File

@ -1,7 +1,7 @@
<template>
<div class="token-drawer" v-if="visible" :class="{ collapsed }">
<div class="token-drawer" v-if="visible" :class="{ collapsed }" data-tutorial="token-drawer">
<div class="token-display-panel">
<button class="token-close-btn" type="button" @click="emit('toggle')" aria-label="收起用量统计">
<button class="token-close-btn" type="button" data-tutorial="token-close" @click="emit('toggle')" aria-label="收起用量统计">
<span class="sr-only">关闭</span>
</button>
<div class="token-panel-content">

View File

@ -0,0 +1,270 @@
import { defineStore } from 'pinia';
import { useModelStore } from './model';
export type TutorialPlacement = 'auto' | 'top' | 'right' | 'bottom' | 'left' | 'center';
export type TutorialStepMode = 'info' | 'must_click';
export type TutorialCondition = 'model_supports_media' | 'is_app_shell' | 'is_mobile_viewport' | 'not_mobile_viewport';
export interface TutorialStep {
id: string;
title: string;
description: string;
target: string | null;
mode: TutorialStepMode;
autoClick?: boolean;
autoOutsideClick?: boolean;
placement?: TutorialPlacement;
condition?: TutorialCondition;
}
const STORAGE_COMPLETED_KEY = 'agents_tutorial_completed_v1';
const STORAGE_VERSION_KEY = 'agents_tutorial_version';
const TUTORIAL_VERSION = 'v1';
const DEFAULT_STEPS: TutorialStep[] = [
{
id: 'welcome',
title: '欢迎使用 AI Agent 系统',
description: '我们将用 2-3 分钟带你快速了解核心功能。',
target: null,
mode: 'info',
placement: 'center'
},
{ id: 'sidebar-conversations-open', title: '展开对话记录', description: '下一步会自动点击展开对话记录。', target: '[data-tutorial="conversation-menu"]', mode: 'info', autoClick: true, placement: 'right', condition: 'not_mobile_viewport' },
{ id: 'sidebar-conversations-close', title: '折叠对话记录', description: '下一步会自动点击折叠按钮收起对话记录。', target: '[data-tutorial="conversation-collapse"]', mode: 'info', autoClick: true, placement: 'right', condition: 'not_mobile_viewport' },
{ id: 'sidebar-new-chat', title: '新建对话', description: '下一步会自动新建并进入一个对话。', target: '[data-tutorial="quick-new-conversation"]', mode: 'info', autoClick: true, placement: 'right', condition: 'not_mobile_viewport' },
{ id: 'sidebar-workspace-toggle', title: '工作区折叠', description: '显示/隐藏左侧工作区面板。', target: '[data-tutorial="workspace-toggle"]', mode: 'info', placement: 'right', condition: 'not_mobile_viewport' },
{ id: 'sidebar-monitor-toggle', title: '虚拟显示器', description: '切换到虚拟显示器模式。', target: '[data-tutorial="monitor-toggle"]', mode: 'info', placement: 'right', condition: 'not_mobile_viewport' },
{ id: 'workspace-panel-switch', title: '工作区面板切换', description: '下一步会自动展开面板切换菜单。', target: '[data-tutorial="panel-menu-toggle"]', mode: 'info', autoClick: true, placement: 'right', condition: 'not_mobile_viewport' },
{ id: 'workspace-panel-options', title: '三合一面板', description: '这里可以切换文件 / 待办 / 子智能体。', target: '[data-tutorial="panel-menu"]', mode: 'info', placement: 'right', condition: 'not_mobile_viewport' },
{ id: 'workspace-mode-indicator', title: '思考模式', description: '点击切换快速 / 思考 / 深度思考。', target: '[data-tutorial="run-mode-indicator"]', mode: 'info', placement: 'right', condition: 'not_mobile_viewport' },
{ id: 'workspace-connection-indicator', title: '连接状态指示灯', description: '绿色表示连接正常;红色表示与后端断开连接。', target: '[data-tutorial="connection-indicator"]', mode: 'info', placement: 'right', condition: 'not_mobile_viewport' },
{ id: 'header-model-selector', title: '模型与模式选择', description: '下一步会自动展开模型与运行模式弹窗。', target: '[data-tutorial="header-model-selector"]', mode: 'info', autoClick: true, placement: 'bottom', condition: 'not_mobile_viewport' },
{ id: 'header-model-options', title: '模型列表', description: '这里是可用模型列表。', target: '[data-tutorial="header-model-options"]', mode: 'info', placement: 'bottom', condition: 'not_mobile_viewport' },
{ id: 'header-runmode-options', title: '运行模式列表', description: '这里可切换快速 / 思考 / 深度思考。', target: '[data-tutorial="header-runmode-options"]', mode: 'info', placement: 'bottom', condition: 'not_mobile_viewport' },
{
id: 'header-menu-close',
title: '关闭模型菜单',
description: '下一步会自动点击空白区域关闭菜单。',
target: '.model-mode-dropdown',
mode: 'info',
autoOutsideClick: true,
placement: 'bottom',
condition: 'not_mobile_viewport'
},
{ id: 'mobile-menu-open-conversation', title: '打开菜单', description: '下一步会自动打开左上角菜单。', target: '[data-tutorial="mobile-menu-trigger"]', mode: 'info', autoClick: true, placement: 'bottom', condition: 'is_mobile_viewport' },
{ id: 'mobile-menu-conversation', title: '对话记录', description: '下一步会自动进入对话记录。', target: '[data-tutorial="mobile-menu-conversation"]', mode: 'info', autoClick: true, placement: 'bottom', condition: 'is_mobile_viewport' },
{ id: 'mobile-conversation-close', title: '关闭对话记录', description: '下一步会自动关闭对话记录面板。', target: '[data-tutorial="conversation-collapse"]', mode: 'info', autoClick: true, placement: 'right', condition: 'is_mobile_viewport' },
{ id: 'mobile-menu-open-workspace', title: '再次打开菜单', description: '下一步会自动打开菜单。', target: '[data-tutorial="mobile-menu-trigger"]', mode: 'info', autoClick: true, placement: 'bottom', condition: 'is_mobile_viewport' },
{ id: 'mobile-menu-workspace', title: '工作文件', description: '下一步会自动进入工作文件。', target: '[data-tutorial="mobile-menu-workspace"]', mode: 'info', autoClick: true, placement: 'bottom', condition: 'is_mobile_viewport' },
{ id: 'mobile-workspace-panel-switch', title: '工作区面板切换', description: '下一步会自动展开切换弹窗。', target: '[data-tutorial="panel-menu-toggle"]', mode: 'info', autoClick: true, placement: 'bottom', condition: 'is_mobile_viewport' },
{ id: 'mobile-workspace-panel-options', title: '切换弹窗选项', description: '这里可切换文件 / 待办 / 子智能体。', target: '[data-tutorial="panel-menu"]', mode: 'info', placement: 'bottom', condition: 'is_mobile_viewport' },
{ id: 'mobile-workspace-close', title: '关闭工作文件', description: '下一步会自动关闭工作文件面板。', target: '[data-tutorial="mobile-workspace-close"]', mode: 'info', autoClick: true, placement: 'right', condition: 'is_mobile_viewport' },
{ id: 'mobile-menu-open-newchat', title: '再次打开菜单', description: '下一步会自动打开菜单。', target: '[data-tutorial="mobile-menu-trigger"]', mode: 'info', autoClick: true, placement: 'bottom', condition: 'is_mobile_viewport' },
{ id: 'mobile-menu-new-chat', title: '新建对话', description: '下一步会自动新建对话。', target: '[data-tutorial="mobile-menu-new-chat"]', mode: 'info', autoClick: true, placement: 'bottom', condition: 'is_mobile_viewport' },
{ id: 'mobile-model-selector-open', title: '模型与思考模式', description: '下一步会自动展开手机端模型/思考模式选择。', target: '[data-tutorial="header-model-selector-mobile"]', mode: 'info', autoClick: true, placement: 'bottom', condition: 'is_mobile_viewport' },
{ id: 'mobile-model-options', title: '模型列表', description: '这里是手机端可选模型。', target: '[data-tutorial="header-model-options"]', mode: 'info', placement: 'bottom', condition: 'is_mobile_viewport' },
{ id: 'mobile-runmode-options', title: '思考模式列表', description: '这里可切换快速 / 思考 / 深度思考。', target: '[data-tutorial="header-runmode-options"]', mode: 'info', placement: 'bottom', condition: 'is_mobile_viewport' },
{ id: 'mobile-model-selector-close', title: '关闭选择菜单', description: '下一步会自动点击空白区域关闭菜单。', target: '.model-mode-dropdown', mode: 'info', autoOutsideClick: true, placement: 'bottom', condition: 'is_mobile_viewport' },
{ id: 'open-quick-menu', title: '打开 + 菜单', description: '将自动为你展开快捷菜单。', target: '[data-tutorial="quick-menu-open"]', mode: 'info', autoClick: true, placement: 'top' },
{ id: 'quick-upload', title: '上传文件', description: '上传本地文件到当前工作区。', target: '[data-tutorial="quick-upload"]', mode: 'info', placement: 'left' },
{ id: 'quick-review', title: '对话回顾', description: '回顾并压缩上下文。', target: '[data-tutorial="quick-review"]', mode: 'info', placement: 'left' },
{ id: 'quick-send-image', title: '发送图片', description: '当前模型支持时,可发送图片给 AI 分析。', target: '[data-tutorial="quick-send-image"]', mode: 'info', placement: 'left', condition: 'model_supports_media' },
{ id: 'quick-send-video', title: '发送视频', description: '当前模型支持时,可发送视频给 AI 分析。', target: '[data-tutorial="quick-send-video"]', mode: 'info', placement: 'left', condition: 'model_supports_media' },
{ id: 'quick-tool-disable', title: '工具禁用', description: '临时禁用工具分类,控制模型行为范围。', target: '[data-tutorial="quick-tool-menu"]', mode: 'info', placement: 'left' },
{ id: 'quick-settings', title: '设置菜单', description: '这里可快速访问常用功能。', target: '[data-tutorial="quick-settings-menu"]', mode: 'info', placement: 'left' },
{ id: 'open-settings-submenu', title: '打开设置子菜单', description: '下一步会自动展开设置子菜单。', target: '[data-tutorial="quick-settings-menu"]', mode: 'info', autoClick: true, placement: 'left' },
{ id: 'open-token-panel', title: '打开用量统计', description: '下一步会自动打开用量统计面板。', target: '[data-tutorial="settings-token-panel"]', mode: 'info', autoClick: true, placement: 'left' },
{ id: 'token-panel', title: 'Token 统计面板', description: '这里可以查看上下文、额度和资源统计。', target: '[data-tutorial="token-drawer"]', mode: 'info', placement: 'left' },
{ id: 'close-token-panel', title: '关闭统计面板', description: '下一步会自动关闭统计面板。', target: '[data-tutorial="token-close"]', mode: 'info', autoClick: true, placement: 'left' },
{ id: 'open-personal-space', title: '打开个人空间', description: '下一步会自动打开个人空间。', target: '[data-tutorial="open-personal-space"]', mode: 'info', autoClick: true, placement: 'right', condition: 'not_mobile_viewport' },
{ id: 'mobile-menu-open-personal', title: '打开菜单', description: '下一步会自动打开菜单。', target: '[data-tutorial="mobile-menu-trigger"]', mode: 'info', autoClick: true, placement: 'bottom', condition: 'is_mobile_viewport' },
{ id: 'mobile-menu-personal', title: '进入个人空间', description: '下一步会自动进入个人空间。', target: '[data-tutorial="mobile-menu-personal"]', mode: 'info', autoClick: true, placement: 'bottom', condition: 'is_mobile_viewport' },
{ id: 'personal-overview', title: '个人空间总览', description: '这里是个性化设置与高级配置中心。', target: '[data-tutorial="personal-card"]', mode: 'info', placement: 'right' },
{ id: 'tab-preferences', title: '个性化设置', description: '下一步自动切换到“个性化设置”。', target: '[data-tutorial="personal-tab-preferences"]', mode: 'info', autoClick: true, placement: 'right' },
{ id: 'page-preferences', title: '个性化设置内容', description: '可配置昵称、语气、职业和必备信息。', target: '[data-tutorial="personal-content-shell"]', mode: 'info', placement: 'right' },
{ id: 'tab-model', title: '模型偏好', description: '下一步自动切换到“模型偏好”。', target: '[data-tutorial="personal-tab-model"]', mode: 'info', autoClick: true, placement: 'right' },
{ id: 'page-model', title: '模型偏好内容', description: '可设置默认模型、默认运行模式和思考频率。', target: '[data-tutorial="personal-content-shell"]', mode: 'info', placement: 'right' },
{ id: 'tab-usage', title: '用量统计', description: '下一步自动切换到“用量统计”。', target: '[data-tutorial="personal-tab-usage"]', mode: 'info', autoClick: true, placement: 'right' },
{ id: 'page-usage', title: '用量统计内容', description: '可查看累计 Token、对话数和工具调用数据。', target: '[data-tutorial="personal-content-shell"]', mode: 'info', placement: 'right' },
{ id: 'tab-app-update', title: '软件更新', description: '移动端将自动切换到“软件更新”。', target: '[data-tutorial="personal-tab-app-update"]', mode: 'info', autoClick: true, placement: 'right', condition: 'is_app_shell' },
{ id: 'page-app-update', title: '软件更新内容', description: '这里会展示最新版本和更新说明。', target: '[data-tutorial="personal-content-shell"]', mode: 'info', placement: 'right', condition: 'is_app_shell' },
{ id: 'tab-behavior', title: '模型行为', description: '下一步自动切换到“模型行为”。', target: '[data-tutorial="personal-tab-behavior"]', mode: 'info', autoClick: true, placement: 'right' },
{ id: 'page-behavior', title: '模型行为内容', description: '可配置工具显示、压缩策略等高级选项。', target: '[data-tutorial="personal-content-shell"]', mode: 'info', placement: 'right' },
{ id: 'tab-skills', title: 'Skills', description: '下一步自动切换到“Skills”。', target: '[data-tutorial="personal-tab-skills"]', mode: 'info', autoClick: true, placement: 'right' },
{ id: 'page-skills', title: 'Skills 内容', description: '可启用/禁用技能并同步到工作区。', target: '[data-tutorial="personal-content-shell"]', mode: 'info', placement: 'right' },
{ id: 'tab-image', title: '图片压缩', description: '下一步自动切换到“图片压缩”。', target: '[data-tutorial="personal-tab-image"]', mode: 'info', autoClick: true, placement: 'right' },
{ id: 'page-image', title: '图片压缩内容', description: '可选择原图、1080p、720p、540p 压缩策略。', target: '[data-tutorial="personal-content-shell"]', mode: 'info', placement: 'right' },
{ id: 'tab-theme', title: '主题切换', description: '下一步自动切换到“主题切换”。', target: '[data-tutorial="personal-tab-theme"]', mode: 'info', autoClick: true, placement: 'right' },
{ id: 'page-theme', title: '主题切换内容', description: '可在 Claude、明亮、夜间主题间切换。', target: '[data-tutorial="personal-content-shell"]', mode: 'info', placement: 'right' },
{ id: 'close-personal-space', title: '关闭个人空间', description: '下一步自动关闭个人空间并结束导览。', target: '[data-tutorial="personal-close"]', mode: 'info', autoClick: true, placement: 'bottom' },
{
id: 'done',
title: '教程完成!',
description: '恭喜你完成新手教程。可随时在个人空间「新手教程」再次查看。',
target: null,
mode: 'info',
placement: 'center'
}
];
interface TutorialState {
running: boolean;
completed: boolean;
currentIndex: number;
steps: TutorialStep[];
activeSelector: string | null;
}
const detectAppShell = () => {
if (typeof window === 'undefined') return false;
const params = new URLSearchParams(window.location.search);
if (params.has('app_shell')) return true;
return Boolean((window as any)?.AndroidThemeBridge);
};
const detectMobileViewport = () => {
if (typeof window === 'undefined') return false;
return window.innerWidth <= 768;
};
const shouldIncludeStepByCondition = (step: TutorialStep): boolean => {
if (!step.condition) return true;
if (step.condition === 'is_app_shell') {
return detectAppShell();
}
if (step.condition === 'model_supports_media') {
const modelStore = useModelStore();
return ['qwen3-vl-plus', 'kimi-k2.5'].includes(modelStore.currentModelKey);
}
if (step.condition === 'is_mobile_viewport') {
return detectMobileViewport();
}
if (step.condition === 'not_mobile_viewport') {
return !detectMobileViewport();
}
return true;
};
const readCompletedState = () => {
if (typeof window === 'undefined' || !window.localStorage) {
return false;
}
try {
const version = window.localStorage.getItem(STORAGE_VERSION_KEY);
if (version !== TUTORIAL_VERSION) {
return false;
}
return window.localStorage.getItem(STORAGE_COMPLETED_KEY) === '1';
} catch {
return false;
}
};
const persistCompletedState = (completed: boolean) => {
if (typeof window === 'undefined' || !window.localStorage) {
return;
}
try {
window.localStorage.setItem(STORAGE_VERSION_KEY, TUTORIAL_VERSION);
window.localStorage.setItem(STORAGE_COMPLETED_KEY, completed ? '1' : '0');
} catch {
// ignore
}
};
export const useTutorialStore = defineStore('tutorial', {
state: (): TutorialState => ({
running: false,
completed: readCompletedState(),
currentIndex: 0,
steps: DEFAULT_STEPS,
activeSelector: null
}),
getters: {
activeStep(state): TutorialStep | null {
return state.steps[state.currentIndex] || null;
},
totalVisibleSteps(state): number {
return state.steps.filter((step) => shouldIncludeStepByCondition(step)).length;
},
visibleStepIndex(state): number {
if (!state.running) return 0;
let count = 0;
for (let i = 0; i <= state.currentIndex; i += 1) {
const step = state.steps[i];
if (step && shouldIncludeStepByCondition(step)) {
count += 1;
}
}
return count;
}
},
actions: {
shouldIncludeStep(step: TutorialStep): boolean {
return shouldIncludeStepByCondition(step);
},
startTutorial() {
this.running = true;
this.completed = false;
this.currentIndex = 0;
this.activeSelector = null;
persistCompletedState(false);
this.skipHiddenSteps('forward');
},
exitTutorial() {
this.running = false;
this.activeSelector = null;
},
finishTutorial() {
this.running = false;
this.completed = true;
this.activeSelector = null;
persistCompletedState(true);
},
goNext() {
if (!this.running) return;
if (this.currentIndex >= this.steps.length - 1) {
this.finishTutorial();
return;
}
this.currentIndex += 1;
this.skipHiddenSteps('forward');
if (this.currentIndex >= this.steps.length - 1 && this.activeStep?.id === 'done') {
this.completed = true;
}
},
goPrev() {
if (!this.running) return;
if (this.currentIndex <= 0) return;
this.currentIndex -= 1;
this.skipHiddenSteps('backward');
},
skipCurrentStep() {
this.goNext();
},
markCompleted() {
this.completed = true;
persistCompletedState(true);
},
skipHiddenSteps(direction: 'forward' | 'backward') {
if (!this.running) return;
if (direction === 'forward') {
while (this.currentIndex < this.steps.length && !this.shouldIncludeStep(this.steps[this.currentIndex])) {
this.currentIndex += 1;
}
if (this.currentIndex >= this.steps.length) {
this.finishTutorial();
return;
}
} else {
while (this.currentIndex > 0 && !this.shouldIncludeStep(this.steps[this.currentIndex])) {
this.currentIndex -= 1;
}
}
},
setActiveSelector(selector: string | null) {
this.activeSelector = selector;
}
}
});

View File

@ -3500,3 +3500,247 @@ body[data-theme='dark'] {
background: #1a1a1a;
}
}
.tutorial-page {
display: flex;
align-items: stretch;
}
.tutorial-intro-content {
width: 100%;
padding: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.tutorial-intro-eyebrow {
margin: 0;
font-size: 12px;
color: var(--claude-text-tertiary);
letter-spacing: 0.06em;
}
.tutorial-intro-header h3 {
margin: 6px 0 8px;
font-size: 24px;
}
.tutorial-intro-desc,
.tutorial-intro-hint,
.tutorial-status-text,
.tutorial-feature-item p {
margin: 0;
color: var(--claude-text-secondary);
line-height: 1.55;
}
.tutorial-intro-features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 12px;
}
.tutorial-feature-item {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 12px;
border: 1px solid var(--theme-control-border);
border-radius: 14px;
background: var(--theme-surface-strong);
}
.tutorial-feature-item h4 {
margin: 0 0 4px;
font-size: 14px;
}
.tutorial-feature-icon {
font-size: 18px;
}
.tutorial-status-badge {
display: inline-flex;
gap: 8px;
align-items: center;
border-radius: 999px;
padding: 6px 12px;
font-size: 13px;
font-weight: 600;
}
.tutorial-status-badge.success {
color: #fff;
background: linear-gradient(135deg, var(--claude-success), #4c8f5f);
}
.tutorial-intro-actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 4px;
}
.tutorial-start-button {
align-self: flex-start;
border: none;
border-radius: 999px;
padding: 10px 20px;
font-size: 14px;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, var(--claude-accent), var(--claude-accent-strong));
cursor: pointer;
}
.tutorial-start-button:hover {
transform: translateY(-1px);
box-shadow: var(--theme-shadow-soft);
}
.tutorial-overlay {
position: fixed;
inset: 0;
z-index: 3200;
pointer-events: auto;
}
.tutorial-click-blocker {
position: fixed;
inset: 0;
pointer-events: auto;
background: transparent;
z-index: 0;
}
.tutorial-mask {
position: fixed;
background: transparent;
pointer-events: auto;
z-index: 1;
}
.tutorial-highlight {
position: fixed;
border-radius: 12px;
border: 2px solid rgba(255, 255, 255, 0.98);
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.5),
0 10px 34px rgba(0, 0, 0, 0.35);
pointer-events: none;
z-index: 2;
}
.tutorial-click-effect {
position: fixed;
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid rgba(47, 129, 247, 0.9);
pointer-events: none;
z-index: 3;
animation: tutorialClickPulse 0.45s ease;
}
@keyframes tutorialClickPulse {
0% { opacity: 1; transform: scale(0.6); }
100% { opacity: 0; transform: scale(1.8); }
}
.tutorial-popover {
position: fixed;
width: min(360px, calc(100vw - 24px));
border-radius: 14px;
border: 1px solid var(--theme-control-border-strong);
background: var(--theme-surface-card);
color: var(--claude-text);
box-shadow: var(--theme-shadow-strong);
padding: 14px 14px 12px;
pointer-events: auto;
z-index: 4;
}
.tutorial-popover.is-hidden {
opacity: 0;
pointer-events: none;
}
.tutorial-popover__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.tutorial-popover__step {
font-size: 12px;
font-weight: 700;
color: var(--claude-text-secondary);
}
.tutorial-popover__close {
border: 1px solid var(--theme-control-border);
background: var(--theme-surface-soft);
color: var(--claude-text);
border-radius: 999px;
padding: 4px 10px;
font-size: 12px;
cursor: pointer;
}
.tutorial-popover__title {
margin: 0;
font-size: 16px;
}
.tutorial-popover__desc {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.58;
color: var(--claude-text-secondary);
}
.tutorial-popover__hint,
.tutorial-popover__warn {
margin: 10px 0 0;
font-size: 12px;
}
.tutorial-popover__hint {
color: var(--claude-accent-strong);
}
.tutorial-popover__warn {
color: #dc6a53;
}
.tutorial-popover__actions {
margin-top: 12px;
display: flex;
gap: 8px;
justify-content: flex-end;
flex-wrap: wrap;
}
.tutorial-btn {
border: none;
border-radius: 10px;
padding: 7px 12px;
font-size: 12px;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, var(--claude-accent), var(--claude-accent-strong));
cursor: pointer;
}
.tutorial-btn:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.tutorial-btn--ghost {
color: var(--claude-text);
border: 1px solid var(--theme-control-border);
background: var(--theme-surface-soft);
}