Files
AstrBot/dashboard/src/views/PlatformPage.vue
T
2025-06-23 14:44:06 +08:00

554 lines
19 KiB
Vue

<template>
<div class="platform-page">
<v-container fluid class="pa-0">
<!-- 页面标题 -->
<v-row>
<v-col cols="12">
<h1 class="text-h4 font-weight-bold mb-2">
<v-icon size="x-large" color="primary" class="me-2">mdi-connection</v-icon>{{ tm('title') }}
</h1>
<p class="text-subtitle-1 text-medium-emphasis mb-4">
{{ tm('subtitle') }}
</p>
</v-col>
</v-row>
<!-- 平台适配器部分 -->
<v-card class="mb-6" elevation="2">
<v-card-title class="d-flex align-center py-3 px-4">
<v-icon color="primary" class="me-2">mdi-apps</v-icon>
<span class="text-h6">{{ tm('adapters') }}</span>
<v-chip color="info" size="small" class="ml-2">{{ config_data.platform?.length || 0 }}</v-chip>
<v-spacer></v-spacer>
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" @click="showAddPlatformDialog = true">
{{ tm('addAdapter') }}
</v-btn>
</v-card-title>
<v-divider></v-divider>
<v-card-text class="px-4 py-3">
<item-card-grid :items="config_data.platform || []" title-field="id" enabled-field="enable"
empty-icon="mdi-connection" :empty-text="tm('emptyText')" @toggle-enabled="platformStatusChange"
@delete="deletePlatform" @edit="editPlatform">
<template v-slot:item-details="{ item }">
<div class="d-flex align-center mb-2">
<v-icon size="small" color="grey" class="me-2">mdi-tag</v-icon>
<span class="text-caption text-medium-emphasis">
{{ tm('details.adapterType') }}:
<v-chip size="x-small" color="primary" class="ml-1">{{ item.type }}</v-chip>
</span>
</div>
<div v-if="item.token" class="d-flex align-center mb-2">
<v-icon size="small" color="grey" class="me-2">mdi-key</v-icon>
<span class="text-caption text-medium-emphasis">{{ tm('details.token') }}: ••••••••</span>
</div>
<div v-if="item.description" class="d-flex align-center">
<v-icon size="small" color="grey" class="me-2">mdi-information-outline</v-icon>
<span class="text-caption text-medium-emphasis text-truncate">{{ item.description }}</span>
</div>
</template>
</item-card-grid>
</v-card-text>
</v-card>
<!-- 日志部分 -->
<v-card elevation="2">
<v-card-title class="d-flex align-center py-3 px-4">
<v-icon color="primary" class="me-2">mdi-console-line</v-icon>
<span class="text-h6">{{ 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-divider></v-divider>
<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>
<!-- 添加平台适配器对话框 -->
<v-dialog v-model="showAddPlatformDialog" max-width="900px" min-height="80%">
<v-card class="platform-selection-dialog">
<v-card-title class="bg-primary text-white py-3 px-4" style="display: flex; align-items: center;">
<v-icon color="white" class="me-2">mdi-plus-circle</v-icon>
<span>{{ tm('dialog.addPlatform') }}</span>
<v-spacer></v-spacer>
<v-btn icon variant="text" color="white" @click="showAddPlatformDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-4" style="overflow-y: auto;">
<v-row class="mt-1">
<v-col v-for="(template, name) in metadata['platform_group']?.metadata?.platform?.config_template || {}"
:key="name" cols="12" sm="6" md="6">
<v-card variant="outlined" hover class="platform-card" @click="selectPlatformTemplate(name)">
<div class="platform-card-content">
<div class="platform-card-text">
<v-card-title class="platform-card-title">{{ tm('dialog.connectTitle', { name }) }}</v-card-title>
<v-card-text class="text-caption text-medium-emphasis platform-card-description">
{{ getPlatformDescription(template, name) }}
</v-card-text>
</div>
<div class="platform-card-logo">
<img :src="getPlatformIcon(name)" v-if="getPlatformIcon(name)" class="platform-logo-img">
<div v-else class="platform-logo-fallback">
{{ name[0].toUpperCase() }}
</div>
</div>
</div>
</v-card>
</v-col>
<v-col
v-if="Object.keys(metadata['platform_group']?.metadata?.platform?.config_template || {}).length === 0"
cols="12">
<v-alert type="info" variant="tonal">
{{ tm('dialog.noTemplates') }}
</v-alert>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
<!-- 配置对话框 -->
<v-dialog v-model="showPlatformCfg" persistent width="900px" max-width="90%">
<v-card>
<v-card-title class="bg-primary text-white py-3">
<v-icon color="white" class="me-2">{{ updatingMode ? 'mdi-pencil' : 'mdi-plus' }}</v-icon>
<span>{{ updatingMode ? tm('dialog.edit') : tm('dialog.add') }} {{ newSelectedPlatformName }} {{
tm('dialog.adapter') }}</span>
</v-card-title>
<v-card-text class="py-4">
<v-row>
<v-col cols="12">
<AstrBotConfig :iterable="newSelectedPlatformConfig" :metadata="metadata['platform_group']?.metadata"
metadataKey="platform" />
</v-col>
</v-row>
<v-row class="mt-2">
<v-col cols="12" class="text-center">
<v-btn color="info" variant="outlined" @click="openTutorial">
<v-icon start>mdi-book-open-variant</v-icon>
{{ tm('dialog.viewTutorial') }}
</v-btn>
</v-col>
</v-row>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="showPlatformCfg = false" :disabled="loading">
{{ tm('dialog.cancel') }}
</v-btn>
<v-btn color="primary" @click="newPlatform" :loading="loading">
{{ tm('dialog.save') }}
</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>
<WaitingForRestart ref="wfr"></WaitingForRestart>
<!-- ID冲突确认对话框 -->
<v-dialog v-model="showIdConflictDialog" max-width="450" persistent>
<v-card>
<v-card-title class="text-h6 bg-warning d-flex align-center">
<v-icon start class="me-2">mdi-alert-circle-outline</v-icon>
{{ tm('dialog.idConflict.title') }}
</v-card-title>
<v-card-text class="py-4 text-body-1 text-medium-emphasis">
{{ tm('dialog.idConflict.message', { id: conflictId }) }}
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" variant="text" @click="handleIdConflictConfirm(false)">{{ tm('dialog.idConflict.confirm') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</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 ItemCardGrid from '@/components/shared/ItemCardGrid.vue';
import { useCommonStore } from '@/stores/common';
import { useI18n, useModuleI18n } from '@/i18n/composables';
export default {
name: 'PlatformPage',
components: {
AstrBotConfig,
WaitingForRestart,
ConsoleDisplayer,
ItemCardGrid
},
setup() {
const { t } = useI18n();
const { tm } = useModuleI18n('features/platform');
return {
t,
tm
};
},
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')
};
}
},
data() {
return {
config_data: {},
fetched: false,
metadata: {},
showPlatformCfg: false,
showAddPlatformDialog: false,
newSelectedPlatformName: '',
newSelectedPlatformConfig: {},
updatingMode: false,
loading: false,
save_message_snack: false,
save_message: "",
save_message_success: "success",
showConsole: false,
// ID冲突确认对话框
showIdConflictDialog: false,
conflictId: '',
idConflictResolve: null,
store: useCommonStore()
}
},
watch: {
showIdConflictDialog(newValue) {
// 当对话框关闭时,如果 Promise 还在等待,则拒绝它以防止内存泄漏
if (!newValue && this.idConflictResolve) {
this.idConflictResolve(false);
this.idConflictResolve = null;
}
}
},
mounted() {
this.getConfig();
},
methods: {
openTutorial() {
const tutorialUrl = this.getTutorialLink(this.newSelectedPlatformConfig.type);
window.open(tutorialUrl, '_blank');
},
getPlatformIcon(name) {
if (name.includes('QQ')) {
return new URL('@/assets/images/platform_logos/qq.png', import.meta.url).href
} else if (name.includes('企业微信')) {
return new URL('@/assets/images/platform_logos/wecom.png', import.meta.url).href
} else if (name.includes('微信')) {
return new URL('@/assets/images/platform_logos/wechat.png', import.meta.url).href
} else if (name.includes('Lark')) {
return new URL('@/assets/images/platform_logos/lark.png', import.meta.url).href
} else if (name.includes('DingTalk')) {
return new URL('@/assets/images/platform_logos/dingtalk.svg', import.meta.url).href
} else if (name.includes('Telegram')) {
return new URL('@/assets/images/platform_logos/telegram.svg', import.meta.url).href
} else if (name.includes('Discord')) {
return new URL('@/assets/images/platform_logos/discord.svg', import.meta.url).href
} else if (name.includes('Slack')) {
return new URL('@/assets/images/platform_logos/slack.svg', import.meta.url).href
} else if (name.includes('kook')) {
return new URL('@/assets/images/platform_logos/kook.png', import.meta.url).href
} else if (name.includes('vocechat')) {
return new URL('@/assets/images/platform_logos/vocechat.png', import.meta.url).href
}
},
getTutorialLink(platform_type) {
let tutorial_map = {
"qq_official_webhook": "https://astrbot.app/deploy/platform/qqofficial/webhook.html",
"qq_official": "https://astrbot.app/deploy/platform/qqofficial/websockets.html",
"aiocqhttp": "https://astrbot.app/deploy/platform/aiocqhttp/napcat.html",
"wecom": "https://astrbot.app/deploy/platform/wecom.html",
"gewechat": "https://astrbot.app/deploy/platform/wechat/gewechat.html",
"lark": "https://astrbot.app/deploy/platform/lark.html",
"telegram": "https://astrbot.app/deploy/platform/telegram.html",
"dingtalk": "https://astrbot.app/deploy/platform/dingtalk.html",
"wechatpadpro": "https://astrbot.app/deploy/platform/wechat/wechatpadpro.html",
"weixin_official_account": "https://astrbot.app/deploy/platform/weixin-official-account.html",
"discord": "https://astrbot.app/deploy/platform/discord.html",
"slack": "https://astrbot.app/deploy/platform/slack.html",
"kook": "https://astrbot.app/deploy/platform/kook.html",
"vocechat": "https://astrbot.app/deploy/platform/vocechat.html",
}
return tutorial_map[platform_type] || "https://docs.astrbot.app";
},
getPlatformDescription(template, name) {
// special judge for community platforms
if (name.includes('vocechat')) {
return "由 @HikariFroya 提供。";
} else if (name.includes('kook')) {
return "由 @wuyan1003 提供。"
}
},
getConfig() {
axios.get('/api/config/get').then((res) => {
this.config_data = res.data.data.config;
this.fetched = true
this.metadata = res.data.data.metadata;
}).catch((err) => {
this.showError(err);
});
},
// 添加一个新方法来选择平台模板
selectPlatformTemplate(name) {
this.newSelectedPlatformName = name;
this.showPlatformCfg = true;
this.updatingMode = false;
this.newSelectedPlatformConfig = JSON.parse(JSON.stringify(
this.metadata['platform_group']?.metadata?.platform?.config_template[name] || {}
));
this.showAddPlatformDialog = false;
},
addFromDefaultConfigTmpl(index) {
this.newSelectedPlatformName = index[0];
this.showPlatformCfg = true;
this.updatingMode = false;
this.newSelectedPlatformConfig = JSON.parse(JSON.stringify(
this.metadata['platform_group']?.metadata?.platform?.config_template[index[0]] || {}
));
},
editPlatform(platform) {
this.newSelectedPlatformName = platform.id;
this.newSelectedPlatformConfig = JSON.parse(JSON.stringify(platform));
this.updatingMode = true;
this.showPlatformCfg = true;
},
newPlatform() {
this.loading = true;
if (this.updatingMode) {
axios.post('/api/config/platform/update', {
id: this.newSelectedPlatformName,
config: this.newSelectedPlatformConfig
}).then((res) => {
this.loading = false;
this.showPlatformCfg = false;
this.getConfig();
this.$refs.wfr.check();
this.showSuccess(res.data.message || this.messages.updateSuccess);
}).catch((err) => {
this.loading = false;
this.showError(err.response?.data?.message || err.message);
});
this.updatingMode = false;
} else {
this.savePlatform();
}
},
async savePlatform() {
// 检查 ID 是否已存在
const existingPlatform = this.config_data.platform?.find(p => p.id === this.newSelectedPlatformConfig.id);
if (existingPlatform) {
const confirmed = await this.confirmIdConflict(this.newSelectedPlatformConfig.id);
if (!confirmed) {
this.loading = false;
return; // 如果用户取消,则中止保存
}
}
try {
const res = await axios.post('/api/config/platform/new', this.newSelectedPlatformConfig);
this.loading = false;
this.showPlatformCfg = false;
this.getConfig();
this.showSuccess(res.data.message || this.messages.addSuccess);
} catch (err) {
this.loading = false;
this.showError(err.response?.data?.message || err.message);
}
},
confirmIdConflict(id) {
this.conflictId = id;
this.showIdConflictDialog = true;
return new Promise((resolve) => {
this.idConflictResolve = resolve;
});
},
handleIdConflictConfirm(confirmed) {
if (this.idConflictResolve) {
this.idConflictResolve(confirmed);
}
this.showIdConflictDialog = false;
},
deletePlatform(platform) {
if (confirm(`${this.messages.deleteConfirm} ${platform.id}?`)) {
axios.post('/api/config/platform/delete', { id: platform.id }).then((res) => {
this.getConfig();
this.$refs.wfr.check();
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.$refs.wfr.check();
this.showSuccess(res.data.message || this.messages.statusUpdateSuccess);
}).catch((err) => {
platform.enable = !platform.enable; // 发生错误时回滚状态
this.showError(err.response?.data?.message || err.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;
}
}
}
</script>
<style scoped>
.platform-page {
padding: 20px;
padding-top: 8px;
}
.platform-selection-dialog .v-card-title {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.platform-card {
transition: all 0.3s ease;
height: 100%;
cursor: pointer;
overflow: hidden;
position: relative;
}
.platform-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 25px 0 rgba(0, 0, 0, 0.05);
border-color: var(--v-primary-base);
}
.platform-card-content {
display: flex;
align-items: center;
height: 100px;
padding: 16px;
position: relative;
z-index: 2;
}
.platform-card-text {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.platform-card-title {
font-size: 15px;
font-weight: 600;
margin-bottom: 4px;
padding: 0;
}
.platform-card-description {
padding: 0;
margin: 0;
}
.platform-card-logo {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 80px;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
.platform-logo-img {
max-width: 60px;
max-height: 60px;
opacity: 0.6;
object-fit: contain;
}
.platform-logo-fallback {
width: 50px;
height: 50px;
border-radius: 50%;
background-color: var(--v-primary-base);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: bold;
opacity: 0.3;
}
</style>