diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..33895ab --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,5 @@ +# Vue 3 + TypeScript + Vite + +This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..75b86e5 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.18.1", + "echarts": "^6.1.0", + "element-plus": "^2.14.2", + "pinia": "^3.0.4", + "vue": "^3.5.39", + "vue-router": "^5.1.0" + }, + "devDependencies": { + "@types/node": "^24.13.2", + "@vitejs/plugin-vue": "^6.0.7", + "@vue/test-utils": "^2.4.11", + "@vue/tsconfig": "^0.9.1", + "jsdom": "^29.1.1", + "typescript": "~6.0.2", + "vite": "^8.1.1", + "vitest": "^4.1.10", + "vue-tsc": "^3.3.5" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..9f7a593 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,5 @@ + + + diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..9cc1a73 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,28 @@ +import axios, { AxiosError } from 'axios' +import { useAuthStore } from '@/stores/auth' +import { router } from '@/router' + +export const apiClient = axios.create({ + baseURL: '/api/v1', + timeout: 60000, +}) + +apiClient.interceptors.request.use((config) => { + const auth = useAuthStore() + if (auth.token) { + config.headers.Authorization = `Bearer ${auth.token}` + } + return config +}) + +apiClient.interceptors.response.use( + (response) => response, + (error: AxiosError) => { + if (error.response?.status === 401) { + const auth = useAuthStore() + auth.logout() + router.push('/login') + } + return Promise.reject(error) + }, +) diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/assets/vue.svg b/frontend/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/frontend/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/HelloWorld.vue b/frontend/src/components/HelloWorld.vue new file mode 100644 index 0000000..c232865 --- /dev/null +++ b/frontend/src/components/HelloWorld.vue @@ -0,0 +1,95 @@ + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..d4d571b --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,9 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import './style.css' +import App from './App.vue' +import { router } from './router' + +createApp(App).use(createPinia()).use(router).use(ElementPlus).mount('#app') diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..de0e552 --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,30 @@ +import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' +import { useAuthStore } from '@/stores/auth' + +const routes: RouteRecordRaw[] = [ + { path: '/login', name: 'login', component: () => import('@/views/Login.vue') }, + { + path: '/', + component: () => import('@/views/Layout.vue'), + children: [ + { path: '', redirect: '/backtest/new' }, + { path: 'backtest/new', name: 'bt-new', component: () => import('@/views/backtest/New.vue') }, + { path: 'backtest/progress/:id', name: 'bt-progress', component: () => import('@/views/backtest/Progress.vue') }, + { path: 'backtest/result/:id', name: 'bt-result', component: () => import('@/views/backtest/Result.vue') }, + { path: 'factor/new', name: 'fc-new', component: () => import('@/views/factor/New.vue') }, + { path: 'factor/result/:id', name: 'fc-result', component: () => import('@/views/factor/Result.vue') }, + ], + }, +] + +export const router = createRouter({ + history: createWebHistory(), + routes, +}) + +router.beforeEach((to) => { + const auth = useAuthStore() + if (to.name !== 'login' && !auth.isAuthenticated) { + return { name: 'login' } + } +}) diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..1239a5b --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,30 @@ +import { defineStore } from 'pinia' + +interface AuthState { + token: string | null + username: string | null +} + +export const useAuthStore = defineStore('auth', { + state: (): AuthState => ({ + token: localStorage.getItem('token'), + username: localStorage.getItem('username'), + }), + getters: { + isAuthenticated: (state): boolean => !!state.token, + }, + actions: { + setToken(token: string, username: string): void { + this.token = token + this.username = username + localStorage.setItem('token', token) + localStorage.setItem('username', username) + }, + logout(): void { + this.token = null + this.username = null + localStorage.removeItem('token') + localStorage.removeItem('username') + }, + }, +}) diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..527d4fb --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,296 @@ +:root { + --text: #6b6375; + --text-h: #08060d; + --bg: #fff; + --border: #e5e4e7; + --code-bg: #f4f3ec; + --accent: #aa3bff; + --accent-bg: rgba(170, 59, 255, 0.1); + --accent-border: rgba(170, 59, 255, 0.5); + --social-bg: rgba(244, 243, 236, 0.5); + --shadow: + rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + + --sans: system-ui, 'Segoe UI', Roboto, sans-serif; + --heading: system-ui, 'Segoe UI', Roboto, sans-serif; + --mono: ui-monospace, Consolas, monospace; + + font: 18px/145% var(--sans); + letter-spacing: 0.18px; + color-scheme: light dark; + color: var(--text); + background: var(--bg); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + @media (max-width: 1024px) { + font-size: 16px; + } +} + +@media (prefers-color-scheme: dark) { + :root { + --text: #9ca3af; + --text-h: #f3f4f6; + --bg: #16171d; + --border: #2e303a; + --code-bg: #1f2028; + --accent: #c084fc; + --accent-bg: rgba(192, 132, 252, 0.15); + --accent-border: rgba(192, 132, 252, 0.5); + --social-bg: rgba(47, 48, 58, 0.5); + --shadow: + rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; + } + + #social .button-icon { + filter: invert(1) brightness(2); + } +} + +body { + margin: 0; +} + +h1, +h2 { + font-family: var(--heading); + font-weight: 500; + color: var(--text-h); +} + +h1 { + font-size: 56px; + letter-spacing: -1.68px; + margin: 32px 0; + @media (max-width: 1024px) { + font-size: 36px; + margin: 20px 0; + } +} +h2 { + font-size: 24px; + line-height: 118%; + letter-spacing: -0.24px; + margin: 0 0 8px; + @media (max-width: 1024px) { + font-size: 20px; + } +} +p { + margin: 0; +} + +code, +.counter { + font-family: var(--mono); + display: inline-flex; + border-radius: 4px; + color: var(--text-h); +} + +code { + font-size: 15px; + line-height: 135%; + padding: 4px 8px; + background: var(--code-bg); +} + +.counter { + font-size: 16px; + padding: 5px 10px; + border-radius: 5px; + color: var(--accent); + background: var(--accent-bg); + border: 2px solid transparent; + transition: border-color 0.3s; + margin-bottom: 24px; + + &:hover { + border-color: var(--accent-border); + } + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } +} + +.hero { + position: relative; + + .base, + .framework, + .vite { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .vite { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) + scale(1.4); + } + + .vite { + z-index: 0; + top: 107px; + height: 26px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) + scale(0.8); + } +} + +#app { + width: 1126px; + max-width: 100%; + margin: 0 auto; + text-align: center; + border-inline: 1px solid var(--border); + min-height: 100svh; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + .logo { + height: 18px; + } + + a { + color: var(--text-h); + font-size: 16px; + border-radius: 6px; + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue new file mode 100644 index 0000000..376e180 --- /dev/null +++ b/frontend/src/views/Layout.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..90b16fd --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/frontend/src/views/backtest/New.vue b/frontend/src/views/backtest/New.vue new file mode 100644 index 0000000..053b669 --- /dev/null +++ b/frontend/src/views/backtest/New.vue @@ -0,0 +1,4 @@ + + diff --git a/frontend/src/views/backtest/Progress.vue b/frontend/src/views/backtest/Progress.vue new file mode 100644 index 0000000..9cea1cf --- /dev/null +++ b/frontend/src/views/backtest/Progress.vue @@ -0,0 +1,4 @@ + + diff --git a/frontend/src/views/backtest/Result.vue b/frontend/src/views/backtest/Result.vue new file mode 100644 index 0000000..eccdc4b --- /dev/null +++ b/frontend/src/views/backtest/Result.vue @@ -0,0 +1,4 @@ + + diff --git a/frontend/src/views/factor/New.vue b/frontend/src/views/factor/New.vue new file mode 100644 index 0000000..50ae65b --- /dev/null +++ b/frontend/src/views/factor/New.vue @@ -0,0 +1,4 @@ + + diff --git a/frontend/src/views/factor/Result.vue b/frontend/src/views/factor/Result.vue new file mode 100644 index 0000000..9c9daf1 --- /dev/null +++ b/frontend/src/views/factor/Result.vue @@ -0,0 +1,4 @@ + + diff --git a/frontend/tests/auth.test.ts b/frontend/tests/auth.test.ts new file mode 100644 index 0000000..8b8b343 --- /dev/null +++ b/frontend/tests/auth.test.ts @@ -0,0 +1,35 @@ +import { setActivePinia, createPinia } from 'pinia' +import { beforeEach, describe, expect, it } from 'vitest' +import { useAuthStore } from '@/stores/auth' + +describe('auth store', () => { + beforeEach(() => { + localStorage.clear() + setActivePinia(createPinia()) + }) + + it('starts unauthenticated', () => { + const auth = useAuthStore() + expect(auth.isAuthenticated).toBe(false) + expect(auth.token).toBe(null) + }) + + it('setToken stores token + username and authenticates', () => { + const auth = useAuthStore() + auth.setToken('jwt-xyz', 'admin') + expect(auth.token).toBe('jwt-xyz') + expect(auth.username).toBe('admin') + expect(auth.isAuthenticated).toBe(true) + expect(localStorage.getItem('token')).toBe('jwt-xyz') + }) + + it('logout clears token + username', () => { + const auth = useAuthStore() + auth.setToken('jwt-xyz', 'admin') + auth.logout() + expect(auth.token).toBe(null) + expect(auth.username).toBe(null) + expect(auth.isAuthenticated).toBe(false) + expect(localStorage.getItem('token')).toBe(null) + }) +}) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..dc512b2 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,22 @@ +/// +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath, URL } from 'node:url' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, + }, + server: { + host: '0.0.0.0', + port: 5173, + proxy: { + '/api': { target: 'http://192.168.2.154:8000', changeOrigin: true }, + '/ws': { target: 'ws://192.168.2.154:8000', ws: true, changeOrigin: true }, + }, + }, + build: { outDir: 'dist', emptyOutDir: true }, + test: { environment: 'jsdom', globals: true }, +})