xtraderClient/src/views/ApiKey.vue
2026-05-23 12:30:02 +08:00

688 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>
/* ── 创建成功弹窗(非 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>