688 lines
15 KiB
Vue
688 lines
15 KiB
Vue
<template>
|
||
<div class="api-key-page">
|
||
<div class="api-key-screen">
|
||
<header class="api-key-header">
|
||
<h1 class="api-key-title">{{ t('apiKey.title') }}</h1>
|
||
<button
|
||
class="create-btn"
|
||
type="button"
|
||
:disabled="creating"
|
||
@click="handleCreate"
|
||
>
|
||
{{ t('apiKey.createBtn') }}
|
||
</button>
|
||
</header>
|
||
|
||
<section v-if="loading && apiKeys.length === 0" class="api-key-loading">
|
||
<v-progress-circular indeterminate size="32" width="2" />
|
||
<span>{{ t('common.loading') }}</span>
|
||
</section>
|
||
|
||
<section v-else-if="loadError" class="api-key-error">
|
||
<p class="error-text">{{ loadError }}</p>
|
||
</section>
|
||
|
||
<section v-else class="api-key-list">
|
||
<article v-for="(item, idx) in apiKeys" :key="item.ID ?? idx" class="api-key-item">
|
||
<p class="item-name">{{ item.desc || item.appKey || t('apiKey.keyDefault', { n: idx + 1 }) }}</p>
|
||
<p class="item-value">{{ item.appKey }}</p>
|
||
<div class="item-actions">
|
||
<button
|
||
class="action-btn action-copy"
|
||
type="button"
|
||
@click="copyKey(item.appKey)"
|
||
>
|
||
{{ t('apiKey.copy') }}
|
||
</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>
|
||
</article>
|
||
<div ref="loadMoreSentinelRef" class="load-more-sentinel" aria-hidden="true"></div>
|
||
</section>
|
||
|
||
<div v-if="loadingMore" class="api-key-loading-more">
|
||
<v-progress-circular indeterminate size="24" width="2" />
|
||
<span>{{ t('searchPage.loadMore') }}</span>
|
||
</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>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { getMyApiAppList, createMyApiApp, deleteMyApiApp, type ApiApp } from '../api/apiApp'
|
||
import { useUserStore } from '../stores/user'
|
||
import { useToastStore } from '../stores/toast'
|
||
|
||
const { t } = useI18n()
|
||
const userStore = useUserStore()
|
||
const toastStore = useToastStore()
|
||
|
||
const apiKeys = ref<ApiApp[]>([])
|
||
const loading = ref(false)
|
||
const loadingMore = ref(false)
|
||
const creating = ref(false)
|
||
const deletingId = ref<number | null>(null)
|
||
const confirmTarget = ref<ApiApp | null>(null)
|
||
const loadError = ref('')
|
||
const page = ref(1)
|
||
const total = ref(0)
|
||
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 noMore = computed(
|
||
() =>
|
||
apiKeys.value.length >= total.value ||
|
||
(apiKeys.value.length > 0 && apiKeys.value.length < PAGE_SIZE),
|
||
)
|
||
|
||
async function fetchList(append: boolean) {
|
||
const headers = userStore.getAuthHeaders()
|
||
if (!headers) {
|
||
loadError.value = t('error.pleaseLogin')
|
||
return
|
||
}
|
||
|
||
const nextPage = append ? page.value : 1
|
||
if (append) loadingMore.value = true
|
||
else loading.value = true
|
||
loadError.value = ''
|
||
|
||
try {
|
||
const res = await getMyApiAppList(
|
||
{ page: nextPage, pageSize: PAGE_SIZE },
|
||
{ headers },
|
||
)
|
||
if (res.code !== 0 && res.code !== 200) {
|
||
loadError.value = res.msg || t('error.loadFailed')
|
||
return
|
||
}
|
||
const list = res.data?.list ?? []
|
||
const dataTotal = res.data?.total ?? 0
|
||
total.value = dataTotal
|
||
page.value = res.data?.page ?? nextPage
|
||
|
||
if (append) {
|
||
apiKeys.value = [...apiKeys.value, ...list]
|
||
} else {
|
||
apiKeys.value = list
|
||
}
|
||
} catch (e) {
|
||
loadError.value = e instanceof Error ? e.message : t('error.loadFailed')
|
||
} finally {
|
||
loading.value = false
|
||
loadingMore.value = false
|
||
}
|
||
}
|
||
|
||
async function handleCreate() {
|
||
const headers = userStore.getAuthHeaders()
|
||
if (!headers) {
|
||
loadError.value = t('error.pleaseLogin')
|
||
return
|
||
}
|
||
const uid = userStore.user?.id ?? userStore.user?.ID
|
||
if (uid == null) {
|
||
loadError.value = t('error.pleaseLogin')
|
||
return
|
||
}
|
||
|
||
creating.value = true
|
||
try {
|
||
const res = await createMyApiApp(
|
||
{
|
||
appKey: '',
|
||
appSecret: '',
|
||
status: true,
|
||
userId: Number(uid),
|
||
desc: t('apiKey.defaultDesc'),
|
||
},
|
||
{ headers },
|
||
)
|
||
if (res.code !== 0 && res.code !== 200) {
|
||
toastStore.show(res.msg || t('error.loadFailed'), 'error')
|
||
return
|
||
}
|
||
if (res.data) {
|
||
newKeyData.value = {
|
||
appKey: res.data.appKey ?? '',
|
||
appSecret: res.data.appSecret ?? '',
|
||
}
|
||
newKeyVisible.value = true
|
||
}
|
||
await fetchList(false)
|
||
} catch (e) {
|
||
toastStore.show(
|
||
e instanceof Error ? e.message : t('error.loadFailed'),
|
||
'error',
|
||
)
|
||
} finally {
|
||
creating.value = false
|
||
}
|
||
}
|
||
|
||
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) {
|
||
if (!key) return
|
||
navigator.clipboard
|
||
.writeText(key)
|
||
.then(() => {
|
||
toastStore.show(t('deposit.copied'))
|
||
})
|
||
.catch(() => {
|
||
toastStore.show(t('profile.copyFailed'), 'error')
|
||
})
|
||
}
|
||
|
||
let loadMoreObserver: IntersectionObserver | null = null
|
||
const LOAD_MORE_THRESHOLD = 200
|
||
|
||
async function loadMore() {
|
||
if (loadingMore.value || noMore.value || apiKeys.value.length === 0) return
|
||
await fetchList(true)
|
||
}
|
||
|
||
function setupLoadMoreObserver() {
|
||
const sentinel = loadMoreSentinelRef.value
|
||
const scrollEl = document.querySelector('[data-main-scroll]')
|
||
if (!sentinel || !scrollEl || loadMoreObserver) return
|
||
loadMoreObserver = new IntersectionObserver(
|
||
(entries) => {
|
||
if (!entries[0]?.isIntersecting) return
|
||
loadMore()
|
||
},
|
||
{ root: scrollEl, rootMargin: `${LOAD_MORE_THRESHOLD}px`, threshold: 0 },
|
||
)
|
||
loadMoreObserver.observe(sentinel)
|
||
}
|
||
|
||
function removeLoadMoreObserver() {
|
||
const sentinel = loadMoreSentinelRef.value
|
||
if (loadMoreObserver && sentinel) loadMoreObserver.unobserve(sentinel)
|
||
loadMoreObserver = null
|
||
}
|
||
|
||
onMounted(() => fetchList(false))
|
||
|
||
watch(
|
||
() => apiKeys.value.length > 0 && !noMore.value,
|
||
(shouldObserve) => {
|
||
removeLoadMoreObserver()
|
||
if (shouldObserve) nextTick(setupLoadMoreObserver)
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
onUnmounted(removeLoadMoreObserver)
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.api-key-page {
|
||
min-height: 100vh;
|
||
background: #fcfcfc;
|
||
display: flex;
|
||
justify-content: center;
|
||
padding: 0;
|
||
margin: 0 auto;
|
||
max-width: 1440px;
|
||
margin-top: $header-height;
|
||
}
|
||
|
||
.api-key-screen {
|
||
width: 100%;
|
||
max-width: 100%;
|
||
min-height: 980px;
|
||
padding: 16px;
|
||
box-sizing: border-box;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
font-family: Inter, sans-serif;
|
||
}
|
||
|
||
.api-key-header {
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.api-key-title {
|
||
margin: 0;
|
||
color: #111827;
|
||
font-size: 24px;
|
||
font-weight: 700;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.create-btn {
|
||
height: 34px;
|
||
padding: 0 12px;
|
||
border: 0;
|
||
border-radius: 12px;
|
||
background: #5b5bd6;
|
||
color: #ffffff;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
}
|
||
.create-btn:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.api-key-loading,
|
||
.api-key-error {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 12px;
|
||
min-height: 120px;
|
||
color: #6b7280;
|
||
}
|
||
|
||
.error-text {
|
||
margin: 0;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.api-key-list {
|
||
width: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.api-key-item {
|
||
width: 100%;
|
||
border-radius: 16px;
|
||
border: 1px solid #e5e7eb;
|
||
background: #ffffff;
|
||
padding: 14px;
|
||
box-sizing: border-box;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.item-name {
|
||
margin: 0;
|
||
color: #6b7280;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.item-value {
|
||
margin: 0;
|
||
color: #111827;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
line-height: 1.2;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.item-actions {
|
||
width: 100%;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
}
|
||
|
||
.action-btn {
|
||
height: 30px;
|
||
border-radius: 8px;
|
||
padding: 0 10px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.action-copy {
|
||
border: 1px solid #e5e7eb;
|
||
background: #fcfcfc;
|
||
color: #111827;
|
||
}
|
||
|
||
.action-delete {
|
||
border: 0;
|
||
background: #fee2e2;
|
||
color: #dc2626;
|
||
}
|
||
|
||
.load-more-sentinel {
|
||
height: 1px;
|
||
visibility: hidden;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.api-key-loading-more {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
padding: 12px;
|
||
color: #6b7280;
|
||
font-size: 13px;
|
||
}
|
||
</style>
|
||
|
||
<style>
|
||
/* ── 创建成功弹窗(非 scoped,Teleport 挂载到 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>
|