fix(webui): 剩余 carbon 图标改为 Lucide、设置页导航高亮与滚动位置对齐(参考 #1119)、HTTP 下复制令牌回退到兼容方式

This commit is contained in:
Estrella Pan
2026-10-21 08:47:35 +02:00
parent 23a0c2e968
commit 6e6461febe
7 changed files with 150 additions and 66 deletions

View File

@@ -7,6 +7,7 @@ import {
ref,
} from 'vue';
import { storeToRefs } from 'pinia';
import { useClipboard } from '@vueuse/core';
import type {
ApiTokenPublic,
ApiTokenStatus,
@@ -137,8 +138,11 @@ async function handleCreateToken(): Promise<void> {
}
}
// legacy: 非安全上下文(HTTP)下 navigator.clipboard 不可用,回落到 execCommand
const { copy } = useClipboard({ legacy: true });
async function copyIssuedToken(): Promise<void> {
await navigator.clipboard.writeText(issuedToken.value);
await copy(issuedToken.value);
message.success(t('access.token_copied'));
}

View File

@@ -1,4 +1,18 @@
<script lang="ts" setup>
import {
Bell,
BellRing,
Eye,
EyeOff,
LoaderCircle,
Pencil,
Play,
Plus,
Smartphone,
Trash2,
Webhook,
} from '@lucide/vue';
import type { Component } from 'vue';
import { NSelect } from 'naive-ui';
import { useConfirm } from '@/hooks/useConfirm';
import type { NotificationProviderConfig, NotificationType } from '#/config';
@@ -146,19 +160,19 @@ function getProviderLabel(type: string): string {
return providerTypes.value.find((p) => p.value === type)?.label || type;
}
function getProviderIcon(type: string): string {
const icons: Record<string, string> = {
telegram: 'i-simple-icons-telegram',
discord: 'i-simple-icons-discord',
bark: 'i-carbon-notification',
'server-chan': 'i-simple-icons-wechat',
wecom: 'i-simple-icons-wechat',
gotify: 'i-carbon-notification-filled',
pushover: 'i-carbon-mobile',
webhook: 'i-carbon-webhook',
};
return icons[type] || 'i-carbon-notification';
}
// 品牌图标走 simple-icons,其余类型用 Lucide,未知(插件)类型回落到 Bell
const brandIcons: Record<string, string> = {
telegram: 'i-simple-icons-telegram',
discord: 'i-simple-icons-discord',
'server-chan': 'i-simple-icons-wechat',
wecom: 'i-simple-icons-wechat',
};
const lucideIcons: Record<string, Component> = {
bark: Bell,
gotify: BellRing,
pushover: Smartphone,
webhook: Webhook,
};
function openAddDialog() {
newProvider.value = {
@@ -291,7 +305,8 @@ function getFieldsForType(type: string) {
>
<div class="provider-info">
<div class="provider-name">
<div :class="getProviderIcon(provider.type)" />
<div v-if="brandIcons[provider.type]" :class="brandIcons[provider.type]" />
<component :is="lucideIcons[provider.type] ?? Bell" v-else :size="16" />
{{ getProviderLabel(provider.type) }}
<span v-if="!provider.enabled" class="disabled-badge">
{{ $t('config.notification_set.disabled') }}
@@ -305,18 +320,15 @@ function getFieldsForType(type: string) {
:label="$t('config.notification_set.test')"
@click="testProvider(index)"
>
<div
v-if="testingIndex === index"
class="i-carbon-circle-dash animate-spin"
/>
<div v-else class="i-carbon-play" />
<LoaderCircle v-if="testingIndex === index" :size="16" class="animate-spin" />
<Play v-else :size="16" />
</ab-icon-button>
<ab-icon-button
size="sm"
:label="$t('config.notification_set.edit')"
@click="openEditDialog(index)"
>
<div class="i-carbon-edit" />
<Pencil :size="16" />
</ab-icon-button>
<ab-icon-button
size="sm"
@@ -327,11 +339,7 @@ function getFieldsForType(type: string) {
"
@click="toggleProvider(index)"
>
<div
:class="
provider.enabled ? 'i-carbon-view' : 'i-carbon-view-off'
"
/>
<component :is="provider.enabled ? Eye : EyeOff" :size="16" />
</ab-icon-button>
<ab-icon-button
size="sm"
@@ -339,7 +347,7 @@ function getFieldsForType(type: string) {
:label="$t('config.notification_set.remove')"
@click="onRemoveProvider(index)"
>
<div class="i-carbon-trash-can" />
<Trash2 :size="16" />
</ab-icon-button>
</div>
</div>
@@ -358,7 +366,7 @@ function getFieldsForType(type: string) {
<!-- Add provider button -->
<div class="flex justify-end">
<ab-button size="sm" variant="primary" @click="openAddDialog">
<div class="i-carbon-add" />
<Plus :size="16" />
{{ $t('config.notification_set.add_provider') }}
</ab-button>
</div>
@@ -420,11 +428,8 @@ function getFieldsForType(type: string) {
:disabled="testingIndex === -999"
@click="testNewProvider"
>
<div
v-if="testingIndex === -999"
class="i-carbon-circle-dash animate-spin"
/>
<div v-else class="i-carbon-play" />
<LoaderCircle v-if="testingIndex === -999" :size="16" class="animate-spin" />
<Play v-else :size="16" />
{{ $t('config.notification_set.test') }}
</ab-button>
<ab-button size="sm" @click="showAddDialog = false">
@@ -492,11 +497,8 @@ function getFieldsForType(type: string) {
:disabled="testingIndex === -999"
@click="testNewProvider"
>
<div
v-if="testingIndex === -999"
class="i-carbon-circle-dash animate-spin"
/>
<div v-else class="i-carbon-play" />
<LoaderCircle v-if="testingIndex === -999" :size="16" class="animate-spin" />
<Play v-else :size="16" />
{{ $t('config.notification_set.test') }}
</ab-button>
<ab-button size="sm" @click="showEditDialog = false">

View File

@@ -20,6 +20,7 @@ import ConfigAccess from '@/components/setting/config-access.vue';
import UpdateCard from '@/components/setting/update-card.vue';
import ConfigPlugins from '@/components/setting/config-plugins.vue';
import { configSectionMatches } from '@/utils/config-search';
import { SCROLL_BOTTOM_EPSILON, pickActiveSection } from '@/utils/scroll-spy';
definePage({
name: 'Config',
@@ -271,21 +272,44 @@ function setSectionEl(id: string, el: unknown) {
else sectionEls.delete(id);
}
// 程序化平滑滚动期间暂停 scroll-spy,避免高亮在途经的分区间跳动
let isProgrammaticScroll = false;
let scrollTimer: ReturnType<typeof setTimeout> | null = null;
function endProgrammaticScroll() {
isProgrammaticScroll = false;
if (scrollTimer) clearTimeout(scrollTimer);
scrollTimer = null;
}
function onScroll() {
const container = scrollEl.value;
if (!container) return;
const anchor = container.scrollTop + 80;
let current = visibleSections.value[0]?.id ?? baseSections[0].id;
for (const section of visibleSections.value) {
const el = sectionEls.get(section.id);
if (el && el.offsetTop <= anchor) current = section.id;
}
activeSection.value = current;
if (!container || isProgrammaticScroll) return;
const containerTop = container.getBoundingClientRect().top;
const tops = visibleSections.value.flatMap(({ id }) => {
const el = sectionEls.get(id);
return el ? [{ id, top: el.getBoundingClientRect().top - containerTop }] : [];
});
const atBottom =
container.scrollHeight - container.scrollTop - container.clientHeight <=
SCROLL_BOTTOM_EPSILON;
activeSection.value =
pickActiveSection(tops, atBottom) ??
visibleSections.value[0]?.id ??
baseSections[0].id;
}
function jumpTo(id: string) {
const el = sectionEls.get(id);
if (!el) return;
activeSection.value = id;
isProgrammaticScroll = true;
// scrollend 不可用(Safari)或未触发时,由定时器兜底解除
if (scrollTimer) clearTimeout(scrollTimer);
scrollTimer = setTimeout(endProgrammaticScroll, 600);
scrollEl.value?.addEventListener('scrollend', endProgrammaticScroll, {
once: true,
});
const reduceMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches;
@@ -293,7 +317,6 @@ function jumpTo(id: string) {
behavior: reduceMotion ? 'auto' : 'smooth',
block: 'start',
});
activeSection.value = id;
}
// --- 保存 / 放弃 ---
@@ -370,7 +393,14 @@ onBeforeRouteLeave(() => {
</button>
</nav>
<div ref="scrollEl" class="config-scroll" @scroll.passive="onScroll">
<div
ref="scrollEl"
class="config-scroll"
@scroll.passive="onScroll"
@wheel.passive="endProgrammaticScroll"
@touchmove.passive="endProgrammaticScroll"
@pointerdown.passive="endProgrammaticScroll"
>
<div v-if="visibleSections.length === 0" class="config-no-match">
<p>{{ $t('config.no_match') }}</p>
<ab-button size="sm" @click="searchQuery = ''">
@@ -496,6 +526,7 @@ onBeforeRouteLeave(() => {
}
.config-scroll {
position: relative;
flex: 1;
min-width: 0;
overflow-y: auto;

View File

@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { RefreshCw } from '@lucide/vue';
import { type DataTableColumns, NDataTable, NTooltip } from 'naive-ui';
import AbButton from '@/components/basic/ab-button.vue';
import AbTag from '@/components/basic/ab-tag.vue';
@@ -119,10 +120,9 @@ const rssColumns = computed<DataTableColumns<RSS>>(() => [
onClick: () => onRefreshOne(rss.id),
},
() =>
h('div', {
class: isRowRefreshing
? 'i-carbon-renew animate-spin'
: 'i-carbon-renew',
h(RefreshCw, {
size: 14,
class: isRowRefreshing ? 'animate-spin' : undefined,
})
);
},
@@ -147,13 +147,7 @@ function onSelectedRSSUpdate(keys: unknown[]) {
@click="refreshAllRSS"
>
<template #icon>
<div
:class="
isRefreshingAll
? 'i-carbon-renew animate-spin'
: 'i-carbon-renew'
"
/>
<RefreshCw :size="14" :class="{ 'animate-spin': isRefreshingAll }" />
</template>
{{ $t('rss.refresh_all') }}
</AbButton>
@@ -214,13 +208,7 @@ function onSelectedRSSUpdate(keys: unknown[]) {
:disabled="refreshingId === item.id"
@click="onRefreshOne(item.id)"
>
<div
:class="
refreshingId === item.id
? 'i-carbon-renew animate-spin'
: 'i-carbon-renew'
"
/>
<RefreshCw :size="14" :class="{ 'animate-spin': refreshingId === item.id }" />
{{ $t('rss.refresh') }}
</AbButton>
</div>

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { pickActiveSection } from '../scroll-spy';
const tops = [
{ id: 'a', top: -300 },
{ id: 'b', top: 40 },
{ id: 'c', top: 500 },
];
describe('pickActiveSection', () => {
it('should return the last section crossing the anchor when not at bottom', () => {
expect(pickActiveSection(tops, false)).toBe('b');
});
it('should return the first section when none crossed the anchor', () => {
expect(pickActiveSection([{ id: 'a', top: 200 }], false)).toBe('a');
});
it('should return the last section when scrolled to the bottom', () => {
expect(pickActiveSection(tops, true)).toBe('c');
});
it('should return undefined when there are no sections', () => {
expect(pickActiveSection([], true)).toBeUndefined();
});
});

View File

@@ -0,0 +1,27 @@
export interface SectionTop {
id: string;
/** 分区上缘相对滚动容器上缘的距离(px) */
top: number;
}
/** 滚动到底部的容差(px) */
export const SCROLL_BOTTOM_EPSILON = 10;
/** 分区上缘越过该锚线即视为当前分区(px) */
export const SCROLL_ANCHOR_OFFSET = 80;
/**
* 计算当前高亮分区:触底时取最后一个;否则取上缘已越过锚线的最后一个,
* 都未越过则取第一个。列表为空返回 undefined。
*/
export function pickActiveSection(
tops: SectionTop[],
atBottom: boolean
): string | undefined {
if (tops.length === 0) return undefined;
if (atBottom) return tops[tops.length - 1].id;
let current = tops[0].id;
for (const { id, top } of tops) {
if (top <= SCROLL_ANCHOR_OFFSET) current = id;
}
return current;
}

View File

@@ -11,6 +11,8 @@ declare global {
const EffectScope: typeof import('vue').EffectScope
const MARKET_CATEGORIES: typeof import('../../src/utils/plugin-market').MARKET_CATEGORIES
const MARKET_STATES: typeof import('../../src/utils/plugin-market').MARKET_STATES
const SCROLL_ANCHOR_OFFSET: typeof import('../../src/utils/scroll-spy').SCROLL_ANCHOR_OFFSET
const SCROLL_BOTTOM_EPSILON: typeof import('../../src/utils/scroll-spy').SCROLL_BOTTOM_EPSILON
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
const apiAuth: typeof import('../../src/api/auth').apiAuth
const apiBangumi: typeof import('../../src/api/bangumi').apiBangumi
@@ -96,6 +98,7 @@ declare global {
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
const parseLogLines: typeof import('../../src/utils/log-parse').parseLogLines
const permissionDiff: typeof import('../../src/utils/plugin-market').permissionDiff
const pickActiveSection: typeof import('../../src/utils/scroll-spy').pickActiveSection
const pluginDescription: typeof import('../../src/utils/plugin-text').pluginDescription
const pluginName: typeof import('../../src/utils/plugin-text').pluginName
const pluginReason: typeof import('../../src/utils/plugin-text').pluginReason
@@ -224,4 +227,7 @@ declare global {
// @ts-ignore
export type { SchemaFieldKind, SchemaField } from '../../src/utils/plugin-schema'
import('../../src/utils/plugin-schema')
// @ts-ignore
export type { SectionTop } from '../../src/utils/scroll-spy'
import('../../src/utils/scroll-spy')
}