formula project
1
frontend/env.d.ts
vendored
Executable file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
13
frontend/index.html
Executable file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
140
frontend/src/App.vue
Executable file
@@ -0,0 +1,140 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter, useRoute, RouterView } from 'vue-router';
|
||||
import { clearToken, setSessionAuth } from './utils/auth';
|
||||
import { userInfoStore } from './store/UserInfo'
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const userInfo = userInfoStore()
|
||||
|
||||
const handleNavClick = (path: string) => {
|
||||
router.push(path);
|
||||
}
|
||||
|
||||
const mainContent = ref<HTMLElement | null>(null)
|
||||
router.afterEach(() => {
|
||||
if (mainContent.value) {
|
||||
mainContent.value.scrollTop = 0
|
||||
}
|
||||
})
|
||||
|
||||
const LogOut = async () => {
|
||||
router.push('/login');
|
||||
clearToken();
|
||||
setSessionAuth(false);
|
||||
await userInfo.clearUserInfo()
|
||||
}
|
||||
|
||||
const hideHeader = computed(() => route.name === 'login' || route.name === 'register')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="app">
|
||||
<el-header v-if="!hideHeader" class="header">
|
||||
<div class="navbar" :router="true">
|
||||
<div @click="handleNavClick('/')">
|
||||
<svg class="icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-home"></use>
|
||||
</svg>Home
|
||||
</div>
|
||||
<div @click="handleNavClick('/seasons')">
|
||||
<svg class="icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-milestone"></use>
|
||||
</svg>Seasons
|
||||
</div>
|
||||
<div @click="handleNavClick('/teams')">
|
||||
<svg class="icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-car"></use>
|
||||
</svg>Teams
|
||||
</div>
|
||||
<div @click="handleNavClick('/drivers')">
|
||||
<svg class="icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-driver"></use>
|
||||
</svg>Drivers
|
||||
</div>
|
||||
</div>
|
||||
<div class="logo">
|
||||
<!-- <span>
|
||||
<img src="@/assets/logo.webp" alt="logo">
|
||||
</span> -->
|
||||
<el-avatar :size="32" class="avatar">{{ userInfo.userInfo.username?.[0] || '?' }}</el-avatar>
|
||||
<el-button type="primary" @click="LogOut()" class="logOut">LogOut</el-button>
|
||||
</div>
|
||||
</el-header>
|
||||
<el-main :class="hideHeader ? 'main-no-header' : 'main'" ref="mainContent">
|
||||
<RouterView />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
height: 90px;
|
||||
background: linear-gradient(90deg, #f00f0f 0%, #ee8d11 25%, #c026d9 50%, #294fd6 75%, #2fd03f 100%);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
.navbar div {
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
padding: 0 30px;
|
||||
}
|
||||
|
||||
.navbar div:hover {
|
||||
cursor: pointer;
|
||||
background-color: rgb(198, 230, 239, 0.3);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
vertical-align: -0.15em;
|
||||
fill: currentColor;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main {
|
||||
margin-top: 90px;
|
||||
padding: 0;
|
||||
background-image: url('@/assets/far.jpg');
|
||||
background-size: cover;
|
||||
height: calc(100vh - 90px);
|
||||
&>*{
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
}
|
||||
|
||||
.main-no-header {
|
||||
margin-top: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
margin: auto 0;
|
||||
}
|
||||
|
||||
.logOut {
|
||||
margin: auto 0;
|
||||
margin-left: 10px;
|
||||
background-color: red;
|
||||
}
|
||||
</style>
|
||||
38
frontend/src/api/comments.ts
Executable file
@@ -0,0 +1,38 @@
|
||||
import { apiJSON } from './http'
|
||||
|
||||
export interface CommentItem {
|
||||
id: number
|
||||
username: string
|
||||
content: string
|
||||
user_id: number
|
||||
parentId?: number
|
||||
toUser?: string
|
||||
}
|
||||
|
||||
export interface CommentListResp {
|
||||
items: CommentItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export const fetchLatestComments = (page: number, pageSize: number) => {
|
||||
const offset = (page - 1) * pageSize
|
||||
return apiJSON<any>(`/api/comments?limit=100&offset=${offset}&pageSize=${pageSize}`)
|
||||
}
|
||||
|
||||
export const postComment = (user_id: number, content: string) => {
|
||||
return apiJSON<{}>('/api/comments', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ user_id, content })
|
||||
})
|
||||
}
|
||||
|
||||
export const fetchCommentThread = (id: number) => {
|
||||
return apiJSON<any>(`/api/comments/${id}/replies`)
|
||||
}
|
||||
|
||||
export const postReply = (rootId: number, content: string, user_id: number, response_id: number = -1) => {
|
||||
return apiJSON<{}>(`/api/comments/${rootId}/replies`, {
|
||||
method: 'POST',
|
||||
body: response_id === -1 ? JSON.stringify({ user_id, content }) : JSON.stringify({ user_id, content, response_id })
|
||||
})
|
||||
}
|
||||
27
frontend/src/api/http.ts
Executable file
@@ -0,0 +1,27 @@
|
||||
import { getToken } from '@/utils/auth'
|
||||
|
||||
export async function apiFetch(path: string, options: RequestInit = {}) {
|
||||
const token = getToken()
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string> || {}),
|
||||
}
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||
const res = await fetch(path, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
export async function apiJSON<T>(path: string, options: RequestInit = {}) {
|
||||
const res = await apiFetch(path, options)
|
||||
if (!res.ok) throw new Error(`请求失败: ${res.status}`)
|
||||
const ct = res.headers.get('content-type') || ''
|
||||
if (ct.includes('application/json')) return res.json() as Promise<T>
|
||||
const text = await res.text()
|
||||
// 文本成功兼容
|
||||
if (/success/i.test(text)) return ({ success: true } as unknown) as T
|
||||
throw new Error(text || '请求失败')
|
||||
}
|
||||
BIN
frontend/src/assets/.DS_Store
vendored
Executable file
BIN
frontend/src/assets/Formula1-Black.woff2
Executable file
BIN
frontend/src/assets/drivers/alb.avif
Executable file
|
After Width: | Height: | Size: 42 KiB |
BIN
frontend/src/assets/drivers/alo.avif
Executable file
|
After Width: | Height: | Size: 45 KiB |
BIN
frontend/src/assets/drivers/ant.avif
Executable file
|
After Width: | Height: | Size: 51 KiB |
BIN
frontend/src/assets/drivers/ber.avif
Executable file
|
After Width: | Height: | Size: 37 KiB |
BIN
frontend/src/assets/drivers/bor.avif
Executable file
|
After Width: | Height: | Size: 50 KiB |
BIN
frontend/src/assets/drivers/col.avif
Executable file
|
After Width: | Height: | Size: 40 KiB |
BIN
frontend/src/assets/drivers/gas.avif
Executable file
|
After Width: | Height: | Size: 45 KiB |
BIN
frontend/src/assets/drivers/haj.avif
Executable file
|
After Width: | Height: | Size: 47 KiB |
BIN
frontend/src/assets/drivers/ham.avif
Executable file
|
After Width: | Height: | Size: 50 KiB |
BIN
frontend/src/assets/drivers/hul.avif
Executable file
|
After Width: | Height: | Size: 41 KiB |
BIN
frontend/src/assets/drivers/lando.avif
Executable file
|
After Width: | Height: | Size: 44 KiB |
BIN
frontend/src/assets/drivers/law.avif
Executable file
|
After Width: | Height: | Size: 49 KiB |
BIN
frontend/src/assets/drivers/lec.avif
Executable file
|
After Width: | Height: | Size: 50 KiB |
BIN
frontend/src/assets/drivers/max.avif
Executable file
|
After Width: | Height: | Size: 44 KiB |
BIN
frontend/src/assets/drivers/oc.avif
Executable file
|
After Width: | Height: | Size: 39 KiB |
BIN
frontend/src/assets/drivers/piastri.avif
Executable file
|
After Width: | Height: | Size: 82 KiB |
BIN
frontend/src/assets/drivers/russell.avif
Executable file
|
After Width: | Height: | Size: 92 KiB |
BIN
frontend/src/assets/drivers/sai.avif
Executable file
|
After Width: | Height: | Size: 44 KiB |
BIN
frontend/src/assets/drivers/str.avif
Executable file
|
After Width: | Height: | Size: 49 KiB |
BIN
frontend/src/assets/drivers/yuki.avif
Executable file
|
After Width: | Height: | Size: 132 KiB |
BIN
frontend/src/assets/far.jpg
Executable file
|
After Width: | Height: | Size: 739 KiB |
BIN
frontend/src/assets/heying.jpg
Executable file
|
After Width: | Height: | Size: 209 KiB |
539
frontend/src/assets/icon/font/demo.css
Executable file
@@ -0,0 +1,539 @@
|
||||
/* Logo 字体 */
|
||||
@font-face {
|
||||
font-family: "iconfont logo";
|
||||
src: url('https://at.alicdn.com/t/font_985780_km7mi63cihi.eot?t=1545807318834');
|
||||
src: url('https://at.alicdn.com/t/font_985780_km7mi63cihi.eot?t=1545807318834#iefix') format('embedded-opentype'),
|
||||
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.woff?t=1545807318834') format('woff'),
|
||||
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.ttf?t=1545807318834') format('truetype'),
|
||||
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.svg?t=1545807318834#iconfont') format('svg');
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-family: "iconfont logo";
|
||||
font-size: 160px;
|
||||
font-style: normal;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* tabs */
|
||||
.nav-tabs {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-more {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 42px;
|
||||
line-height: 42px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
#tabs {
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
#tabs li {
|
||||
cursor: pointer;
|
||||
width: 100px;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
border-bottom: 2px solid transparent;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-bottom: -1px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
|
||||
#tabs .active {
|
||||
border-bottom-color: #f00;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.tab-container .content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 页面布局 */
|
||||
.main {
|
||||
padding: 30px 100px;
|
||||
width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.main .logo {
|
||||
color: #333;
|
||||
text-align: left;
|
||||
margin-bottom: 30px;
|
||||
line-height: 1;
|
||||
height: 110px;
|
||||
margin-top: -50px;
|
||||
overflow: hidden;
|
||||
*zoom: 1;
|
||||
}
|
||||
|
||||
.main .logo a {
|
||||
font-size: 160px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.helps {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.helps pre {
|
||||
padding: 20px;
|
||||
margin: 10px 0;
|
||||
border: solid 1px #e7e1cd;
|
||||
background-color: #fffdef;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.icon_lists {
|
||||
width: 100% !important;
|
||||
overflow: hidden;
|
||||
*zoom: 1;
|
||||
}
|
||||
|
||||
.icon_lists li {
|
||||
width: 100px;
|
||||
margin-bottom: 10px;
|
||||
margin-right: 20px;
|
||||
text-align: center;
|
||||
list-style: none !important;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.icon_lists li .code-name {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.icon_lists .icon {
|
||||
display: block;
|
||||
height: 100px;
|
||||
line-height: 100px;
|
||||
font-size: 42px;
|
||||
margin: 10px auto;
|
||||
color: #333;
|
||||
-webkit-transition: font-size 0.25s linear, width 0.25s linear;
|
||||
-moz-transition: font-size 0.25s linear, width 0.25s linear;
|
||||
transition: font-size 0.25s linear, width 0.25s linear;
|
||||
}
|
||||
|
||||
.icon_lists .icon:hover {
|
||||
font-size: 100px;
|
||||
}
|
||||
|
||||
.icon_lists .svg-icon {
|
||||
/* 通过设置 font-size 来改变图标大小 */
|
||||
width: 1em;
|
||||
/* 图标和文字相邻时,垂直对齐 */
|
||||
vertical-align: -0.15em;
|
||||
/* 通过设置 color 来改变 SVG 的颜色/fill */
|
||||
fill: currentColor;
|
||||
/* path 和 stroke 溢出 viewBox 部分在 IE 下会显示
|
||||
normalize.css 中也包含这行 */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.icon_lists li .name,
|
||||
.icon_lists li .code-name {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* markdown 样式 */
|
||||
.markdown {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.markdown img {
|
||||
vertical-align: middle;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.markdown h1 {
|
||||
color: #404040;
|
||||
font-weight: 500;
|
||||
line-height: 40px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.markdown h2,
|
||||
.markdown h3,
|
||||
.markdown h4,
|
||||
.markdown h5,
|
||||
.markdown h6 {
|
||||
color: #404040;
|
||||
margin: 1.6em 0 0.6em 0;
|
||||
font-weight: 500;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.markdown h1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.markdown h2 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.markdown h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.markdown h4 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.markdown h5 {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.markdown h6 {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.markdown hr {
|
||||
height: 1px;
|
||||
border: 0;
|
||||
background: #e9e9e9;
|
||||
margin: 16px 0;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.markdown p {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.markdown>p,
|
||||
.markdown>blockquote,
|
||||
.markdown>.highlight,
|
||||
.markdown>ol,
|
||||
.markdown>ul {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.markdown ul>li {
|
||||
list-style: circle;
|
||||
}
|
||||
|
||||
.markdown>ul li,
|
||||
.markdown blockquote ul>li {
|
||||
margin-left: 20px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.markdown>ul li p,
|
||||
.markdown>ol li p {
|
||||
margin: 0.6em 0;
|
||||
}
|
||||
|
||||
.markdown ol>li {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
.markdown>ol li,
|
||||
.markdown blockquote ol>li {
|
||||
margin-left: 20px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.markdown code {
|
||||
margin: 0 3px;
|
||||
padding: 0 5px;
|
||||
background: #eee;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.markdown strong,
|
||||
.markdown b {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown>table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0px;
|
||||
empty-cells: show;
|
||||
border: 1px solid #e9e9e9;
|
||||
width: 95%;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.markdown>table th {
|
||||
white-space: nowrap;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown>table th,
|
||||
.markdown>table td {
|
||||
border: 1px solid #e9e9e9;
|
||||
padding: 8px 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown>table th {
|
||||
background: #F7F7F7;
|
||||
}
|
||||
|
||||
.markdown blockquote {
|
||||
font-size: 90%;
|
||||
color: #999;
|
||||
border-left: 4px solid #e9e9e9;
|
||||
padding-left: 0.8em;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.markdown blockquote p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.markdown .anchor {
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.markdown .waiting {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.markdown h1:hover .anchor,
|
||||
.markdown h2:hover .anchor,
|
||||
.markdown h3:hover .anchor,
|
||||
.markdown h4:hover .anchor,
|
||||
.markdown h5:hover .anchor,
|
||||
.markdown h6:hover .anchor {
|
||||
opacity: 1;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.markdown>br,
|
||||
.markdown>p>br {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
|
||||
.hljs {
|
||||
display: block;
|
||||
background: white;
|
||||
padding: 0.5em;
|
||||
color: #333333;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-meta {
|
||||
color: #969896;
|
||||
}
|
||||
|
||||
.hljs-string,
|
||||
.hljs-variable,
|
||||
.hljs-template-variable,
|
||||
.hljs-strong,
|
||||
.hljs-emphasis,
|
||||
.hljs-quote {
|
||||
color: #df5000;
|
||||
}
|
||||
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag,
|
||||
.hljs-type {
|
||||
color: #a71d5d;
|
||||
}
|
||||
|
||||
.hljs-literal,
|
||||
.hljs-symbol,
|
||||
.hljs-bullet,
|
||||
.hljs-attribute {
|
||||
color: #0086b3;
|
||||
}
|
||||
|
||||
.hljs-section,
|
||||
.hljs-name {
|
||||
color: #63a35c;
|
||||
}
|
||||
|
||||
.hljs-tag {
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.hljs-title,
|
||||
.hljs-attr,
|
||||
.hljs-selector-id,
|
||||
.hljs-selector-class,
|
||||
.hljs-selector-attr,
|
||||
.hljs-selector-pseudo {
|
||||
color: #795da3;
|
||||
}
|
||||
|
||||
.hljs-addition {
|
||||
color: #55a532;
|
||||
background-color: #eaffea;
|
||||
}
|
||||
|
||||
.hljs-deletion {
|
||||
color: #bd2c00;
|
||||
background-color: #ffecec;
|
||||
}
|
||||
|
||||
.hljs-link {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 代码高亮 */
|
||||
/* PrismJS 1.15.0
|
||||
https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript */
|
||||
/**
|
||||
* prism.js default theme for JavaScript, CSS and HTML
|
||||
* Based on dabblet (http://dabblet.com)
|
||||
* @author Lea Verou
|
||||
*/
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
color: black;
|
||||
background: none;
|
||||
text-shadow: 0 1px white;
|
||||
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
word-spacing: normal;
|
||||
word-break: normal;
|
||||
word-wrap: normal;
|
||||
line-height: 1.5;
|
||||
|
||||
-moz-tab-size: 4;
|
||||
-o-tab-size: 4;
|
||||
tab-size: 4;
|
||||
|
||||
-webkit-hyphens: none;
|
||||
-moz-hyphens: none;
|
||||
-ms-hyphens: none;
|
||||
hyphens: none;
|
||||
}
|
||||
|
||||
pre[class*="language-"]::-moz-selection,
|
||||
pre[class*="language-"] ::-moz-selection,
|
||||
code[class*="language-"]::-moz-selection,
|
||||
code[class*="language-"] ::-moz-selection {
|
||||
text-shadow: none;
|
||||
background: #b3d4fc;
|
||||
}
|
||||
|
||||
pre[class*="language-"]::selection,
|
||||
pre[class*="language-"] ::selection,
|
||||
code[class*="language-"]::selection,
|
||||
code[class*="language-"] ::selection {
|
||||
text-shadow: none;
|
||||
background: #b3d4fc;
|
||||
}
|
||||
|
||||
@media print {
|
||||
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
text-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
pre[class*="language-"] {
|
||||
padding: 1em;
|
||||
margin: .5em 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
:not(pre)>code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
background: #f5f2f0;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
:not(pre)>code[class*="language-"] {
|
||||
padding: .1em;
|
||||
border-radius: .3em;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.token.comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: slategray;
|
||||
}
|
||||
|
||||
.token.punctuation {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.namespace {
|
||||
opacity: .7;
|
||||
}
|
||||
|
||||
.token.property,
|
||||
.token.tag,
|
||||
.token.boolean,
|
||||
.token.number,
|
||||
.token.constant,
|
||||
.token.symbol,
|
||||
.token.deleted {
|
||||
color: #905;
|
||||
}
|
||||
|
||||
.token.selector,
|
||||
.token.attr-name,
|
||||
.token.string,
|
||||
.token.char,
|
||||
.token.builtin,
|
||||
.token.inserted {
|
||||
color: #690;
|
||||
}
|
||||
|
||||
.token.operator,
|
||||
.token.entity,
|
||||
.token.url,
|
||||
.language-css .token.string,
|
||||
.style .token.string {
|
||||
color: #9a6e3a;
|
||||
background: hsla(0, 0%, 100%, .5);
|
||||
}
|
||||
|
||||
.token.atrule,
|
||||
.token.attr-value,
|
||||
.token.keyword {
|
||||
color: #07a;
|
||||
}
|
||||
|
||||
.token.function,
|
||||
.token.class-name {
|
||||
color: #DD4A68;
|
||||
}
|
||||
|
||||
.token.regex,
|
||||
.token.important,
|
||||
.token.variable {
|
||||
color: #e90;
|
||||
}
|
||||
|
||||
.token.important,
|
||||
.token.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.token.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.entity {
|
||||
cursor: help;
|
||||
}
|
||||
303
frontend/src/assets/icon/font/demo_index.html
Executable file
@@ -0,0 +1,303 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>iconfont Demo</title>
|
||||
<link rel="shortcut icon" href="//img.alicdn.com/imgextra/i4/O1CN01Z5paLz1O0zuCC7osS_!!6000000001644-55-tps-83-82.svg" type="image/x-icon"/>
|
||||
<link rel="icon" type="image/svg+xml" href="//img.alicdn.com/imgextra/i4/O1CN01Z5paLz1O0zuCC7osS_!!6000000001644-55-tps-83-82.svg"/>
|
||||
<link rel="stylesheet" href="https://g.alicdn.com/thx/cube/1.3.2/cube.min.css">
|
||||
<link rel="stylesheet" href="demo.css">
|
||||
<link rel="stylesheet" href="iconfont.css">
|
||||
<script src="iconfont.js"></script>
|
||||
<!-- jQuery -->
|
||||
<script src="https://a1.alicdn.com/oss/uploads/2018/12/26/7bfddb60-08e8-11e9-9b04-53e73bb6408b.js"></script>
|
||||
<!-- 代码高亮 -->
|
||||
<script src="https://a1.alicdn.com/oss/uploads/2018/12/26/a3f714d0-08e6-11e9-8a15-ebf944d7534c.js"></script>
|
||||
<style>
|
||||
.main .logo {
|
||||
margin-top: 0;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.main .logo a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.main .logo .sub-title {
|
||||
margin-left: 0.5em;
|
||||
font-size: 22px;
|
||||
color: #fff;
|
||||
background: linear-gradient(-45deg, #3967FF, #B500FE);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main">
|
||||
<h1 class="logo"><a href="https://www.iconfont.cn/" title="iconfont 首页" target="_blank">
|
||||
<img width="200" src="https://img.alicdn.com/imgextra/i3/O1CN01Mn65HV1FfSEzR6DKv_!!6000000000514-55-tps-228-59.svg">
|
||||
|
||||
</a></h1>
|
||||
<div class="nav-tabs">
|
||||
<ul id="tabs" class="dib-box">
|
||||
<li class="dib active"><span>Unicode</span></li>
|
||||
<li class="dib"><span>Font class</span></li>
|
||||
<li class="dib"><span>Symbol</span></li>
|
||||
</ul>
|
||||
|
||||
<a href="https://www.iconfont.cn/manage/index?manage_type=myprojects&projectId=5075080" target="_blank" class="nav-more">查看项目</a>
|
||||
|
||||
</div>
|
||||
<div class="tab-container">
|
||||
<div class="content unicode" style="display: block;">
|
||||
<ul class="icon_lists dib-box">
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">cup</div>
|
||||
<div class="code-name">&#xe637;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">race car</div>
|
||||
<div class="code-name">&#xe600;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">sports_icon_racing car@2x</div>
|
||||
<div class="code-name">&#xe67b;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">Home, homepage, menu</div>
|
||||
<div class="code-name">&#xe9db;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">icon_task_details_milestone</div>
|
||||
<div class="code-name">&#xe6c4;</div>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
<div class="article markdown">
|
||||
<h2 id="unicode-">Unicode 引用</h2>
|
||||
<hr>
|
||||
|
||||
<p>Unicode 是字体在网页端最原始的应用方式,特点是:</p>
|
||||
<ul>
|
||||
<li>支持按字体的方式去动态调整图标大小,颜色等等。</li>
|
||||
<li>默认情况下不支持多色,直接添加多色图标会自动去色。</li>
|
||||
</ul>
|
||||
<blockquote>
|
||||
<p>注意:新版 iconfont 支持两种方式引用多色图标:SVG symbol 引用方式和彩色字体图标模式。(使用彩色字体图标需要在「编辑项目」中开启「彩色」选项后并重新生成。)</p>
|
||||
</blockquote>
|
||||
<p>Unicode 使用步骤如下:</p>
|
||||
<h3 id="-font-face">第一步:拷贝项目下面生成的 <code>@font-face</code></h3>
|
||||
<pre><code class="language-css"
|
||||
>@font-face {
|
||||
font-family: 'iconfont';
|
||||
src: url('iconfont.woff2?t=1764608411765') format('woff2'),
|
||||
url('iconfont.woff?t=1764608411765') format('woff'),
|
||||
url('iconfont.ttf?t=1764608411765') format('truetype');
|
||||
}
|
||||
</code></pre>
|
||||
<h3 id="-iconfont-">第二步:定义使用 iconfont 的样式</h3>
|
||||
<pre><code class="language-css"
|
||||
>.iconfont {
|
||||
font-family: "iconfont" !important;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
</code></pre>
|
||||
<h3 id="-">第三步:挑选相应图标并获取字体编码,应用于页面</h3>
|
||||
<pre>
|
||||
<code class="language-html"
|
||||
><span class="iconfont">&#x33;</span>
|
||||
</code></pre>
|
||||
<blockquote>
|
||||
<p>"iconfont" 是你项目下的 font-family。可以通过编辑项目查看,默认是 "iconfont"。</p>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content font-class">
|
||||
<ul class="icon_lists dib-box">
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont icon-cup"></span>
|
||||
<div class="name">
|
||||
cup
|
||||
</div>
|
||||
<div class="code-name">.icon-cup
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont icon-car"></span>
|
||||
<div class="name">
|
||||
race car
|
||||
</div>
|
||||
<div class="code-name">.icon-car
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont icon-driver"></span>
|
||||
<div class="name">
|
||||
sports_icon_racing car@2x
|
||||
</div>
|
||||
<div class="code-name">.icon-driver
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont icon-home"></span>
|
||||
<div class="name">
|
||||
Home, homepage, menu
|
||||
</div>
|
||||
<div class="code-name">.icon-home
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont icon-milestone"></span>
|
||||
<div class="name">
|
||||
icon_task_details_milestone
|
||||
</div>
|
||||
<div class="code-name">.icon-milestone
|
||||
</div>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
<div class="article markdown">
|
||||
<h2 id="font-class-">font-class 引用</h2>
|
||||
<hr>
|
||||
|
||||
<p>font-class 是 Unicode 使用方式的一种变种,主要是解决 Unicode 书写不直观,语意不明确的问题。</p>
|
||||
<p>与 Unicode 使用方式相比,具有如下特点:</p>
|
||||
<ul>
|
||||
<li>相比于 Unicode 语意明确,书写更直观。可以很容易分辨这个 icon 是什么。</li>
|
||||
<li>因为使用 class 来定义图标,所以当要替换图标时,只需要修改 class 里面的 Unicode 引用。</li>
|
||||
</ul>
|
||||
<p>使用步骤如下:</p>
|
||||
<h3 id="-fontclass-">第一步:引入项目下面生成的 fontclass 代码:</h3>
|
||||
<pre><code class="language-html"><link rel="stylesheet" href="./iconfont.css">
|
||||
</code></pre>
|
||||
<h3 id="-">第二步:挑选相应图标并获取类名,应用于页面:</h3>
|
||||
<pre><code class="language-html"><span class="iconfont icon-xxx"></span>
|
||||
</code></pre>
|
||||
<blockquote>
|
||||
<p>"
|
||||
iconfont" 是你项目下的 font-family。可以通过编辑项目查看,默认是 "iconfont"。</p>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content symbol">
|
||||
<ul class="icon_lists dib-box">
|
||||
|
||||
<li class="dib">
|
||||
<svg class="icon svg-icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-cup"></use>
|
||||
</svg>
|
||||
<div class="name">cup</div>
|
||||
<div class="code-name">#icon-cup</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<svg class="icon svg-icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-car"></use>
|
||||
</svg>
|
||||
<div class="name">race car</div>
|
||||
<div class="code-name">#icon-car</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<svg class="icon svg-icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-driver"></use>
|
||||
</svg>
|
||||
<div class="name">sports_icon_racing car@2x</div>
|
||||
<div class="code-name">#icon-driver</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<svg class="icon svg-icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-home"></use>
|
||||
</svg>
|
||||
<div class="name">Home, homepage, menu</div>
|
||||
<div class="code-name">#icon-home</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<svg class="icon svg-icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-milestone"></use>
|
||||
</svg>
|
||||
<div class="name">icon_task_details_milestone</div>
|
||||
<div class="code-name">#icon-milestone</div>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
<div class="article markdown">
|
||||
<h2 id="symbol-">Symbol 引用</h2>
|
||||
<hr>
|
||||
|
||||
<p>这是一种全新的使用方式,应该说这才是未来的主流,也是平台目前推荐的用法。相关介绍可以参考这篇<a href="">文章</a>
|
||||
这种用法其实是做了一个 SVG 的集合,与另外两种相比具有如下特点:</p>
|
||||
<ul>
|
||||
<li>支持多色图标了,不再受单色限制。</li>
|
||||
<li>通过一些技巧,支持像字体那样,通过 <code>font-size</code>, <code>color</code> 来调整样式。</li>
|
||||
<li>兼容性较差,支持 IE9+,及现代浏览器。</li>
|
||||
<li>浏览器渲染 SVG 的性能一般,还不如 png。</li>
|
||||
</ul>
|
||||
<p>使用步骤如下:</p>
|
||||
<h3 id="-symbol-">第一步:引入项目下面生成的 symbol 代码:</h3>
|
||||
<pre><code class="language-html"><script src="./iconfont.js"></script>
|
||||
</code></pre>
|
||||
<h3 id="-css-">第二步:加入通用 CSS 代码(引入一次就行):</h3>
|
||||
<pre><code class="language-html"><style>
|
||||
.icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
vertical-align: -0.15em;
|
||||
fill: currentColor;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</code></pre>
|
||||
<h3 id="-">第三步:挑选相应图标并获取类名,应用于页面:</h3>
|
||||
<pre><code class="language-html"><svg class="icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-xxx"></use>
|
||||
</svg>
|
||||
</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
$('.tab-container .content:first').show()
|
||||
|
||||
$('#tabs li').click(function (e) {
|
||||
var tabContent = $('.tab-container .content')
|
||||
var index = $(this).index()
|
||||
|
||||
if ($(this).hasClass('active')) {
|
||||
return
|
||||
} else {
|
||||
$('#tabs li').removeClass('active')
|
||||
$(this).addClass('active')
|
||||
|
||||
tabContent.hide().eq(index).fadeIn()
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
35
frontend/src/assets/icon/font/iconfont.css
Executable file
@@ -0,0 +1,35 @@
|
||||
@font-face {
|
||||
font-family: "iconfont"; /* Project id 5075080 */
|
||||
src: url('iconfont.woff2?t=1764608411765') format('woff2'),
|
||||
url('iconfont.woff?t=1764608411765') format('woff'),
|
||||
url('iconfont.ttf?t=1764608411765') format('truetype');
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
font-family: "iconfont" !important;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-cup:before {
|
||||
content: "\e637";
|
||||
}
|
||||
|
||||
.icon-car:before {
|
||||
content: "\e600";
|
||||
}
|
||||
|
||||
.icon-driver:before {
|
||||
content: "\e67b";
|
||||
}
|
||||
|
||||
.icon-home:before {
|
||||
content: "\e9db";
|
||||
}
|
||||
|
||||
.icon-milestone:before {
|
||||
content: "\e6c4";
|
||||
}
|
||||
|
||||
1
frontend/src/assets/icon/font/iconfont.js
Executable file
44
frontend/src/assets/icon/font/iconfont.json
Executable file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"id": "5075080",
|
||||
"name": "f1",
|
||||
"font_family": "iconfont",
|
||||
"css_prefix_text": "icon-",
|
||||
"description": "",
|
||||
"glyphs": [
|
||||
{
|
||||
"icon_id": "1206006",
|
||||
"name": "cup",
|
||||
"font_class": "cup",
|
||||
"unicode": "e637",
|
||||
"unicode_decimal": 58935
|
||||
},
|
||||
{
|
||||
"icon_id": "3900649",
|
||||
"name": "race car",
|
||||
"font_class": "car",
|
||||
"unicode": "e600",
|
||||
"unicode_decimal": 58880
|
||||
},
|
||||
{
|
||||
"icon_id": "9936992",
|
||||
"name": "sports_icon_racing car@2x",
|
||||
"font_class": "driver",
|
||||
"unicode": "e67b",
|
||||
"unicode_decimal": 59003
|
||||
},
|
||||
{
|
||||
"icon_id": "11982742",
|
||||
"name": "Home, homepage, menu",
|
||||
"font_class": "home",
|
||||
"unicode": "e9db",
|
||||
"unicode_decimal": 59867
|
||||
},
|
||||
{
|
||||
"icon_id": "13570233",
|
||||
"name": "icon_task_details_milestone",
|
||||
"font_class": "milestone",
|
||||
"unicode": "e6c4",
|
||||
"unicode_decimal": 59076
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
frontend/src/assets/icon/font/iconfont.ttf
Executable file
BIN
frontend/src/assets/icon/font/iconfont.woff
Executable file
BIN
frontend/src/assets/icon/font/iconfont.woff2
Executable file
BIN
frontend/src/assets/logo.webp
Executable file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
frontend/src/assets/prix/Australia.avif
Executable file
|
After Width: | Height: | Size: 548 KiB |
BIN
frontend/src/assets/prix/Bahrain.avif
Executable file
|
After Width: | Height: | Size: 229 KiB |
BIN
frontend/src/assets/prix/China.avif
Executable file
|
After Width: | Height: | Size: 284 KiB |
BIN
frontend/src/assets/prix/Japan.avif
Executable file
|
After Width: | Height: | Size: 272 KiB |
BIN
frontend/src/assets/rb.jpg
Executable file
|
After Width: | Height: | Size: 1.2 MiB |
689
frontend/src/assets/source.ts
Executable file
@@ -0,0 +1,689 @@
|
||||
import piastri from '@/assets/drivers/piastri.avif'
|
||||
import lando from '@/assets/drivers/lando.avif'
|
||||
import russell from '@/assets/drivers/russell.avif'
|
||||
import ant from '@/assets/drivers/ant.avif'
|
||||
import max from '@/assets/drivers/max.avif'
|
||||
import yuki from '@/assets/drivers/yuki.avif'
|
||||
import ham from '@/assets/drivers/ham.avif'
|
||||
import lec from '@/assets/drivers/lec.avif'
|
||||
import alb from '@/assets/drivers/alb.avif'
|
||||
import sai from '@/assets/drivers/sai.avif'
|
||||
import law from '@/assets/drivers/law.avif'
|
||||
import haj from '@/assets/drivers/haj.avif'
|
||||
import str from '@/assets/drivers/str.avif'
|
||||
import alo from '@/assets/drivers/alo.avif'
|
||||
import hul from '@/assets/drivers/hul.avif'
|
||||
import bor from '@/assets/drivers/bor.avif'
|
||||
import oc from '@/assets/drivers/oc.avif'
|
||||
import ber from '@/assets/drivers/ber.avif'
|
||||
import gas from '@/assets/drivers/gas.avif'
|
||||
import col from '@/assets/drivers/col.avif'
|
||||
|
||||
import mclaren from '@/assets/teams/mclaren.avif'
|
||||
import mercedes from '@/assets/teams/merc.avif'
|
||||
import redbull from '@/assets/teams/rb.avif'
|
||||
import ferrari from '@/assets/teams/fe.avif'
|
||||
import williams from '@/assets/teams/will.avif'
|
||||
import racingBulls from '@/assets/teams/srb.avif'
|
||||
import astonMartin from '@/assets/teams/am.avif'
|
||||
import haasF1Team from '@/assets/teams/hass.avif'
|
||||
import kickSauber from '@/assets/teams/kick.avif'
|
||||
import alpine from '@/assets/teams/alp.avif'
|
||||
|
||||
import mclarenLogo from '@/assets/teams/mclogo.avif'
|
||||
import mercedesLogo from '@/assets/teams/merclogo.avif'
|
||||
import redbullLogo from '@/assets/teams/rblogo.avif'
|
||||
import ferrariLogo from '@/assets/teams/felogo.avif'
|
||||
import williamsLogo from '@/assets/teams/willogo.avif'
|
||||
import racingBullsLogo from '@/assets/teams/srblogo.avif'
|
||||
import astonMartinLogo from '@/assets/teams/amlogo.avif'
|
||||
import haasF1TeamLogo from '@/assets/teams/hasslogo.avif'
|
||||
import kickSauberLogo from '@/assets/teams/kicklogo.avif'
|
||||
import alpineLogo from '@/assets/teams/alplogo.avif'
|
||||
|
||||
import australia from '@/assets/prix/australia.avif'
|
||||
import china from '@/assets/prix/china.avif'
|
||||
import japan from '@/assets/prix/japan.avif'
|
||||
import bahrain from '@/assets/prix/bahrain.avif'
|
||||
|
||||
export const getColor = (team: string) : string => {
|
||||
switch (team) {
|
||||
case "McLaren":
|
||||
return "#eb7100";
|
||||
case "Mercedes":
|
||||
return "#00cfaf";
|
||||
case "Red Bull Racing":
|
||||
return "#003282";
|
||||
case "Ferrari":
|
||||
return "#710006";
|
||||
case "Williams":
|
||||
return "#155dd1";
|
||||
case "Racing Bulls":
|
||||
return "#2345ab";
|
||||
case "Aston Martin":
|
||||
return "#00482c";
|
||||
case "Haas F1 Team":
|
||||
return "#4d5052";
|
||||
case "Kick Sauber":
|
||||
return "#006300";
|
||||
case "Alpine":
|
||||
return "#005081";
|
||||
default:
|
||||
return "#000";
|
||||
}
|
||||
}
|
||||
|
||||
export const getLogoColor = (team: string) : string => {
|
||||
switch (team) {
|
||||
case "McLaren":
|
||||
return "#eb7100";
|
||||
case "Mercedes":
|
||||
return "#00cfaf";
|
||||
case "Red Bull Racing":
|
||||
return "#003282";
|
||||
case "Ferrari":
|
||||
return "#710006";
|
||||
case "Williams":
|
||||
return "#155dd1";
|
||||
case "Racing Bulls":
|
||||
return "#2345ab";
|
||||
case "Aston Martin":
|
||||
return "#00482c";
|
||||
case "Haas F1 Team":
|
||||
return "#4d5052";
|
||||
case "Kick Sauber":
|
||||
return "#006300";
|
||||
case "Alpine":
|
||||
return "#005081";
|
||||
default:
|
||||
return "#000";
|
||||
}
|
||||
}
|
||||
|
||||
export const getImage = (name: string) : string => {
|
||||
switch (name) {
|
||||
case "Oscar Piastri":
|
||||
return piastri;
|
||||
case "Lando Norris":
|
||||
return lando;
|
||||
case "George Russell":
|
||||
return russell;
|
||||
case "Kimi Antonelli":
|
||||
return ant;
|
||||
case "Max Verstappen":
|
||||
return max;
|
||||
case "Yuki Tsunoda":
|
||||
return yuki;
|
||||
case "Lewis Hamilton":
|
||||
return ham;
|
||||
case "Charles Leclerc":
|
||||
return lec;
|
||||
case "Alexander Albon":
|
||||
return alb;
|
||||
case "Carlos Sainz":
|
||||
return sai;
|
||||
case "Liam Lawson":
|
||||
return law;
|
||||
case "Isack Hadjar":
|
||||
return haj;
|
||||
case "Lance Stroll":
|
||||
return str;
|
||||
case "Fernando Alonso":
|
||||
return alo;
|
||||
case "Esteban Ocon":
|
||||
return oc;
|
||||
case "Oliver Bearman":
|
||||
return ber;
|
||||
case "Nico Hulkenberg":
|
||||
return hul;
|
||||
case "Gabriel Bortoleto":
|
||||
return bor;
|
||||
case "Pierre Gasly":
|
||||
return gas;
|
||||
case "Franco Colapinto":
|
||||
return col;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export const getCarImage = (team: string) : string => {
|
||||
switch (team) {
|
||||
case "McLaren":
|
||||
return mclaren;
|
||||
case "Mercedes":
|
||||
return mercedes;
|
||||
case "Red Bull Racing":
|
||||
return redbull;
|
||||
case "Ferrari":
|
||||
return ferrari;
|
||||
case "Williams":
|
||||
return williams;
|
||||
case "Racing Bulls":
|
||||
return racingBulls;
|
||||
case "Aston Martin":
|
||||
return astonMartin;
|
||||
case "Haas F1 Team":
|
||||
return haasF1Team;
|
||||
case "Kick Sauber":
|
||||
return kickSauber;
|
||||
case "Alpine":
|
||||
return alpine;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export const getLogo = (team: string) : string => {
|
||||
switch (team) {
|
||||
case "McLaren":
|
||||
return mclarenLogo;
|
||||
case "Mercedes":
|
||||
return mercedesLogo;
|
||||
case "Red Bull Racing":
|
||||
return redbullLogo;
|
||||
case "Ferrari":
|
||||
return ferrariLogo;
|
||||
case "Williams":
|
||||
return williamsLogo;
|
||||
case "Racing Bulls":
|
||||
return racingBullsLogo;
|
||||
case "Aston Martin":
|
||||
return astonMartinLogo;
|
||||
case "Haas F1 Team":
|
||||
return haasF1TeamLogo;
|
||||
case "Kick Sauber":
|
||||
return kickSauberLogo;
|
||||
case "Alpine":
|
||||
return alpineLogo;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export const prix = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Australia",
|
||||
image: australia,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "China",
|
||||
image: china,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Japan",
|
||||
image: japan,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Bahrain",
|
||||
image: bahrain,
|
||||
}
|
||||
]
|
||||
|
||||
/* export const teams = [
|
||||
{
|
||||
id: 1,
|
||||
name: "McLaren",
|
||||
nation: "United Kingdom",
|
||||
image: mclaren,
|
||||
color: "#eb7100",
|
||||
driver1: "Oscar Piastri",
|
||||
driver2: "Lando Norris",
|
||||
logo: mclarenLogo
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Mercedes",
|
||||
nation: "Germany",
|
||||
image: mercedes,
|
||||
color: "#00d2be",
|
||||
driver1: "George Russell",
|
||||
driver2: "Kimi Antonell",
|
||||
logo: mercedesLogo
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Red Bull Racing",
|
||||
nation: "United Kingdom",
|
||||
image: redbull,
|
||||
color: "#003282",
|
||||
driver1: "Max Verstappen",
|
||||
driver2: "yuki Tsunoda",
|
||||
logo: redbullLogo
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Ferrari",
|
||||
nation: "Italy",
|
||||
image: ferrari,
|
||||
color: "#dc0000",
|
||||
driver1: "Lewis Hamilton",
|
||||
driver2: "Charles Leclerc",
|
||||
logo: ferrariLogo
|
||||
},
|
||||
]
|
||||
*/
|
||||
|
||||
export const prixes = [
|
||||
{
|
||||
id: 1,
|
||||
name: "澳大利亚",
|
||||
date: "16 Mar",
|
||||
pos: 0,
|
||||
pts: 0
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "中国",
|
||||
date: "23 Mar",
|
||||
pos: 0,
|
||||
pts: 0
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "日本",
|
||||
date: "06 Apr",
|
||||
pos: 0,
|
||||
pts: 0
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "巴林",
|
||||
date: "13 Apr",
|
||||
pos: 0,
|
||||
pts: 0
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "沙特阿拉伯",
|
||||
date: "20 Apr",
|
||||
pos: 0,
|
||||
pts: 0
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "迈阿密",
|
||||
date: "04 May",
|
||||
pos: 0,
|
||||
pts: 0
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: "伊莫拉",
|
||||
date: "18 May",
|
||||
pos: 0,
|
||||
pts: 0
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: "摩纳哥",
|
||||
date: "25 May",
|
||||
pos: 0,
|
||||
pts: 0
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: "西班牙",
|
||||
date: "01 Jun",
|
||||
pos: 0,
|
||||
pts: 0
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: "意大利",
|
||||
date: "15 Jun",
|
||||
pos: 0,
|
||||
pts: 0
|
||||
},
|
||||
]
|
||||
|
||||
export const driver_career = [
|
||||
{
|
||||
name: "Oscar Piastri",
|
||||
races: 69,
|
||||
points: 781,
|
||||
hf: 1,
|
||||
podiums: 25,
|
||||
hg: 1,
|
||||
polepositions: 6,
|
||||
wc: 0,
|
||||
dnfs: 4,
|
||||
wins: 9
|
||||
},
|
||||
{
|
||||
name: "Lando Norris",
|
||||
races: 151,
|
||||
points: 1415,
|
||||
hf: 1,
|
||||
podiums: 43,
|
||||
hg: 1,
|
||||
polepositions: 16,
|
||||
wc: 0,
|
||||
dnfs: 13,
|
||||
wins: 11
|
||||
},
|
||||
{
|
||||
name: "George Russell",
|
||||
races: 151,
|
||||
points: 1023,
|
||||
hf: 1,
|
||||
podiums: 24,
|
||||
hg: 1,
|
||||
polepositions: 7,
|
||||
wc: 0,
|
||||
dnfs: 19,
|
||||
wins: 5
|
||||
},
|
||||
{
|
||||
name: "Kimi Antonelli",
|
||||
races: 23,
|
||||
points: 150,
|
||||
hf: 2,
|
||||
podiums: 3,
|
||||
hg: 2,
|
||||
polepositions: 0,
|
||||
wc: 0,
|
||||
dnfs: 4,
|
||||
wins: 0
|
||||
},
|
||||
{
|
||||
name: "Max Verstappen",
|
||||
races: 232,
|
||||
points: 3419.5,
|
||||
hf: 1,
|
||||
podiums: 126,
|
||||
hg: 1,
|
||||
polepositions: 47,
|
||||
wc: 4,
|
||||
dnfs: 33,
|
||||
wins: 71
|
||||
},
|
||||
{
|
||||
name: "yuki Tsunoda",
|
||||
races: 110,
|
||||
points: 124,
|
||||
hf: 4,
|
||||
podiums: 0,
|
||||
hg: 3,
|
||||
polepositions: 0,
|
||||
wc: 0,
|
||||
dnfs: 15,
|
||||
wins: 0
|
||||
},
|
||||
{
|
||||
name: "Charles Leclerc",
|
||||
races: 170,
|
||||
points: 1660,
|
||||
hf: 1,
|
||||
podiums: 50,
|
||||
hg: 1,
|
||||
polepositions: 27,
|
||||
wc: 0,
|
||||
dnfs: 23,
|
||||
wins: 8
|
||||
},
|
||||
{
|
||||
name: "Lewis Hamilton",
|
||||
races: 379,
|
||||
points: 5014.5,
|
||||
hf: 1,
|
||||
podiums: 202,
|
||||
hg: 1,
|
||||
polepositions: 104,
|
||||
wc: 7,
|
||||
dnfs: 34,
|
||||
wins: 105
|
||||
},
|
||||
{
|
||||
name: "Alexander Albon",
|
||||
races: 127,
|
||||
points: 313,
|
||||
hf: 3,
|
||||
podiums: 2,
|
||||
hg: 4,
|
||||
polepositions: 0,
|
||||
wc: 0,
|
||||
dnfs: 22,
|
||||
wins: 0
|
||||
},
|
||||
{
|
||||
name: "Carlos Sainz",
|
||||
races: 229,
|
||||
points: 1336.5,
|
||||
hf: 1,
|
||||
podiums: 29,
|
||||
hg: 1,
|
||||
polepositions: 6,
|
||||
wc: 0,
|
||||
dnfs: 42,
|
||||
wins: 4
|
||||
},
|
||||
{
|
||||
name: "Liam Lawson",
|
||||
races: 34,
|
||||
points: 44,
|
||||
hf: 5,
|
||||
podiums: 0,
|
||||
hg: 3,
|
||||
polepositions: 0,
|
||||
wc: 0,
|
||||
dnfs: 6,
|
||||
wins: 0
|
||||
},
|
||||
{
|
||||
name: "Isack Hadjar",
|
||||
races: 22,
|
||||
points: 51,
|
||||
hf: 3,
|
||||
podiums: 1,
|
||||
hg: 4,
|
||||
polepositions: 0,
|
||||
wc: 0,
|
||||
dnfs: 2,
|
||||
wins: 0
|
||||
},
|
||||
{
|
||||
name: "Lance Stroll",
|
||||
races: 189,
|
||||
points: 324,
|
||||
hf: 3,
|
||||
podiums: 3,
|
||||
hg: 1,
|
||||
polepositions: 1,
|
||||
wc: 0,
|
||||
dnfs: 31,
|
||||
wins: 0
|
||||
},
|
||||
{
|
||||
name: "Fernando Alonso",
|
||||
races: 426,
|
||||
points: 2385,
|
||||
hf: 1,
|
||||
podiums: 106,
|
||||
hg: 1,
|
||||
polepositions: 22,
|
||||
wc: 2,
|
||||
dnfs: 83,
|
||||
wins: 32
|
||||
},
|
||||
{
|
||||
name: "Esteban Ocon",
|
||||
races: 179,
|
||||
points: 477,
|
||||
hf: 1,
|
||||
podiums: 4,
|
||||
hg: 3,
|
||||
polepositions: 0,
|
||||
wc: 0,
|
||||
dnfs: 25,
|
||||
wins: 1
|
||||
},
|
||||
{
|
||||
name: "Oliver Bearman",
|
||||
races: 26,
|
||||
points: 48,
|
||||
hf: 4,
|
||||
podiums: 0,
|
||||
hg: 8,
|
||||
polepositions: 0,
|
||||
wc: 0,
|
||||
dnfs: 3,
|
||||
wins: 0
|
||||
},
|
||||
{
|
||||
name: "Nico Hulkenberg",
|
||||
races: 250,
|
||||
points: 620,
|
||||
hf: 3,
|
||||
podiums: 1,
|
||||
hg: 1,
|
||||
polepositions: 1,
|
||||
wc: 0,
|
||||
dnfs: 44,
|
||||
wins: 0
|
||||
},
|
||||
{
|
||||
name: "Gabriel Bortoleto",
|
||||
races: 23,
|
||||
points: 19,
|
||||
hf: 6,
|
||||
podiums: 0,
|
||||
hg: 7,
|
||||
polepositions: 0,
|
||||
wc: 0,
|
||||
dnfs: 5,
|
||||
wins: 0
|
||||
},
|
||||
{
|
||||
name: "Pierre Gasly",
|
||||
races: 176,
|
||||
points: 458,
|
||||
hf: 1,
|
||||
podiums: 5,
|
||||
hg: 2,
|
||||
polepositions: 0,
|
||||
wc: 0,
|
||||
dnfs: 26,
|
||||
wins: 1
|
||||
},
|
||||
{
|
||||
name: "Franco Colapinto",
|
||||
races: 26,
|
||||
points: 5,
|
||||
hf: 8,
|
||||
podiums: 0,
|
||||
hg: 8,
|
||||
polepositions: 0,
|
||||
wc: 0,
|
||||
dnfs: 3,
|
||||
wins: 0
|
||||
}
|
||||
]
|
||||
|
||||
export const team_career = [
|
||||
{
|
||||
name: "Alpine",
|
||||
races: 392,
|
||||
points: 2000,
|
||||
hf: 1,
|
||||
podiums: 60,
|
||||
hg: 1,
|
||||
polepositions: 20,
|
||||
wc: 2
|
||||
},
|
||||
{
|
||||
name: "Aston Martin",
|
||||
races: 152,
|
||||
points: 863,
|
||||
hf: 1,
|
||||
podiums: 12,
|
||||
hg: 1,
|
||||
polepositions: 1,
|
||||
wc: 0
|
||||
},
|
||||
{
|
||||
name: "Ferrari",
|
||||
races: 1123,
|
||||
points: 10675,
|
||||
hf: 1,
|
||||
podiums: 639,
|
||||
hg: 1,
|
||||
polepositions: 254,
|
||||
wc: 16
|
||||
},
|
||||
{
|
||||
name: "Hass F1 Team",
|
||||
races: 214,
|
||||
points: 386,
|
||||
hf: 4,
|
||||
podiums: 0,
|
||||
hg: 4,
|
||||
polepositions: 1,
|
||||
wc: 0
|
||||
},
|
||||
{
|
||||
name: "Kick Sauber",
|
||||
races: 615,
|
||||
points: 1088,
|
||||
hf: 1,
|
||||
podiums: 27,
|
||||
hg: 1,
|
||||
polepositions: 1,
|
||||
wc: 0
|
||||
},
|
||||
{
|
||||
name: "McLaren",
|
||||
races: 995,
|
||||
points: 7783.5,
|
||||
hf: 1,
|
||||
podiums: 445,
|
||||
hg: 1,
|
||||
polepositions: 177,
|
||||
wc: 10
|
||||
},
|
||||
{
|
||||
name: "Mercedes",
|
||||
races: 329,
|
||||
points: 8159.5,
|
||||
hf: 1,
|
||||
podiums: 201,
|
||||
hg: 1,
|
||||
polepositions: 135,
|
||||
wc: 8
|
||||
},
|
||||
{
|
||||
name: "Racing Bulls",
|
||||
races: 399,
|
||||
points: 947,
|
||||
hf: 1,
|
||||
podiums: 6,
|
||||
hg: 1,
|
||||
polepositions: 1,
|
||||
wc: 0
|
||||
},
|
||||
{
|
||||
name: "Red Bull Racing",
|
||||
races: 418,
|
||||
points: 8288,
|
||||
hf: 1,
|
||||
podiums: 233,
|
||||
hg: 1,
|
||||
polepositions: 111,
|
||||
wc: 6
|
||||
},
|
||||
{
|
||||
name: "Williams",
|
||||
races: 851,
|
||||
points: 3768,
|
||||
hf: 1,
|
||||
podiums: 245,
|
||||
hg: 1,
|
||||
polepositions: 128,
|
||||
wc: 9
|
||||
}
|
||||
]
|
||||
|
||||
4
frontend/src/assets/style.css
Executable file
@@ -0,0 +1,4 @@
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
BIN
frontend/src/assets/teams/alp.avif
Executable file
|
After Width: | Height: | Size: 35 KiB |
BIN
frontend/src/assets/teams/alplogo.avif
Executable file
|
After Width: | Height: | Size: 709 B |
BIN
frontend/src/assets/teams/am.avif
Executable file
|
After Width: | Height: | Size: 37 KiB |
BIN
frontend/src/assets/teams/amlogo.avif
Executable file
|
After Width: | Height: | Size: 829 B |
BIN
frontend/src/assets/teams/fe.avif
Executable file
|
After Width: | Height: | Size: 41 KiB |
BIN
frontend/src/assets/teams/felogo.avif
Executable file
|
After Width: | Height: | Size: 888 B |
BIN
frontend/src/assets/teams/hass.avif
Executable file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/src/assets/teams/hasslogo.avif
Executable file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
frontend/src/assets/teams/kick.avif
Executable file
|
After Width: | Height: | Size: 28 KiB |
BIN
frontend/src/assets/teams/kicklogo.avif
Executable file
|
After Width: | Height: | Size: 886 B |
BIN
frontend/src/assets/teams/mclaren.avif
Executable file
|
After Width: | Height: | Size: 32 KiB |
BIN
frontend/src/assets/teams/mclogo.avif
Executable file
|
After Width: | Height: | Size: 775 B |
BIN
frontend/src/assets/teams/merc.avif
Executable file
|
After Width: | Height: | Size: 28 KiB |
BIN
frontend/src/assets/teams/merclogo.avif
Executable file
|
After Width: | Height: | Size: 878 B |
BIN
frontend/src/assets/teams/rb.avif
Executable file
|
After Width: | Height: | Size: 36 KiB |
BIN
frontend/src/assets/teams/rblogo.avif
Executable file
|
After Width: | Height: | Size: 752 B |
BIN
frontend/src/assets/teams/srb.avif
Executable file
|
After Width: | Height: | Size: 38 KiB |
BIN
frontend/src/assets/teams/srblogo.avif
Executable file
|
After Width: | Height: | Size: 816 B |
BIN
frontend/src/assets/teams/will.avif
Executable file
|
After Width: | Height: | Size: 39 KiB |
BIN
frontend/src/assets/teams/willogo.avif
Executable file
|
After Width: | Height: | Size: 721 B |
BIN
frontend/src/assets/ver.gif
Executable file
|
After Width: | Height: | Size: 872 KiB |
BIN
frontend/src/components/.DS_Store
vendored
Executable file
71
frontend/src/components/DriverCard.vue
Executable file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="driver-card" :style="{ background: color }">
|
||||
<img :src="image" alt="driver photo">
|
||||
<div class="driver-info">
|
||||
<h2>{{ name }}</h2>
|
||||
<p>{{ team }}</p>
|
||||
<p>{{ nation }}</p>
|
||||
<p><span style="font-weight: bolder;">#</span>{{ num }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface props {
|
||||
name: string;
|
||||
nation: string;
|
||||
team: string;
|
||||
num: number;
|
||||
image: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
defineProps<props>()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.driver-card {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
overflow: hidden;
|
||||
max-height: 200px;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.driver-card:hover {
|
||||
transform: scale(1.025);
|
||||
}
|
||||
|
||||
.driver-card img {
|
||||
height: 500px;
|
||||
margin-top: 300px;
|
||||
margin-right: 50px;
|
||||
}
|
||||
|
||||
.driver-info {
|
||||
padding-left: 100px;
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.driver-info h2 {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.driver-info p {
|
||||
font-size: 20px;
|
||||
margin-top: -20px;
|
||||
}
|
||||
|
||||
.driver-info p:nth-of-type(3) {
|
||||
margin-top: 20px;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
93
frontend/src/components/DriverResult.vue
Executable file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div class="driver-result">
|
||||
<div class="title">
|
||||
<h2>{{ driver.name }} 比赛结果</h2>
|
||||
</div>
|
||||
<div class="result">
|
||||
<div class="resulttitle line1">
|
||||
<div class="resultitem">大奖赛</div>
|
||||
<div class="resultitem">队伍</div>
|
||||
<div class="resultitem">位次</div>
|
||||
<div class="resultitem">积分</div>
|
||||
</div>
|
||||
<div class="resultrow line2" v-for="result in driverResults" :key="result.index">
|
||||
<div class="resultitem">{{ result.is_sprint ? `${result.prix_name}(sprint)` : result.prix_name }}</div>
|
||||
<div class="resultitem">{{ result.team_name }}</div>
|
||||
<div class="resultitem">{{ result.position }}</div>
|
||||
<div class="resultitem">{{ result.score }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { prixes } from "@/assets/source";
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useDriversStore } from '@/store/SeasonDrivers'
|
||||
|
||||
const driversStore = useDriversStore()
|
||||
|
||||
interface Props {
|
||||
id: number;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const driver = ref<any>({})
|
||||
const driverResults = ref<any>([])
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/drivers/${props.id}/results?season=2025`)
|
||||
const json = await res.json()
|
||||
driverResults.value = Array.isArray(json) ? json : (json?.data ?? [])
|
||||
driversStore.ensureDriversLoaded()
|
||||
driver.value = driversStore.driversList.find((d: any) => d.id === props.id) ?? {}
|
||||
} catch (e) {
|
||||
driverResults.value = undefined
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.driver-result {
|
||||
background-color: #f7f4f1;
|
||||
padding-bottom: 20px;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.title {
|
||||
padding-left: 20px;
|
||||
font-size: 25px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.result {
|
||||
width: 90%;
|
||||
background-color: #fff;
|
||||
margin: 0 auto;
|
||||
padding: 20px 0;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.resultrow, .resulttitle {
|
||||
width: 90%;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.resulttitle {
|
||||
color: #606066;
|
||||
}
|
||||
|
||||
.resultitem {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.line1 {
|
||||
border-bottom: 2px solid #ccc;
|
||||
}
|
||||
|
||||
.line2 {
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
</style>
|
||||
89
frontend/src/components/QualifyingResult.vue
Executable file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div class="qualifying-result">
|
||||
<div class="result">
|
||||
<div class="resulttitle line1">
|
||||
<div class="resultitem">排名</div>
|
||||
<div class="resultitem">车号</div>
|
||||
<div class="resultitem">车手</div>
|
||||
<div class="resultitem">车队</div>
|
||||
<div class="resultitem">Q1</div>
|
||||
<div class="resultitem">Q2</div>
|
||||
<div class="resultitem">Q3</div>
|
||||
</div>
|
||||
<div class="resultrow line2" v-for="data in driverdatas" :key="data.driver_id">
|
||||
<div class="resultitem">{{ data.position }}</div>
|
||||
<div class="resultitem">{{ data.car_num }}</div>
|
||||
<div class="resultitem">{{ data.name }}</div>
|
||||
<div class="resultitem">{{ data.team }}</div>
|
||||
<div class="resultitem">{{ data.q1 }}</div>
|
||||
<div class="resultitem">{{ data.q2 }}</div>
|
||||
<div class="resultitem">{{ data.q3 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
interface Props {
|
||||
id: number
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const route = useRoute()
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
const driverdatas = ref<any[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/prix/${route.params.id}/qualifying?isSprint=${route.params.type!=='qualifying'}`)
|
||||
const json = await res.json()
|
||||
driverdatas.value = Array.isArray(json) ? json : (json?.data ?? [])
|
||||
} catch (e) {
|
||||
error.value = '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.qualifying-result {
|
||||
padding-bottom: 20px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.result {
|
||||
width: 90%;
|
||||
background-color: #fff;
|
||||
margin: 0 auto;
|
||||
padding: 20px 0;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.resultrow,
|
||||
.resulttitle {
|
||||
width: 90%;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.resulttitle {
|
||||
color: #606066;
|
||||
}
|
||||
|
||||
.resultitem {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.line1 {
|
||||
border-bottom: 2px solid #ccc;
|
||||
}
|
||||
|
||||
.line2 {
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
</style>
|
||||
85
frontend/src/components/RaceResult.vue
Executable file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="race-result">
|
||||
<div class="result">
|
||||
<div class="resulttitle line1">
|
||||
<div class="resultitem">Pos</div>
|
||||
<div class="resultitem">Num</div>
|
||||
<div class="resultitem">Driver</div>
|
||||
<div class="resultitem">Team</div>
|
||||
<div class="resultitem">Time</div>
|
||||
<div class="resultitem">Pts</div>
|
||||
</div>
|
||||
<div class="resultrow line2" v-for="data in driverdatas" :key="data.driver_id">
|
||||
<div class="resultitem">{{ data.pos }}</div>
|
||||
<div class="resultitem">{{ data.car_num }}</div>
|
||||
<div class="resultitem">{{ data.name }}</div>
|
||||
<div class="resultitem">{{ data.team }}</div>
|
||||
<div class="resultitem">{{ data.finish_time }}</div>
|
||||
<div class="resultitem">{{ data.score }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
interface Props {
|
||||
id: number
|
||||
}
|
||||
const driverdatas = ref<any[]>([])
|
||||
const error = ref('')
|
||||
const loading = ref(true)
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/prix/${route.params.id}/race?isSprint=${route.params.type !== 'race'}`)
|
||||
const json = await res.json()
|
||||
driverdatas.value = Array.isArray(json) ? json : (json?.data ?? [])
|
||||
} catch (e) {
|
||||
error.value = '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.race-result {
|
||||
padding-bottom: 20px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.result {
|
||||
width: 90%;
|
||||
background-color: #fff;
|
||||
margin: 0 auto;
|
||||
padding: 20px 0;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.resultrow,
|
||||
.resulttitle {
|
||||
width: 90%;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.resulttitle {
|
||||
color: #606066;
|
||||
}
|
||||
|
||||
.resultitem {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.line1 {
|
||||
border-bottom: 2px solid #ccc;
|
||||
}
|
||||
|
||||
.line2 {
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
</style>
|
||||
74
frontend/src/components/TeamCard.vue
Executable file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="team-card" :style="{ background: color }">
|
||||
<div class="team-info">
|
||||
<h2>{{ name }}</h2>
|
||||
<p>{{ nation }}</p>
|
||||
<p><span>{{ driver1 }}</span> & <span>{{ driver2 }}</span></p>
|
||||
</div>
|
||||
<img :src="image" alt="" class="car">
|
||||
<img :src="logo" alt="" class="logo">
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
interface props {
|
||||
name: string;
|
||||
nation: string;
|
||||
image: string;
|
||||
color: string;
|
||||
driver1: string;
|
||||
driver2: string;
|
||||
logo: string;
|
||||
}
|
||||
|
||||
defineProps<props>()
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.team-card {
|
||||
width: 80vw;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 10px;
|
||||
margin: 0 auto 20px auto;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.7);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.team-card:hover {
|
||||
transform: scale(1.025);
|
||||
}
|
||||
|
||||
.car {
|
||||
display: block;
|
||||
width: 40vw;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.team-info {
|
||||
margin-left: 100px;
|
||||
}
|
||||
|
||||
.team-info h2 {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.team-info p {
|
||||
margin-top: -20px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.team-info p:nth-of-type(2), .team-info p:nth-of-type(3) {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 50px;
|
||||
padding: 6px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0px 0px 0px 1px #ccc;
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
right: 100px;
|
||||
background-color: rgba(51, 51, 51, 0.5);
|
||||
}
|
||||
|
||||
</style>
|
||||
89
frontend/src/components/TeamResult.vue
Executable file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div class="team-result">
|
||||
<div class="title">
|
||||
<h2>{{ team.name }} 比赛结果</h2>
|
||||
</div>
|
||||
<div class="result">
|
||||
<div class="resulttitle line1">
|
||||
<div class="resultitem">大奖赛</div>
|
||||
<div class="resultitem">积分</div>
|
||||
</div>
|
||||
<div class="resultrow line2" v-for="prix in teamtt" :key="prix.id">
|
||||
<div class="resultitem">{{ prix.prix_name }}</div>
|
||||
<div class="resultitem">{{ prix.total_score }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
interface props {
|
||||
id: number;
|
||||
}
|
||||
|
||||
const props = defineProps<props>()
|
||||
|
||||
let team = ref<any>({})
|
||||
const teamtt = ref<any>([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/teams')
|
||||
const json = await res.json()
|
||||
const list = Array.isArray(json) ? json : (json?.data ?? [])
|
||||
team.value = list.find((d: any) => d.id === props.id) ?? {} // TODO:后续获取team的比赛信息而不是单纯的teams
|
||||
const ttres = await fetch(`/api/teams/${props.id}/results?season=2025`)
|
||||
const ttjson = await ttres.json()
|
||||
teamtt.value = Array.isArray(ttjson) ? ttjson : (ttjson?.data ?? [])
|
||||
} catch (e) {
|
||||
team.value = undefined
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.team-result {
|
||||
background-color: #f7f4f1;
|
||||
padding-bottom: 20px;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.title {
|
||||
padding: 8px;
|
||||
padding-left: 20px;
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
.result {
|
||||
width: 90%;
|
||||
background-color: #fff;
|
||||
margin: 0 auto;
|
||||
padding: 20px 0;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.resultrow, .resulttitle {
|
||||
width: 90%;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.resulttitle {
|
||||
color: #606066;
|
||||
}
|
||||
|
||||
.resultitem {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.line1 {
|
||||
border-bottom: 2px solid #ccc;
|
||||
}
|
||||
|
||||
.line2 {
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
</style>
|
||||
23
frontend/src/main.ts
Executable file
@@ -0,0 +1,23 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import '@/assets/icon/font/iconfont.js'
|
||||
import './assets/style.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
const app = createApp(App)
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
app.use(ElementPlus)
|
||||
app.use(router)
|
||||
app.use(pinia)
|
||||
|
||||
|
||||
app.mount('#app')
|
||||
106
frontend/src/router/index.ts
Executable file
@@ -0,0 +1,106 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Home from '@/views/Home.vue'
|
||||
import Seasons from '@/views/Seasons.vue'
|
||||
import Teams from '@/views/Teams.vue'
|
||||
import Drivers from '@/views/Drivers.vue'
|
||||
import SeasonDetail from '@/views/SeasonDetail.vue'
|
||||
import DriverDetail from '@/views/DriverDetail.vue'
|
||||
import TeamDetail from '@/views/TeamDetail.vue'
|
||||
import Result from '@/views/Result.vue'
|
||||
import RacePage from '@/views/RacePage.vue'
|
||||
import RaceResult from '@/views/RaceResult.vue'
|
||||
import Login from '@/views/Login.vue'
|
||||
import Register from '@/views/Register.vue'
|
||||
import CommentDetail from '@/views/CommentDetail.vue'
|
||||
import { isAuthenticated } from '@/utils/auth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: Home,
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: Login,
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: Register,
|
||||
},
|
||||
{
|
||||
path: '/seasons',
|
||||
name: 'seasons',
|
||||
component: Seasons,
|
||||
},
|
||||
{
|
||||
path: '/seasons/:year',
|
||||
name: 'season-detail',
|
||||
component: SeasonDetail,
|
||||
},
|
||||
{
|
||||
path: '/seasons/:year/races/:id',
|
||||
name: 'race-page',
|
||||
component: RacePage,
|
||||
},
|
||||
{
|
||||
path: '/seasons/:year/races/:id/:type',
|
||||
name: 'race-result',
|
||||
component: RaceResult,
|
||||
},
|
||||
{
|
||||
path: '/teams',
|
||||
name: 'teams',
|
||||
component: Teams,
|
||||
},
|
||||
{
|
||||
path: '/teams/:id',
|
||||
name: 'team-detail',
|
||||
component: TeamDetail,
|
||||
},
|
||||
{
|
||||
path: '/drivers',
|
||||
name: 'drivers',
|
||||
component: Drivers,
|
||||
},
|
||||
{
|
||||
path: '/drivers/:id',
|
||||
name: 'driver-detail',
|
||||
component: DriverDetail,
|
||||
},
|
||||
{
|
||||
path: '/drivers/:id/results',
|
||||
name: 'driver-result',
|
||||
component: Result,
|
||||
},
|
||||
{
|
||||
path: '/teams/:id/results',
|
||||
name: 'team-result',
|
||||
component: Result,
|
||||
}
|
||||
,
|
||||
{
|
||||
path: '/comments/:id',
|
||||
name: 'comment-detail',
|
||||
component: CommentDetail,
|
||||
}
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
if (to.name === 'login' || to.name === 'register') {
|
||||
next()
|
||||
return
|
||||
}
|
||||
if (!isAuthenticated()) {
|
||||
next({ name: 'login' })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
46
frontend/src/store/SeasonDrivers.ts
Executable file
@@ -0,0 +1,46 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useDriversStore = defineStore('drivers', () => {
|
||||
const driversList = ref<any[]>([])
|
||||
const hasFetched = ref(false)
|
||||
const isLoading = ref(false)
|
||||
|
||||
const fetchDrivers = async (forceRefresh = false) => {
|
||||
if (hasFetched.value && !forceRefresh) {
|
||||
return driversList.value
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const response = await fetch('/api/season-drivers?season=2025')
|
||||
const data = await response.json()
|
||||
|
||||
driversList.value = Array.isArray(data) ? data : (data?.data ?? [])
|
||||
|
||||
hasFetched.value = true // 标记为已获取
|
||||
return driversList.value
|
||||
} catch (error) {
|
||||
console.error('获取drivers失败:', error)
|
||||
throw error
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const ensureDriversLoaded = async () => {
|
||||
if (!hasFetched.value) {
|
||||
await fetchDrivers()
|
||||
}
|
||||
}
|
||||
|
||||
fetchDrivers(true).catch(() => {})
|
||||
|
||||
return {
|
||||
driversList,
|
||||
isLoading,
|
||||
hasFetched,
|
||||
fetchDrivers,
|
||||
ensureDriversLoaded
|
||||
}
|
||||
})
|
||||
54
frontend/src/store/UserInfo.ts
Executable file
@@ -0,0 +1,54 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const userInfoStore = defineStore('userInfo', () => {
|
||||
const userInfo = ref<any>({})
|
||||
const hasFetched = ref(false)
|
||||
const isLoading = ref(false)
|
||||
|
||||
const fetchUserInfo = async (forceRefresh = false) => {
|
||||
if (hasFetched.value && !forceRefresh) {
|
||||
return userInfo.value
|
||||
}
|
||||
isLoading.value = true
|
||||
try {
|
||||
const response = await fetch('/api/user')
|
||||
const json = await response.json()
|
||||
if (json?.data) {
|
||||
userInfo.value = json.data
|
||||
} else {
|
||||
userInfo.value = json
|
||||
}
|
||||
hasFetched.value = true // 标记为已获取
|
||||
return userInfo.value
|
||||
} catch (error) {
|
||||
console.error('获取userInfo失败:', error)
|
||||
throw error
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const ensureUserInfoLoaded = async () => {
|
||||
if (!hasFetched.value) {
|
||||
await fetchUserInfo()
|
||||
}
|
||||
}
|
||||
|
||||
const clearUserInfo = () => {
|
||||
userInfo.value = {}
|
||||
hasFetched.value = false
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
fetchUserInfo(true).catch(() => {})
|
||||
|
||||
return {
|
||||
userInfo,
|
||||
isLoading,
|
||||
hasFetched,
|
||||
fetchUserInfo,
|
||||
ensureUserInfoLoaded,
|
||||
clearUserInfo
|
||||
}
|
||||
})
|
||||
11
frontend/src/utils/auth.ts
Executable file
@@ -0,0 +1,11 @@
|
||||
export const TOKEN_KEY = 'auth_token'
|
||||
export const SESSION_KEY = 'auth_session'
|
||||
|
||||
export const getToken = () => localStorage.getItem(TOKEN_KEY) || ''
|
||||
export const setToken = (token: string) => localStorage.setItem(TOKEN_KEY, token)
|
||||
export const clearToken = () => localStorage.removeItem(TOKEN_KEY)
|
||||
|
||||
export const setSessionAuth = (v: boolean) => localStorage.setItem(SESSION_KEY, v ? '1' : '')
|
||||
export const hasSessionAuth = () => localStorage.getItem(SESSION_KEY) === '1'
|
||||
|
||||
export const isAuthenticated = () => !!getToken() || hasSessionAuth()
|
||||
BIN
frontend/src/views/.DS_Store
vendored
Executable file
249
frontend/src/views/CommentDetail.vue
Executable file
@@ -0,0 +1,249 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { fetchCommentThread, postReply, type CommentItem } from '@/api/comments'
|
||||
import { isAuthenticated } from '@/utils/auth'
|
||||
import { userInfoStore } from '@/store/UserInfo'
|
||||
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userInfo = userInfoStore()
|
||||
const user_id = ref(-1)
|
||||
|
||||
const id = Number(route.params.id)
|
||||
|
||||
const root = ref<any>({})
|
||||
const replies = ref<any>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await fetchCommentThread(id)
|
||||
replies.value = Array.isArray(data['replies']) ? data['replies'] : []
|
||||
console.log(replies.value)
|
||||
root.value = data['parent']
|
||||
await userInfo.ensureUserInfoLoaded()
|
||||
user_id.value = userInfo.userInfo.id
|
||||
} catch (e: any) {
|
||||
// 占位数据,便于前后端未对接时验证界面
|
||||
root.value = { id, user: 'Alex', time: '刚刚', content: '这是原始评论' }
|
||||
replies.value = [
|
||||
{ id: id * 10 + 1, user: 'Mia', time: '1 分钟前', content: '同意你的观点', parentId: id, toUser: 'Alex' },
|
||||
{ id: id * 10 + 2, user: 'Ken', time: '2 分钟前', content: '我有不同看法', parentId: id, toUser: 'Mia' }
|
||||
]
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
const replyText = ref('')
|
||||
const replyPosting = ref(false)
|
||||
const replyTo = ref<any | null>(null)
|
||||
|
||||
const doReply = (c: any) => {
|
||||
if (!isAuthenticated()) {
|
||||
ElMessage.warning('请先登录后再回复')
|
||||
return
|
||||
}
|
||||
replyTo.value = c
|
||||
}
|
||||
|
||||
const submitReply = async () => {
|
||||
if (!replyTo.value || !replyText.value.trim()) {
|
||||
ElMessage.warning('请输入回复内容')
|
||||
return
|
||||
}
|
||||
replyPosting.value = true
|
||||
try {
|
||||
await postReply(Number(route.params.id), replyText.value.trim(), user_id.value, replyTo.value.id)
|
||||
ElMessage.success('回复成功')
|
||||
const data = await fetchCommentThread(id)
|
||||
replies.value = Array.isArray(data['replies']) ? data['replies'] : []
|
||||
replyText.value = ''
|
||||
replyTo.value = null
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '回复失败')
|
||||
} finally {
|
||||
replyPosting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getReplyContent = (reply_id: number) => {
|
||||
const r = replies.value.find((item: any) => item.id === reply_id)
|
||||
return r?.content || root.value.content
|
||||
}
|
||||
|
||||
const deleteRootCom = async (id: number) => {
|
||||
if (!isAuthenticated()) {
|
||||
ElMessage.warning('请先登录后再删除评论')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该评论?删除后该页面将不存在', '确认删除', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await fetch(`/api/comments/${id}`, { method: 'DELETE', credentials: 'include' })
|
||||
ElMessage.success('删除成功')
|
||||
router.push('/')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteRepCom = async (id: number) => {
|
||||
if (!isAuthenticated()) {
|
||||
ElMessage.warning('请先登录后再删除评论')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该评论?删除后相关评论将同时删除', '确认删除', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await fetch(`/api/comments/${id}`, { method: 'DELETE', credentials: 'include' })
|
||||
ElMessage.success('删除成功')
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="comment-detail" v-loading="loading">
|
||||
<el-card class="root-card">
|
||||
<div class="comment-header">
|
||||
<div class="user-basinfo">
|
||||
<el-avatar :size="32">{{ root?.username?.[0] || '?' }}</el-avatar>
|
||||
<div class="comment-user">{{ root?.username }}</div>
|
||||
</div>
|
||||
<el-button v-if="root.user_id === user_id" type="danger" class="delete" @click="deleteRootCom(root.id)"><el-icon>
|
||||
<Delete />
|
||||
</el-icon></el-button>
|
||||
</div>
|
||||
<div class="comment-content">{{ root?.content }}</div>
|
||||
<div class="reply-actions">
|
||||
<el-button size="small" @click="doReply(root)">回复</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<h3 class="reply-title" style="color:white;">全部回复</h3>
|
||||
<div class="replies">
|
||||
<el-card v-for="r in replies" :key="r.id" class="reply-card" shadow="never">
|
||||
<div class="reply-meta">{{ r.username }} 回复 {{ r.reply_to_username }} : {{
|
||||
getReplyContent(r.response_id).slice(0, 5) + (getReplyContent(r.response_id).length > 5 ? '...' : '') }}</div>
|
||||
<div class="comment-header">
|
||||
<div class="user-basinfo">
|
||||
<el-avatar :size="28">{{ r.username?.[0] || '?' }}</el-avatar>
|
||||
<div class="comment-user">{{ r.username }}</div>
|
||||
</div>
|
||||
<el-button v-if="r.user_id === user_id" type="danger" class="delete" @click="deleteRepCom(r.id)"><el-icon>
|
||||
<Delete />
|
||||
</el-icon></el-button>
|
||||
</div>
|
||||
<div class="comment-content">{{ r.content }}</div>
|
||||
<div class="reply-actions">
|
||||
<el-button size="small" @click="doReply(r)">回复</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div class="reply-compose">
|
||||
<el-input v-model="replyText" type="textarea" :rows="3"
|
||||
:placeholder="replyTo ? '回复 ' + (replyTo.username || '') : '选择要回复的评论后输入...'" />
|
||||
<div class="compose-actions">
|
||||
<el-button type="primary" :loading="replyPosting" @click="submitReply">发表回复</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.comment-detail {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.root-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.comment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user-basinfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.comment-user {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.comment-time {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.comment-content {
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.reply-title {
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
|
||||
.replies {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.reply-card {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.reply-meta {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.reply-actions {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.reply-compose {
|
||||
margin-top: 16px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.compose-actions {
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
341
frontend/src/views/DriverDetail.vue
Executable file
@@ -0,0 +1,341 @@
|
||||
<template>
|
||||
<div class="driver-detail" v-if="driver">
|
||||
<div class="header" :style="{ background: getColor(driver.team) }">
|
||||
<svg class="vertical-bar1" viewBox="0 0 1000 500" preserveAspectRatio="none">
|
||||
<rect x="0" y="0" width="15" height="100" fill="white" opacity="1" />
|
||||
<rect x="18" y="0" width="15" height="100" fill="white" opacity="1" />
|
||||
</svg>
|
||||
<svg class="vertical-bar2" viewBox="0 0 1000 500" preserveAspectRatio="none">
|
||||
<rect x="0" y="0" width="15" height="100" fill="white" opacity="1" />
|
||||
<rect x="18" y="0" width="15" height="100" fill="white" opacity="1" />
|
||||
</svg>
|
||||
<img :src="getImage(driver.name)" alt="driver" class="photo">
|
||||
<div class="info">
|
||||
<h1>{{ driver.name }}</h1>
|
||||
<div class="meta">{{ driver.team }} · {{ driver.nation }} · #{{ driver.carNum }}</div>
|
||||
<div class="stats">
|
||||
<div class="stat"><span>生涯胜场</span><b>{{ dc?.wins }}</b></div>
|
||||
<div class="stat"><span>登上领奖台</span><b>{{ dc?.podiums }}</b></div>
|
||||
<div class="stat"><span>分站参赛</span><b>{{ dc?.races }}</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bio">
|
||||
<div class="intro">
|
||||
<h2>简介<br></br>2025赛季</h2>
|
||||
<div class="content">
|
||||
<div class="content-row line">
|
||||
<div class="content-col">
|
||||
<span class="label">姓名</span><br>
|
||||
<span class="value">{{ driver.name }}</span>
|
||||
</div>
|
||||
<div class="content-col">
|
||||
<span class="label">国籍</span><br>
|
||||
<span class="value">{{ driver.country }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-row">
|
||||
<div class="content-col">
|
||||
<span class="label">正赛次数</span><br>
|
||||
<span class="value">{{ driver_statistics.formal.totalCnt }}</span>
|
||||
</div>
|
||||
<div class="content-col">
|
||||
<span class="label">正赛积分</span><br>
|
||||
<span class="value">{{ driver_statistics.formal.scoreSum }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-row">
|
||||
<div class="content-col">
|
||||
<span class="label">正赛胜场</span><br>
|
||||
<span class="value">{{ driver_statistics.formal.gold }}</span>
|
||||
</div>
|
||||
<div class="content-col">
|
||||
<span class="label">正赛领奖台</span><br>
|
||||
<span class="value">{{ driver_statistics.formal.medal }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-row">
|
||||
<div class="content-col">
|
||||
<span class="label">正赛杆位</span><br>
|
||||
<span class="value">{{ driver_statistics.formal.pole }}</span>
|
||||
</div>
|
||||
<div class="content-col">
|
||||
<span class="label">正赛前十</span><br>
|
||||
<span class="value">{{ driver_statistics.formal.topTen }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-row line">
|
||||
<div class="content-col">
|
||||
<span class="label">最快单圈</span><br>
|
||||
<span class="value">{{ driver_statistics.formal.fastestLap }}</span>
|
||||
</div>
|
||||
<div class="content-col">
|
||||
<span class="label">未完赛</span><br>
|
||||
<span class="value">{{ driver_statistics.formal.unfinished }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-row">
|
||||
<div class="content-col">
|
||||
<span class="label">冲刺赛</span><br>
|
||||
<span class="value">{{ driver_statistics.sprint.totalCnt }}</span>
|
||||
</div>
|
||||
<div class="content-col">
|
||||
<span class="label">冲刺赛积分</span><br>
|
||||
<span class="value">{{ driver_statistics.sprint.scoreSum }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-row">
|
||||
<div class="content-col">
|
||||
<span class="label">冲刺赛胜场</span><br>
|
||||
<span class="value">{{ driver_statistics.sprint.gold }}</span>
|
||||
</div>
|
||||
<div class="content-col">
|
||||
<span class="label">冲刺赛领奖台</span><br>
|
||||
<span class="value">{{ driver_statistics.sprint.medal }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-row line">
|
||||
<div class="content-col">
|
||||
<span class="label">冲刺赛杆位</span><br>
|
||||
<span class="value">{{ driver_statistics.sprint.pole }}</span>
|
||||
</div>
|
||||
<div class="content-col">
|
||||
<span class="label">冲刺赛前十</span><br>
|
||||
<span class="value">{{ driver_statistics.sprint.topTen }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toresult">
|
||||
<el-button type="primary" @click="goResult">详细比赛结果</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="statics">
|
||||
<h2>生涯数据</h2>
|
||||
<div class="content">
|
||||
<div class="statics-row line">
|
||||
<span class="item">参加比赛场次</span>
|
||||
<span class="itemvalue">{{ dc?.races }}</span>
|
||||
</div>
|
||||
<div class="statics-row line">
|
||||
<span class="item">生涯得分</span>
|
||||
<span class="itemvalue">{{ dc?.points }}</span>
|
||||
</div>
|
||||
<div class="statics-row line">
|
||||
<span class="item">最佳完赛成绩</span>
|
||||
<span class="itemvalue">{{ dc?.hf }}</span>
|
||||
</div>
|
||||
<div class="statics-row line">
|
||||
<span class="item">领奖台数</span>
|
||||
<span class="itemvalue">{{ dc?.podiums }}</span>
|
||||
</div>
|
||||
<div class="statics-row line">
|
||||
<span class="item">最佳发车位次</span>
|
||||
<span class="itemvalue">{{ dc?.hg }}</span>
|
||||
</div>
|
||||
<div class="statics-row line">
|
||||
<span class="item">杆位数</span>
|
||||
<span class="itemvalue">{{ dc?.polepositions }}</span>
|
||||
</div>
|
||||
<div class="statics-row line">
|
||||
<span class="item">世界冠军数</span>
|
||||
<span class="itemvalue">{{ dc?.wc }}</span>
|
||||
</div>
|
||||
<div class="statics-row">
|
||||
<span class="item">未完赛数</span>
|
||||
<span class="itemvalue">{{ dc?.dnfs }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button @click="goBack">返回</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useDriversStore } from '@/store/SeasonDrivers'
|
||||
import { getColor, getImage , driver_career } from '@/assets/source'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const driversStore = useDriversStore()
|
||||
|
||||
const driver = ref<any>()
|
||||
const dc = computed(() => driver_career.find((d: any) => d.name === driver.value.name))
|
||||
const driver_statistics = ref<any>()
|
||||
|
||||
onMounted(async () => {
|
||||
const id = Number(route.params.id)
|
||||
try {
|
||||
driversStore.ensureDriversLoaded()
|
||||
driver.value = driversStore.driversList.find((d: any) => (d.id) === id)
|
||||
const sres = await fetch(`/api/drivers/${id}/statistics?season=2025`)
|
||||
driver_statistics.value = await sres.json()
|
||||
} catch (e) {
|
||||
driver.value = undefined
|
||||
}
|
||||
})
|
||||
|
||||
const goResult = () => {
|
||||
const d = driver.value
|
||||
if (!d) return
|
||||
router.push(`/drivers/${d.id}/results`)
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/drivers')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.driver-detail {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
max-height: 500px;
|
||||
overflow: hidden;
|
||||
border-radius: 20px 20px 0 0;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.7);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.photo {
|
||||
height: 1250px;
|
||||
margin-top: 750px;
|
||||
}
|
||||
|
||||
.info h1 {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.vertical-bar1 {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 250px;
|
||||
}
|
||||
|
||||
.vertical-bar2 {
|
||||
position: absolute;
|
||||
top: 400px;
|
||||
left: 250px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.stat span {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.stat b {
|
||||
display: block;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.bio {
|
||||
background-color: #1c1c25;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-evenly;
|
||||
padding-bottom: 30px;
|
||||
border-radius: 0 0 20px 20px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
.statics {
|
||||
width: 45%;
|
||||
border-radius: 20px;
|
||||
background-color: #303037;
|
||||
margin-top: 150px;
|
||||
}
|
||||
|
||||
.intro h2,
|
||||
.statics h2 {
|
||||
font-size: 40px;
|
||||
margin-top: 0;
|
||||
padding-top: 40px;
|
||||
}
|
||||
|
||||
.intro h2 {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.statics h2 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.content-row {
|
||||
display: flex;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.content-col {
|
||||
width: 50%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
color: #aaaaaa;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-weight: bold;
|
||||
font-size: 25px;
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.line {
|
||||
border-bottom: 2px solid gray;
|
||||
}
|
||||
|
||||
.statics .content {
|
||||
width: 90%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.statics-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.item {
|
||||
font-size: 14px;
|
||||
color: #aaaaaa;
|
||||
}
|
||||
|
||||
.itemvalue {
|
||||
font-weight: bold;
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
78
frontend/src/views/Drivers.vue
Executable file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="intro">
|
||||
<h1 style="color:white;margin-left: 20px;">F1 Drivers</h1>
|
||||
<p style="color:white;margin-left: 20px;">Find the Formula 1 drivers for the 2025 season</p>
|
||||
</div>
|
||||
<el-skeleton v-if="loading" :rows="4" animated />
|
||||
<el-empty v-else-if="error || !drivers.length" description="暂无数据" />
|
||||
<div v-else class="showcase">
|
||||
<template v-for="driver in drivers" :key="driver.id">
|
||||
<div class="card-wrapper" @click="goDriver(driver.id)">
|
||||
<DriverCard :name="driver.name" :nation="driver.nation" :num="driver.num" :image="getImage(driver.name)"
|
||||
:color="getColor(driver.team)" :team="driver.team" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import DriverCard from '@/components/DriverCard.vue'
|
||||
import { getColor, getImage } from '@/assets/source'
|
||||
import { useDriversStore } from '@/store/SeasonDrivers'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const drivers = ref<any[]>([])
|
||||
const driversStore = useDriversStore()
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
driversStore.ensureDriversLoaded()
|
||||
drivers.value = driversStore.driversList.map((d: any) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
team: d.team,
|
||||
nation: d.country,
|
||||
num: d.carNum,
|
||||
birth: d.birthday
|
||||
}))
|
||||
} catch (e) {
|
||||
error.value = '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const goDriver = (id: number) => {
|
||||
router.push(`/drivers/${id}`)
|
||||
}
|
||||
|
||||
/* import { drivers } from '@/assets/source' */
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.intro h1 {
|
||||
font-size: 40px;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.intro p {
|
||||
font-size: 20px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.showcase {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
column-gap: 20px;
|
||||
width: 90%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card-wrapper:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
348
frontend/src/views/Home.vue
Executable file
@@ -0,0 +1,348 @@
|
||||
<template>
|
||||
<div class="home">
|
||||
<div class="intro">
|
||||
<h1 style="color:white;">Formula 1</h1>
|
||||
<p style="color:white;">赛事与社区评论</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="section">
|
||||
<h2 style="color:white;">已完成赛事</h2>
|
||||
<div class="grid">
|
||||
<el-card v-for="race in upcomingRaces" :key="race.id" class="race-card" shadow="hover">
|
||||
<div class="race-header">
|
||||
<span class="race-round">Round {{ race.round }}</span>
|
||||
<span class="race-date">{{ race.date }}</span>
|
||||
</div>
|
||||
<div class="race-title">{{ race.name }}</div>
|
||||
<div class="race-meta">赛道:{{ race.circuit }} · 城市:{{ race.city }}</div>
|
||||
<div class="race-actions">
|
||||
<el-button type="primary" size="small" @click="goDetail(race.season, race.id)">查看详情</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2 style="color:white;">最新评论</h2>
|
||||
<div class="comments">
|
||||
<el-card v-for="c in pageComments[page - 1]" :key="c.id" class="comment-card" shadow="never">
|
||||
<div class="comment-header">
|
||||
<div class="user-info">
|
||||
<el-avatar :size="32">{{ c.username?.[0] || '?' }}</el-avatar>
|
||||
<div class="comment-user">{{ c.username }}</div>
|
||||
</div>
|
||||
<!-- <div class="comment-time">{{ c.time }}</div> -->
|
||||
<el-button v-if="c.user_id === user_id" type="danger" class="delete" @click="deleteComment(c.id)"><el-icon>
|
||||
<Delete />
|
||||
</el-icon></el-button>
|
||||
</div>
|
||||
<div class="comment-content">{{ c.content }}</div>
|
||||
<div class="comment-actions">
|
||||
<el-button size="small" @click="replyComment(c)">回复</el-button>
|
||||
<el-button size="small" type="primary" @click="viewDetail(c)">查看详情</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
<div class="comment-pagination">
|
||||
<el-button :disabled="page === 1" @click="prevPage">上一页</el-button>
|
||||
<span class="page-info">第 {{ page }} / {{ totalPages }} 页</span>
|
||||
<el-button :disabled="page >= totalPages" @click="nextPage">下一页</el-button>
|
||||
</div>
|
||||
<div class="comment-compose">
|
||||
<el-input v-model="compose" type="textarea" :rows="3" placeholder="发表你的评论..." />
|
||||
<div class="compose-actions">
|
||||
<el-button type="primary" :loading="posting" @click="postNewComment">发表</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-dialog v-model="replyVisible" title="回复评论" width="500px">
|
||||
<div class="reply-target">回复:{{ replyTarget?.content }}</div>
|
||||
<el-input v-model="replyText" type="textarea" :rows="3" placeholder="输入你的回复..." />
|
||||
<template #footer>
|
||||
<el-button @click="replyVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="replyPosting" @click="submitReply">发表回复</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { fetchLatestComments, postComment, postReply, type CommentItem } from '@/api/comments'
|
||||
import { isAuthenticated } from '@/utils/auth'
|
||||
import { userInfoStore } from '@/store/UserInfo'
|
||||
|
||||
const upcomingRaces = ref([
|
||||
{ id: 1, round: 1, name: '澳大利亚大奖赛', circuit: 'Albert Park Circuit', city: 'Melbourne', date: '2025-03-09', season: 2025, has_sprint: false },
|
||||
{ id: 2, round: 2, name: '中国大奖赛', circuit: 'Shanghai International Circuit', city: 'Shanghai', date: '2025-04-20', season: 2025, has_sprint: true },
|
||||
{ id: 3, round: 3, name: '日本大奖赛', circuit: 'Suzuka International Racing Course', city: 'Mie Prefecture', date: '2025-04-06', season: 2025, has_sprint: false },
|
||||
{ id: 4, round: 4, name: '巴林大奖赛', circuit: 'Bahrain International Circuit', city: 'Sakhir', date: '2025-03-23', season: 2025, has_sprint: false },
|
||||
])
|
||||
|
||||
const userInfo = userInfoStore()
|
||||
const user_id = ref(0)
|
||||
|
||||
|
||||
const allComments = ref<any>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(5)
|
||||
const total = ref(0)
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(Math.min(100, total.value) / pageSize.value)))
|
||||
const pageComments = computed(() => allComments.value)
|
||||
|
||||
const loadComments = async () => {
|
||||
try {
|
||||
const data = await fetch(`/api/comments?limit=100&offset=0&pageSize=${pageSize.value}`)
|
||||
const json = await data.json()
|
||||
allComments.value = Array.isArray(json) ? json : (json?.data ?? [])
|
||||
|
||||
total.value = 0
|
||||
for (const c of allComments.value) {
|
||||
total.value += c.length
|
||||
}
|
||||
await userInfo.ensureUserInfoLoaded()
|
||||
console.log(userInfo.userInfo)
|
||||
user_id.value = userInfo.userInfo.id
|
||||
console.log(user_id.value)
|
||||
} catch (e: any) {
|
||||
console.log(e)
|
||||
//后端未就绪可使用占位数据
|
||||
allComments.value = [
|
||||
{ id: 1, username: 'Alex', content: '期待新赛季的轮胎策略变化。', user_id: 1 },
|
||||
{ id: 2, username: 'Mia', content: '澳洲站的升级包会是关键。', user_id: 2 },
|
||||
{ id: 3, username: 'Ken', content: '维斯塔潘和诺里斯的较量值得期待。', user_id: 3 }
|
||||
]
|
||||
total.value = allComments.value.length
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadComments)
|
||||
|
||||
const prevPage = async () => {
|
||||
if (page.value === 1) return
|
||||
page.value -= 1
|
||||
await loadComments()
|
||||
}
|
||||
const nextPage = async () => {
|
||||
if (page.value >= totalPages.value) return
|
||||
page.value += 1
|
||||
await loadComments()
|
||||
}
|
||||
|
||||
const compose = ref('')
|
||||
const posting = ref(false)
|
||||
const postNewComment = async () => {
|
||||
if (!isAuthenticated()) {
|
||||
ElMessage.warning('请先登录后再发表评论')
|
||||
return
|
||||
}
|
||||
if (!compose.value.trim()) {
|
||||
ElMessage.warning('请输入评论内容')
|
||||
return
|
||||
}
|
||||
posting.value = true
|
||||
try {
|
||||
await postComment(user_id.value, compose.value.trim())
|
||||
ElMessage.success('发表成功')
|
||||
compose.value = ''
|
||||
await loadComments()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '发表失败')
|
||||
} finally {
|
||||
posting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const replyVisible = ref(false)
|
||||
const replyTarget = ref<CommentItem | null>(null)
|
||||
const replyText = ref('')
|
||||
const replyPosting = ref(false)
|
||||
const replyComment = (c: CommentItem) => {
|
||||
if (!isAuthenticated()) {
|
||||
ElMessage.warning('请先登录后再回复')
|
||||
return
|
||||
}
|
||||
replyTarget.value = c
|
||||
replyText.value = ''
|
||||
replyVisible.value = true
|
||||
}
|
||||
const submitReply = async () => {
|
||||
if (!replyTarget.value) return
|
||||
if (!replyText.value.trim()) {
|
||||
ElMessage.warning('请输入回复内容')
|
||||
return
|
||||
}
|
||||
replyPosting.value = true
|
||||
try {
|
||||
await postReply(replyTarget.value.id, replyText.value.trim(), user_id.value)
|
||||
ElMessage.success('回复已发表')
|
||||
replyVisible.value = false
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '回复失败')
|
||||
} finally {
|
||||
replyPosting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteComment = async (id: number) => {
|
||||
if (!isAuthenticated()) {
|
||||
ElMessage.warning('请先登录后再删除评论')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该评论?删除后不可恢复', '确认删除', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await fetch(`/api/comments/${id}`, { method: 'DELETE', credentials: 'include' })
|
||||
ElMessage.success('删除成功')
|
||||
await loadComments()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const goDetail = (season: number, id: number) => {
|
||||
router.push(`/seasons/${season}/races/${id}`)
|
||||
}
|
||||
|
||||
const viewDetail = (c: CommentItem) => {
|
||||
router.push({ name: 'comment-detail', params: { id: c.id } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.home {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.intro h1 {
|
||||
font-size: 40px;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.intro p {
|
||||
font-size: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
row-gap: 30px;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: 28px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.race-card {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.race-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.race-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.race-meta {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.race-actions {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.comments {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.comment-card {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.comment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.comment-user {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.comment-time {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.delete {}
|
||||
|
||||
.comment-content {
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.comment-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.comment-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.comment-compose {
|
||||
margin-top: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.compose-actions {
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
106
frontend/src/views/Login.vue
Executable file
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { setToken, setSessionAuth } from '@/utils/auth'
|
||||
import { userInfoStore } from '@/store/UserInfo'
|
||||
|
||||
const router = useRouter()
|
||||
const userInfo = userInfoStore()
|
||||
const form = ref({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const rules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const formRef = ref()
|
||||
|
||||
const onSubmit = () => {
|
||||
if (!formRef.value) return
|
||||
formRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ username: form.value.username, password: form.value.password })
|
||||
})
|
||||
if (!res.ok) throw new Error('登录失败')
|
||||
const ct = res.headers.get('content-type') || ''
|
||||
let token = ''
|
||||
if (ct.includes('application/json')) {
|
||||
const data = await res.json()
|
||||
token = data?.token || ''
|
||||
} else {
|
||||
const text = await res.text()
|
||||
if (!/success/i.test(text)) throw new Error(text || '登录失败')
|
||||
}
|
||||
if (token) {
|
||||
setToken(token)
|
||||
} else {
|
||||
setSessionAuth(true)
|
||||
}
|
||||
ElMessage.success('登录成功')
|
||||
await userInfo.fetchUserInfo(true)
|
||||
router.push('/')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '登录失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const goRegister = () => {
|
||||
router.push({ name: 'register' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<h1 style="color: white;text-align: center;">探索F1方程式世界</h1>
|
||||
<el-card class="auth-card">
|
||||
<h2 class="title">用户登录</h2>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="form.username" placeholder="请输入用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input v-model="form.password" type="password" placeholder="请输入密码" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="onSubmit">登录</el-button>
|
||||
<el-button type="text" @click="goRegister">没有账号?去注册</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh; /*calc(100vh - 90px);*/
|
||||
background-image: url('@/assets/rb.jpg');
|
||||
background-size: cover;
|
||||
}
|
||||
.auth-card {
|
||||
width: 420px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.title {
|
||||
margin: 0 0 20px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
237
frontend/src/views/RacePage.vue
Executable file
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<div class="racepage">
|
||||
<div class="title" :style="{ backgroundImage: `url(${prixImage})` }">
|
||||
<h1 style="text-align: center;">{{ race?.name }}</h1>
|
||||
</div>
|
||||
<div class="card-capacity">
|
||||
<div v-for="(item, index) in involvedRace" :key="index" class="singlerace-card">
|
||||
<div>
|
||||
<svg class="icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-milestone"></use>
|
||||
</svg>|
|
||||
{{ item }}
|
||||
</div>
|
||||
<div class="result" @click="goResult(getType(item))">results</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { prix } from '@/assets/source'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const Races = ref([
|
||||
{ id: 1, round: 1, name: '澳大利亚大奖赛', circuit: 'Albert Park', city: '墨尔本', date: '2025-03-09', season: 2025, has_sprint: false },
|
||||
{ id: 4, round: 4, name: '巴林大奖赛', circuit: 'Jeddah Corniche', city: '吉达', date: '2025-03-23', season: 2025, has_sprint: false },
|
||||
{ id: 3, round: 3, name: '日本大奖赛', circuit: '铃鹿赛道', city: '铃鹿', date: '2025-04-06', season: 2025, has_sprint: false },
|
||||
{ id: 2, round: 2, name: '中国大奖赛', circuit: '上汽国际', city: '上海', date: '2025-04-20', season: 2025, has_sprint: true }
|
||||
])
|
||||
const raceId = Number(route.params.id)
|
||||
const race = computed(() => Races.value.find(race => race.id === raceId))
|
||||
const involvedRace = computed(() => race.value?.has_sprint ? ['Sprint Qualifying', 'Sprint Race', 'Qualifying', 'Race'] : ['Qualifying', 'Race'])
|
||||
const prixImage = computed(() => prix.find(p => p.id === race.value?.id)?.image)
|
||||
const getType = (type: string) => {
|
||||
if (type === 'Sprint Qualifying') {
|
||||
return 'sprint-qualifying'
|
||||
} else if (type === 'Sprint Race') {
|
||||
return 'sprint-race'
|
||||
} else if (type === 'Qualifying') {
|
||||
return 'qualifying'
|
||||
} else if (type === 'Race') {
|
||||
return 'race'
|
||||
} else {
|
||||
return type.toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
const goResult = (type: string) => {
|
||||
router.push(`${route.path}/${type}`)
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.racepage {
|
||||
background-color: #f7f4f1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.title {
|
||||
height: 400px;
|
||||
width: 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.title h1 {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
padding: 5px;
|
||||
box-shadow: 3px 3px black;
|
||||
}
|
||||
|
||||
.card-capacity {
|
||||
width: 90%;
|
||||
margin: 0 auto;
|
||||
margin-top: 20px;
|
||||
font-size: 25px;
|
||||
color: white;
|
||||
font-weight: bolder;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.singlerace-card {
|
||||
width: 75%;
|
||||
margin: 0 auto;
|
||||
margin-top: 20px;
|
||||
padding: 10px;
|
||||
min-height: 200px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
/* 金色渐变 - 豪华黄金色系 */
|
||||
background: linear-gradient(145deg,
|
||||
#B8860B 0%,
|
||||
/* 深金色 - 暗部 */
|
||||
#DAA520 25%,
|
||||
/* 黄金色 */
|
||||
#FFD700 50%,
|
||||
/* 亮金色 - 高光 */
|
||||
#DAA520 75%,
|
||||
/* 黄金色 */
|
||||
#B8860B 100%
|
||||
/* 深金色 - 暗部 */
|
||||
);
|
||||
|
||||
/* 金属氧化纹理和光泽 */
|
||||
background-image:
|
||||
/* 金色高光区域 */
|
||||
radial-gradient(ellipse at 70% 20%, rgba(255, 215, 0, 0.7) 10%, transparent 30%),
|
||||
/* 暗部阴影区域 */
|
||||
radial-gradient(ellipse at 30% 80%, rgba(184, 134, 11, 0.6) 10%, transparent 30%),
|
||||
/* 金属条纹纹理 */
|
||||
linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
rgba(184, 134, 11, 0.8) 25%,
|
||||
rgba(255, 215, 0, 0.6) 50%,
|
||||
rgba(184, 134, 11, 0.8) 75%,
|
||||
transparent 100%);
|
||||
|
||||
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 添加金属拉丝纹理 */
|
||||
.singlerace-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
/* 横向拉丝纹理 */
|
||||
repeating-linear-gradient(0deg,
|
||||
rgba(255, 255, 255, 0.1) 0px,
|
||||
rgba(255, 255, 255, 0.1) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px),
|
||||
/* 纵向拉丝纹理 */
|
||||
repeating-linear-gradient(90deg,
|
||||
rgba(0, 0, 0, 0.1) 0px,
|
||||
rgba(0, 0, 0, 0.1) 1px,
|
||||
transparent 1px,
|
||||
transparent 4px);
|
||||
pointer-events: none;
|
||||
mix-blend-mode: overlay;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 添加动态光泽效果 */
|
||||
.singlerace-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -100%;
|
||||
left: -100%;
|
||||
width: 300%;
|
||||
height: 300%;
|
||||
background: linear-gradient(45deg,
|
||||
transparent 40%,
|
||||
rgba(255, 255, 255, 0.1) 45%,
|
||||
rgba(255, 255, 255, 0.3) 50%,
|
||||
rgba(255, 255, 255, 0.1) 55%,
|
||||
transparent 60%);
|
||||
transform: rotate(30deg);
|
||||
animation: gold-shine 6s infinite linear;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes gold-shine {
|
||||
0% {
|
||||
transform: rotate(30deg) translateX(-100%) translateY(-100%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(30deg) translateX(100%) translateY(100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* 添加宝石点缀效果(可选) */
|
||||
.singlerace-card .gem-effect {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
background: radial-gradient(circle at 30% 30%,
|
||||
#FFD700,
|
||||
#FFA500,
|
||||
#FF8C00);
|
||||
border-radius: 50%;
|
||||
box-shadow:
|
||||
0 0 10px #FFD700,
|
||||
0 0 20px #FFA500;
|
||||
animation: gem-sparkle 2s infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes gem-sparkle {
|
||||
0% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
box-shadow:
|
||||
0 0 15px #FFD700,
|
||||
0 0 25px #FFA500,
|
||||
0 0 35px #FF8C00;
|
||||
}
|
||||
}
|
||||
|
||||
.singlerace-card>div {
|
||||
text-align: center;
|
||||
vertical-align: center;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
vertical-align: -0.15em;
|
||||
fill: currentColor;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.result:hover {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
34
frontend/src/views/RaceResult.vue
Executable file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="race-result">
|
||||
<h1>{{ race?.name }}<br>{{ type }}结果</h1>
|
||||
<QualifyingResult v-if="type === 'qualifying' || type === 'sprint-qualifying'" :id="Number(route.params.id)"/>
|
||||
<RaceResult v-else-if="type === 'race' || type === 'sprint-race'" :id="Number(route.params.id)"/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import QualifyingResult from '@/components/QualifyingResult.vue'
|
||||
import RaceResult from '@/components/RaceResult.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const type = computed(() => route.params.type)
|
||||
const Races = ref([
|
||||
{ id: 1, round: 1, name: '澳大利亚大奖赛', circuit: 'Albert Park', city: '墨尔本', date: '2025-03-09', season: 2025, has_sprint: false },
|
||||
{ id: 4, round: 4, name: '巴林大奖赛', circuit: 'Jeddah Corniche', city: '吉达', date: '2025-03-23', season: 2025, has_sprint: false },
|
||||
{ id: 3, round: 3, name: '日本大奖赛', circuit: '铃鹿赛道', city: '铃鹿', date: '2025-04-06', season: 2025, has_sprint: false },
|
||||
{ id: 2, round: 2, name: '中国大奖赛', circuit: '上汽国际', city: '上海', date: '2025-04-20', season: 2025, has_sprint: true }
|
||||
])
|
||||
|
||||
const race = computed(() => Races.value.find((d: any) => d.id === Number(route.params.id)))
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.race-result {
|
||||
background-color: #f7f4f1;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
122
frontend/src/views/Register.vue
Executable file
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const form = ref({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const validateConfirm = (_: any, value: string, callback: (e?: Error) => void) => {
|
||||
if (value !== form.value.password) {
|
||||
callback(new Error('两次输入的密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
const rules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱', trigger: 'blur' },
|
||||
{ type: 'email', message: '邮箱格式不正确', trigger: ['blur', 'change'] }
|
||||
],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请再次输入密码', trigger: 'blur' },
|
||||
{ validator: validateConfirm, trigger: ['blur', 'change'] }
|
||||
]
|
||||
}
|
||||
|
||||
const formRef = ref()
|
||||
|
||||
const onSubmit = () => {
|
||||
if (!formRef.value) return
|
||||
formRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ username: form.value.username, email: form.value.email, password: form.value.password })
|
||||
})
|
||||
if (!res.ok) throw new Error('注册失败')
|
||||
const ct = res.headers.get('content-type') || ''
|
||||
if (ct.includes('application/json')) {
|
||||
await res.json()
|
||||
} else {
|
||||
const text = await res.text()
|
||||
if (!/success/i.test(text)) throw new Error(text || '注册失败')
|
||||
}
|
||||
ElMessage.success('注册成功,请登录')
|
||||
router.push({ name: 'login' })
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '注册失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const goLogin = () => {
|
||||
router.push({ name: 'login' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<h1 style="color: white;text-align: center;">探索F1方程式世界</h1>
|
||||
<el-card class="auth-card">
|
||||
<h2 class="title">用户注册</h2>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="form.username" placeholder="请输入用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="form.email" placeholder="请输入邮箱" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input v-model="form.password" type="password" placeholder="请输入密码" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" prop="confirmPassword">
|
||||
<el-input v-model="form.confirmPassword" type="password" placeholder="请再次输入密码" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="onSubmit">注册</el-button>
|
||||
<el-button type="text" @click="goLogin">已有账号?去登录</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
background-image: url('@/assets/rb.jpg');
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 460px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 20px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
34
frontend/src/views/Result.vue
Executable file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div v-if="isTeam">
|
||||
<TeamResult :id="id" />
|
||||
</div>
|
||||
<div v-else-if="isDriver">
|
||||
<DriverResult :id="id" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="result"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import { ref, computed, onMounted, Suspense } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import TeamResult from '@/components/TeamResult.vue'
|
||||
import DriverResult from '@/components/DriverResult.vue'
|
||||
const name = ref('')
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const isTeam = computed(() => route.path.includes('/teams'))
|
||||
const isDriver = computed(() => route.path.includes('/drivers'))
|
||||
|
||||
const id = Number(route.params.id)
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.result {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
177
frontend/src/views/SeasonDetail.vue
Executable file
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div class="season-detail">
|
||||
<div class="intro">
|
||||
<h1 style="color: white;">{{ year }} 赛季</h1>
|
||||
<p style="color: white;">赛程与积分榜</p>
|
||||
</div>
|
||||
<div class="layout">
|
||||
<div class="left">
|
||||
<h2 style="color: white;">赛程</h2>
|
||||
<el-card v-for="r in schedule" :key="r.round" class="race-item" shadow="never">
|
||||
<div class="race-line">
|
||||
<span class="round">Round {{ r.round }}</span>
|
||||
<span class="name">{{ r.name }}</span>
|
||||
</div>
|
||||
<div class="info-line">
|
||||
<div class="meta">{{ r.city }} · {{ r.circuit }}</div>
|
||||
<el-button type="primary" size="small" @click="goToRace(r.round)">查看详情</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
<div class="right">
|
||||
<h2 style="color: white;">车手积分榜(Top 5)</h2>
|
||||
<el-table :data="driverStandings" size="small" stripe>
|
||||
<el-table-column prop="ranking" label="#" width="60" />
|
||||
<el-table-column prop="driver_name" label="车手" />
|
||||
<el-table-column prop="team_name" label="车队" />
|
||||
<el-table-column prop="total_score" label="积分" width="100" />
|
||||
</el-table>
|
||||
<h2 style="color: white;" class="mt">车队积分榜(Top 5)</h2>
|
||||
<el-table :data="teamStandings" size="small" stripe>
|
||||
<el-table-column prop="ranking" label="#" width="60" />
|
||||
<el-table-column prop="team_name" label="车队" />
|
||||
<el-table-column prop="total_score" label="积分" width="100" />
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button @click="goBack">返回</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const year = computed(() => Number(route.params.year))
|
||||
|
||||
const scheduleMap: Record<number, Array<{ round: number; name: string; city: string; circuit: string; date: string }>> = {
|
||||
2025: [
|
||||
{ round: 1, name: '澳大利亚大奖赛', circuit: 'Albert Park Circuit', city: 'Melbourne', date: '2025-03-09' },
|
||||
{ round: 2, name: '中国大奖赛', circuit: 'Shanghai International Circuit', city: 'Shanghai', date: '2025-04-20' },
|
||||
{ round: 3, name: '日本大奖赛', circuit: 'Suzuka International Racing Course', city: 'Mie Prefecture', date: '2025-04-06' },
|
||||
{ round: 4, name: '巴林大奖赛', circuit: 'Bahrain International Circuit', city: 'Sakhir', date: '2025-03-23' },
|
||||
],
|
||||
2024: [
|
||||
{ round: 1, name: '巴林大奖赛', city: '萨基尔', circuit: 'Sakhir', date: '2024-03-02' },
|
||||
{ round: 2, name: '沙特阿拉伯大奖赛', city: '吉达', circuit: 'Jeddah Corniche', date: '2024-03-09' },
|
||||
{ round: 3, name: '澳大利亚大奖赛', city: '墨尔本', circuit: 'Albert Park', date: '2024-03-24' },
|
||||
{ round: 4, name: '日本大奖赛', city: '铃鹿', circuit: 'Suzuka', date: '2024-04-07' }
|
||||
]
|
||||
}
|
||||
|
||||
const standingsMap = ref<any>({
|
||||
2025: {
|
||||
|
||||
},
|
||||
2024: {
|
||||
drivers: [
|
||||
{ ranking: 1, driver_name: 'Max Verstappen', team_name: 'Red Bull', total_score: 575 },
|
||||
{ ranking: 2, driver_name: 'Lando Norris', team_name: 'McLaren', total_score: 353 },
|
||||
{ ranking: 3, driver_name: 'Charles Leclerc', team_name: 'Ferrari', total_score: 301 },
|
||||
{ ranking: 4, driver_name: 'Oscar Piastri', team_name: 'McLaren', total_score: 273 },
|
||||
{ ranking: 5, driver_name: 'George Russell', team_name: 'Mercedes', total_score: 284 }
|
||||
],
|
||||
teams: [
|
||||
{ ranking: 1, team_name: 'Red Bull Racing', total_score: 860 },
|
||||
{ ranking: 2, team_name: 'Ferrari', total_score: 552 },
|
||||
{ ranking: 3, team_name: 'McLaren', total_score: 512 },
|
||||
{ ranking: 4, team_name: 'Mercedes', total_score: 408 },
|
||||
{ ranking: 5, team_name: 'Aston Martin', total_score: 208 }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const schedule = computed(() => scheduleMap[year.value] || [])
|
||||
const driverStandings = computed(() => standingsMap.value[year.value]?.drivers || [])
|
||||
const teamStandings = computed(() => standingsMap.value[year.value]?.teams || [])
|
||||
const error = ref('')
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const driverRes = await fetch('/api/standings/drivers?season=2025')
|
||||
const driverData = await driverRes.json()
|
||||
standingsMap.value[2025]['drivers'] = driverData
|
||||
const teamRes = await fetch('/api/standings/teams?season=2025')
|
||||
const teamData = await teamRes.json()
|
||||
standingsMap.value[2025]['teams'] = teamData
|
||||
console.log(standingsMap.value[2025])
|
||||
console.log(driverStandings.value)
|
||||
} catch (e) {
|
||||
error.value = '获取积分榜失败'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/seasons')
|
||||
}
|
||||
|
||||
const goToRace = (id: number) => {
|
||||
router.push(`/seasons/${year.value}/races/${id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.season-detail {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.intro h1 {
|
||||
font-size: 40px;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.intro p {
|
||||
font-size: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.race-item {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.race-line,
|
||||
.info-line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.round {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.date {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.info-line {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.mt {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
89
frontend/src/views/Seasons.vue
Executable file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div class="seasons">
|
||||
<div class="intro">
|
||||
<h1 style="color:white;">F1 赛季</h1>
|
||||
<p style="color:white;">选择一个赛季查看详细赛程与积分榜</p>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<el-card v-for="s in seasons" :key="s.year" class="season-card" shadow="hover">
|
||||
<div class="season-header">
|
||||
<div class="season-year">{{ s.year }}</div>
|
||||
<div class="season-meta">赛事数:{{ s.races }} · 车队:{{ s.teams }}</div>
|
||||
</div>
|
||||
<div class="season-body">
|
||||
<div>车手冠军:{{ s.champion }}</div>
|
||||
<div>车队冠军:{{ s.teamChampion }}</div>
|
||||
</div>
|
||||
<div class="season-actions">
|
||||
<el-button type="primary" size="small" @click="goDetail(s.year)">查看详情</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const seasons = ref([
|
||||
{ year: 2025, races: 24, teams: 10, champion: '待定', teamChampion: '待定' },
|
||||
{ year: 2024, races: 24, teams: 10, champion: 'Max Verstappen', teamChampion: 'Red Bull Racing' }
|
||||
])
|
||||
|
||||
const goDetail = (year: number) => {
|
||||
router.push(`/seasons/${year}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.seasons {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.intro h1 {
|
||||
font-size: 40px;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.intro p {
|
||||
font-size: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.season-card {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.season-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.season-year {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.season-meta {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.season-body {
|
||||
margin-top: 8px;
|
||||
display: grid;
|
||||
row-gap: 4px;
|
||||
}
|
||||
|
||||
.season-actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
410
frontend/src/views/TeamDetail.vue
Executable file
@@ -0,0 +1,410 @@
|
||||
<template>
|
||||
<div class="team-detail" v-if="team">
|
||||
<div class="header" :style="{ background: getColor(team.name) }">
|
||||
<div class="horizontal-bar1">
|
||||
<svg viewBox="0 0 1000 500" preserveAspectRatio="none">
|
||||
<rect x="0" y="0" width="350" height="15" fill="white" opacity="1" />
|
||||
<rect x="0" y="18" width="350" height="15" fill="white" opacity="1" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="horizontal-bar2">
|
||||
<svg viewBox="0 0 1000 500" preserveAspectRatio="none">
|
||||
<rect x="0" y="0" width="350" height="15" fill="white" opacity="1" />
|
||||
<rect x="0" y="18" width="350" height="15" fill="white" opacity="1" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="car">
|
||||
<img :src="getCarImage(team.name)" alt="car">
|
||||
</div>
|
||||
<div class="info">
|
||||
<h1>{{ team.name }}</h1>
|
||||
<div class="meta">{{ team.country }}</div>
|
||||
<div class="drivers"><span>{{ team_sttt.drivers[0] }}</span><span> | </span><span>{{ team_sttt.drivers[1] }}</span></div>
|
||||
<img :src="getLogo(team.name)" alt="logo" class="logo">
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="drivershow">
|
||||
<h2>车手</h2>
|
||||
<div class="showcase">
|
||||
<template v-for="driver in drivers" :key="driver.id">
|
||||
<div class="card-wrapper" @click="goDriver(driver.id)">
|
||||
<DriverCard :name="driver.name" :nation="driver.country" :num="driver.carNum" :image="getImage(driver.name)"
|
||||
:color="getColor(team.name)" :team="team.name" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="redgap"></div>
|
||||
<div class="redgap"></div>
|
||||
</div>
|
||||
<div class="bio">
|
||||
<div class="intro">
|
||||
<h2>简介<br></br>2025赛季</h2>
|
||||
<div class="content">
|
||||
<div class="content-row line">
|
||||
<div class="content-col">
|
||||
<span class="label">赛季排名</span><br>
|
||||
<span class="value">{{ team_sttt.rank }}</span>
|
||||
</div>
|
||||
<div class="content-col">
|
||||
<span class="label">赛季得分</span><br>
|
||||
<span class="value">{{ team_sttt.total_score }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-row">
|
||||
<div class="content-col">
|
||||
<span class="label">正赛次数</span><br>
|
||||
<span class="value">{{ team_sttt.formal.totalCnt }}</span>
|
||||
</div>
|
||||
<div class="content-col">
|
||||
<span class="label">正赛积分</span><br>
|
||||
<span class="value">{{ team_sttt.formal.scoreSum }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-row">
|
||||
<div class="content-col">
|
||||
<span class="label">正赛胜场</span><br>
|
||||
<span class="value">{{ team_sttt.formal.gold }}</span>
|
||||
</div>
|
||||
<div class="content-col">
|
||||
<span class="label">正赛领奖台</span><br>
|
||||
<span class="value">{{ team_sttt.formal.medal }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-row">
|
||||
<div class="content-col">
|
||||
<span class="label">正赛杆位</span><br>
|
||||
<span class="value">{{ team_sttt.formal.pole }}</span>
|
||||
</div>
|
||||
<div class="content-col">
|
||||
<span class="label">正赛前十</span><br>
|
||||
<span class="value">{{ team_sttt.formal.topTen }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-row line">
|
||||
<div class="content-col">
|
||||
<span class="label">最快单圈</span><br>
|
||||
<span class="value">{{ team_sttt.formal.fastestLap }}</span>
|
||||
</div>
|
||||
<div class="content-col">
|
||||
<span class="label">未完赛</span><br>
|
||||
<span class="value">{{ team_sttt.formal.unfinished }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-row">
|
||||
<div class="content-col">
|
||||
<span class="label">冲刺赛次数</span><br>
|
||||
<span class="value">{{ team_sttt.sprint.totalCnt }}</span>
|
||||
</div>
|
||||
<div class="content-col">
|
||||
<span class="label">冲刺赛积分</span><br>
|
||||
<span class="value">{{ team_sttt.sprint.scoreSum }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-row">
|
||||
<div class="content-col">
|
||||
<span class="label">冲刺赛胜场</span><br>
|
||||
<span class="value">{{ team_sttt.sprint.gold }}</span>
|
||||
</div>
|
||||
<div class="content-col">
|
||||
<span class="label">冲刺赛领奖台</span><br>
|
||||
<span class="value">{{ team_sttt.sprint.medal }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-row line">
|
||||
<div class="content-col">
|
||||
<span class="label">冲刺赛杆位</span><br>
|
||||
<span class="value">{{ team_sttt.sprint.pole }}</span>
|
||||
</div>
|
||||
<div class="content-col">
|
||||
<span class="label">冲刺赛前十</span><br>
|
||||
<span class="value">{{ team_sttt.sprint.topTen }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toresult">
|
||||
<el-button type="primary" @click="goResult">详细比赛结果</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="statics">
|
||||
<h2>生涯数据</h2>
|
||||
<div class="content">
|
||||
<div class="statics-row line">
|
||||
<span class="item">参加比赛场次</span>
|
||||
<span class="itemvalue">{{ tc?.races }}</span>
|
||||
</div>
|
||||
<div class="statics-row line">
|
||||
<span class="item">历史积分</span>
|
||||
<span class="itemvalue">{{ tc?.points }}</span>
|
||||
</div>
|
||||
<div class="statics-row line">
|
||||
<span class="item">最佳完赛成绩</span>
|
||||
<span class="itemvalue">{{ tc?.hf }}</span>
|
||||
</div>
|
||||
<div class="statics-row line">
|
||||
<span class="item">领奖台数</span>
|
||||
<span class="itemvalue">{{ tc?.podiums }}</span>
|
||||
</div>
|
||||
<div class="statics-row line">
|
||||
<span class="item">最佳发车位次</span>
|
||||
<span class="itemvalue">{{ tc?.hg }}</span>
|
||||
</div>
|
||||
<div class="statics-row line">
|
||||
<span class="item">杆位数</span>
|
||||
<span class="itemvalue">{{ tc?.polepositions }}</span>
|
||||
</div>
|
||||
<div class="statics-row">
|
||||
<span class="item">世界冠军数</span>
|
||||
<span class="itemvalue">{{ tc?.wc }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button @click="goBack">返回</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getColor, getCarImage, getLogo, getImage } from '@/assets/source'
|
||||
import DriverCard from '@/components/DriverCard.vue'
|
||||
import { team_career } from '@/assets/source'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const team = ref<any>()
|
||||
const drivers = ref<any[]>([])
|
||||
const team_sttt = ref<any>()
|
||||
const tc = computed(() => team_career.find((t: any) => t.name === team.value.name))
|
||||
|
||||
onMounted(async () => {
|
||||
const id = Number(route.params.id)
|
||||
try {
|
||||
const [teamRes, driverRes] = await Promise.all([
|
||||
fetch('/api/teams'),
|
||||
fetch('/api/season-drivers?season=2025')
|
||||
])
|
||||
const teamJson = await teamRes.json()
|
||||
const driverJson = await driverRes.json()
|
||||
const teamList = Array.isArray(teamJson) ? teamJson : (teamJson?.data ?? [])
|
||||
const driverList = Array.isArray(driverJson) ? driverJson : (driverJson?.data ?? [])
|
||||
team.value = teamList.find((t: any) => (t.id ?? t.teamId) === id)
|
||||
const sttRes = await fetch(`/api/teams/${id}/statistics?season=2025`)
|
||||
team_sttt.value = await sttRes.json()
|
||||
drivers.value = driverList.filter((d: any) => team_sttt.value.drivers.includes(d.name))
|
||||
} catch (e) {
|
||||
team.value = undefined
|
||||
drivers.value = []
|
||||
}
|
||||
})
|
||||
|
||||
const goDriver = (id: number) => {
|
||||
router.push(`/drivers/${id}`)
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/teams')
|
||||
}
|
||||
|
||||
const goResult = () => {
|
||||
const t = team.value
|
||||
if (!t) return
|
||||
router.push(`/teams/${t.id}/results`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.team-detail {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
border-radius: 20px 20px 0 0;
|
||||
position: relative;
|
||||
height: 600px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.horizontal-bar1,
|
||||
.horizontal-bar2 {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.horizontal-bar1 {
|
||||
top: 350px;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.horizontal-bar2 {
|
||||
top: 350px;
|
||||
left: 65%;
|
||||
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin-top: 50px;
|
||||
}
|
||||
|
||||
.info h1 {
|
||||
font-size: 50px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.drivers {
|
||||
margin-top: 6px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 0 10px;
|
||||
}
|
||||
|
||||
.drivers span:first-child {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.car {
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
.car img {
|
||||
display: block;
|
||||
width: 60vw;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.stat {
|
||||
background-color: #1c1c25;
|
||||
color: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 0 0 20px 20px;
|
||||
}
|
||||
|
||||
.drivershow {
|
||||
width: 90%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.drivershow h2 {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.showcase {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
column-gap: 20px;
|
||||
}
|
||||
|
||||
.redgap {
|
||||
height: 10px;
|
||||
background-color: red;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.bio {
|
||||
background-color: #1c1c25;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-evenly;
|
||||
padding-bottom: 30px;
|
||||
border-radius: 0 0 20px 20px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
.statics {
|
||||
width: 45%;
|
||||
border-radius: 20px;
|
||||
background-color: #303037;
|
||||
margin-top: 150px;
|
||||
}
|
||||
|
||||
.intro h2,
|
||||
.statics h2 {
|
||||
font-size: 40px;
|
||||
margin-top: 0;
|
||||
padding-top: 40px;
|
||||
}
|
||||
|
||||
.statics h2 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.content-row {
|
||||
display: flex;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.content-col {
|
||||
width: 50%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
color: #aaaaaa;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-weight: bold;
|
||||
font-size: 25px;
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.line {
|
||||
border-bottom: 2px solid gray;
|
||||
}
|
||||
|
||||
.statics .content {
|
||||
width: 90%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.statics-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.item {
|
||||
font-size: 14px;
|
||||
color: #aaaaaa;
|
||||
}
|
||||
|
||||
.itemvalue {
|
||||
font-weight: bold;
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
84
frontend/src/views/Teams.vue
Executable file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div class="intro">
|
||||
<h1 style="color:white;margin-left: 20px;">F1 Teams</h1>
|
||||
<p style="color:white;margin-left: 20px;">Find the Formula 1 teams for the 2025 season</p>
|
||||
</div>
|
||||
<el-skeleton v-if="loading" :rows="3" animated />
|
||||
<el-empty v-else-if="error || !teams.length" description="暂无数据" />
|
||||
<div v-else class="showcase">
|
||||
<template v-for="team in teams" :key="team.id">
|
||||
<div class="card-wrapper" @click="goTeam(team.id)">
|
||||
<TeamCard :name="team.name" :nation="team.nation" :image="getCarImage(team.name)" :color="getColor(team.name)" :driver1="getDriverList(team.name)[0].name" :driver2="getDriverList(team.name)[1].name" :logo="getLogo(team.name)" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import TeamCard from '@/components/TeamCard.vue'
|
||||
import { getColor, getCarImage, getLogo } from '@/assets/source'
|
||||
import { useDriversStore } from '@/store/SeasonDrivers'
|
||||
// import { teams } from '@/assets/source'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const teams = ref<any[]>([])
|
||||
const driversStore = useDriversStore()
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/teams')
|
||||
const json = await res.json()
|
||||
const list = Array.isArray(json) ? json : (json?.data ?? [])
|
||||
teams.value = list.map((t: any) => ({
|
||||
id: t.id ?? t.teamId,
|
||||
name: t.name,
|
||||
nation: t.nation,
|
||||
image: t.image ?? t.imageUrl,
|
||||
color: t.color ?? getColor(t.name),
|
||||
driver1: Array.isArray(t.drivers) ? t.drivers[0] : t.driver1 ?? '',
|
||||
driver2: Array.isArray(t.drivers) ? t.drivers[1] : t.driver2 ?? '',
|
||||
logo: t.logo ?? t.logoUrl
|
||||
}))
|
||||
driversStore.ensureDriversLoaded()
|
||||
} catch (e) {
|
||||
error.value = '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const getDriverList = (name: string) => {
|
||||
return driversStore.driversList.filter((d: any) => d.team === name)
|
||||
}
|
||||
|
||||
const goTeam = (id: number) => {
|
||||
router.push(`/teams/${id}`)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.intro h1 {
|
||||
font-size: 40px;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.intro p {
|
||||
font-size: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.showcase {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
row-gap: 20px;
|
||||
}
|
||||
|
||||
.card-wrapper:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
24
frontend/vite.config.ts
Executable file
@@ -0,0 +1,24 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080',
|
||||
// '/api': 'http://10.128.50.6:8080',
|
||||
},
|
||||
},
|
||||
})
|
||||