283 lines
10 KiB
Vue
283 lines
10 KiB
Vue
<template>
|
||
<div>
|
||
<div class="flex items-center justify-between mb-6">
|
||
<h2 class="text-xl font-bold text-gray-800">API Clients</h2>
|
||
<div class="flex gap-2">
|
||
<el-button type="primary" @click="openCreate">新增 Client</el-button>
|
||
<el-button :loading="loading" @click="load" :icon="Refresh" size="small">重新整理</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<el-alert v-if="error" :title="errorMsg" type="error" show-icon :closable="false" class="mb-4" />
|
||
|
||
<el-table :data="items" stripe border class="w-full shadow-sm" v-loading="loading">
|
||
<template #empty><el-empty description="目前無 API Client" /></template>
|
||
<el-table-column prop="client_key" label="Client Key" min-width="180" />
|
||
<el-table-column prop="name" label="名稱" min-width="160" />
|
||
<el-table-column label="狀態" width="100">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.status === 'active' ? 'success' : 'info'">{{ row.status }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="Allowed Paths" min-width="220">
|
||
<template #default="{ row }">{{ (row.allowed_paths || []).join(', ') || '-' }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="last_used_at" label="最後使用" min-width="170" />
|
||
<el-table-column prop="expires_at" label="到期日" min-width="170" />
|
||
<el-table-column label="操作" width="260" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button size="small" @click="openEdit(row)">編輯</el-button>
|
||
<el-button size="small" type="warning" @click="handleRotate(row)">重置 Key</el-button>
|
||
<el-button size="small" :type="row.status === 'active' ? 'danger' : 'success'" @click="toggleStatus(row)">
|
||
{{ row.status === 'active' ? '停用' : '啟用' }}
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<el-dialog v-model="showCreateDialog" title="新增 API Client" width="700px" @close="resetCreate">
|
||
<el-form ref="createFormRef" :model="createForm" :rules="rules" label-width="130px">
|
||
<el-form-item label="名稱" prop="name"><el-input v-model="createForm.name" /></el-form-item>
|
||
<el-form-item label="Client Key">
|
||
<el-input v-model="createForm.client_key" placeholder="留空自動產生" />
|
||
</el-form-item>
|
||
<el-form-item label="Allowed Origins">
|
||
<el-input v-model="createAllowedOrigins" placeholder="逗號分隔,例如 https://erp.ose.tw" />
|
||
</el-form-item>
|
||
<el-form-item label="Allowed IPs">
|
||
<el-input v-model="createAllowedIps" placeholder="逗號分隔,例如 10.0.0.1,10.0.0.2" />
|
||
</el-form-item>
|
||
<el-form-item label="Allowed Paths">
|
||
<el-input v-model="createAllowedPaths" placeholder="逗號分隔,例如 /internal/" />
|
||
</el-form-item>
|
||
<el-form-item label="Rate Limit/min">
|
||
<el-input-number v-model="createForm.rate_limit_per_min" :min="0" :step="10" />
|
||
</el-form-item>
|
||
<el-form-item label="到期日">
|
||
<el-date-picker v-model="createExpiresAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss[Z]" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="showCreateDialog = false">取消</el-button>
|
||
<el-button type="primary" :loading="creating" @click="handleCreate">建立</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-dialog v-model="showEditDialog" title="編輯 API Client" width="700px" @close="resetEdit">
|
||
<el-form :model="editForm" label-width="130px">
|
||
<el-form-item label="Client Key"><el-input :model-value="editForm.client_key" disabled /></el-form-item>
|
||
<el-form-item label="名稱"><el-input v-model="editForm.name" /></el-form-item>
|
||
<el-form-item label="狀態">
|
||
<el-select v-model="editForm.status">
|
||
<el-option label="active" value="active" />
|
||
<el-option label="inactive" value="inactive" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="Allowed Origins">
|
||
<el-input v-model="editAllowedOrigins" placeholder="逗號分隔" />
|
||
</el-form-item>
|
||
<el-form-item label="Allowed IPs">
|
||
<el-input v-model="editAllowedIps" placeholder="逗號分隔" />
|
||
</el-form-item>
|
||
<el-form-item label="Allowed Paths">
|
||
<el-input v-model="editAllowedPaths" placeholder="逗號分隔" />
|
||
</el-form-item>
|
||
<el-form-item label="Rate Limit/min">
|
||
<el-input-number v-model="editForm.rate_limit_per_min" :min="0" :step="10" />
|
||
</el-form-item>
|
||
<el-form-item label="到期日">
|
||
<el-date-picker v-model="editExpiresAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss[Z]" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="showEditDialog = false">取消</el-button>
|
||
<el-button type="primary" :loading="saving" @click="handleSave">儲存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, onMounted } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { Refresh } from '@element-plus/icons-vue'
|
||
import { createApiClient, getApiClients, rotateApiClientKey, updateApiClient } from '@/api/api-clients'
|
||
|
||
const items = ref([])
|
||
const loading = ref(false)
|
||
const error = ref(false)
|
||
const errorMsg = ref('')
|
||
|
||
const showCreateDialog = ref(false)
|
||
const showEditDialog = ref(false)
|
||
const creating = ref(false)
|
||
const saving = ref(false)
|
||
|
||
const createFormRef = ref()
|
||
const rules = {
|
||
name: [{ required: true, message: '請輸入名稱', trigger: 'blur' }]
|
||
}
|
||
|
||
const createForm = ref({
|
||
name: '',
|
||
client_key: '',
|
||
rate_limit_per_min: null
|
||
})
|
||
const createAllowedOrigins = ref('')
|
||
const createAllowedIps = ref('')
|
||
const createAllowedPaths = ref('/internal/')
|
||
const createExpiresAt = ref(null)
|
||
|
||
const editForm = ref({
|
||
client_key: '',
|
||
name: '',
|
||
status: 'active',
|
||
rate_limit_per_min: null
|
||
})
|
||
const editAllowedOrigins = ref('')
|
||
const editAllowedIps = ref('')
|
||
const editAllowedPaths = ref('')
|
||
const editExpiresAt = ref(null)
|
||
|
||
function toList(value) {
|
||
return String(value || '')
|
||
.split(',')
|
||
.map(v => v.trim())
|
||
.filter(Boolean)
|
||
}
|
||
|
||
function resetCreate() {
|
||
createForm.value = { name: '', client_key: '', rate_limit_per_min: null }
|
||
createAllowedOrigins.value = ''
|
||
createAllowedIps.value = ''
|
||
createAllowedPaths.value = '/internal/'
|
||
createExpiresAt.value = null
|
||
}
|
||
|
||
function resetEdit() {
|
||
editForm.value = { client_key: '', name: '', status: 'active', rate_limit_per_min: null }
|
||
editAllowedOrigins.value = ''
|
||
editAllowedIps.value = ''
|
||
editAllowedPaths.value = ''
|
||
editExpiresAt.value = null
|
||
}
|
||
|
||
function openCreate() {
|
||
resetCreate()
|
||
showCreateDialog.value = true
|
||
}
|
||
|
||
function openEdit(row) {
|
||
editForm.value = {
|
||
client_key: row.client_key,
|
||
name: row.name,
|
||
status: row.status,
|
||
rate_limit_per_min: row.rate_limit_per_min
|
||
}
|
||
editAllowedOrigins.value = (row.allowed_origins || []).join(', ')
|
||
editAllowedIps.value = (row.allowed_ips || []).join(', ')
|
||
editAllowedPaths.value = (row.allowed_paths || []).join(', ')
|
||
editExpiresAt.value = row.expires_at
|
||
showEditDialog.value = true
|
||
}
|
||
|
||
async function load() {
|
||
loading.value = true
|
||
error.value = false
|
||
try {
|
||
const res = await getApiClients()
|
||
items.value = res.data?.items || []
|
||
} catch (err) {
|
||
error.value = true
|
||
errorMsg.value = err.response?.data?.detail || '載入失敗'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function handleCreate() {
|
||
const valid = await createFormRef.value.validate().catch(() => false)
|
||
if (!valid) return
|
||
creating.value = true
|
||
try {
|
||
const payload = {
|
||
name: createForm.value.name,
|
||
client_key: createForm.value.client_key || null,
|
||
allowed_origins: toList(createAllowedOrigins.value),
|
||
allowed_ips: toList(createAllowedIps.value),
|
||
allowed_paths: toList(createAllowedPaths.value),
|
||
rate_limit_per_min: createForm.value.rate_limit_per_min,
|
||
expires_at: createExpiresAt.value
|
||
}
|
||
const res = await createApiClient(payload)
|
||
const apiKey = res.data?.api_key || ''
|
||
if (apiKey) {
|
||
await navigator.clipboard.writeText(apiKey)
|
||
ElMessage.success(`建立成功,API Key 已複製:${apiKey}`)
|
||
} else {
|
||
ElMessage.success('建立成功')
|
||
}
|
||
showCreateDialog.value = false
|
||
await load()
|
||
} catch (err) {
|
||
ElMessage.error(err.response?.data?.detail || '建立失敗')
|
||
} finally {
|
||
creating.value = false
|
||
}
|
||
}
|
||
|
||
async function handleSave() {
|
||
saving.value = true
|
||
try {
|
||
const payload = {
|
||
name: editForm.value.name,
|
||
status: editForm.value.status,
|
||
allowed_origins: toList(editAllowedOrigins.value),
|
||
allowed_ips: toList(editAllowedIps.value),
|
||
allowed_paths: toList(editAllowedPaths.value),
|
||
rate_limit_per_min: editForm.value.rate_limit_per_min,
|
||
expires_at: editExpiresAt.value
|
||
}
|
||
await updateApiClient(editForm.value.client_key, payload)
|
||
ElMessage.success('更新成功')
|
||
showEditDialog.value = false
|
||
await load()
|
||
} catch (err) {
|
||
ElMessage.error(err.response?.data?.detail || '更新失敗')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
async function handleRotate(row) {
|
||
try {
|
||
await ElMessageBox.confirm(`確定要重置 ${row.client_key} 的 API Key 嗎?`, '重置確認', { type: 'warning' })
|
||
const res = await rotateApiClientKey(row.client_key)
|
||
const apiKey = res.data?.api_key || ''
|
||
if (apiKey) {
|
||
await navigator.clipboard.writeText(apiKey)
|
||
ElMessage.success(`重置成功,API Key 已複製:${apiKey}`)
|
||
} else {
|
||
ElMessage.success('重置成功')
|
||
}
|
||
await load()
|
||
} catch (err) {
|
||
if (err === 'cancel') return
|
||
ElMessage.error(err.response?.data?.detail || '重置失敗')
|
||
}
|
||
}
|
||
|
||
async function toggleStatus(row) {
|
||
try {
|
||
const next = row.status === 'active' ? 'inactive' : 'active'
|
||
await updateApiClient(row.client_key, { status: next })
|
||
ElMessage.success(next === 'active' ? '已啟用' : '已停用')
|
||
await load()
|
||
} catch (err) {
|
||
ElMessage.error(err.response?.data?.detail || '更新狀態失敗')
|
||
}
|
||
}
|
||
|
||
onMounted(load)
|
||
</script>
|