mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-01 13:15:59 +08:00
feat(webui): 插件市场页面
- 新增 /market 与 /market/:id:列表 + 详情双栏,手机上点行进入整屏详情, 主操作固定在底部;侧栏与手机导航加入口 - 按扩展点归类的分类标签、安装状态筛选与搜索;签名目录获取成功后缓存, 目录不可达时显示上次的目录并禁用安装/更新 - 安装/更新前的确认步骤:来源与审查、权限声明、前端、同进程警告; 更新显示版本与权限差异,权限变多须勾选确认 - README 用安全的 Markdown 子集渲染(先转义 HTML,链接只允许 http(s)) - 设置页的插件目录区换成「打开插件市场」入口 - 后端 CatalogEntry 补上 sdk 与 path 字段
This commit is contained in:
@@ -64,6 +64,8 @@ class CatalogEntry(BaseModel):
|
||||
kind: str = "plugin"
|
||||
extension_points: list[str] = []
|
||||
description: str = ""
|
||||
# 插件依赖的 ab_sdk 版本范围(清单中的 sdk)
|
||||
sdk: str = ""
|
||||
min_ab_version: str = "0.0.0"
|
||||
authors: list[str] = []
|
||||
permissions: list[str] = []
|
||||
@@ -71,6 +73,8 @@ class CatalogEntry(BaseModel):
|
||||
# 源码位置:作者仓库与固定的 commit(插件市场的索引条目)
|
||||
repo: str = ""
|
||||
commit: str = ""
|
||||
# 插件在仓库中的子目录,"." 为仓库根
|
||||
path: str = "."
|
||||
readme: str = ""
|
||||
installed_version: str | None
|
||||
|
||||
|
||||
@@ -370,6 +370,7 @@ class TestPluginsApi:
|
||||
assert response.status_code == 200
|
||||
[item] = response.json()
|
||||
assert item["id"] == PLUGIN_ID and item["installed_version"] is None
|
||||
assert item["path"] == "."
|
||||
|
||||
def test_catalog_unreachable_returns_502(self, authed_client, ctx, monkeypatch):
|
||||
from module.api import plugins as plugins_api
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
AppStore,
|
||||
Calendar,
|
||||
Download,
|
||||
Home,
|
||||
@@ -47,6 +48,12 @@ const navItems = [
|
||||
hidden: localStorage.getItem('enable_downloader_iframe') !== '1',
|
||||
},
|
||||
{ id: 6, icon: Log, label: () => t('sidebar.log'), path: '/log' },
|
||||
{
|
||||
id: 8,
|
||||
icon: AppStore,
|
||||
label: () => t('sidebar.market'),
|
||||
path: '/market',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
icon: SettingTwo,
|
||||
@@ -77,7 +84,10 @@ const visibleItems = computed(() => {
|
||||
:to="item.path"
|
||||
replace
|
||||
class="mobile-nav__item"
|
||||
:class="{ 'mobile-nav__item--active': route.path === item.path }"
|
||||
:class="{
|
||||
'mobile-nav__item--active':
|
||||
route.path === item.path || route.path.startsWith(`${item.path}/`),
|
||||
}"
|
||||
:aria-label="item.label()"
|
||||
>
|
||||
<Component :is="item.icon" :size="18" class="mobile-nav__icon" />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue';
|
||||
import { Attention, CloseOne, Delete, Info, Remind } from '@icon-park/vue-next';
|
||||
import type { InboxMessage } from '@/api/notification';
|
||||
import { relativeTime } from '@/utils/relative-time';
|
||||
|
||||
const { t } = useMyI18n();
|
||||
const router = useRouter();
|
||||
@@ -62,19 +63,6 @@ function onClearClick() {
|
||||
confirmClear.value = false;
|
||||
store.clearAll();
|
||||
}
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return '';
|
||||
const minutes = Math.floor((Date.now() - then) / 60000);
|
||||
if (minutes < 1) return t('notifications.time.just_now');
|
||||
if (minutes < 60) return t('notifications.time.minutes_ago', { n: minutes });
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return t('notifications.time.hours_ago', { n: hours });
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) return t('notifications.time.days_ago', { n: days });
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -147,7 +135,7 @@ function relativeTime(iso: string): string {
|
||||
</div>
|
||||
<div class="notification-body">{{ store.bodyOf(msg) }}</div>
|
||||
<div class="notification-meta">
|
||||
{{ relativeTime(msg.updated_at) }}
|
||||
{{ relativeTime(t, msg.updated_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<ab-icon-button
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="tsx" setup>
|
||||
import {
|
||||
AppStore,
|
||||
Calendar,
|
||||
Download,
|
||||
Home,
|
||||
@@ -85,6 +86,12 @@ const items = [
|
||||
label: () => t('sidebar.log'),
|
||||
path: '/log',
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
icon: AppStore,
|
||||
label: () => t('sidebar.market'),
|
||||
path: '/market',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
icon: SettingTwo,
|
||||
@@ -93,6 +100,11 @@ const items = [
|
||||
},
|
||||
];
|
||||
|
||||
// 子路由(如 /market/:id)也高亮所属入口
|
||||
function isActive(path: string) {
|
||||
return route.path === path || route.path.startsWith(`${path}/`);
|
||||
}
|
||||
|
||||
// 插件经 page 挂载点提供的页面,排在设置之前
|
||||
const pageSlots = usePluginPages();
|
||||
const navItems = computed(() => {
|
||||
@@ -161,7 +173,7 @@ function Exit() {
|
||||
:title="i.label()"
|
||||
class="sidebar-item"
|
||||
:class="[
|
||||
route.path === i.path && 'sidebar-item--active',
|
||||
isActive(i.path) && 'sidebar-item--active',
|
||||
i.hidden && 'hidden',
|
||||
]"
|
||||
>
|
||||
@@ -204,7 +216,7 @@ function Exit() {
|
||||
:title="i.label()"
|
||||
class="sidebar-item"
|
||||
:class="[
|
||||
route.path === i.path && 'sidebar-item--active',
|
||||
isActive(i.path) && 'sidebar-item--active',
|
||||
i.hidden && 'hidden',
|
||||
]"
|
||||
>
|
||||
|
||||
78
webui/src/components/market/__tests__/market-confirm.test.ts
Normal file
78
webui/src/components/market/__tests__/market-confirm.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import MarketConfirm from '../market-confirm.vue';
|
||||
import type { CatalogEntry } from '#/plugins';
|
||||
|
||||
vi.mock('@/hooks/useMyI18n', () => ({
|
||||
useMyI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
function entry(overrides: Partial<CatalogEntry> = {}): CatalogEntry {
|
||||
return {
|
||||
id: 'nfo',
|
||||
name: 'NFO',
|
||||
version: '0.2.0',
|
||||
kind: 'plugin',
|
||||
extension_points: [],
|
||||
description: '',
|
||||
sdk: '',
|
||||
min_ab_version: '4.0.0',
|
||||
authors: [],
|
||||
permissions: ['filesystem', 'network'],
|
||||
has_web: false,
|
||||
repo: 'alice/nfo',
|
||||
commit: '7c0d9e2aa',
|
||||
path: '.',
|
||||
readme: '',
|
||||
installed_version: '0.1.0',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mountConfirm(e: CatalogEntry, installedPermissions: string[]) {
|
||||
return mount(MarketConfirm, {
|
||||
props: { entry: e, installedPermissions, busy: false },
|
||||
});
|
||||
}
|
||||
|
||||
function primary(w: ReturnType<typeof mountConfirm>) {
|
||||
return w.findAll('button').at(-1)!;
|
||||
}
|
||||
|
||||
describe('market-confirm', () => {
|
||||
it('should require the acknowledgement before an update that adds permissions', async () => {
|
||||
const w = mountConfirm(entry(), ['filesystem']);
|
||||
|
||||
expect(w.find('.perm--added').attributes('title')).toContain('network');
|
||||
expect(primary(w).text()).toBe('market.update');
|
||||
expect(primary(w).attributes('disabled')).toBeDefined();
|
||||
|
||||
await w.find('input[type="checkbox"]').setValue(true);
|
||||
await primary(w).trigger('click');
|
||||
|
||||
expect(primary(w).attributes('disabled')).toBeUndefined();
|
||||
expect(w.emitted('confirm')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an update without new permissions', entry(), ['filesystem', 'network']],
|
||||
['an install', entry({ installed_version: null }), []],
|
||||
])('should not ask for acknowledgement for %s', (_, e, installed) => {
|
||||
const w = mountConfirm(e, installed);
|
||||
|
||||
expect(w.find('input[type="checkbox"]').exists()).toBe(false);
|
||||
expect(w.find('.perm--added').exists()).toBe(false);
|
||||
expect(primary(w).attributes('disabled')).toBeUndefined();
|
||||
expect(primary(w).text()).toBe(
|
||||
e.installed_version ? 'market.update' : 'market.install_enable'
|
||||
);
|
||||
});
|
||||
|
||||
it('should show a permission dropped by the update as removed', () => {
|
||||
const w = mountConfirm(entry({ permissions: ['network'] }), [
|
||||
'network',
|
||||
'fs.write',
|
||||
]);
|
||||
expect(w.find('.perm--removed').attributes('title')).toContain('fs.write');
|
||||
});
|
||||
});
|
||||
251
webui/src/components/market/market-confirm.vue
Normal file
251
webui/src/components/market/market-confirm.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<script lang="ts" setup>
|
||||
import AbAlert from '../basic/ab-alert.vue';
|
||||
import AbButton from '../basic/ab-button.vue';
|
||||
import AbTag from '../basic/ab-tag.vue';
|
||||
import type { CatalogEntry } from '#/plugins';
|
||||
import { idLabel } from '@/utils/id-label';
|
||||
import { permissionDiff, sourceUrl } from '@/utils/plugin-market';
|
||||
|
||||
// 安装/更新前的确认步骤,替换详情栏的内容(手机上是同一页的下一步)。
|
||||
// 更新时 installedPermissions 为已安装版本声明的权限;权限变多须勾选确认。
|
||||
const props = defineProps<{
|
||||
entry: CatalogEntry;
|
||||
installedPermissions: string[];
|
||||
busy: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{ back: []; confirm: [] }>();
|
||||
|
||||
const { t } = useMyI18n();
|
||||
|
||||
const isUpdate = computed(() => props.entry.installed_version !== null);
|
||||
const diff = computed(() =>
|
||||
isUpdate.value
|
||||
? permissionDiff(props.installedPermissions, props.entry.permissions)
|
||||
: { kept: props.entry.permissions, added: [], removed: [] }
|
||||
);
|
||||
const acknowledged = ref(false);
|
||||
const blocked = computed(
|
||||
() => diff.value.added.length > 0 && !acknowledged.value
|
||||
);
|
||||
|
||||
function permissionLabel(id: string) {
|
||||
return idLabel(t, 'config.plugins_set.permission_labels', id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="confirm">
|
||||
<header class="confirm__head">
|
||||
<h2 class="confirm__title">
|
||||
{{
|
||||
$t(isUpdate ? 'market.confirm_update' : 'market.confirm_install', {
|
||||
name: entry.name || entry.id,
|
||||
})
|
||||
}}
|
||||
</h2>
|
||||
<span class="confirm__version">
|
||||
<template v-if="isUpdate"
|
||||
>{{ entry.installed_version }} → {{ entry.version }}</template
|
||||
>
|
||||
<template v-else>{{ entry.version }}</template>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<dl class="confirm__kv">
|
||||
<template v-if="entry.repo && entry.commit">
|
||||
<dt>{{ $t('market.source') }}</dt>
|
||||
<dd>
|
||||
<a
|
||||
class="confirm__link"
|
||||
:href="sourceUrl(entry)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{ entry.repo }}@{{ entry.commit.slice(0, 7) }}
|
||||
<span aria-hidden="true">↗</span></a
|
||||
>
|
||||
</dd>
|
||||
<dt>{{ $t('market.review') }}</dt>
|
||||
<dd>{{ $t('market.reviewed') }}</dd>
|
||||
</template>
|
||||
|
||||
<dt>{{ $t('market.permissions') }}</dt>
|
||||
<dd class="confirm__tags">
|
||||
<!-- ab-tag 的 title 是文字内容,原始 id 的提示放在外层 -->
|
||||
<span v-for="id in diff.kept" :key="id" :title="id">
|
||||
<AbTag>{{ permissionLabel(id) }}</AbTag>
|
||||
</span>
|
||||
<span
|
||||
v-for="id in diff.added"
|
||||
:key="`+${id}`"
|
||||
class="perm--added"
|
||||
:title="`${id} · ${$t('market.permission_added')}`"
|
||||
>
|
||||
<AbTag type="warning">+ {{ permissionLabel(id) }}</AbTag>
|
||||
</span>
|
||||
<span
|
||||
v-for="id in diff.removed"
|
||||
:key="`-${id}`"
|
||||
class="perm--removed"
|
||||
:title="`${id} · ${$t('market.permission_removed')}`"
|
||||
>
|
||||
<AbTag>{{ permissionLabel(id) }}</AbTag>
|
||||
</span>
|
||||
<span
|
||||
v-if="!entry.permissions.length && !diff.removed.length"
|
||||
class="confirm__muted"
|
||||
>
|
||||
{{ $t('market.none') }}
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
<dt>{{ $t('market.frontend') }}</dt>
|
||||
<dd>{{ entry.has_web ? $t('market.yes') : $t('market.no') }}</dd>
|
||||
</dl>
|
||||
|
||||
<AbAlert type="warning" :title="$t('market.warning_lead')">
|
||||
{{ $t('market.warning') }}
|
||||
</AbAlert>
|
||||
|
||||
<label v-if="diff.added.length" class="confirm__ack">
|
||||
<input v-model="acknowledged" type="checkbox" />
|
||||
<span>{{ $t('market.ack_permissions') }}</span>
|
||||
</label>
|
||||
|
||||
<footer class="confirm__actions">
|
||||
<AbButton variant="ghost" :disabled="busy" @click="$emit('back')">
|
||||
{{ $t('market.back') }}
|
||||
</AbButton>
|
||||
<AbButton
|
||||
variant="primary"
|
||||
:loading="busy"
|
||||
:disabled="blocked"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
{{ isUpdate ? $t('market.update') : $t('market.install_enable') }}
|
||||
</AbButton>
|
||||
</footer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.confirm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
|
||||
// 手机上确认是整屏的一步:撑满详情区,按钮落在底部
|
||||
@include forMobile {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.confirm__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.confirm__title {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.confirm__version {
|
||||
color: var(--color-text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.confirm__kv {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(64px, max-content) 1fr;
|
||||
gap: 8px 14px;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
|
||||
dt {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.confirm__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.confirm__muted {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.confirm__link {
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--color-text-muted);
|
||||
text-underline-offset: 3px;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
&:hover {
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.perm--added :deep(.ab-tag) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.perm--removed :deep(.ab-tag-text) {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.confirm__ack {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: var(--touch-target);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
accent-color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
// 内容超出时按钮贴在滚动区底部;手机上始终在屏幕底部(底部导航之上)
|
||||
.confirm__actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
|
||||
@include forMobile {
|
||||
margin-top: auto;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import ConfigPlugins from '../config-plugins.vue';
|
||||
import type { CatalogEntry, PluginInfo, PluginsOverview } from '#/plugins';
|
||||
import type { PluginInfo, PluginsOverview } from '#/plugins';
|
||||
import en from '@/i18n/en.json';
|
||||
import zhCN from '@/i18n/zh-CN.json';
|
||||
import { apiPlugins } from '@/api/plugins';
|
||||
@@ -10,12 +10,13 @@ vi.mock('@/api/plugins', () => ({
|
||||
list: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateSettings: vi.fn(),
|
||||
catalog: vi.fn(),
|
||||
install: vi.fn(),
|
||||
uninstall: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const pushMock = vi.fn();
|
||||
vi.stubGlobal('useRouter', () => ({ push: pushMock }));
|
||||
|
||||
const confirmMock = vi.fn();
|
||||
vi.mock('@/hooks/useConfirm', () => ({
|
||||
useConfirm: () => ({ confirm: confirmMock }),
|
||||
@@ -73,26 +74,6 @@ function overview(plugins: PluginInfo[]): PluginsOverview {
|
||||
return { allow_unsigned: false, plugins };
|
||||
}
|
||||
|
||||
function entry(overrides: Partial<CatalogEntry> = {}): CatalogEntry {
|
||||
return {
|
||||
id: 'ntfy',
|
||||
name: 'ntfy',
|
||||
version: '0.2.0',
|
||||
kind: 'plugin',
|
||||
extension_points: ['notifier'],
|
||||
description: '',
|
||||
min_ab_version: '4.0.0',
|
||||
authors: [],
|
||||
permissions: [],
|
||||
has_web: false,
|
||||
repo: '',
|
||||
commit: '',
|
||||
readme: '',
|
||||
installed_version: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function mountPage(plugins: PluginInfo[]) {
|
||||
api.list.mockResolvedValue(overview(plugins));
|
||||
const wrapper = mount(ConfigPlugins, {
|
||||
@@ -157,93 +138,12 @@ describe('config-plugins', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it('should load the catalog only when the user browses it', async () => {
|
||||
api.catalog.mockResolvedValue([]);
|
||||
it('should link to the plugin market instead of listing the catalog', async () => {
|
||||
const wrapper = await mountPage([]);
|
||||
expect(api.catalog).not.toHaveBeenCalled();
|
||||
|
||||
await button(wrapper, 'config.plugins_set.catalog_browse')[0].trigger(
|
||||
'click'
|
||||
);
|
||||
await flushPromises();
|
||||
await button(wrapper, 'config.plugins_set.open_market')[0].trigger('click');
|
||||
|
||||
expect(api.catalog).toHaveBeenCalledTimes(1);
|
||||
expect(wrapper.text()).toContain('config.plugins_set.catalog_empty');
|
||||
});
|
||||
|
||||
it('should show the install action for each catalog state', async () => {
|
||||
api.catalog.mockResolvedValue([
|
||||
entry({ id: 'fresh' }),
|
||||
entry({ id: 'stale', installed_version: '0.1.0' }),
|
||||
entry({ id: 'current', installed_version: '0.2.0' }),
|
||||
]);
|
||||
const wrapper = await mountPage([]);
|
||||
await button(wrapper, 'config.plugins_set.catalog_browse')[0].trigger(
|
||||
'click'
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
const rows = wrapper.findAll('.catalog__entry');
|
||||
expect(rows.map((r) => r.find('button').exists())).toEqual([
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
]);
|
||||
expect(rows[0].find('button').text()).toBe('config.plugins_set.install');
|
||||
expect(rows[1].find('button').text()).toBe('config.plugins_set.update');
|
||||
expect(rows[2].text()).toContain('config.plugins_set.installed');
|
||||
});
|
||||
|
||||
it('should install, apply the returned overview and mark the entry installed', async () => {
|
||||
api.catalog.mockResolvedValue([entry()]);
|
||||
api.install.mockResolvedValue(
|
||||
overview([plugin({ name: 'ntfy-installed', version: '0.2.0' })])
|
||||
);
|
||||
const wrapper = await mountPage([]);
|
||||
await button(wrapper, 'config.plugins_set.catalog_browse')[0].trigger(
|
||||
'click'
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
await button(wrapper, 'config.plugins_set.install')[0].trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(api.install).toHaveBeenCalledWith('ntfy');
|
||||
expect(wrapper.text()).toContain('ntfy-installed');
|
||||
expect(button(wrapper, 'config.plugins_set.install')).toHaveLength(0);
|
||||
expect(wrapper.find('.catalog__entry').text()).toContain(
|
||||
'config.plugins_set.installed'
|
||||
);
|
||||
});
|
||||
|
||||
it('should keep the entry installable when install fails', async () => {
|
||||
api.catalog.mockResolvedValue([entry()]);
|
||||
api.install.mockRejectedValue(new Error('signature invalid'));
|
||||
const wrapper = await mountPage([]);
|
||||
await button(wrapper, 'config.plugins_set.catalog_browse')[0].trigger(
|
||||
'click'
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
await button(wrapper, 'config.plugins_set.install')[0].trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(button(wrapper, 'config.plugins_set.install')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should show the backend detail inline when the catalog is unavailable', async () => {
|
||||
api.catalog.mockRejectedValue({
|
||||
response: { data: { detail: 'catalog signature verification failed' } },
|
||||
});
|
||||
const wrapper = await mountPage([]);
|
||||
await button(wrapper, 'config.plugins_set.catalog_browse')[0].trigger(
|
||||
'click'
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
const alert = wrapper.find('[role="alert"]');
|
||||
expect(alert.text()).toContain('config.plugins_set.catalog_failed');
|
||||
expect(alert.text()).toContain('catalog signature verification failed');
|
||||
expect(pushMock).toHaveBeenCalledWith('/market');
|
||||
});
|
||||
|
||||
it('should refresh allow_unsigned in the config store when the card toggles it', async () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import AbSkeleton from '../basic/ab-skeleton.vue';
|
||||
import AbSwitch from '../basic/ab-switch.vue';
|
||||
import AbTag from '../basic/ab-tag.vue';
|
||||
import PluginSchemaForm from './plugin-schema-form.vue';
|
||||
import type { CatalogEntry, PluginInfo, PluginsOverview } from '#/plugins';
|
||||
import type { PluginInfo, PluginsOverview } from '#/plugins';
|
||||
import { apiPlugins } from '@/api/plugins';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
import { refreshPluginProviders } from '@/hooks/usePluginProviders';
|
||||
@@ -18,6 +18,7 @@ import { fillSchemaDefaults, schemaFields } from '@/utils/plugin-schema';
|
||||
// 随后刷新 config store 的 plugins 段,避免全局保存用旧值覆盖。
|
||||
const { t } = useMyI18n();
|
||||
const message = useMessage();
|
||||
const router = useRouter();
|
||||
const { confirm } = useConfirm();
|
||||
const configStore = useConfigStore();
|
||||
const { refreshGroup } = configStore;
|
||||
@@ -28,12 +29,6 @@ const drafts = ref<Record<string, Record<string, unknown>>>({});
|
||||
const busy = ref(new Set<string>());
|
||||
const loadError = ref(false);
|
||||
|
||||
// 签名目录要访问 GitHub,用户点「浏览目录」时才拉取;
|
||||
// catalogError 为 '' 表示失败但后端没给出原因
|
||||
const catalog = ref<CatalogEntry[] | null>(null);
|
||||
const catalogLoading = ref(false);
|
||||
const catalogError = ref<string | null>(null);
|
||||
|
||||
const stateType = {
|
||||
active: 'success',
|
||||
disabled: 'neutral',
|
||||
@@ -156,31 +151,6 @@ async function saveOptions(plugin: PluginInfo) {
|
||||
if (ok) message.success(t('config.plugins_set.save_success'));
|
||||
}
|
||||
|
||||
async function loadCatalog() {
|
||||
catalogLoading.value = true;
|
||||
catalogError.value = null;
|
||||
try {
|
||||
catalog.value = await apiPlugins.catalog();
|
||||
} catch (e) {
|
||||
const detail = (e as { response?: { data?: { detail?: unknown } } })
|
||||
.response?.data?.detail;
|
||||
catalogError.value = typeof detail === 'string' ? detail : '';
|
||||
} finally {
|
||||
catalogLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function install(entry: CatalogEntry) {
|
||||
const ok = await run(
|
||||
`install:${entry.id}`,
|
||||
() => apiPlugins.install(entry.id),
|
||||
'install_failed'
|
||||
);
|
||||
if (!ok) return;
|
||||
entry.installed_version = entry.version;
|
||||
message.success(t('config.plugins_set.install_success'));
|
||||
}
|
||||
|
||||
// 只有签名目录安装的插件(source 为 catalog)显示卸载按钮:后端卸载的前提
|
||||
// (installed.json 指向含 plugin.toml 的版本目录)与加载器判定 catalog 来源
|
||||
// 是同一条件。LLM 提供商插件不在此列表中,仍在 LLM 设置里管理。
|
||||
@@ -201,10 +171,7 @@ async function uninstall(plugin: PluginInfo) {
|
||||
() => apiPlugins.uninstall(plugin.id),
|
||||
'uninstall_failed'
|
||||
);
|
||||
if (!ok) return;
|
||||
const entry = catalog.value?.find((e) => e.id === plugin.id);
|
||||
if (entry) entry.installed_version = null;
|
||||
message.success(t('config.plugins_set.uninstall_success'));
|
||||
if (ok) message.success(t('config.plugins_set.uninstall_success'));
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
@@ -339,91 +306,14 @@ onMounted(load);
|
||||
</AbButton>
|
||||
</section>
|
||||
|
||||
<section class="catalog">
|
||||
<header class="plugin__header">
|
||||
<div class="plugin__title">
|
||||
<strong>{{ $t('config.plugins_set.catalog_title') }}</strong>
|
||||
<span class="plugin__desc">
|
||||
{{ $t('config.plugins_set.catalog_hint') }}
|
||||
</span>
|
||||
</div>
|
||||
<AbButton size="sm" :loading="catalogLoading" @click="loadCatalog">
|
||||
{{
|
||||
catalog
|
||||
? $t('config.plugins_set.catalog_refresh')
|
||||
: $t('config.plugins_set.catalog_browse')
|
||||
}}
|
||||
</AbButton>
|
||||
</header>
|
||||
|
||||
<AbAlert
|
||||
v-if="catalogError !== null"
|
||||
type="danger"
|
||||
:title="$t('config.plugins_set.catalog_failed')"
|
||||
>
|
||||
{{ catalogError || $t('config.plugins_set.catalog_failed_hint') }}
|
||||
</AbAlert>
|
||||
<AbSkeleton v-else-if="catalogLoading && !catalog" preset="row" />
|
||||
<p v-else-if="catalog && !catalog.length" class="plugins__empty">
|
||||
{{ $t('config.plugins_set.catalog_empty') }}
|
||||
</p>
|
||||
|
||||
<ul v-if="catalog?.length" class="catalog__list">
|
||||
<li v-for="entry in catalog" :key="entry.id" class="catalog__entry">
|
||||
<div class="plugin__title">
|
||||
<strong>{{ entry.name || entry.id }}</strong>
|
||||
<span class="plugin__meta"
|
||||
>{{ entry.id }} · v{{ entry.version }}</span
|
||||
>
|
||||
<span v-if="entry.description" class="plugin__desc">
|
||||
{{ entry.description }}
|
||||
</span>
|
||||
<span
|
||||
v-if="entry.extension_points.length"
|
||||
class="plugin__desc"
|
||||
:title="entry.extension_points.join(', ')"
|
||||
>
|
||||
{{
|
||||
$t('config.plugins_set.extension_points', {
|
||||
list: entry.extension_points
|
||||
.map((id) =>
|
||||
idLabel(t, 'config.plugins_set.point_labels', id)
|
||||
)
|
||||
.join(', '),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="entry.installed_version === entry.version"
|
||||
class="plugin__desc"
|
||||
>
|
||||
{{ $t('config.plugins_set.installed') }}
|
||||
</span>
|
||||
<div v-else class="catalog__action">
|
||||
<span v-if="entry.installed_version" class="plugin__desc">
|
||||
{{
|
||||
$t('config.plugins_set.installed_version', {
|
||||
version: entry.installed_version,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<AbButton
|
||||
size="sm"
|
||||
variant="primary"
|
||||
:loading="busy.has(`install:${entry.id}`)"
|
||||
@click="install(entry)"
|
||||
>
|
||||
{{
|
||||
entry.installed_version
|
||||
? $t('config.plugins_set.update')
|
||||
: $t('config.plugins_set.install')
|
||||
}}
|
||||
</AbButton>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<p class="plugins__market">
|
||||
<span class="plugin__desc">{{
|
||||
$t('config.plugins_set.market_hint')
|
||||
}}</span>
|
||||
<AbButton size="sm" @click="router.push('/market')">
|
||||
{{ $t('config.plugins_set.open_market') }}
|
||||
</AbButton>
|
||||
</p>
|
||||
</div>
|
||||
</ab-fold-panel>
|
||||
</template>
|
||||
@@ -500,38 +390,14 @@ onMounted(load);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.catalog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.catalog__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.catalog__entry {
|
||||
.plugins__market {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
|
||||
.catalog__action {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--layout-gap);
|
||||
margin: 0;
|
||||
padding-top: var(--layout-padding);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -190,7 +190,7 @@
|
||||
},
|
||||
"plugins_set": {
|
||||
"title": "Plugins",
|
||||
"empty": "No plugins found. Install one from the plugin catalog below. Or put a plugin directory (named after the plugin id) into config/plugins/local/, or install it with pip, then restart.",
|
||||
"empty": "No plugins found. Install one from the plugin market, or put a plugin directory (named after the plugin id) into config/plugins/local/ or install it with pip, then restart.",
|
||||
"allow_unsigned": "Allow unsigned plugins",
|
||||
"allow_unsigned_hint": "Plugins from the local directory or pip are unsigned and only load when this is on. Plugins run inside AutoBangumi with full privileges — only enable plugins you trust.",
|
||||
"enabled": "Enabled",
|
||||
@@ -219,20 +219,6 @@
|
||||
"uninstall_confirm_body": "The plugin files are deleted. Its options stay in config.json and apply again if you reinstall it.",
|
||||
"uninstall_success": "Plugin uninstalled",
|
||||
"uninstall_failed": "Failed to uninstall the plugin",
|
||||
"catalog_title": "Plugin catalog",
|
||||
"catalog_hint": "AutoBangumi signs the catalog and every plugin package, and checks the signature and sha256 before install. Catalog plugins count as signed and do not need \"Allow unsigned plugins\".",
|
||||
"catalog_browse": "Browse catalog",
|
||||
"catalog_refresh": "Refresh",
|
||||
"catalog_failed": "Catalog unavailable.",
|
||||
"catalog_failed_hint": "Check the network, then select Refresh.",
|
||||
"catalog_empty": "The catalog has no plugins yet.",
|
||||
"extension_points": "Extension points: {list}",
|
||||
"install": "Install",
|
||||
"update": "Update",
|
||||
"installed": "Installed",
|
||||
"installed_version": "Installed v{version}",
|
||||
"install_success": "Plugin installed and enabled",
|
||||
"install_failed": "Failed to install the plugin",
|
||||
"retry": "Retry",
|
||||
"enabled_for": "Enable {name}",
|
||||
"unsaved": "Unsaved changes",
|
||||
@@ -258,7 +244,8 @@
|
||||
},
|
||||
"permission_labels": {
|
||||
"fs_write": "Write files",
|
||||
"network": "Network access"
|
||||
"network": "Network access",
|
||||
"filesystem": "File system access"
|
||||
},
|
||||
"disable": "Disable",
|
||||
"disable_confirm_title": "Disable {name}?",
|
||||
@@ -270,7 +257,9 @@
|
||||
"remove_row_confirm_body": "The values in this row are deleted when you save the options.",
|
||||
"field_required": "Required",
|
||||
"field_min": "Must be {n} or more",
|
||||
"field_max": "Must be {n} or less"
|
||||
"field_max": "Must be {n} or less",
|
||||
"market_hint": "Install and update plugins in the plugin market.",
|
||||
"open_market": "Open plugin market"
|
||||
}
|
||||
},
|
||||
"downloader": {
|
||||
@@ -641,7 +630,8 @@
|
||||
"logout": "Logout",
|
||||
"player": "Player",
|
||||
"rss": "RSS",
|
||||
"title": "Menu"
|
||||
"title": "Menu",
|
||||
"market": "Plugin market"
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Cancel",
|
||||
@@ -854,5 +844,67 @@
|
||||
"body": "{torrent_name} (rename method: {strategy}). Detail: {reason}. The files keep their original names. Fix the rename setting. AutoBangumi retries on the next run."
|
||||
}
|
||||
}
|
||||
},
|
||||
"market": {
|
||||
"title": "Plugin market",
|
||||
"catalog_verified": "Catalog signature verified",
|
||||
"updated": "Updated {time}",
|
||||
"refresh": "Refresh",
|
||||
"search": "Search by name, id, author or description",
|
||||
"state_filter": "Install state",
|
||||
"state_all": "All states",
|
||||
"state": {
|
||||
"installed": "Installed",
|
||||
"update": "Update available",
|
||||
"available": "Not installed"
|
||||
},
|
||||
"update_from": "Update available · {version} installed",
|
||||
"categories": "Categories",
|
||||
"category_all": "All",
|
||||
"category": {
|
||||
"source": "Sources",
|
||||
"notify": "Notifications",
|
||||
"organize": "Organizing",
|
||||
"interface": "UI & API",
|
||||
"other": "Other"
|
||||
},
|
||||
"list_label": "Plugins",
|
||||
"version": "Version",
|
||||
"version_sdk": "{version} · SDK {sdk}",
|
||||
"authors": "Authors",
|
||||
"source": "Source",
|
||||
"review": "Review",
|
||||
"reviewed": "The catalog maintainers reviewed this commit.",
|
||||
"extension_points": "Extension points",
|
||||
"permissions": "Declared permissions",
|
||||
"permission_added": "new in this version",
|
||||
"permission_removed": "no longer declared",
|
||||
"frontend": "Web UI",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"none": "None",
|
||||
"install": "Install",
|
||||
"update": "Update",
|
||||
"install_enable": "Install and enable",
|
||||
"back": "Back",
|
||||
"confirm_install": "Install {name}",
|
||||
"confirm_update": "Update {name}",
|
||||
"warning_lead": "Note",
|
||||
"warning": "The plugin runs inside the AutoBangumi process and can read and change your settings and downloaders. The permissions are only what the author declares; AutoBangumi cannot enforce them.",
|
||||
"ack_permissions": "I understand the new permissions",
|
||||
"install_success": "{name} installed and enabled",
|
||||
"update_success": "{name} updated to {version}",
|
||||
"install_failed": "Could not install {name}",
|
||||
"update_failed": "Could not update {name}",
|
||||
"load_failed": "Could not load the plugin catalog",
|
||||
"load_failed_hint": "Check the network or proxy settings, then try again.",
|
||||
"retry": "Retry",
|
||||
"stale_lead": "Catalog unavailable",
|
||||
"stale": "Showing the catalog from {time}. You can install and update again once the connection is back.",
|
||||
"no_match": "No plugins match “{query}”",
|
||||
"clear_search": "Clear search",
|
||||
"no_filter_match": "No plugins match these filters",
|
||||
"show_all": "Show all plugins",
|
||||
"empty": "The catalog has no plugins yet"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@
|
||||
},
|
||||
"plugins_set": {
|
||||
"title": "插件",
|
||||
"empty": "未发现插件。从下方的插件目录安装;或把插件目录放进 config/plugins/local/(目录名与插件 id 相同)、用 pip 安装,重启后即可在此出现。",
|
||||
"empty": "未发现插件。可以在插件市场中安装;或把插件目录放进 config/plugins/local/(目录名与插件 id 相同)、用 pip 安装,然后重启。",
|
||||
"allow_unsigned": "允许未签名插件",
|
||||
"allow_unsigned_hint": "本地目录与 pip 安装的插件未经签名,开启后才会加载。插件与 AutoBangumi 同进程运行,拥有完整权限,只启用你信任的插件。",
|
||||
"enabled": "启用",
|
||||
@@ -219,20 +219,6 @@
|
||||
"uninstall_confirm_body": "插件文件会被删除。它的配置保留在 config.json 中,重新安装后继续生效。",
|
||||
"uninstall_success": "插件已卸载",
|
||||
"uninstall_failed": "卸载插件失败",
|
||||
"catalog_title": "插件目录",
|
||||
"catalog_hint": "目录与插件包由 AutoBangumi 签名,安装前校验签名与 sha256。从目录安装的插件视为已签名,不需要开启「允许未签名插件」。",
|
||||
"catalog_browse": "浏览目录",
|
||||
"catalog_refresh": "刷新",
|
||||
"catalog_failed": "目录不可用。",
|
||||
"catalog_failed_hint": "检查网络后点击「刷新」重试。",
|
||||
"catalog_empty": "目录中暂时没有插件。",
|
||||
"extension_points": "扩展点:{list}",
|
||||
"install": "安装",
|
||||
"update": "更新",
|
||||
"installed": "已安装",
|
||||
"installed_version": "已安装 v{version}",
|
||||
"install_success": "插件已安装并启用",
|
||||
"install_failed": "安装插件失败",
|
||||
"retry": "重试",
|
||||
"enabled_for": "启用 {name}",
|
||||
"unsaved": "有未保存的修改",
|
||||
@@ -258,7 +244,8 @@
|
||||
},
|
||||
"permission_labels": {
|
||||
"fs_write": "写入文件",
|
||||
"network": "访问网络"
|
||||
"network": "访问网络",
|
||||
"filesystem": "访问文件系统"
|
||||
},
|
||||
"disable": "停用",
|
||||
"disable_confirm_title": "停用 {name}?",
|
||||
@@ -270,7 +257,9 @@
|
||||
"remove_row_confirm_body": "保存插件选项后,该行的内容会被删除。",
|
||||
"field_required": "必填",
|
||||
"field_min": "不能小于 {n}",
|
||||
"field_max": "不能大于 {n}"
|
||||
"field_max": "不能大于 {n}",
|
||||
"market_hint": "在插件市场中安装和更新插件。",
|
||||
"open_market": "打开插件市场"
|
||||
}
|
||||
},
|
||||
"downloader": {
|
||||
@@ -641,7 +630,8 @@
|
||||
"logout": "退出",
|
||||
"player": "播放器",
|
||||
"rss": "RSS 管理",
|
||||
"title": "菜单"
|
||||
"title": "菜单",
|
||||
"market": "插件市场"
|
||||
},
|
||||
"common": {
|
||||
"cancel": "取消",
|
||||
@@ -854,5 +844,67 @@
|
||||
"body": "{torrent_name}(重命名方式:{strategy})。详情:{reason}。文件保留原名。请修正重命名设置,下次运行时会自动重试。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"market": {
|
||||
"title": "插件市场",
|
||||
"catalog_verified": "目录签名有效",
|
||||
"updated": "{time}更新",
|
||||
"refresh": "刷新",
|
||||
"search": "搜索名称、id、作者或描述",
|
||||
"state_filter": "安装状态",
|
||||
"state_all": "全部状态",
|
||||
"state": {
|
||||
"installed": "已安装",
|
||||
"update": "可更新",
|
||||
"available": "未安装"
|
||||
},
|
||||
"update_from": "可更新 · 已装 {version}",
|
||||
"categories": "分类",
|
||||
"category_all": "全部",
|
||||
"category": {
|
||||
"source": "来源",
|
||||
"notify": "通知",
|
||||
"organize": "整理",
|
||||
"interface": "界面与接口",
|
||||
"other": "其他"
|
||||
},
|
||||
"list_label": "插件列表",
|
||||
"version": "版本",
|
||||
"version_sdk": "{version} · SDK {sdk}",
|
||||
"authors": "作者",
|
||||
"source": "源码",
|
||||
"review": "审查",
|
||||
"reviewed": "目录维护者审查过这个 commit。",
|
||||
"extension_points": "扩展点",
|
||||
"permissions": "权限声明",
|
||||
"permission_added": "新版本新增",
|
||||
"permission_removed": "新版本不再声明",
|
||||
"frontend": "前端",
|
||||
"yes": "有",
|
||||
"no": "无",
|
||||
"none": "无",
|
||||
"install": "安装",
|
||||
"update": "更新",
|
||||
"install_enable": "安装并启用",
|
||||
"back": "返回",
|
||||
"confirm_install": "安装 {name}",
|
||||
"confirm_update": "更新 {name}",
|
||||
"warning_lead": "注意",
|
||||
"warning": "插件在 AutoBangumi 的进程中运行,可以读写你的配置和下载器。权限只是作者的声明,AutoBangumi 不能强制。",
|
||||
"ack_permissions": "我已了解新增的权限",
|
||||
"install_success": "已安装并启用 {name}",
|
||||
"update_success": "已将 {name} 更新到 {version}",
|
||||
"install_failed": "安装 {name} 失败",
|
||||
"update_failed": "更新 {name} 失败",
|
||||
"load_failed": "无法获取插件目录",
|
||||
"load_failed_hint": "检查网络或代理设置后重试。",
|
||||
"retry": "重试",
|
||||
"stale_lead": "目录不可用",
|
||||
"stale": "下面是 {time} 的目录,安装和更新要等连接恢复。",
|
||||
"no_match": "没有与 “{query}” 匹配的插件",
|
||||
"clear_search": "清除搜索",
|
||||
"no_filter_match": "没有符合筛选条件的插件",
|
||||
"show_all": "显示全部插件",
|
||||
"empty": "目录中还没有插件"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ vi.stubGlobal('useRoute', () => ({
|
||||
}));
|
||||
vi.mock('@/hooks/useMyI18n', async () => {
|
||||
const { ref } = await vi.importActual<typeof import('vue')>('vue');
|
||||
return { useMyI18n: () => ({ lang: ref('en') }) };
|
||||
return { useMyI18n: () => ({ t: (k: string) => k, lang: ref('en') }) };
|
||||
});
|
||||
|
||||
const slots = ref<unknown[]>([]);
|
||||
|
||||
@@ -10,12 +10,13 @@ definePage({
|
||||
const { editRule } = storeToRefs(useBangumiStore());
|
||||
|
||||
// 插件页面的标题取自清单(清单到达前留空,避免闪出路由名 Plugin;清单已到达
|
||||
// 或加载失败仍无匹配页面时回退为插件 id);其它页面沿用路由名
|
||||
// 或加载失败仍无匹配页面时回退为插件 id);插件市场用翻译后的标题,其它页面沿用路由名
|
||||
const route = useRoute();
|
||||
const { lang } = useMyI18n();
|
||||
const { t, lang } = useMyI18n();
|
||||
const { slots, loaded, loadFailed } = usePluginUi();
|
||||
const pageTitle = computed(() => {
|
||||
const name = String(route.name ?? '');
|
||||
if (name === 'Market') return t('market.title');
|
||||
if (name !== 'Plugin') return name;
|
||||
const id = (route.params as Record<string, string>).id;
|
||||
const ui = slots.value.find((s) => s.slot === 'page' && s.plugin_id === id);
|
||||
|
||||
855
webui/src/pages/index/market/[[id]].vue
Normal file
855
webui/src/pages/index/market/[[id]].vue
Normal file
@@ -0,0 +1,855 @@
|
||||
<script lang="ts" setup>
|
||||
import { Left } from '@icon-park/vue-next';
|
||||
import MarketConfirm from '@/components/market/market-confirm.vue';
|
||||
import type { ApiError } from '#/api';
|
||||
import type { CatalogEntry, PluginInfo } from '#/plugins';
|
||||
import { apiPlugins } from '@/api/plugins';
|
||||
import { refreshPluginProviders } from '@/hooks/usePluginProviders';
|
||||
import { refreshPluginUi } from '@/hooks/usePluginUi';
|
||||
import { idLabel } from '@/utils/id-label';
|
||||
import { renderMarkdown } from '@/utils/markdown';
|
||||
import {
|
||||
MARKET_CATEGORIES,
|
||||
MARKET_STATES,
|
||||
type MarketCategory,
|
||||
type MarketState,
|
||||
categoryCounts,
|
||||
entryState,
|
||||
filterEntries,
|
||||
readCachedCatalog,
|
||||
sourceUrl,
|
||||
writeCachedCatalog,
|
||||
} from '@/utils/plugin-market';
|
||||
import { relativeTime } from '@/utils/relative-time';
|
||||
|
||||
definePage({
|
||||
name: 'Market',
|
||||
});
|
||||
|
||||
const { t, lang } = useMyI18n();
|
||||
const message = useMessage();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { isMobile } = useBreakpointQuery();
|
||||
const { refreshGroup } = useConfigStore();
|
||||
|
||||
// 目录:成功获取后写入 localStorage;目录不可达时显示上一次的目录(stale),
|
||||
// 此时安装与更新不可用。error 为 null 表示最近一次获取成功,'' 表示失败但后端没给原因。
|
||||
const catalog = ref<CatalogEntry[] | null>(null);
|
||||
const fetchedAt = ref(0);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const stale = computed(() => error.value !== null && catalog.value !== null);
|
||||
|
||||
const installed = ref<PluginInfo[]>([]);
|
||||
|
||||
async function loadCatalog() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const entries = await apiPlugins.catalog();
|
||||
catalog.value = entries;
|
||||
fetchedAt.value = Date.now();
|
||||
error.value = null;
|
||||
writeCachedCatalog({ entries, fetchedAt: fetchedAt.value });
|
||||
} catch (e) {
|
||||
// axios 拦截器把失败归一成 ApiError,后端原因在 detail
|
||||
error.value = (e as ApiError).detail ?? '';
|
||||
const cached = catalog.value ? null : readCachedCatalog();
|
||||
if (cached) {
|
||||
catalog.value = cached.entries;
|
||||
fetchedAt.value = cached.fetchedAt;
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInstalled() {
|
||||
try {
|
||||
installed.value = (await apiPlugins.list()).plugins;
|
||||
} catch {
|
||||
// 只用于更新确认中的权限对比;失败时按“没有旧权限”处理,新增权限都需确认
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => Promise.all([loadCatalog(), loadInstalled()]));
|
||||
|
||||
// 相对时间每分钟刷新一次
|
||||
const now = ref(Date.now());
|
||||
useIntervalFn(() => (now.value = Date.now()), 60_000);
|
||||
const updatedText = computed(() =>
|
||||
relativeTime(t, fetchedAt.value, Math.max(now.value, fetchedAt.value))
|
||||
);
|
||||
const cachedAt = computed(() =>
|
||||
new Date(fetchedAt.value).toLocaleString(lang.value, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
);
|
||||
|
||||
// 筛选(分类与状态的取值来自下面的选项,模型类型是 string)
|
||||
const query = ref('');
|
||||
const category = ref<string>('all');
|
||||
const state = ref<string>('all');
|
||||
const filter = computed(() => ({
|
||||
query: query.value,
|
||||
category: category.value as MarketCategory | 'all',
|
||||
state: state.value as MarketState | 'all',
|
||||
}));
|
||||
|
||||
const counts = computed(() =>
|
||||
categoryCounts(catalog.value ?? [], filter.value)
|
||||
);
|
||||
const categoryOptions = computed(() => [
|
||||
{
|
||||
value: 'all',
|
||||
label: `${t('market.category_all')} ${counts.value.all}`,
|
||||
},
|
||||
...MARKET_CATEGORIES.filter(
|
||||
(c) => counts.value[c] > 0 || c === category.value
|
||||
).map((c) => ({
|
||||
value: c,
|
||||
label: `${t(`market.category.${c}`)} ${counts.value[c]}`,
|
||||
})),
|
||||
]);
|
||||
const stateOptions = computed(() => [
|
||||
{ value: 'all', label: t('market.state_all') },
|
||||
...MARKET_STATES.map((s) => ({ value: s, label: t(`market.state.${s}`) })),
|
||||
]);
|
||||
|
||||
const visible = computed(() =>
|
||||
filterEntries(catalog.value ?? [], filter.value)
|
||||
);
|
||||
|
||||
function resetFilters() {
|
||||
query.value = '';
|
||||
category.value = 'all';
|
||||
state.value = 'all';
|
||||
}
|
||||
|
||||
// 选中项:/market/:id;桌面上没有 id 时默认显示列表第一项
|
||||
const routeId = computed(
|
||||
() => (route.params as Record<string, string | undefined>).id
|
||||
);
|
||||
const selected = computed(() => {
|
||||
const list = catalog.value ?? [];
|
||||
if (routeId.value) return list.find((e) => e.id === routeId.value) ?? null;
|
||||
return isMobile.value ? null : visible.value[0] ?? null;
|
||||
});
|
||||
const showDetail = computed(() => isMobile.value && Boolean(routeId.value));
|
||||
|
||||
const confirming = ref(false);
|
||||
watch(routeId, () => (confirming.value = false));
|
||||
|
||||
function back() {
|
||||
if ((window.history.state as { back?: unknown } | null)?.back) router.back();
|
||||
else router.replace('/market');
|
||||
}
|
||||
|
||||
function stateLabel(entry: CatalogEntry) {
|
||||
const s = entryState(entry);
|
||||
return s === 'update'
|
||||
? t('market.update_from', { version: entry.installed_version })
|
||||
: t(`market.state.${s}`);
|
||||
}
|
||||
|
||||
const installedPermissions = computed(
|
||||
() =>
|
||||
installed.value.find((p) => p.id === selected.value?.id)?.permissions ?? []
|
||||
);
|
||||
|
||||
const primaryLabel = computed(() =>
|
||||
selected.value
|
||||
? t(
|
||||
{
|
||||
available: 'market.install',
|
||||
update: 'market.update',
|
||||
installed: 'market.state.installed',
|
||||
}[entryState(selected.value)]
|
||||
)
|
||||
: ''
|
||||
);
|
||||
// 已是最新,或目录不可达(安装需要联网)
|
||||
const primaryDisabled = computed(
|
||||
() =>
|
||||
!selected.value || entryState(selected.value) === 'installed' || stale.value
|
||||
);
|
||||
|
||||
const readme = computed(() =>
|
||||
selected.value?.readme ? renderMarkdown(selected.value.readme) : ''
|
||||
);
|
||||
|
||||
// 安装/更新
|
||||
const busy = ref(false);
|
||||
|
||||
async function install(entry: CatalogEntry) {
|
||||
const name = entry.name || entry.id;
|
||||
const updating = entry.installed_version !== null;
|
||||
busy.value = true;
|
||||
try {
|
||||
installed.value = (await apiPlugins.install(entry.id)).plugins;
|
||||
} catch {
|
||||
// 后端的具体原因(如验签失败)由 axios 拦截器另行提示
|
||||
message.error(
|
||||
t(updating ? 'market.update_failed' : 'market.install_failed', { name })
|
||||
);
|
||||
busy.value = false;
|
||||
return;
|
||||
}
|
||||
message.success(
|
||||
updating
|
||||
? t('market.update_success', { name, version: entry.version })
|
||||
: t('market.install_success', { name })
|
||||
);
|
||||
confirming.value = false;
|
||||
busy.value = false;
|
||||
// 与设置页插件卡片相同:同步 config store 的 plugins 段、Provider 候选与前端挂载点
|
||||
await Promise.all([
|
||||
loadCatalog(),
|
||||
refreshGroup('plugins', ['allow_unsigned', 'enabled', 'options']),
|
||||
refreshPluginProviders(),
|
||||
refreshPluginUi(),
|
||||
]);
|
||||
}
|
||||
|
||||
function pointLabel(id: string) {
|
||||
return idLabel(t, 'config.plugins_set.point_labels', id);
|
||||
}
|
||||
|
||||
function permissionLabel(id: string) {
|
||||
return idLabel(t, 'config.plugins_set.permission_labels', id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="market" :class="{ 'market--detail': showDetail }">
|
||||
<!-- 手机:详情是单独一屏,顶部返回 -->
|
||||
<header v-if="showDetail" class="market__bar">
|
||||
<ab-icon-button :label="$t('market.back')" @click="back">
|
||||
<Left :size="20" />
|
||||
</ab-icon-button>
|
||||
<h1 class="market__bar-title">
|
||||
{{ selected ? selected.name || selected.id : $t('market.title') }}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<template v-else>
|
||||
<!-- 桌面:搜索 | 状态 | 目录状态;手机:标题 | 目录状态,下一行搜索 | 状态 -->
|
||||
<header class="market__top">
|
||||
<h1 v-if="isMobile" class="market__title">{{ $t('market.title') }}</h1>
|
||||
<div class="market__meta">
|
||||
<span
|
||||
v-if="catalog && !stale"
|
||||
class="market__verified"
|
||||
:title="$t('market.catalog_verified')"
|
||||
>
|
||||
<span class="mark mark--installed" aria-hidden="true"></span>
|
||||
<span class="market__verified-text"
|
||||
>{{ $t('market.catalog_verified') }} ·</span
|
||||
>
|
||||
<span class="market__time">{{
|
||||
$t('market.updated', { time: updatedText })
|
||||
}}</span>
|
||||
</span>
|
||||
<ab-button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
:loading="loading"
|
||||
@click="loadCatalog"
|
||||
>
|
||||
{{ $t('market.refresh') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
<ab-input
|
||||
v-model="query"
|
||||
clearable
|
||||
class="market__search"
|
||||
:placeholder="$t('market.search')"
|
||||
:aria-label="$t('market.search')"
|
||||
/>
|
||||
<ab-select
|
||||
v-model="state"
|
||||
class="market__state"
|
||||
:options="stateOptions"
|
||||
:aria-label="$t('market.state_filter')"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<ab-segmented
|
||||
v-if="catalog?.length"
|
||||
v-model:value="category"
|
||||
size="sm"
|
||||
class="market__tabs"
|
||||
:options="categoryOptions"
|
||||
:aria-label="$t('market.categories')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<ab-alert v-if="stale" type="warning" :title="$t('market.stale_lead')">
|
||||
{{ $t('market.stale', { time: cachedAt }) }}
|
||||
<span v-if="error" class="market__error">{{ error }}</span>
|
||||
<template #action>
|
||||
<ab-button size="sm" :loading="loading" @click="loadCatalog">
|
||||
{{ $t('market.retry') }}
|
||||
</ab-button>
|
||||
</template>
|
||||
</ab-alert>
|
||||
|
||||
<ab-skeleton v-if="!catalog && loading" preset="row" :count="5" />
|
||||
|
||||
<ab-empty
|
||||
v-else-if="!catalog && error !== null"
|
||||
:title="$t('market.load_failed')"
|
||||
:description="$t('market.load_failed_hint')"
|
||||
>
|
||||
<template #action>
|
||||
<p v-if="error" class="market__error">{{ error }}</p>
|
||||
<ab-button size="sm" :loading="loading" @click="loadCatalog">
|
||||
{{ $t('market.retry') }}
|
||||
</ab-button>
|
||||
</template>
|
||||
</ab-empty>
|
||||
|
||||
<div v-else-if="catalog" class="market__panes">
|
||||
<nav
|
||||
v-if="!showDetail"
|
||||
class="market__list"
|
||||
:aria-label="$t('market.list_label')"
|
||||
>
|
||||
<RouterLink
|
||||
v-for="entry in visible"
|
||||
:key="entry.id"
|
||||
:to="`/market/${encodeURIComponent(entry.id)}`"
|
||||
:replace="!isMobile"
|
||||
class="row"
|
||||
:class="{ 'row--on': !isMobile && selected?.id === entry.id }"
|
||||
:aria-current="
|
||||
!isMobile && selected?.id === entry.id ? 'true' : undefined
|
||||
"
|
||||
>
|
||||
<span class="row__name">{{ entry.name || entry.id }}</span>
|
||||
<span class="row__version">{{ entry.version }}</span>
|
||||
<span class="row__state">
|
||||
<span
|
||||
class="mark"
|
||||
:class="`mark--${entryState(entry)}`"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{{ stateLabel(entry) }}
|
||||
</span>
|
||||
<span class="row__author">{{ entry.authors.join(', ') }}</span>
|
||||
</RouterLink>
|
||||
|
||||
<ab-empty
|
||||
v-if="!visible.length && query.trim()"
|
||||
:title="$t('market.no_match', { query: query.trim() })"
|
||||
>
|
||||
<template #action>
|
||||
<ab-button size="sm" variant="ghost" @click="query = ''">
|
||||
{{ $t('market.clear_search') }}
|
||||
</ab-button>
|
||||
</template>
|
||||
</ab-empty>
|
||||
<ab-empty
|
||||
v-else-if="!visible.length && catalog.length"
|
||||
:title="$t('market.no_filter_match')"
|
||||
>
|
||||
<template #action>
|
||||
<ab-button size="sm" variant="ghost" @click="resetFilters">
|
||||
{{ $t('market.show_all') }}
|
||||
</ab-button>
|
||||
</template>
|
||||
</ab-empty>
|
||||
<ab-empty v-else-if="!catalog.length" :title="$t('market.empty')" />
|
||||
</nav>
|
||||
|
||||
<section
|
||||
v-if="selected && (!isMobile || showDetail)"
|
||||
class="market__detail"
|
||||
>
|
||||
<MarketConfirm
|
||||
v-if="confirming"
|
||||
:key="selected.id"
|
||||
:entry="selected"
|
||||
:installed-permissions="installedPermissions"
|
||||
:busy="busy"
|
||||
@back="confirming = false"
|
||||
@confirm="install(selected)"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<header class="detail__head">
|
||||
<div class="detail__heading">
|
||||
<h2 v-if="!isMobile" class="detail__title">
|
||||
{{ selected.name || selected.id }}
|
||||
</h2>
|
||||
<p v-if="selected.description" class="detail__desc">
|
||||
{{ selected.description }}
|
||||
</p>
|
||||
</div>
|
||||
<ab-button
|
||||
v-if="!isMobile"
|
||||
variant="primary"
|
||||
:disabled="primaryDisabled"
|
||||
@click="confirming = true"
|
||||
>
|
||||
{{ primaryLabel }}
|
||||
</ab-button>
|
||||
</header>
|
||||
|
||||
<dl class="detail__kv">
|
||||
<dt>{{ $t('market.version') }}</dt>
|
||||
<dd class="detail__mono">
|
||||
{{
|
||||
selected.sdk
|
||||
? $t('market.version_sdk', {
|
||||
version: selected.version,
|
||||
sdk: selected.sdk,
|
||||
})
|
||||
: selected.version
|
||||
}}
|
||||
</dd>
|
||||
|
||||
<template v-if="selected.authors.length">
|
||||
<dt>{{ $t('market.authors') }}</dt>
|
||||
<dd>{{ selected.authors.join(', ') }}</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="selected.repo && selected.commit">
|
||||
<dt>{{ $t('market.source') }}</dt>
|
||||
<dd>
|
||||
<a
|
||||
class="detail__link"
|
||||
:href="sourceUrl(selected)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{ selected.repo }}@{{ selected.commit.slice(0, 7) }}
|
||||
<span aria-hidden="true">↗</span></a
|
||||
>
|
||||
</dd>
|
||||
</template>
|
||||
|
||||
<dt>{{ $t('market.extension_points') }}</dt>
|
||||
<dd class="detail__tags">
|
||||
<!-- ab-tag 的 title 是文字内容,原始 id 的提示放在外层 -->
|
||||
<span
|
||||
v-for="id in selected.extension_points"
|
||||
:key="id"
|
||||
:title="id"
|
||||
>
|
||||
<ab-tag>{{ pointLabel(id) }}</ab-tag>
|
||||
</span>
|
||||
<span
|
||||
v-if="!selected.extension_points.length"
|
||||
class="detail__muted"
|
||||
>
|
||||
{{ $t('market.none') }}
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
<dt>{{ $t('market.permissions') }}</dt>
|
||||
<dd class="detail__tags">
|
||||
<span v-for="id in selected.permissions" :key="id" :title="id">
|
||||
<ab-tag>{{ permissionLabel(id) }}</ab-tag>
|
||||
</span>
|
||||
<span v-if="!selected.permissions.length" class="detail__muted">
|
||||
{{ $t('market.none') }}
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
<dt>{{ $t('market.frontend') }}</dt>
|
||||
<dd>{{ selected.has_web ? $t('market.yes') : $t('market.no') }}</dd>
|
||||
</dl>
|
||||
|
||||
<!-- renderMarkdown 先转义全部 HTML,只输出白名单标签与 http(s) 链接 -->
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<article v-if="readme" class="readme" v-html="readme"></article>
|
||||
|
||||
<div v-if="isMobile" class="detail__sticky">
|
||||
<ab-button
|
||||
variant="primary"
|
||||
block
|
||||
:disabled="primaryDisabled"
|
||||
@click="confirming = true"
|
||||
>
|
||||
{{ primaryLabel }}
|
||||
</ab-button>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.market {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.market__top {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'title meta'
|
||||
'search state';
|
||||
grid-template-columns: minmax(0, 1fr) 128px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
@include forTablet {
|
||||
grid-template-areas: 'search state meta';
|
||||
grid-template-columns: minmax(0, 1fr) 140px auto;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.market__title,
|
||||
.market__bar-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.market__title {
|
||||
grid-area: title;
|
||||
}
|
||||
|
||||
.market__meta {
|
||||
display: flex;
|
||||
grid-area: meta;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.market__verified {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
// 手机上标题旁放不下:只留色块与时间,文字留给读屏(与 title 提示)
|
||||
.market__verified-text {
|
||||
@include forMobile {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.market__time {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.market__search {
|
||||
grid-area: search;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.market__state {
|
||||
grid-area: state;
|
||||
}
|
||||
|
||||
// 后端给出的原因(英文原文),单独一行
|
||||
.market__error {
|
||||
display: block;
|
||||
margin: 4px 0 8px;
|
||||
color: var(--color-text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.market__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.market__bar-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 桌面:列表 + 详情双栏,各自滚动;手机:只显示其中一栏
|
||||
.market__panes {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
@include forTablet {
|
||||
grid-template-columns: minmax(260px, 320px) 1fr;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.market__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
|
||||
@include forTablet {
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: baseline;
|
||||
gap: 2px 10px;
|
||||
min-height: var(--touch-target);
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
transition: background-color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@include forMobile {
|
||||
padding: 10px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.row--on {
|
||||
background: var(--color-surface-2);
|
||||
box-shadow: inset 2px 0 0 var(--color-primary);
|
||||
}
|
||||
|
||||
.row__name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row__version,
|
||||
.detail__mono {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.row__version {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.row__state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row__author {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Soft Ink 状态标记:方形色块 + 墨色文字
|
||||
.mark {
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.mark--installed {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.mark--update {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
.mark--available {
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.market__detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
|
||||
@include forTablet {
|
||||
padding: 16px 20px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.detail__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detail__heading {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail__title {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.detail__desc {
|
||||
margin: 2px 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail__kv {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(64px, max-content) 1fr;
|
||||
gap: 8px 14px;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
|
||||
dt {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.detail__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.detail__muted {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.detail__link {
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--color-text-muted);
|
||||
text-underline-offset: 3px;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
&:hover {
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// 手机:主操作固定在详情滚动区的底部(底部导航之上)
|
||||
.detail__sticky {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: var(--z-sticky);
|
||||
margin-top: auto;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.readme {
|
||||
padding: 12px 0 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
:deep(h3),
|
||||
:deep(h4),
|
||||
:deep(h5),
|
||||
:deep(h6) {
|
||||
margin: 12px 0 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
:deep(h3:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
:deep(p),
|
||||
:deep(ul),
|
||||
:deep(ol) {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
:deep(ul),
|
||||
:deep(ol) {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
:deep(ul) {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
:deep(ol) {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
:deep(code) {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
:deep(pre) {
|
||||
margin: 0 0 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-2);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
:deep(a) {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
203
webui/src/pages/index/market/market.test.ts
Normal file
203
webui/src/pages/index/market/market.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import { ref } from 'vue';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import MarketPage from './[[id]].vue';
|
||||
import type { CatalogEntry } from '#/plugins';
|
||||
import { apiPlugins } from '@/api/plugins';
|
||||
import AbAlert from '@/components/basic/ab-alert.vue';
|
||||
import AbButton from '@/components/basic/ab-button.vue';
|
||||
import AbEmpty from '@/components/basic/ab-empty.vue';
|
||||
import AbInput from '@/components/basic/ab-input.vue';
|
||||
import AbSegmented from '@/components/basic/ab-segmented.vue';
|
||||
import AbTag from '@/components/basic/ab-tag.vue';
|
||||
import { refreshPluginProviders } from '@/hooks/usePluginProviders';
|
||||
import { refreshPluginUi } from '@/hooks/usePluginUi';
|
||||
|
||||
vi.stubGlobal('definePage', vi.fn());
|
||||
vi.stubGlobal('useRoute', () => ({ params: {} }));
|
||||
vi.stubGlobal('useRouter', () => ({ back: vi.fn(), replace: vi.fn() }));
|
||||
vi.stubGlobal('useIntervalFn', vi.fn());
|
||||
|
||||
vi.mock('@/api/plugins', () => ({
|
||||
apiPlugins: { catalog: vi.fn(), list: vi.fn(), install: vi.fn() },
|
||||
}));
|
||||
vi.mock('@/hooks/useMyI18n', async () => {
|
||||
const { ref } = await vi.importActual<typeof import('vue')>('vue');
|
||||
return { useMyI18n: () => ({ t: (key: string) => key, lang: ref('en') }) };
|
||||
});
|
||||
vi.mock('@/hooks/useMessage', () => ({
|
||||
useMessage: () => ({ success: vi.fn(), error: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/hooks/useBreakpointQuery', () => ({
|
||||
useBreakpointQuery: () => ({ isMobile: ref(false) }),
|
||||
}));
|
||||
vi.mock('@/hooks/usePluginProviders', () => ({
|
||||
refreshPluginProviders: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/hooks/usePluginUi', () => ({ refreshPluginUi: vi.fn() }));
|
||||
const refreshGroup = vi.fn();
|
||||
vi.mock('@/store/config', () => ({
|
||||
useConfigStore: () => ({ refreshGroup }),
|
||||
}));
|
||||
|
||||
const api = vi.mocked(apiPlugins);
|
||||
const getItem = vi.mocked(localStorage.getItem);
|
||||
const setItem = vi.mocked(localStorage.setItem);
|
||||
|
||||
function entry(overrides: Partial<CatalogEntry> = {}): CatalogEntry {
|
||||
return {
|
||||
id: 'bili',
|
||||
name: 'Bilibili',
|
||||
version: '0.2.0',
|
||||
kind: 'plugin',
|
||||
extension_points: ['search_site'],
|
||||
description: '',
|
||||
sdk: '',
|
||||
min_ab_version: '4.0.0',
|
||||
authors: ['alice'],
|
||||
permissions: ['network'],
|
||||
has_web: false,
|
||||
repo: 'alice/bili',
|
||||
commit: '3f9c2e1aa',
|
||||
path: '.',
|
||||
readme: '',
|
||||
installed_version: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function mountPage() {
|
||||
const wrapper = mount(MarketPage, {
|
||||
global: {
|
||||
components: { AbAlert, AbButton, AbEmpty, AbInput, AbSegmented, AbTag },
|
||||
stubs: {
|
||||
AbSelect: true,
|
||||
AbSkeleton: true,
|
||||
AbIconButton: true,
|
||||
RouterLink: {
|
||||
props: ['to'],
|
||||
template: '<a class="row-link" :href="to"><slot /></a>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function buttons(w: Awaited<ReturnType<typeof mountPage>>, text: string) {
|
||||
return w.findAll('button').filter((b) => b.text() === text);
|
||||
}
|
||||
|
||||
describe('market page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getItem.mockReturnValue(null);
|
||||
api.list.mockResolvedValue({ allow_unsigned: false, plugins: [] });
|
||||
});
|
||||
|
||||
it('should show the cached catalog with install disabled when the catalog is unavailable', async () => {
|
||||
getItem.mockReturnValue(
|
||||
JSON.stringify({ entries: [entry()], fetchedAt: 0 })
|
||||
);
|
||||
api.catalog.mockRejectedValue({
|
||||
status: 502,
|
||||
msg_en: '',
|
||||
msg_zh: '',
|
||||
detail: 'connect timeout',
|
||||
});
|
||||
|
||||
const w = await mountPage();
|
||||
|
||||
const alert = w.find('[role="alert"]');
|
||||
expect(alert.text()).toContain('market.stale_lead');
|
||||
expect(alert.text()).toContain('connect timeout');
|
||||
expect(w.findAll('.row-link')).toHaveLength(1);
|
||||
expect(
|
||||
buttons(w, 'market.install')[0].attributes('disabled')
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('should keep the loaded catalog and disable install when a refresh fails', async () => {
|
||||
api.catalog.mockResolvedValueOnce([entry()]);
|
||||
const w = await mountPage();
|
||||
expect(w.find('[role="alert"]').exists()).toBe(false);
|
||||
|
||||
api.catalog.mockRejectedValueOnce({ status: 0, msg_en: '', msg_zh: '' });
|
||||
await buttons(w, 'market.refresh')[0].trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(w.find('[role="alert"]').text()).toContain('market.stale_lead');
|
||||
expect(getItem).not.toHaveBeenCalled();
|
||||
expect(w.findAll('.row-link')).toHaveLength(1);
|
||||
expect(
|
||||
buttons(w, 'market.install')[0].attributes('disabled')
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('should show the error and recover on retry when there is no cache', async () => {
|
||||
api.catalog.mockRejectedValueOnce(new Error('down'));
|
||||
const w = await mountPage();
|
||||
expect(w.text()).toContain('market.load_failed');
|
||||
expect(w.find('.row-link').exists()).toBe(false);
|
||||
|
||||
api.catalog.mockResolvedValue([entry()]);
|
||||
await buttons(w, 'market.retry')[0].trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(w.text()).not.toContain('market.load_failed');
|
||||
expect(w.findAll('.row-link')).toHaveLength(1);
|
||||
expect(setItem).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should offer to clear a search that matches nothing', async () => {
|
||||
api.catalog.mockResolvedValue([
|
||||
entry(),
|
||||
entry({ id: 'ntfy', name: 'ntfy' }),
|
||||
]);
|
||||
const w = await mountPage();
|
||||
|
||||
await w.find('input').setValue('jellyfin');
|
||||
expect(w.findAll('.row-link')).toHaveLength(0);
|
||||
expect(w.text()).toContain('market.no_match');
|
||||
|
||||
await buttons(w, 'market.clear_search')[0].trigger('click');
|
||||
expect(w.findAll('.row-link')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should install after the confirm step and refresh catalog, config and plugin UI', async () => {
|
||||
api.catalog.mockResolvedValue([entry()]);
|
||||
api.install.mockResolvedValue({ allow_unsigned: false, plugins: [] });
|
||||
const w = await mountPage();
|
||||
|
||||
await buttons(w, 'market.install')[0].trigger('click');
|
||||
expect(w.text()).toContain('market.warning');
|
||||
expect(api.install).not.toHaveBeenCalled();
|
||||
|
||||
await buttons(w, 'market.install_enable')[0].trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(api.install).toHaveBeenCalledWith('bili');
|
||||
expect(api.catalog).toHaveBeenCalledTimes(2);
|
||||
expect(refreshGroup).toHaveBeenCalledWith(
|
||||
'plugins',
|
||||
expect.arrayContaining(['enabled'])
|
||||
);
|
||||
expect(refreshPluginProviders).toHaveBeenCalled();
|
||||
expect(refreshPluginUi).toHaveBeenCalled();
|
||||
expect(w.text()).not.toContain('market.warning');
|
||||
});
|
||||
|
||||
it('should stay on the confirm step when the install fails', async () => {
|
||||
api.catalog.mockResolvedValue([entry()]);
|
||||
api.install.mockRejectedValue(new Error('signature invalid'));
|
||||
const w = await mountPage();
|
||||
|
||||
await buttons(w, 'market.install')[0].trigger('click');
|
||||
await buttons(w, 'market.install_enable')[0].trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(w.text()).toContain('market.warning');
|
||||
expect(refreshPluginUi).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
65
webui/src/utils/__tests__/markdown.test.ts
Normal file
65
webui/src/utils/__tests__/markdown.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { renderMarkdown } from '../markdown';
|
||||
|
||||
describe('renderMarkdown', () => {
|
||||
it.each([
|
||||
['# Title', '<h3>Title</h3>'],
|
||||
['### Deep ###', '<h5>Deep</h5>'],
|
||||
['one\ntwo\n\nthree', '<p>one two</p><p>three</p>'],
|
||||
['- a\n- b', '<ul><li>a</li><li>b</li></ul>'],
|
||||
['1. a\n2) b', '<ol><li>a</li><li>b</li></ol>'],
|
||||
['- a\n more\n\n1. b', '<ul><li>a more</li></ul><ol><li>b</li></ol>'],
|
||||
['text\n- a', '<p>text</p><ul><li>a</li></ul>'],
|
||||
['**b** and *i*', '<p><strong>b</strong> and <em>i</em></p>'],
|
||||
['__b__ _i_ media_id', '<p><strong>b</strong> <em>i</em> media_id</p>'],
|
||||
['use `a **b**`', '<p>use <code>a **b**</code></p>'],
|
||||
[
|
||||
'```toml\nid = 1 # *x*\n\n<b>\n```',
|
||||
'<pre><code>id = 1 # *x*\n\n<b></code></pre>',
|
||||
],
|
||||
['```\nopen', '<pre><code>open</code></pre>'],
|
||||
[
|
||||
'[docs](https://a.io/x_y_z)',
|
||||
'<p><a href="https://a.io/x_y_z" target="_blank" rel="noopener noreferrer">docs</a></p>',
|
||||
],
|
||||
])('should render %j', (source, html) => {
|
||||
expect(renderMarkdown(source)).toBe(html);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'<script>alert(1)</script>',
|
||||
'<p><script>alert(1)</script></p>',
|
||||
],
|
||||
[
|
||||
'<img src=x onerror="alert(1)">',
|
||||
'<p><img src=x onerror="alert(1)"></p>',
|
||||
],
|
||||
['[x](javascript:alert(1))', '<p>x)</p>'],
|
||||
['[x](JavaScript:alert`1`)', '<p>x</p>'],
|
||||
['[x](data:text/html,hi)', '<p>x</p>'],
|
||||
['', '<p>pic</p>'],
|
||||
['# <iframe src=x>', '<h3><iframe src=x></h3>'],
|
||||
])('should neutralise %j', (source, html) => {
|
||||
expect(renderMarkdown(source)).toBe(html);
|
||||
});
|
||||
|
||||
it('should keep a quote in a link URL inside the href attribute', () => {
|
||||
const host = document.createElement('div');
|
||||
host.innerHTML = renderMarkdown('[x](https://a.io"onmouseover="alert(1))');
|
||||
const link = host.querySelector('a');
|
||||
expect(link?.getAttributeNames().sort()).toEqual(['href', 'rel', 'target']);
|
||||
expect(link?.getAttribute('href')).toBe(
|
||||
'https://a.io"onmouseover="alert(1'
|
||||
);
|
||||
});
|
||||
|
||||
it('should produce no element other than the allowed tags', () => {
|
||||
const host = document.createElement('div');
|
||||
host.innerHTML = renderMarkdown(
|
||||
'<svg onload=alert(1)>\n\n<a href="javascript:x">y</a>\n\n**<b>z</b>**'
|
||||
);
|
||||
const tags = Array.from(host.querySelectorAll('*'), (el) => el.tagName);
|
||||
expect(tags).toEqual(['P', 'P', 'P', 'STRONG']);
|
||||
});
|
||||
});
|
||||
189
webui/src/utils/__tests__/plugin-market.test.ts
Normal file
189
webui/src/utils/__tests__/plugin-market.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
categoriesOf,
|
||||
categoryCounts,
|
||||
entryState,
|
||||
filterEntries,
|
||||
permissionDiff,
|
||||
readCachedCatalog,
|
||||
sourceUrl,
|
||||
writeCachedCatalog,
|
||||
} from '../plugin-market';
|
||||
import type { CatalogEntry } from '#/plugins';
|
||||
|
||||
function catalogEntry(overrides: Partial<CatalogEntry> = {}): CatalogEntry {
|
||||
return {
|
||||
id: 'ntfy',
|
||||
name: 'ntfy',
|
||||
version: '0.2.0',
|
||||
kind: 'plugin',
|
||||
extension_points: ['notifier'],
|
||||
description: '',
|
||||
sdk: '>=0.5,<1',
|
||||
min_ab_version: '4.0.0',
|
||||
authors: ['AutoBangumi'],
|
||||
permissions: [],
|
||||
has_web: false,
|
||||
repo: 'EstrellaXD/Auto_Bangumi',
|
||||
commit: 'a421814c0ffee',
|
||||
path: '.',
|
||||
readme: '',
|
||||
installed_version: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('categoriesOf', () => {
|
||||
it.each([
|
||||
[['notifier'], false, ['notify']],
|
||||
[['search_site', 'http.request'], false, ['source']],
|
||||
[['rename_strategy', 'notifier'], false, ['notify', 'organize']],
|
||||
[['downloader'], false, ['other']],
|
||||
[['api_router'], true, ['interface']],
|
||||
[[], true, ['interface']],
|
||||
[[], false, ['other']],
|
||||
])(
|
||||
'should map points %j with has_web=%s to %j',
|
||||
(points, hasWeb, categories) => {
|
||||
const entry = catalogEntry({
|
||||
extension_points: points,
|
||||
has_web: hasWeb,
|
||||
});
|
||||
expect(categoriesOf(entry)).toEqual(categories);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('entryState', () => {
|
||||
it.each([
|
||||
[null, 'available'],
|
||||
['0.2.0', 'installed'],
|
||||
['0.1.0', 'update'],
|
||||
] as const)('should be %s → %s', (installed, state) => {
|
||||
expect(entryState(catalogEntry({ installed_version: installed }))).toBe(
|
||||
state
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterEntries', () => {
|
||||
const entries = [
|
||||
catalogEntry({ id: 'ntfy', name: 'ntfy 通知', installed_version: '0.2.0' }),
|
||||
catalogEntry({
|
||||
id: 'bili',
|
||||
name: 'Bilibili 源',
|
||||
extension_points: ['search_site'],
|
||||
authors: ['alice'],
|
||||
}),
|
||||
catalogEntry({
|
||||
id: 'nfo',
|
||||
name: 'NFO',
|
||||
extension_points: ['rename_strategy', 'notifier'],
|
||||
description: 'Writes Jellyfin metadata',
|
||||
installed_version: '0.1.0',
|
||||
}),
|
||||
];
|
||||
const ids = (list: CatalogEntry[]) => list.map((e) => e.id);
|
||||
const all = { query: '', category: 'all', state: 'all' } as const;
|
||||
|
||||
it.each([
|
||||
['BILI', ['bili']],
|
||||
['alice', ['bili']],
|
||||
['jellyfin', ['nfo']],
|
||||
['通知', ['ntfy']],
|
||||
[' ', ['ntfy', 'bili', 'nfo']],
|
||||
['nothing', []],
|
||||
])('should match query %j by name, id, author or description', (q, out) => {
|
||||
expect(ids(filterEntries(entries, { ...all, query: q }))).toEqual(out);
|
||||
});
|
||||
|
||||
it('should list a plugin in every category it belongs to', () => {
|
||||
expect(ids(filterEntries(entries, { ...all, category: 'notify' }))).toEqual(
|
||||
['ntfy', 'nfo']
|
||||
);
|
||||
expect(
|
||||
ids(filterEntries(entries, { ...all, category: 'organize' }))
|
||||
).toEqual(['nfo']);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['installed', ['ntfy']],
|
||||
['update', ['nfo']],
|
||||
['available', ['bili']],
|
||||
] as const)('should keep only %s plugins', (state, out) => {
|
||||
expect(ids(filterEntries(entries, { ...all, state }))).toEqual(out);
|
||||
});
|
||||
|
||||
it('should count a multi-category plugin once in all', () => {
|
||||
expect(categoryCounts(entries, { query: '', state: 'all' })).toEqual({
|
||||
all: 3,
|
||||
source: 1,
|
||||
notify: 2,
|
||||
organize: 1,
|
||||
interface: 0,
|
||||
other: 0,
|
||||
});
|
||||
expect(categoryCounts(entries, { query: 'nfo', state: 'all' }).all).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('permissionDiff', () => {
|
||||
it('should split kept, added and removed permissions', () => {
|
||||
expect(
|
||||
permissionDiff(['filesystem', 'fs.write'], ['filesystem', 'network'])
|
||||
).toEqual({
|
||||
kept: ['filesystem'],
|
||||
added: ['network'],
|
||||
removed: ['fs.write'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sourceUrl', () => {
|
||||
it.each([
|
||||
['.', 'https://github.com/alice/repo/tree/abc'],
|
||||
['plugins/bili', 'https://github.com/alice/repo/tree/abc/plugins/bili'],
|
||||
])('should build the GitHub tree URL for path %j', (path, url) => {
|
||||
expect(
|
||||
sourceUrl(catalogEntry({ repo: 'alice/repo', commit: 'abc', path }))
|
||||
).toBe(url);
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalog cache', () => {
|
||||
const getItem = vi.mocked(localStorage.getItem);
|
||||
const setItem = vi.mocked(localStorage.setItem);
|
||||
|
||||
it('should round-trip the last good catalog', () => {
|
||||
const cache = { entries: [catalogEntry()], fetchedAt: 1 };
|
||||
writeCachedCatalog(cache);
|
||||
getItem.mockReturnValueOnce(setItem.mock.lastCall?.[1] ?? null);
|
||||
expect(readCachedCatalog()).toEqual(cache);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing', () => null],
|
||||
['corrupt', () => '{'],
|
||||
[
|
||||
'blocked',
|
||||
() => {
|
||||
throw new Error('SecurityError');
|
||||
},
|
||||
],
|
||||
])(
|
||||
'should return null when the cache is %s',
|
||||
(_, read: () => string | null) => {
|
||||
getItem.mockImplementationOnce(read);
|
||||
expect(readCachedCatalog()).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
it('should ignore a failing write', () => {
|
||||
setItem.mockImplementationOnce(() => {
|
||||
throw new Error('QuotaExceededError');
|
||||
});
|
||||
expect(() =>
|
||||
writeCachedCatalog({ entries: [], fetchedAt: 0 })
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
112
webui/src/utils/markdown.ts
Normal file
112
webui/src/utils/markdown.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 插件 README 用的安全 Markdown 子集:先转义全部 HTML,再识别标题、段落、
|
||||
* 列表、代码块、行内代码、粗体/斜体与链接。链接只接受 http(s),图片只保留
|
||||
* 替代文字,原始 HTML 一律按文本显示。输出可直接用于 v-html。
|
||||
*/
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function emphasis(html: string): string {
|
||||
return html
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/(^|\W)__(.+?)__(?=\W|$)/g, '$1<strong>$2</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/(^|\W)_(.+?)_(?=\W|$)/g, '$1<em>$2</em>');
|
||||
}
|
||||
|
||||
// 行内代码与链接先换成占位符,强调语法不会改到它们里面(如 URL 中的 _)
|
||||
function inline(raw: string): string {
|
||||
const tokens: string[] = [];
|
||||
const hold = (html: string) => `\uE000${tokens.push(html) - 1}\uE000`;
|
||||
let html = escapeHtml(raw)
|
||||
.replace(/`([^`]+)`/g, (_, code: string) => hold(`<code>${code}</code>`))
|
||||
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, text: string, url: string) =>
|
||||
/^https?:\/\//i.test(url)
|
||||
? hold(
|
||||
`<a href="${url}" target="_blank" rel="noopener noreferrer">${emphasis(
|
||||
text
|
||||
)}</a>`
|
||||
)
|
||||
: text
|
||||
);
|
||||
html = emphasis(html);
|
||||
return html.replace(/\uE000(\d+)\uE000/g, (_, i: string) => tokens[+i]);
|
||||
}
|
||||
|
||||
const LIST_ITEM = /^\s*(?:([-*+])|(\d+)[.)])\s+(.*)$/;
|
||||
|
||||
export function renderMarkdown(source: string): string {
|
||||
const lines = source
|
||||
.replace(/\uE000/g, '')
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.split('\n');
|
||||
const out: string[] = [];
|
||||
let paragraph: string[] = [];
|
||||
let list: { ordered: boolean; items: string[] } | null = null;
|
||||
let code: string[] | null = null;
|
||||
|
||||
const flush = () => {
|
||||
if (paragraph.length) out.push(`<p>${inline(paragraph.join(' '))}</p>`);
|
||||
paragraph = [];
|
||||
if (list) {
|
||||
const tag = list.ordered ? 'ol' : 'ul';
|
||||
const items = list.items.map((item) => `<li>${inline(item)}</li>`);
|
||||
out.push(`<${tag}>${items.join('')}</${tag}>`);
|
||||
}
|
||||
list = null;
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
if (code) {
|
||||
if (/^\s*```/.test(line)) {
|
||||
out.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`);
|
||||
code = null;
|
||||
} else code.push(line);
|
||||
continue;
|
||||
}
|
||||
if (/^\s*```/.test(line)) {
|
||||
flush();
|
||||
code = [];
|
||||
continue;
|
||||
}
|
||||
const heading = /^\s*(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
|
||||
if (heading) {
|
||||
flush();
|
||||
// README 嵌在详情栏里,标题从 h3 起,不与页面标题争层级
|
||||
const level = Math.min(heading[1].length + 2, 6);
|
||||
out.push(`<h${level}>${inline(heading[2])}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
const item = LIST_ITEM.exec(line);
|
||||
if (item) {
|
||||
const ordered = item[2] !== undefined;
|
||||
if (paragraph.length || (list && list.ordered !== ordered)) flush();
|
||||
list ??= { ordered, items: [] };
|
||||
list.items.push(item[3]);
|
||||
continue;
|
||||
}
|
||||
if (!line.trim()) {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
// 列表项的续行并入上一项
|
||||
if (list && /^\s+/.test(line)) {
|
||||
list.items[list.items.length - 1] += ` ${line.trim()}`;
|
||||
continue;
|
||||
}
|
||||
if (list) flush();
|
||||
paragraph.push(line.trim());
|
||||
}
|
||||
// 未闭合的代码块按代码块收尾
|
||||
if (code) out.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`);
|
||||
flush();
|
||||
return out.join('');
|
||||
}
|
||||
139
webui/src/utils/plugin-market.ts
Normal file
139
webui/src/utils/plugin-market.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { CatalogEntry } from '#/plugins';
|
||||
|
||||
/** 插件市场的分类:按扩展点归成几个用户能理解的大类 */
|
||||
export type MarketCategory =
|
||||
| 'source'
|
||||
| 'notify'
|
||||
| 'organize'
|
||||
| 'interface'
|
||||
| 'other';
|
||||
export type MarketState = 'installed' | 'update' | 'available';
|
||||
|
||||
export const MARKET_CATEGORIES: MarketCategory[] = [
|
||||
'source',
|
||||
'notify',
|
||||
'organize',
|
||||
'interface',
|
||||
'other',
|
||||
];
|
||||
export const MARKET_STATES: MarketState[] = [
|
||||
'installed',
|
||||
'update',
|
||||
'available',
|
||||
];
|
||||
|
||||
// 扩展点 id(ab_sdk.points)→ 分类;未列出的扩展点归入 other
|
||||
const POINT_CATEGORY: Record<string, MarketCategory> = {
|
||||
search_site: 'source',
|
||||
metadata_provider: 'source',
|
||||
'http.request': 'source',
|
||||
notifier: 'notify',
|
||||
message_template: 'notify',
|
||||
rename_strategy: 'organize',
|
||||
media_files: 'organize',
|
||||
conflict_policy: 'organize',
|
||||
api_router: 'interface',
|
||||
mcp_tool: 'interface',
|
||||
mcp_resource: 'interface',
|
||||
};
|
||||
|
||||
/** 插件所属的分类;有多个扩展点的插件可属于多个分类 */
|
||||
export function categoriesOf(entry: CatalogEntry): MarketCategory[] {
|
||||
const found = new Set(
|
||||
entry.extension_points.map((p) => POINT_CATEGORY[p] ?? 'other')
|
||||
);
|
||||
if (entry.has_web) found.add('interface');
|
||||
if (!found.size) found.add('other');
|
||||
return MARKET_CATEGORIES.filter((c) => found.has(c));
|
||||
}
|
||||
|
||||
export function entryState(entry: CatalogEntry): MarketState {
|
||||
if (!entry.installed_version) return 'available';
|
||||
return entry.installed_version === entry.version ? 'installed' : 'update';
|
||||
}
|
||||
|
||||
export interface MarketFilter {
|
||||
query: string;
|
||||
category: MarketCategory | 'all';
|
||||
state: MarketState | 'all';
|
||||
}
|
||||
|
||||
function matchesQuery(entry: CatalogEntry, query: string) {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return [entry.name, entry.id, entry.description, ...entry.authors].some(
|
||||
(text) => text.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
/** 搜索与状态筛选后,各分类(及“全部”)的插件数;“全部”中每个插件只算一次 */
|
||||
export function categoryCounts(
|
||||
entries: CatalogEntry[],
|
||||
filter: Omit<MarketFilter, 'category'>
|
||||
): Record<MarketCategory | 'all', number> {
|
||||
const counts = {
|
||||
all: 0,
|
||||
source: 0,
|
||||
notify: 0,
|
||||
organize: 0,
|
||||
interface: 0,
|
||||
other: 0,
|
||||
};
|
||||
for (const entry of filterEntries(entries, { ...filter, category: 'all' })) {
|
||||
counts.all += 1;
|
||||
for (const c of categoriesOf(entry)) counts[c] += 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
export function filterEntries(
|
||||
entries: CatalogEntry[],
|
||||
{ query, category, state }: MarketFilter
|
||||
): CatalogEntry[] {
|
||||
return entries.filter(
|
||||
(entry) =>
|
||||
(category === 'all' || categoriesOf(entry).includes(category)) &&
|
||||
(state === 'all' || entryState(entry) === state) &&
|
||||
matchesQuery(entry, query)
|
||||
);
|
||||
}
|
||||
|
||||
/** 更新前后声明的权限差异 */
|
||||
export function permissionDiff(before: string[], after: string[]) {
|
||||
return {
|
||||
kept: after.filter((p) => before.includes(p)),
|
||||
added: after.filter((p) => !before.includes(p)),
|
||||
removed: before.filter((p) => !after.includes(p)),
|
||||
};
|
||||
}
|
||||
|
||||
/** GitHub 上插件源码的位置:仓库、固定的 commit 与子目录 */
|
||||
export function sourceUrl(entry: CatalogEntry): string {
|
||||
const path = entry.path && entry.path !== '.' ? `/${entry.path}` : '';
|
||||
return `https://github.com/${entry.repo}/tree/${entry.commit}${path}`;
|
||||
}
|
||||
|
||||
// 上一次成功获取的目录:目录不可达时仍可浏览(安装需要联网,届时禁用)
|
||||
const CACHE_KEY = 'plugin_market_catalog';
|
||||
|
||||
export interface CachedCatalog {
|
||||
entries: CatalogEntry[];
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
export function readCachedCatalog(): CachedCatalog | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(CACHE_KEY);
|
||||
return raw ? (JSON.parse(raw) as CachedCatalog) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCachedCatalog(cache: CachedCatalog) {
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
|
||||
} catch {
|
||||
// 隐私模式或存储已满:只是少了离线缓存
|
||||
}
|
||||
}
|
||||
17
webui/src/utils/relative-time.ts
Normal file
17
webui/src/utils/relative-time.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/** 相对时间(刚刚 / N 分钟前 / N 小时前 / N 天前),一周以上显示日期 */
|
||||
export function relativeTime(
|
||||
t: (key: string, params?: Record<string, unknown>) => string,
|
||||
time: string | number,
|
||||
now = Date.now()
|
||||
): string {
|
||||
const then = new Date(time).getTime();
|
||||
if (Number.isNaN(then)) return '';
|
||||
const minutes = Math.floor((now - then) / 60000);
|
||||
if (minutes < 1) return t('notifications.time.just_now');
|
||||
if (minutes < 60) return t('notifications.time.minutes_ago', { n: minutes });
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return t('notifications.time.hours_ago', { n: hours });
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) return t('notifications.time.days_ago', { n: days });
|
||||
return new Date(time).toLocaleDateString();
|
||||
}
|
||||
14
webui/types/dts/auto-imports.d.ts
vendored
14
webui/types/dts/auto-imports.d.ts
vendored
@@ -2,6 +2,8 @@
|
||||
export {}
|
||||
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 acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
|
||||
const afterAll: typeof import('vitest')['afterAll']
|
||||
const afterEach: typeof import('vitest')['afterEach']
|
||||
@@ -28,6 +30,8 @@ declare global {
|
||||
const beforeAll: typeof import('vitest')['beforeAll']
|
||||
const beforeEach: typeof import('vitest')['beforeEach']
|
||||
const buildNexusPhpSearchUrl: typeof import('../../src/utils/nexusphp')['buildNexusPhpSearchUrl']
|
||||
const categoriesOf: typeof import('../../src/utils/plugin-market')['categoriesOf']
|
||||
const categoryCounts: typeof import('../../src/utils/plugin-market')['categoryCounts']
|
||||
const chai: typeof import('vitest')['chai']
|
||||
const computed: typeof import('vue')['computed']
|
||||
const configSectionMatches: typeof import('../../src/utils/config-search')['configSectionMatches']
|
||||
@@ -44,14 +48,17 @@ declare global {
|
||||
const describe: typeof import('vitest')['describe']
|
||||
const dirtyConfigGroups: typeof import('../../src/utils/config-diff')['dirtyConfigGroups']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
const entryState: typeof import('../../src/utils/plugin-market')['entryState']
|
||||
const expect: typeof import('vitest')['expect']
|
||||
const fillSchemaDefaults: typeof import('../../src/utils/plugin-schema')['fillSchemaDefaults']
|
||||
const filterEntries: typeof import('../../src/utils/plugin-market')['filterEntries']
|
||||
const getActivePinia: typeof import('pinia')['getActivePinia']
|
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||
const h: typeof import('vue')['h']
|
||||
const hasConfiguredDownloader: typeof import('../../src/store/downloader')['hasConfiguredDownloader']
|
||||
const i18n: typeof import('../../src/hooks/useMyI18n')['i18n']
|
||||
const idLabel: typeof import('../../src/utils/id-label')['idLabel']
|
||||
const inject: typeof import('vue')['inject']
|
||||
const isProxy: typeof import('vue')['isProxy']
|
||||
const isReactive: typeof import('vue')['isReactive']
|
||||
@@ -83,12 +90,16 @@ declare global {
|
||||
const onUnmounted: typeof import('vue')['onUnmounted']
|
||||
const onUpdated: typeof import('vue')['onUpdated']
|
||||
const parseLogLines: typeof import('../../src/utils/log-parse')['parseLogLines']
|
||||
const permissionDiff: typeof import('../../src/utils/plugin-market')['permissionDiff']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const reactive: typeof import('vue')['reactive']
|
||||
const readCachedCatalog: typeof import('../../src/utils/plugin-market')['readCachedCatalog']
|
||||
const readonly: typeof import('vue')['readonly']
|
||||
const ref: typeof import('vue')['ref']
|
||||
const refreshPluginProviders: typeof import('../../src/hooks/usePluginProviders')['refreshPluginProviders']
|
||||
const refreshPluginUi: typeof import('../../src/hooks/usePluginUi')['refreshPluginUi']
|
||||
const relativeTime: typeof import('../../src/utils/relative-time')['relativeTime']
|
||||
const renderMarkdown: typeof import('../../src/utils/markdown')['renderMarkdown']
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const resolvePosterUrl: typeof import('../../src/utils/poster')['resolvePosterUrl']
|
||||
const schemaFields: typeof import('../../src/utils/plugin-schema')['schemaFields']
|
||||
@@ -98,6 +109,7 @@ declare global {
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
const slotTitle: typeof import('../../src/hooks/usePluginUi')['slotTitle']
|
||||
const sourceUrl: typeof import('../../src/utils/plugin-market')['sourceUrl']
|
||||
const storeToRefs: typeof import('pinia')['storeToRefs']
|
||||
const suite: typeof import('vitest')['suite']
|
||||
const test: typeof import('vitest')['test']
|
||||
@@ -138,6 +150,7 @@ declare global {
|
||||
const usePasskey: typeof import('../../src/hooks/usePasskey')['usePasskey']
|
||||
const usePlayerStore: typeof import('../../src/store/player')['usePlayerStore']
|
||||
const usePluginHostDeps: typeof import('../../src/hooks/usePluginHostDeps')['usePluginHostDeps']
|
||||
const usePluginPages: typeof import('../../src/hooks/usePluginUi')['usePluginPages']
|
||||
const usePluginProviders: typeof import('../../src/hooks/usePluginProviders')['usePluginProviders']
|
||||
const usePluginUi: typeof import('../../src/hooks/usePluginUi')['usePluginUi']
|
||||
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
|
||||
@@ -157,4 +170,5 @@ declare global {
|
||||
const watchEffect: typeof import('vue')['watchEffect']
|
||||
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
||||
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
|
||||
const writeCachedCatalog: typeof import('../../src/utils/plugin-market')['writeCachedCatalog']
|
||||
}
|
||||
|
||||
2
webui/types/dts/components.d.ts
vendored
2
webui/types/dts/components.d.ts
vendored
@@ -49,6 +49,7 @@ declare module '@vue/runtime-core' {
|
||||
AbStatusBar: typeof import('./../../src/components/ab-status-bar.vue')['default']
|
||||
AbSwitch: typeof import('./../../src/components/basic/ab-switch.vue')['default']
|
||||
AbTag: typeof import('./../../src/components/basic/ab-tag.vue')['default']
|
||||
AbTagsInput: typeof import('./../../src/components/basic/ab-tags-input.vue')['default']
|
||||
AbToolbar: typeof import('./../../src/components/basic/ab-toolbar.vue')['default']
|
||||
AbTooltip: typeof import('./../../src/components/basic/ab-tooltip.vue')['default']
|
||||
AbTopbar: typeof import('./../../src/components/layout/ab-topbar.vue')['default']
|
||||
@@ -79,6 +80,7 @@ declare module '@vue/runtime-core' {
|
||||
ConfigSearchProvider: typeof import('./../../src/components/setting/config-search-provider.vue')['default']
|
||||
ConfigSecurity: typeof import('./../../src/components/setting/config-security.vue')['default']
|
||||
LlmAuthDialog: typeof import('./../../src/components/setting/llm-auth-dialog.vue')['default']
|
||||
MarketConfirm: typeof import('./../../src/components/market/market-confirm.vue')['default']
|
||||
MediaQuery: typeof import('./../../src/components/media-query.vue')['default']
|
||||
PluginSchemaForm: typeof import('./../../src/components/setting/plugin-schema-form.vue')['default']
|
||||
PluginSlot: typeof import('./../../src/components/plugin-slot.vue')['default']
|
||||
|
||||
1
webui/types/dts/router-type.d.ts
vendored
1
webui/types/dts/router-type.d.ts
vendored
@@ -47,6 +47,7 @@ declare module 'vue-router/auto/routes' {
|
||||
'Config': RouteRecordInfo<'Config', '/config', Record<never, never>, Record<never, never>>,
|
||||
'Downloader': RouteRecordInfo<'Downloader', '/downloader', Record<never, never>, Record<never, never>>,
|
||||
'Log': RouteRecordInfo<'Log', '/log', Record<never, never>, Record<never, never>>,
|
||||
'Market': RouteRecordInfo<'Market', '/market/:id?', { id?: ParamValueZeroOrOne<true> }, { id?: ParamValueZeroOrOne<false> }>,
|
||||
'Player': RouteRecordInfo<'Player', '/player', Record<never, never>, Record<never, never>>,
|
||||
'Plugin': RouteRecordInfo<'Plugin', '/plugins/:id', { id: ParamValue<true> }, { id: ParamValue<false> }>,
|
||||
'RSS': RouteRecordInfo<'RSS', '/rss', Record<never, never>, Record<never, never>>,
|
||||
|
||||
@@ -67,6 +67,8 @@ export interface CatalogEntry {
|
||||
kind: string;
|
||||
extension_points: string[];
|
||||
description: string;
|
||||
/** 插件依赖的 ab_sdk 版本范围 */
|
||||
sdk: string;
|
||||
min_ab_version: string;
|
||||
authors: string[];
|
||||
permissions: string[];
|
||||
@@ -74,6 +76,8 @@ export interface CatalogEntry {
|
||||
/** 源码位置:作者仓库与固定的 commit */
|
||||
repo: string;
|
||||
commit: string;
|
||||
/** 插件在仓库中的子目录,"." 为仓库根 */
|
||||
path: string;
|
||||
readme: string;
|
||||
installed_version: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user