feat: sync current progress (P0 hardening + P1 observability + deploy docs/systemd)
This commit is contained in:
24
web/frontend/.gitignore
vendored
Normal file
24
web/frontend/.gitignore
vendored
Normal file
@@ -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?
|
||||
5
web/frontend/README.md
Normal file
5
web/frontend/README.md
Normal file
@@ -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 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
13
web/frontend/index.html
Normal file
13
web/frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
2178
web/frontend/package-lock.json
generated
Normal file
2178
web/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
web/frontend/package.json
Normal file
27
web/frontend/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "asset-tracker-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.11.0",
|
||||
"echarts": "^5.6.0",
|
||||
"element-plus": "^2.11.4",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.25",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vue-tsc": "^3.1.5"
|
||||
}
|
||||
}
|
||||
1
web/frontend/public/vite.svg
Normal file
1
web/frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
3
web/frontend/src/App.vue
Normal file
3
web/frontend/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
28
web/frontend/src/api/assets.ts
Normal file
28
web/frontend/src/api/assets.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import client from './client'
|
||||
|
||||
export type AssetQuery = { page?: number; page_size?: number; status?: string; category_id?: number }
|
||||
|
||||
export async function listAssets(params: AssetQuery) {
|
||||
const { data } = await client.get('/assets', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createAsset(payload: any) {
|
||||
const { data } = await client.post('/assets', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateAsset(id: number, payload: any) {
|
||||
const { data } = await client.put(`/assets/${id}`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteAsset(id: number) {
|
||||
const { data } = await client.delete(`/assets/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function dashboardSummary() {
|
||||
const { data } = await client.get('/dashboard/summary')
|
||||
return data
|
||||
}
|
||||
6
web/frontend/src/api/auth.ts
Normal file
6
web/frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import client from './client'
|
||||
|
||||
export async function loginApi(username: string, password: string) {
|
||||
const { data } = await client.post('/auth/login', { username, password })
|
||||
return data
|
||||
}
|
||||
11
web/frontend/src/api/categories.ts
Normal file
11
web/frontend/src/api/categories.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import client from './client'
|
||||
|
||||
export async function listCategories() {
|
||||
const { data } = await client.get('/categories')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createCategory(payload: { name: string; type: 'real' | 'digital' }) {
|
||||
const { data } = await client.post('/categories', payload)
|
||||
return data
|
||||
}
|
||||
84
web/frontend/src/api/client.ts
Normal file
84
web/frontend/src/api/client.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||
import router from '../router'
|
||||
|
||||
type RetryableConfig = InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
|
||||
let isRefreshing = false
|
||||
let pendingQueue: Array<(token: string | null) => void> = []
|
||||
|
||||
function processQueue(token: string | null) {
|
||||
pendingQueue.forEach((cb) => cb(token))
|
||||
pendingQueue = []
|
||||
}
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 10000,
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
client.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('asset_tracker_token')
|
||||
if (token) {
|
||||
config.headers = config.headers || {}
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
async function refreshAccessToken(): Promise<string> {
|
||||
const resp = await axios.post('/api/v1/auth/refresh', {}, { withCredentials: true, timeout: 10000 })
|
||||
const token = resp?.data?.access_token || ''
|
||||
if (!token) throw new Error('refresh failed')
|
||||
localStorage.setItem('asset_tracker_token', token)
|
||||
return token
|
||||
}
|
||||
|
||||
function redirectSessionExpired() {
|
||||
localStorage.removeItem('asset_tracker_token')
|
||||
if (router.currentRoute.value.path !== '/session-expired') {
|
||||
router.push('/session-expired')
|
||||
}
|
||||
}
|
||||
|
||||
client.interceptors.response.use(
|
||||
(resp) => resp,
|
||||
async (err: AxiosError) => {
|
||||
const status = err?.response?.status
|
||||
const original = (err.config || {}) as RetryableConfig
|
||||
|
||||
if (status !== 401 || original._retry) {
|
||||
return Promise.reject(err)
|
||||
}
|
||||
|
||||
original._retry = true
|
||||
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingQueue.push((token) => {
|
||||
if (!token) return reject(err)
|
||||
original.headers = original.headers || {}
|
||||
original.headers.Authorization = `Bearer ${token}`
|
||||
resolve(client(original))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
isRefreshing = true
|
||||
try {
|
||||
const token = await refreshAccessToken()
|
||||
processQueue(token)
|
||||
original.headers = original.headers || {}
|
||||
original.headers.Authorization = `Bearer ${token}`
|
||||
return client(original)
|
||||
} catch (e) {
|
||||
processQueue(null)
|
||||
redirectSessionExpired()
|
||||
return Promise.reject(e)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export default client
|
||||
6
web/frontend/src/api/reminders.ts
Normal file
6
web/frontend/src/api/reminders.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import client from './client'
|
||||
|
||||
export async function listReminders(params: { status?: string; page?: number; page_size?: number }) {
|
||||
const { data } = await client.get('/reminders', { params })
|
||||
return data
|
||||
}
|
||||
1
web/frontend/src/assets/vue.svg
Normal file
1
web/frontend/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
25
web/frontend/src/components/AppNav.vue
Normal file
25
web/frontend/src/components/AppNav.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
|
||||
function go(path: string) { router.push(path) }
|
||||
function logout() { auth.logout(); router.push('/login') }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card class="card" shadow="never" style="margin-bottom: 12px;">
|
||||
<el-space wrap>
|
||||
<el-button :type="route.path==='/' ? 'primary':'default'" @click="go('/')">公开记录</el-button>
|
||||
<el-button :type="route.path==='/app' ? 'primary':'default'" @click="go('/app')">仪表盘</el-button>
|
||||
<el-button :type="route.path==='/assets' ? 'primary':'default'" @click="go('/assets')">资产</el-button>
|
||||
<el-button :type="route.path==='/categories' ? 'primary':'default'" @click="go('/categories')">分类</el-button>
|
||||
<el-button :type="route.path==='/reminders' ? 'primary':'default'" @click="go('/reminders')">提醒</el-button>
|
||||
<el-button v-if="auth.token" type="danger" plain @click="logout">退出</el-button>
|
||||
<el-button v-else @click="go('/login')">登录</el-button>
|
||||
</el-space>
|
||||
</el-card>
|
||||
</template>
|
||||
43
web/frontend/src/components/AssetFormDialog.vue
Normal file
43
web/frontend/src/components/AssetFormDialog.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = withDefaults(defineProps<{ modelValue: boolean; editing?: any; categories: any[] }>(), { editing: null })
|
||||
const emit = defineEmits(['update:modelValue', 'submit'])
|
||||
|
||||
const form = reactive<any>({ name: '', category_id: undefined, quantity: 1, unit_price: 0, currency: 'USD', expiry_date: '' })
|
||||
watch(() => props.editing, (v) => {
|
||||
if (v) Object.assign(form, { ...v, expiry_date: v.expiry_date ? v.expiry_date.slice(0,16) : '' })
|
||||
else Object.assign(form, { name: '', category_id: undefined, quantity: 1, unit_price: 0, currency: 'USD', expiry_date: '' })
|
||||
}, { immediate: true })
|
||||
|
||||
function save() {
|
||||
if (!form.name?.trim()) return ElMessage.error('资产名必填')
|
||||
if (Number(form.quantity) < 0 || Number(form.unit_price) < 0) return ElMessage.error('数量/单价必须 >= 0')
|
||||
emit('submit', {
|
||||
name: form.name.trim(),
|
||||
category_id: Number(form.category_id),
|
||||
quantity: Number(form.quantity),
|
||||
unit_price: Number(form.unit_price),
|
||||
currency: String(form.currency || '').toUpperCase(),
|
||||
expiry_date: form.expiry_date ? new Date(form.expiry_date).toISOString() : '',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog :model-value="modelValue" :title="editing ? '编辑资产' : '新增资产'" width="560px" @close="emit('update:modelValue', false)">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="名称"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="分类"><el-select v-model="form.category_id" style="width:100%"><el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" /></el-select></el-form-item>
|
||||
<el-form-item label="数量"><el-input-number v-model="form.quantity" :min="0" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="单价"><el-input-number v-model="form.unit_price" :min="0" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="币种"><el-input v-model="form.currency" /></el-form-item>
|
||||
<el-form-item label="到期日"><el-date-picker v-model="form.expiry_date" type="datetime" value-format="YYYY-MM-DDTHH:mm" style="width:100%" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="emit('update:modelValue', false)">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
13
web/frontend/src/components/ExpiryBadge.vue
Normal file
13
web/frontend/src/components/ExpiryBadge.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ expiryDate?: string }>()
|
||||
function text(v?: string) {
|
||||
if (!v) return '无到期日'
|
||||
const ms = new Date(v).getTime() - Date.now()
|
||||
const d = Math.ceil(ms / (24 * 3600 * 1000))
|
||||
return d >= 0 ? `剩余 ${d} 天` : `已过期 ${Math.abs(d)} 天`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-tag size="small" :type="!expiryDate ? 'info' : 'warning'">{{ text(props.expiryDate) }}</el-tag>
|
||||
</template>
|
||||
41
web/frontend/src/components/HelloWorld.vue
Normal file
41
web/frontend/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{ msg: string }>()
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
17
web/frontend/src/components/PageState.vue
Normal file
17
web/frontend/src/components/PageState.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ loading?: boolean; error?: string; empty?: boolean; emptyText?: string }>(), {
|
||||
loading: false,
|
||||
error: '',
|
||||
empty: false,
|
||||
emptyText: '暂无数据',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" style="padding: 12px;"><el-skeleton :rows="5" animated /></div>
|
||||
<el-result v-else-if="error" icon="error" title="加载失败" :sub-title="error">
|
||||
<template #extra><slot name="retry" /></template>
|
||||
</el-result>
|
||||
<el-empty v-else-if="empty" :description="emptyText" />
|
||||
<slot v-else />
|
||||
</template>
|
||||
11
web/frontend/src/components/SummaryCards.vue
Normal file
11
web/frontend/src/components/SummaryCards.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ total: number; assetsCount: number; expiringCount: number }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :sm="8"><el-card><div>总资产</div><h2>{{ total }}</h2></el-card></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-card><div>资产数量</div><h2>{{ assetsCount }}</h2></el-card></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-card><div>30天到期</div><h2>{{ expiringCount }}</h2></el-card></el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
13
web/frontend/src/main.ts
Normal file
13
web/frontend/src/main.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
app.mount('#app')
|
||||
31
web/frontend/src/router/index.ts
Normal file
31
web/frontend/src/router/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
const Login = () => import('../views/Login.vue')
|
||||
const PublicRecords = () => import('../views/PublicRecords.vue')
|
||||
const Dashboard = () => import('../views/Dashboard.vue')
|
||||
const Assets = () => import('../views/Assets.vue')
|
||||
const Categories = () => import('../views/Categories.vue')
|
||||
const Reminders = () => import('../views/Reminders.vue')
|
||||
const SessionExpired = () => import('../views/SessionExpired.vue')
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: PublicRecords },
|
||||
{ path: '/login', component: Login },
|
||||
{ path: '/session-expired', component: SessionExpired },
|
||||
{ path: '/app', component: Dashboard, meta: { requiresAuth: true } },
|
||||
{ path: '/assets', component: Assets, meta: { requiresAuth: true } },
|
||||
{ path: '/categories', component: Categories, meta: { requiresAuth: true } },
|
||||
{ path: '/reminders', component: Reminders, meta: { requiresAuth: true } },
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const auth = useAuthStore()
|
||||
if (to.meta.requiresAuth && !auth.token) return '/login'
|
||||
if (to.path === '/login' && auth.token) return '/app'
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
22
web/frontend/src/stores/assets.ts
Normal file
22
web/frontend/src/stores/assets.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { createAsset, deleteAsset, listAssets, updateAsset } from '../api/assets'
|
||||
|
||||
export const useAssetsStore = defineStore('assets', {
|
||||
state: () => ({
|
||||
list: [] as any[],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
status: '' as '' | 'active' | 'inactive',
|
||||
}),
|
||||
actions: {
|
||||
async fetch() {
|
||||
const data = await listAssets({ page: this.page, page_size: this.page_size, status: this.status || undefined })
|
||||
this.list = data.data || []
|
||||
this.total = data.total || 0
|
||||
},
|
||||
async create(payload: any) { await createAsset(payload); await this.fetch() },
|
||||
async update(id: number, payload: any) { await updateAsset(id, payload); await this.fetch() },
|
||||
async remove(id: number) { await deleteAsset(id); await this.fetch() },
|
||||
},
|
||||
})
|
||||
19
web/frontend/src/stores/auth.ts
Normal file
19
web/frontend/src/stores/auth.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { loginApi } from '../api/auth'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
token: localStorage.getItem('asset_tracker_token') || '',
|
||||
}),
|
||||
actions: {
|
||||
async login(username: string, password: string) {
|
||||
const data = await loginApi(username, password)
|
||||
this.token = data.access_token
|
||||
localStorage.setItem('asset_tracker_token', this.token)
|
||||
},
|
||||
logout() {
|
||||
this.token = ''
|
||||
localStorage.removeItem('asset_tracker_token')
|
||||
},
|
||||
},
|
||||
})
|
||||
18
web/frontend/src/stores/dashboard.ts
Normal file
18
web/frontend/src/stores/dashboard.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { dashboardSummary } from '../api/assets'
|
||||
|
||||
export const useDashboardStore = defineStore('dashboard', {
|
||||
state: () => ({
|
||||
total_assets_value: 0,
|
||||
by_category: [] as any[],
|
||||
expiring_in_30_days: [] as any[],
|
||||
}),
|
||||
actions: {
|
||||
async fetch() {
|
||||
const data = await dashboardSummary()
|
||||
this.total_assets_value = data.total_assets_value || 0
|
||||
this.by_category = data.by_category || []
|
||||
this.expiring_in_30_days = data.expiring_in_30_days || []
|
||||
},
|
||||
},
|
||||
})
|
||||
3
web/frontend/src/style.css
Normal file
3
web/frontend/src/style.css
Normal file
@@ -0,0 +1,3 @@
|
||||
body { margin: 0; background: #f5f7fb; font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
|
||||
.page { max-width: 1200px; margin: 16px auto; padding: 0 12px; }
|
||||
.card { background: white; border-radius: 12px; padding: 12px; box-shadow: 0 1px 6px rgba(0,0,0,.06); margin-bottom: 12px; }
|
||||
18
web/frontend/src/utils/errors.ts
Normal file
18
web/frontend/src/utils/errors.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export function getErrorMessage(err: any, fallback = '请求失败,请稍后重试') {
|
||||
const status = err?.response?.status
|
||||
const serverMsg = err?.response?.data?.message
|
||||
if (serverMsg && typeof serverMsg === 'string') {
|
||||
const rid = err?.response?.data?.request_id
|
||||
return rid ? `${serverMsg}(请求编号: ${rid})` : serverMsg
|
||||
}
|
||||
|
||||
let msg = fallback
|
||||
if (status === 400) msg = '请求参数有误,请检查后重试'
|
||||
else if (status === 401) msg = '登录状态已失效,请重新登录'
|
||||
else if (status === 403) msg = '你没有权限执行该操作'
|
||||
else if (status === 404) msg = '请求资源不存在'
|
||||
else if (status >= 500) msg = '服务暂时不可用,请稍后重试'
|
||||
|
||||
const rid = err?.response?.data?.request_id || err?.response?.headers?.['x-request-id']
|
||||
return rid ? `${msg}(请求编号: ${rid})` : msg
|
||||
}
|
||||
144
web/frontend/src/views/Assets.vue
Normal file
144
web/frontend/src/views/Assets.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import AppNav from '../components/AppNav.vue'
|
||||
import AssetFormDialog from '../components/AssetFormDialog.vue'
|
||||
import ExpiryBadge from '../components/ExpiryBadge.vue'
|
||||
import PageState from '../components/PageState.vue'
|
||||
import { useAssetsStore } from '../stores/assets'
|
||||
import { listCategories } from '../api/categories'
|
||||
import { getErrorMessage } from '../utils/errors'
|
||||
|
||||
const assets = useAssetsStore()
|
||||
const categories = ref<any[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const editing = ref<any>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const keyword = ref('')
|
||||
let kwTimer: any = null
|
||||
|
||||
async function loadCategories() {
|
||||
const data = await listCategories()
|
||||
categories.value = data.data || []
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await Promise.all([assets.fetch(), loadCategories()])
|
||||
} catch (e: any) {
|
||||
error.value = getErrorMessage(e, '资产数据加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: any) {
|
||||
editing.value = row
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function save(payload: any) {
|
||||
try {
|
||||
if (editing.value) await assets.update(editing.value.id, payload)
|
||||
else await assets.create(payload)
|
||||
dialogVisible.value = false
|
||||
ElMessage.success('保存成功')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(getErrorMessage(e, '保存失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
await ElMessageBox.confirm('确认删除该资产?', '提示', { type: 'warning' })
|
||||
await assets.remove(id)
|
||||
ElMessage.success('删除成功')
|
||||
}
|
||||
|
||||
const categoryMap = computed(() => Object.fromEntries(categories.value.map((x) => [x.id, x.name])))
|
||||
const filteredList = computed(() => {
|
||||
const kw = keyword.value.trim().toLowerCase()
|
||||
if (!kw) return assets.list
|
||||
return (assets.list || []).filter((x: any) => {
|
||||
return String(x.name || '').toLowerCase().includes(kw) || String(categoryMap.value[x.category_id] || '').toLowerCase().includes(kw)
|
||||
})
|
||||
})
|
||||
|
||||
function onKeywordInput() {
|
||||
if (kwTimer) clearTimeout(kwTimer)
|
||||
kwTimer = setTimeout(() => {
|
||||
// local filter only; reserved for server-side keyword later
|
||||
}, 300)
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppNav />
|
||||
<el-card class="card">
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-select v-model="assets.status" placeholder="状态筛选" clearable @change="assets.fetch" style="width:100%">
|
||||
<el-option label="active" value="active" />
|
||||
<el-option label="inactive" value="inactive" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-input v-model="keyword" placeholder="按资产名/分类搜索" clearable @input="onKeywordInput" />
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-button type="primary" @click="openCreate">新增资产</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<el-card class="card">
|
||||
<PageState :loading="loading" :error="error" :empty="!loading && !error && filteredList.length===0" empty-text="当前没有资产记录">
|
||||
<template #retry><el-button @click="load">重试</el-button></template>
|
||||
<el-table :data="filteredList">
|
||||
<el-table-column prop="name" label="名称" min-width="120" />
|
||||
<el-table-column label="分类" min-width="120">
|
||||
<template #default="scope">{{ categoryMap[scope.row.category_id] || scope.row.category_id }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="估值" min-width="120">
|
||||
<template #default="scope">{{ scope.row.total_value }} {{ scope.row.currency }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="到期" min-width="180">
|
||||
<template #default="scope">
|
||||
<div>{{ scope.row.expiry_date || '-' }}</div>
|
||||
<ExpiryBadge :expiry-date="scope.row.expiry_date" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100" />
|
||||
<el-table-column label="操作" width="160">
|
||||
<template #default="scope">
|
||||
<el-button link type="primary" @click="openEdit(scope.row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="remove(scope.row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div style="margin-top:12px;display:flex;justify-content:flex-end;">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, total"
|
||||
:total="assets.total"
|
||||
:page-size="assets.page_size"
|
||||
:current-page="assets.page"
|
||||
@current-change="(p:number)=>{assets.page=p;assets.fetch()}"
|
||||
/>
|
||||
</div>
|
||||
</PageState>
|
||||
</el-card>
|
||||
|
||||
<AssetFormDialog v-model="dialogVisible" :editing="editing" :categories="categories" @submit="save" />
|
||||
</div>
|
||||
</template>
|
||||
65
web/frontend/src/views/Categories.vue
Normal file
65
web/frontend/src/views/Categories.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppNav from '../components/AppNav.vue'
|
||||
import PageState from '../components/PageState.vue'
|
||||
import { createCategory, listCategories } from '../api/categories'
|
||||
import { getErrorMessage } from '../utils/errors'
|
||||
|
||||
const rows = ref<any[]>([])
|
||||
const form = reactive<{ name: string; type: 'real' | 'digital' }>({ name: '', type: 'digital' })
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await listCategories()
|
||||
rows.value = data.data || []
|
||||
} catch (e: any) {
|
||||
error.value = getErrorMessage(e, '分类数据加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.name.trim()) return ElMessage.error('分类名必填')
|
||||
try {
|
||||
await createCategory({ name: form.name.trim(), type: form.type })
|
||||
form.name = ''
|
||||
await load()
|
||||
ElMessage.success('新增成功')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(getErrorMessage(e, '新增失败'))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppNav />
|
||||
<el-card class="card">
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :sm="10"><el-input v-model="form.name" placeholder="分类名称" /></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-select v-model="form.type" style="width:100%"><el-option label="digital" value="digital" /><el-option label="real" value="real" /></el-select></el-col>
|
||||
<el-col :xs="24" :sm="6"><el-button type="primary" @click="submit">新增分类</el-button></el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<el-card class="card">
|
||||
<PageState :loading="loading" :error="error" :empty="!loading && !error && rows.length===0" empty-text="当前没有分类记录">
|
||||
<template #retry><el-button @click="load">重试</el-button></template>
|
||||
<el-table :data="rows">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="分类名" min-width="140" />
|
||||
<el-table-column prop="type" label="类型" width="120" />
|
||||
<el-table-column prop="created_at" label="创建时间" min-width="180" />
|
||||
</el-table>
|
||||
</PageState>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
64
web/frontend/src/views/Dashboard.vue
Normal file
64
web/frontend/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { useDashboardStore } from '../stores/dashboard'
|
||||
import SummaryCards from '../components/SummaryCards.vue'
|
||||
import AppNav from '../components/AppNav.vue'
|
||||
|
||||
const dashboard = useDashboardStore()
|
||||
const chartEl = ref<HTMLDivElement>()
|
||||
let chart: echarts.ECharts | null = null
|
||||
|
||||
function renderChart() {
|
||||
if (!chartEl.value) return
|
||||
if (!chart) chart = echarts.init(chartEl.value)
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'item' },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
data: (dashboard.by_category || []).map((x: any) => ({ name: x.category_name || '未分类', value: x.total_value })),
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
async function load() {
|
||||
await dashboard.fetch()
|
||||
await nextTick()
|
||||
renderChart()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppNav />
|
||||
<SummaryCards
|
||||
:total="dashboard.total_assets_value"
|
||||
:assets-count="dashboard.by_category.length"
|
||||
:expiring-count="dashboard.expiring_in_30_days.length"
|
||||
/>
|
||||
|
||||
<el-row :gutter="12" style="margin-top: 12px;">
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-card class="card">
|
||||
<template #header>分类占比</template>
|
||||
<div ref="chartEl" style="height:320px"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-card class="card">
|
||||
<template #header>30天到期 Top10</template>
|
||||
<el-table :data="dashboard.expiring_in_30_days.slice(0,10)" size="small">
|
||||
<el-table-column prop="name" label="名称" min-width="120" />
|
||||
<el-table-column prop="expiry_date" label="到期日" min-width="180" />
|
||||
<el-table-column label="金额" min-width="110">
|
||||
<template #default="scope">{{ scope.row.total_value }} {{ scope.row.currency }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
38
web/frontend/src/views/Login.vue
Normal file
38
web/frontend/src/views/Login.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getErrorMessage } from '../utils/errors'
|
||||
|
||||
const form = reactive({ username: '', password: '' })
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
|
||||
async function submit() {
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(form.username, form.password)
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/app')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(getErrorMessage(e, '登录失败,请检查账号密码'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page" style="max-width: 420px; margin-top: 80px;">
|
||||
<el-card class="card">
|
||||
<h2>登录</h2>
|
||||
<el-form @submit.prevent="submit" label-width="80px">
|
||||
<el-form-item label="用户名"><el-input v-model="form.username" /></el-form-item>
|
||||
<el-form-item label="密码"><el-input v-model="form.password" show-password type="password" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" :loading="loading" @click="submit" style="width:100%">登录</el-button></el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
62
web/frontend/src/views/PublicRecords.vue
Normal file
62
web/frontend/src/views/PublicRecords.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import AppNav from '../components/AppNav.vue'
|
||||
import PageState from '../components/PageState.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const summary = ref<any>({})
|
||||
const records = ref<any[]>([])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await fetch('/public/records')
|
||||
if (!res.ok) throw new Error('请求失败')
|
||||
const data = await res.json()
|
||||
summary.value = data.summary || {}
|
||||
records.value = data.records || []
|
||||
} catch {
|
||||
error.value = '公开记录加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppNav />
|
||||
<el-card class="card">
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<span>已记录内容(公开只读)</span>
|
||||
<el-button @click="load">刷新</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<PageState :loading="loading" :error="error" :empty="!loading && !error && records.length===0" empty-text="当前 0 条记录。请先到 /app 添加资产">
|
||||
<template #retry><el-button @click="load">重试</el-button></template>
|
||||
<el-row :gutter="12" style="margin-bottom:12px;">
|
||||
<el-col :xs="24" :sm="8"><el-statistic title="活跃资产" :value="summary.active_asset_count || 0" /></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-statistic title="总资产值" :value="summary.total_assets_value || 0" /></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-statistic title="记录数" :value="records.length" /></el-col>
|
||||
</el-row>
|
||||
|
||||
<el-table :data="records" style="width:100%">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="名称" min-width="140" />
|
||||
<el-table-column prop="category_name" label="分类" min-width="120" />
|
||||
<el-table-column label="金额" min-width="120">
|
||||
<template #default="scope">{{ scope.row.total_value }} {{ scope.row.currency }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100" />
|
||||
<el-table-column prop="expiry_date" label="到期日" min-width="180" />
|
||||
</el-table>
|
||||
</PageState>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
79
web/frontend/src/views/Reminders.vue
Normal file
79
web/frontend/src/views/Reminders.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import AppNav from '../components/AppNav.vue'
|
||||
import PageState from '../components/PageState.vue'
|
||||
import { listReminders } from '../api/reminders'
|
||||
import { getErrorMessage } from '../utils/errors'
|
||||
|
||||
const status = ref('pending')
|
||||
const rows = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
function onTabChange() {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await listReminders({ status: status.value || undefined, page: page.value, page_size: pageSize.value })
|
||||
rows.value = data.data || []
|
||||
total.value = data.total || 0
|
||||
} catch (e: any) {
|
||||
error.value = getErrorMessage(e, '提醒数据加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppNav />
|
||||
<el-card class="card">
|
||||
<el-tabs v-model="status" @tab-change="onTabChange">
|
||||
<el-tab-pane label="待处理" name="pending" />
|
||||
<el-tab-pane label="发送中" name="sending" />
|
||||
<el-tab-pane label="已发送" name="sent" />
|
||||
<el-tab-pane label="失败" name="failed" />
|
||||
</el-tabs>
|
||||
|
||||
<PageState :loading="loading" :error="error" :empty="!loading && !error && rows.length===0" empty-text="当前没有提醒记录">
|
||||
<template #retry><el-button @click="load">重试</el-button></template>
|
||||
<el-table :data="rows">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="asset_name" label="资产" min-width="140" />
|
||||
<el-table-column prop="status" label="状态" width="100" />
|
||||
<el-table-column prop="remind_at" label="提醒时间" min-width="180" />
|
||||
<el-table-column prop="next_retry_at" label="下次重试" min-width="180" />
|
||||
<el-table-column prop="retry_count" label="重试" width="80" />
|
||||
<el-table-column label="错误信息" min-width="240">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.last_error" type="danger" size="small">{{ scope.row.last_error }}</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div style="margin-top:12px;display:flex;justify-content:flex-end;">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, total"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:current-page="page"
|
||||
@current-change="(p:number)=>{page=p;load()}"
|
||||
/>
|
||||
</div>
|
||||
</PageState>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
9
web/frontend/src/views/SessionExpired.vue
Normal file
9
web/frontend/src/views/SessionExpired.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="page" style="max-width: 520px; margin-top: 80px;">
|
||||
<el-card class="card">
|
||||
<h2>会话已过期</h2>
|
||||
<p>登录状态失效,请重新登录后继续操作。</p>
|
||||
<el-button type="primary" @click="$router.push('/login')">去登录</el-button>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
16
web/frontend/tsconfig.app.json
Normal file
16
web/frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
7
web/frontend/tsconfig.json
Normal file
7
web/frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
web/frontend/tsconfig.node.json
Normal file
26
web/frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
10
web/frontend/vite.config.ts
Normal file
10
web/frontend/vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: '/',
|
||||
build: {
|
||||
assetsDir: '_assets',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user