| <template> |
| <div class="platform-page"> |
| <v-container fluid class="pa-0"> |
| <v-row class="d-flex justify-space-between align-center px-4 py-3 pb-8"> |
| <div> |
| <h1 class="text-h1 font-weight-bold mb-2 d-flex align-center"> |
| <v-icon color="black" class="me-2">mdi-robot</v-icon>{{ tm('title') }} |
| </h1> |
| <p class="text-subtitle-1 text-medium-emphasis mb-4"> |
| {{ tm('subtitle') }} |
| </p> |
| </div> |
| <v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" @click="updatingMode = false; showAddPlatformDialog = true" |
| rounded="xl" size="x-large"> |
| {{ tm('addAdapter') }} |
| </v-btn> |
| </v-row> |
| |
| <div> |
| <v-row v-if="(config_data.platform || []).length === 0"> |
| <v-col cols="12" class="text-center pa-8"> |
| <v-icon size="64" color="grey-lighten-1">mdi-connection</v-icon> |
| <p class="text-grey mt-4">{{ tm('emptyText') }}</p> |
| </v-col> |
| </v-row> |
| |
| <v-row v-else> |
| <v-col v-for="(platform, index) in config_data.platform || []" :key="index" cols="12" md="6" lg="4" xl="3"> |
| <item-card :item="platform" title-field="id" enabled-field="enable" |
| :bglogo="getPlatformIcon(platform.type || platform.id)" @toggle-enabled="platformStatusChange" |
| @delete="deletePlatform" @edit="editPlatform"> |
| <template #item-details="{ item }"> |
| |
| <div class="platform-status-row mb-2" v-if="getPlatformStat(item.id) && (getPlatformStat(item.id)?.status !== 'running' || getPlatformStat(item.id)?.error_count > 0)"> |
| |
| <v-chip |
| v-if="getPlatformStat(item.id)?.status !== 'running'" |
| size="small" |
| :color="getStatusColor(getPlatformStat(item.id)?.status)" |
| variant="tonal" |
| class="status-chip" |
| > |
| <v-icon size="small" start>{{ getStatusIcon(getPlatformStat(item.id)?.status) }}</v-icon> |
| {{ tm('runtimeStatus.' + (getPlatformStat(item.id)?.status || 'unknown')) }} |
| </v-chip> |
| |
| <v-chip |
| v-if="getPlatformStat(item.id)?.error_count > 0" |
| size="small" |
| color="error" |
| variant="tonal" |
| class="error-chip" |
| :class="{ 'ms-2': getPlatformStat(item.id)?.status !== 'running' }" |
| @click.stop="showErrorDetails(item)" |
| > |
| <v-icon size="small" start>mdi-bug</v-icon> |
| {{ getPlatformStat(item.id)?.error_count }} {{ tm('runtimeStatus.errors') }} |
| </v-chip> |
| </div> |
| <div v-if="getPlatformStat(item.id)?.unified_webhook && item.webhook_uuid" class="webhook-info"> |
| <v-chip |
| size="small" |
| color="primary" |
| variant="tonal" |
| class="webhook-chip" |
| @click.stop="openWebhookDialog(item.webhook_uuid)" |
| > |
| <v-icon size="small" start>mdi-webhook</v-icon> |
| {{ tm('viewWebhook') }} |
| </v-chip> |
| </div> |
| </template> |
| </item-card> |
| </v-col> |
| </v-row> |
| </div> |
| |
| |
| <v-card elevation="0" class="mt-4 mb-10"> |
| <v-card-title class="d-flex align-center py-3 px-4"> |
| <v-icon class="me-2">mdi-console-line</v-icon> |
| <span class="text-h4">{{ tm('logs.title') }}</span> |
| <v-spacer></v-spacer> |
| <v-btn variant="text" color="primary" @click="showConsole = !showConsole"> |
| {{ showConsole ? tm('logs.collapse') : tm('logs.expand') }} |
| <v-icon>{{ showConsole ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon> |
| </v-btn> |
| </v-card-title> |
| |
| |
| <v-expand-transition> |
| <v-card-text class="pa-0" v-if="showConsole"> |
| <ConsoleDisplayer style="background-color: #1e1e1e; height: 300px; border-radius: 0"></ConsoleDisplayer> |
| </v-card-text> |
| </v-expand-transition> |
| </v-card> |
| </v-container> |
| |
| |
| <AddNewPlatform v-model:show="showAddPlatformDialog" :metadata="metadata" :config_data="config_data" ref="addPlatformDialog" |
| :updating-mode="updatingMode" :updating-platform-config="updatingPlatformConfig" @update="getConfig" |
| @show-toast="showToast" @refresh-config="getConfig"/> |
| |
| |
| <v-dialog v-model="showWebhookDialog" max-width="600"> |
| <v-card> |
| <v-card-title class="d-flex align-center pa-4"> |
| <v-icon class="me-2" color="primary">mdi-webhook</v-icon> |
| {{ tm('webhookDialog.title') }} |
| </v-card-title> |
| <v-card-text class="px-4 pb-2"> |
| <p class="text-body-2 text-medium-emphasis mb-3">{{ tm('webhookDialog.description') }}</p> |
| <v-text-field |
| :model-value="currentWebhookUrl" |
| readonly |
| variant="outlined" |
| hide-details |
| class="webhook-url-field" |
| > |
| <template v-slot:append-inner> |
| <v-btn |
| icon |
| size="small" |
| variant="text" |
| @click="copyWebhookUrl(currentWebhookUuid)" |
| > |
| <v-icon>mdi-content-copy</v-icon> |
| </v-btn> |
| </template> |
| </v-text-field> |
| </v-card-text> |
| <v-card-actions class="pa-4 pt-2"> |
| <v-spacer></v-spacer> |
| <v-btn variant="tonal" color="primary" @click="showWebhookDialog = false"> |
| {{ tm('webhookDialog.close') }} |
| </v-btn> |
| </v-card-actions> |
| </v-card> |
| </v-dialog> |
| |
| |
| <v-dialog v-model="showErrorDialog" max-width="700"> |
| <v-card> |
| <v-card-title class="d-flex align-center pa-4"> |
| <v-icon class="me-2" color="error">mdi-alert-circle</v-icon> |
| {{ tm('errorDialog.title') }} |
| </v-card-title> |
| <v-card-text class="px-4 pb-4" v-if="currentErrorPlatform"> |
| <div class="mb-3"> |
| <strong>{{ tm('errorDialog.platformId') }}:</strong> {{ currentErrorPlatform.id }} |
| </div> |
| <div class="mb-3"> |
| <strong>{{ tm('errorDialog.errorCount') }}:</strong> {{ currentErrorPlatform.error_count }} |
| </div> |
| <div v-if="currentErrorPlatform.last_error" class="error-details"> |
| <div class="mb-2"> |
| <strong>{{ tm('errorDialog.lastError') }}:</strong> |
| </div> |
| <v-alert type="error" variant="tonal" class="mb-3"> |
| <div class="error-message">{{ currentErrorPlatform.last_error.message }}</div> |
| <div class="error-time text-caption text-medium-emphasis mt-1"> |
| {{ tm('errorDialog.occurredAt') }}: {{ new Date(currentErrorPlatform.last_error.timestamp).toLocaleString() }} |
| </div> |
| </v-alert> |
| <div v-if="currentErrorPlatform.last_error.traceback"> |
| <div class="mb-2"> |
| <strong>{{ tm('errorDialog.traceback') }}:</strong> |
| </div> |
| <pre class="traceback-box">{{ currentErrorPlatform.last_error.traceback }}</pre> |
| </div> |
| </div> |
| </v-card-text> |
| <v-card-actions class="pa-4 pt-0"> |
| <v-spacer></v-spacer> |
| <v-btn variant="tonal" color="primary" @click="showErrorDialog = false"> |
| {{ tm('errorDialog.close') }} |
| </v-btn> |
| </v-card-actions> |
| </v-card> |
| </v-dialog> |
| |
| |
| <v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack" |
| location="top"> |
| {{ save_message }} |
| </v-snackbar> |
| </div> |
| </template> |
| |
| <script> |
| import axios from 'axios'; |
| import AstrBotConfig from '@/components/shared/AstrBotConfig.vue'; |
| import WaitingForRestart from '@/components/shared/WaitingForRestart.vue'; |
| import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue'; |
| import ItemCard from '@/components/shared/ItemCard.vue'; |
| import AddNewPlatform from '@/components/platform/AddNewPlatform.vue'; |
| import { useCommonStore } from '@/stores/common'; |
| import { useI18n, useModuleI18n, mergeDynamicTranslations } from '@/i18n/composables'; |
| import { getPlatformIcon, getTutorialLink } from '@/utils/platformUtils'; |
| import { |
| askForConfirmation as askForConfirmationDialog, |
| useConfirmDialog |
| } from '@/utils/confirmDialog'; |
| |
| export default { |
| name: 'PlatformPage', |
| components: { |
| AstrBotConfig, |
| WaitingForRestart, |
| ConsoleDisplayer, |
| ItemCard, |
| AddNewPlatform |
| }, |
| setup() { |
| const { t } = useI18n(); |
| const { tm } = useModuleI18n('features/platform'); |
| const confirmDialog = useConfirmDialog(); |
| |
| return { |
| t, |
| tm, |
| confirmDialog |
| }; |
| }, |
| data() { |
| return { |
| config_data: {}, |
| fetched: false, |
| metadata: {}, |
| showAddPlatformDialog: false, |
| |
| updatingPlatformConfig: {}, |
| updatingMode: false, |
| |
| save_message_snack: false, |
| save_message: "", |
| save_message_success: "success", |
| |
| showConsole: localStorage.getItem('platformPage_showConsole') === 'true', |
| |
| showWebhookDialog: false, |
| currentWebhookUuid: '', |
| |
| |
| platformStats: {}, |
| statsRefreshInterval: null, |
| |
| |
| showErrorDialog: false, |
| currentErrorPlatform: null, |
| |
| store: useCommonStore() |
| } |
| }, |
| |
| watch: { |
| showConsole(newValue) { |
| localStorage.setItem('platformPage_showConsole', newValue.toString()); |
| }, |
| |
| showIdConflictDialog(newValue) { |
| if (!newValue && this.idConflictResolve) { |
| this.idConflictResolve(false); |
| this.idConflictResolve = null; |
| } |
| }, |
| |
| showOneBotEmptyTokenWarnDialog(newValue) { |
| if (!newValue && this.oneBotEmptyTokenWarningResolve) { |
| this.oneBotEmptyTokenWarningResolve(true); |
| this.oneBotEmptyTokenWarningResolve = null; |
| } |
| } |
| }, |
| |
| mounted() { |
| this.getConfig(); |
| this.getPlatformStats(); |
| |
| this.statsRefreshInterval = setInterval(() => { |
| this.getPlatformStats(); |
| }, 10000); |
| |
| |
| window.addEventListener('astrbot-locale-changed', this.handleLocaleChange); |
| }, |
| |
| beforeUnmount() { |
| if (this.statsRefreshInterval) { |
| clearInterval(this.statsRefreshInterval); |
| } |
| |
| window.removeEventListener('astrbot-locale-changed', this.handleLocaleChange); |
| }, |
| |
| methods: { |
| |
| handleLocaleChange() { |
| this.getConfig(); |
| }, |
| |
| |
| getPlatformIcon(platform_id) { |
| |
| const template = this.metadata['platform_group']?.metadata?.platform?.config_template?.[platform_id]; |
| if (template && template.logo_token) { |
| |
| return `/api/file/${template.logo_token}`; |
| } |
| return getPlatformIcon(platform_id); |
| }, |
| |
| getConfig() { |
| axios.get('/api/config/get').then((res) => { |
| this.config_data = res.data.data.config; |
| this.fetched = true |
| this.metadata = res.data.data.metadata; |
| |
| |
| const platformI18n = res.data.data.platform_i18n_translations; |
| if (platformI18n && typeof platformI18n === 'object') { |
| mergeDynamicTranslations('features.config-metadata', platformI18n); |
| } |
| }).catch((err) => { |
| this.showError(err); |
| }); |
| }, |
| |
| getPlatformStats() { |
| axios.get('/api/platform/stats').then((res) => { |
| if (res.data.status === 'ok') { |
| |
| const stats = {}; |
| for (const platform of res.data.data.platforms || []) { |
| stats[platform.id] = platform; |
| } |
| this.platformStats = stats; |
| } |
| }).catch((err) => { |
| console.warn('获取平台统计信息失败:', err); |
| }); |
| }, |
| |
| getPlatformStat(platformId) { |
| return this.platformStats[platformId] || null; |
| }, |
| |
| getStatusColor(status) { |
| switch (status) { |
| case 'running': return 'success'; |
| case 'error': return 'error'; |
| case 'pending': return 'warning'; |
| case 'stopped': return 'grey'; |
| default: return 'grey'; |
| } |
| }, |
| |
| getStatusIcon(status) { |
| switch (status) { |
| case 'running': return 'mdi-check-circle'; |
| case 'error': return 'mdi-alert-circle'; |
| case 'pending': return 'mdi-clock-outline'; |
| case 'stopped': return 'mdi-stop-circle'; |
| default: return 'mdi-help-circle'; |
| } |
| }, |
| |
| showErrorDetails(platform) { |
| const stat = this.getPlatformStat(platform.id); |
| if (stat && stat.error_count > 0) { |
| this.currentErrorPlatform = stat; |
| this.showErrorDialog = true; |
| } |
| }, |
| |
| findPlatformTemplate(platform) { |
| const templates = this.metadata?.platform_group?.metadata?.platform?.config_template || {}; |
| |
| if (platform?.type && templates[platform.type]) { |
| return templates[platform.type]; |
| } |
| if (platform?.id && templates[platform.id]) { |
| return templates[platform.id]; |
| } |
| |
| for (const template of Object.values(templates)) { |
| if (template?.type === platform?.type) { |
| return template; |
| } |
| } |
| return null; |
| }, |
| |
| mergeConfigWithTemplate(sourceConfig, templateConfig) { |
| const merge = (source, reference) => { |
| const target = {}; |
| const sourceObj = source && typeof source === 'object' && !Array.isArray(source) ? source : {}; |
| const referenceObj = reference && typeof reference === 'object' && !Array.isArray(reference) ? reference : null; |
| |
| if (!referenceObj) { |
| for (const [key, value] of Object.entries(sourceObj)) { |
| if (Array.isArray(value)) { |
| target[key] = [...value]; |
| } else if (value && typeof value === 'object') { |
| target[key] = { ...value }; |
| } else { |
| target[key] = value; |
| } |
| } |
| return target; |
| } |
| |
| |
| for (const [key, refValue] of Object.entries(referenceObj)) { |
| const hasSourceKey = Object.prototype.hasOwnProperty.call(sourceObj, key); |
| const sourceValue = sourceObj[key]; |
| |
| if (refValue && typeof refValue === 'object' && !Array.isArray(refValue)) { |
| target[key] = merge( |
| hasSourceKey && sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue) |
| ? sourceValue |
| : {}, |
| refValue |
| ); |
| continue; |
| } |
| |
| if (hasSourceKey) { |
| if (Array.isArray(sourceValue)) { |
| target[key] = [...sourceValue]; |
| } else if (sourceValue && typeof sourceValue === 'object') { |
| target[key] = { ...sourceValue }; |
| } else { |
| target[key] = sourceValue; |
| } |
| } else if (Array.isArray(refValue)) { |
| target[key] = [...refValue]; |
| } else { |
| target[key] = refValue; |
| } |
| } |
| |
| |
| for (const [key, value] of Object.entries(sourceObj)) { |
| if (Object.prototype.hasOwnProperty.call(referenceObj, key)) { |
| continue; |
| } |
| if (Array.isArray(value)) { |
| target[key] = [...value]; |
| } else if (value && typeof value === 'object') { |
| target[key] = { ...value }; |
| } else { |
| target[key] = value; |
| } |
| } |
| |
| return target; |
| }; |
| |
| return merge(sourceConfig, templateConfig); |
| }, |
| |
| editPlatform(platform) { |
| const platformCopy = JSON.parse(JSON.stringify(platform)); |
| const template = this.findPlatformTemplate(platformCopy); |
| this.updatingPlatformConfig = template |
| ? this.mergeConfigWithTemplate(platformCopy, template) |
| : platformCopy; |
| this.updatingMode = true; |
| this.showAddPlatformDialog = true; |
| this.$nextTick(() => { |
| this.$refs.addPlatformDialog.toggleShowConfigSection(); |
| }); |
| }, |
| |
| async deletePlatform(platform) { |
| const message = `${this.messages.deleteConfirm} ${platform.id}?`; |
| if (!(await askForConfirmationDialog(message, this.confirmDialog))) { |
| return; |
| } |
| |
| axios.post('/api/config/platform/delete', { id: platform.id }).then((res) => { |
| this.getConfig(); |
| this.showSuccess(res.data.message || this.messages.deleteSuccess); |
| }).catch((err) => { |
| this.showError(err.response?.data?.message || err.message); |
| }); |
| }, |
| |
| platformStatusChange(platform) { |
| platform.enable = !platform.enable; |
| |
| axios.post('/api/config/platform/update', { |
| id: platform.id, |
| config: platform |
| }).then((res) => { |
| this.getConfig(); |
| this.showSuccess(res.data.message || this.messages.statusUpdateSuccess); |
| }).catch((err) => { |
| platform.enable = !platform.enable; |
| this.showError(err.response?.data?.message || err.message); |
| }); |
| }, |
| |
| showToast({ message, type }) { |
| if (type === 'success') { |
| this.showSuccess(message); |
| } else if (type === 'error') { |
| this.showError(message); |
| } |
| }, |
| |
| showSuccess(message) { |
| this.save_message = message; |
| this.save_message_success = "success"; |
| this.save_message_snack = true; |
| }, |
| |
| showError(message) { |
| this.save_message = message; |
| this.save_message_success = "error"; |
| this.save_message_snack = true; |
| }, |
| |
| getWebhookUrl(webhookUuid) { |
| let callbackBase = this.config_data.callback_api_base || ''; |
| if (!callbackBase) { |
| callbackBase = "http(s)://<your-domain-or-ip>"; |
| } |
| if (callbackBase) { |
| return `${callbackBase.replace(/\/$/, '')}/api/platform/webhook/${webhookUuid}`; |
| } |
| return `/api/platform/webhook/${webhookUuid}`; |
| }, |
| |
| openWebhookDialog(webhookUuid) { |
| this.currentWebhookUuid = webhookUuid; |
| this.showWebhookDialog = true; |
| }, |
| |
| async copyWebhookUrl(webhookUuid) { |
| const url = this.getWebhookUrl(webhookUuid); |
| try { |
| await navigator.clipboard.writeText(url); |
| this.showSuccess(this.tm('webhookCopied')); |
| } catch (err) { |
| this.showError(this.tm('webhookCopyFailed')); |
| } |
| } |
| }, |
| computed: { |
| |
| messages() { |
| return { |
| updateSuccess: this.tm('messages.updateSuccess'), |
| addSuccess: this.tm('messages.addSuccess'), |
| deleteSuccess: this.tm('messages.deleteSuccess'), |
| statusUpdateSuccess: this.tm('messages.statusUpdateSuccess'), |
| deleteConfirm: this.tm('messages.deleteConfirm') |
| }; |
| }, |
| currentWebhookUrl() { |
| return this.getWebhookUrl(this.currentWebhookUuid); |
| } |
| } |
| } |
| </script> |
| |
| <style scoped> |
| .platform-page { |
| padding: 20px; |
| padding-top: 8px; |
| padding-bottom: 40px; |
| } |
| |
| .webhook-info { |
| margin-top: 4px; |
| } |
| |
| .webhook-chip { |
| cursor: pointer; |
| } |
| |
| .platform-status-row { |
| display: flex; |
| align-items: center; |
| flex-wrap: wrap; |
| gap: 4px; |
| } |
| |
| .status-chip { |
| font-size: 12px; |
| } |
| |
| .error-chip { |
| cursor: pointer; |
| font-size: 12px; |
| } |
| |
| .error-details { |
| margin-top: 8px; |
| } |
| |
| .error-message { |
| word-break: break-word; |
| } |
| |
| .traceback-box { |
| background-color: #1e1e1e; |
| color: #d4d4d4; |
| padding: 12px; |
| border-radius: 8px; |
| font-size: 12px; |
| line-height: 1.5; |
| overflow-x: auto; |
| white-space: pre-wrap; |
| word-break: break-word; |
| max-height: 300px; |
| overflow-y: auto; |
| } |
| </style> |
| |