optimize+注释

This commit is contained in:
stardrophere
2026-03-13 23:48:49 +08:00
parent 6aee65af6c
commit da00ebb8f2
41 changed files with 874 additions and 174 deletions
+1
View File
@@ -1,3 +1,4 @@
<!-- 根组件路由出口带页面切换淡入淡出动画 -->
<template>
<RouterView v-slot="{ Component }">
<transition name="page-fade" mode="out-in">
+1 -1
View File
@@ -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': '该关键词已经订阅过了',
+1 -1
View File
@@ -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,
+3
View File
@@ -1,3 +1,6 @@
/**
* 认证 API:登录、注册、发送验证码(不走通用 client,无 Bearer
*/
import type {
AuthTokenResponse,
LoginPayload,
+1
View File
@@ -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">
+201
View File
@@ -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 -4
View File
@@ -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'
+5
View File
@@ -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
View File
@@ -1,3 +1,4 @@
<!-- 仪表盘布局侧边栏导航主内容区移动端抽屉 -->
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
+4
View File
@@ -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()
+4
View File
@@ -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()
+4
View File
@@ -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) {
+3
View File
@@ -1,3 +1,6 @@
/**
* 主题状态:深色/浅色切换,持久化到 localStorage,支持系统偏好
*/
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
+2 -1
View File
@@ -1,6 +1,7 @@
<!-- 关于页占位 -->
<template>
<div class="about">
<h1>This is an about page</h1>
<h1>关于 InsightRadar</h1>
</div>
</template>
+1
View File
@@ -1,3 +1,4 @@
<!-- 主仪表盘事件流为你推荐公关修改追踪系统状态 -->
<script setup lang="ts">
import { onMounted, ref, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
+1
View File
@@ -1,3 +1,4 @@
<!-- 推送设置页管理推送时间表与推送渠道邮箱等 -->
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
+1
View File
@@ -1,3 +1,4 @@
<!-- 概览页展示当前账户会话状态认证接入说明 -->
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
+2
View File
@@ -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()
+2 -3
View File
@@ -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
View File
@@ -1,3 +1,4 @@
<!-- 公关修改追踪页展示热搜标题被偷偷修改的历史记录 -->
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
+33 -76
View File
@@ -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
View File
@@ -1,3 +1,4 @@
<!-- 兴趣关键词页添加/删除关键词查看命中事件 -->
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'