first commit
This commit is contained in:
commit
63e3dc85c5
44 changed files with 3509 additions and 0 deletions
7
.env
Normal file
7
.env
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
VITE_ADMIN_API_KEY=sk_a4008f0fe5771cdd6640f57be3809c2827d7e339589ce319e65f0021bba0e5a09290823a3620a894ff30b1bdc8adf75f
|
||||
VITE_API_BASE_URL=http://localhost:9310/api
|
||||
|
||||
|
||||
//live server
|
||||
# VITE_ADMIN_API_KEY=sk_deff77b01191f0e477a7f55e9e37f1c091e01445cd8495f1ed930a7ba6611508e751c5f8a9d469ec696fb0f87049ca36
|
||||
# VITE_API_BASE_URL=http://34.104.242.125:9310/api
|
||||
24
.gitignore
vendored
Normal file
24
.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?
|
||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
README.md
Normal file
5
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
index.html
Normal file
13
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="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>mm-admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1689
package-lock.json
generated
Normal file
1689
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
28
package.json
Normal file
28
package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "mm-admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/font": "^7.4.47",
|
||||
"axios": "^1.17.0",
|
||||
"router": "^2.2.0",
|
||||
"vue": "^3.5.34",
|
||||
"vue-router": "^4.6.4",
|
||||
"vuetify": "^4.1.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.3",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.12",
|
||||
"vue-tsc": "^3.2.8"
|
||||
}
|
||||
}
|
||||
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
public/icons.svg
Normal file
24
public/icons.svg
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
7
src/App.vue
Normal file
7
src/App.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
<AdminLayout />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AdminLayout from "@/components/common/AdminLayout.vue";
|
||||
</script>
|
||||
BIN
src/assets/hero.png
Normal file
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
src/assets/vite.svg
Normal file
1
src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
1
src/assets/vue.svg
Normal file
1
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 |
249
src/components/DashboardView.vue
Normal file
249
src/components/DashboardView.vue
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="text-h5 font-weight-bold mb-6">ダッシュボード</div>
|
||||
|
||||
<!-- 統計カード -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card rounded="lg" class="pa-4 text-center" elevation="1">
|
||||
<v-icon size="32" color="primary" class="mb-2">mdi-key-outline</v-icon>
|
||||
<div class="text-h4 font-weight-bold text-primary">{{ totalKeys }}</div>
|
||||
<div class="text-body-2 text-medium-emphasis mt-1">APIキー総数</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card rounded="lg" class="pa-4 text-center" elevation="1">
|
||||
<v-icon size="32" color="success" class="mb-2">mdi-check-circle-outline</v-icon>
|
||||
<div class="text-h4 font-weight-bold text-success">{{ activeKeys }}</div>
|
||||
<div class="text-body-2 text-medium-emphasis mt-1">有効なキー</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card
|
||||
rounded="lg"
|
||||
class="pa-4 text-center"
|
||||
elevation="1"
|
||||
style="cursor:pointer"
|
||||
@click="revokedKeyDialog = true"
|
||||
>
|
||||
<v-icon size="32" color="warning" class="mb-2">mdi-lock-outline</v-icon>
|
||||
<div class="text-h4 font-weight-bold text-warning">{{ revokedKeys }}</div>
|
||||
<div class="text-body-2 text-medium-emphasis mt-1">冻结中のキー</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card
|
||||
rounded="lg"
|
||||
class="pa-4 text-center"
|
||||
elevation="1"
|
||||
style="cursor:pointer"
|
||||
@click="errorLogDialog = true"
|
||||
>
|
||||
<v-icon size="32" color="error" class="mb-2">mdi-alert-circle-outline</v-icon>
|
||||
<div class="text-h4 font-weight-bold text-error">{{ errorLogs }}</div>
|
||||
<div class="text-body-2 text-medium-emphasis mt-1">エラーログ</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<!-- キー構成 -->
|
||||
<v-col cols="12" md="4">
|
||||
<v-card rounded="lg" elevation="1" height="100%">
|
||||
<v-card-title class="pa-4 text-subtitle-1 font-weight-bold">
|
||||
<v-icon class="mr-2">mdi-key-variant</v-icon>
|
||||
キー構成
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text class="pa-4">
|
||||
<div class="d-flex justify-space-between align-center mb-4">
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-icon color="primary" size="18">mdi-shield-account</v-icon>
|
||||
<span class="text-body-2">Admin キー</span>
|
||||
</div>
|
||||
<v-chip color="primary" size="small" variant="tonal">
|
||||
{{ adminKeys }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between align-center mb-4">
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-icon color="secondary" size="18">mdi-account</v-icon>
|
||||
<span class="text-body-2">User キー</span>
|
||||
</div>
|
||||
<v-chip color="secondary" size="small" variant="tonal">
|
||||
{{ userKeys }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between align-center">
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-icon color="warning" size="18">mdi-lock-outline</v-icon>
|
||||
<span class="text-body-2">冻结中</span>
|
||||
</div>
|
||||
<v-chip color="warning" size="small" variant="tonal">
|
||||
{{ revokedKeys }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- 最近のログ -->
|
||||
<v-col cols="12" md="8">
|
||||
<v-card rounded="lg" elevation="1">
|
||||
<v-card-title class="pa-4 text-subtitle-1 font-weight-bold">
|
||||
<v-icon class="mr-2">mdi-clock-outline</v-icon>
|
||||
最近のログ
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="log in recentLogs"
|
||||
:key="log.id"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-chip
|
||||
:color="log.statusCode < 400 ? 'success' : 'error'"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
class="mr-2"
|
||||
>
|
||||
{{ log.statusCode }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template #title>
|
||||
<span class="text-caption font-weight-medium">
|
||||
{{ log.requestType }} {{ log.endpoint }}
|
||||
</span>
|
||||
</template>
|
||||
<template #subtitle>
|
||||
<span class="text-caption text-medium-emphasis">
|
||||
{{ new Date(log.calledAt).toLocaleString() }}
|
||||
・{{ log.ipAddress ?? "IP不明" }}
|
||||
</span>
|
||||
</template>
|
||||
<template #append>
|
||||
<span class="text-caption text-medium-emphasis">
|
||||
{{ log.apiKeyId.slice(0, 8) }}...
|
||||
</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="recentLogs.length === 0">
|
||||
<v-list-item-title class="text-caption text-medium-emphasis">
|
||||
ログなし
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- エラーログ Dialog -->
|
||||
<v-dialog v-model="errorLogDialog" max-width="700">
|
||||
<v-card rounded="lg">
|
||||
<v-card-title class="pa-4 d-flex align-center ga-2">
|
||||
<v-icon color="error">mdi-alert-circle-outline</v-icon>
|
||||
エラーログ一覧
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text class="pa-0">
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="log in errorLogList"
|
||||
:key="log.id"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-chip color="error" size="x-small" variant="tonal" class="mr-2">
|
||||
{{ log.statusCode }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template #title>
|
||||
<span class="text-caption font-weight-medium">
|
||||
{{ log.requestType }} {{ log.endpoint }}
|
||||
</span>
|
||||
</template>
|
||||
<template #subtitle>
|
||||
<span class="text-caption">
|
||||
{{ new Date(log.calledAt).toLocaleString() }}
|
||||
・{{ log.ipAddress ?? "IP不明" }}
|
||||
</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="errorLogList.length === 0">
|
||||
<v-list-item-title class="text-caption text-medium-emphasis">
|
||||
エラーログなし
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
<v-divider />
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="errorLogDialog = false">閉じる</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 冻结キー Dialog -->
|
||||
<v-dialog v-model="revokedKeyDialog" max-width="600">
|
||||
<v-card rounded="lg">
|
||||
<v-card-title class="pa-4 d-flex align-center ga-2">
|
||||
<v-icon color="warning">mdi-lock-outline</v-icon>
|
||||
冻结中のキー一覧
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text class="pa-0">
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="key in revokedKeyList"
|
||||
:key="key.id"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-chip
|
||||
:color="key.role === 'admin' ? 'primary' : 'secondary'"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
class="mr-2"
|
||||
>
|
||||
{{ key.role }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template #title>
|
||||
<span class="text-caption font-weight-medium">
|
||||
{{ key.name }}
|
||||
</span>
|
||||
</template>
|
||||
<template #subtitle>
|
||||
<span class="text-caption text-medium-emphasis">
|
||||
{{ key.id }}
|
||||
</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="revokedKeyList.length === 0">
|
||||
<v-list-item-title class="text-caption text-medium-emphasis">
|
||||
冻结中のキーなし
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
<v-divider />
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="revokedKeyDialog = false">閉じる</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue";
|
||||
import {
|
||||
totalKeys, activeKeys, adminKeys, userKeys,
|
||||
revokedKeys, revokedKeyList,
|
||||
errorLogs, errorLogList, recentLogs,
|
||||
errorLogDialog, revokedKeyDialog,
|
||||
dashboardLogic,
|
||||
} from "@/logics/dashboard";
|
||||
|
||||
onMounted(dashboardLogic.fetch);
|
||||
</script>
|
||||
57
src/components/apikey/ApKeyActions.vue
Normal file
57
src/components/apikey/ApKeyActions.vue
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<template>
|
||||
<div class="d-flex ga-1">
|
||||
<v-btn
|
||||
v-if="isUserKey(apiKey)"
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="$emit('edit')"
|
||||
>
|
||||
<v-icon size="18">mdi-pencil-outline</v-icon>
|
||||
<v-tooltip activator="parent" location="top">編集</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<template v-if="isUserKey(apiKey)">
|
||||
<v-btn
|
||||
icon size="small"
|
||||
variant="text"
|
||||
@click="$emit('toggle-active')"
|
||||
>
|
||||
<v-icon
|
||||
size="18"
|
||||
:color="apiKey.isActive ? 'error' : 'success'"
|
||||
>
|
||||
{{ apiKey.isActive ? "mdi-lock-outline" : "mdi-lock-open-outline" }}
|
||||
</v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
{{ apiKey.isActive ? "無効化" : "有効化" }}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="$emit('regenerate')"
|
||||
>
|
||||
<v-icon size="18" color="warning">mdi-refresh</v-icon>
|
||||
<v-tooltip activator="parent" location="top">再生成</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ApiKey } from "@/types/apikey";
|
||||
import { isUserKey } from "@/logics/apikey";
|
||||
|
||||
defineProps<{ apiKey: ApiKey }>();
|
||||
|
||||
defineEmits<{
|
||||
edit: [];
|
||||
delete: [];
|
||||
"toggle-active": [];
|
||||
regenerate: [];
|
||||
}>();
|
||||
</script>
|
||||
90
src/components/apikey/ApiDialog.vue
Normal file
90
src/components/apikey/ApiDialog.vue
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<template>
|
||||
<v-dialog
|
||||
:model-value="modelValue"
|
||||
max-width="500"
|
||||
persistent
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<v-card rounded="lg">
|
||||
<v-card-title class="pa-4 text-h6 d-flex align-center ga-2">
|
||||
<v-icon :color="mode === 'create' ? 'primary' : 'warning'">
|
||||
{{ mode === "create" ? "mdi-plus-circle-outline" : "mdi-pencil-outline" }}
|
||||
</v-icon>
|
||||
{{ mode === "create" ? "APIキー作成" : "APIキー編集" }}
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<v-form v-model="valid" class="d-flex flex-column ga-4">
|
||||
<v-text-field
|
||||
v-model="form.name"
|
||||
label="名前 *"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
prepend-inner-icon="mdi-tag-outline"
|
||||
:rules="[v => !!v || '名前は必須です']"
|
||||
/>
|
||||
<v-select
|
||||
v-model="form.level"
|
||||
label="ロール *"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
prepend-inner-icon="mdi-shield-account-outline"
|
||||
:items="[
|
||||
{ title: 'Admin', value: 10 },
|
||||
{ title: 'User', value: 1 },
|
||||
]"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="form.expiresAt"
|
||||
label="有効期限"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
prepend-inner-icon="mdi-calendar-outline"
|
||||
type="date"
|
||||
clearable
|
||||
/>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="$emit('cancel')">
|
||||
キャンセル
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
:disabled="!valid"
|
||||
@click="$emit('save')"
|
||||
>
|
||||
保存
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import type { CreateApiKeyInput } from "@/types/apikey";
|
||||
|
||||
defineProps<{
|
||||
modelValue: boolean;
|
||||
mode: "create" | "edit";
|
||||
form: CreateApiKeyInput;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
"update:modelValue": [value: boolean];
|
||||
save: [];
|
||||
cancel: [];
|
||||
}>();
|
||||
|
||||
const valid = ref(false);
|
||||
</script>
|
||||
99
src/components/apikey/ApiKeyManager.vue
Normal file
99
src/components/apikey/ApiKeyManager.vue
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="d-flex align-center justify-space-between mb-6">
|
||||
<div class="text-h5 font-weight-bold">APIキー管理</div>
|
||||
|
||||
<div class="d-flex ga-2">
|
||||
<v-btn
|
||||
v-if="tab === 'user' && selectedKeys.length > 0"
|
||||
color="error"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-delete"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
選択中を削除 ({{ selectedKeys.length }})
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-plus"
|
||||
@click="apikeyLogic.openCreate"
|
||||
>
|
||||
新規作成
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-tabs v-model="tab" class="mb-4">
|
||||
<v-tab value="user">
|
||||
<v-icon start>mdi-account</v-icon>
|
||||
User キー
|
||||
</v-tab>
|
||||
<v-tab value="admin">
|
||||
<v-icon start>mdi-shield-account</v-icon>
|
||||
Admin キー
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<v-tabs-window v-model="tab">
|
||||
<v-tabs-window-item value="user">
|
||||
<ApiKeyTable
|
||||
mode="user"
|
||||
:keys="userKeyList"
|
||||
:loading="loading"
|
||||
v-model:selected="selectedKeys"
|
||||
@edit="apikeyLogic.openEdit"
|
||||
@toggle-active="apikeyLogic.toggleActive"
|
||||
@regenerate="apikeyLogic.regenerate"
|
||||
/>
|
||||
</v-tabs-window-item>
|
||||
|
||||
<v-tabs-window-item value="admin">
|
||||
<ApiKeyTable
|
||||
mode="admin"
|
||||
:keys="adminKeyList"
|
||||
:loading="loading"
|
||||
/>
|
||||
</v-tabs-window-item>
|
||||
</v-tabs-window>
|
||||
|
||||
<ApiDialog
|
||||
v-model="dialog"
|
||||
:mode="dialogMode"
|
||||
:form="form"
|
||||
@save="apikeyLogic.save"
|
||||
@cancel="dialog = false"
|
||||
/>
|
||||
|
||||
<ApiKeyRevealDialog
|
||||
v-model="keyDialog"
|
||||
:api-key="newApiKey"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import ApiKeyTable from "@/components/apikey/ApiKeyTable.vue";
|
||||
import ApiDialog from "@/components/apikey/ApiDialog.vue";
|
||||
import ApiKeyRevealDialog from "@/components/common/ApiKeyRevealDialog.vue";
|
||||
import {
|
||||
keys, loading, dialog, dialogMode, form,
|
||||
keyDialog, newApiKey, apikeyLogic,
|
||||
} from "@/logics/apikey";
|
||||
import type { ApiKey } from "@/types/apikey";
|
||||
|
||||
const tab = ref<"user" | "admin">("user");
|
||||
const selectedKeys = ref<ApiKey[]>([]);
|
||||
|
||||
const userKeyList = computed(() => keys.value.filter(k => k.role === "user"));
|
||||
const adminKeyList = computed(() => keys.value.filter(k => k.role === "admin"));
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
const ids = selectedKeys.value.map(k => k.id);
|
||||
await apikeyLogic.deleteSelected(ids);
|
||||
selectedKeys.value = [];
|
||||
};
|
||||
|
||||
onMounted(apikeyLogic.fetchKeys);
|
||||
</script>
|
||||
128
src/components/apikey/ApiKeyTable.vue
Normal file
128
src/components/apikey/ApiKeyTable.vue
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<template>
|
||||
<v-card rounded="lg" elevation="1">
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="keys"
|
||||
:loading="loading"
|
||||
item-value="id"
|
||||
hover
|
||||
v-model="internalSelected"
|
||||
:show-select="mode === 'user'"
|
||||
return-object
|
||||
>
|
||||
<template #item.id="{ item }">
|
||||
<div class="d-flex align-center ga-1">
|
||||
<span class="text-caption text-medium-emphasis">
|
||||
{{ item.id.slice(0, 8) }}...
|
||||
</span>
|
||||
<v-btn
|
||||
icon
|
||||
size="x-small"
|
||||
variant="text"
|
||||
@click="copyId(item.id)"
|
||||
>
|
||||
<v-icon size="12">mdi-content-copy</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.isActive="{ item }">
|
||||
<v-chip
|
||||
:color="item.isActive ? 'success' : 'error'"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
<v-icon start size="12">
|
||||
{{ item.isActive ? "mdi-check" : "mdi-lock" }}
|
||||
</v-icon>
|
||||
{{ item.isActive ? "有効" : "凍結中" }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template #item.role="{ item }">
|
||||
<v-chip
|
||||
:color="item.role === 'admin' ? 'primary' : 'secondary'"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
<v-icon start size="12">
|
||||
{{ item.role === "admin"
|
||||
? "mdi-shield-account"
|
||||
: "mdi-account" }}
|
||||
</v-icon>
|
||||
{{ item.role }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template #item.lastUsedAt="{ item }">
|
||||
<span class="text-caption">
|
||||
{{ item.lastUsedAt
|
||||
? new Date(item.lastUsedAt).toLocaleString()
|
||||
: "未使用"
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #item.createdAt="{ item }">
|
||||
<span class="text-caption">
|
||||
{{ new Date(item.createdAt).toLocaleString() }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-if="mode === 'user'" #item.actions="{ item }">
|
||||
<ApiKeyActions
|
||||
:api-key="item"
|
||||
@edit="$emit('edit', item)"
|
||||
@toggle-active="$emit('toggle-active', item)"
|
||||
@regenerate="$emit('regenerate', item.id)"
|
||||
/>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import ApiKeyActions from "@/components/apikey/ApKeyActions.vue";
|
||||
import type { ApiKey } from "@/types/apikey";
|
||||
|
||||
const props = defineProps<{
|
||||
keys: ApiKey[];
|
||||
loading: boolean;
|
||||
mode: "user" | "admin";
|
||||
selected?: ApiKey[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:selected": [keys: ApiKey[]];
|
||||
edit: [key: ApiKey];
|
||||
"toggle-active": [key: ApiKey];
|
||||
regenerate: [id: string];
|
||||
}>();
|
||||
|
||||
const internalSelected = computed({
|
||||
get: () => props.selected ?? [],
|
||||
set: v => emit("update:selected", v),
|
||||
});
|
||||
|
||||
const headers = computed(() => {
|
||||
const base = [
|
||||
{ title: "ID", key: "id", sortable: false },
|
||||
{ title: "名前", key: "name", sortable: true },
|
||||
{ title: "ロール", key: "role", sortable: true },
|
||||
{ title: "状態", key: "isActive", sortable: true },
|
||||
{ title: "最終使用", key: "lastUsedAt",sortable: true },
|
||||
{ title: "作成日", key: "createdAt", sortable: true },
|
||||
];
|
||||
|
||||
if (props.mode === "user") {
|
||||
base.push({ title: "操作", key: "actions", sortable: false });
|
||||
}
|
||||
|
||||
return base;
|
||||
});
|
||||
|
||||
const copyId = async (id: string) => {
|
||||
await navigator.clipboard.writeText(id);
|
||||
};
|
||||
</script>
|
||||
147
src/components/common/AdminLayout.vue
Normal file
147
src/components/common/AdminLayout.vue
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
<template>
|
||||
<v-app>
|
||||
<v-navigation-drawer
|
||||
v-model="navOpen"
|
||||
width="240"
|
||||
color="surface"
|
||||
elevation="2"
|
||||
border="0"
|
||||
>
|
||||
<div class="pa-3 d-flex align-center ga-3">
|
||||
<v-avatar color="primary" size="36" rounded="lg">
|
||||
<v-icon color="white" size="20">mdi-shield-key</v-icon>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<div class="text-subtitle-2 font-weight-bold">
|
||||
MM Admin
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
管理画面
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-end px-2">
|
||||
<v-btn
|
||||
icon
|
||||
size="x-small"
|
||||
variant="text"
|
||||
@click="navController.close()"
|
||||
>
|
||||
<v-icon size="18">mdi-chevron-left</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-divider class="mb-2" />
|
||||
|
||||
<v-list density="compact" nav class="px-2">
|
||||
<v-list-item
|
||||
v-for="item in menuItems"
|
||||
:key="item.path"
|
||||
:prepend-icon="item.icon"
|
||||
:title="item.title"
|
||||
:subtitle="item.subtitle"
|
||||
:to="item.path"
|
||||
rounded="lg"
|
||||
class="mb-1"
|
||||
active-color="primary"
|
||||
/>
|
||||
</v-list>
|
||||
|
||||
<template #append>
|
||||
<ServerStatus />
|
||||
<v-divider />
|
||||
<div class="pa-3 text-caption text-medium-emphasis text-center">
|
||||
MM Knowledge System
|
||||
</div>
|
||||
</template>
|
||||
</v-navigation-drawer>
|
||||
|
||||
<v-main
|
||||
class="bg-grey-lighten-4"
|
||||
:class="{ 'nav-closed': !navOpen }"
|
||||
>
|
||||
<v-btn
|
||||
v-if="!navOpen"
|
||||
icon
|
||||
size="small"
|
||||
elevation="2"
|
||||
color="primary"
|
||||
class="nav-open-btn"
|
||||
@click="navController.open()"
|
||||
>
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-container fluid class="pa-6">
|
||||
<router-view />
|
||||
</v-container>
|
||||
</v-main>
|
||||
|
||||
<div class="popup-container">
|
||||
<v-slide-y-transition group>
|
||||
<v-alert
|
||||
v-for="popup in popupList"
|
||||
:key="popup.id"
|
||||
:type="popup.type"
|
||||
:text="popup.text"
|
||||
closable
|
||||
class="mb-2"
|
||||
elevation="3"
|
||||
@click:close="popupController.remove(popup.id)"
|
||||
/>
|
||||
</v-slide-y-transition>
|
||||
</div>
|
||||
|
||||
<ConfirmationDialog />
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ConfirmationDialog from "@/components/common/Confirmation.vue";
|
||||
import ServerStatus from "@/components/common/ServerStatus.vue";
|
||||
import { navOpen, navController } from "@/logics/nav";
|
||||
import { popupList, popupController } from "@/logics/popup";
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
title: "ダッシュボード",
|
||||
subtitle: "概要",
|
||||
icon: "mdi-view-dashboard-outline",
|
||||
path: "/dashboard",
|
||||
},
|
||||
{
|
||||
title: "APIキー管理",
|
||||
subtitle: "作成・管理",
|
||||
icon: "mdi-key-outline",
|
||||
path: "/apikey",
|
||||
},
|
||||
{
|
||||
title: "ログ",
|
||||
subtitle: "アクセス履歴",
|
||||
icon: "mdi-history",
|
||||
path: "/logs",
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nav-closed {
|
||||
padding-left: 48px;
|
||||
}
|
||||
|
||||
.nav-open-btn {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.popup-container {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 9999;
|
||||
width: 360px;
|
||||
}
|
||||
</style>
|
||||
104
src/components/common/ApiKeyRevealDialog.vue
Normal file
104
src/components/common/ApiKeyRevealDialog.vue
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<template>
|
||||
<v-dialog
|
||||
:model-value="modelValue"
|
||||
max-width="520"
|
||||
persistent
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<v-card rounded="lg">
|
||||
<v-card-title class="pa-4 d-flex align-center ga-2">
|
||||
<v-icon color="success">mdi-key-variant</v-icon>
|
||||
APIキーが発行されました
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<v-alert
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
text="このキーは一度しか表示されません。必ずコピーして安全な場所に保存してください。"
|
||||
/>
|
||||
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-text-field
|
||||
:model-value="apiKey"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
readonly
|
||||
hide-details
|
||||
class="flex-grow-1"
|
||||
:type="show ? 'text' : 'password'"
|
||||
>
|
||||
<template #append-inner>
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="show = !show"
|
||||
>
|
||||
<v-icon size="18">
|
||||
{{ show ? "mdi-eye-off" : "mdi-eye" }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-text-field>
|
||||
|
||||
<v-btn
|
||||
icon
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
size="small"
|
||||
@click="copyKey"
|
||||
>
|
||||
<v-icon size="18">
|
||||
{{ copied ? "mdi-check" : "mdi-content-copy" }}
|
||||
</v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
{{ copied ? "コピー済み" : "コピー" }}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
@click="$emit('update:modelValue', false)"
|
||||
>
|
||||
閉じる
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
apiKey: string | null;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
"update:modelValue": [value: boolean];
|
||||
}>();
|
||||
|
||||
const show = ref(false);
|
||||
const copied = ref(false);
|
||||
|
||||
const copyKey = async () => {
|
||||
if (!props.apiKey) return;
|
||||
await navigator.clipboard.writeText(props.apiKey);
|
||||
copied.value = true;
|
||||
setTimeout(() => {
|
||||
copied.value = false;
|
||||
}, 2000);
|
||||
};
|
||||
</script>
|
||||
45
src/components/common/Confirmation.vue
Normal file
45
src/components/common/Confirmation.vue
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<template>
|
||||
<v-dialog
|
||||
v-if="confirmationState"
|
||||
:model-value="!!confirmationState"
|
||||
max-width="420"
|
||||
persistent
|
||||
>
|
||||
<v-card rounded="lg">
|
||||
<v-card-title class="pa-4 d-flex align-center ga-2">
|
||||
<v-icon :color="confirmationState.type">mdi-alert-outline</v-icon>
|
||||
{{ confirmationState.title }}
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-4 text-body-1">
|
||||
{{ confirmationState.text }}
|
||||
</v-card-text>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
v-if="!confirmationState.confirmOnly"
|
||||
variant="text"
|
||||
@click="confirmationController.resolve(false)"
|
||||
>
|
||||
キャンセル
|
||||
</v-btn>
|
||||
<v-btn
|
||||
:color="confirmationState.type"
|
||||
variant="tonal"
|
||||
@click="confirmationController.resolve(true)"
|
||||
>
|
||||
確認
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { confirmationState, confirmationController } from "@/logics/confirmation";
|
||||
</script>
|
||||
68
src/components/common/ServerStatus.vue
Normal file
68
src/components/common/ServerStatus.vue
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<template>
|
||||
<div class="server-status pa-3">
|
||||
|
||||
<div class="d-flex align-center ga-2 mb-1">
|
||||
<v-icon
|
||||
:color="serverOk ? 'success' : 'error'"
|
||||
size="10"
|
||||
>
|
||||
mdi-circle
|
||||
</v-icon>
|
||||
|
||||
<span class="text-caption text-medium-emphasis">
|
||||
Server: {{ serverUrl }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center ga-2 mb-1">
|
||||
<v-icon
|
||||
:color="dbOk ? 'success' : 'error'"
|
||||
size="10"
|
||||
>
|
||||
mdi-circle
|
||||
</v-icon>
|
||||
|
||||
<span class="text-caption text-medium-emphasis">
|
||||
DB: {{ dbName }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import api from "@/utils/api";
|
||||
|
||||
const serverUrl = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
const serverOk = ref(false);
|
||||
const dbOk = ref(false);
|
||||
|
||||
const dbName = ref("-");
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
|
||||
const res = await api.get("/health");
|
||||
|
||||
serverOk.value = res.data.ok ?? false;
|
||||
dbOk.value = res.data.db ?? false;
|
||||
|
||||
dbName.value = res.data.dbName ?? "-";
|
||||
|
||||
} catch {
|
||||
|
||||
serverOk.value = false;
|
||||
dbOk.value = false;
|
||||
|
||||
dbName.value = "-";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.server-status {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
</style>
|
||||
140
src/logics/apikey.ts
Normal file
140
src/logics/apikey.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { ref } from "vue";
|
||||
import { apikeyService } from "@/services/apikeyService";
|
||||
import { popupController } from "@/logics/popup";
|
||||
import { confirmationController } from "@/logics/confirmation";
|
||||
import type { ApiKey, CreateApiKeyInput } from "@/types/apikey";
|
||||
|
||||
export const keys = ref<ApiKey[]>([]);
|
||||
export const loading = ref(false);
|
||||
export const dialog = ref(false);
|
||||
export const dialogMode = ref<"create" | "edit">("create");
|
||||
export const editingId = ref<string | null>(null);
|
||||
export const newApiKey = ref<string | null>(null);
|
||||
export const keyDialog = ref(false);
|
||||
|
||||
export const isUserKey = (key: ApiKey) => key.level === 1;
|
||||
export const isAdminKey = (key: ApiKey) => key.level === 10;
|
||||
|
||||
export const form = ref<CreateApiKeyInput>({
|
||||
name: "",
|
||||
level: 1,
|
||||
});
|
||||
|
||||
export const apikeyLogic = {
|
||||
|
||||
fetchKeys: async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
keys.value = await apikeyService.getAll();
|
||||
} catch (e: any) {
|
||||
popupController.error(`取得に失敗しました: ${e.message}`);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
|
||||
openCreate: () => {
|
||||
dialogMode.value = "create";
|
||||
editingId.value = null;
|
||||
form.value = { name: "", level: 1 };
|
||||
dialog.value = true;
|
||||
},
|
||||
|
||||
openEdit: (key: ApiKey) => {
|
||||
dialogMode.value = "edit";
|
||||
editingId.value = key.id;
|
||||
form.value = { name: key.name, level: key.level };
|
||||
dialog.value = true;
|
||||
},
|
||||
|
||||
save: async () => {
|
||||
try {
|
||||
if (dialogMode.value === "create") {
|
||||
const result = await apikeyService.create(form.value);
|
||||
newApiKey.value = result.apiKey;
|
||||
keyDialog.value = true;
|
||||
} else if (editingId.value) {
|
||||
await apikeyService.update(editingId.value, form.value);
|
||||
popupController.success("更新しました");
|
||||
}
|
||||
dialog.value = false;
|
||||
await apikeyLogic.fetchKeys();
|
||||
} catch (e: any) {
|
||||
popupController.error(`保存に失敗しました: ${e.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
// 単体削除(既存の関数名維持)
|
||||
delete: async (id: string) => {
|
||||
const ok = await confirmationController.ask(
|
||||
"削除確認",
|
||||
"このAPIキーを削除しますか?この操作は取り消せません。",
|
||||
"error",
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await apikeyService.delete(id);
|
||||
popupController.success("削除しました");
|
||||
await apikeyLogic.fetchKeys();
|
||||
} catch (e: any) {
|
||||
popupController.error(`削除に失敗しました: ${e.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
// 複数削除(既存の関数名維持)
|
||||
deleteSelected: async (ids: string[]) => {
|
||||
const ok = await confirmationController.ask(
|
||||
"削除確認",
|
||||
`選択中の${ids.length}件のAPIキーを削除しますか?この操作は取り消せません。`,
|
||||
"error",
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await apikeyService.deleteByIds(ids);
|
||||
popupController.success(`${ids.length}件削除しました`);
|
||||
await apikeyLogic.fetchKeys();
|
||||
} catch (e: any) {
|
||||
popupController.error(`削除に失敗しました: ${e.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
// 単体active切り替え(既存の関数名維持)
|
||||
toggleActive: async (key: ApiKey) => {
|
||||
const action = key.isActive ? "無効化" : "有効化";
|
||||
const ok = await confirmationController.ask(
|
||||
`${action}確認`,
|
||||
`このAPIキーを${action}しますか?`,
|
||||
"warning",
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await apikeyService.setActive(key.id, !key.isActive);
|
||||
popupController.success(`${action}しました`);
|
||||
await apikeyLogic.fetchKeys();
|
||||
} catch (e: any) {
|
||||
popupController.error(`操作に失敗しました: ${e.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
// 単体再生成(既存の関数名維持)
|
||||
regenerate: async (id: string) => {
|
||||
const ok = await confirmationController.ask(
|
||||
"再生成確認",
|
||||
"APIキーを再生成しますか?既存のキーは無効になります。",
|
||||
"warning",
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
const result = await apikeyService.regenerate(id);
|
||||
newApiKey.value = result.apiKey;
|
||||
keyDialog.value = true;
|
||||
await apikeyLogic.fetchKeys();
|
||||
} catch (e: any) {
|
||||
popupController.error(`再生成に失敗しました: ${e.message}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
21
src/logics/auth.ts
Normal file
21
src/logics/auth.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// import { ref } from "vue";
|
||||
|
||||
// export const currentApiKey = ref<string | null>(
|
||||
// sessionStorage.getItem("mm_admin_key")
|
||||
// );
|
||||
|
||||
// export const currentLevel = ref<number>(
|
||||
// Number(sessionStorage.getItem("mm_admin_level") ?? 0)
|
||||
// );
|
||||
|
||||
// export const authController = {
|
||||
|
||||
// // 同級以上は操作不可
|
||||
// canOperate: (targetLevel: number): boolean => {
|
||||
// return currentLevel.value > targetLevel;
|
||||
// },
|
||||
// };
|
||||
|
||||
|
||||
|
||||
//TODO:APIKEY複数管理の仕組み考える
|
||||
37
src/logics/confirmation.ts
Normal file
37
src/logics/confirmation.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { ref } from "vue";
|
||||
|
||||
export type ConfirmationType = "warning" | "error" | "info";
|
||||
|
||||
export type ConfirmationState = {
|
||||
title: string;
|
||||
text: string;
|
||||
type: ConfirmationType;
|
||||
confirmOnly: boolean;
|
||||
};
|
||||
|
||||
export const confirmationState = ref<ConfirmationState | null>(null);
|
||||
|
||||
let _resolve: ((v: boolean) => void) | null = null;
|
||||
|
||||
export const confirmationController = {
|
||||
|
||||
ask: (
|
||||
title: string,
|
||||
text: string,
|
||||
type: ConfirmationType = "warning",
|
||||
confirmOnly = false,
|
||||
): Promise<boolean> => {
|
||||
confirmationState.value = { title, text, type, confirmOnly };
|
||||
return new Promise(resolve => {
|
||||
_resolve = resolve;
|
||||
});
|
||||
},
|
||||
|
||||
resolve: (value: boolean) => {
|
||||
confirmationState.value = null;
|
||||
if (_resolve) {
|
||||
_resolve(value);
|
||||
_resolve = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
50
src/logics/dashboard.ts
Normal file
50
src/logics/dashboard.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { ref } from "vue";
|
||||
import { apikeyService } from "@/services/apikeyService";
|
||||
import { logService } from "@/services/logService";
|
||||
import { popupController } from "@/logics/popup";
|
||||
import type { ApiKey } from "@/types/apikey";
|
||||
import type { ApiKeyLog } from "@/types/log";
|
||||
|
||||
export const totalKeys = ref(0);
|
||||
export const activeKeys = ref(0);
|
||||
export const adminKeys = ref(0);
|
||||
export const userKeys = ref(0);
|
||||
export const revokedKeys = ref(0);
|
||||
export const totalLogs = ref(0);
|
||||
export const errorLogs = ref(0);
|
||||
export const recentLogs = ref<ApiKeyLog[]>([]);
|
||||
export const errorLogList = ref<ApiKeyLog[]>([]);
|
||||
export const revokedKeyList = ref<ApiKey[]>([]);
|
||||
export const loading = ref(false);
|
||||
|
||||
export const errorLogDialog = ref(false);
|
||||
export const revokedKeyDialog = ref(false);
|
||||
|
||||
export const dashboardLogic = {
|
||||
|
||||
fetch: async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [keyList, logList] = await Promise.all([
|
||||
apikeyService.getAll(),
|
||||
logService.getAll(100),
|
||||
]);
|
||||
|
||||
totalKeys.value = keyList.length;
|
||||
activeKeys.value = keyList.filter(k => k.isActive).length;
|
||||
adminKeys.value = keyList.filter(k => k.role === "admin").length;
|
||||
userKeys.value = keyList.filter(k => k.role === "user").length;
|
||||
revokedKeys.value = keyList.filter(k => !k.isActive).length;
|
||||
revokedKeyList.value = keyList.filter(k => !k.isActive);
|
||||
totalLogs.value = logList.length;
|
||||
errorLogList.value = logList.filter(l => l.statusCode >= 400);
|
||||
errorLogs.value = errorLogList.value.length;
|
||||
recentLogs.value = logList.slice(0, 5);
|
||||
|
||||
} catch (e: any) {
|
||||
popupController.error(`取得に失敗しました: ${e.message}`);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
78
src/logics/log.ts
Normal file
78
src/logics/log.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { ref } from "vue";
|
||||
import { logService } from "@/services/logService";
|
||||
import { popupController } from "@/logics/popup";
|
||||
import type { ApiKeyLog } from "@/types/log";
|
||||
|
||||
export const logs = ref<ApiKeyLog[]>([]);
|
||||
export const loading = ref(false);
|
||||
export const selectedLog = ref<ApiKeyLog | null>(null);
|
||||
export const logDialog = ref(false);
|
||||
|
||||
export const filter = ref({
|
||||
apiKeyId: "",
|
||||
start: "",
|
||||
end: "",
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
export const logLogic = {
|
||||
|
||||
fetchLogs: async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
if (filter.value.start && filter.value.end) {
|
||||
logs.value = await logService.getByDateRange(
|
||||
filter.value.start,
|
||||
filter.value.end,
|
||||
filter.value.limit,
|
||||
);
|
||||
} else if (filter.value.apiKeyId) {
|
||||
logs.value = await logService.getByApiKeyId(
|
||||
filter.value.apiKeyId,
|
||||
filter.value.limit,
|
||||
);
|
||||
} else {
|
||||
logs.value = await logService.getAll(filter.value.limit);
|
||||
}
|
||||
} catch (e: any) {
|
||||
popupController.error(`取得に失敗しました: ${e.message}`);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
|
||||
openDialog: (log: ApiKeyLog) => {
|
||||
selectedLog.value = log;
|
||||
logDialog.value = true;
|
||||
},
|
||||
|
||||
deleteAll: async () => {
|
||||
try {
|
||||
await logService.deleteAll();
|
||||
popupController.success("全件削除しました");
|
||||
await logLogic.fetchLogs();
|
||||
} catch (e: any) {
|
||||
popupController.error(`削除に失敗しました: ${e.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
deleteBefore: async (before: string) => {
|
||||
try {
|
||||
await logService.deleteBefore(before);
|
||||
popupController.success("削除しました");
|
||||
await logLogic.fetchLogs();
|
||||
} catch (e: any) {
|
||||
popupController.error(`削除に失敗しました: ${e.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
deleteByApiKeyId: async (apiKeyId: string) => {
|
||||
try {
|
||||
await logService.deleteByApiKeyId(apiKeyId);
|
||||
popupController.success("削除しました");
|
||||
await logLogic.fetchLogs();
|
||||
} catch (e: any) {
|
||||
popupController.error(`削除に失敗しました: ${e.message}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
15
src/logics/nav.ts
Normal file
15
src/logics/nav.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { ref } from "vue";
|
||||
|
||||
export const navOpen = ref(true);
|
||||
|
||||
export const navController = {
|
||||
toggle: () => {
|
||||
navOpen.value = !navOpen.value;
|
||||
},
|
||||
open: () => {
|
||||
navOpen.value = true;
|
||||
},
|
||||
close: () => {
|
||||
navOpen.value = false;
|
||||
},
|
||||
};
|
||||
62
src/logics/popup.ts
Normal file
62
src/logics/popup.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { ref, type Ref } from "vue";
|
||||
|
||||
export type PopupType = "success" | "error" | "warning" | "info";
|
||||
export type PopupLocation = "top-right" | "top-left" | "bottom";
|
||||
|
||||
export type Popup = {
|
||||
id: string;
|
||||
type: PopupType;
|
||||
text: string;
|
||||
life: number;
|
||||
position: PopupLocation;
|
||||
dismissable: boolean;
|
||||
};
|
||||
|
||||
export const popupList: Ref<Popup[]> = ref([]);
|
||||
|
||||
let _idCounter = 0;
|
||||
|
||||
export const popupController = {
|
||||
|
||||
new: (
|
||||
type: PopupType,
|
||||
text: string,
|
||||
life = 4000,
|
||||
options?: {
|
||||
position?: PopupLocation;
|
||||
dismissable?: boolean;
|
||||
}
|
||||
) => {
|
||||
const popup: Popup = {
|
||||
id: String(_idCounter++),
|
||||
type,
|
||||
text,
|
||||
life,
|
||||
position: options?.position ?? "top-right",
|
||||
dismissable: options?.dismissable ?? true,
|
||||
};
|
||||
|
||||
popupList.value.push(popup);
|
||||
|
||||
if (life > 0) {
|
||||
setTimeout(() => popupController.remove(popup.id), life);
|
||||
}
|
||||
},
|
||||
|
||||
remove: (id: string) => {
|
||||
const idx = popupList.value.findIndex(p => p.id === id);
|
||||
if (idx !== -1) popupList.value.splice(idx, 1);
|
||||
},
|
||||
|
||||
success: (text: string, life = 4000) =>
|
||||
popupController.new("success", text, life),
|
||||
|
||||
error: (text: string, life = 5000) =>
|
||||
popupController.new("error", text, life),
|
||||
|
||||
warning: (text: string, life = 4000) =>
|
||||
popupController.new("warning", text, life),
|
||||
|
||||
info: (text: string, life = 4000) =>
|
||||
popupController.new("info", text, life),
|
||||
};
|
||||
33
src/main.ts
Normal file
33
src/main.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { createApp } from "vue";
|
||||
import { createVuetify } from "vuetify";
|
||||
import * as components from "vuetify/components";
|
||||
import * as directives from "vuetify/directives";
|
||||
import "vuetify/styles";
|
||||
import "@mdi/font/css/materialdesignicons.css";
|
||||
import router from "./router";
|
||||
import App from "./App.vue";
|
||||
|
||||
const vuetify = createVuetify({
|
||||
components,
|
||||
directives,
|
||||
theme: {
|
||||
defaultTheme: "light",
|
||||
themes: {
|
||||
light: {
|
||||
colors: {
|
||||
primary: "#6366F1",
|
||||
secondary: "#8B5CF6",
|
||||
success: "#10B981",
|
||||
warning: "#F59E0B",
|
||||
error: "#EF4444",
|
||||
info: "#3B82F6",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
createApp(App)
|
||||
.use(router)
|
||||
.use(vuetify)
|
||||
.mount("#app");
|
||||
25
src/router/index.ts
Normal file
25
src/router/index.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { createRouter, createWebHistory } from "vue-router";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
redirect: "/dashboard",
|
||||
},
|
||||
{
|
||||
path: "/dashboard",
|
||||
component: () => import("@/views/DashboardView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/apikey",
|
||||
component: () => import("@/views/ApiKeyView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/logs",
|
||||
component: () => import("@/views/LogView.vue"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default router;
|
||||
55
src/services/apikeyService.ts
Normal file
55
src/services/apikeyService.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import api from "@/utils/api";
|
||||
import { ApiKeySchema, type ApiKey, type CreateApiKeyInput } from "@/types/apikey";
|
||||
import { z } from "zod";
|
||||
|
||||
export const apikeyService = {
|
||||
|
||||
getAll: async (): Promise<ApiKey[]> => {
|
||||
const res = await api.get("/apikey");
|
||||
return z.array(ApiKeySchema).parse(res.data.data);
|
||||
},
|
||||
|
||||
get: async (id: string): Promise<ApiKey> => {
|
||||
const res = await api.get(`/apikey/${id}`);
|
||||
return ApiKeySchema.parse(res.data.data);
|
||||
},
|
||||
|
||||
create: async (data: CreateApiKeyInput): Promise<{ apiKey: string }> => {
|
||||
const res = await api.post("/apikey", data);
|
||||
return res.data.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: Partial<CreateApiKeyInput>): Promise<void> => {
|
||||
await api.patch(`/apikey/${id}`, data);
|
||||
},
|
||||
|
||||
// 単体または複数削除
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete("/apikey", { data: { id } });
|
||||
},
|
||||
|
||||
deleteByIds: async (ids: string[]): Promise<void> => {
|
||||
await api.delete("/apikey", { data: { ids } });
|
||||
},
|
||||
|
||||
// 単体または複数のactive切り替え
|
||||
setActive: async (id: string, active: boolean): Promise<void> => {
|
||||
await api.patch("/apikey/active", { id, active });
|
||||
},
|
||||
|
||||
setActiveByIds: async (ids: string[], active: boolean): Promise<void> => {
|
||||
await api.patch("/apikey/active", { ids, active });
|
||||
},
|
||||
|
||||
// 単体再生成
|
||||
regenerate: async (id: string): Promise<{ apiKey: string }> => {
|
||||
const res = await api.post("/apikey/regenerate", { id });
|
||||
return res.data.data;
|
||||
},
|
||||
|
||||
// 複数再生成
|
||||
regenerateByIds: async (ids: string[]): Promise<{ id: string; apiKey: string }[]> => {
|
||||
const res = await api.post("/apikey/regenerate", { ids });
|
||||
return res.data.data;
|
||||
},
|
||||
};
|
||||
37
src/services/logService.ts
Normal file
37
src/services/logService.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import api from "@/utils/api";
|
||||
import { ApiKeyLogSchema, type ApiKeyLog } from "@/types/log";
|
||||
import { z } from "zod";
|
||||
|
||||
export const logService = {
|
||||
|
||||
getAll: async (limit = 100): Promise<ApiKeyLog[]> => {
|
||||
const res = await api.get("/apikey/logs", { params: { limit } });
|
||||
return z.array(ApiKeyLogSchema).parse(res.data.data);
|
||||
},
|
||||
|
||||
getByApiKeyId: async (apiKeyId: string, limit = 100): Promise<ApiKeyLog[]> => {
|
||||
const res = await api.get(`/apikey/logs/key/${apiKeyId}`, { params: { limit } });
|
||||
return z.array(ApiKeyLogSchema).parse(res.data.data);
|
||||
},
|
||||
|
||||
getByDateRange: async (start: string, end: string, limit = 100): Promise<ApiKeyLog[]> => {
|
||||
const res = await api.get("/apikey/logs/range", {
|
||||
params: { start, end, limit }
|
||||
});
|
||||
return z.array(ApiKeyLogSchema).parse(res.data.data);
|
||||
},
|
||||
|
||||
deleteAll: async (): Promise<void> => {
|
||||
await api.delete("/apikey/logs");
|
||||
},
|
||||
|
||||
deleteByIds: async (ids: string[]): Promise<void> => {
|
||||
await api.delete(`/apikey/logs/_`, {
|
||||
params: { ids: ids.join(",") }
|
||||
});
|
||||
},
|
||||
|
||||
deleteBefore: async (before: string): Promise<void> => {
|
||||
await api.delete("/apikey/logs/before", { params: { before } });
|
||||
},
|
||||
};
|
||||
35
src/types/apikey.ts
Normal file
35
src/types/apikey.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export type ApiKeyRole = "user" | "admin";
|
||||
|
||||
const levelToRole = (level: number): ApiKeyRole => {
|
||||
if (level === 10) return "admin";
|
||||
return "user";
|
||||
};
|
||||
|
||||
export const ApiKeySchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
level: z.number(),
|
||||
keyHash: z.string(),
|
||||
isActive: z.boolean(),
|
||||
expiresAt: z.string().nullable(),
|
||||
lastUsedAt: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
}).transform(v => ({
|
||||
...v,
|
||||
role: levelToRole(v.level),
|
||||
}));
|
||||
|
||||
export const CreateApiKeySchema = z.object({
|
||||
name: z.string(),
|
||||
level: z.number(),
|
||||
expiresAt: z.string().optional(),
|
||||
});
|
||||
|
||||
export const UpdateApiKeySchema = CreateApiKeySchema.partial();
|
||||
|
||||
export type ApiKey = z.infer<typeof ApiKeySchema>;
|
||||
export type CreateApiKeyInput = z.infer<typeof CreateApiKeySchema>;
|
||||
export type UpdateApiKeyInput = z.infer<typeof UpdateApiKeySchema>;
|
||||
13
src/types/log.ts
Normal file
13
src/types/log.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const ApiKeyLogSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
apiKeyId: z.string().uuid(),
|
||||
endpoint: z.string(),
|
||||
requestType: z.string(),
|
||||
statusCode: z.number(),
|
||||
ipAddress: z.string().nullable(),
|
||||
calledAt: z.string(),
|
||||
});
|
||||
|
||||
export type ApiKeyLog = z.infer<typeof ApiKeyLogSchema>;
|
||||
24
src/utils/api.ts
Normal file
24
src/utils/api.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import axios from "axios";
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `APIKEY ${import.meta.env.VITE_ADMIN_API_KEY}`,
|
||||
},
|
||||
});
|
||||
|
||||
// レスポンスのエラーハンドリング
|
||||
api.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
const message =
|
||||
error.response?.data?.message ??
|
||||
error.message ??
|
||||
"不明なエラーが発生しました";
|
||||
console.error(`[API Error] ${message}`);
|
||||
return Promise.reject(new Error(message));
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
11
src/utils/role.ts
Normal file
11
src/utils/role.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export type Role = "admin" | "user";
|
||||
|
||||
export function levelToRole(level: number): Role {
|
||||
if (level >= 10) return "admin";
|
||||
return "user";
|
||||
}
|
||||
|
||||
export function roleToLevel(role: Role): number {
|
||||
if (role === "admin") return 10;
|
||||
return 1;
|
||||
}
|
||||
7
src/views/ApiKeyView.vue
Normal file
7
src/views/ApiKeyView.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
<ApiKeyManager />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ApiKeyManager from "@/components/apikey/ApiKeyManager.vue";
|
||||
</script>
|
||||
7
src/views/DashboardView.vue
Normal file
7
src/views/DashboardView.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
<Dashboard />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Dashboard from "@/components/DashboardView.vue";
|
||||
</script>
|
||||
7
src/views/LogView.vue
Normal file
7
src/views/LogView.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
<LogManager />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LogManager from "@/components/logs/LogManager.vue";
|
||||
</script>
|
||||
19
tsconfig.app.json
Normal file
19
tsconfig.app.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
tsconfig.node.json
Normal file
24
tsconfig.node.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"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 */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
12
vite.config.ts
Normal file
12
vite.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue