feat(webui): 插件市场页面

- 新增 /market 与 /market/:id:列表 + 详情双栏,手机上点行进入整屏详情,
  主操作固定在底部;侧栏与手机导航加入口
- 按扩展点归类的分类标签、安装状态筛选与搜索;签名目录获取成功后缓存,
  目录不可达时显示上次的目录并禁用安装/更新
- 安装/更新前的确认步骤:来源与审查、权限声明、前端、同进程警告;
  更新显示版本与权限差异,权限变多须勾选确认
- README 用安全的 Markdown 子集渲染(先转义 HTML,链接只允许 http(s))
- 设置页的插件目录区换成「打开插件市场」入口
- 后端 CatalogEntry 补上 sdk 与 path 字段
This commit is contained in:
Estrella Pan
2026-10-09 13:33:48 +02:00
parent 40a525ecdc
commit 56ac06db2a
24 changed files with 2129 additions and 313 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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" />

View File

@@ -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

View File

@@ -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',
]"
>

View 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');
});
});

View 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>

View File

@@ -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 () => {

View File

@@ -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>

View File

@@ -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"
}
}

View File

@@ -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": "目录中还没有插件"
}
}

View File

@@ -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[]>([]);

View File

@@ -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);

View 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>

View 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();
});
});

View 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&lt;b&gt;</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>&lt;script&gt;alert(1)&lt;/script&gt;</p>',
],
[
'<img src=x onerror="alert(1)">',
'<p>&lt;img src=x onerror=&quot;alert(1)&quot;&gt;</p>',
],
['[x](javascript:alert(1))', '<p>x)</p>'],
['[x](JavaScript:alert`1`)', '<p>x</p>'],
['[x](data:text/html,hi)', '<p>x</p>'],
['![pic](https://a.io/p.png)', '<p>pic</p>'],
['# <iframe src=x>', '<h3>&lt;iframe src=x&gt;</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']);
});
});

View 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
View File

@@ -0,0 +1,112 @@
/**
* 插件 README 用的安全 Markdown 子集:先转义全部 HTML,再识别标题、段落、
* 列表、代码块、行内代码、粗体/斜体与链接。链接只接受 http(s),图片只保留
* 替代文字,原始 HTML 一律按文本显示。输出可直接用于 v-html。
*/
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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('');
}

View 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 {
// 隐私模式或存储已满:只是少了离线缓存
}
}

View 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();
}

View File

@@ -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']
}

View File

@@ -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']

View File

@@ -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>>,

View File

@@ -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;
}