mirror of
https://github.com/stardrophere/InsightRadar.git
synced 2026-06-05 23:32:49 +08:00
optimize+注释
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
<!-- 根组件:路由出口,带页面切换淡入淡出动画 -->
|
||||
<template>
|
||||
<RouterView v-slot="{ Component }">
|
||||
<transition name="page-fade" mode="out-in">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { pinia } from '@/stores'
|
||||
import { fetchApi } from '@/config/apiBase'
|
||||
|
||||
// 后端错误消息中英文映射。
|
||||
// 后端英文错误消息到中文的映射
|
||||
const MESSAGE_MAP: Record<string, string> = {
|
||||
'You can only operate your own resources': '只能操作你自己的资源',
|
||||
'Preference keyword already exists for this user': '该关键词已经订阅过了',
|
||||
|
||||
@@ -36,7 +36,7 @@ export function searchEventsTimeline(
|
||||
hours: number = 168,
|
||||
mode: 'exact' | 'semantic' | 'hybrid' = 'hybrid'
|
||||
): Promise<SearchTimelineResponse> {
|
||||
// JS 的 getTimezoneOffset: 本地 - UTC(东八区是 -480),这里转成 UTC+ 偏移分钟。
|
||||
// 获取客户端时区偏移:getTimezoneOffset 为 本地-UTC 分钟,东八区为 -480,转为 UTC+ 偏移
|
||||
const utcOffsetMinutes = -new Date().getTimezoneOffset()
|
||||
return apiGet<SearchTimelineResponse>('/events/search_timeline', {
|
||||
keyword,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* 认证 API:登录、注册、发送验证码(不走通用 client,无 Bearer)
|
||||
*/
|
||||
import type {
|
||||
AuthTokenResponse,
|
||||
LoginPayload,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- 品牌 Logo:雷达扫描风格 SVG,带呼吸灯与旋转动画 -->
|
||||
<template>
|
||||
<div class="brand-logo-container">
|
||||
<svg class="insight-logo" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | number
|
||||
options: { label: string; value: string | number }[]
|
||||
icon: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | number): void
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const selectRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const toggle = () => {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
const selectOption = (value: string | number) => {
|
||||
emit('update:modelValue', value)
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (selectRef.value && !selectRef.value.contains(event.target as Node)) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="custom-select-wrapper" ref="selectRef">
|
||||
<i :class="['select-icon', icon]"></i>
|
||||
<div class="select-trigger" :class="{ 'is-open': isOpen }" @click="toggle">
|
||||
<span class="selected-label">
|
||||
{{ options.find(o => o.value === modelValue)?.label }}
|
||||
</span>
|
||||
<i class="fa-solid fa-chevron-down select-arrow"></i>
|
||||
</div>
|
||||
|
||||
<transition name="dropdown">
|
||||
<ul v-if="isOpen" class="select-dropdown">
|
||||
<li
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
class="select-option"
|
||||
:class="{ 'is-active': option.value === modelValue }"
|
||||
@click="selectOption(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</li>
|
||||
</ul>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.custom-select-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.select-icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
color: var(--text-placeholder);
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.select-arrow {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
color: var(--text-placeholder);
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
transition: color 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.select-trigger {
|
||||
width: 100%;
|
||||
padding: 12px 34px 12px 38px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.select-trigger:hover {
|
||||
border-color: var(--border-strong);
|
||||
background-color: var(--bg-surface);
|
||||
}
|
||||
|
||||
.select-trigger.is-open {
|
||||
border-color: var(--brand-primary);
|
||||
box-shadow: 0 0 0 3px var(--brand-primary-alpha);
|
||||
background-color: var(--bg-surface);
|
||||
}
|
||||
|
||||
.select-trigger.is-open .select-arrow {
|
||||
transform: rotate(180deg);
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.custom-select-wrapper:hover .select-icon,
|
||||
.custom-select-wrapper:hover .select-arrow {
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.select-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
list-style: none;
|
||||
background: var(--bg-surface);
|
||||
backdrop-filter: var(--backdrop-blur, blur(12px));
|
||||
-webkit-backdrop-filter: var(--backdrop-blur, blur(12px));
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||
z-index: 100;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.select-option {
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.select-option:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.select-option:hover {
|
||||
background-color: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.select-option.is-active {
|
||||
background-color: var(--brand-primary-alpha);
|
||||
color: var(--brand-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 滚动条美化 */
|
||||
.select-dropdown::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.select-dropdown::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.select-dropdown::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-strong);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 过渡动画 */
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
.dropdown-enter-from,
|
||||
.dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
@@ -4,17 +4,17 @@ import { useThemeStore } from '@/stores/theme'
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
/**
|
||||
* 切换主题,使用 View Transitions API 实现高级扩散动画(如果浏览器支持)
|
||||
* 这种动画比之前像玩具一样的开关要高级得多,提供原生级的丝滑过渡
|
||||
* 切换主题:支持 View Transitions API 时使用点击位置扩散动画,
|
||||
* 否则直接切换
|
||||
*/
|
||||
function handleToggle(event: MouseEvent) {
|
||||
// 检查浏览器是否支持 document.startViewTransition 并且用户没有开启减弱动画
|
||||
// 检测浏览器是否支持 View Transitions 且用户未开启减弱动画
|
||||
const isAppearanceTransition = typeof document !== 'undefined' &&
|
||||
'startViewTransition' in document &&
|
||||
!window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
if (!isAppearanceTransition) {
|
||||
// 降级处理:直接切换
|
||||
// 不支持时直接切换,无动画
|
||||
themeStore.toggleTheme()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- 统一事件卡片:展示标题、摘要、平台来源、排名轨迹,悬停展开图表 -->
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* API 基础配置:自动探测内网/公网后端,失败时回退公网
|
||||
*/
|
||||
const API_PREFIX = '/api/v1'
|
||||
const LAN_BACKEND_ORIGIN = 'http://10.252.130.135:8000'
|
||||
const PUBLIC_BACKEND_ORIGIN = 'http://47.107.130.88:51290'
|
||||
@@ -42,6 +45,7 @@ function isLanHostname(hostname: string): boolean {
|
||||
return isPrivateIpv4(normalized)
|
||||
}
|
||||
|
||||
// 探测内网后端是否可用(请求 openapi.json)
|
||||
async function probeLanBackend(): Promise<boolean> {
|
||||
if (typeof window === 'undefined') return false
|
||||
|
||||
@@ -69,6 +73,7 @@ async function probeLanBackend(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
// 根据当前 hostname 与探测结果选择内网或公网 API 地址
|
||||
async function detectApiBaseUrl(): Promise<string> {
|
||||
if (ENV_API_BASE_URL) return ENV_API_BASE_URL
|
||||
if (typeof window === 'undefined') return PUBLIC_API_BASE_URL
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- 仪表盘布局:侧边栏导航、主内容区、移动端抽屉 -->
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* 应用入口:初始化 Vue、Pinia、路由、主题
|
||||
*/
|
||||
import './assets/main.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
@@ -9,6 +12,7 @@ import { useThemeStore } from './stores/theme'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// 先挂载 Pinia,再初始化主题(依赖 Pinia)
|
||||
app.use(pinia)
|
||||
const themeStore = useThemeStore(pinia)
|
||||
themeStore.initTheme()
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* 路由配置:认证页(guestOnly)、仪表盘子路由(requiresAuth)
|
||||
*/
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import { pinia } from '@/stores'
|
||||
@@ -59,6 +62,7 @@ const router = createRouter({
|
||||
],
|
||||
})
|
||||
|
||||
// 全局前置守卫:认证校验、未登录重定向
|
||||
router.beforeEach((to) => {
|
||||
const authStore = useAuthStore(pinia)
|
||||
authStore.restore()
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* 认证状态:Token、用户信息持久化,登录/注册/登出
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
@@ -12,6 +15,7 @@ interface PersistedAuthState {
|
||||
|
||||
const AUTH_STORAGE_KEY = 'insight-radar-auth'
|
||||
|
||||
// 从 localStorage 恢复登录状态
|
||||
function loadPersistedState(): PersistedAuthState | null {
|
||||
const raw = localStorage.getItem(AUTH_STORAGE_KEY)
|
||||
if (!raw) {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* 主题状态:深色/浅色切换,持久化到 localStorage,支持系统偏好
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<!-- 关于页(占位) -->
|
||||
<template>
|
||||
<div class="about">
|
||||
<h1>This is an about page</h1>
|
||||
<h1>关于 InsightRadar</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- 主仪表盘:事件流、为你推荐、公关修改追踪、系统状态 -->
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- 推送设置页:管理推送时间表与推送渠道(邮箱等) -->
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- 概览页:展示当前账户、会话状态、认证接入说明 -->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- 登录页:支持密码登录与邮箱验证码登录 -->
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@@ -8,6 +9,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
type LoginMode = 'password' | 'code'
|
||||
|
||||
// 验证码重发冷却时间(秒)
|
||||
const CODE_RESEND_SECONDS = 60
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- 注册页:邮箱验证码 + 密码,带密码强度提示 -->
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -34,7 +35,7 @@ const canSendCode = computed(() => {
|
||||
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.email)
|
||||
})
|
||||
|
||||
// === 新增:密码强度计算逻辑 ===
|
||||
// 密码强度:根据长度、字母、数字、特殊字符计算 0~4 档
|
||||
const passwordStrength = computed(() => {
|
||||
const pwd = form.password
|
||||
if (!pwd) return 0
|
||||
@@ -52,8 +53,6 @@ const strengthColor = computed(() => {
|
||||
return colors[passwordStrength.value]
|
||||
})
|
||||
|
||||
// ==========================
|
||||
|
||||
function startCooldown(seconds = CODE_RESEND_SECONDS) {
|
||||
countdown.value = Math.max(1, seconds)
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- 公关修改追踪页:展示热搜标题被偷偷修改的历史记录 -->
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<!-- 事件追踪分析页:关键词搜索、时间热度图表、关联事件列表 -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import VueApexCharts from 'vue3-apexcharts'
|
||||
import { searchEventsTimeline } from '@/api/events'
|
||||
import type { SearchTimelineResponse } from '@/types/event'
|
||||
import UnifiedEventCard from '@/components/UnifiedEventCard.vue'
|
||||
import CustomSelect from '@/components/CustomSelect.vue'
|
||||
|
||||
const keyword = ref('')
|
||||
const searchResult = ref<SearchTimelineResponse | null>(null)
|
||||
@@ -12,6 +14,22 @@ const hours = ref(2)
|
||||
const searchMode = ref<'exact' | 'semantic' | 'hybrid'>('hybrid')
|
||||
const selectedTimeLabel = ref<string | null>(null)
|
||||
|
||||
const timeOptions = [
|
||||
{ label: '最近 2 小时', value: 2 },
|
||||
{ label: '最近 12 小时', value: 12 },
|
||||
{ label: '最近 24 小时', value: 24 },
|
||||
{ label: '最近 48 小时', value: 48 },
|
||||
{ label: '最近 7 天', value: 168 },
|
||||
{ label: '最近 15 天', value: 360 }
|
||||
]
|
||||
|
||||
const modeOptions = [
|
||||
{ label: '混合匹配', value: 'hybrid' },
|
||||
{ label: '关键词匹配', value: 'exact' },
|
||||
{ label: '语义匹配', value: 'semantic' }
|
||||
]
|
||||
|
||||
// 根据选中的时间点筛选事件:点击图表节点时只显示该时间点关联的事件
|
||||
const filteredEvents = computed(() => {
|
||||
if (!searchResult.value) return []
|
||||
if (!selectedTimeLabel.value) return searchResult.value.events
|
||||
@@ -51,6 +69,7 @@ const chartOptions = ref({
|
||||
easing: 'easeinout',
|
||||
speed: 800,
|
||||
},
|
||||
// 点击图表数据点:切换选中时间,再次点击则取消筛选
|
||||
events: {
|
||||
markerClick: function(event: any, chartContext: any, { dataPointIndex }: any) {
|
||||
if (searchResult.value && searchResult.value.timeline[dataPointIndex]) {
|
||||
@@ -188,27 +207,16 @@ async function handleSearch() {
|
||||
class="search-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="time-select-wrapper">
|
||||
<i class="fa-regular fa-clock select-icon"></i>
|
||||
<select v-model="hours" class="time-select">
|
||||
<option :value="2">最近 2 小时</option>
|
||||
<option :value="12">最近 12 小时</option>
|
||||
<option :value="24">最近 24 小时</option>
|
||||
<option :value="48">最近 48 小时</option>
|
||||
<option :value="168">最近 7 天</option>
|
||||
<option :value="360">最近 15 天</option>
|
||||
</select>
|
||||
<i class="fa-solid fa-chevron-down select-arrow"></i>
|
||||
</div>
|
||||
<div class="time-select-wrapper">
|
||||
<i class="fa-solid fa-filter select-icon"></i>
|
||||
<select v-model="searchMode" class="time-select">
|
||||
<option value="hybrid">混合匹配</option>
|
||||
<option value="exact">关键词匹配</option>
|
||||
<option value="semantic">语义匹配</option>
|
||||
</select>
|
||||
<i class="fa-solid fa-chevron-down select-arrow"></i>
|
||||
</div>
|
||||
<CustomSelect
|
||||
v-model="hours"
|
||||
:options="timeOptions"
|
||||
icon="fa-regular fa-clock"
|
||||
/>
|
||||
<CustomSelect
|
||||
v-model="searchMode"
|
||||
:options="modeOptions"
|
||||
icon="fa-solid fa-filter"
|
||||
/>
|
||||
<button class="btn btn-primary" @click="handleSearch" :disabled="loading || !keyword.trim()">
|
||||
<i class="fa-solid fa-bolt" v-if="!loading"></i>
|
||||
<i class="fa-solid fa-spinner fa-spin" v-else></i>
|
||||
@@ -359,6 +367,8 @@ async function handleSearch() {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: stretch;
|
||||
position: relative;
|
||||
z-index: 10; /* 确保搜索框及下拉选择框显示在下方图表之上 */
|
||||
}
|
||||
|
||||
.search-box {
|
||||
@@ -470,61 +480,6 @@ async function handleSearch() {
|
||||
background-color: var(--bg-surface);
|
||||
}
|
||||
|
||||
.time-select-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.select-icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
color: var(--text-placeholder);
|
||||
font-size: 15px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.select-arrow {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
color: var(--text-placeholder);
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.time-select {
|
||||
width: 100%;
|
||||
padding: 14px 36px 14px 40px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.time-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand-primary);
|
||||
box-shadow: 0 0 0 3px var(--brand-primary-alpha);
|
||||
background-color: var(--bg-surface);
|
||||
}
|
||||
|
||||
.time-select-wrapper:hover .time-select {
|
||||
border-color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.time-select-wrapper:hover .select-icon,
|
||||
.time-select-wrapper:hover .select-arrow {
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -562,6 +517,8 @@ async function handleSearch() {
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
position: relative;
|
||||
z-index: 1; /* 低于 top-panels,避免图表覆盖搜索框下拉 */
|
||||
}
|
||||
|
||||
.section-header {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- 兴趣关键词页:添加/删除关键词,查看命中事件 -->
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user