mirror of
https://github.com/stardrophere/InsightRadar.git
synced 2026-06-05 23:07:51 +08:00
big update
This commit is contained in:
+4
-2
@@ -1,10 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
<title>InsightRadar - 全网热点监控中枢</title>
|
||||
<!-- Font Awesome 图标库 -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Generated
+34
-1
@@ -8,9 +8,11 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"apexcharts": "^5.10.3",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.29",
|
||||
"vue-router": "^5.0.3"
|
||||
"vue-router": "^5.0.3",
|
||||
"vue3-apexcharts": "^1.11.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
@@ -2535,6 +2537,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@yr/monotone-cubic-spline": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz",
|
||||
"integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -2605,6 +2613,16 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/apexcharts": {
|
||||
"version": "5.10.3",
|
||||
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.10.3.tgz",
|
||||
"integrity": "sha512-wwvkSLsodNOc/rHo5MJsn3GPM4Krc5Gs0zKX4Lfzq4LohcTbyKylYUGEqJFmXXxGR7yLbZQz31sB5RTqT5mv1g==",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@yr/monotone-cubic-spline": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-kit": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz",
|
||||
@@ -5195,6 +5213,21 @@
|
||||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue3-apexcharts": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/vue3-apexcharts/-/vue3-apexcharts-1.11.1.tgz",
|
||||
"integrity": "sha512-MbN3vg8bMG19wc0Lm1HkeQvODgLm56DgpIxtNUO0xpf/JCzYWVGE4jzXp2JISzy2s3Kul1yOxNQUYsLvKQ5L9g==",
|
||||
"license": "see LICENSE in LICENSE",
|
||||
"peerDependencies": {
|
||||
"apexcharts": ">=5.10.0",
|
||||
"vue": ">=3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"apexcharts": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-virtual-modules": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||
|
||||
@@ -15,9 +15,11 @@
|
||||
"format": "prettier --write --experimental-cli src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"apexcharts": "^5.10.3",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.29",
|
||||
"vue-router": "^5.0.3"
|
||||
"vue-router": "^5.0.3",
|
||||
"vue3-apexcharts": "^1.11.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
|
||||
@@ -9,16 +9,11 @@
|
||||
<style>
|
||||
.page-fade-enter-active,
|
||||
.page-fade-leave-active {
|
||||
transition: opacity 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
}
|
||||
|
||||
.page-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-15px);
|
||||
transition: opacity 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
}
|
||||
|
||||
.page-fade-enter-from,
|
||||
.page-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(15px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 通用 HTTP 客户端:自动注入 Bearer Token,统一处理错误
|
||||
*/
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { pinia } from '@/stores'
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? '/api/v1'
|
||||
|
||||
// 后端返回的错误消息中英映射
|
||||
const MESSAGE_MAP: Record<string, string> = {
|
||||
'You can only operate your own resources': '只能操作自己的资源',
|
||||
'Preference keyword already exists for this user': '该关键词已订阅',
|
||||
'Keyword cannot be empty': '关键词不能为空',
|
||||
'This delivery time already exists': '该推送时间已存在',
|
||||
'This channel type already exists for the user': '该渠道类型已存在',
|
||||
'Schedule not found': '推送时间不存在',
|
||||
'Push endpoint not found': '推送渠道不存在',
|
||||
'Preference not found': '偏好不存在',
|
||||
'Invalid or expired token': '登录已过期,请重新登录',
|
||||
'Authentication credentials were not provided': '请先登录',
|
||||
}
|
||||
|
||||
function localizeMessage(msg: string): string {
|
||||
return MESSAGE_MAP[msg] ?? msg
|
||||
}
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
const authStore = useAuthStore(pinia)
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (authStore.accessToken) {
|
||||
headers['Authorization'] = `Bearer ${authStore.accessToken}`
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
async function handleResponse<T>(response: Response): Promise<T> {
|
||||
const raw = await response.text()
|
||||
let data: Record<string, unknown> = {}
|
||||
if (raw) {
|
||||
try {
|
||||
data = JSON.parse(raw) as Record<string, unknown>
|
||||
} catch {
|
||||
data = {}
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = data.detail
|
||||
if (typeof detail === 'string') {
|
||||
throw new Error(localizeMessage(detail))
|
||||
}
|
||||
throw new Error(`请求失败 (${response.status})`)
|
||||
}
|
||||
|
||||
return data as T
|
||||
}
|
||||
|
||||
/** GET 请求 */
|
||||
export async function apiGet<T>(path: string, params?: Record<string, string | number>): Promise<T> {
|
||||
let url = `${API_BASE}${path}`
|
||||
if (params) {
|
||||
const searchParams = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
searchParams.set(key, String(value))
|
||||
}
|
||||
url += `?${searchParams.toString()}`
|
||||
}
|
||||
const response = await fetch(url, { method: 'GET', headers: getAuthHeaders() })
|
||||
return handleResponse<T>(response)
|
||||
}
|
||||
|
||||
/** POST 请求 */
|
||||
export async function apiPost<T>(path: string, body?: unknown): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
return handleResponse<T>(response)
|
||||
}
|
||||
|
||||
/** PATCH 请求 */
|
||||
export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'PATCH',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return handleResponse<T>(response)
|
||||
}
|
||||
|
||||
/** DELETE 请求 */
|
||||
export async function apiDelete(path: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
if (!response.ok && response.status !== 204) {
|
||||
const raw = await response.text()
|
||||
let detail = `请求失败 (${response.status})`
|
||||
try {
|
||||
const data = JSON.parse(raw) as Record<string, unknown>
|
||||
if (typeof data.detail === 'string') detail = localizeMessage(data.detail)
|
||||
} catch { /* ignore */ }
|
||||
throw new Error(detail)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from './client'
|
||||
import type { DeliveryConfig, DeliverySchedule, PushEndpoint } from '@/types/delivery'
|
||||
|
||||
/** 获取用户完整推送配置(时间表 + 渠道) */
|
||||
export function fetchDeliveryConfig(userId: number): Promise<DeliveryConfig> {
|
||||
return apiGet<DeliveryConfig>(`/users/${userId}/delivery-config`)
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 推送时间表
|
||||
// ==========================================
|
||||
export function createDeliverySchedule(
|
||||
userId: number,
|
||||
payload: { delivery_time: string; is_active?: boolean },
|
||||
): Promise<DeliverySchedule> {
|
||||
return apiPost<DeliverySchedule>(`/users/${userId}/delivery-schedules`, payload)
|
||||
}
|
||||
|
||||
export function updateDeliverySchedule(
|
||||
userId: number,
|
||||
scheduleId: number,
|
||||
payload: { delivery_time?: string; is_active?: boolean },
|
||||
): Promise<DeliverySchedule> {
|
||||
return apiPatch<DeliverySchedule>(
|
||||
`/users/${userId}/delivery-schedules/${scheduleId}`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function deleteDeliverySchedule(
|
||||
userId: number,
|
||||
scheduleId: number,
|
||||
): Promise<void> {
|
||||
return apiDelete(`/users/${userId}/delivery-schedules/${scheduleId}`)
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 推送渠道
|
||||
// ==========================================
|
||||
export function createPushEndpoint(
|
||||
userId: number,
|
||||
payload: {
|
||||
channel_type: string
|
||||
channel_account: string
|
||||
is_active?: boolean
|
||||
priority_level?: number
|
||||
},
|
||||
): Promise<PushEndpoint> {
|
||||
return apiPost<PushEndpoint>(`/users/${userId}/push-endpoints`, payload)
|
||||
}
|
||||
|
||||
export function updatePushEndpoint(
|
||||
userId: number,
|
||||
endpointId: number,
|
||||
payload: { channel_account?: string; is_active?: boolean; priority_level?: number },
|
||||
): Promise<PushEndpoint> {
|
||||
return apiPatch<PushEndpoint>(
|
||||
`/users/${userId}/push-endpoints/${endpointId}`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function deletePushEndpoint(
|
||||
userId: number,
|
||||
endpointId: number,
|
||||
): Promise<void> {
|
||||
return apiDelete(`/users/${userId}/push-endpoints/${endpointId}`)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { apiGet } from './client'
|
||||
import type { PaginatedEvents, UnifiedEvent, HeadlineRevision, SystemStats } from '@/types/event'
|
||||
|
||||
/** 按 ID 查询单个统一事件(用于推荐跳转聚光灯展示) */
|
||||
export function fetchEventById(eventId: number): Promise<UnifiedEvent> {
|
||||
return apiGet<UnifiedEvent>(`/events/unified/${eventId}`)
|
||||
}
|
||||
|
||||
/** 分页获取 AI 聚合事件列表 */
|
||||
export function fetchUnifiedEvents(params?: {
|
||||
min_hot?: number
|
||||
hours?: number
|
||||
skip?: number
|
||||
limit?: number
|
||||
}): Promise<PaginatedEvents> {
|
||||
return apiGet<PaginatedEvents>('/events/unified', params as Record<string, number>)
|
||||
}
|
||||
|
||||
/** 获取标题修改追踪列表 */
|
||||
export function fetchHeadlineRevisions(params?: {
|
||||
hours?: number
|
||||
limit?: number
|
||||
}): Promise<HeadlineRevision[]> {
|
||||
return apiGet<HeadlineRevision[]>('/events/headline-revisions', params as Record<string, number>)
|
||||
}
|
||||
|
||||
/** 获取系统运行状态 */
|
||||
export function fetchSystemStats(): Promise<SystemStats> {
|
||||
return apiGet<SystemStats>('/system/stats')
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { apiGet, apiPost, apiDelete } from './client'
|
||||
import type { UserTopicPreference, RecommendationResponse } from '@/types/preference'
|
||||
|
||||
/** 获取用户兴趣关键词列表 */
|
||||
export function fetchPreferences(userId: number): Promise<UserTopicPreference[]> {
|
||||
return apiGet<UserTopicPreference[]>(`/users/${userId}/preferences`)
|
||||
}
|
||||
|
||||
/** 新增一个兴趣关键词 */
|
||||
export function createPreference(
|
||||
userId: number,
|
||||
keyword: string,
|
||||
): Promise<UserTopicPreference> {
|
||||
return apiPost<UserTopicPreference>(`/users/${userId}/preferences`, {
|
||||
interested_keyword: keyword,
|
||||
})
|
||||
}
|
||||
|
||||
/** 删除一个兴趣关键词 */
|
||||
export function deletePreference(userId: number, preferenceId: number): Promise<void> {
|
||||
return apiDelete(`/users/${userId}/preferences/${preferenceId}`)
|
||||
}
|
||||
|
||||
/** 基于兴趣词获取推荐事件 */
|
||||
export function fetchRecommendedEvents(
|
||||
userId: number,
|
||||
params?: { min_hot?: number; hours?: number; limit?: number },
|
||||
): Promise<RecommendationResponse> {
|
||||
return apiGet<RecommendationResponse>(
|
||||
`/users/${userId}/recommended-events`,
|
||||
params as Record<string, number>,
|
||||
)
|
||||
}
|
||||
@@ -62,9 +62,6 @@ body {
|
||||
min-height: 100vh;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
transition:
|
||||
color 0.5s,
|
||||
background-color 0.5s;
|
||||
line-height: 1.6;
|
||||
font-family:
|
||||
Inter,
|
||||
|
||||
+113
-74
@@ -1,59 +1,84 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&display=swap');
|
||||
|
||||
/* =========================================
|
||||
1. 现代 SaaS 风格主题变量
|
||||
1. 现代 SaaS 风格高级主题变量
|
||||
========================================= */
|
||||
:root {
|
||||
/* 明亮模式 - 极简白与浅灰 */
|
||||
--bg-base: #f8fafc;
|
||||
--bg-surface: #ffffff;
|
||||
--bg-input: #f1f5f9;
|
||||
/* 明亮模式 - 高级极简白与冷灰,去除了单调的死白 */
|
||||
--bg-base: #f4f6f8;
|
||||
--bg-surface: rgba(255, 255, 255, 0.85); /* 半透明表面,为毛玻璃效果打基础 */
|
||||
--bg-input: #ffffff;
|
||||
--bg-hover: rgba(0, 0, 0, 0.04);
|
||||
|
||||
--border-subtle: #e2e8f0;
|
||||
--border-strong: #cbd5e1;
|
||||
--border-subtle: rgba(0, 0, 0, 0.08);
|
||||
--border-strong: rgba(0, 0, 0, 0.15);
|
||||
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #64748b;
|
||||
--text-placeholder: #94a3b8;
|
||||
--text-primary: #111827;
|
||||
--text-secondary: #4b5563;
|
||||
--text-placeholder: #9ca3af;
|
||||
|
||||
--brand-primary: #4f46e5;
|
||||
--brand-primary-hover: #4338ca;
|
||||
--brand-primary-alpha: rgba(79, 70, 229, 0.1);
|
||||
/* 品牌色优化:更具高级感的靛蓝色 */
|
||||
--brand-primary: #4338ca;
|
||||
--brand-primary-hover: #3730a3;
|
||||
--brand-primary-alpha: rgba(67, 56, 202, 0.08);
|
||||
|
||||
--status-error: #ef4444;
|
||||
--status-success: #10b981;
|
||||
|
||||
/* 现代柔和扩散阴影 */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.05), 0 8px 10px -6px rgba(0, 0, 0, 0.01);
|
||||
/* 现代柔和长弥散阴影(Apple 风格) */
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.03);
|
||||
--shadow-md: 0 4px 12px rgba(0,0,0,0.04), 0 2px 4px rgba(0,0,0,0.02);
|
||||
--shadow-xl: 0 20px 40px rgba(0,0,0,0.08), 0 8px 16px rgba(0,0,0,0.04);
|
||||
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 20px;
|
||||
|
||||
--backdrop-blur: blur(12px);
|
||||
}
|
||||
|
||||
html.dark {
|
||||
/* 暗黑模式 - 深邃黑与暗石板色 */
|
||||
--bg-base: #020617;
|
||||
--bg-surface: #0f172a;
|
||||
--bg-input: #1e293b;
|
||||
/* 暗黑模式 - 纯粹的深邃黑与极光蓝 */
|
||||
--bg-base: #09090b;
|
||||
--bg-surface: rgba(24, 24, 27, 0.7);
|
||||
--bg-input: rgba(255, 255, 255, 0.05);
|
||||
--bg-hover: rgba(255, 255, 255, 0.1);
|
||||
|
||||
--border-subtle: #1e293b;
|
||||
--border-strong: #334155;
|
||||
--border-subtle: rgba(255, 255, 255, 0.1);
|
||||
--border-strong: rgba(255, 255, 255, 0.2);
|
||||
|
||||
--text-primary: #f8fafc;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-placeholder: #475569;
|
||||
--text-primary: #f9fafb;
|
||||
--text-secondary: #a1a1aa;
|
||||
--text-placeholder: #52525b;
|
||||
|
||||
--brand-primary: #6366f1;
|
||||
--brand-primary-hover: #818cf8;
|
||||
--brand-primary-alpha: rgba(99, 102, 241, 0.15);
|
||||
--brand-primary: #818cf8;
|
||||
--brand-primary-hover: #a5b4fc;
|
||||
--brand-primary-alpha: rgba(129, 140, 248, 0.15);
|
||||
|
||||
--status-error: #f87171;
|
||||
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.2);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3);
|
||||
/* 深色模式需要更强的光效阴影 */
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5), 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
--shadow-xl: 0 20px 40px rgba(0, 0, 0, 0.6), 0 8px 16px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
滚动条美化
|
||||
========================================= */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-placeholder);
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
@@ -67,12 +92,13 @@ html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
font-family: 'Inter', 'Noto Sans SC', sans-serif;
|
||||
/* 优化字体渲染,让文字显得更纤细高级 */
|
||||
font-family: 'Inter', 'Noto Sans SC', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background-color: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
#app {
|
||||
@@ -92,23 +118,23 @@ button {
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
高级背景环境光与数据网格
|
||||
高级背景环境光与数据网格 (极简唯美风)
|
||||
========================================= */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -2;
|
||||
/* 绘制点阵网格 */
|
||||
/* 高级细腻的点阵网格 */
|
||||
background-image: radial-gradient(var(--border-strong) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
/* 使用遮罩让网格在四周自然淡出,避免边缘生硬 */
|
||||
mask-image: radial-gradient(ellipse 80% 80% at 50% -20%, black 20%, transparent 80%);
|
||||
-webkit-mask-image: radial-gradient(ellipse 80% 80% at 50% -20%, black 20%, transparent 80%);
|
||||
opacity: 0.4;
|
||||
background-size: 28px 28px;
|
||||
mask-image: radial-gradient(ellipse 80% 80% at 50% -10%, black 10%, transparent 80%);
|
||||
-webkit-mask-image: radial-gradient(ellipse 80% 80% at 50% -10%, black 10%, transparent 80%);
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -116,46 +142,47 @@ html.dark body::before {
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
/* 极弱的雷达扫射呼吸环境光 */
|
||||
/* 更为克制的极光呼吸环境光 */
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: -50%;
|
||||
left: -20%;
|
||||
right: -20%;
|
||||
height: 100vh;
|
||||
top: -60%;
|
||||
left: -30%;
|
||||
right: -30%;
|
||||
height: 120vh;
|
||||
z-index: -3;
|
||||
background: radial-gradient(ellipse at bottom, var(--brand-primary-alpha) 0%, transparent 60%);
|
||||
opacity: 0.6;
|
||||
background: radial-gradient(ellipse at bottom, var(--brand-primary-alpha) 0%, transparent 50%);
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
animation: ambient-pulse 8s ease-in-out infinite alternate;
|
||||
animation: ambient-pulse 10s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes ambient-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.4;
|
||||
transform: scale(1) translateY(0);
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.7;
|
||||
transform: scale(1.05) translateY(-2%);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
3. 现代表单控件体系
|
||||
3. 现代表单控件体系 (磨砂与无边框风格)
|
||||
========================================= */
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
gap: 10px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
@@ -166,17 +193,19 @@ body::after {
|
||||
|
||||
.input-field {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
padding: 14px 16px;
|
||||
background-color: var(--bg-input);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
transition: all 0.2s ease;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: inset 0 2px 4px rgba(0,0,0,0.02);
|
||||
}
|
||||
|
||||
.input-field::placeholder {
|
||||
color: var(--text-placeholder);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.input-field:hover {
|
||||
@@ -186,7 +215,7 @@ body::after {
|
||||
.input-field:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand-primary);
|
||||
box-shadow: 0 0 0 3px var(--brand-primary-alpha);
|
||||
box-shadow: 0 0 0 4px var(--brand-primary-alpha), inset 0 1px 2px rgba(0,0,0,0.02);
|
||||
background-color: var(--bg-surface);
|
||||
}
|
||||
|
||||
@@ -194,11 +223,11 @@ body::after {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
color: var(--brand-primary);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.input-action-btn:hover:not(:disabled) {
|
||||
@@ -210,31 +239,41 @@ body::after {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 主按钮的高级拟物渐变效果 */
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background-color: var(--brand-primary);
|
||||
padding: 14px;
|
||||
background: linear-gradient(135deg, var(--brand-primary) 0%, var(--brand-primary-hover) 100%);
|
||||
color: #ffffff;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
border-radius: var(--radius-md);
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-shadow: 0 4px 12px var(--brand-primary-alpha);
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: var(--brand-primary-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px var(--brand-primary-alpha);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.btn-primary:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 8px var(--brand-primary-alpha);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-placeholder);
|
||||
border-color: var(--border-subtle);
|
||||
box-shadow: none;
|
||||
text-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,20 @@ function localizeMessage(message: string): string {
|
||||
return MESSAGE_MAP[message] ?? message
|
||||
}
|
||||
|
||||
function localizeDetail(detail: string): string {
|
||||
const direct = localizeMessage(detail)
|
||||
if (direct !== detail) {
|
||||
return direct
|
||||
}
|
||||
|
||||
const cooldownMatch = detail.match(/^Please wait (\d+)s before requesting another verification code$/)
|
||||
if (cooldownMatch) {
|
||||
return `操作过于频繁,请 ${cooldownMatch[1]} 秒后再试`
|
||||
}
|
||||
|
||||
return detail
|
||||
}
|
||||
|
||||
async function request<T>(path: string, payload: JsonValue): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
method: 'POST',
|
||||
@@ -47,7 +61,17 @@ async function request<T>(path: string, payload: JsonValue): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const detail = data.detail
|
||||
if (typeof detail === 'string') {
|
||||
throw new Error(localizeMessage(detail))
|
||||
const error = new Error(localizeDetail(detail)) as Error & { retryAfter?: number }
|
||||
if (response.status === 429) {
|
||||
const retryAfterHeader = response.headers.get('Retry-After')
|
||||
if (retryAfterHeader) {
|
||||
const retryAfter = Number.parseInt(retryAfterHeader, 10)
|
||||
if (Number.isFinite(retryAfter) && retryAfter > 0) {
|
||||
error.retryAfter = retryAfter
|
||||
}
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
throw new Error('请求失败,请稍后重试')
|
||||
}
|
||||
|
||||
@@ -1,392 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const isAnimating = ref(false)
|
||||
|
||||
/**
|
||||
* 切换主题,使用 View Transitions API 实现高级扩散动画(如果浏览器支持)
|
||||
* 这种动画比之前像玩具一样的开关要高级得多,提供原生级的丝滑过渡
|
||||
*/
|
||||
function handleToggle(event: MouseEvent) {
|
||||
const root = document.documentElement
|
||||
root.style.setProperty('--theme-flash-x', `${event.clientX}px`)
|
||||
root.style.setProperty('--theme-flash-y', `${event.clientY}px`)
|
||||
// 检查浏览器是否支持 document.startViewTransition 并且用户没有开启减弱动画
|
||||
const isAppearanceTransition = typeof document !== 'undefined' &&
|
||||
'startViewTransition' in document &&
|
||||
!window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
isAnimating.value = true
|
||||
themeStore.toggleTheme()
|
||||
if (!isAppearanceTransition) {
|
||||
// 降级处理:直接切换
|
||||
themeStore.toggleTheme()
|
||||
return
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
isAnimating.value = false
|
||||
}, 520)
|
||||
const x = event.clientX
|
||||
const y = event.clientY
|
||||
const endRadius = Math.hypot(
|
||||
Math.max(x, innerWidth - x),
|
||||
Math.max(y, innerHeight - y)
|
||||
)
|
||||
|
||||
// @ts-ignore: TypeScript 类型可能较旧,忽略 startViewTransition 报错
|
||||
const transition = document.startViewTransition(() => {
|
||||
themeStore.toggleTheme()
|
||||
})
|
||||
|
||||
transition.ready.then(() => {
|
||||
const clipPath = [
|
||||
`circle(0px at ${x}px ${y}px)`,
|
||||
`circle(${endRadius}px at ${x}px ${y}px)`
|
||||
]
|
||||
document.documentElement.animate(
|
||||
{
|
||||
clipPath: clipPath,
|
||||
},
|
||||
{
|
||||
duration: 500,
|
||||
easing: 'ease-in-out',
|
||||
pseudoElement: '::view-transition-new(root)',
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="theme-toggle"
|
||||
:class="{ 'is-dark': themeStore.isDark, 'is-animating': isAnimating }"
|
||||
type="button"
|
||||
class="theme-toggle-btn"
|
||||
:aria-label="themeStore.isDark ? '切换到浅色模式' : '切换到暗黑模式'"
|
||||
title="切换显示模式"
|
||||
@click="handleToggle"
|
||||
>
|
||||
<span class="toggle-track">
|
||||
<span class="track-glow"></span>
|
||||
<span class="track-stars"></span>
|
||||
<span class="spark-layer">
|
||||
<span class="spark"></span>
|
||||
<span class="spark"></span>
|
||||
<span class="spark"></span>
|
||||
</span>
|
||||
<span class="toggle-thumb">
|
||||
<svg class="icon icon-sun" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M12 5.25a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0V6a.75.75 0 0 1 .75-.75ZM7.227 7.227a.75.75 0 0 1 1.06 0l1.06 1.06a.75.75 0 1 1-1.06 1.06l-1.06-1.06a.75.75 0 0 1 0-1.06Zm9.426 0a.75.75 0 0 1 1.06 1.06l-1.06 1.06a.75.75 0 1 1-1.06-1.06l1.06-1.06ZM12 9a3 3 0 1 1 0 6 3 3 0 0 1 0-6Zm-6.75 3a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 0 1.5H6a.75.75 0 0 1-.75-.75Zm11.25 0a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 0 1.5h-1.5a.75.75 0 0 1-.75-.75Zm-8.213 3.653a.75.75 0 0 1 1.06 0l1.06 1.06a.75.75 0 0 1-1.06 1.06l-1.06-1.06a.75.75 0 0 1 0-1.06Zm7.426 0a.75.75 0 0 1 1.06 1.06l-1.06 1.06a.75.75 0 0 1-1.06-1.06l1.06-1.06ZM12 16.5a.75.75 0 0 1 .75.75v1.5a.75.75 0 1 1-1.5 0v-1.5a.75.75 0 0 1 .75-.75Z"
|
||||
/>
|
||||
</svg>
|
||||
<svg class="icon icon-moon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M14.5 2.25a.75.75 0 0 1 .604 1.195 7.95 7.95 0 0 0 4.6 12.395.75.75 0 0 1 .194 1.387 10.5 10.5 0 1 1-6.592-15.052.75.75 0 0 1 .194 1.387 8.954 8.954 0 0 0-1.75 16.693 9.002 9.002 0 0 0 6.074-2.04 9.45 9.45 0 0 1-4.822-8.253 9.44 9.44 0 0 1 1.305-4.82.75.75 0 0 1 .194-1.202Z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
<span class="toggle-text">{{ themeStore.isDark ? '浅色模式' : '暗黑模式' }}</span>
|
||||
<div class="icon-container">
|
||||
<i class="fa-solid fa-sun sun-icon" :class="{ 'is-hidden': themeStore.isDark }"></i>
|
||||
<i class="fa-solid fa-moon moon-icon" :class="{ 'is-hidden': !themeStore.isDark }"></i>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.theme-toggle {
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: 14px;
|
||||
padding: 6px 10px 6px 8px;
|
||||
display: inline-flex;
|
||||
/* ==========================================
|
||||
极简且高级的毛玻璃材质主题切换按钮
|
||||
========================================== */
|
||||
.theme-toggle-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-surface);
|
||||
/* 使用轻微透明度与模糊实现毛玻璃质感 */
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border-subtle);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
transition: transform 220ms ease, border-color 220ms ease, box-shadow 220ms ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 8px 24px color-mix(in srgb, var(--primary) 24%, transparent);
|
||||
.theme-toggle-btn:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-strong);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.theme-toggle:active {
|
||||
transform: translateY(0) scale(0.98);
|
||||
.theme-toggle-btn:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.toggle-track {
|
||||
width: 58px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(120deg, #d4e4ff, #b2c6ff);
|
||||
border: 1px solid rgba(255, 255, 255, 0.45);
|
||||
padding: 3px;
|
||||
.icon-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: background 360ms ease;
|
||||
}
|
||||
|
||||
.theme-toggle.is-dark .toggle-track {
|
||||
background: linear-gradient(120deg, #0f1731, #1f2e62);
|
||||
}
|
||||
|
||||
.track-glow {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
left: 8px;
|
||||
top: 5px;
|
||||
background: rgba(255, 244, 170, 0.75);
|
||||
filter: blur(2px);
|
||||
opacity: 0.85;
|
||||
transition: left 360ms cubic-bezier(0.22, 1, 0.36, 1), opacity 300ms ease, background 300ms ease;
|
||||
}
|
||||
|
||||
.theme-toggle.is-dark .track-glow {
|
||||
left: 30px;
|
||||
opacity: 0.4;
|
||||
background: rgba(166, 195, 255, 0.7);
|
||||
}
|
||||
|
||||
.track-stars {
|
||||
/* 图标动画:旋转加缩放的平滑过渡 */
|
||||
.sun-icon, .moon-icon {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 18px;
|
||||
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* 隐藏状态:优雅地旋出 */
|
||||
.sun-icon.is-hidden {
|
||||
opacity: 0;
|
||||
transition: opacity 260ms ease;
|
||||
transform: translate(-50%, -50%) rotate(90deg) scale(0.5);
|
||||
}
|
||||
|
||||
.track-stars::before,
|
||||
.track-stars::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: rgba(226, 235, 255, 0.95);
|
||||
}
|
||||
|
||||
.track-stars::before {
|
||||
left: 16px;
|
||||
top: 9px;
|
||||
box-shadow: 16px 6px 0 rgba(226, 235, 255, 0.75), 22px -2px 0 rgba(226, 235, 255, 0.55);
|
||||
}
|
||||
|
||||
.track-stars::after {
|
||||
left: 28px;
|
||||
top: 18px;
|
||||
box-shadow: -12px 5px 0 rgba(226, 235, 255, 0.65), 8px -8px 0 rgba(226, 235, 255, 0.75);
|
||||
}
|
||||
|
||||
.theme-toggle.is-dark .track-stars {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.spark-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.spark {
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
.moon-icon.is-hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.spark:nth-child(1) {
|
||||
left: 22px;
|
||||
top: 14px;
|
||||
}
|
||||
|
||||
.spark:nth-child(2) {
|
||||
left: 30px;
|
||||
top: 11px;
|
||||
}
|
||||
|
||||
.spark:nth-child(3) {
|
||||
left: 26px;
|
||||
top: 19px;
|
||||
}
|
||||
|
||||
.theme-toggle.is-animating .spark:nth-child(1) {
|
||||
animation: spark-burst-1 480ms ease-out;
|
||||
}
|
||||
|
||||
.theme-toggle.is-animating .spark:nth-child(2) {
|
||||
animation: spark-burst-2 480ms ease-out;
|
||||
}
|
||||
|
||||
.theme-toggle.is-animating .spark:nth-child(3) {
|
||||
animation: spark-burst-3 480ms ease-out;
|
||||
}
|
||||
|
||||
.toggle-thumb {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(145deg, #ffffff, #f0f4ff);
|
||||
box-shadow: 0 3px 10px rgba(37, 49, 89, 0.26);
|
||||
position: relative;
|
||||
transform: translateX(0);
|
||||
transition: transform 360ms cubic-bezier(0.22, 1, 0.36, 1), background 320ms ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.theme-toggle.is-dark .toggle-thumb {
|
||||
transform: translateX(26px);
|
||||
background: linear-gradient(145deg, #d5e0ff, #b7c9ff);
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
inset: 5px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
fill: #526ab0;
|
||||
transition: opacity 240ms ease, transform 360ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.icon-sun {
|
||||
opacity: 1;
|
||||
transform: rotate(0deg) scale(1);
|
||||
}
|
||||
|
||||
.icon-moon {
|
||||
opacity: 0;
|
||||
transform: rotate(-40deg) scale(0.45);
|
||||
}
|
||||
|
||||
.theme-toggle.is-dark .icon-sun {
|
||||
opacity: 0;
|
||||
transform: rotate(70deg) scale(0.4);
|
||||
}
|
||||
|
||||
.theme-toggle.is-dark .icon-moon {
|
||||
opacity: 1;
|
||||
transform: rotate(0deg) scale(1);
|
||||
}
|
||||
|
||||
.toggle-text {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.theme-toggle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--primary) 36%, transparent);
|
||||
left: 34px;
|
||||
top: 14px;
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.theme-toggle.is-animating::after {
|
||||
animation: click-ripple 520ms ease-out;
|
||||
}
|
||||
|
||||
.theme-toggle.is-animating .toggle-thumb {
|
||||
animation: thumb-pop 520ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.theme-toggle.is-dark.is-animating .toggle-thumb {
|
||||
animation-name: thumb-pop-dark;
|
||||
}
|
||||
|
||||
.theme-toggle.is-animating .toggle-track {
|
||||
animation: track-flare 520ms ease;
|
||||
}
|
||||
|
||||
@keyframes click-ripple {
|
||||
0% {
|
||||
transform: scale(0.2);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(4.2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes thumb-pop {
|
||||
0% {
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: translateX(13px) scale(1.1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(26px) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes thumb-pop-dark {
|
||||
0% {
|
||||
transform: translateX(26px) scale(1);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: translateX(13px) scale(1.1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes track-flare {
|
||||
0% {
|
||||
box-shadow: 0 0 0 rgba(255, 255, 255, 0);
|
||||
}
|
||||
|
||||
45% {
|
||||
box-shadow: 0 0 16px rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 rgba(255, 255, 255, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spark-burst-1 {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(0.3);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(-12px, -8px) scale(1.1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spark-burst-2 {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(0.3);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(10px, -10px) scale(1.15);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spark-burst-3 {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(0.3);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(2px, 12px) scale(1.2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.toggle-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
padding-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.theme-toggle,
|
||||
.toggle-track,
|
||||
.toggle-thumb,
|
||||
.track-glow,
|
||||
.icon {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.theme-toggle.is-animating::after,
|
||||
.theme-toggle.is-animating .toggle-thumb,
|
||||
.theme-toggle.is-dark.is-animating .toggle-thumb,
|
||||
.theme-toggle.is-animating .toggle-track,
|
||||
.theme-toggle.is-animating .spark {
|
||||
animation: none;
|
||||
}
|
||||
transform: translate(-50%, -50%) rotate(-90deg) scale(0.5);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* ==========================================
|
||||
全局 View Transitions API 动画样式
|
||||
控制页面级别的黑白模式无缝扩散切换
|
||||
========================================== */
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
::view-transition-old(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
::view-transition-new(root) {
|
||||
z-index: 9999;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import BrandLogo from '@/components/BrandLogo.vue'
|
||||
import ThemeToggle from '@/components/ThemeToggle.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const sidebarOpen = ref(false)
|
||||
|
||||
const displayName = computed(() => authStore.user?.nickname || authStore.user?.email?.split('@')[0] || '用户')
|
||||
const avatarUrl = computed(
|
||||
() =>
|
||||
authStore.user?.avatar_url ||
|
||||
`https://ui-avatars.com/api/?name=${encodeURIComponent(displayName.value)}&background=6366f1&color=fff`,
|
||||
)
|
||||
|
||||
const navItems = [
|
||||
{ name: '全局热点池', icon: 'fa-solid fa-fire', route: '/' },
|
||||
{ name: '公关修改追踪', icon: 'fa-solid fa-mask', route: '/revisions' },
|
||||
{ name: '我的泛订阅', icon: 'fa-solid fa-rss', route: '/topics' },
|
||||
{ name: 'AI 简报设置', icon: 'fa-solid fa-paper-plane', route: '/delivery' },
|
||||
]
|
||||
|
||||
function isActive(path: string) {
|
||||
return route.path === path
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
authStore.logout()
|
||||
await router.replace('/login')
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarOpen.value = !sidebarOpen.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard-shell">
|
||||
<!-- 移动端侧边栏遮罩 -->
|
||||
<div v-if="sidebarOpen" class="sidebar-overlay" @click="sidebarOpen = false"></div>
|
||||
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="sidebar" :class="{ open: sidebarOpen }">
|
||||
<div class="sidebar-inner">
|
||||
<!-- Logo -->
|
||||
<div class="sidebar-logo">
|
||||
<BrandLogo />
|
||||
<span class="logo-text">InsightRadar<span class="logo-dot">.AI</span></span>
|
||||
</div>
|
||||
|
||||
<!-- 导航菜单 -->
|
||||
<nav class="sidebar-nav">
|
||||
<RouterLink
|
||||
v-for="item in navItems"
|
||||
:key="item.route"
|
||||
:to="item.route"
|
||||
class="nav-item"
|
||||
:class="{ active: isActive(item.route) }"
|
||||
@click="sidebarOpen = false"
|
||||
>
|
||||
<i :class="item.icon" class="nav-icon"></i>
|
||||
<span>{{ item.name }}</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- 用户信息 -->
|
||||
<div class="sidebar-user">
|
||||
<img :src="avatarUrl" class="user-avatar" alt="头像" />
|
||||
<div class="user-info">
|
||||
<p class="user-name">{{ displayName }}</p>
|
||||
<p class="user-status">
|
||||
<span class="status-dot"></span>
|
||||
已登录
|
||||
</p>
|
||||
</div>
|
||||
<button class="logout-btn" title="退出登录" @click="handleLogout">
|
||||
<i class="fa-solid fa-right-from-bracket"></i>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<main class="main-area">
|
||||
<!-- 顶部通栏 -->
|
||||
<header class="top-header">
|
||||
<button class="menu-toggle" @click="toggleSidebar">
|
||||
<i class="fa-solid fa-bars"></i>
|
||||
</button>
|
||||
<div class="header-right">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 页面内容插槽 -->
|
||||
<div class="page-content">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<transition name="page-fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</RouterView>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-shell {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
侧边栏
|
||||
========================================== */
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
min-width: 260px;
|
||||
/* 增加侧边栏的毛玻璃高级感 */
|
||||
background: var(--bg-surface);
|
||||
backdrop-filter: var(--backdrop-blur);
|
||||
-webkit-backdrop-filter: var(--backdrop-blur);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
z-index: 40;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar-inner {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.logo-dot {
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
/* 导航 */
|
||||
.sidebar-nav {
|
||||
padding: 16px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: var(--brand-primary);
|
||||
background: var(--brand-primary-alpha);
|
||||
border-left: 3px solid var(--brand-primary);
|
||||
padding-left: 13px; /* 减去 border 的 3px 保持布局不跳动 */
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* 用户区 */
|
||||
.sidebar-user {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-status {
|
||||
font-size: 11px;
|
||||
color: var(--status-success);
|
||||
margin: 2px 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--status-success);
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
animation: pulse-dot 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
color: var(--text-secondary);
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
color: var(--status-error);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
主内容区
|
||||
========================================== */
|
||||
.main-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.top-header {
|
||||
height: 60px;
|
||||
min-height: 60px;
|
||||
/* 顶部导航毛玻璃 */
|
||||
background: var(--bg-surface);
|
||||
backdrop-filter: var(--backdrop-blur);
|
||||
-webkit-backdrop-filter: var(--backdrop-blur);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
display: none;
|
||||
font-size: 18px;
|
||||
color: var(--text-secondary);
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.menu-toggle:hover {
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
移动端适配
|
||||
========================================== */
|
||||
.sidebar-overlay {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 页面过渡动画 */
|
||||
.page-fade-enter-active,
|
||||
.page-fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.page-fade-enter-from,
|
||||
.page-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -2,36 +2,54 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import { pinia } from '@/stores'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import HomeView from '@/views/HomeView.vue'
|
||||
import DashboardLayout from '@/layouts/DashboardLayout.vue'
|
||||
import LoginView from '@/views/LoginView.vue'
|
||||
import RegisterView from '@/views/RegisterView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: HomeView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
// 认证页面(不使用仪表盘布局)
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: LoginView,
|
||||
meta: {
|
||||
guestOnly: true,
|
||||
},
|
||||
meta: { guestOnly: true },
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: RegisterView,
|
||||
meta: {
|
||||
guestOnly: true,
|
||||
},
|
||||
meta: { guestOnly: true },
|
||||
},
|
||||
|
||||
// 仪表盘内部页面(使用统一侧边栏布局)
|
||||
{
|
||||
path: '/',
|
||||
component: DashboardLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'dashboard',
|
||||
component: () => import('@/views/DashboardView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'revisions',
|
||||
name: 'revisions',
|
||||
component: () => import('@/views/RevisionsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'topics',
|
||||
name: 'topics',
|
||||
component: () => import('@/views/TopicsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'delivery',
|
||||
name: 'delivery',
|
||||
component: () => import('@/views/DeliveryView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -41,16 +59,11 @@ router.beforeEach((to) => {
|
||||
authStore.restore()
|
||||
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
return {
|
||||
name: 'login',
|
||||
query: {
|
||||
redirect: to.fullPath,
|
||||
},
|
||||
}
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
|
||||
if (to.meta.guestOnly && authStore.isAuthenticated) {
|
||||
return { name: 'home' }
|
||||
return { name: 'dashboard' }
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/** 推送时间表 */
|
||||
export interface DeliverySchedule {
|
||||
id: number
|
||||
user_id: number
|
||||
delivery_time: string
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 推送渠道端点 */
|
||||
export interface PushEndpoint {
|
||||
id: number
|
||||
user_id: number
|
||||
channel_type: string
|
||||
channel_account: string
|
||||
is_active: boolean
|
||||
priority_level: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 用户完整推送配置 */
|
||||
export interface DeliveryConfig {
|
||||
schedules: DeliverySchedule[]
|
||||
endpoints: PushEndpoint[]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/** 各平台热搜子条目 */
|
||||
export interface PlatformTrend {
|
||||
source_id: number
|
||||
platform_name: string
|
||||
headline: string
|
||||
url: string | null
|
||||
current_ranking: number | null
|
||||
ranking_history: number[]
|
||||
}
|
||||
|
||||
/** AI 统一大事件 */
|
||||
export interface UnifiedEvent {
|
||||
event_id: number
|
||||
unified_title: string
|
||||
summary: string | null
|
||||
hot_score: number
|
||||
created_at: string
|
||||
platforms: PlatformTrend[]
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
/** 分页包装 */
|
||||
export interface PaginatedEvents {
|
||||
total: number
|
||||
has_more: boolean
|
||||
data: UnifiedEvent[]
|
||||
}
|
||||
|
||||
/** 标题修改记录 */
|
||||
export interface HeadlineRevision {
|
||||
id: number
|
||||
event_id: number
|
||||
previous_headline: string
|
||||
revised_headline: string
|
||||
source_name: string | null
|
||||
platform_icon: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 系统运行状态 */
|
||||
export interface SystemStats {
|
||||
active_sources: number
|
||||
total_sources: number
|
||||
items_today: number
|
||||
success_tasks_today: number
|
||||
error_tasks_today: number
|
||||
last_sync_at: string | null
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/** 用户兴趣关键词 */
|
||||
export interface UserTopicPreference {
|
||||
id: number
|
||||
user_id: number
|
||||
interested_keyword: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 语义命中详情 */
|
||||
export interface SemanticHit {
|
||||
preference_keyword: string
|
||||
topic_keyword: string
|
||||
similarity: number
|
||||
}
|
||||
|
||||
/** 推荐事件 */
|
||||
export interface MatchedEvent {
|
||||
event_id: number
|
||||
unified_title: string
|
||||
summary: string | null
|
||||
hot_score: number
|
||||
created_at: string
|
||||
tags: string[]
|
||||
match_score: number
|
||||
exact_hits: string[]
|
||||
semantic_hits: SemanticHit[]
|
||||
}
|
||||
|
||||
/** 推荐列表响应 */
|
||||
export interface RecommendationResponse {
|
||||
user_id: number
|
||||
total: number
|
||||
data: MatchedEvent[]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,720 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {
|
||||
fetchDeliveryConfig,
|
||||
createDeliverySchedule,
|
||||
updateDeliverySchedule,
|
||||
deleteDeliverySchedule,
|
||||
createPushEndpoint,
|
||||
updatePushEndpoint,
|
||||
deletePushEndpoint,
|
||||
} from '@/api/delivery'
|
||||
import type { DeliverySchedule, PushEndpoint } from '@/types/delivery'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const userId = computed(() => authStore.user?.id ?? 0)
|
||||
|
||||
const schedules = ref<DeliverySchedule[]>([])
|
||||
const endpoints = ref<PushEndpoint[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const successMsg = ref('')
|
||||
|
||||
// 新增时间表单
|
||||
const newTime = ref('08:30')
|
||||
// 新增渠道表单
|
||||
const newChannelType = ref('EMAIL')
|
||||
const newChannelAccount = ref('')
|
||||
const submittingSchedule = ref(false)
|
||||
const submittingEndpoint = ref(false)
|
||||
|
||||
function showSuccess(msg: string) {
|
||||
successMsg.value = msg
|
||||
setTimeout(() => { successMsg.value = '' }, 3000)
|
||||
}
|
||||
|
||||
function getChannelLabel(_type: string): string {
|
||||
return '邮箱'
|
||||
}
|
||||
|
||||
function getChannelIcon(_type: string): string {
|
||||
return 'fa-solid fa-envelope'
|
||||
}
|
||||
|
||||
/** 加载推送配置 */
|
||||
async function loadConfig() {
|
||||
if (!userId.value) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const config = await fetchDeliveryConfig(userId.value)
|
||||
schedules.value = config.schedules
|
||||
endpoints.value = config.endpoints
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 推送时间表操作
|
||||
// ==========================================
|
||||
async function handleAddSchedule() {
|
||||
if (!userId.value || !newTime.value) return
|
||||
submittingSchedule.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const created = await createDeliverySchedule(userId.value, {
|
||||
delivery_time: newTime.value,
|
||||
is_active: true,
|
||||
})
|
||||
schedules.value.push(created)
|
||||
schedules.value.sort((a, b) => a.delivery_time.localeCompare(b.delivery_time))
|
||||
showSuccess(`已添加推送时间 ${newTime.value}`)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '添加失败'
|
||||
} finally {
|
||||
submittingSchedule.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleSchedule(schedule: DeliverySchedule) {
|
||||
if (!userId.value) return
|
||||
error.value = ''
|
||||
try {
|
||||
const updated = await updateDeliverySchedule(userId.value, schedule.id, {
|
||||
is_active: !schedule.is_active,
|
||||
})
|
||||
const idx = schedules.value.findIndex(s => s.id === schedule.id)
|
||||
if (idx >= 0) schedules.value[idx] = updated
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '更新失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteSchedule(schedule: DeliverySchedule) {
|
||||
if (!userId.value) return
|
||||
error.value = ''
|
||||
try {
|
||||
await deleteDeliverySchedule(userId.value, schedule.id)
|
||||
schedules.value = schedules.value.filter(s => s.id !== schedule.id)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '删除失败'
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 推送渠道操作
|
||||
// ==========================================
|
||||
async function handleAddEndpoint() {
|
||||
if (!userId.value || !newChannelAccount.value.trim()) return
|
||||
submittingEndpoint.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const created = await createPushEndpoint(userId.value, {
|
||||
channel_type: newChannelType.value,
|
||||
channel_account: newChannelAccount.value.trim(),
|
||||
})
|
||||
endpoints.value.push(created)
|
||||
newChannelAccount.value = ''
|
||||
showSuccess(`已添加${getChannelLabel(newChannelType.value)}推送渠道`)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '添加失败'
|
||||
} finally {
|
||||
submittingEndpoint.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleEndpoint(endpoint: PushEndpoint) {
|
||||
if (!userId.value) return
|
||||
error.value = ''
|
||||
try {
|
||||
const updated = await updatePushEndpoint(userId.value, endpoint.id, {
|
||||
is_active: !endpoint.is_active,
|
||||
})
|
||||
const idx = endpoints.value.findIndex(ep => ep.id === endpoint.id)
|
||||
if (idx >= 0) endpoints.value[idx] = updated
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '更新失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteEndpoint(endpoint: PushEndpoint) {
|
||||
if (!userId.value) return
|
||||
error.value = ''
|
||||
try {
|
||||
await deletePushEndpoint(userId.value, endpoint.id)
|
||||
endpoints.value = endpoints.value.filter(ep => ep.id !== endpoint.id)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '删除失败'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadConfig)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="delivery-page">
|
||||
<div class="page-header">
|
||||
<h1>
|
||||
<i class="fa-solid fa-paper-plane" style="color: var(--brand-primary)"></i>
|
||||
AI 简报设置
|
||||
</h1>
|
||||
<p class="page-desc">
|
||||
配置你的专属 AI 简报推送。设定推送时间和接收渠道后,
|
||||
系统会在指定时间将匹配到的热点事件整理成简报发送给你。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 全局消息 -->
|
||||
<p v-if="successMsg" class="global-msg success-msg">
|
||||
<i class="fa-solid fa-check-circle"></i> {{ successMsg }}
|
||||
</p>
|
||||
<p v-if="error" class="global-msg error-msg">
|
||||
<i class="fa-solid fa-circle-exclamation"></i> {{ error }}
|
||||
</p>
|
||||
|
||||
<div v-if="loading" class="loading-state">
|
||||
<i class="fa-solid fa-spinner fa-spin"></i>
|
||||
<span>加载配置中...</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="config-sections">
|
||||
<!-- ==========================================
|
||||
推送时间管理
|
||||
========================================== -->
|
||||
<section class="config-section">
|
||||
<div class="section-title">
|
||||
<h2><i class="fa-regular fa-clock"></i> 推送时间</h2>
|
||||
<p>设定每天希望收到 AI 简报的时间点(可设多个,相邻时间至少间隔 30 分钟)</p>
|
||||
</div>
|
||||
|
||||
<!-- 添加时间 -->
|
||||
<div class="add-row">
|
||||
<input v-model="newTime" type="time" class="time-input" />
|
||||
<button class="add-btn" :disabled="submittingSchedule" @click="handleAddSchedule">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
{{ submittingSchedule ? '添加中...' : '添加时间' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 时间列表 -->
|
||||
<div v-if="schedules.length === 0" class="empty-hint">
|
||||
<p>还没有设置推送时间</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="schedule-list">
|
||||
<div v-for="s in schedules" :key="s.id" class="schedule-card" :class="{ disabled: !s.is_active }">
|
||||
<div class="schedule-info">
|
||||
<span class="schedule-time">{{ s.delivery_time }}</span>
|
||||
<span class="schedule-status" :class="s.is_active ? 'active' : 'paused'">
|
||||
{{ s.is_active ? '已启用' : '已暂停' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="schedule-actions">
|
||||
<button class="toggle-btn" :title="s.is_active ? '暂停' : '启用'" @click="handleToggleSchedule(s)">
|
||||
<i :class="s.is_active ? 'fa-solid fa-pause' : 'fa-solid fa-play'"></i>
|
||||
</button>
|
||||
<button class="del-btn" title="删除" @click="handleDeleteSchedule(s)">
|
||||
<i class="fa-solid fa-trash-can"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ==========================================
|
||||
推送渠道管理
|
||||
========================================== -->
|
||||
<section class="config-section">
|
||||
<div class="section-title">
|
||||
<h2><i class="fa-solid fa-envelope"></i> 接收邮箱</h2>
|
||||
<p>填写接收简报的邮箱地址(可添加多个备用邮箱,系统按优先级依次尝试)</p>
|
||||
</div>
|
||||
|
||||
<!-- 添加邮箱 -->
|
||||
<div class="add-row endpoint-add">
|
||||
<input
|
||||
v-model="newChannelAccount"
|
||||
type="email"
|
||||
class="channel-input"
|
||||
placeholder="输入接收邮箱地址"
|
||||
/>
|
||||
<button
|
||||
class="add-btn"
|
||||
:disabled="submittingEndpoint || !newChannelAccount.trim()"
|
||||
@click="handleAddEndpoint"
|
||||
>
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
{{ submittingEndpoint ? '添加中...' : '添加' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 渠道列表 -->
|
||||
<div v-if="endpoints.length === 0" class="empty-hint">
|
||||
<p>还没有配置推送渠道</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="endpoint-list">
|
||||
<div v-for="ep in endpoints" :key="ep.id" class="endpoint-card" :class="{ disabled: !ep.is_active }">
|
||||
<div class="endpoint-info">
|
||||
<div class="endpoint-icon-wrap">
|
||||
<i :class="getChannelIcon(ep.channel_type)"></i>
|
||||
</div>
|
||||
<div class="endpoint-detail">
|
||||
<p class="endpoint-type">{{ getChannelLabel(ep.channel_type) }}</p>
|
||||
<p class="endpoint-account">{{ ep.channel_account }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="endpoint-right">
|
||||
<span class="priority-badge">优先级 {{ ep.priority_level }}</span>
|
||||
<div class="endpoint-actions">
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:title="ep.is_active ? '暂停' : '启用'"
|
||||
@click="handleToggleEndpoint(ep)"
|
||||
>
|
||||
<i :class="ep.is_active ? 'fa-solid fa-pause' : 'fa-solid fa-play'"></i>
|
||||
</button>
|
||||
<button class="del-btn" title="删除" @click="handleDeleteEndpoint(ep)">
|
||||
<i class="fa-solid fa-trash-can"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 工作原理说明 -->
|
||||
<section class="config-section info-section">
|
||||
<h3><i class="fa-solid fa-gears"></i> 推送工作原理</h3>
|
||||
<div class="info-grid">
|
||||
<div class="info-step">
|
||||
<div class="step-num">1</div>
|
||||
<p><strong>爬虫抓取</strong>:系统定时从各平台抓取热搜数据</p>
|
||||
</div>
|
||||
<div class="info-step">
|
||||
<div class="step-num">2</div>
|
||||
<p><strong>AI 聚类</strong>:通过语义向量将相同事件聚合归并</p>
|
||||
</div>
|
||||
<div class="info-step">
|
||||
<div class="step-num">3</div>
|
||||
<p><strong>兴趣匹配</strong>:将事件标签与您的关键词进行精确/语义匹配</p>
|
||||
</div>
|
||||
<div class="info-step">
|
||||
<div class="step-num">4</div>
|
||||
<p><strong>定时推送</strong>:在设定时间将命中的事件整理成简报推送</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.delivery-page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.global-msg {
|
||||
font-size: 13px;
|
||||
margin: 0 0 16px;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.success-msg {
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
color: var(--status-success);
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
color: var(--status-error);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 60px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
通用区块样式
|
||||
========================================== */
|
||||
.config-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
background: var(--bg-surface);
|
||||
backdrop-filter: var(--backdrop-blur);
|
||||
-webkit-backdrop-filter: var(--backdrop-blur);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 30px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.config-section:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title h2 {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-title p {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
添加行
|
||||
========================================== */
|
||||
.add-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.time-input {
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.time-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand-primary);
|
||||
box-shadow: 0 0 0 3px var(--brand-primary-alpha);
|
||||
}
|
||||
|
||||
.channel-input {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.channel-input::placeholder {
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.channel-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand-primary);
|
||||
box-shadow: 0 0 0 3px var(--brand-primary-alpha);
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
padding: 12px 24px;
|
||||
background: linear-gradient(135deg, var(--brand-primary) 0%, var(--brand-primary-hover) 100%);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-md);
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 4px 12px var(--brand-primary-alpha);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.add-btn:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px var(--brand-primary-alpha);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.add-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
时间表列表
|
||||
========================================== */
|
||||
.schedule-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.schedule-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-input);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-subtle);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.schedule-card.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.schedule-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.schedule-time {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.schedule-status {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.schedule-status.active {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: var(--status-success);
|
||||
}
|
||||
|
||||
.schedule-status.paused {
|
||||
background: rgba(107, 114, 128, 0.12);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.schedule-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toggle-btn,
|
||||
.del-btn {
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
color: var(--brand-primary);
|
||||
background: var(--brand-primary-alpha);
|
||||
}
|
||||
|
||||
.del-btn:hover {
|
||||
color: var(--status-error);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
渠道列表
|
||||
========================================== */
|
||||
.endpoint-add {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.endpoint-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.endpoint-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg-input);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-subtle);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.endpoint-card.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.endpoint-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.endpoint-icon-wrap {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--brand-primary-alpha);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--brand-primary);
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.endpoint-detail {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.endpoint-type {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.endpoint-account {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin: 2px 0 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.endpoint-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.priority-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.endpoint-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
工作原理说明
|
||||
========================================== */
|
||||
.info-section {
|
||||
background: transparent;
|
||||
border: 1px dashed var(--border-subtle);
|
||||
}
|
||||
|
||||
.info-section h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.info-step {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--brand-primary-alpha);
|
||||
color: var(--brand-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-step p {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-step strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -40,8 +40,8 @@ watch(loginMode, () => {
|
||||
successMessage.value = ''
|
||||
})
|
||||
|
||||
function startCooldown() {
|
||||
countdown.value = CODE_RESEND_SECONDS
|
||||
function startCooldown(seconds = CODE_RESEND_SECONDS) {
|
||||
countdown.value = Math.max(1, seconds)
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
}
|
||||
@@ -88,6 +88,10 @@ async function handleSendLoginCode() {
|
||||
successMessage.value = result.message || '验证码已发送'
|
||||
startCooldown()
|
||||
} catch (error) {
|
||||
const retryAfter = (error as Error & { retryAfter?: number }).retryAfter
|
||||
if (typeof retryAfter === 'number' && retryAfter > 0) {
|
||||
startCooldown(retryAfter)
|
||||
}
|
||||
errorMessage.value = error instanceof Error ? error.message : '验证码发送失败,请稍后重试'
|
||||
}
|
||||
}
|
||||
@@ -261,17 +265,21 @@ onUnmounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ==========================================
|
||||
全新高级分屏布局与背景
|
||||
========================================== */
|
||||
.split-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
.brand-panel {
|
||||
flex: 1;
|
||||
display: none;
|
||||
background-color: var(--bg-surface);
|
||||
background: linear-gradient(135deg, var(--bg-surface), var(--bg-base));
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
padding: 60px;
|
||||
padding: 80px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -287,43 +295,62 @@ onUnmounted(() => {
|
||||
.brand-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
max-width: 480px;
|
||||
max-width: 500px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
padding: 40px;
|
||||
border-radius: var(--radius-xl);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 60px;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 40px;
|
||||
letter-spacing: -0.02em;
|
||||
background: linear-gradient(to right, var(--text-primary), var(--text-secondary));
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 40px;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
font-size: 48px;
|
||||
line-height: 1.1;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
margin-bottom: 24px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.brand-desc {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
font-size: 18px;
|
||||
line-height: 1.7;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ambient-glow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: -20%;
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
right: -10%;
|
||||
width: 80vw;
|
||||
height: 80vw;
|
||||
background: radial-gradient(circle, var(--brand-primary-alpha) 0%, transparent 60%);
|
||||
transform: translateY(-50%);
|
||||
filter: blur(60px);
|
||||
filter: blur(80px);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
animation: float-glow 10s infinite alternate ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes float-glow {
|
||||
0% { transform: translateY(-50%) scale(1); opacity: 0.5; }
|
||||
100% { transform: translateY(-48%) scale(1.05); opacity: 0.8; }
|
||||
}
|
||||
|
||||
.form-panel {
|
||||
@@ -331,64 +358,71 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
background: var(--bg-surface);
|
||||
backdrop-filter: var(--backdrop-blur);
|
||||
-webkit-backdrop-filter: var(--backdrop-blur);
|
||||
}
|
||||
|
||||
.top-actions {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
top: 32px;
|
||||
right: 32px;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
margin: auto;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
max-width: 440px;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.form-header h2 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px 0;
|
||||
letter-spacing: -0.01em;
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
margin: 0 0 12px 0;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.form-header p {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.login-mode-tabs {
|
||||
margin-bottom: 18px;
|
||||
margin-bottom: 24px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
background: var(--bg-input);
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
height: 40px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--bg-input);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.mode-btn.active {
|
||||
border-color: var(--brand-primary);
|
||||
background: var(--brand-primary-alpha);
|
||||
background: var(--bg-surface);
|
||||
color: var(--brand-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.mode-btn:hover {
|
||||
border-color: var(--border-strong);
|
||||
.mode-btn:hover:not(.active) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
|
||||
@@ -52,8 +52,8 @@ const strengthColor = computed(() => {
|
||||
|
||||
// ==========================
|
||||
|
||||
function startCooldown() {
|
||||
countdown.value = CODE_RESEND_SECONDS
|
||||
function startCooldown(seconds = CODE_RESEND_SECONDS) {
|
||||
countdown.value = Math.max(1, seconds)
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
countdownTimer = setInterval(() => {
|
||||
countdown.value -= 1
|
||||
@@ -76,6 +76,10 @@ async function handleSendCode() {
|
||||
successMessage.value = result.message || '验证码已发送'
|
||||
startCooldown()
|
||||
} catch (error) {
|
||||
const retryAfter = (error as Error & { retryAfter?: number }).retryAfter
|
||||
if (typeof retryAfter === 'number' && retryAfter > 0) {
|
||||
startCooldown(retryAfter)
|
||||
}
|
||||
errorMessage.value = error instanceof Error ? error.message : '验证码发送失败,请稍后重试'
|
||||
}
|
||||
}
|
||||
@@ -242,18 +246,21 @@ onUnmounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 大部分样式复用 Login 的 split-layout 体系 */
|
||||
/* ==========================================
|
||||
全新高级分屏布局与背景
|
||||
========================================== */
|
||||
.split-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
.brand-panel {
|
||||
flex: 1;
|
||||
display: none;
|
||||
background-color: var(--bg-surface);
|
||||
background: linear-gradient(135deg, var(--bg-surface), var(--bg-base));
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
padding: 60px;
|
||||
padding: 80px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -269,50 +276,63 @@ onUnmounted(() => {
|
||||
.brand-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
max-width: 480px;
|
||||
max-width: 500px;
|
||||
/* 增加悬浮透视感 */
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
padding: 40px;
|
||||
border-radius: var(--radius-xl);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.logo-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: var(--brand-primary);
|
||||
border-radius: 4px;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 40px;
|
||||
letter-spacing: -0.02em;
|
||||
background: linear-gradient(to right, var(--text-primary), var(--text-secondary));
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 40px;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
font-size: 48px;
|
||||
line-height: 1.1;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
margin-bottom: 24px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.brand-desc {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
font-size: 18px;
|
||||
line-height: 1.7;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ambient-glow {
|
||||
position: absolute;
|
||||
top: 60%;
|
||||
top: 50%;
|
||||
left: -10%;
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
width: 80vw;
|
||||
height: 80vw;
|
||||
background: radial-gradient(circle, var(--brand-primary-alpha) 0%, transparent 60%);
|
||||
transform: translateY(-50%);
|
||||
filter: blur(50px);
|
||||
filter: blur(80px);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
animation: float-glow 10s infinite alternate ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes float-glow {
|
||||
0% { transform: translateY(-50%) scale(1); opacity: 0.5; }
|
||||
100% { transform: translateY(-48%) scale(1.05); opacity: 0.8; }
|
||||
}
|
||||
|
||||
.form-panel {
|
||||
@@ -320,18 +340,21 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
background: var(--bg-surface);
|
||||
backdrop-filter: var(--backdrop-blur);
|
||||
-webkit-backdrop-filter: var(--backdrop-blur);
|
||||
}
|
||||
|
||||
.top-actions {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
top: 32px;
|
||||
right: 32px;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
margin: auto;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
max-width: 440px;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
@@ -340,15 +363,16 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.form-header h2 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
margin: 0 0 12px 0;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.form-header p {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 现代密码强度条 */
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { fetchHeadlineRevisions } from '@/api/events'
|
||||
import type { HeadlineRevision } from '@/types/event'
|
||||
|
||||
/** 按事件分组后的修改链条 */
|
||||
interface RevisionChain {
|
||||
event_id: number
|
||||
source_name: string | null
|
||||
/** 标题演变链:从最早的 previous 到最终 revised,已去重 */
|
||||
titles: string[]
|
||||
/** 每次修改对应的时间(与 titles[i+1] 对应) */
|
||||
change_times: string[]
|
||||
first_at: string
|
||||
last_at: string
|
||||
change_count: number
|
||||
}
|
||||
|
||||
const revisions = ref<HeadlineRevision[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const hoursRange = ref(48)
|
||||
|
||||
// 平台名到图标的映射(与首页保持一致,避免同一平台在不同页面图标不一致)
|
||||
const platformIconMap: Record<string, string> = {
|
||||
微博热搜: 'fa-brands fa-weibo',
|
||||
微博: 'fa-brands fa-weibo',
|
||||
知乎热榜: 'fa-brands fa-zhihu',
|
||||
知乎: 'fa-brands fa-zhihu',
|
||||
百度热搜: 'fa-solid fa-b',
|
||||
今日头条: 'fa-solid fa-newspaper',
|
||||
抖音热榜: 'fa-brands fa-tiktok',
|
||||
抖音: 'fa-brands fa-tiktok',
|
||||
B站热搜: 'fa-brands fa-bilibili',
|
||||
'B站热搜': 'fa-brands fa-bilibili',
|
||||
'bilibili 热搜': 'fa-brands fa-bilibili',
|
||||
华尔街见闻: 'fa-solid fa-chart-line',
|
||||
澎湃新闻: 'fa-solid fa-water',
|
||||
财联社热门: 'fa-solid fa-coins',
|
||||
凤凰网: 'fa-solid fa-feather',
|
||||
贴吧: 'fa-solid fa-comments',
|
||||
}
|
||||
|
||||
function getPlatformIcon(name: string): string {
|
||||
return platformIconMap[name] || 'fa-solid fa-globe'
|
||||
}
|
||||
|
||||
/** 格式化时间 */
|
||||
function formatTime(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
const now = Date.now()
|
||||
const diff = now - d.getTime()
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
if (minutes < 1) return '刚刚'
|
||||
if (minutes < 60) return `${minutes} 分钟前`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours} 小时前`
|
||||
return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 将原始修改记录按 event_id 分组,并在每组内拼接完整的标题演变链。
|
||||
* 规则:同组内按 created_at 升序排列,然后依次将 previous/revised 串成链条。
|
||||
*/
|
||||
const revisionChains = computed<RevisionChain[]>(() => {
|
||||
// 按 event_id 分组
|
||||
const groups = new Map<number, HeadlineRevision[]>()
|
||||
for (const rev of revisions.value) {
|
||||
const list = groups.get(rev.event_id) ?? []
|
||||
list.push(rev)
|
||||
groups.set(rev.event_id, list)
|
||||
}
|
||||
|
||||
const chains: RevisionChain[] = []
|
||||
for (const [event_id, items] of groups) {
|
||||
// 组内按时间升序
|
||||
items.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
|
||||
|
||||
// 拼接标题链,避免重复(相邻记录的 revised 与下一条 previous 通常相同)
|
||||
const titles: string[] = [items[0].previous_headline]
|
||||
const change_times: string[] = []
|
||||
for (const item of items) {
|
||||
// 若链条末尾与本条 previous 不同,说明有断层,仍然追加
|
||||
if (titles[titles.length - 1] !== item.previous_headline) {
|
||||
titles.push(item.previous_headline)
|
||||
change_times.push(item.created_at)
|
||||
}
|
||||
titles.push(item.revised_headline)
|
||||
change_times.push(item.created_at)
|
||||
}
|
||||
|
||||
chains.push({
|
||||
event_id,
|
||||
source_name: items[0].source_name,
|
||||
titles,
|
||||
change_times,
|
||||
first_at: items[0].created_at,
|
||||
last_at: items[items.length - 1].created_at,
|
||||
change_count: items.length,
|
||||
})
|
||||
}
|
||||
|
||||
// 最终按最新修改时间降序
|
||||
chains.sort((a, b) => new Date(b.last_at).getTime() - new Date(a.last_at).getTime())
|
||||
return chains
|
||||
})
|
||||
|
||||
/** 加载数据 */
|
||||
async function loadRevisions() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
revisions.value = await fetchHeadlineRevisions({ hours: hoursRange.value, limit: 200 })
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换时间范围 */
|
||||
function changeRange(hours: number) {
|
||||
hoursRange.value = hours
|
||||
loadRevisions()
|
||||
}
|
||||
|
||||
onMounted(loadRevisions)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="revisions-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>
|
||||
<i class="fa-solid fa-mask" style="color: var(--status-error)"></i>
|
||||
公关修改追踪
|
||||
</h1>
|
||||
<p class="page-desc">
|
||||
实时监控各平台热搜标题被暗改的记录。当爬虫检测到标题变更时会自动记录修改前后的差异。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 时间范围选择 -->
|
||||
<div class="filter-bar">
|
||||
<span class="filter-label">查看范围:</span>
|
||||
<div class="filter-tabs">
|
||||
<button
|
||||
v-for="opt in [{ label: '24小时', value: 24 }, { label: '48小时', value: 48 }, { label: '7天', value: 168 }]"
|
||||
:key="opt.value"
|
||||
class="filter-tab"
|
||||
:class="{ active: hoursRange === opt.value }"
|
||||
@click="changeRange(opt.value)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
<span class="result-count">共 {{ revisionChains.length }} 个事件 · {{ revisions.length }} 次修改</span>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<i class="fa-solid fa-spinner fa-spin"></i>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="error" class="error-state">
|
||||
<i class="fa-solid fa-circle-exclamation"></i>
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="revisions.length === 0" class="empty-state">
|
||||
<i class="fa-solid fa-shield-check"></i>
|
||||
<p>该时段内未检测到标题修改</p>
|
||||
<p class="empty-hint">这是个好消息!说明各平台暂无异常公关操作</p>
|
||||
</div>
|
||||
|
||||
<!-- 修改记录列表(按事件分组,展示完整标题演变链) -->
|
||||
<div v-else class="revision-list">
|
||||
<div v-for="chain in revisionChains" :key="chain.event_id" class="revision-card">
|
||||
<div class="revision-header">
|
||||
<div class="platform-info">
|
||||
<i :class="getPlatformIcon(chain.source_name || '')"></i>
|
||||
<span>{{ chain.source_name || '未知平台' }}</span>
|
||||
<span v-if="chain.change_count > 1" class="change-badge">
|
||||
{{ chain.change_count }} 次修改
|
||||
</span>
|
||||
</div>
|
||||
<div class="revision-time-range">
|
||||
<span>{{ formatTime(chain.first_at) }}</span>
|
||||
<template v-if="chain.change_count > 1">
|
||||
<i class="fa-solid fa-arrow-right time-arrow"></i>
|
||||
<span>{{ formatTime(chain.last_at) }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标题演变链 -->
|
||||
<div class="chain-area">
|
||||
<template v-for="(title, idx) in chain.titles" :key="idx">
|
||||
<!-- 标题节点 -->
|
||||
<div
|
||||
class="chain-title"
|
||||
:class="{
|
||||
'chain-title--original': idx === 0,
|
||||
'chain-title--current': idx === chain.titles.length - 1,
|
||||
'chain-title--middle': idx > 0 && idx < chain.titles.length - 1,
|
||||
}"
|
||||
>
|
||||
<span class="chain-step-label">
|
||||
{{ idx === 0 ? '原始' : idx === chain.titles.length - 1 ? '现在' : `第 ${idx} 次` }}
|
||||
</span>
|
||||
<p class="chain-title-text">{{ title }}</p>
|
||||
<span v-if="idx < chain.change_times.length" class="chain-step-time">
|
||||
{{ formatTime(chain.change_times[idx]) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 箭头分隔(最后一个标题后不需要) -->
|
||||
<div v-if="idx < chain.titles.length - 1" class="chain-arrow">
|
||||
<i class="fa-solid fa-arrow-down"></i>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.revisions-page {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
过滤栏
|
||||
========================================== */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: var(--bg-input);
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.filter-tab:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.filter-tab.active {
|
||||
background: var(--bg-surface);
|
||||
color: var(--brand-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.result-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
状态
|
||||
========================================== */
|
||||
.loading-state,
|
||||
.error-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 60px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.error-state {
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 40px;
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
color: var(--status-success);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
margin-top: 6px !important;
|
||||
font-size: 13px !important;
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
修改记录卡片
|
||||
========================================== */
|
||||
.revision-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.revision-card {
|
||||
background: var(--bg-surface);
|
||||
backdrop-filter: var(--backdrop-blur);
|
||||
-webkit-backdrop-filter: var(--backdrop-blur);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 24px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.revision-card:hover {
|
||||
border-color: var(--brand-primary-alpha);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.revision-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.platform-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.platform-info i {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.revision-time-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.time-arrow {
|
||||
font-size: 10px;
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
/* 修改次数徽章 */
|
||||
.change-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: var(--status-error);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
标题演变链
|
||||
========================================== */
|
||||
.chain-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.chain-title {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
/* 原始标题 —— 红色删除线风格 */
|
||||
.chain-title--original {
|
||||
background: rgba(239, 68, 68, 0.05);
|
||||
border-color: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
|
||||
.chain-title--original .chain-step-label {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.chain-title--original .chain-title-text {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: var(--status-error);
|
||||
}
|
||||
|
||||
/* 中间过渡版本 —— 橙/琥珀色风格 */
|
||||
.chain-title--middle {
|
||||
background: rgba(245, 158, 11, 0.05);
|
||||
border-color: rgba(245, 158, 11, 0.15);
|
||||
}
|
||||
|
||||
.chain-title--middle .chain-step-label {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.chain-title--middle .chain-title-text {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 当前最新版本 —— 绿色高亮风格 */
|
||||
.chain-title--current {
|
||||
background: rgba(16, 185, 129, 0.05);
|
||||
border-color: rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
|
||||
.chain-title--current .chain-step-label {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: var(--status-success);
|
||||
}
|
||||
|
||||
.chain-title--current .chain-title-text {
|
||||
color: var(--status-success);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 步骤标签(原始 / 第N次 / 现在) */
|
||||
.chain-step-label {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.chain-title-text {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.chain-step-time {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-placeholder);
|
||||
white-space: nowrap;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
/* 链条箭头 */
|
||||
.chain-arrow {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
padding: 3px 0 3px 22px;
|
||||
color: var(--text-placeholder);
|
||||
font-size: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,668 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { fetchPreferences, createPreference, deletePreference, fetchRecommendedEvents } from '@/api/preferences'
|
||||
import type { UserTopicPreference, MatchedEvent } from '@/types/preference'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const userId = computed(() => authStore.user?.id ?? 0)
|
||||
|
||||
const preferences = ref<UserTopicPreference[]>([])
|
||||
const newKeyword = ref('')
|
||||
const loading = ref(true)
|
||||
const submitting = ref(false)
|
||||
const error = ref('')
|
||||
const successMsg = ref('')
|
||||
|
||||
const matchedEvents = ref<MatchedEvent[]>([])
|
||||
const loadingMatched = ref(false)
|
||||
const matchedError = ref('')
|
||||
|
||||
/** 加载用户的兴趣关键词 */
|
||||
async function loadPreferences() {
|
||||
if (!userId.value) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
preferences.value = await fetchPreferences(userId.value)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载命中关键词的推荐事件 */
|
||||
async function loadMatchedEvents() {
|
||||
if (!userId.value) return
|
||||
loadingMatched.value = true
|
||||
matchedError.value = ''
|
||||
try {
|
||||
const result = await fetchRecommendedEvents(userId.value, { limit: 30 })
|
||||
matchedEvents.value = result.data
|
||||
} catch (e) {
|
||||
matchedError.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loadingMatched.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 格式化热度标签 */
|
||||
function hotLabel(score: number): { text: string; color: string; bg: string } {
|
||||
if (score >= 50) return { text: `🔥 ${score}`, color: '#ef4444', bg: 'rgba(239,68,68,0.1)' }
|
||||
if (score >= 20) return { text: `🌡 ${score}`, color: '#f59e0b', bg: 'rgba(245,158,11,0.1)' }
|
||||
return { text: `${score}`, color: 'var(--text-secondary)', bg: 'var(--bg-input)' }
|
||||
}
|
||||
|
||||
/** 添加新关键词 */
|
||||
async function handleAdd() {
|
||||
const keyword = newKeyword.value.trim()
|
||||
if (!keyword) return
|
||||
if (!userId.value) return
|
||||
|
||||
submitting.value = true
|
||||
error.value = ''
|
||||
successMsg.value = ''
|
||||
try {
|
||||
const created = await createPreference(userId.value, keyword)
|
||||
preferences.value.unshift(created)
|
||||
newKeyword.value = ''
|
||||
successMsg.value = `已添加「${keyword}」`
|
||||
setTimeout(() => { successMsg.value = '' }, 3000)
|
||||
loadMatchedEvents()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '添加失败'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除关键词 */
|
||||
async function handleDelete(pref: UserTopicPreference) {
|
||||
if (!userId.value) return
|
||||
error.value = ''
|
||||
try {
|
||||
await deletePreference(userId.value, pref.id)
|
||||
preferences.value = preferences.value.filter(p => p.id !== pref.id)
|
||||
loadMatchedEvents()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '删除失败'
|
||||
}
|
||||
}
|
||||
|
||||
/** Enter 键提交 */
|
||||
function onInputKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleAdd()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadPreferences()
|
||||
loadMatchedEvents()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="topics-page">
|
||||
<div class="page-header">
|
||||
<h1>
|
||||
<i class="fa-solid fa-rss" style="color: var(--brand-primary)"></i>
|
||||
我的泛订阅
|
||||
</h1>
|
||||
<p class="page-desc">
|
||||
添加你感兴趣的关键词,系统会自动匹配全网热点事件并推送给你。
|
||||
支持精确匹配和 AI 语义匹配。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 添加关键词 -->
|
||||
<div class="add-section">
|
||||
<div class="add-form">
|
||||
<div class="input-wrapper">
|
||||
<i class="fa-solid fa-plus input-icon"></i>
|
||||
<input
|
||||
v-model="newKeyword"
|
||||
type="text"
|
||||
class="keyword-input"
|
||||
placeholder="输入关键词,如「直升机」「科比」「佐巴扬」..."
|
||||
maxlength="100"
|
||||
@keydown="onInputKeydown"
|
||||
/>
|
||||
</div>
|
||||
<button class="add-btn" :disabled="submitting || !newKeyword.trim()" @click="handleAdd">
|
||||
{{ submitting ? '添加中...' : '添加' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 提示消息 -->
|
||||
<p v-if="successMsg" class="msg success-msg">
|
||||
<i class="fa-solid fa-check-circle"></i> {{ successMsg }}
|
||||
</p>
|
||||
<p v-if="error" class="msg error-msg">
|
||||
<i class="fa-solid fa-circle-exclamation"></i> {{ error }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<i class="fa-solid fa-spinner fa-spin"></i>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
|
||||
<!-- 关键词列表 -->
|
||||
<div v-else class="keywords-section">
|
||||
<h2 class="sub-title">
|
||||
已订阅的关键词
|
||||
<span class="count-badge">{{ preferences.length }}</span>
|
||||
</h2>
|
||||
|
||||
<div v-if="preferences.length === 0" class="empty-state">
|
||||
<i class="fa-solid fa-bookmark"></i>
|
||||
<p>还没有添加任何关键词</p>
|
||||
<p class="empty-hint">在上方输入框中添加你感兴趣的话题</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="keywords-grid">
|
||||
<div v-for="pref in preferences" :key="pref.id" class="keyword-card">
|
||||
<div class="keyword-content">
|
||||
<i class="fa-solid fa-hashtag keyword-icon"></i>
|
||||
<span class="keyword-text">{{ pref.interested_keyword }}</span>
|
||||
</div>
|
||||
<button class="delete-btn" title="删除" @click="handleDelete(pref)">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 命中的热点事件 -->
|
||||
<div class="matched-section">
|
||||
<h2 class="sub-title">
|
||||
<i class="fa-solid fa-wand-magic-sparkles" style="color: var(--brand-primary)"></i>
|
||||
命中的热点事件
|
||||
<span v-if="!loadingMatched && matchedEvents.length > 0" class="count-badge">
|
||||
{{ matchedEvents.length }}
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loadingMatched" class="loading-state">
|
||||
<i class="fa-solid fa-spinner fa-spin"></i>
|
||||
<span>AI 匹配中...</span>
|
||||
</div>
|
||||
|
||||
<!-- 错误 -->
|
||||
<p v-else-if="matchedError" class="msg error-msg">
|
||||
<i class="fa-solid fa-circle-exclamation"></i> {{ matchedError }}
|
||||
</p>
|
||||
|
||||
<!-- 无关键词 -->
|
||||
<div v-else-if="preferences.length === 0" class="empty-state">
|
||||
<i class="fa-solid fa-search"></i>
|
||||
<p>先添加关键词才能查看匹配事件</p>
|
||||
</div>
|
||||
|
||||
<!-- 无匹配 -->
|
||||
<div v-else-if="matchedEvents.length === 0" class="empty-state">
|
||||
<i class="fa-solid fa-satellite-dish"></i>
|
||||
<p>暂未匹配到相关事件</p>
|
||||
<p class="empty-hint">系统会在下次 AI 摘要生成后自动更新</p>
|
||||
</div>
|
||||
|
||||
<!-- 匹配事件列表 -->
|
||||
<div v-else class="matched-list">
|
||||
<RouterLink
|
||||
v-for="ev in matchedEvents"
|
||||
:key="ev.event_id"
|
||||
:to="{ path: '/', query: { event: ev.event_id } }"
|
||||
class="matched-card"
|
||||
>
|
||||
<!-- 热度 + 匹配度 -->
|
||||
<div class="matched-card-meta">
|
||||
<span
|
||||
class="hot-chip"
|
||||
:style="{ color: hotLabel(ev.hot_score).color, background: hotLabel(ev.hot_score).bg }"
|
||||
>
|
||||
{{ hotLabel(ev.hot_score).text }}
|
||||
</span>
|
||||
<span class="match-score-chip">
|
||||
<i class="fa-solid fa-crosshairs"></i>
|
||||
匹配度 {{ ev.match_score.toFixed(0) }}
|
||||
</span>
|
||||
<span class="matched-goto">
|
||||
<i class="fa-solid fa-arrow-up-right-from-square"></i> 查看详情
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
<p class="matched-title">{{ ev.unified_title }}</p>
|
||||
|
||||
<!-- AI 摘要 -->
|
||||
<p v-if="ev.summary" class="matched-summary">{{ ev.summary }}</p>
|
||||
|
||||
<!-- 命中的关键词标签 -->
|
||||
<div class="matched-hits">
|
||||
<span v-for="hit in ev.exact_hits.slice(0, 4)" :key="hit" class="hit-tag exact">
|
||||
<i class="fa-solid fa-bullseye"></i> {{ hit }}
|
||||
</span>
|
||||
<span
|
||||
v-for="sh in ev.semantic_hits.slice(0, 3)"
|
||||
:key="sh.topic_keyword"
|
||||
class="hit-tag semantic"
|
||||
>
|
||||
<i class="fa-solid fa-brain"></i> {{ sh.topic_keyword }}
|
||||
<span class="sim-pct">{{ (sh.similarity * 100).toFixed(0) }}%</span>
|
||||
</span>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 功能说明 -->
|
||||
<div class="info-panel">
|
||||
<h3><i class="fa-solid fa-lightbulb"></i> 匹配说明</h3>
|
||||
<ul>
|
||||
<li><strong>精确匹配:</strong>关键词与事件标签完全一致或互为包含关系时命中</li>
|
||||
<li><strong>语义匹配:</strong>使用向量模型计算语义相似度,超过阈值自动命中</li>
|
||||
<li><strong>推送触发:</strong>当新事件的标签命中您的关键词时,将在设定时间推送简报</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.topics-page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
添加区域
|
||||
========================================== */
|
||||
.add-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.add-form {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
color: var(--text-placeholder);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.keyword-input {
|
||||
width: 100%;
|
||||
padding: 14px 16px 14px 42px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: inset 0 2px 4px rgba(0,0,0,0.02);
|
||||
}
|
||||
|
||||
.keyword-input::placeholder {
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.keyword-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand-primary);
|
||||
box-shadow: 0 0 0 4px var(--brand-primary-alpha), inset 0 1px 2px rgba(0,0,0,0.02);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
padding: 14px 28px;
|
||||
background: linear-gradient(135deg, var(--brand-primary) 0%, var(--brand-primary-hover) 100%);
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-md);
|
||||
white-space: nowrap;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 4px 12px var(--brand-primary-alpha);
|
||||
}
|
||||
|
||||
.add-btn:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px var(--brand-primary-alpha);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.add-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.msg {
|
||||
font-size: 13px;
|
||||
margin: 10px 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.success-msg {
|
||||
color: var(--status-success);
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
关键词列表
|
||||
========================================== */
|
||||
.keywords-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
font-size: 12px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
background: var(--brand-primary-alpha);
|
||||
color: var(--brand-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 40px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 36px;
|
||||
opacity: 0.3;
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
margin-top: 6px !important;
|
||||
font-size: 13px !important;
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.keywords-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.keyword-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: var(--bg-surface);
|
||||
backdrop-filter: var(--backdrop-blur);
|
||||
-webkit-backdrop-filter: var(--backdrop-blur);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.keyword-card:hover {
|
||||
border-color: var(--brand-primary-alpha);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.keyword-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.keyword-icon {
|
||||
color: var(--brand-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.keyword-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
color: var(--text-placeholder);
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
color: var(--status-error);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
命中事件区块
|
||||
========================================== */
|
||||
.matched-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.matched-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.matched-card {
|
||||
display: block;
|
||||
padding: 16px;
|
||||
background: var(--bg-surface);
|
||||
backdrop-filter: var(--backdrop-blur);
|
||||
-webkit-backdrop-filter: var(--backdrop-blur);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-sm);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.matched-card:hover {
|
||||
border-color: var(--brand-primary-alpha);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.matched-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hot-chip {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
.match-score-chip {
|
||||
font-size: 11px;
|
||||
color: var(--text-placeholder);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.matched-goto {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--brand-primary);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.matched-card:hover .matched-goto {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.matched-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 6px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.matched-summary {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 8px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.matched-hits {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.hit-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hit-tag.exact {
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.hit-tag.semantic {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--status-success);
|
||||
}
|
||||
|
||||
.sim-pct {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
功能说明
|
||||
========================================== */
|
||||
.info-panel {
|
||||
background: var(--bg-surface);
|
||||
backdrop-filter: var(--backdrop-blur);
|
||||
-webkit-backdrop-filter: var(--backdrop-blur);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 24px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.info-panel h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.info-panel h3 i {
|
||||
color: #facc15;
|
||||
}
|
||||
|
||||
.info-panel ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info-panel li {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.8;
|
||||
padding-left: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.info-panel li::before {
|
||||
content: '•';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -8,12 +8,15 @@ import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
// vueDevTools(),
|
||||
],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8000',
|
||||
target: 'http://10.252.130.135:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user