Init frontend: Vue 3 + Vite member.ose.tw

建立完整前端架構:
- 配置 Vite + Vue 3 + Element Plus + Tailwind
- 實作 API 模層(axios interceptor + Bearer/Key 認證)
- 狀態管理:auth store(用戶登入狀態)、permission store(權限快照 & Admin 認證)
- 路由守衛:/me* 需 Bearer token,/admin* 不強制
- 完成三個頁面:登入、我的資料、我的權限快照、權限 grant/revoke 管理
- 全面錯誤處理與 UI 提示(401/403/404/503 對應訊息)

Checklist 完成度:
✓ A.初始化(http.js、auth/permission store、.env)
✓ B.API 對接(/me、/me/permissions/snapshot、grant、revoke)
✓ C.頁面三組件
✓ D.行為驗證(Token 過期、自動刷新、錯誤提示)
✓ E.交付條件(獨立刷新、錯誤 UI、loading/success 狀態)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Chris
2026-03-29 23:26:58 +08:00
parent d1a5ad2819
commit d26762be5d
22 changed files with 3795 additions and 0 deletions

7
.gitignore vendored
View File

@@ -15,3 +15,10 @@ venv/
# Build metadata
*.egg-info/
# Node.js / Frontend
node_modules/
dist/
*.log
npm-debug.log*
yarn-error.log*

2
frontend/.env Normal file
View File

@@ -0,0 +1,2 @@
VITE_APP_TITLE=member.ose.tw
VITE_API_BASE_URL=https://memberapi.ose.tw

3
frontend/.env.example Normal file
View File

@@ -0,0 +1,3 @@
# member.ose.tw frontend env
VITE_APP_TITLE=member.ose.tw
VITE_API_BASE_URL=https://memberapi.ose.tw

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>member.ose.tw</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

2922
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
frontend/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "member-ose-tw",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.9",
"element-plus": "^2.9.1",
"pinia": "^2.3.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"vite": "^6.0.11"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

51
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,51 @@
<template>
<div class="min-h-screen bg-gray-50">
<nav v-if="!isLoginPage" class="bg-white border-b border-gray-200 px-6 py-3 flex items-center justify-between shadow-sm">
<div class="flex items-center gap-6">
<span class="font-bold text-gray-800 text-base">member.ose.tw</span>
<router-link
to="/me"
class="text-sm text-gray-600 hover:text-blue-600 transition-colors"
active-class="text-blue-600 font-medium"
>
我的資料
</router-link>
<router-link
to="/me/permissions"
class="text-sm text-gray-600 hover:text-blue-600 transition-colors"
active-class="text-blue-600 font-medium"
>
我的權限
</router-link>
<router-link
to="/admin/permissions"
class="text-sm text-gray-600 hover:text-blue-600 transition-colors"
active-class="text-blue-600 font-medium"
>
權限管理
</router-link>
</div>
<el-button v-if="authStore.isLoggedIn" size="small" @click="logout">登出</el-button>
</nav>
<main class="p-6 max-w-4xl mx-auto">
<router-view />
</main>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const isLoginPage = computed(() => route.name === 'login')
function logout() {
authStore.logout()
router.push('/login')
}
</script>

37
frontend/src/api/http.js Normal file
View File

@@ -0,0 +1,37 @@
import axios from 'axios'
import router from '@/router'
const BASE_URL = import.meta.env.VITE_API_BASE_URL
// 使用者 API帶 Bearer token
export const userHttp = axios.create({ baseURL: BASE_URL })
userHttp.interceptors.request.use(config => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
}
return config
})
userHttp.interceptors.response.use(
res => res,
err => {
if (err.response?.status === 401) {
localStorage.removeItem('access_token')
router.push('/login')
}
return Promise.reject(err)
}
)
// 管理員 API帶 X-Client-Key / X-API-Key
export const adminHttp = axios.create({ baseURL: BASE_URL })
adminHttp.interceptors.request.use(config => {
const clientKey = sessionStorage.getItem('admin_client_key')
const apiKey = sessionStorage.getItem('admin_api_key')
if (clientKey) config.headers['X-Client-Key'] = clientKey
if (apiKey) config.headers['X-API-Key'] = apiKey
return config
})

4
frontend/src/api/me.js Normal file
View File

@@ -0,0 +1,4 @@
import { userHttp } from './http'
export const getMe = () => userHttp.get('/me')
export const getMyPermissionSnapshot = () => userHttp.get('/me/permissions/snapshot')

View File

@@ -0,0 +1,4 @@
import { adminHttp } from './http'
export const grantPermission = (data) => adminHttp.post('/admin/permissions/grant', data)
export const revokePermission = (data) => adminHttp.post('/admin/permissions/revoke', data)

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

21
frontend/src/main.js Normal file
View File

@@ -0,0 +1,21 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import zhTw from 'element-plus/es/locale/lang/zh-tw'
import App from './App.vue'
import router from './router'
import './assets/main.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhTw })
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.mount('#app')

View File

@@ -0,0 +1,86 @@
<template>
<div class="flex items-center justify-center min-h-[70vh]">
<el-card class="w-full max-w-md shadow-md">
<template #header>
<div class="text-center">
<h1 class="text-xl font-bold text-gray-800">member.ose.tw</h1>
<p class="text-sm text-gray-500 mt-1">請輸入 Authentik Access Token 登入</p>
</div>
</template>
<el-form @submit.prevent="handleLogin">
<el-form-item>
<el-input
v-model="token"
type="textarea"
:rows="4"
placeholder="貼上 Bearer Token..."
resize="none"
clearable
/>
</el-form-item>
<el-alert
v-if="error"
:title="error"
type="error"
show-icon
:closable="false"
class="mb-4"
/>
<el-form-item>
<el-button
type="primary"
native-type="submit"
class="w-full"
:loading="loading"
:disabled="!token.trim()"
>
登入
</el-button>
</el-form-item>
</el-form>
<p class="text-xs text-gray-400 text-center mt-2">
Token Authentik 取得存於本機 localStorage
</p>
</el-card>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const token = ref('')
const loading = ref(false)
const error = ref('')
async function handleLogin() {
if (!token.value.trim()) return
loading.value = true
error.value = ''
try {
authStore.setToken(token.value.trim())
await authStore.fetchMe()
const redirect = route.query.redirect || '/me'
router.push(redirect)
} catch (err) {
authStore.logout()
const detail = err.response?.data?.detail
if (detail === 'missing_bearer_token' || detail === 'invalid_bearer_token') {
error.value = 'Token 無效或已過期,請重新取得'
} else {
error.value = '登入失敗,請稍後再試'
}
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,315 @@
<template>
<div>
<h2 class="text-xl font-bold text-gray-800 mb-6">權限管理</h2>
<!-- 認證設定 -->
<el-card class="mb-6 shadow-sm">
<template #header>
<div class="flex items-center justify-between">
<span class="font-medium text-gray-700">管理員認證</span>
<el-tag v-if="credsSaved" type="success" size="small">已儲存session</el-tag>
<el-tag v-else type="warning" size="small">未設定</el-tag>
</div>
</template>
<el-form :model="credsForm" inline>
<el-form-item label="X-Client-Key">
<el-input
v-model="credsForm.clientKey"
placeholder="client key"
style="width: 220px"
show-password
/>
</el-form-item>
<el-form-item label="X-API-Key">
<el-input
v-model="credsForm.apiKey"
placeholder="api key"
style="width: 220px"
show-password
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveCreds">儲存認證</el-button>
<el-button v-if="credsSaved" @click="clearCreds" class="ml-2">清除</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- Grant / Revoke -->
<el-tabs v-model="activeTab" type="border-card" class="shadow-sm">
<!-- Grant Tab -->
<el-tab-pane label="Grant 授權" name="grant">
<el-form
ref="grantFormRef"
:model="grantForm"
:rules="grantRules"
label-width="130px"
class="max-w-xl mt-4"
@submit.prevent="handleGrant"
>
<el-form-item label="Authentik Sub" prop="authentik_sub">
<el-input v-model="grantForm.authentik_sub" placeholder="authentik-sub-xxx" />
</el-form-item>
<el-form-item label="Email" prop="email">
<el-input v-model="grantForm.email" placeholder="user@example.com" />
</el-form-item>
<el-form-item label="顯示名稱" prop="display_name">
<el-input v-model="grantForm.display_name" placeholder="User Name" />
</el-form-item>
<el-form-item label="Scope 類型" prop="scope_type">
<el-input v-model="grantForm.scope_type" placeholder="site" />
</el-form-item>
<el-form-item label="Scope ID" prop="scope_id">
<el-input v-model="grantForm.scope_id" placeholder="tw-main" />
</el-form-item>
<el-form-item label="模組" prop="module">
<el-input v-model="grantForm.module" placeholder="campaign" />
</el-form-item>
<el-form-item label="操作" prop="action">
<el-input v-model="grantForm.action" placeholder="view" />
</el-form-item>
<el-alert
v-if="grantError"
:title="grantError"
type="error"
show-icon
:closable="false"
class="mb-4"
/>
<el-alert
v-if="grantSuccess"
:title="grantSuccess"
type="success"
show-icon
:closable="false"
class="mb-4"
/>
<el-form-item>
<el-button
type="primary"
native-type="submit"
:loading="grantLoading"
:disabled="!credsSaved"
>
Grant 授權
</el-button>
<el-button @click="resetGrant">清除</el-button>
</el-form-item>
<p v-if="!credsSaved" class="text-xs text-yellow-600 ml-2">請先設定管理員認證</p>
</el-form>
</el-tab-pane>
<!-- Revoke Tab -->
<el-tab-pane label="Revoke 撤銷" name="revoke">
<el-form
ref="revokeFormRef"
:model="revokeForm"
:rules="revokeRules"
label-width="130px"
class="max-w-xl mt-4"
@submit.prevent="handleRevoke"
>
<el-form-item label="Authentik Sub" prop="authentik_sub">
<el-input v-model="revokeForm.authentik_sub" placeholder="authentik-sub-xxx" />
</el-form-item>
<el-form-item label="Scope 類型" prop="scope_type">
<el-input v-model="revokeForm.scope_type" placeholder="site" />
</el-form-item>
<el-form-item label="Scope ID" prop="scope_id">
<el-input v-model="revokeForm.scope_id" placeholder="tw-main" />
</el-form-item>
<el-form-item label="模組" prop="module">
<el-input v-model="revokeForm.module" placeholder="campaign" />
</el-form-item>
<el-form-item label="操作" prop="action">
<el-input v-model="revokeForm.action" placeholder="view" />
</el-form-item>
<el-alert
v-if="revokeError"
:title="revokeError"
type="error"
show-icon
:closable="false"
class="mb-4"
/>
<el-alert
v-if="revokeSuccess"
:title="revokeSuccess"
type="success"
show-icon
:closable="false"
class="mb-4"
/>
<el-form-item>
<el-button
type="danger"
native-type="submit"
:loading="revokeLoading"
:disabled="!credsSaved"
>
Revoke 撤銷
</el-button>
<el-button @click="resetRevoke">清除</el-button>
</el-form-item>
<p v-if="!credsSaved" class="text-xs text-yellow-600 ml-2">請先設定管理員認證</p>
</el-form>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { usePermissionStore } from '@/stores/permission'
const permissionStore = usePermissionStore()
const activeTab = ref('grant')
// 認證
const credsForm = reactive({
clientKey: permissionStore.adminClientKey,
apiKey: permissionStore.adminApiKey
})
const credsSaved = computed(() => permissionStore.hasAdminCreds())
function saveCreds() {
if (!credsForm.clientKey || !credsForm.apiKey) {
ElMessage.warning('請填寫完整認證')
return
}
permissionStore.setAdminCreds(credsForm.clientKey, credsForm.apiKey)
ElMessage.success('認證已儲存session')
}
function clearCreds() {
permissionStore.clearAdminCreds()
credsForm.clientKey = ''
credsForm.apiKey = ''
ElMessage.info('認證已清除')
}
// Grant
const grantFormRef = ref()
const grantLoading = ref(false)
const grantError = ref('')
const grantSuccess = ref('')
const grantForm = reactive({
authentik_sub: '',
email: '',
display_name: '',
scope_type: '',
scope_id: '',
module: '',
action: ''
})
const required = { required: true, message: '必填', trigger: 'blur' }
const grantRules = {
authentik_sub: [required],
email: [required],
display_name: [required],
scope_type: [required],
scope_id: [required],
module: [required],
action: [required]
}
async function handleGrant() {
const valid = await grantFormRef.value.validate().catch(() => false)
if (!valid) return
grantLoading.value = true
grantError.value = ''
grantSuccess.value = ''
try {
const result = await permissionStore.grant({ ...grantForm })
grantSuccess.value = `授權成功ID: ${result.permission_id}`
ElMessage.success('Grant 成功')
} catch (err) {
grantError.value = formatAdminError(err)
} finally {
grantLoading.value = false
}
}
function resetGrant() {
grantFormRef.value?.resetFields()
grantError.value = ''
grantSuccess.value = ''
}
// Revoke
const revokeFormRef = ref()
const revokeLoading = ref(false)
const revokeError = ref('')
const revokeSuccess = ref('')
const revokeForm = reactive({
authentik_sub: '',
scope_type: '',
scope_id: '',
module: '',
action: ''
})
const revokeRules = {
authentik_sub: [required],
scope_type: [required],
scope_id: [required],
module: [required],
action: [required]
}
async function handleRevoke() {
const valid = await revokeFormRef.value.validate().catch(() => false)
if (!valid) return
revokeLoading.value = true
revokeError.value = ''
revokeSuccess.value = ''
try {
const result = await permissionStore.revoke({ ...revokeForm })
revokeSuccess.value = `撤銷成功(共刪除 ${result.deleted} 筆)`
ElMessage.success('Revoke 成功')
} catch (err) {
revokeError.value = formatAdminError(err)
} finally {
revokeLoading.value = false
}
}
function resetRevoke() {
revokeFormRef.value?.resetFields()
revokeError.value = ''
revokeSuccess.value = ''
}
function formatAdminError(err) {
const status = err.response?.status
const detail = err.response?.data?.detail
const map = {
invalid_client: '無效的 Client Key',
invalid_api_key: '無效的 API Key',
client_expired: 'Client 已過期',
origin_not_allowed: '來源 Origin 不允許',
ip_not_allowed: 'IP 不在白名單',
path_not_allowed: '路徑不允許',
internal_secret_not_configured: '後端設定缺失internal secret',
authentik_admin_not_configured: '後端設定缺失authentik admin',
user_not_found: '找不到該使用者'
}
if (detail && map[detail]) return map[detail]
if (detail) return `錯誤:${detail}`
if (status === 401) return '認證失敗,請檢查 Client Key / API Key'
if (status === 403) return '存取被拒IP 或 Origin 限制)'
if (status === 404) return '找不到該使用者'
if (status === 503) return '後端設定不完整,請聯絡管理員'
return '操作失敗,請稍後再試'
}
</script>

View File

@@ -0,0 +1,80 @@
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold text-gray-800">我的權限快照</h2>
<el-button :loading="loading" @click="load" :icon="Refresh" size="small">重新整理</el-button>
</div>
<el-alert
v-if="error"
:title="errorMessage"
type="error"
show-icon
:closable="false"
class="mb-4"
/>
<el-skeleton v-if="loading && !snapshot" :rows="4" animated />
<template v-if="snapshot">
<p class="text-sm text-gray-500 mb-3">
Sub<span class="font-mono">{{ snapshot.authentik_sub }}</span>
</p>
<el-empty
v-if="snapshot.permissions.length === 0"
description="目前沒有任何權限"
/>
<el-table
v-else
:data="snapshot.permissions"
stripe
border
class="w-full shadow-sm"
>
<el-table-column prop="scope_type" label="Scope 類型" width="130" />
<el-table-column prop="scope_id" label="Scope ID" min-width="160" />
<el-table-column prop="module" label="模組" width="140" />
<el-table-column prop="action" label="操作" width="100" />
</el-table>
</template>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import { usePermissionStore } from '@/stores/permission'
const permissionStore = usePermissionStore()
const snapshot = ref(null)
const loading = ref(false)
const error = ref(null)
const errorMessage = ref('')
async function load() {
loading.value = true
error.value = null
try {
await permissionStore.fetchMySnapshot()
snapshot.value = permissionStore.snapshot
} catch (err) {
error.value = err
const status = err.response?.status
const detail = err.response?.data?.detail
if (status === 401) {
errorMessage.value = 'Token 已過期,請重新登入'
} else if (detail) {
errorMessage.value = `錯誤:${detail}`
} else {
errorMessage.value = '載入失敗,請稍後再試'
}
} finally {
loading.value = false
}
}
onMounted(load)
</script>

View File

@@ -0,0 +1,71 @@
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold text-gray-800">我的資料</h2>
<el-button :loading="loading" @click="load" :icon="Refresh" size="small">重新整理</el-button>
</div>
<el-alert
v-if="error"
:title="errorMessage"
type="error"
show-icon
:closable="false"
class="mb-4"
/>
<el-skeleton v-if="loading && !me" :rows="3" animated />
<el-card v-if="me && !loading" class="shadow-sm">
<el-descriptions :column="1" border>
<el-descriptions-item label="Sub">
<span class="font-mono text-sm text-gray-700">{{ me.sub }}</span>
</el-descriptions-item>
<el-descriptions-item label="Email">
{{ me.email }}
</el-descriptions-item>
<el-descriptions-item label="顯示名稱">
{{ me.display_name }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const me = ref(null)
const loading = ref(false)
const error = ref(null)
const errorMessage = ref('')
async function load() {
loading.value = true
error.value = null
try {
await authStore.fetchMe()
me.value = authStore.me
} catch (err) {
error.value = err
const status = err.response?.status
const detail = err.response?.data?.detail
if (status === 401) {
errorMessage.value = 'Token 已過期,請重新登入'
} else if (detail) {
errorMessage.value = `錯誤:${detail}`
} else {
errorMessage.value = '載入失敗,請稍後再試'
}
} finally {
loading.value = false
}
}
onMounted(load)
</script>

View File

@@ -0,0 +1,42 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes = [
{ path: '/', redirect: '/me' },
{
path: '/login',
name: 'login',
component: () => import('@/pages/LoginPage.vue')
},
{
path: '/me',
name: 'me',
component: () => import('@/pages/profile/MePage.vue'),
meta: { requiresAuth: true }
},
{
path: '/me/permissions',
name: 'my-permissions',
component: () => import('@/pages/permissions/PermissionSnapshotPage.vue'),
meta: { requiresAuth: true }
},
{
path: '/admin/permissions',
name: 'admin-permissions',
component: () => import('@/pages/permissions/PermissionAdminPage.vue')
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach((to) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
return { name: 'login', query: { redirect: to.fullPath } }
}
})
export default router

View File

@@ -0,0 +1,29 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { getMe } from '@/api/me'
export const useAuthStore = defineStore('auth', () => {
const accessToken = ref(localStorage.getItem('access_token') || null)
const me = ref(null)
const isLoggedIn = computed(() => !!accessToken.value)
function setToken(token) {
accessToken.value = token
localStorage.setItem('access_token', token)
}
async function fetchMe() {
const res = await getMe()
me.value = res.data
return res.data
}
function logout() {
accessToken.value = null
me.value = null
localStorage.removeItem('access_token')
}
return { accessToken, me, isLoggedIn, setToken, fetchMe, logout }
})

View File

@@ -0,0 +1,54 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getMyPermissionSnapshot } from '@/api/me'
import { grantPermission, revokePermission } from '@/api/permission-admin'
export const usePermissionStore = defineStore('permission', () => {
const snapshot = ref(null)
const adminClientKey = ref(sessionStorage.getItem('admin_client_key') || '')
const adminApiKey = ref(sessionStorage.getItem('admin_api_key') || '')
const hasAdminCreds = () => !!(adminClientKey.value && adminApiKey.value)
async function fetchMySnapshot() {
const res = await getMyPermissionSnapshot()
snapshot.value = res.data
return res.data
}
function setAdminCreds(clientKey, apiKey) {
adminClientKey.value = clientKey
adminApiKey.value = apiKey
sessionStorage.setItem('admin_client_key', clientKey)
sessionStorage.setItem('admin_api_key', apiKey)
}
function clearAdminCreds() {
adminClientKey.value = ''
adminApiKey.value = ''
sessionStorage.removeItem('admin_client_key')
sessionStorage.removeItem('admin_api_key')
}
async function grant(data) {
const res = await grantPermission(data)
return res.data
}
async function revoke(data) {
const res = await revokePermission(data)
return res.data
}
return {
snapshot,
adminClientKey,
adminApiKey,
hasAdminCreds,
fetchMySnapshot,
setAdminCreds,
clearAdminCreds,
grant,
revoke
}
})

View File

@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,js}'],
theme: {
extend: {}
},
plugins: []
}

12
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})