导师相关接口换offer派接口,职位列表和职位详情,Nova聊天侧边栏UI重写

This commit is contained in:
2026-07-03 19:55:53 +08:00
parent a1a27073e4
commit f90c8be2a9
26 changed files with 896 additions and 1129 deletions
+82 -74
View File
@@ -1,40 +1,32 @@
<template>
<!-- Nova AI 对话面板 -->
<div class="ai-chat">
<!-- 顶部会员横幅 点击打开会员购买弹窗 -->
<div class="ai-chat__banner" @click="showMemberDialog = true">
<span class="dflex-start aliite-c"> <img src="@/assets/images/bule-flash.png" alt="Offer派" class="w16 h19 mr5" /> <span>升级 Pro 开启求职加速</span></span>
<span class="ai-chat__banner-arrow"></span>
</div>
<!-- AI 头像和名称 -->
<!-- 顶部头部区域 -->
<div class="ai-chat__header">
<div class="ai-chat__avatar">👤</div>
<div class="ai-chat__info">
<div class="ai-chat__name">Nova</div>
<div class="ai-chat__desc">你的AI求职助手</div>
<div class="ai-chat__header-left">
<!-- Nova 头像 -->
<img class="ai-chat__avatar" src="@/assets/images/nova-logo.png" alt="Nova" />
<!-- 名称和副标题 -->
<div class="ai-chat__info">
<div class="ai-chat__name">Nova</div>
<div class="ai-chat__desc">你的AI求职助理</div>
</div>
</div>
<!-- 在线状态标签 -->
<div class="ai-chat__online-tag">
<span class="ai-chat__online-dot"></span>
<span>在线</span>
</div>
</div>
<!-- 聊天消息区域 -->
<div class="ai-chat__messages" ref="messagesRef">
<!-- AI 欢迎消息 -->
<div class="ai-chat__msg ai-chat__msg--ai">
<div class="ai-chat__msg-bubble">
<div class="ai-chat__msg-title">欢迎回来{{nickName}}!</div>
<div class="ai-chat__msg-text">很高兴再次见到你让我们继续您通往理想工作的旅程吧</div>
</div>
</div>
<div class="ai-chat__messages scrollbar-thin" ref="messagesRef">
<!-- 快捷问题仅岗位详情页显示3个用户提问靠右 -->
<div v-if="userQuestions.length > 0" class="ai-chat__quick-questions">
<div
v-for="(q, i) in userQuestions"
:key="i"
class="ai-chat__quick-item ai-chat__quick-item--user"
@click="sendQuickQuestion(q)"
>
{{ q }}
</div>
<!-- 固定欢迎气泡 -->
<div class="ai-chat__welcome-bubble">
<div class="ai-chat__welcome-title">欢迎使用 Offer派👋</div>
<div class="ai-chat__welcome-text">从找岗位到投递跟进我会陪你一步步拿下理想Offer</div>
</div>
<!-- 动态消息列表 -->
@@ -47,8 +39,33 @@
<div class="ai-chat__msg-bubble" v-html="formatContent(msg.content)"></div>
</div>
<!-- AI 正在思考中加载指示器 -->
<AiThinkingIndicator v-if="aiLoading" text="AI正在思考中" />
<!-- AI 等待中 3 点动效 -->
<div v-if="aiLoading" class="ai-chat__msg ai-chat__msg--ai">
<div class="ai-chat__typing-indicator">
<span class="ai-chat__typing-dot"></span>
<span class="ai-chat__typing-dot"></span>
<span class="ai-chat__typing-dot"></span>
</div>
</div>
</div>
<!-- 话题推荐模块固定在输入框上方 -->
<div class="ai-chat__topics">
<div class="ai-chat__topics-header">
<svg class="ai-chat__topics-icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M8 1L10 6L15 6.5L11.5 10L12.5 15L8 12.5L3.5 15L4.5 10L1 6.5L6 6L8 1Z" fill="#52CAD1"/>
</svg>
<span class="ai-chat__topics-title">话题推荐</span>
</div>
<div
v-for="(topic, i) in topicList"
:key="i"
class="ai-chat__topic-btn"
@click="sendQuickQuestion(topic)"
>
{{ topic }}
</div>
</div>
<!-- 底部输入框 -->
@@ -56,17 +73,17 @@
<input
v-model="inputText"
class="ai-chat__input"
placeholder="搜索职位、公司或关键词..."
placeholder="请输入你想问的问题…"
@keyup.enter="sendMessage"
:disabled="aiLoading"
/>
<button class="ai-chat__send-btn" @click="sendMessage" :disabled="aiLoading">
<span></span>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M4 10L16 4L13 10L16 16L4 10Z" fill="#FFFFFF"/>
</svg>
</button>
</div>
<!-- 会员购买弹窗 -->
<MemberDialog v-model="showMemberDialog" />
</div>
</template>
@@ -75,8 +92,6 @@ import { ref, computed, watch, nextTick, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useStore } from 'vuex'
import markdownit from 'markdown-it'
import MemberDialog from '@/components/MemberDialog.vue'
import AiThinkingIndicator from '@/components/tools/AiThinkingIndicator.vue'
import { sendNovaChat } from '@/utils/aiRequest'
import type { NovaChatHistoryItem } from '@/utils/aiRequest'
import { fetchResumeList } from '@/api/resume'
@@ -85,9 +100,9 @@ import { fetchResumeList } from '@/api/resume'
/** markdown-it 实例,用于将 AI 回复的 Markdown 文本渲染为 HTML */
const md = markdownit({
html: false, // 禁止原始 HTML(防 XSS
breaks: true, // 将换行符转为 <br>
linkify: true, // 自动识别链接
html: false,
breaks: true,
linkify: true,
})
// ==================== Props ====================
@@ -98,23 +113,16 @@ const props = defineProps<{
jobId?: string
}>()
/** 当前路由(用于判断是否在岗位详情页) */
/** 当前路由 */
const currentRoute = useRoute()
const store = useStore()
/** 用户昵称 — 从全局 store 读取 */
const nickName = computed(() => store.state.userInfo?.nick || '')
// ==================== 状态 ====================
/** 会员购买弹窗的显示状态 */
const showMemberDialog = ref(false)
/** 输入框内容 */
const inputText = ref('')
/** 消息列表容器 DOM 引用(用于滚动控制) */
/** 消息列表容器 DOM 引用 */
const messagesRef = ref<HTMLElement>()
/** AI 是否正在请求中 */
@@ -123,13 +131,20 @@ const aiLoading = ref(false)
/** 默认简历 ID */
const defaultResumeId = ref<string>('')
// ==================== 话题推荐列表 ====================
/** 固定话题推荐(始终显示) */
const topicList = [
'我适合投哪些岗位?',
'帮我看看我的简历可以怎么优化?',
'近期有哪些岗位值得投?',
]
// ==================== 类型定义 ====================
/** 聊天消息类型 */
interface ChatMsg {
/** 角色:assistant-AI助手 user-用户 */
role: 'assistant' | 'user'
/** 消息内容 */
content: string
}
@@ -137,8 +152,6 @@ interface ChatMsg {
/** 获取当前用户的缓存 key */
function getCacheKey(): string {
// 用 sessionStorage 中的登录状态 + 一个简单标识区分用户
// 如果有更精确的用户 ID 可以替换
const userId = sessionStorage.getItem('userId') || 'anonymous'
return `nova_chat_${userId}`
}
@@ -175,19 +188,6 @@ const messages = ref<ChatMsg[]>([])
/** 是否在岗位详情页 */
const isJobDetailPage = computed(() => currentRoute.name === 'JobDetail' || currentRoute.path.startsWith('/jobs/'))
/** 快捷问题列表(仅岗位详情页显示的 3 个用户提问) */
const quickQuestions = computed(() => {
if (!isJobDetailPage.value) return []
return [
'告诉我这个工作为什么适合我?',
'我想修改这个岗位,怎么优化简历?',
'帮我针对这个岗位生成一份面试攻略',
]
})
/** 用户提问列表(等同于 quickQuestions */
const userQuestions = computed(() => quickQuestions.value)
// ==================== 内容格式化 ====================
/** 将消息内容通过 markdown-it 渲染为 HTML */
@@ -208,7 +208,7 @@ function scrollToBottom() {
// ==================== 加载默认简历 ID ====================
/** 加载默认简历 ID(用于 Nova 对话接口) */
/** 加载默认简历 ID */
async function loadDefaultResumeId() {
try {
const res = await fetchResumeList()
@@ -223,13 +223,13 @@ async function loadDefaultResumeId() {
// ==================== 事件处理 ====================
/** 点击快捷问题,填入输入框并发送 */
/** 点击话题推荐,填入输入框并发送 */
function sendQuickQuestion(question: string) {
inputText.value = question
sendMessage()
}
/** 发送消息(回车或点击发送按钮触发) */
/** 发送消息 */
async function sendMessage() {
if (!inputText.value.trim() || aiLoading.value) return
@@ -256,8 +256,8 @@ async function sendMessage() {
jobId: props.jobId || undefined,
history,
})
// 添加 AI 回复消息
const aiReply = res?.data?.message || res?.message || '抱歉,我暂时无法回答这个问题。'
// 从 res.data.message 取 AI 回复(接口返回结构:{ code, msg, data: { message } }
const aiReply = res?.data?.message || '抱歉,我暂时无法回答这个问题。'
messages.value.push({ role: 'assistant', content: aiReply })
} catch (e) {
console.error('Nova 对话请求失败', e)
@@ -269,8 +269,6 @@ async function sendMessage() {
}
}
// ==================== 动态宽度:小屏缩放时逐渐缩小 ====================
// ==================== 生命周期 ====================
onMounted(() => {
@@ -285,13 +283,23 @@ onMounted(() => {
}
})
// 监听 jobId 变化Jobs 页面点击"问助手"按钮时触发),自动插入 AI 提问消息
// 监听 jobId 变化
watch(() => props.jobId, (newId, oldId) => {
if (newId && newId !== oldId && !isJobDetailPage.value) {
// Jobs 页面:以 AI 身份插入提问到对话记录
messages.value.push({ role: 'assistant', content: '你想知道关于这个岗位的什么信息?' })
saveChatToCache()
scrollToBottom()
}
})
// ==================== 对外暴露方法 ====================
/** 触发"问Nova"交互:推送提示消息并聚焦输入框 */
function triggerAsk() {
messages.value.push({ role: 'assistant', content: '你想知道关于这个岗位的什么信息?' })
saveChatToCache()
scrollToBottom()
}
defineExpose({ triggerAsk })
</script>
+20 -18
View File
@@ -1,21 +1,23 @@
<template>
<div>
<div class="job-page-header">
<div class="bg-f mb16 prl24 ptb16">
<div class="job-page-header dflex-start aliite-c">
<h2 class="job-page-header__title">发现理想职位</h2>
<p class="job-page-header__subtitle">找到最适合你的工作机会</p>
</div>
<div class="job-page-header__tabs mt20">
<div
v-for="(tab,index) in tabs"
:key="tab.key"
class="job-page-header__tab"
:class="{ 'job-page-header__tab--active': activeTab === tab.key }"
:style="index==0?'padding:0.06rem 0.30rem;':''"
@click="handleTabClick(tab.key)"
>
{{ tab.label }}
<div class="job-page-header__tabs ml30 ">
<div
v-for="(tab,index) in tabs"
:key="tab.key"
class="job-page-header__tab"
:class="{ 'job-page-header__tab--active': activeTab === tab.key }"
:style="index==0?'padding:0.06rem 0.20rem;':''"
@click="handleTabClick(tab.key)"
>
{{ tab.label }}
<!-- 收藏/已投递 计数圆形徽章 -->
<span v-if="tab.count !== undefined" class="job-page-header__tab-badge">{{ tab.count }}</span>
</div>
</div>
</div>
</div>
</template>
@@ -46,11 +48,11 @@ const route = useRoute()
// ==================== 常量数据 ====================
/** Tab 选项列表 */
/** Tab 选项列表count 用于显示圆形徽章计数 */
const tabs = computed(() => [
{ key: 'recommend', label: '推荐' },
{ key: 'collected', label: `收藏(${props.favoriteCount ?? 0}` },
{ key: 'applied', label: `投递(${props.applyCount ?? 0}` },
{ key: 'recommend', label: '推荐', count: undefined },
{ key: 'collected', label: '收藏', count: props.favoriteCount ?? 0 },
{ key: 'applied', label: '已投递', count: props.applyCount ?? 0 },
])
// ==================== 事件处理 ====================
+9 -52
View File
@@ -1,49 +1,10 @@
<template>
<!-- 岗位专属简历定制弹窗步骤1居中弹窗步骤2+右侧抽屉 -->
<div v-if="modelValue" class="job-resume-custom-dialog" :class="{ 'job-resume-custom-dialog--drawer': currentStep >= 2 }">
<!-- 岗位专属简历定制弹窗直接显示右侧抽屉 -->
<div v-if="modelValue" class="job-resume-custom-dialog job-resume-custom-dialog--drawer">
<div class="job-resume-custom-dialog__overlay" @click="handleClose"></div>
<!-- ===== 步骤一居中弹窗 ===== -->
<div v-if="currentStep === 1" class="job-resume-custom-dialog__panel">
<div class="job-resume-custom-dialog__header">
<h2 class="job-resume-custom-dialog__title">10s快速定制岗位专属简历</h2>
<button class="job-resume-custom-dialog__close-btn" @click="handleClose" aria-label="关闭">
<svg viewBox="0 0 16 16" fill="none" class="job-resume-custom-dialog__close-icon"><path d="M12 4L4 12M4 4l8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
</div>
<div v-if="isLowMatch" class="job-resume-custom-dialog__tip-bar"><span>你与该岗位的匹配度较低简历可能无法通过机筛</span></div>
<div class="job-resume-custom-dialog__job-card">
<div class="job-resume-custom-dialog__job-left">
<div class="job-resume-custom-dialog__company-icon">
<img v-if="jobInfo.companyLogoUrl" :src="jobInfo.companyLogoUrl" :alt="jobInfo.company" class="job-resume-custom-dialog__company-logo-img" />
<svg v-else viewBox="0 0 24 24" fill="none" class="job-resume-custom-dialog__company-svg"><rect x="3" y="7" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/><path d="M7 7V5a2 2 0 012-2h6a2 2 0 012 2v2" stroke="currentColor" stroke-width="1.5"/><path d="M3 13h18" stroke="currentColor" stroke-width="1.5"/></svg>
</div>
<div class="job-resume-custom-dialog__job-info">
<span class="job-resume-custom-dialog__job-title">{{ jobInfo.title }}</span>
<span class="job-resume-custom-dialog__job-sub">{{ jobInfo.location }} · {{ jobInfo.company }}</span>
</div>
</div>
<div class="job-resume-custom-dialog__match-area">
<div class="job-resume-custom-dialog__match-ring">
<svg viewBox="0 0 60 60" class="job-resume-custom-dialog__ring-svg"><circle cx="30" cy="30" r="24" stroke-width="4" stroke="#E8E8E8" fill="none" opacity="0.3"/><circle cx="30" cy="30" r="24" stroke-width="4" fill="none" stroke="#4FC2C9" stroke-linecap="round" :stroke-dasharray="2*Math.PI*24" :stroke-dashoffset="2*Math.PI*24*(1-jobInfo.matchScore/10)" transform="rotate(-90 30 30)"/></svg>
<span class="job-resume-custom-dialog__match-score">{{ jobInfo.matchScore }}</span>
</div>
<span class="job-resume-custom-dialog__match-label">{{ matchLevelText }}</span>
</div>
</div>
<div class="job-resume-custom-dialog__skills-section">
<p class="job-resume-custom-dialog__skills-title">缺少{{ missingSkills.length }}项技能</p>
<div class="job-resume-custom-dialog__skills-list">
<span v-for="skill in missingSkills" :key="skill" class="job-resume-custom-dialog__skill-tag">{{ skill }}</span>
</div>
</div>
<div class="job-resume-custom-dialog__footer">
<button class="job-resume-custom-dialog__primary-btn" @click="goToStep(2)">立即定制简历</button>
<span class="job-resume-custom-dialog__skip-link" @click="handleSkip">不优化直接投递</span>
</div>
</div>
<!-- ===== 步骤2+右侧抽屉 ===== -->
<div v-if="currentStep >= 2" class="job-resume-custom-dialog__drawer">
<!-- ===== 右侧抽屉 ===== -->
<div class="job-resume-custom-dialog__drawer">
<!-- 抽屉头部 -->
<div class="job-resume-custom-dialog__drawer-header">
<button class="job-resume-custom-dialog__close-btn" @click="handleClose" aria-label="关闭">
@@ -62,7 +23,7 @@
<span class="job-resume-custom-dialog__step-num">2</span><span>定制简历</span>
</div>
<div class="job-resume-custom-dialog__step" :class="{ 'job-resume-custom-dialog__step--active': currentStep === 4 }">
<span class="job-resume-custom-dialog__step-num">3</span><span>预览</span>
<span class="job-resume-custom-dialog__step-num">3</span><span>简历预览</span>
</div>
</div>
<!-- 抽屉内容区可滚动 -->
@@ -423,8 +384,8 @@ const fullLoading = ref(false)
/** 全屏加载文案 */
const fullLoadingText = ref('')
/** 当前步骤:1-确认入口(居中弹窗) 2-差距分析(右侧抽屉) 3-定制简历 4-预览 */
const currentStep = ref(1)
/** 当前步骤:2-差距分析(右侧抽屉) 3-定制简历 4-预览 */
const currentStep = ref(2)
/** 缺少的技能列表 */
const missingSkills = computed(() => props.jobInfo.missingSkills || [])
@@ -602,7 +563,7 @@ function onEditPanelUpdate(data: CustomizeResumeData) {
/** 关闭弹窗并重置步骤 */
function handleClose() {
currentStep.value = 1
currentStep.value = 2
showResumeDropdown.value = false
showDownloadMenu.value = false
// 重置AI对话和差异对比状态
@@ -613,11 +574,7 @@ function handleClose() {
emit('update:modelValue', false)
}
/** 不优化,直接投递 */
function handleSkip() {
handleClose()
emit('skip')
}
// ==================== 定制简历选项(步骤三) ====================
+7 -1
View File
@@ -41,7 +41,13 @@
</span>
<!-- 作品集链接显示在个人信息卡片内 -->
<span v-if="profile.portfolioUrl" class="profile-page-content__info-contacts">
<span class="profile-page-content__contact-item">🔗 <a :href="profile.portfolioUrl" target="_blank" rel="noopener noreferrer" class="profile-page-content__portfolio-link">{{ profile.portfolioUrl }}</a></span>
<span class="profile-page-content__contact-item dib">
<svg xmlns="http://www.w3.org/2000/svg" class="resume-detail__contact-icon mr6" width="0.15rem" height="0.15rem" viewBox="0 0 16 16">
<path d="M0 0h16v16H0z" fill="none" />
<path fill="none" stroke="currentColor" d="M14.5 8A6.5 6.5 0 0 1 8 14.5M14.5 8A6.5 6.5 0 0 0 8 1.5M14.5 8c0 1.657-2.91 3-6.5 3S1.5 9.657 1.5 8m13 0c0-1.657-2.91-3-6.5-3S1.5 6.343 1.5 8M8 14.5A6.5 6.5 0 0 1 1.5 8M8 14.5c1.657 0 3-2.91 3-6.5S9.657 1.5 8 1.5m0 13c-1.657 0-3-2.91-3-6.5s1.343-6.5 3-6.5M1.5 8A6.5 6.5 0 0 1 8 1.5" />
</svg>
<a :href="profile.portfolioUrl?.startsWith('http') ? profile.portfolioUrl : `http://${profile.portfolioUrl}`" target="_blank" rel="noopener noreferrer" class="profile-page-content__portfolio-link">{{ profile.portfolioUrl }}</a>
</span>
</span>
</div>
</template>
+7 -1
View File
@@ -3,11 +3,12 @@
<div class="industry-selector" ref="selectorRef">
<!-- 触发按钮显示已选行业名称或默认文字"行业" -->
<div class="industry-selector__trigger" :style="triggerStyle" @click="toggleDropdown">
<div class="industry-selector__trigger" :style="{ ...triggerStyle, ...(isHovered || visible ? hoverStyle : {}) }" @click="toggleDropdown" @mouseenter="isHovered = true" @mouseleave="isHovered = false">
<span class="industry-selector__display" :style="displayStyle" :title="displayText">{{ displayText }}</span>
<svg
class="industry-selector__arrow"
:class="{ 'industry-selector__arrow--open': visible }"
:style="(isHovered || visible) && hoverStyle?.color ? { color: hoverStyle.color } : {}"
viewBox="0 0 12 12"
fill="none"
>
@@ -147,6 +148,8 @@ const props = withDefaults(
level?: 1 | 2
/** 父组件传入的触发按钮自定义样式,用于在不同场景下覆盖默认外观 */
triggerStyle?: Record<string, string>
/** 父组件传入的触发按钮 hover 时的自定义样式 */
hoverStyle?: Record<string, string>
/** 父组件传入的显示文字自定义样式,用于覆盖 max-width 等默认样式 */
displayStyle?: Record<string, string>
}>(),
@@ -170,6 +173,9 @@ const selectorRef = ref<HTMLElement | null>(null)
/** 下拉面板是否可见 */
const visible = ref(false)
/** 触发按钮是否处于 hover 状态 */
const isHovered = ref(false)
/** 搜索关键词 */
const searchText = ref('')
+7 -1
View File
@@ -3,11 +3,12 @@
<div class="job-category-selector" ref="selectorRef">
<!-- 触发按钮显示已选岗位名称或默认文字"岗位" -->
<div class="job-category-selector__trigger" :style="triggerStyle" @click="toggleDropdown">
<div class="job-category-selector__trigger" :style="{ ...triggerStyle, ...(isHovered || visible ? hoverStyle : {}) }" @click="toggleDropdown" @mouseenter="isHovered = true" @mouseleave="isHovered = false">
<span class="job-category-selector__display" :style="displayStyle" :title="displayText">{{ displayText }}</span>
<svg
class="job-category-selector__arrow"
:class="{ 'job-category-selector__arrow--open': visible }"
:style="(isHovered || visible) && hoverStyle?.color ? { color: hoverStyle.color } : {}"
viewBox="0 0 12 12"
fill="none"
>
@@ -170,6 +171,8 @@ const props = withDefaults(
level?: 1 | 2 | 3
/** 父组件传入的触发按钮自定义样式,用于在不同场景下覆盖默认外观 */
triggerStyle?: Record<string, string>
/** 父组件传入的触发按钮 hover 时的自定义样式 */
hoverStyle?: Record<string, string>
/** 父组件传入的显示文字自定义样式,用于覆盖 max-width 等默认样式 */
displayStyle?: Record<string, string>
}>(),
@@ -193,6 +196,9 @@ const selectorRef = ref<HTMLElement | null>(null)
/** 下拉面板是否可见 */
const visible = ref(false)
/** 触发按钮是否处于 hover 状态 */
const isHovered = ref(false)
/** 搜索关键词 */
const searchText = ref('')
+7 -1
View File
@@ -3,11 +3,12 @@
<div class="region-selector" ref="selectorRef">
<!-- 触发按钮显示已选地区名称或默认文字"城市" -->
<div class="region-selector__trigger" :style="triggerStyle" @click="toggleDropdown">
<div class="region-selector__trigger" :style="{ ...triggerStyle, ...(isHovered || visible ? hoverStyle : {}) }" @click="toggleDropdown" @mouseenter="isHovered = true" @mouseleave="isHovered = false">
<span class="region-selector__display" :style="displayStyle" :title="displayText">{{ displayText }}</span>
<svg
class="region-selector__arrow"
:class="{ 'region-selector__arrow--open': visible }"
:style="(isHovered || visible) && hoverStyle?.color ? { color: hoverStyle.color } : {}"
viewBox="0 0 12 12"
fill="none"
>
@@ -157,6 +158,8 @@ const props = withDefaults(
maxSelect?: number
/** 父组件传入的触发按钮自定义样式,用于在不同场景下覆盖默认外观 */
triggerStyle?: Record<string, string>
/** 父组件传入的触发按钮 hover 时的自定义样式 */
hoverStyle?: Record<string, string>
/** 父组件传入的显示文字自定义样式,用于覆盖 max-width 等默认样式 */
displayStyle?: Record<string, string>
}>(),
@@ -179,6 +182,9 @@ const selectorRef = ref<HTMLElement | null>(null)
/** 下拉面板是否可见 */
const visible = ref(false)
/** 触发按钮是否处于 hover 状态 */
const isHovered = ref(false)
/** 搜索关键词 */
const searchText = ref('')