feat: complete guided tutorial with mobile flow and ui polish
This commit is contained in:
parent
e804ef52d2
commit
692a12affd
@ -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
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
456
static/src/components/overlay/TutorialOverlay.vue
Normal file
456
static/src/components/overlay/TutorialOverlay.vue
Normal 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>
|
||||
@ -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' }"
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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')"
|
||||
|
||||
@ -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">
|
||||
|
||||
270
static/src/stores/tutorial.ts
Normal file
270
static/src/stores/tutorial.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user