修改:完善api管理界面

This commit is contained in:
马丁 2026-03-27 18:30:58 +08:00
parent e1456f14d6
commit f93332d259
8 changed files with 439 additions and 20 deletions

View File

@ -3,7 +3,7 @@
* doc.json: /AA/getApiAppList/AA/createApiApp * doc.json: /AA/getApiAppList/AA/createApiApp
*/ */
import { get, post } from './request' import { get, httpDelete, post } from './request'
import { buildQuery } from './request' import { buildQuery } from './request'
import type { PageResult } from './types' import type { PageResult } from './types'
@ -37,13 +37,13 @@ export interface ApiAppListResponse {
* GET /AA/getApiAppList * GET /AA/getApiAppList
* API x-tokenx-user-id * API x-tokenx-user-id
*/ */
export async function getApiAppList( export async function getMyApiAppList(
params: GetApiAppListParams = {}, params: GetApiAppListParams = {},
config?: { headers?: Record<string, string> }, config?: { headers?: Record<string, string> },
): Promise<ApiAppListResponse> { ): Promise<ApiAppListResponse> {
const { page = 1, pageSize = 10, keyword, createdAtRange } = params const { page = 1, pageSize = 10, keyword, createdAtRange } = params
const query = buildQuery({ page, pageSize, keyword, createdAtRange }) const query = buildQuery({ page, pageSize, keyword, createdAtRange })
return get<ApiAppListResponse>('/AA/getApiAppList', query, config) return get<ApiAppListResponse>('/AA/getMyApiAppList', query, config)
} }
/** 创建 API 应用请求体appKey/appSecret 由后端生成时可传空) */ /** 创建 API 应用请求体appKey/appSecret 由后端生成时可传空) */
@ -65,9 +65,20 @@ export interface CreateApiAppResponse {
* POST /AA/createApiApp * POST /AA/createApiApp
* API ApiApp appKeyappSecret * API ApiApp appKeyappSecret
*/ */
export async function createApiApp( export async function createMyApiApp(
body: CreateApiAppBody, body: CreateApiAppBody,
config?: { headers?: Record<string, string> }, config?: { headers?: Record<string, string> },
): Promise<CreateApiAppResponse> { ): Promise<CreateApiAppResponse> {
return post<CreateApiAppResponse>('/AA/createApiApp', body, config) return post<CreateApiAppResponse>('/AA/createMyApiApp', body, config)
}
/**
* POST /AA/deleteMyApiApp
* API
*/
export async function deleteMyApiApp(
ID: number,
config?: { headers?: Record<string, string> },
): Promise<CreateApiAppResponse> {
return httpDelete<CreateApiAppResponse>('/AA/deleteMyApiApp?ID='+ ID, null, config)
} }

View File

@ -159,3 +159,28 @@ export async function put<T = unknown>(
} }
return res.json() as Promise<T> return res.json() as Promise<T>
} }
/**
* x-token POST
*/
export async function httpDelete<T = unknown>(
path: string,
body?: unknown,
config?: RequestConfig,
): Promise<T> {
const url = new URL(path, BASE_URL || window.location.origin)
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept-Language': i18n.global.locale.value as string,
...config?.headers,
}
const res = await fetch(url.toString(), {
method: 'DELETE',
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}
return res.json() as Promise<T>
}

View File

@ -18,7 +18,8 @@
"noData": "No data", "noData": "No data",
"more": "More", "more": "More",
"user": "User", "user": "User",
"chance": "chance" "chance": "chance",
"cancel": "Cancel"
}, },
"chart": { "chart": {
"yesnoTimeSeries": "YES/NO Time Series", "yesnoTimeSeries": "YES/NO Time Series",
@ -148,7 +149,18 @@
"copy": "Copy", "copy": "Copy",
"delete": "Delete", "delete": "Delete",
"keyDefault": "Key #{n}", "keyDefault": "Key #{n}",
"defaultDesc": "API Key" "defaultDesc": "API Key",
"newKeyTitle": "Created Successfully",
"newKeyHint": "Save now — App Secret is only shown once",
"appKeyLabel": "App Key",
"appSecretLabel": "App Secret",
"copyAppKey": "Copy App Key",
"copyAppSecret": "Copy App Secret",
"copied": "Copied",
"closeBtn": "I've saved it, Close",
"deleteConfirm": "Are you sure you want to delete this API Key? This action cannot be undone.",
"deleteConfirmTitle": "Confirm Delete",
"deleteSuccess": "Deleted successfully"
}, },
"activity": { "activity": {
"comments": "Comments", "comments": "Comments",

View File

@ -18,7 +18,8 @@
"noData": "データがありません", "noData": "データがありません",
"more": "その他", "more": "その他",
"user": "ユーザー", "user": "ユーザー",
"chance": "確率" "chance": "確率",
"cancel": "キャンセル"
}, },
"chart": { "chart": {
"yesnoTimeSeries": "YES/NO 時系列", "yesnoTimeSeries": "YES/NO 時系列",
@ -148,7 +149,18 @@
"copy": "コピー", "copy": "コピー",
"delete": "削除", "delete": "削除",
"keyDefault": "Key #{n}", "keyDefault": "Key #{n}",
"defaultDesc": "API Key" "defaultDesc": "API Key",
"newKeyTitle": "作成完了",
"newKeyHint": "今すぐ保存してください。App Secret はここにのみ表示されます",
"appKeyLabel": "App Key",
"appSecretLabel": "App Secret",
"copyAppKey": "App Key をコピー",
"copyAppSecret": "App Secret をコピー",
"copied": "コピーしました",
"closeBtn": "保存しました、閉じる",
"deleteConfirm": "この API Key を削除してもよろしいですか?この操作は元に戻せません。",
"deleteConfirmTitle": "削除の確認",
"deleteSuccess": "削除しました"
}, },
"activity": { "activity": {
"comments": "コメント", "comments": "コメント",

View File

@ -18,7 +18,8 @@
"noData": "데이터 없음", "noData": "데이터 없음",
"more": "더보기", "more": "더보기",
"user": "사용자", "user": "사용자",
"chance": "확률" "chance": "확률",
"cancel": "취소"
}, },
"chart": { "chart": {
"yesnoTimeSeries": "YES/NO 시계열", "yesnoTimeSeries": "YES/NO 시계열",
@ -148,7 +149,18 @@
"copy": "복사", "copy": "복사",
"delete": "삭제", "delete": "삭제",
"keyDefault": "Key #{n}", "keyDefault": "Key #{n}",
"defaultDesc": "API Key" "defaultDesc": "API Key",
"newKeyTitle": "생성 완료",
"newKeyHint": "지금 저장하세요. App Secret은 여기서만 표시됩니다",
"appKeyLabel": "App Key",
"appSecretLabel": "App Secret",
"copyAppKey": "App Key 복사",
"copyAppSecret": "App Secret 복사",
"copied": "복사됨",
"closeBtn": "저장 완료, 닫기",
"deleteConfirm": "이 API Key를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"deleteConfirmTitle": "삭제 확인",
"deleteSuccess": "삭제되었습니다"
}, },
"activity": { "activity": {
"comments": "댓글", "comments": "댓글",

View File

@ -18,7 +18,8 @@
"noData": "暂无数据", "noData": "暂无数据",
"more": "更多操作", "more": "更多操作",
"user": "用户", "user": "用户",
"chance": "概率" "chance": "概率",
"cancel": "取消"
}, },
"chart": { "chart": {
"yesnoTimeSeries": "YES/NO 分时", "yesnoTimeSeries": "YES/NO 分时",
@ -148,7 +149,18 @@
"copy": "复制", "copy": "复制",
"delete": "删除", "delete": "删除",
"keyDefault": "Key #{n}", "keyDefault": "Key #{n}",
"defaultDesc": "API Key" "defaultDesc": "API Key",
"newKeyTitle": "创建成功",
"newKeyHint": "请立即保存AppSecret 仅在此处显示一次",
"appKeyLabel": "App Key",
"appSecretLabel": "App Secret",
"copyAppKey": "复制 App Key",
"copyAppSecret": "复制 App Secret",
"copied": "已复制",
"closeBtn": "我已保存,关闭",
"deleteConfirm": "确定要删除该 API Key 吗?此操作不可撤销。",
"deleteConfirmTitle": "删除确认",
"deleteSuccess": "删除成功"
}, },
"activity": { "activity": {
"comments": "评论", "comments": "评论",

View File

@ -18,7 +18,8 @@
"noData": "暫無數據", "noData": "暫無數據",
"more": "更多操作", "more": "更多操作",
"user": "用戶", "user": "用戶",
"chance": "機率" "chance": "機率",
"cancel": "取消"
}, },
"chart": { "chart": {
"yesnoTimeSeries": "YES/NO 分時", "yesnoTimeSeries": "YES/NO 分時",
@ -148,7 +149,18 @@
"copy": "複製", "copy": "複製",
"delete": "刪除", "delete": "刪除",
"keyDefault": "Key #{n}", "keyDefault": "Key #{n}",
"defaultDesc": "API Key" "defaultDesc": "API Key",
"newKeyTitle": "創建成功",
"newKeyHint": "請立即保存AppSecret 僅在此處顯示一次",
"appKeyLabel": "App Key",
"appSecretLabel": "App Secret",
"copyAppKey": "複製 App Key",
"copyAppSecret": "複製 App Secret",
"copied": "已複製",
"closeBtn": "我已保存,關閉",
"deleteConfirm": "確定要刪除該 API Key 嗎?此操作不可撤銷。",
"deleteConfirmTitle": "刪除確認",
"deleteSuccess": "刪除成功"
}, },
"activity": { "activity": {
"comments": "評論", "comments": "評論",

View File

@ -34,7 +34,14 @@
> >
{{ t('apiKey.copy') }} {{ t('apiKey.copy') }}
</button> </button>
<button class="action-btn action-delete" type="button">{{ t('apiKey.delete') }}</button> <button
class="action-btn action-delete"
type="button"
:disabled="deletingId === item.ID"
@click="handleDelete(item)"
>
{{ deletingId === item.ID ? t('common.loading') : t('apiKey.delete') }}
</button>
</div> </div>
</article> </article>
<div ref="loadMoreSentinelRef" class="load-more-sentinel" aria-hidden="true"></div> <div ref="loadMoreSentinelRef" class="load-more-sentinel" aria-hidden="true"></div>
@ -46,12 +53,79 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 删除确认弹窗 -->
<Teleport to="body">
<div v-if="confirmTarget != null" class="new-key-overlay" role="dialog" aria-modal="true">
<div class="new-key-dialog confirm-dialog">
<h2 class="new-key-title confirm-title">{{ t('apiKey.deleteConfirmTitle') }}</h2>
<p class="confirm-msg">{{ t('apiKey.deleteConfirm') }}</p>
<div class="confirm-actions">
<button class="confirm-cancel-btn" type="button" @click="cancelDelete">
{{ t('common.cancel') }}
</button>
<button
class="confirm-ok-btn"
type="button"
:disabled="deletingId != null"
@click="confirmDelete"
>
{{ deletingId != null ? t('common.loading') : t('apiKey.delete') }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- 创建成功弹窗 -->
<Teleport to="body">
<div v-if="newKeyVisible" class="new-key-overlay" role="dialog" aria-modal="true">
<div class="new-key-dialog">
<h2 class="new-key-title">{{ t('apiKey.newKeyTitle') }}</h2>
<p class="new-key-hint">{{ t('apiKey.newKeyHint') }}</p>
<div class="new-key-field">
<span class="new-key-label">{{ t('apiKey.appKeyLabel') }}</span>
<div class="new-key-value-row">
<span class="new-key-value">{{ newKeyData.appKey }}</span>
<button
class="new-key-copy-btn"
:class="{ copied: copiedField === 'appKey' }"
type="button"
@click="copyNewKey('appKey')"
>
{{ copiedField === 'appKey' ? t('apiKey.copied') : t('apiKey.copyAppKey') }}
</button>
</div>
</div>
<div class="new-key-field">
<span class="new-key-label">{{ t('apiKey.appSecretLabel') }}</span>
<div class="new-key-value-row">
<span class="new-key-value new-key-secret">{{ newKeyData.appSecret }}</span>
<button
class="new-key-copy-btn"
:class="{ copied: copiedField === 'appSecret' }"
type="button"
@click="copyNewKey('appSecret')"
>
{{ copiedField === 'appSecret' ? t('apiKey.copied') : t('apiKey.copyAppSecret') }}
</button>
</div>
</div>
<button class="new-key-close-btn" type="button" @click="closeNewKeyDialog">
{{ t('apiKey.closeBtn') }}
</button>
</div>
</div>
</Teleport>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue' import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { getApiAppList, createApiApp, type ApiApp } from '../api/apiApp' import { getMyApiAppList, createMyApiApp, deleteMyApiApp, type ApiApp } from '../api/apiApp'
import { useUserStore } from '../stores/user' import { useUserStore } from '../stores/user'
import { useToastStore } from '../stores/toast' import { useToastStore } from '../stores/toast'
@ -63,11 +137,40 @@ const apiKeys = ref<ApiApp[]>([])
const loading = ref(false) const loading = ref(false)
const loadingMore = ref(false) const loadingMore = ref(false)
const creating = ref(false) const creating = ref(false)
const deletingId = ref<number | null>(null)
const confirmTarget = ref<ApiApp | null>(null)
const loadError = ref('') const loadError = ref('')
const page = ref(1) const page = ref(1)
const total = ref(0) const total = ref(0)
const loadMoreSentinelRef = ref<HTMLElement | null>(null) const loadMoreSentinelRef = ref<HTMLElement | null>(null)
//
const newKeyVisible = ref(false)
const newKeyData = ref<{ appKey: string; appSecret: string }>({ appKey: '', appSecret: '' })
const copiedField = ref<'appKey' | 'appSecret' | null>(null)
function copyNewKey(field: 'appKey' | 'appSecret') {
const val = newKeyData.value[field]
if (!val) return
navigator.clipboard
.writeText(val)
.then(() => {
copiedField.value = field
setTimeout(() => {
if (copiedField.value === field) copiedField.value = null
}, 2000)
})
.catch(() => {
toastStore.show(t('profile.copyFailed'), 'error')
})
}
function closeNewKeyDialog() {
newKeyVisible.value = false
newKeyData.value = { appKey: '', appSecret: '' }
copiedField.value = null
}
const PAGE_SIZE = 12 const PAGE_SIZE = 12
const noMore = computed( const noMore = computed(
@ -89,7 +192,7 @@ async function fetchList(append: boolean) {
loadError.value = '' loadError.value = ''
try { try {
const res = await getApiAppList( const res = await getMyApiAppList(
{ page: nextPage, pageSize: PAGE_SIZE }, { page: nextPage, pageSize: PAGE_SIZE },
{ headers }, { headers },
) )
@ -129,7 +232,7 @@ async function handleCreate() {
creating.value = true creating.value = true
try { try {
const res = await createApiApp( const res = await createMyApiApp(
{ {
appKey: '', appKey: '',
appSecret: '', appSecret: '',
@ -143,7 +246,13 @@ async function handleCreate() {
toastStore.show(res.msg || t('error.loadFailed'), 'error') toastStore.show(res.msg || t('error.loadFailed'), 'error')
return return
} }
toastStore.show(t('toast.createKeySuccess')) if (res.data) {
newKeyData.value = {
appKey: res.data.appKey ?? '',
appSecret: res.data.appSecret ?? '',
}
newKeyVisible.value = true
}
await fetchList(false) await fetchList(false)
} catch (e) { } catch (e) {
toastStore.show( toastStore.show(
@ -155,6 +264,45 @@ async function handleCreate() {
} }
} }
function handleDelete(item: ApiApp) {
if (item.ID == null) return
confirmTarget.value = item
}
function cancelDelete() {
confirmTarget.value = null
}
async function confirmDelete() {
const item = confirmTarget.value
if (!item || item.ID == null) return
const headers = userStore.getAuthHeaders()
if (!headers) {
toastStore.show(t('error.pleaseLogin'), 'error')
return
}
deletingId.value = item.ID
try {
const res = await deleteMyApiApp(item.ID, { headers })
if (res.code !== 0 && res.code !== 200) {
toastStore.show(res.msg || t('error.loadFailed'), 'error')
return
}
toastStore.show(t('apiKey.deleteSuccess'))
confirmTarget.value = null
await fetchList(false)
} catch (e) {
toastStore.show(
e instanceof Error ? e.message : t('error.loadFailed'),
'error',
)
} finally {
deletingId.value = null
}
}
function copyKey(key: string) { function copyKey(key: string) {
if (!key) return if (!key) return
navigator.clipboard navigator.clipboard
@ -359,3 +507,178 @@ onUnmounted(removeLoadMoreObserver)
font-size: 13px; font-size: 13px;
} }
</style> </style>
<style>
/* ── 创建成功弹窗(非 scopedTeleport 挂载到 body ── */
.new-key-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.new-key-dialog {
width: 100%;
max-width: 420px;
border-radius: 20px;
background: #ffffff;
padding: 24px 20px 20px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
}
.new-key-title {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #111827;
line-height: 1.3;
}
.new-key-hint {
margin: 0;
font-size: 12px;
color: #f59e0b;
font-weight: 600;
background: #fef3c7;
border-radius: 8px;
padding: 8px 10px;
line-height: 1.5;
}
.new-key-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.new-key-label {
font-size: 11px;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.new-key-value-row {
display: flex;
align-items: center;
gap: 8px;
background: #f3f4f6;
border-radius: 10px;
padding: 10px 12px;
}
.new-key-value {
flex: 1;
font-size: 12px;
font-weight: 500;
color: #111827;
overflow-wrap: anywhere;
word-break: break-all;
line-height: 1.5;
font-family: 'Courier New', monospace;
}
.new-key-secret {
color: #5b5bd6;
}
.new-key-copy-btn {
flex-shrink: 0;
height: 28px;
padding: 0 10px;
border-radius: 8px;
border: 1px solid #e5e7eb;
background: #ffffff;
color: #111827;
font-size: 11px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.new-key-copy-btn.copied {
background: #dcfce7;
border-color: #86efac;
color: #16a34a;
}
.new-key-close-btn {
width: 100%;
height: 44px;
border: 0;
border-radius: 12px;
background: #5b5bd6;
color: #ffffff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
margin-top: 4px;
transition: opacity 0.15s;
}
.new-key-close-btn:hover {
opacity: 0.88;
}
/* ── 删除确认弹窗 ── */
.confirm-dialog {
max-width: 320px;
}
.confirm-title {
font-size: 16px;
}
.confirm-msg {
margin: 0;
font-size: 13px;
color: #374151;
line-height: 1.6;
}
.confirm-actions {
display: flex;
gap: 10px;
margin-top: 4px;
}
.confirm-cancel-btn {
flex: 1;
height: 40px;
border-radius: 10px;
border: 1px solid #e5e7eb;
background: #f9fafb;
color: #374151;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.confirm-ok-btn {
flex: 1;
height: 40px;
border-radius: 10px;
border: 0;
background: #dc2626;
color: #ffffff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.confirm-ok-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>