formula project

This commit is contained in:
colden
2025-12-20 12:20:43 +08:00
commit 28e1507889
156 changed files with 7444 additions and 0 deletions

1
frontend/env.d.ts vendored Executable file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

13
frontend/index.html Executable file
View 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
View 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
View 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
View 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

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

BIN
frontend/src/assets/far.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 KiB

BIN
frontend/src/assets/heying.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

View 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;
}

View 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">&#xe637;</span>
<div class="name">cup</div>
<div class="code-name">&amp;#xe637;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe600;</span>
<div class="name">race car</div>
<div class="code-name">&amp;#xe600;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe67b;</span>
<div class="name">sports_icon_racing car@2x</div>
<div class="code-name">&amp;#xe67b;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe9db;</span>
<div class="name">Home, homepage, menu</div>
<div class="code-name">&amp;#xe9db;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe6c4;</span>
<div class="name">icon_task_details_milestone</div>
<div class="code-name">&amp;#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"
>&lt;span class="iconfont"&gt;&amp;#x33;&lt;/span&gt;
</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">&lt;link rel="stylesheet" href="./iconfont.css"&gt;
</code></pre>
<h3 id="-">第二步:挑选相应图标并获取类名,应用于页面:</h3>
<pre><code class="language-html">&lt;span class="iconfont icon-xxx"&gt;&lt;/span&gt;
</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">&lt;script src="./iconfont.js"&gt;&lt;/script&gt;
</code></pre>
<h3 id="-css-">第二步:加入通用 CSS 代码(引入一次就行):</h3>
<pre><code class="language-html">&lt;style&gt;
.icon {
width: 1em;
height: 1em;
vertical-align: -0.15em;
fill: currentColor;
overflow: hidden;
}
&lt;/style&gt;
</code></pre>
<h3 id="-">第三步:挑选相应图标并获取类名,应用于页面:</h3>
<pre><code class="language-html">&lt;svg class="icon" aria-hidden="true"&gt;
&lt;use xlink:href="#icon-xxx"&gt;&lt;/use&gt;
&lt;/svg&gt;
</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>

View 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";
}

File diff suppressed because one or more lines are too long

View 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
}
]
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
frontend/src/assets/logo.webp Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

BIN
frontend/src/assets/rb.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

689
frontend/src/assets/source.ts Executable file
View 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
View File

@@ -0,0 +1,4 @@
body {
margin: 0;
padding: 0;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 B

BIN
frontend/src/assets/teams/am.avif Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 829 B

BIN
frontend/src/assets/teams/fe.avif Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 888 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 886 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 775 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 878 B

BIN
frontend/src/assets/teams/rb.avif Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 752 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 816 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

BIN
frontend/src/assets/ver.gif Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 872 KiB

BIN
frontend/src/components/.DS_Store vendored Executable file

Binary file not shown.

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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

View 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
View 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
View 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

Binary file not shown.

View 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>

View 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
View 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
View 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
View 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
View 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>

View 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
View 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
View 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>

View 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
View 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
View 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
View 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
View 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',
},
},
})