feat:移除了弹窗,服务器添加sls

This commit is contained in:
AxTps committed 2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 files changed
+1322534

No files matched your search

@@ -0,0 +1,47 @@
<template>
<nav class="breadcrumbs flex mb-4 items-center flex-wrap">
<template v-for="(link, index) in linkStack" :key="index">
<RouterLink
:to="link.href"
class="breadcrumb goto-link"
:class="{ trim: link.allowTrimming ? link.allowTrimming : false }"
>
{{ link.label }}
</RouterLink>
<ChevronRightIcon />
</template>
<span class="breadcrumb">{{ currentTitle }}</span>
</nav>
</template>
<script setup>
import { ChevronRightIcon } from '@modrinth/assets'
defineProps({
linkStack: {
type: Array,
default: () => [],
},
currentTitle: {
type: String,
required: true,
},
})
</script>
<style lang="scss" scoped>
.breadcrumbs {
svg {
width: 1.25rem;
height: 1.25rem;
}
a.breadcrumb {
padding-block: var(--gap-xs);
&.trim {
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
</style>
@@ -0,0 +1,310 @@
<template>
<div
class="vue-notification-group"
:class="{
'intercom-present': isIntercomPresent,
'location-left': notificationLocation === 'left',
'location-right': notificationLocation === 'right',
'has-sidebar': hasSidebar && !hasModalActive,
}"
>
<transition-group name="notifs">
<div
v-for="(item, index) in notifications"
:key="item.id"
class="vue-notification-wrapper"
@mouseenter="stopTimer(item)"
@mouseleave="setNotificationTimer(item)"
>
<div class="flex w-full gap-2 overflow-hidden rounded-lg bg-bg-raised shadow-xl">
<div
class="w-2"
:class="{
'bg-red': item.type === 'error',
'bg-orange': item.type === 'warning',
'bg-green': item.type === 'success',
'bg-blue': !item.type || !['error', 'warning', 'success'].includes(item.type),
}"
></div>
<div
class="grid w-full grid-cols-[auto_1fr_auto] items-center gap-x-2 gap-y-1 py-2 pl-1 pr-3"
>
<div
class="flex items-center"
:class="{
'text-red': item.type === 'error',
'text-orange': item.type === 'warning',
'text-green': item.type === 'success',
'text-blue': !item.type || !['error', 'warning', 'success'].includes(item.type),
}"
>
<IssuesIcon v-if="item.type === 'warning'" class="h-6 w-6" />
<CheckCircleIcon v-else-if="item.type === 'success'" class="h-6 w-6" />
<XCircleIcon v-else-if="item.type === 'error'" class="h-6 w-6" />
<InfoIcon v-else class="h-6 w-6" />
</div>
<div class="m-0 text-wrap font-bold text-contrast">{{ item.title }}</div>
<div class="flex items-center gap-1">
<div v-if="item.count && item.count > 1" class="text-xs font-bold text-contrast">
x{{ item.count }}
</div>
<ButtonStyled circular size="small">
<button
v-tooltip="
item.supportData
? formatMessage(messages.copySupport)
: formatMessage(messages.copy)
"
@click="copyToClipboard(item)"
>
<CheckIcon v-if="copied[getCopyKey(item)]" />
<CopyIcon v-else />
</button>
</ButtonStyled>
<ButtonStyled v-if="item.type === 'error' && onErrorAction" circular size="small">
<button
v-tooltip="errorActionLabel || formatMessage(messages.exportErrorLogs)"
:disabled="exporting[item.id]"
@click="handleErrorAction(item)"
>
<DownloadIcon />
</button>
</ButtonStyled>
<ButtonStyled circular size="small">
<button
:aria-label="formatMessage(messages.dismiss)"
v-tooltip="formatMessage(messages.dismiss)"
@click="dismissNotification(index)"
>
<XIcon />
</button>
</ButtonStyled>
</div>
<div></div>
<div
class="col-span-2 whitespace-pre-line break-words text-sm leading-5 text-primary line-clamp-5"
>
{{ item.text }}
</div>
<template v-if="item.errorCode">
<div></div>
<div class="m-0 text-wrap text-xs font-medium text-secondary">
{{ item.errorCode }}
</div>
</template>
</div>
</div>
</div>
</transition-group>
</div>
</template>
<script setup lang="ts">
import {
CheckCircleIcon,
CheckIcon,
CopyIcon,
DownloadIcon,
InfoIcon,
IssuesIcon,
XCircleIcon,
XIcon,
} from '@modrinth/assets'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useModalStack } from '#ui/composables/modal-stack.ts'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { injectNotificationManager, type WebNotification } from '../../providers'
import ButtonStyled from '../base/ButtonStyled.vue'
const notificationManager = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
copySupport: {
id: 'notification.panel.copy-support',
defaultMessage: 'Copy error details for support',
},
copy: { id: 'notification.panel.copy', defaultMessage: 'Copy to clipboard' },
dismiss: { id: 'notification.panel.dismiss', defaultMessage: 'Dismiss' },
exportErrorLogs: {
id: 'notification.panel.export-error-logs',
defaultMessage: 'Export error logs',
},
})
const notifications = computed<WebNotification[]>(() =>
[...notificationManager.getNotifications()]
.filter((notification) => !notification.collapsed)
.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)),
)
const notificationLocation = computed(() => notificationManager.getNotificationLocation())
const isIntercomPresent = ref<boolean>(false)
const copied = ref<Record<string, boolean>>({})
const exporting = ref<Record<string | number, boolean>>({})
const stopTimer = (n: WebNotification) => notificationManager.stopNotificationTimer(n)
const setNotificationTimer = (n: WebNotification) => notificationManager.setNotificationTimer(n)
const dismissNotification = (n: number) => {
const notification = notifications.value[n]
if (notification) notificationManager.collapseNotification(notification.id)
}
function createNotifText(notif: WebNotification): string {
return [notif.title, notif.text, notif.errorCode].filter(Boolean).join('\n')
}
function getCopyKey(notif: WebNotification): string {
return notif.supportData ? `support-${notif.id}` : createNotifText(notif)
}
function checkIntercomPresence(): void {
isIntercomPresent.value = !!document.querySelector('.intercom-lightweight-app')
}
function copyToClipboard(notif: WebNotification): void {
// If supportData is present, copy the full JSON for support; otherwise copy plain text
const text = notif.supportData
? JSON.stringify(notif.supportData, null, 2)
: createNotifText(notif)
const key = getCopyKey(notif)
copied.value[key] = true
navigator.clipboard.writeText(text)
setTimeout(() => {
const { [key]: _, ...rest } = copied.value
copied.value = rest
}, 2000)
}
async function handleErrorAction(notification: WebNotification): Promise<void> {
if (!onErrorAction || exporting.value[notification.id]) return
exporting.value[notification.id] = true
try {
await onErrorAction(notification)
} finally {
const { [notification.id]: _, ...rest } = exporting.value
exporting.value = rest as Record<string | number, boolean>
}
}
onMounted(() => {
checkIntercomPresence()
const observer = new MutationObserver(() => {
checkIntercomPresence()
})
observer.observe(document.body, {
childList: true,
subtree: true,
})
onBeforeUnmount(() => {
observer.disconnect()
})
})
const { hasModal: hasModalActive } = useModalStack()
const {
hasSidebar = false,
onErrorAction,
errorActionLabel = 'Export error logs',
} = defineProps<{
hasSidebar?: boolean
onErrorAction?: (notification: WebNotification) => void | Promise<void>
errorActionLabel?: string
}>()
</script>
<style lang="scss" scoped>
.vue-notification-group {
position: fixed;
bottom: 1.5rem;
z-index: 200;
width: 450px;
transition: bottom 0.25s ease-in-out;
&.location-right {
right: 1.5rem;
transition: right 0.25s ease-in-out;
&.has-sidebar {
right: 325px;
}
}
&.location-left {
left: 1.5rem;
}
@media screen and (max-width: 500px) {
width: calc(100% - 0.75rem * 2);
bottom: 0.75rem;
&.location-right {
right: 0.75rem;
left: auto;
}
&.location-left {
left: 0.75rem;
right: auto;
}
}
&.intercom-present {
bottom: 5rem;
}
.vue-notification-wrapper {
width: 100%;
overflow: hidden;
margin-bottom: 10px;
&:last-child {
margin: 0;
}
}
@media screen and (max-width: 750px) {
transition: bottom 0.25s ease-in-out;
bottom: calc(var(--size-mobile-navbar-height) + 10px) !important;
&.browse-menu-open {
bottom: calc(var(--size-mobile-navbar-height-expanded) + 10px) !important;
}
}
}
.notifs-enter-active,
.notifs-leave-active,
.notifs-move {
transition: all 0.25s ease-in-out;
}
.notifs-enter-from,
.notifs-leave-to {
opacity: 0;
}
.notifs-enter-from {
transform: translateY(100%) scale(0.8);
}
.notifs-leave-to {
.location-right & {
transform: translateX(100%) scale(0.8);
}
.location-left & {
transform: translateX(-100%) scale(0.8);
}
}
body.floating-action-bar-shown .vue-notification-group {
bottom: calc(90px);
}
</style>
@@ -0,0 +1,92 @@
<template>
<div
:class="[
'banner-grid relative border-b-2 border-solid border-0 z-10',
containerClasses[variant],
{ 'no-actions': !$slots.actions, slim: slim },
]"
>
<div
:class="[
'[grid-area:title] flex items-center gap-2 font-bold text-[var(--font-size-md)]',
iconClasses[variant],
]"
>
<component :is="getSeverityIcon(variant)" aria-hidden="true" class="w-6 h-6 flex-shrink-0" />
<slot name="title" />
</div>
<div class="[grid-area:description] flex flex-col gap-3">
<slot name="description" />
</div>
<div v-if="$slots.actions" class="[grid-area:actions] flex items-center gap-2">
<slot name="actions" />
</div>
<div
v-if="$slots.actions_right || $slots.actions_top_right"
class="[grid-area:actions_right] flex flex-col gap-2 items-end"
>
<div v-if="$slots.actions_top_right" class="flex items-center gap-2 justify-end">
<slot name="actions_top_right" />
</div>
<div v-if="$slots.actions_right" class="flex items-center gap-2 justify-end my-auto">
<slot name="actions_right" />
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { getSeverityIcon } from '../../utils'
withDefaults(
defineProps<{
variant: 'error' | 'warning' | 'info'
slim?: boolean
}>(),
{
slim: false,
},
)
const containerClasses = {
error: 'bg-banners-error-bg text-banners-error-text border-banners-error-border',
warning: 'bg-banners-warning-bg text-banners-warning-text border-banners-warning-border',
info: 'bg-banners-info-bg text-banners-info-text border-banners-info-border',
}
const iconClasses = {
error: 'text-brand-red',
warning: 'text-brand-orange',
info: 'text-brand-blue',
}
</script>
<style scoped>
.banner-grid {
display: grid;
gap: 0.5rem;
grid-template-areas:
'title actions_right'
'description actions_right'
'actions actions_right';
padding-block: var(--gap-xl);
padding-inline: max(calc((100% - 80rem) / 2 + var(--gap-md)), var(--gap-xl));
}
.banner-grid.no-actions {
grid-template-areas:
'title actions_right'
'description actions_right';
}
.banner-grid.slim {
@apply flex py-4 gap-2 items-center;
}
.banner-grid a {
@apply underline text-current;
}
</style>
@@ -0,0 +1,394 @@
<template>
<div
class="popup-notification-group"
:class="{
'has-sidebar': hasSidebar,
'has-modal': hasModalActive,
}"
:style="notificationGroupStyle"
>
<transition-group name="popup-notifs">
<div
v-for="item in notifications"
:key="item.id"
class="popup-notification-wrapper"
@mouseenter="stopTimer(item)"
@mouseleave="setNotificationTimer(item)"
>
<NotificationToast
v-if="item.toast"
:type="item.toast.type"
:actor-name="item.toast.actorName"
:actor-avatar-url="item.toast.actorAvatarUrl"
:entity-name="item.toast.entityName"
:entity-icon-url="item.toast.entityIconUrl"
:status-text="item.toast.statusText"
:progress="item.toast.progress"
:waiting="item.toast.waiting"
:show-progress="item.toast.showProgress"
:progress-type="item.toast.progressType"
:progress-current="item.toast.progressCurrent"
:progress-total="item.toast.progressTotal"
@accept="handleToastAction(item, item.toast.onAccept)"
@decline="handleToastAction(item, item.toast.onDecline)"
@dismiss="handleToastAction(item, item.toast.onDismiss)"
@launch="handleToastAction(item, item.toast.onLaunch)"
@open-actor="item.toast.onOpenActor?.()"
@open-instance="handleToastAction(item, item.toast.onOpenInstance)"
/>
<div v-else-if="isDownloadNotification(item)" class="flex flex-col gap-4">
<div v-for="progressItem in downloadToastItems(item)" :key="progressItem.id">
<NotificationToast
type="instance-download"
:entity-name="progressItem.title || item.title"
:entity-icon-url="progressItem.iconUrl ?? item.iconUrl ?? MinecraftServerIcon"
:status-text="progressItem.text"
:progress="progressItem.progress"
:waiting="progressItem.waiting"
:show-progress="progressItem.showProgress"
:wrap-text="progressItem.wrapText"
:progress-type="progressItem.progressType"
:progress-current="progressItem.progressCurrent"
:progress-total="progressItem.progressTotal"
:actions="progressItem.buttons"
class="cursor-pointer"
@click="handleDownloadClick(item, $event)"
@dismiss="handleProgressItemDismiss(item, progressItem)"
@action="(index) => handleProgressItemAction(progressItem, index)"
/>
</div>
</div>
<div
v-else
class="flex w-full flex-col gap-3 overflow-hidden rounded-2xl bg-bg-raised shadow-xl border-surface-5 border-solid border p-4"
>
<div class="flex flex-col gap-2 w-full">
<div class="flex items-center justify-between gap-2.5">
<div class="flex min-w-0 flex-1 items-center gap-2">
<component
:is="item.titleLogo"
v-if="item.titleLogo"
class="h-7 w-auto min-w-0 max-w-full text-contrast"
/>
<template v-else>
<div
class="flex items-center"
:class="{
'text-red': item.type === 'error',
'text-orange': item.type === 'warning',
'text-green': item.type === 'download',
'text-contrast': item.type === 'success',
'text-blue':
!item.type ||
!['error', 'warning', 'success', 'download'].includes(item.type),
}"
>
<IssuesIcon v-if="item.type === 'warning'" class="h-5 w-5" />
<DownloadIcon v-else-if="item.type === 'download'" class="h-5 w-5" />
<CheckCircleIcon v-else-if="item.type === 'success'" class="h-5 w-5" />
<XCircleIcon v-else-if="item.type === 'error'" class="h-5 w-5" />
<InfoIcon v-else class="h-5 w-5" />
</div>
<div class="text-contrast font-semibold m-0 grow">
{{ item.title }}
</div>
</template>
</div>
<ButtonStyled size="small" type="transparent" circular>
<button @click="dismiss(item.id)">
<XIcon />
</button>
</ButtonStyled>
</div>
<span v-if="item.text" class="text-primary">
{{ item.text }}
</span>
<component
:is="item.bodyComponent"
v-if="item.bodyComponent"
v-bind="item.bodyProps ?? {}"
/>
</div>
<div v-if="item.progressItems?.length" class="flex flex-col gap-3">
<div
v-for="progressItem in item.progressItems"
:key="progressItem.id"
class="flex flex-col gap-2"
>
<div class="text-contrast truncate">
{{ progressItem.title }}
</div>
<ProgressBar
:progress="progressItem.progress"
:max="1"
:waiting="progressItem.waiting"
:color="progressColorForType(item.type)"
:gradient-border="false"
full-width
/>
<div v-if="progressItem.text" class="text-sm text-secondary truncate">
{{ progressItem.text }}
</div>
</div>
</div>
<ProgressBar
v-else-if="item.progress != null || item.waiting"
:progress="item.progress ?? 0"
:max="1"
:waiting="item.waiting ?? false"
:color="progressColorForType(item.type)"
:gradient-border="false"
full-width
/>
<div
v-if="(item.type === 'error' && onErrorAction) || item.buttons?.length"
class="flex gap-1.5"
>
<ButtonStyled v-if="item.type === 'error' && onErrorAction">
<button :disabled="exporting[item.id]" @click="handleErrorAction(item)">
<DownloadIcon /> {{ errorActionLabel || formatMessage(messages.exportErrorLogs) }}
</button>
</ButtonStyled>
<ButtonStyled
v-for="(btn, idx) in item.buttons"
:key="idx"
:color="btn.color || (idx === 0 ? 'brand' : undefined)"
>
<button @click="handleButtonClick(item.id, btn)">
<component :is="btn.icon" v-if="btn.icon" />
{{ btn.label }}
</button>
</ButtonStyled>
</div>
</div>
</div>
</transition-group>
</div>
</template>
<script setup lang="ts">
import {
CheckCircleIcon,
DownloadIcon,
InfoIcon,
IssuesIcon,
MinecraftServerIcon,
XCircleIcon,
XIcon,
} from '@modrinth/assets'
import { computed, ref } from 'vue'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { useModalStack } from '../../composables/modal-stack'
import {
injectPopupNotificationManager,
type PopupNotification,
type PopupNotificationButton,
type PopupNotificationProgressItem,
} from '../../providers'
import ButtonStyled from '../base/ButtonStyled.vue'
import ProgressBar from '../base/ProgressBar.vue'
import NotificationToast from '../notifications/NotificationToast.vue'
const popupNotificationManager = injectPopupNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
exportErrorLogs: {
id: 'notification.panel.export-error-logs',
defaultMessage: 'Export error logs',
},
})
const notifications = computed<PopupNotification[]>(() =>
[...popupNotificationManager.getNotifications()]
.filter((notification) => !notification.collapsed)
.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)),
)
const { stackCount } = useModalStack()
const hasModalActive = computed(() => stackCount.value > 0)
const notificationGroupStyle = computed(() => ({
zIndex: hasModalActive.value ? 100 + stackCount.value * 10 + 8 : 200,
}))
const exporting = ref<Record<string | number, boolean>>({})
// Download progress items share one parent popup notification. Keep dismissal
// state per item so closing one row does not hide the other rows.
const dismissedProgressItems = ref<Record<string, string[]>>({})
const stopTimer = (n: PopupNotification) => popupNotificationManager.stopNotificationTimer(n)
const setNotificationTimer = (n: PopupNotification) =>
popupNotificationManager.setNotificationTimer(n)
const dismiss = (id: string | number) => popupNotificationManager.collapseNotification(id)
function isDownloadNotification(item: PopupNotification) {
return (
item.type === 'download' &&
(!!item.progressItems?.length || item.progress != null || item.waiting)
)
}
function downloadToastItems(item: PopupNotification): PopupNotificationProgressItem[] {
const dismissed = dismissedProgressItems.value[String(item.id)] ?? []
const items = item.progressItems?.length
? item.progressItems
: [
{
id: `${item.id}`,
title: item.title,
text: item.text,
iconUrl: item.iconUrl,
progress: item.progress ?? 0,
waiting: item.waiting ?? false,
showProgress: true,
progressType: 'percentage',
},
]
return items.filter((progressItem) => !dismissed.includes(progressItem.id))
}
function handleDownloadClick(item: PopupNotification, event: MouseEvent) {
if ((event.target as HTMLElement).closest('button')) return
void item.onClick?.()
}
async function handleProgressItemDismiss(
item: PopupNotification,
progressItem: PopupNotificationProgressItem,
) {
// Dismissal is presentation-only. Download history must remain available for diagnosis.
const notificationKey = String(item.id)
const dismissed = dismissedProgressItems.value[notificationKey] ?? []
if (!dismissed.includes(progressItem.id)) {
dismissedProgressItems.value = {
...dismissedProgressItems.value,
[notificationKey]: [...dismissed, progressItem.id],
}
}
if (downloadToastItems(item).length === 0) {
dismiss(item.id)
}
}
async function handleProgressItemAction(
progressItem: PopupNotificationProgressItem,
index: number,
) {
const button = progressItem.buttons?.[index]
if (button) {
await handleProgressItemButtonClick(progressItem, button)
}
}
async function handleProgressItemButtonClick(
progressItem: PopupNotificationProgressItem,
btn: PopupNotificationButton,
) {
await btn.action()
if (!btn.keepOpen) {
await progressItem.onDismiss?.()
}
}
async function handleButtonClick(id: string | number, btn: PopupNotificationButton) {
await btn.action()
if (!btn.keepOpen) {
popupNotificationManager.removeNotification(id)
}
}
async function handleToastAction(item: PopupNotification, action?: () => void | Promise<void>) {
popupNotificationManager.removeNotification(item.id)
await action?.()
}
async function handleErrorAction(notification: PopupNotification): Promise<void> {
if (!onErrorAction || exporting.value[notification.id]) return
exporting.value[notification.id] = true
try {
await onErrorAction(notification)
} finally {
const { [notification.id]: _, ...rest } = exporting.value
exporting.value = rest as Record<string | number, boolean>
}
}
function progressColorForType(type: PopupNotification['type']) {
if (type === 'error') {
return 'red'
} else if (type === 'warning') {
return 'orange'
} else if (type === 'download') {
return 'green'
} else if (type === 'success') {
return 'green'
} else if (type === 'info') {
return 'blue'
}
return 'green'
}
const {
hasSidebar = false,
onErrorAction,
errorActionLabel,
} = defineProps<{
hasSidebar?: boolean
onErrorAction?: (notification: PopupNotification) => void | Promise<void>
errorActionLabel?: string
}>()
</script>
<style scoped>
.popup-notification-group {
position: fixed;
top: calc(var(--top-bar-height, 3rem) + 1.5rem);
right: 1.5rem;
z-index: 200;
width: min(420px, calc(100vw - 1.5rem));
min-width: min(420px, calc(100vw - 1.5rem));
max-width: min(420px, calc(100vw - 1.5rem));
display: flex;
flex-direction: column;
gap: 0.75rem;
transition:
opacity 0.2s ease-in-out,
transform 0.2s ease-in-out;
}
.popup-notification-group.has-sidebar {
right: calc(var(--right-bar-width, 0px) + 1.5rem);
}
.popup-notification-group.has-modal {
opacity: 0;
pointer-events: none;
transform: translateY(-0.5rem);
}
@media screen and (max-width: 500px) {
.popup-notification-group {
right: 0.75rem;
}
}
.popup-notification-group .popup-notification-wrapper {
width: 100%;
}
.popup-notifs-enter-active,
.popup-notifs-leave-active,
.popup-notifs-move {
transition: all 0.3s ease-in-out;
}
.popup-notifs-enter-from {
opacity: 0;
transform: translateX(100%) scale(0.8);
}
.popup-notifs-leave-to {
opacity: 0;
transform: translateX(100%) scale(0.8);
}
</style>
+4
View File
@@ -0,0 +1,4 @@
export { default as Breadcrumbs } from './Breadcrumbs.vue'
export { default as NotificationPanel } from './NotificationPanel.vue'
export { default as PagewideBanner } from './PagewideBanner.vue'
export { default as PopupNotificationPanel } from './PopupNotificationPanel.vue'