Files
AstrBot/dashboard/src/components/shared/ExtensionCard.vue
2026-03-27 19:08:34 +08:00

647 lines
17 KiB
Vue

<script setup lang="ts">
import { ref, computed, inject, watch, useAttrs } from "vue";
import { useCustomizerStore } from "@/stores/customizer";
import { useModuleI18n } from "@/i18n/composables";
import { getPlatformDisplayName, getPlatformIcon } from "@/utils/platformUtils";
import UninstallConfirmDialog from "./UninstallConfirmDialog.vue";
import PluginPlatformChip from "./PluginPlatformChip.vue";
import StyledMenu from "./StyledMenu.vue";
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
const props = defineProps({
extension: {
type: Object,
required: true,
},
pinned: {
type: Boolean,
default: false,
},
marketMode: {
type: Boolean,
default: false,
},
highlight: {
type: Boolean,
default: false,
},
});
// 定义要发送到父组件的事件
const emit = defineEmits([
"configure",
"update",
"reload",
"install",
"uninstall",
"toggle-activation",
"toggle-pin",
"view-handlers",
"view-readme",
"view-changelog",
]);
const reveal = ref(false);
const showUninstallDialog = ref(false);
const attrs = useAttrs();
// 国际化
const { tm } = useModuleI18n("features/extension");
const supportPlatforms = computed(() => {
const platforms = props.extension?.support_platforms;
if (!Array.isArray(platforms)) {
return [];
}
return platforms.filter((item) => typeof item === "string");
});
const supportPlatformDisplayNames = computed(() =>
supportPlatforms.value.map((platformId) => getPlatformDisplayName(platformId)),
);
const astrbotVersionRequirement = computed(() => {
const versionSpec = props.extension?.astrbot_version;
return typeof versionSpec === "string" && versionSpec.trim().length
? versionSpec.trim()
: "";
});
// 作者显示(兼容多种字段名)
const authorDisplay = computed(() => {
const ext = props.extension || {};
if (typeof ext.author === 'string' && ext.author.trim()) return ext.author;
if (Array.isArray(ext.authors) && ext.authors.length) return ext.authors.join(', ');
if (typeof ext.author_name === 'string' && ext.author_name.trim()) return ext.author_name;
if (typeof ext.owner === 'string' && ext.owner.trim()) return ext.owner;
if (ext.author && typeof ext.author === 'object' && ext.author.name) return ext.author.name;
return '';
});
const logoLoadFailed = ref(false);
const logoSrc = computed(() => {
const logo = props.extension?.logo;
if (logoLoadFailed.value) {
return defaultPluginIcon;
}
return typeof logo === "string" && logo.trim().length
? logo
: defaultPluginIcon;
});
watch(
() => props.extension?.logo,
() => {
logoLoadFailed.value = false;
},
);
// 操作函数
const configure = () => {
emit("configure", props.extension);
};
const updateExtension = () => {
emit("update", props.extension);
};
const reloadExtension = () => {
emit("reload", props.extension);
};
const $confirm = inject("$confirm");
const installExtension = async () => {
emit("install", props.extension);
};
const uninstallExtension = async () => {
showUninstallDialog.value = true;
};
const handleUninstallConfirm = (options: {
deleteConfig: boolean;
deleteData: boolean;
}) => {
emit("uninstall", props.extension, options);
};
const toggleActivation = () => {
emit("toggle-activation", props.extension);
};
const togglePin = (e?: Event) => {
if (e) e.stopPropagation();
emit("toggle-pin", props.extension);
};
const viewHandlers = () => {
emit("view-handlers", props.extension);
};
const viewReadme = () => {
emit("view-readme", props.extension);
};
const viewChangelog = () => {
emit("view-changelog", props.extension);
};
</script>
<template>
<v-card
v-bind="attrs"
class="mx-auto d-flex flex-column h-100"
elevation="0"
height="100%"
:style="{
position: 'relative',
backgroundColor:
!useCustomizerStore().isDarkTheme
? marketMode
? '#f8f0dd'
: '#ffffff'
: '#282833',
color:
!useCustomizerStore().isDarkTheme
? '#000000dd'
: '#ffffff',
}"
>
<v-card-text
style="
padding: 16px;
padding-bottom: 0px;
width: 100%;
"
>
<div style="overflow-x: auto; width: 100%">
<div style="width: 100%; margin-bottom: 24px">
<div class="extension-title-row">
<p
class="text-h3 font-weight-black extension-title"
:class="{ 'text-h4': $vuetify.display.xs }"
>
<v-tooltip
location="top"
:text="
extension.display_name?.length &&
extension.display_name !== extension.name
? `${extension.display_name} (${extension.name})`
: extension.name
"
>
<template #activator="{ props: titleTooltipProps }">
<span
v-bind="titleTooltipProps"
class="extension-title__text"
>{{
extension.display_name?.length
? extension.display_name
: extension.name
}}</span>
</template>
</v-tooltip>
<v-tooltip
v-if="extension?.has_update && !marketMode"
location="top"
>
<template #activator="{ props: tooltipProps }">
<v-icon
v-bind="tooltipProps"
color="warning"
class="ml-2"
icon="mdi-update"
size="small"
style="cursor: pointer"
@click.stop="updateExtension"
/>
</template>
<span>{{ tm("card.status.hasUpdate") }}:
{{ extension.online_version }}</span>
</v-tooltip>
</p>
<template v-if="!marketMode">
<v-tooltip location="left">
<template #activator="{ props: tooltipProps }">
<div
class="extension-switch-wrap"
@click.stop
>
<div
v-bind="tooltipProps"
style="display:inline-flex; align-items:center;"
>
<v-switch
:model-value="extension.activated"
color="success"
density="compact"
hide-details
inset
@update:model-value="toggleActivation"
/>
</div>
<v-tooltip
location="top"
:text="pinned ? tm('buttons.unpin') : tm('buttons.pin')"
>
<template #activator="{ props: pinProps }">
<v-btn
v-bind="pinProps"
icon
size="small"
variant="tonal"
:color="pinned ? 'primary' : 'secondary'"
class="ml-2"
@click.stop="togglePin"
>
<v-icon size="18">
{{ pinned ? 'mdi-pin' : 'mdi-pin-outline' }}
</v-icon>
</v-btn>
</template>
</v-tooltip>
</div>
</template>
<span>{{
extension.activated ? tm("buttons.disable") : tm("buttons.enable")
}}</span>
</v-tooltip>
</template>
<template v-else>
<div class="extension-market-menu-wrap">
<v-menu offset-y>
<template #activator="{ props: menuProps }">
<v-btn
v-if="extension?.repo"
icon
variant="text"
aria-label="more"
:href="extension?.repo"
target="_blank"
>
<v-icon icon="mdi-github" />
</v-btn>
<v-btn
v-bind="menuProps"
icon
variant="text"
aria-label="more"
>
<v-icon icon="mdi-dots-vertical" />
</v-btn>
</template>
<v-list>
<v-list-item @click="viewReadme">
<v-list-item-title>
📄 {{ tm("buttons.viewDocs") }}
</v-list-item-title>
</v-list-item>
<v-list-item
v-if="marketMode && !extension?.installed"
@click="installExtension"
>
<v-list-item-title>
{{ tm("buttons.install") }}
</v-list-item-title>
</v-list-item>
<v-list-item v-if="marketMode && extension?.installed">
<v-list-item-title class="text--disabled">
{{
tm("status.installed")
}}
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</template>
</div>
<div class="extension-content-row mt-2">
<div class="extension-image-container">
<img
:src="logoSrc"
:alt="extension.name"
class="extension-logo"
@error="logoLoadFailed = true"
>
</div>
<div class="extension-meta-group">
<div class="extension-chip-group d-flex flex-wrap">
<v-chip
color="primary"
label
size="small"
>
<v-icon
icon="mdi-source-branch"
start
/>
{{ extension.version }}
</v-chip>
<v-chip
v-if="extension?.has_update"
color="warning"
label
size="small"
style="cursor: pointer"
@click="updateExtension"
>
<v-icon
icon="mdi-arrow-up-bold"
start
/>
{{ extension.online_version }}
</v-chip>
<v-chip
v-if="extension.handlers?.length"
color="primary"
label
size="small"
style="cursor: pointer"
@click="viewHandlers"
>
<v-icon
icon="mdi-cogs"
start
/>
{{ extension.handlers?.length
}}{{ tm("card.status.handlersCount") }}
</v-chip>
<v-chip
v-for="tag in extension.tags"
:key="tag"
:color="tag === 'danger' ? 'error' : 'primary'"
label
size="small"
>
{{ tag === "danger" ? tm("tags.danger") : tag }}
</v-chip>
<PluginPlatformChip :platforms="supportPlatforms" />
<v-chip v-if="authorDisplay" color="info" label size="small">
<v-icon icon="mdi-account" start></v-icon>
{{ authorDisplay }}
</v-chip>
<v-chip
v-if="astrbotVersionRequirement"
color="secondary"
variant="outlined"
label
size="small"
>
AstrBot: {{ astrbotVersionRequirement }}
</v-chip>
</div>
<div
class="extension-desc"
:class="{ 'text-caption': $vuetify.display.xs }"
>
{{ extension.desc }}
</div>
</div>
</div>
</div>
</div>
</v-card-text>
<v-card-actions
class="extension-actions"
@click.stop
>
<template v-if="!marketMode">
<v-spacer />
<v-tooltip
location="top"
:text="tm('buttons.viewDocs')"
>
<template #activator="{ props: actionProps }">
<v-btn
v-bind="actionProps"
icon="mdi-book-open-page-variant"
size="small"
variant="tonal"
color="info"
@click="viewReadme"
/>
</template>
</v-tooltip>
<v-tooltip
location="top"
:text="tm('card.actions.pluginConfig')"
>
<template #activator="{ props: actionProps }">
<v-btn
v-bind="actionProps"
icon="mdi-cog"
size="small"
variant="tonal"
color="primary"
@click="configure"
/>
</template>
</v-tooltip>
<v-tooltip
v-if="extension?.repo"
location="top"
:text="tm('buttons.viewRepo')"
>
<template #activator="{ props: actionProps }">
<v-btn
v-bind="actionProps"
icon="mdi-github"
size="small"
variant="tonal"
color="secondary"
:href="extension.repo"
target="_blank"
/>
</template>
</v-tooltip>
<v-tooltip
location="top"
:text="tm('card.actions.reloadPlugin')"
>
<template #activator="{ props: actionProps }">
<v-btn
v-bind="actionProps"
icon="mdi-refresh"
size="small"
variant="tonal"
color="primary"
@click="reloadExtension"
/>
</template>
</v-tooltip>
<StyledMenu
location="top end"
offset="8"
>
<template #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
icon="mdi-dots-horizontal"
size="small"
variant="tonal"
color="secondary"
/>
</template>
<v-list-item
class="styled-menu-item"
prepend-icon="mdi-information"
@click="viewHandlers"
>
<v-list-item-title>{{ tm("buttons.viewInfo") }}</v-list-item-title>
</v-list-item>
<v-list-item
class="styled-menu-item"
prepend-icon="mdi-update"
@click="updateExtension"
>
<v-list-item-title>
{{
extension.has_update
? tm("card.actions.updateTo") + " " + extension.online_version
: tm("card.actions.reinstall")
}}
</v-list-item-title>
</v-list-item>
<v-list-item
class="styled-menu-item"
prepend-icon="mdi-delete"
@click="uninstallExtension"
>
<v-list-item-title class="text-error">
{{ tm("card.actions.uninstallPlugin") }}
</v-list-item-title>
</v-list-item>
</StyledMenu>
</template>
<template v-else>
<v-btn
color="primary"
size="small"
@click="viewReadme"
>
{{ tm("buttons.viewDocs") }}
</v-btn>
</template>
</v-card-actions>
</v-card>
<!-- 卸载确认对话框 -->
<UninstallConfirmDialog
v-model="showUninstallDialog"
@confirm="handleUninstallConfirm"
/>
</template>
<style scoped>
.extension-image-container {
display: flex;
align-items: flex-start;
flex-shrink: 0;
}
.extension-logo {
width: 72px;
height: 72px;
border-radius: 12px;
object-fit: cover;
}
.extension-content-row {
display: flex;
gap: 12px;
align-items: flex-start;
}
.extension-meta-group {
flex: 1;
min-width: 0;
}
.extension-chip-group {
gap: 8px;
}
.extension-desc {
margin-top: 8px;
font-size: 90%;
overflow-y: auto;
height: 70px;
}
.extension-title {
display: flex;
align-items: center;
min-width: 0;
flex: 1;
margin: 0;
}
.extension-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.extension-title__text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.extension-switch-wrap {
display: flex;
align-items: center;
flex-shrink: 0;
}
.extension-switch-wrap ::v-deep(.v-switch) {
margin: 0;
}
.extension-market-menu-wrap {
display: flex;
align-items: center;
flex-shrink: 0;
}
@media (max-width: 600px) {
.extension-content-row {
flex-direction: column;
}
.extension-logo {
width: 64px;
height: 64px;
}
}
.extension-actions {
margin-top: auto;
gap: 8px;
justify-content: flex-end;
}
</style>