Files
AstrBot/dashboard/src/components/platform/AddNewPlatform.vue
Weilong Liao 55af880369 feat(qqofficial): allow QQ Official Webhook adapters to proactively send group messages without requiring a cached msg_id (#8841)
* feat(qqofficial): support webhook QR setup

* docs(qqofficial): simplify webhook QR setup step
2026-06-17 21:13:05 +08:00

1971 lines
63 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>
<v-dialog
v-model="showDialog"
max-width="800px"
max-height="90%"
@after-enter="prepareData"
>
<v-card
:title="
updatingMode
? `${tm('dialog.edit')} ${updatingPlatformConfig.id} ${tm(
'dialog.adapter',
)}`
: tm('dialog.addPlatform')
"
>
<v-card-text
ref="dialogScrollContainer"
class="pa-4 ml-2"
style="overflow-y: auto"
>
<div class="d-flex align-start" style="width: 100%">
<div>
<v-icon icon="mdi-numeric-1-circle" class="mr-3"></v-icon>
</div>
<div style="flex: 1">
<h3>
{{ tm("createDialog.step1Title") }}
</h3>
<small style="color: grey">{{
tm("createDialog.step1Hint")
}}</small>
<div>
<div v-if="!updatingMode">
<v-select
v-model="selectedPlatformType"
:items="Object.keys(platformTemplates)"
item-title="name"
item-value="name"
:label="tm('createDialog.platformTypeLabel')"
variant="outlined"
rounded="md"
dense
hide-details
class="mt-6"
style="max-width: 30%; min-width: 300px"
>
<template v-slot:item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps">
<template v-slot:prepend>
<img
:src="
getPlatformIcon(platformTemplates[item.raw].type)
"
style="
width: 32px;
height: 32px;
object-fit: contain;
margin-right: 16px;
"
/>
</template>
</v-list-item>
</template>
</v-select>
<div class="mt-3" v-if="selectedPlatformConfig">
<div v-if="isLarkPlatform">
<div class="creation-mode-title mt-4 mb-1">
{{ tm("registrationAction.mode.title") }}
</div>
<v-radio-group
v-model="larkCreationMode"
class="creation-mode-group"
hide-details
>
<v-radio
value="scan"
:label="tm('registrationAction.mode.scan')"
></v-radio>
<v-radio
value="manual"
:label="tm('registrationAction.mode.larkManual')"
></v-radio>
</v-radio-group>
<div
v-if="larkCreationMode === 'scan'"
class="registration-inline mt-3"
>
<PlatformRegistrationAction
:platform-config="selectedPlatformConfig"
:active="larkCreationMode === 'scan'"
@created="handlePlatformRegistrationCreated"
@success="showSuccess"
@error="showError"
/>
</div>
<div v-else-if="larkCreationMode === 'manual'" class="mt-2">
<div class="platform-action-row">
<v-btn
color="info"
variant="tonal"
@click="openTutorial"
class="mt-2"
>
<v-icon start>mdi-book-open-variant</v-icon>
{{ tm("dialog.viewTutorial") }}
</v-btn>
</div>
<AstrBotConfig
:iterable="selectedPlatformConfig"
:metadata="metadata['platform_group']?.metadata"
metadataKey="platform"
/>
</div>
</div>
<div v-else-if="isDingtalkPlatform">
<div class="creation-mode-title mt-4 mb-1">
{{ tm("registrationAction.mode.title") }}
</div>
<v-radio-group
v-model="dingtalkCreationMode"
class="creation-mode-group"
hide-details
>
<v-radio
value="scan"
:label="tm('registrationAction.mode.scan')"
></v-radio>
<v-radio
value="manual"
:label="tm('registrationAction.mode.manual')"
></v-radio>
</v-radio-group>
<div
v-if="dingtalkCreationMode === 'scan'"
class="registration-inline mt-3"
>
<PlatformRegistrationAction
:platform-config="selectedPlatformConfig"
:active="dingtalkCreationMode === 'scan'"
@created="handlePlatformRegistrationCreated"
@success="showSuccess"
@error="showError"
/>
</div>
<div
v-else-if="dingtalkCreationMode === 'manual'"
class="mt-2"
>
<div class="platform-action-row">
<v-btn
color="info"
variant="tonal"
@click="openTutorial"
class="mt-2"
>
<v-icon start>mdi-book-open-variant</v-icon>
{{ tm("dialog.viewTutorial") }}
</v-btn>
</div>
<AstrBotConfig
:iterable="selectedPlatformConfig"
:metadata="metadata['platform_group']?.metadata"
metadataKey="platform"
/>
</div>
</div>
<div v-else-if="isQqOfficialPlatform">
<div class="creation-mode-title mt-4 mb-1">
{{ tm("registrationAction.mode.title") }}
</div>
<v-radio-group
v-model="qqOfficialCreationMode"
class="creation-mode-group"
hide-details
>
<v-radio
value="scan"
:label="tm('registrationAction.mode.scan')"
></v-radio>
<v-radio
value="manual"
:label="tm('registrationAction.mode.manual')"
></v-radio>
</v-radio-group>
<div
v-if="qqOfficialCreationMode === 'scan'"
class="registration-inline mt-3"
>
<PlatformRegistrationAction
:platform-config="selectedPlatformConfig"
:active="qqOfficialCreationMode === 'scan'"
@created="handlePlatformRegistrationCreated"
@success="showSuccess"
@error="showError"
/>
</div>
<div
v-else-if="qqOfficialCreationMode === 'manual'"
class="mt-2"
>
<div class="platform-action-row">
<v-btn
color="info"
variant="tonal"
@click="openTutorial"
class="mt-2"
>
<v-icon start>mdi-book-open-variant</v-icon>
{{ tm("dialog.viewTutorial") }}
</v-btn>
</div>
<AstrBotConfig
:iterable="selectedPlatformConfig"
:metadata="metadata['platform_group']?.metadata"
metadataKey="platform"
/>
</div>
</div>
<div
v-else-if="isWeixinOcPlatform"
class="weixin-oc-registration-inline mt-4"
>
<PlatformRegistrationAction
:platform-config="selectedPlatformConfig"
:active="isWeixinOcPlatform"
@created="handlePlatformRegistrationCreated"
@success="showSuccess"
@error="showError"
/>
</div>
<div v-else class="mt-2">
<div class="platform-action-row">
<v-btn
color="info"
variant="tonal"
@click="openTutorial"
class="mt-2"
>
<v-icon start>mdi-book-open-variant</v-icon>
{{ tm("dialog.viewTutorial") }}
</v-btn>
</div>
<AstrBotConfig
:iterable="selectedPlatformConfig"
:metadata="metadata['platform_group']?.metadata"
metadataKey="platform"
/>
</div>
</div>
</div>
<div v-else>
<v-text-field
:label="tm('createDialog.platformTypeLabel')"
variant="outlined"
rounded="md"
dense
hide-details
class="mt-6"
style="max-width: 30%; min-width: 300px"
v-model="updatingPlatformConfig.type"
disabled
></v-text-field>
<div class="mt-3">
<div class="mt-2">
<AstrBotConfig
:iterable="updatingPlatformConfig"
:metadata="metadata['platform_group']?.metadata"
metadataKey="platform"
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="d-flex align-start mt-6">
<div>
<v-icon icon="mdi-numeric-2-circle" class="mr-3"></v-icon>
</div>
<div style="flex: 1">
<div class="d-flex align-center justify-space-between">
<div>
<div class="d-flex align-center">
<h3>
{{ tm("createDialog.configFileTitle") }}
</h3>
<v-chip
size="x-small"
color="primary"
variant="tonal"
rounded="sm"
class="ml-2"
v-if="!updatingMode"
>{{ tm("createDialog.optional") }}</v-chip
>
</div>
<small style="color: grey">{{
tm("createDialog.configHint")
}}</small>
<small style="color: grey" v-if="!updatingMode">{{
tm("createDialog.configDefaultHint")
}}</small>
</div>
<div>
<v-btn
variant="plain"
icon
@click="toggleConfigSection"
class="mt-2"
>
<v-icon>{{
showConfigSection ? "mdi-chevron-up" : "mdi-chevron-down"
}}</v-icon>
</v-btn>
</div>
</div>
<div v-if="showConfigSection">
<div v-if="!updatingMode">
<v-radio-group
class="mt-2"
v-model="aBConfigRadioVal"
hide-details="true"
>
<v-radio value="0">
<template v-slot:label>
<span>{{ tm("createDialog.useExistingConfig") }}</span>
</template>
</v-radio>
<div
class="d-flex align-center ml-10 my-2"
v-if="aBConfigRadioVal === '0'"
>
<v-select
v-model="selectedAbConfId"
:items="configInfoList"
item-title="name"
item-value="id"
:label="tm('createDialog.selectConfigLabel')"
variant="outlined"
rounded="md"
dense
hide-details
style="max-width: 30%; min-width: 200px"
>
</v-select>
<v-btn
icon
variant="text"
density="comfortable"
class="ml-2"
:disabled="!selectedAbConfId"
@click="openConfigDrawer(selectedAbConfId)"
>
<v-icon>mdi-arrow-top-right-thick</v-icon>
</v-btn>
</div>
<v-radio
value="1"
:label="tm('createDialog.createNewConfig')"
>
</v-radio>
<div
class="d-flex align-center"
v-if="aBConfigRadioVal === '1'"
>
<v-text-field
v-model="selectedAbConfId"
:label="tm('createDialog.newConfigNameLabel')"
variant="outlined"
rounded="md"
dense
hide-details
style="max-width: 30%; min-width: 200px"
class="ml-10 my-2"
>
</v-text-field>
</div>
</v-radio-group>
<!-- 现有配置文件预览区域 -->
<!-- <div v-if="aBConfigRadioVal === '0' && selectedAbConfId" class="mt-4">
<div v-if="configPreviewLoading" class="d-flex justify-center py-4">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
</div>
<div v-else-if="selectedConfigData && selectedConfigMetadata" class="config-preview-container">
<h4 class="mb-3">配置文件预览</h4>
<AstrBotCoreConfigWrapper :metadata="selectedConfigMetadata" :config_data="selectedConfigData"
readonly="true" />
</div>
<div v-else class="text-center py-4 text-grey">
<v-icon>mdi-information-outline</v-icon>
<p class="mt-2">无法加载配置文件预览</p>
</div>
</div> -->
<!-- 新配置文件编辑区域 -->
<div v-if="aBConfigRadioVal === '1'" class="mt-4">
<div
v-if="newConfigLoading"
class="d-flex justify-center py-4"
>
<v-progress-circular
indeterminate
color="primary"
></v-progress-circular>
</div>
<div
v-else-if="newConfigData && newConfigMetadata"
class="config-preview-container"
>
<h4 class="mb-3">
{{ tm("createDialog.newConfigTitle") }}
</h4>
<AstrBotCoreConfigWrapper
:metadata="newConfigMetadata"
:config_data="newConfigData"
/>
</div>
<div v-else class="text-center py-4 text-grey">
<v-icon>mdi-information-outline</v-icon>
<p class="mt-2">
{{ tm("createDialog.newConfigLoadFailed") }}
</p>
</div>
</div>
</div>
<div v-else>
<div class="mb-3 d-flex align-center justify-space-between">
<div>
<v-btn
v-if="isEditingRoutes"
color="primary"
variant="tonal"
@click="addNewRoute"
size="small"
>
<v-icon start>mdi-plus</v-icon>
{{ tm("createDialog.addRouteRule") }}
</v-btn>
</div>
<v-btn
:color="isEditingRoutes ? 'grey' : 'primary'"
variant="tonal"
size="small"
@click="toggleEditMode"
>
<v-icon start>{{
isEditingRoutes ? "mdi-eye" : "mdi-pencil"
}}</v-icon>
{{
isEditingRoutes
? tm("createDialog.viewMode")
: tm("createDialog.editMode")
}}
</v-btn>
</div>
<v-data-table
:headers="routeTableHeaders"
:items="platformRoutes"
item-value="umop"
:no-data-text="tm('createDialog.noRouteRules')"
hide-default-footer
:items-per-page="-1"
class="mt-2"
variant="outlined"
>
<template v-slot:item.source="{ item }">
<div class="route-source-cell">
<div
class="d-flex align-center route-source-input-row"
:class="{
'route-source-input-row--editing': isEditingRoutes,
}"
style="min-width: 250px"
>
<v-autocomplete
v-if="
isEditingRoutes &&
updatingMode &&
getRouteSourceMode(item) === 'known'
"
v-model="item.sourceUmo"
:items="filteredKnownRouteUmoItems"
:loading="loadingKnownRouteUmos"
variant="outlined"
density="compact"
hide-details
clearable
:placeholder="
tm('createDialog.routeSource.selectPlaceholder')
"
:no-data-text="tm('createDialog.routeSource.noData')"
style="min-width: 260px"
@update:model-value="
applyKnownRouteSource(item, $event)
"
@focus="loadKnownRouteUmos"
>
<template v-slot:item="{ props, item: sourceItem }">
<v-list-item v-bind="props">
<template v-slot:title>
<UmoDisplay
v-bind="
getKnownRouteUmoDisplayProps(sourceItem.raw)
"
compact
:show-info="false"
/>
</template>
</v-list-item>
</template>
<template v-slot:selection="{ item: sourceItem }">
<v-chip
v-if="
sourceItem &&
getKnownRouteUmoSelectionText(sourceItem.raw)
"
size="small"
variant="tonal"
color="primary"
class="umo-selection-chip"
>
{{
getKnownRouteUmoSelectionText(sourceItem.raw)
}}
</v-chip>
</template>
</v-autocomplete>
<template v-else>
<v-select
v-if="isEditingRoutes"
v-model="item.messageType"
:items="messageTypeOptions"
item-title="label"
item-value="value"
variant="outlined"
density="compact"
hide-details
style="max-width: 140px"
>
</v-select>
<small v-else>{{
getMessageTypeLabel(item.messageType)
}}</small>
<small class="mx-1">:</small>
<v-text-field
v-if="isEditingRoutes"
v-model="item.sessionId"
variant="outlined"
density="compact"
hide-details
:placeholder="
tm('createDialog.sessionIdPlaceholder')
"
>
</v-text-field>
<small v-else>{{
item.sessionId === "*"
? tm("createDialog.allSessions")
: item.sessionId
}}</small>
</template>
</div>
<span
v-if="updatingMode && isEditingRoutes"
class="route-source-mode-link"
@click="toggleRouteSourceMode(item)"
>
{{ getRouteSourceModeLinkText(item) }}
</span>
</div>
</template>
<template v-slot:item.configId="{ item }">
<div class="d-flex align-center">
<v-select
v-if="isEditingRoutes"
v-model="item.configId"
:items="configInfoList"
item-title="name"
item-value="id"
variant="outlined"
density="compact"
style="min-width: 200px"
hide-details
>
</v-select>
<div v-else>
<small>{{ getConfigName(item.configId) }}</small>
</div>
<v-btn
icon
variant="text"
density="compact"
class="ml-2"
:disabled="!item.configId"
@click="openConfigDrawer(item.configId)"
>
<v-icon size="18">mdi-arrow-top-right-thick</v-icon>
</v-btn>
</div>
<small
v-if="
configInfoList.findIndex(
(c) => c.id === item.configId,
) === -1
"
style="color: red"
class="ml-2"
>{{ tm("createDialog.configMissing") }}</small
>
</template>
<template v-slot:item.actions="{ item, index }">
<div v-if="isEditingRoutes" class="d-flex align-center">
<v-btn
icon
size="x-small"
variant="text"
@click="moveRouteUp(index)"
:disabled="index === 0"
>
<v-icon>mdi-arrow-up</v-icon>
</v-btn>
<v-btn
icon
size="x-small"
variant="text"
@click="moveRouteDown(index)"
:disabled="index === platformRoutes.length - 1"
>
<v-icon>mdi-arrow-down</v-icon>
</v-btn>
<v-btn
icon
size="x-small"
variant="text"
color="error"
@click="deleteRoute(index)"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
</div>
<span v-else class="text-grey">-</span>
</template>
</v-data-table>
<small class="ml-2 mt-2 d-block" style="color: grey">{{
tm("createDialog.routeHint")
}}</small>
</div>
</div>
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text @click="closeDialog">{{ tm("dialog.cancel") }}</v-btn>
<v-btn
:disabled="!canSave"
color="primary"
v-if="!updatingMode"
@click="newPlatform"
:loading="loading"
>{{ tm("dialog.save") }}</v-btn
>
<v-btn
:disabled="!selectedAbConfId"
color="primary"
v-else
@click="newPlatform"
:loading="loading"
>{{ tm("dialog.save") }}</v-btn
>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 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>
<!-- 安全警告对话框 -->
<v-dialog v-model="showOneBotEmptyTokenWarnDialog" max-width="600" persistent>
<v-card>
<v-card-title>
{{ tm("dialog.securityWarning.title") }}
</v-card-title>
<v-card-text class="py-4">
<p>{{ tm("dialog.securityWarning.aiocqhttpTokenMissing") }}</p>
<span
><a
href="https://docs.astrbot.app/platform/aiocqhttp.html"
target="_blank"
>{{ tm("dialog.securityWarning.learnMore") }}</a
></span
>
</v-card-text>
<v-card-actions class="px-4 pb-4">
<v-spacer></v-spacer>
<v-btn
color="error"
@click="handleOneBotEmptyTokenWarningDismiss(true)"
>
{{ tm("createDialog.warningContinue") }}
</v-btn>
<v-btn
color="primary"
@click="handleOneBotEmptyTokenWarningDismiss(false)"
>
{{ tm("createDialog.warningEditAgain") }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-overlay
v-model="showConfigDrawer"
class="config-drawer-overlay"
location="right"
transition="slide-x-reverse-transition"
:scrim="true"
@click:outside="closeConfigDrawer"
>
<v-card class="config-drawer-card" elevation="12">
<div class="config-drawer-header">
<div>
<span class="text-h6">{{
tm("createDialog.configDrawerTitle")
}}</span>
<div v-if="configDrawerTargetId" class="text-caption text-grey">
{{ tm("createDialog.configDrawerIdLabel") }}:
{{ configDrawerTargetId }}
</div>
</div>
<v-btn icon variant="text" @click="closeConfigDrawer">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<v-divider></v-divider>
<div class="config-drawer-content">
<ConfigPage
v-if="showConfigDrawer"
:initial-config-id="configDrawerTargetId"
/>
</div>
</v-card>
</v-overlay>
</template>
<script>
import { botApi, configProfileApi, configRouteApi, fileApi, sessionApi } from "@/api/v1";
import { useModuleI18n } from "@/i18n/composables";
import {
getPlatformIcon,
getPlatformDescription,
getTutorialLink,
} from "@/utils/platformUtils";
import AstrBotConfig from "@/components/shared/AstrBotConfig.vue";
import AstrBotCoreConfigWrapper from "@/components/config/AstrBotCoreConfigWrapper.vue";
import ConfigPage from "@/views/ConfigPage.vue";
import PlatformRegistrationAction from "@/components/platform/PlatformRegistrationAction.vue";
import UmoDisplay from "@/components/shared/UmoDisplay.vue";
export default {
name: "AddNewPlatform",
components: {
AstrBotConfig,
AstrBotCoreConfigWrapper,
ConfigPage,
PlatformRegistrationAction,
UmoDisplay,
},
emits: ["update:show", "show-toast", "refresh-config"],
props: {
show: {
type: Boolean,
default: false,
},
metadata: {
type: Object,
default: () => ({}),
},
config_data: {
type: Object,
default: () => ({}),
},
updatingMode: {
type: Boolean,
default: false,
},
updatingPlatformConfig: {
type: Object,
default: null,
},
},
data() {
return {
selectedPlatformType: null,
selectedPlatformConfig: null,
larkCreationMode: "",
dingtalkCreationMode: "",
qqOfficialCreationMode: "",
aBConfigRadioVal: "0",
selectedAbConfId: "default",
configInfoList: [],
// 选中的配置文件预览数据
selectedConfigData: null,
selectedConfigMetadata: null,
configPreviewLoading: false,
// 新配置文件相关数据
newConfigData: null,
newConfigMetadata: null,
newConfigLoading: false,
// 平台配置文件表格(已弃用,改用 platformRoutes
platformConfigs: [],
// 平台路由表
platformRoutes: [],
isEditingRoutes: false, // 编辑模式开关
knownRouteUmos: [],
knownRouteUmoInfoMap: {},
loadingKnownRouteUmos: false,
// ID冲突确认对话框
showIdConflictDialog: false,
conflictId: "",
idConflictResolve: null,
// OneBot Empty Token Warning #2639
showOneBotEmptyTokenWarnDialog: false,
oneBotEmptyTokenWarningResolve: null,
loading: false,
showConfigSection: false,
// 配置抽屉
showConfigDrawer: false,
configDrawerTargetId: null,
// 保存更新前的平台 ID防止用户修改 ID 后丢失原始定位
originalUpdatingPlatformId: null,
};
},
setup() {
const { tm } = useModuleI18n("features/platform");
return { tm };
},
computed: {
showDialog: {
get() {
return this.show;
},
set(value) {
this.$emit("update:show", value);
},
},
platformTemplates() {
return (
this.metadata["platform_group"]?.metadata?.platform?.config_template ||
{}
);
},
canSave() {
// 基本条件:必须选择平台类型
if (!this.selectedPlatformType) {
return false;
}
if (!this.isPlatformIdValid(this.selectedPlatformConfig?.id)) {
return false;
}
if (this.isLarkPlatform && !this.larkCreationMode) {
return false;
}
if (this.isLarkPlatform && this.larkCreationMode === "scan") {
if (
!this.selectedPlatformConfig?.app_id ||
!this.selectedPlatformConfig?.app_secret
) {
return false;
}
}
if (this.isDingtalkPlatform && !this.dingtalkCreationMode) {
return false;
}
if (this.isDingtalkPlatform && this.dingtalkCreationMode === "scan") {
if (
!this.selectedPlatformConfig?.client_id ||
!this.selectedPlatformConfig?.client_secret
) {
return false;
}
}
if (this.isQqOfficialPlatform && !this.qqOfficialCreationMode) {
return false;
}
if (this.isQqOfficialPlatform && this.qqOfficialCreationMode === "scan") {
if (
!this.selectedPlatformConfig?.appid ||
!this.selectedPlatformConfig?.secret
) {
return false;
}
}
if (
this.isWeixinOcPlatform &&
!this.selectedPlatformConfig?.weixin_oc_token
) {
return false;
}
// 如果是使用现有配置文件模式
if (this.aBConfigRadioVal === "0") {
return !!this.selectedAbConfId;
}
// 如果是创建新配置文件模式
if (this.aBConfigRadioVal === "1") {
// 需要配置文件名称,且新配置数据已加载
return !!(this.selectedAbConfId && this.newConfigData);
}
return false;
},
configTableHeaders() {
return [
{
title: this.tm("createDialog.configTableHeaders.configId"),
key: "name",
sortable: false,
},
{
title: this.tm("createDialog.configTableHeaders.scope"),
key: "scope",
sortable: false,
},
];
},
routeTableHeaders() {
return [
{
title: this.tm("createDialog.routeTableHeaders.source"),
key: "source",
sortable: false,
width: "60%",
},
{
title: this.tm("createDialog.routeTableHeaders.config"),
key: "configId",
sortable: false,
width: "20%",
},
{
title: this.tm("createDialog.routeTableHeaders.actions"),
key: "actions",
sortable: false,
align: "center",
width: "20%",
},
];
},
messageTypeOptions() {
return [
{ label: this.tm("createDialog.messageTypeOptions.all"), value: "*" },
{
label: this.tm("createDialog.messageTypeOptions.group"),
value: "GroupMessage",
},
{
label: this.tm("createDialog.messageTypeOptions.friend"),
value: "FriendMessage",
},
];
},
routePlatformId() {
if (this.updatingMode) {
return (
this.updatingPlatformConfig?.id ||
this.originalUpdatingPlatformId ||
""
);
}
return this.selectedPlatformConfig?.id || "";
},
filteredKnownRouteUmoItems() {
const platformId = this.routePlatformId;
return this.knownRouteUmos.filter((umo) => {
const parsed = this.parseUmop(umo);
return parsed && parsed.platform === platformId;
});
},
isLarkPlatform() {
return this.selectedPlatformConfig?.type === "lark";
},
isWeixinOcPlatform() {
return this.selectedPlatformConfig?.type === "weixin_oc";
},
isDingtalkPlatform() {
return this.selectedPlatformConfig?.type === "dingtalk";
},
isQqOfficialPlatform() {
return ["qq_official", "qq_official_webhook"].includes(
this.selectedPlatformConfig?.type,
);
},
},
watch: {
selectedPlatformType(newType) {
if (newType && this.platformTemplates[newType]) {
this.selectedPlatformConfig = JSON.parse(
JSON.stringify(this.platformTemplates[newType]),
);
this.larkCreationMode = "";
this.dingtalkCreationMode = "";
this.qqOfficialCreationMode = "";
} else {
this.selectedPlatformConfig = null;
this.larkCreationMode = "";
this.dingtalkCreationMode = "";
this.qqOfficialCreationMode = "";
}
},
selectedAbConfId(newConfigId) {
// 当选择配置文件改变时,获取配置文件数据用于预览
if (!this.updatingMode && this.aBConfigRadioVal === "0" && newConfigId) {
this.getConfigForPreview(newConfigId);
} else {
this.selectedConfigData = null;
this.selectedConfigMetadata = null;
}
},
aBConfigRadioVal(newVal) {
// 当切换到创建新配置文件时,获取默认配置模板
if (newVal === "1") {
this.selectedConfigData = null;
this.selectedConfigMetadata = null;
this.selectedAbConfId = null;
this.getDefaultConfigTemplate();
} else if (newVal === "0") {
// 如果切换回使用现有配置文件但没有选择配置文件,重置为默认
this.newConfigData = null;
this.newConfigMetadata = null;
if (!this.selectedAbConfId) {
this.selectedAbConfId = "default";
}
}
},
showIdConflictDialog(newValue) {
if (!newValue && this.idConflictResolve) {
this.idConflictResolve(false);
this.idConflictResolve = null;
}
},
showOneBotEmptyTokenWarnDialog(newValue) {
if (!newValue && this.oneBotEmptyTokenWarningResolve) {
this.oneBotEmptyTokenWarningResolve(true);
this.oneBotEmptyTokenWarningResolve = null;
}
},
// 监听更新模式变化,获取相关配置文件
updatingPlatformConfig: {
handler(newConfig) {
if (this.updatingMode && newConfig && newConfig.id) {
this.originalUpdatingPlatformId = newConfig.id;
this.getPlatformConfigs(newConfig.id);
}
},
immediate: true,
},
showConfigSection(newValue) {
if (newValue && !this.updatingMode && this.aBConfigRadioVal === "0") {
this.getConfigForPreview(this.selectedAbConfId);
}
if (newValue) {
this.$nextTick(() => {
this.scrollDialogToBottom();
});
}
},
// 监听编辑模式变化,自动展开配置文件部分
updatingMode: {
handler(newValue) {
if (newValue) {
this.showConfigSection = true;
// 编辑模式下默认不开启路由编辑模式,用户需要手动点击
this.isEditingRoutes = false;
}
},
immediate: true,
},
},
methods: {
getPlatformIcon(platformType) {
// Check for plugin-provided logo_token first
const template = this.platformTemplates?.[platformType];
if (template && template.logo_token) {
return fileApi.tokenUrl(template.logo_token);
}
return getPlatformIcon(platformType);
},
getPlatformDescription,
resetForm() {
this.selectedPlatformType = null;
this.selectedPlatformConfig = null;
this.larkCreationMode = "";
this.dingtalkCreationMode = "";
this.qqOfficialCreationMode = "";
this.aBConfigRadioVal = "0";
this.selectedAbConfId = "default";
// 重置配置预览数据
this.selectedConfigData = null;
this.selectedConfigMetadata = null;
this.configPreviewLoading = false;
// 重置新配置文件数据
this.newConfigData = null;
this.newConfigMetadata = null;
this.newConfigLoading = false;
this.showConfigSection = false;
this.isEditingRoutes = false; // 重置编辑模式
this.knownRouteUmos = [];
this.knownRouteUmoInfoMap = {};
this.loadingKnownRouteUmos = false;
this.showConfigDrawer = false;
this.configDrawerTargetId = null;
this.originalUpdatingPlatformId = null;
},
closeDialog() {
this.resetForm();
this.showDialog = false;
},
async getConfigInfoList() {
await configProfileApi.list().then((res) => {
this.configInfoList = res.data.data.info_list;
});
},
// 获取配置文件数据用于预览
async getConfigForPreview(configId) {
if (!configId) {
this.selectedConfigData = null;
this.selectedConfigMetadata = null;
return;
}
this.configPreviewLoading = true;
try {
const response = await configProfileApi.get(configId);
this.selectedConfigData = response.data.data.config;
this.selectedConfigMetadata = response.data.data.metadata;
} catch (error) {
console.error("获取配置文件预览数据失败:", error);
this.selectedConfigData = null;
this.selectedConfigMetadata = null;
} finally {
this.configPreviewLoading = false;
}
},
// 获取默认配置模板用于创建新配置文件
async getDefaultConfigTemplate() {
this.newConfigLoading = true;
try {
const response = await configProfileApi.schema();
this.newConfigData = response.data.data.config;
this.newConfigMetadata = response.data.data.metadata;
} catch (error) {
console.error("获取默认配置模板失败:", error);
this.newConfigData = null;
this.newConfigMetadata = null;
} finally {
this.newConfigLoading = false;
}
},
openTutorial() {
const tutorialUrl = getTutorialLink(this.selectedPlatformConfig.type);
window.open(tutorialUrl, "_blank");
},
openConfigDrawer(configId) {
const targetId = configId || "default";
if (
configId &&
this.configInfoList.findIndex((c) => c.id === configId) === -1
) {
this.showError(this.tm("messages.configNotFoundOpenConfig"));
}
this.configDrawerTargetId = targetId;
this.showConfigDrawer = true;
},
closeConfigDrawer() {
this.showConfigDrawer = false;
},
newPlatform() {
this.loading = true;
if (this.updatingMode) {
if (this.updatingPlatformConfig.type === "aiocqhttp") {
const token = this.updatingPlatformConfig.ws_reverse_token;
if (!token || token.trim() === "") {
this.showOneBotEmptyTokenWarning().then((continueWithWarning) => {
if (continueWithWarning) {
this.updatePlatform();
} else {
this.loading = false;
}
});
return;
}
}
this.updatePlatform();
} else {
this.savePlatform();
}
},
async updatePlatform() {
const id =
this.originalUpdatingPlatformId || this.updatingPlatformConfig.id;
if (!id) {
this.loading = false;
this.showError(this.tm("messages.updateMissingPlatformId"));
return;
}
if (!this.isPlatformIdValid(id)) {
this.loading = false;
this.showError(this.tm("dialog.invalidPlatformId"));
return;
}
try {
// 更新平台配置
let resp = await botApi.update(id, this.updatingPlatformConfig);
if (resp.data.status === "error") {
throw new Error(
resp.data.message || this.tm("messages.platformUpdateFailed"),
);
}
// 同时更新路由表
await this.saveRoutesInternal();
this.loading = false;
this.showDialog = false;
this.resetForm();
this.$emit("refresh-config");
this.showSuccess(this.tm("messages.updateSuccess"));
} catch (err) {
this.loading = false;
this.showError(err.response?.data?.message || err.message);
}
},
async savePlatform() {
if (!this.isPlatformIdValid(this.selectedPlatformConfig?.id)) {
this.loading = false;
this.showError(this.tm("dialog.invalidPlatformId"));
return;
}
// 检查 ID 是否已存在
const existingPlatform = this.config_data.platform?.find(
(p) => p.id === this.selectedPlatformConfig.id,
);
if (existingPlatform || this.selectedPlatformConfig.id === "webchat") {
const confirmed = await this.confirmIdConflict(
this.selectedPlatformConfig.id,
);
if (!confirmed) {
this.loading = false;
return; // 如果用户取消,则中止保存
}
}
// 检查 aiocqhttp 适配器的安全设置
if (this.selectedPlatformConfig.type === "aiocqhttp") {
const token = this.selectedPlatformConfig.ws_reverse_token;
if (!token || token.trim() === "") {
const continueWithWarning = await this.showOneBotEmptyTokenWarning();
if (!continueWithWarning) {
return;
}
}
}
try {
// 先保存平台配置
const res = await botApi.create(this.selectedPlatformConfig);
// 平台保存成功后,处理配置文件
await this.handleConfigFile();
this.loading = false;
this.showDialog = false;
this.resetForm();
this.$emit("refresh-config");
this.showSuccess(
res.data.message || this.tm("messages.addSuccessWithConfig"),
);
} catch (err) {
this.loading = false;
this.showError(err.response?.data?.message || err.message);
}
},
async handleConfigFile() {
if (!this.selectedAbConfId) {
return;
}
const platformId = this.selectedPlatformConfig.id;
// 生成默认的UMOP平台ID:*:*(表示该平台的所有消息类型和会话)
const newUmop = `${platformId}:*:*`;
let configId = null;
// 第一步创建或获取配置文件ID
if (this.aBConfigRadioVal === "0") {
// 使用现有配置文件
configId = this.selectedAbConfId;
} else if (this.aBConfigRadioVal === "1") {
// 创建新配置文件
configId = await this.createNewConfigFile(this.selectedAbConfId);
}
if (!configId) {
throw new Error(this.tm("messages.configIdMissing"));
}
// 第二步:统一更新路由表
await this.updateRoutingTable(newUmop, configId);
},
async updateRoutingTable(umop, configId) {
try {
await configRouteApi.upsert(umop, { config_id: configId });
console.log(`成功更新路由表: ${umop} -> ${configId}`);
} catch (err) {
console.error("更新路由表失败:", err);
const errorMessage = err.response?.data?.message || err.message;
throw new Error(
this.tm("messages.routingUpdateFailed", { message: errorMessage }),
);
}
},
async createNewConfigFile(configName) {
try {
// 准备配置数据,如果是创建模式且有新配置数据,使用用户填写的配置
const configData =
this.aBConfigRadioVal === "1" && this.newConfigData
? this.newConfigData
: undefined;
// 创建新的配置文件不传入umop
const createRes = await configProfileApi.create({
name: configName,
config: configData, // 传入用户配置的数据
});
const newConfigId = createRes.data.data.conf_id;
console.log(`成功创建新配置文件 ${configName}ID: ${newConfigId}`);
return newConfigId;
} catch (err) {
console.error("创建新配置文件失败:", err);
const errorMessage = err.response?.data?.message || err.message;
throw new Error(
this.tm("messages.createConfigFailed", { message: errorMessage }),
);
}
},
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;
},
showOneBotEmptyTokenWarning() {
this.showOneBotEmptyTokenWarnDialog = true;
return new Promise((resolve) => {
this.oneBotEmptyTokenWarningResolve = resolve;
});
},
handleOneBotEmptyTokenWarningDismiss(continueWithWarning) {
this.showOneBotEmptyTokenWarnDialog = false;
if (this.oneBotEmptyTokenWarningResolve) {
this.oneBotEmptyTokenWarningResolve(continueWithWarning);
this.oneBotEmptyTokenWarningResolve = null;
}
if (!continueWithWarning) {
this.loading = false;
}
},
showSuccess(message) {
this.$emit("show-toast", { message: message, type: "success" });
},
showError(message) {
this.$emit("show-toast", { message: message, type: "error" });
},
buildRandomPlatformIdSuffix() {
const letters = "abcdefghijklmnopqrstuvwxyz";
let suffix = "_";
for (let i = 0; i < 4; i += 1) {
suffix += letters[Math.floor(Math.random() * letters.length)];
}
return suffix;
},
sanitizePlatformIdPart(value) {
return String(value || "")
.trim()
.replace(/\s+/g, "")
.replace(/[!:]/g, "_");
},
handlePlatformRegistrationCreated(data) {
if (!this.selectedPlatformConfig || !data) {
return;
}
const currentId = String(this.selectedPlatformConfig.id || "").trim();
const platformType = this.selectedPlatformConfig.type;
if (!currentId) {
return;
}
let suffix = "";
const explicitSuffix = this.sanitizePlatformIdPart(data.platform_id_suffix);
if (explicitSuffix) {
suffix =
explicitSuffix.startsWith("_") || explicitSuffix.startsWith("-")
? explicitSuffix
: `_${explicitSuffix}`;
} else if (data.bot_name) {
const safeBotName = this.sanitizePlatformIdPart(data.bot_name);
if (safeBotName) {
suffix = `-${safeBotName}`;
}
} else if (platformType === "weixin_oc" || platformType === "dingtalk") {
suffix = this.buildRandomPlatformIdSuffix();
}
if (!suffix) {
return;
}
if (
(platformType === "weixin_oc" || platformType === "dingtalk") &&
/_[a-z]{4}$/.test(currentId)
) {
return;
}
this.selectedPlatformConfig.id = currentId.endsWith(suffix)
? currentId
: `${currentId}${suffix}`;
},
isPlatformIdValid(id) {
if (!id) {
return false;
}
return !/[!:\s]/.test(id);
},
// 获取该平台适配器使用的所有配置文件(新版本:直接操作路由表)
async getPlatformConfigs(platformId) {
if (!platformId) {
this.platformRoutes = [];
return;
}
try {
// 获取路由表 (UMOP -> conf_id)
const routesRes = await configRouteApi.list();
const routingTable = routesRes.data.data.routing;
// 过滤出属于该平台的路由,并保持顺序
const routes = [];
for (const [umop, confId] of Object.entries(routingTable)) {
const parsedUmop = this.parseUmop(umop);
if (this.isParsedUmopMatchPlatform(parsedUmop, platformId)) {
routes.push({
umop: umop,
originalUmop: umop, // 保存原始 UMOP 用于更新时查找
sourceMode: "manual",
sourceUmo: parsedUmop.sessionId === "*" ? "" : umop,
messageType: parsedUmop.messageType || "*",
sessionId: parsedUmop.sessionId || "*",
configId: confId,
});
}
}
this.platformRoutes = routes;
// 如果没有路由,添加一个默认的空路由供用户编辑
if (this.platformRoutes.length === 0) {
this.platformRoutes.push({
umop: null,
originalUmop: null,
sourceMode: "manual",
sourceUmo: "",
messageType: "*",
sessionId: "*",
configId: "default",
});
}
} catch (err) {
console.error("获取平台路由配置失败:", err);
this.platformRoutes = [];
}
},
async loadKnownRouteUmos() {
if (this.loadingKnownRouteUmos) {
return;
}
this.loadingKnownRouteUmos = true;
try {
const res = await sessionApi.activeUmos();
if (res.data.status === "ok") {
const umos = Array.isArray(res.data.data?.umos)
? res.data.data.umos
: [];
this.knownRouteUmos = Array.from(
new Set([...this.knownRouteUmos, ...umos]),
);
this.mergeKnownRouteUmoInfos(res.data.data?.umo_infos || []);
}
} catch (err) {
console.error("获取已有消息来源失败:", err);
} finally {
this.loadingKnownRouteUmos = false;
}
},
mergeKnownRouteUmoInfos(infos = []) {
const next = { ...this.knownRouteUmoInfoMap };
for (const info of infos) {
if (info?.umo) {
next[info.umo] = { ...(next[info.umo] || {}), ...info };
}
}
this.knownRouteUmoInfoMap = next;
},
getKnownRouteUmoInfo(umo) {
const parsed = this.parseUmop(umo);
return (
this.knownRouteUmoInfoMap[umo] || {
umo,
platform: parsed?.platform || "",
message_type: parsed?.messageType || "",
session_id: parsed?.sessionId || umo,
auto_name: "",
user_alias: "",
display_name: umo,
}
);
},
getKnownRouteUmoDisplayProps(umo) {
const info = this.getKnownRouteUmoInfo(umo);
const parsed = this.parseUmop(umo);
return {
umo,
platform: info.platform || parsed?.platform || "",
messageType: info.message_type || parsed?.messageType || "",
sessionId: info.session_id || parsed?.sessionId || "",
autoName: info.auto_name || "",
userAlias: info.user_alias || "",
};
},
getKnownRouteUmoSelectionText(umo) {
if (!umo) return "";
const info = this.getKnownRouteUmoInfo(umo);
const parsed = this.parseUmop(umo);
const aliasName = info.user_alias || "";
const autoName = info.auto_name || "";
if (aliasName && autoName && aliasName !== autoName) {
return `${aliasName}${autoName}`;
}
return (
aliasName ||
autoName ||
(parsed
? `${this.getMessageTypeLabel(parsed.messageType)}:${
parsed.sessionId
}`
: umo)
);
},
getRouteSourceMode(route) {
return route.sourceMode || "manual";
},
getRouteSourceModeLinkText(route) {
return this.getRouteSourceMode(route) === "known"
? this.tm("createDialog.routeSource.switchToManual")
: this.tm("createDialog.routeSource.switchToKnown");
},
toggleRouteSourceMode(route) {
const nextMode =
this.getRouteSourceMode(route) === "known" ? "manual" : "known";
route.sourceMode = nextMode;
if (nextMode === "known") {
this.loadKnownRouteUmos();
}
},
applyKnownRouteSource(route, umo) {
if (!umo) {
route.sourceUmo = "";
return;
}
const parsed = this.parseUmop(umo);
if (!parsed) {
return;
}
route.sourceUmo = umo;
route.messageType = parsed.messageType || "*";
route.sessionId = parsed.sessionId || "*";
},
// 添加新路由
addNewRoute() {
this.platformRoutes.push({
umop: null,
originalUmop: null,
sourceMode: "manual",
sourceUmo: "",
messageType: "*",
sessionId: "*",
configId: "default",
});
},
// 删除路由
deleteRoute(index) {
this.platformRoutes.splice(index, 1);
},
// 上移路由
moveRouteUp(index) {
if (index > 0) {
const temp = this.platformRoutes[index];
this.platformRoutes[index] = this.platformRoutes[index - 1];
this.platformRoutes[index - 1] = temp;
// 强制更新视图
this.platformRoutes = [...this.platformRoutes];
}
},
// 下移路由
moveRouteDown(index) {
if (index < this.platformRoutes.length - 1) {
const temp = this.platformRoutes[index];
this.platformRoutes[index] = this.platformRoutes[index + 1];
this.platformRoutes[index + 1] = temp;
// 强制更新视图
this.platformRoutes = [...this.platformRoutes];
}
},
// 内部保存路由表方法(不显示成功提示)
async saveRoutesInternal() {
const originalPlatformId =
this.originalUpdatingPlatformId || this.updatingPlatformConfig?.id;
const newPlatformId =
this.updatingPlatformConfig?.id || originalPlatformId;
if (!originalPlatformId && !newPlatformId) {
throw new Error(this.tm("messages.platformIdMissing"));
}
try {
// 获取完整的路由表
const routesRes = await configRouteApi.list();
const fullRoutingTable = routesRes.data.data.routing;
// 删除该平台的所有旧路由
for (const umop in fullRoutingTable) {
if (
(originalPlatformId &&
this.isUmopMatchPlatform(umop, originalPlatformId)) ||
(newPlatformId && this.isUmopMatchPlatform(umop, newPlatformId))
) {
delete fullRoutingTable[umop];
}
}
// 添加新路由(按顺序)
for (const route of this.platformRoutes) {
const messageType =
route.messageType === "*" ? "*" : route.messageType;
const sessionId = route.sessionId === "*" ? "*" : route.sessionId;
const platformIdForRoute = newPlatformId || originalPlatformId;
const newUmop = `${platformIdForRoute}:${messageType}:${sessionId}`;
if (route.configId) {
fullRoutingTable[newUmop] = route.configId;
}
}
// 使用 v1 replace 更新整个路由表
await configRouteApi.replace({
routing: fullRoutingTable,
});
} catch (err) {
console.error("保存路由表失败:", err);
const errorMessage = err.response?.data?.message || err.message;
throw new Error(
this.tm("messages.routingSaveFailed", { message: errorMessage }),
);
}
},
// 切换编辑模式
toggleEditMode() {
this.isEditingRoutes = !this.isEditingRoutes;
},
toggleConfigSection() {
this.showConfigSection = !this.showConfigSection;
},
// 根据配置文件ID获取名称
getConfigName(configId) {
const config = this.configInfoList.find((c) => c.id === configId);
return config ? config.name : configId;
},
isUmopMatchPlatform(umop, platformId) {
const parsedUmop = this.parseUmop(umop);
return this.isParsedUmopMatchPlatform(parsedUmop, platformId);
},
isParsedUmopMatchPlatform(parsedUmop, platformId) {
if (!parsedUmop) return false;
return (
parsedUmop.platform === platformId ||
parsedUmop.platform === "" ||
parsedUmop.platform === "*"
);
},
parseUmop(umop) {
if (!umop) return null;
const firstSeparatorIndex = umop.indexOf(":");
if (firstSeparatorIndex === -1) return null;
const secondSeparatorIndex = umop.indexOf(":", firstSeparatorIndex + 1);
if (secondSeparatorIndex === -1) return null;
return {
platform: umop.slice(0, firstSeparatorIndex),
messageType: umop.slice(firstSeparatorIndex + 1, secondSeparatorIndex),
sessionId: umop.slice(secondSeparatorIndex + 1),
};
},
// 获取消息类型标签
getMessageTypeLabel(messageType) {
const typeMap = {
"*": this.tm("createDialog.messageTypeLabels.all"),
"": this.tm("createDialog.messageTypeLabels.all"),
GroupMessage: this.tm("createDialog.messageTypeLabels.group"),
FriendMessage: this.tm("createDialog.messageTypeLabels.friend"),
};
return typeMap[messageType] || messageType;
},
toggleShowConfigSection() {
this.showConfigSection = false;
this.showConfigSection = true;
},
prepareData() {
this.getConfigInfoList();
this.getConfigForPreview(this.selectedAbConfId);
if (
this.updatingMode &&
this.updatingPlatformConfig &&
this.updatingPlatformConfig.id
) {
this.getPlatformConfigs(this.updatingPlatformConfig.id);
}
},
scrollDialogToBottom() {
const containerRef = this.$refs.dialogScrollContainer;
const el = containerRef?.$el || containerRef;
if (!el) {
return;
}
const scrollOptions = { top: el.scrollHeight, behavior: "smooth" };
if (typeof el.scrollTo === "function") {
el.scrollTo(scrollOptions);
} else {
el.scrollTop = el.scrollHeight;
}
},
},
};
</script>
<style>
.v-select__selection-text {
font-size: 12px;
}
.config-drawer-overlay {
align-items: stretch;
justify-content: flex-end;
}
.config-drawer-card {
width: clamp(320px, 60vw, 820px);
height: calc(100vh - 32px);
display: flex;
flex-direction: column;
margin: 16px;
}
.config-drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 12px 20px;
}
.config-drawer-content {
flex: 1;
overflow-y: auto;
padding: 16px 16px 24px 16px;
}
.platform-action-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.creation-mode-group .v-label {
opacity: 0.9;
}
.creation-mode-title {
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.78);
}
.route-source-cell {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
min-width: 260px;
}
.route-source-input-row--editing {
padding-top: 20px;
}
.route-source-mode-link {
color: #0000ee;
cursor: pointer;
font-size: 12px;
line-height: 1;
padding: 2px 0;
text-decoration: underline;
}
.route-source-mode-link:hover {
text-decoration: underline;
}
.umo-selection-chip {
max-width: 100%;
}
.umo-selection-chip .v-chip__content {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.registration-inline {
display: flex;
align-items: flex-start;
justify-content: flex-start;
width: 320px;
}
</style>