first commit

This commit is contained in:
chizhongjie 2026-06-23 11:27:28 +09:00
commit 63e3dc85c5
44 changed files with 3509 additions and 0 deletions

7
src/App.vue Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

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

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

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

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

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

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

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

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

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

View 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
View 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
View 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複数管理の仕組み考える

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

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

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

@ -0,0 +1,7 @@
<template>
<ApiKeyManager />
</template>
<script setup lang="ts">
import ApiKeyManager from "@/components/apikey/ApiKeyManager.vue";
</script>

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

@ -0,0 +1,7 @@
<template>
<LogManager />
</template>
<script setup lang="ts">
import LogManager from "@/components/logs/LogManager.vue";
</script>