mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-04 14:45:26 +08:00
Merge pull request #1056 from EstrellaXD/feat/nexusphp-pt-provider
feat(webui): NexusPHP PT-site preset for search providers
This commit is contained in:
63
backend/src/test/test_nexusphp_rss.py
Normal file
63
backend/src/test/test_nexusphp_rss.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""NexusPHP PT 站点(如 Audiences.me)torrentrss.php 输出的解析回归测试。
|
||||
|
||||
PT 支持依赖通用 RSS 解析路径:条目带 <enclosure>(.torrent 下载地址含
|
||||
passkey)时取 enclosure 为下载链接、<link>(details.php)为主页;
|
||||
linktype=dl 且无 enclosure 时 <link> 本身就是下载地址。
|
||||
"""
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from module.network.site import rss_parser
|
||||
|
||||
NEXUSPHP_RSS_WITH_ENCLOSURE = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Audiences :: torrents</title>
|
||||
<link>https://audiences.me</link>
|
||||
<item>
|
||||
<title>[Sakurato] Kusuriya no Hitorigoto [21][AVC-8bit 1080p AAC][CHS]</title>
|
||||
<link>https://audiences.me/details.php?id=12345</link>
|
||||
<enclosure url="https://audiences.me/download.php?id=12345&passkey=abc123" type="application/x-bittorrent" length="694157244"/>
|
||||
<guid>https://audiences.me/details.php?id=12345</guid>
|
||||
<pubDate>Sat, 04 Jul 2026 12:00:00 +0800</pubDate>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>"""
|
||||
|
||||
NEXUSPHP_RSS_LINKTYPE_DL = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Audiences :: torrents</title>
|
||||
<link>https://audiences.me</link>
|
||||
<item>
|
||||
<title>[ANi] Boku no Hero Academia S07 - 08 [1080P][WEB-DL][AAC AVC][CHT]</title>
|
||||
<link>https://audiences.me/download.php?id=67890&passkey=abc123</link>
|
||||
<guid>https://audiences.me/details.php?id=67890</guid>
|
||||
<pubDate>Sat, 04 Jul 2026 12:00:00 +0800</pubDate>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>"""
|
||||
|
||||
|
||||
def test_rss_parser_nexusphp_enclosure_yields_download_url() -> None:
|
||||
soup = ET.fromstring(NEXUSPHP_RSS_WITH_ENCLOSURE)
|
||||
results = rss_parser(soup)
|
||||
assert results == [
|
||||
(
|
||||
"[Sakurato] Kusuriya no Hitorigoto [21][AVC-8bit 1080p AAC][CHS]",
|
||||
"https://audiences.me/download.php?id=12345&passkey=abc123",
|
||||
"https://audiences.me/details.php?id=12345",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_rss_parser_nexusphp_linktype_dl_uses_link_as_download_url() -> None:
|
||||
soup = ET.fromstring(NEXUSPHP_RSS_LINKTYPE_DL)
|
||||
results = rss_parser(soup)
|
||||
assert results == [
|
||||
(
|
||||
"[ANi] Boku no Hero Academia S07 - 08 [1080P][WEB-DL][AAC AVC][CHT]",
|
||||
"https://audiences.me/download.php?id=67890&passkey=abc123",
|
||||
"",
|
||||
)
|
||||
]
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
import { Delete, EditTwo, Plus } from '@icon-park/vue-next';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
import {
|
||||
buildNexusPhpSearchUrl,
|
||||
isValidNexusPhpCategoryIds,
|
||||
} from '@/utils/nexusphp';
|
||||
|
||||
interface SearchProvider {
|
||||
name: string;
|
||||
@@ -33,6 +37,53 @@ const editingIndex = ref<number>(-1);
|
||||
const formName = ref('');
|
||||
const formUrl = ref('');
|
||||
|
||||
// Add-dialog mode: custom URL template, or NexusPHP PT-site preset that
|
||||
// builds a torrentrss.php search template from base URL + passkey.
|
||||
type AddMode = 'custom' | 'nexusphp';
|
||||
const formMode = ref<AddMode>('custom');
|
||||
const formBaseUrl = ref('');
|
||||
const formPasskey = ref('');
|
||||
const formCategoryId = ref('');
|
||||
|
||||
const addModeOptions = computed(() => [
|
||||
{ label: t('config.search_provider_set.mode_custom'), value: 'custom' },
|
||||
{ label: t('config.search_provider_set.mode_nexusphp'), value: 'nexusphp' },
|
||||
]);
|
||||
|
||||
const categoryIdsValid = computed(() =>
|
||||
isValidNexusPhpCategoryIds(formCategoryId.value)
|
||||
);
|
||||
|
||||
const builtNexusPhpUrl = computed(() => {
|
||||
if (!formBaseUrl.value.trim() || !formPasskey.value.trim()) return '';
|
||||
if (!categoryIdsValid.value) return '';
|
||||
return buildNexusPhpSearchUrl({
|
||||
baseUrl: formBaseUrl.value,
|
||||
passkey: formPasskey.value,
|
||||
categoryId: formCategoryId.value,
|
||||
});
|
||||
});
|
||||
|
||||
// 预览里遮住 passkey(存储的 URL 不变——供应商列表本就显示完整 URL)
|
||||
const previewNexusPhpUrl = computed(() =>
|
||||
builtNexusPhpUrl.value.replace(
|
||||
/(passkey=)([^&]+)/,
|
||||
(_, prefix: string, key: string) =>
|
||||
`${prefix}${key.length > 4 ? `${key.slice(0, 4)}…` : '…'}`
|
||||
)
|
||||
);
|
||||
|
||||
const addFormUrl = computed(() =>
|
||||
formMode.value === 'nexusphp' ? builtNexusPhpUrl.value : formUrl.value.trim()
|
||||
);
|
||||
|
||||
const canAdd = computed(
|
||||
() =>
|
||||
!!formName.value.trim() &&
|
||||
!!addFormUrl.value &&
|
||||
validateUrl(addFormUrl.value)
|
||||
);
|
||||
|
||||
// Default providers that cannot be deleted
|
||||
const defaultProviderNames = ['mikan', 'nyaa', 'dmhy'];
|
||||
|
||||
@@ -76,6 +127,10 @@ async function saveProviders() {
|
||||
function openAddDialog() {
|
||||
formName.value = '';
|
||||
formUrl.value = '';
|
||||
formMode.value = 'custom';
|
||||
formBaseUrl.value = '';
|
||||
formPasskey.value = '';
|
||||
formCategoryId.value = '';
|
||||
showAddDialog.value = true;
|
||||
}
|
||||
|
||||
@@ -88,7 +143,7 @@ function openEditDialog(provider: SearchProvider, index: number) {
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
if (!formName.value.trim() || !formUrl.value.trim()) return;
|
||||
if (!canAdd.value) return;
|
||||
|
||||
// Check for duplicate name
|
||||
if (providers.value.some((p) => p.name === formName.value.trim())) {
|
||||
@@ -97,13 +152,16 @@ async function handleAdd() {
|
||||
|
||||
providers.value.push({
|
||||
name: formName.value.trim(),
|
||||
url: formUrl.value.trim(),
|
||||
url: addFormUrl.value,
|
||||
});
|
||||
|
||||
await saveProviders();
|
||||
showAddDialog.value = false;
|
||||
formName.value = '';
|
||||
formUrl.value = '';
|
||||
formBaseUrl.value = '';
|
||||
formPasskey.value = '';
|
||||
formCategoryId.value = '';
|
||||
}
|
||||
|
||||
async function handleEdit() {
|
||||
@@ -226,26 +284,90 @@ function validateUrl(url: string): boolean {
|
||||
:title="$t('config.search_provider_set.add_title')"
|
||||
>
|
||||
<div space-y-16>
|
||||
<ab-segmented
|
||||
v-model:value="formMode"
|
||||
size="sm"
|
||||
:options="addModeOptions"
|
||||
:aria-label="$t('config.search_provider_set.add_title')"
|
||||
/>
|
||||
|
||||
<ab-field :label="$t('config.search_provider_set.name')">
|
||||
<ab-input
|
||||
v-model="formName"
|
||||
:placeholder="$t('config.search_provider_set.name_placeholder')"
|
||||
:placeholder="
|
||||
formMode === 'nexusphp'
|
||||
? $t('config.search_provider_set.name_placeholder_nexusphp')
|
||||
: $t('config.search_provider_set.name_placeholder')
|
||||
"
|
||||
:maxlength="32"
|
||||
/>
|
||||
</ab-field>
|
||||
|
||||
<ab-field :label="$t('config.search_provider_set.url')">
|
||||
<ab-input
|
||||
v-model="formUrl"
|
||||
type="text"
|
||||
:placeholder="$t('config.search_provider_set.url_placeholder')"
|
||||
@keyup.enter="handleAdd"
|
||||
/>
|
||||
</ab-field>
|
||||
<template v-if="formMode === 'custom'">
|
||||
<ab-field :label="$t('config.search_provider_set.url')">
|
||||
<ab-input
|
||||
v-model="formUrl"
|
||||
type="text"
|
||||
:placeholder="$t('config.search_provider_set.url_placeholder')"
|
||||
@keyup.enter="handleAdd"
|
||||
/>
|
||||
</ab-field>
|
||||
|
||||
<div v-if="formUrl && !validateUrl(formUrl)" class="validation-warning">
|
||||
{{ $t('config.search_provider_set.url_missing_placeholder') }}
|
||||
</div>
|
||||
<div
|
||||
v-if="formUrl && !validateUrl(formUrl)"
|
||||
class="validation-warning"
|
||||
>
|
||||
{{ $t('config.search_provider_set.url_missing_placeholder') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<ab-field :label="$t('config.search_provider_set.base_url')">
|
||||
<ab-input
|
||||
v-model="formBaseUrl"
|
||||
type="text"
|
||||
:placeholder="
|
||||
$t('config.search_provider_set.base_url_placeholder')
|
||||
"
|
||||
/>
|
||||
</ab-field>
|
||||
|
||||
<ab-field :label="$t('config.search_provider_set.passkey')">
|
||||
<ab-input
|
||||
v-model="formPasskey"
|
||||
type="text"
|
||||
:placeholder="
|
||||
$t('config.search_provider_set.passkey_placeholder')
|
||||
"
|
||||
/>
|
||||
</ab-field>
|
||||
|
||||
<ab-field :label="$t('config.search_provider_set.category_id')">
|
||||
<ab-input
|
||||
v-model="formCategoryId"
|
||||
type="text"
|
||||
:placeholder="
|
||||
$t('config.search_provider_set.category_id_placeholder')
|
||||
"
|
||||
@keyup.enter="handleAdd"
|
||||
/>
|
||||
</ab-field>
|
||||
|
||||
<div
|
||||
v-if="formCategoryId && !categoryIdsValid"
|
||||
class="validation-warning"
|
||||
>
|
||||
{{ $t('config.search_provider_set.category_id_invalid') }}
|
||||
</div>
|
||||
|
||||
<div class="hint-text">
|
||||
{{ $t('config.search_provider_set.nexusphp_hint') }}
|
||||
</div>
|
||||
|
||||
<div v-if="previewNexusPhpUrl" class="url-preview">
|
||||
{{ previewNexusPhpUrl }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
@@ -255,9 +377,7 @@ function validateUrl(url: string): boolean {
|
||||
<ab-button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
:disabled="
|
||||
!formName.trim() || !formUrl.trim() || !validateUrl(formUrl)
|
||||
"
|
||||
:disabled="!canAdd"
|
||||
@click="handleAdd"
|
||||
>
|
||||
{{ $t('config.apply') }}
|
||||
@@ -381,6 +501,21 @@ function validateUrl(url: string): boolean {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.url-preview {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
|
||||
monospace;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-surface-elevated, #f9fafb);
|
||||
border-radius: 6px;
|
||||
word-break: break-all;
|
||||
|
||||
:root.dark & {
|
||||
background: var(--color-surface-elevated, #1f2937);
|
||||
}
|
||||
}
|
||||
|
||||
.validation-warning {
|
||||
font-size: 12px;
|
||||
color: var(--color-danger, #ef4444);
|
||||
|
||||
@@ -142,7 +142,18 @@
|
||||
"delete_confirm": "Are you sure you want to delete this provider?",
|
||||
"load_failed": "Failed to load search providers",
|
||||
"save_failed": "Failed to save search providers",
|
||||
"save_success": "Search providers saved"
|
||||
"save_success": "Search providers saved",
|
||||
"mode_custom": "Custom URL",
|
||||
"mode_nexusphp": "PT Site (NexusPHP)",
|
||||
"name_placeholder_nexusphp": "e.g., audiences",
|
||||
"base_url": "Site URL",
|
||||
"base_url_placeholder": "https://audiences.me",
|
||||
"passkey": "Passkey",
|
||||
"passkey_placeholder": "Passkey from the site's user control panel",
|
||||
"category_id": "Category ID (optional)",
|
||||
"category_id_placeholder": "e.g., 402",
|
||||
"nexusphp_hint": "Builds a torrentrss.php search template from your passkey. Note: stock NexusPHP ignores the search parameter and always returns the latest torrents — verify on your site that RSS search actually filters results.",
|
||||
"category_id_invalid": "Category IDs must be numeric (comma-separated for multiple)"
|
||||
}
|
||||
},
|
||||
"downloader": {
|
||||
|
||||
@@ -142,7 +142,18 @@
|
||||
"delete_confirm": "确定删除此搜索源?",
|
||||
"load_failed": "加载搜索源失败",
|
||||
"save_failed": "保存搜索源失败",
|
||||
"save_success": "搜索源已保存"
|
||||
"save_success": "搜索源已保存",
|
||||
"mode_custom": "自定义 URL",
|
||||
"mode_nexusphp": "PT 站点(NexusPHP)",
|
||||
"name_placeholder_nexusphp": "例如:audiences",
|
||||
"base_url": "站点地址",
|
||||
"base_url_placeholder": "https://audiences.me",
|
||||
"passkey": "Passkey",
|
||||
"passkey_placeholder": "站点用户控制面板中的 Passkey",
|
||||
"category_id": "分类 ID(可选)",
|
||||
"category_id_placeholder": "例如:402",
|
||||
"nexusphp_hint": "根据 Passkey 生成 torrentrss.php 搜索模板。注意:原生 NexusPHP 已停用 search 参数、对任何搜索词都返回最新种子,请先在站点上确认 RSS 搜索确实按词过滤。",
|
||||
"category_id_invalid": "分类 ID 必须是数字(多个用逗号分隔)"
|
||||
}
|
||||
},
|
||||
"downloader": {
|
||||
|
||||
115
webui/src/utils/__tests__/nexusphp.test.ts
Normal file
115
webui/src/utils/__tests__/nexusphp.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* NexusPHP PT 站点(如 Audiences.me)搜索源 URL 模板构造。
|
||||
* 生成的模板交给后端 search_provider 使用:后端用 str.replace 把
|
||||
* 字面量 "%s" 换成搜索词,因此 %s 必须原样保留、不能被编码。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildNexusPhpSearchUrl,
|
||||
isValidNexusPhpCategoryIds,
|
||||
} from '../nexusphp';
|
||||
|
||||
describe('buildNexusPhpSearchUrl', () => {
|
||||
it('should build a torrentrss URL template when given base URL and passkey', () => {
|
||||
const url = buildNexusPhpSearchUrl({
|
||||
baseUrl: 'https://audiences.me',
|
||||
passkey: 'abc123',
|
||||
});
|
||||
expect(url).toBe(
|
||||
'https://audiences.me/torrentrss.php?rows=50&linktype=dl&passkey=abc123&search=%s'
|
||||
);
|
||||
});
|
||||
|
||||
it('should keep the literal %s placeholder unencoded', () => {
|
||||
const url = buildNexusPhpSearchUrl({
|
||||
baseUrl: 'https://audiences.me',
|
||||
passkey: 'abc123',
|
||||
});
|
||||
expect(url.endsWith('search=%s')).toBe(true);
|
||||
});
|
||||
|
||||
it('should emit a per-category flag when a category ID is provided', () => {
|
||||
// 原生 NexusPHP torrentrss.php 只识别 cat<ID>=1 形式的分类参数,
|
||||
// 裸 cat=<ID> 会被静默忽略
|
||||
const url = buildNexusPhpSearchUrl({
|
||||
baseUrl: 'https://audiences.me',
|
||||
passkey: 'abc123',
|
||||
categoryId: '402',
|
||||
});
|
||||
expect(url).toContain('&cat402=1&search=%s');
|
||||
expect(url).not.toContain('cat=402');
|
||||
});
|
||||
|
||||
it('should emit one flag per comma-separated category ID', () => {
|
||||
const url = buildNexusPhpSearchUrl({
|
||||
baseUrl: 'https://audiences.me',
|
||||
passkey: 'abc123',
|
||||
categoryId: '402, 403',
|
||||
});
|
||||
expect(url).toContain('&cat402=1&cat403=1&search=%s');
|
||||
});
|
||||
|
||||
it('should omit the cat parameter when the category ID is blank', () => {
|
||||
const url = buildNexusPhpSearchUrl({
|
||||
baseUrl: 'https://audiences.me',
|
||||
passkey: 'abc123',
|
||||
categoryId: ' ',
|
||||
});
|
||||
expect(url).not.toContain('cat');
|
||||
});
|
||||
|
||||
it('should strip trailing slashes from the base URL', () => {
|
||||
const url = buildNexusPhpSearchUrl({
|
||||
baseUrl: 'https://audiences.me/',
|
||||
passkey: 'abc123',
|
||||
});
|
||||
expect(url).toContain('audiences.me/torrentrss.php');
|
||||
});
|
||||
|
||||
it('should prepend https scheme when the base URL has none', () => {
|
||||
const url = buildNexusPhpSearchUrl({
|
||||
baseUrl: 'audiences.me',
|
||||
passkey: 'abc123',
|
||||
});
|
||||
expect(url.startsWith('https://audiences.me/')).toBe(true);
|
||||
});
|
||||
|
||||
it('should URL-encode reserved characters in the passkey', () => {
|
||||
const url = buildNexusPhpSearchUrl({
|
||||
baseUrl: 'https://audiences.me',
|
||||
passkey: 'a&b=c',
|
||||
});
|
||||
expect(url).toContain('passkey=a%26b%3Dc');
|
||||
});
|
||||
|
||||
it('should trim surrounding whitespace from inputs', () => {
|
||||
const url = buildNexusPhpSearchUrl({
|
||||
baseUrl: ' https://audiences.me ',
|
||||
passkey: ' abc123 ',
|
||||
});
|
||||
expect(url).toBe(
|
||||
'https://audiences.me/torrentrss.php?rows=50&linktype=dl&passkey=abc123&search=%s'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidNexusPhpCategoryIds', () => {
|
||||
it('should accept an empty input as no filter', () => {
|
||||
expect(isValidNexusPhpCategoryIds('')).toBe(true);
|
||||
expect(isValidNexusPhpCategoryIds(' ')).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept a single numeric ID', () => {
|
||||
expect(isValidNexusPhpCategoryIds('402')).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept comma-separated numeric IDs with spaces and a trailing comma', () => {
|
||||
expect(isValidNexusPhpCategoryIds('402, 403,')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject non-numeric IDs', () => {
|
||||
expect(isValidNexusPhpCategoryIds('abc')).toBe(false);
|
||||
expect(isValidNexusPhpCategoryIds('402,abc')).toBe(false);
|
||||
expect(isValidNexusPhpCategoryIds('4 02')).toBe(false);
|
||||
});
|
||||
});
|
||||
51
webui/src/utils/nexusphp.ts
Normal file
51
webui/src/utils/nexusphp.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* NexusPHP PT 站点(如 Audiences.me)搜索源 URL 模板构造。
|
||||
*
|
||||
* NexusPHP 的 torrentrss.php 用 passkey 放在 URL 里鉴权,linktype=dl 让
|
||||
* 条目链接指向 .torrent 下载地址,qBittorrent 可直接抓取。
|
||||
*
|
||||
* 注意:原生(新版)NexusPHP 已停用 torrentrss.php 的 search 参数
|
||||
* (源码中写死 `$searchstr = null`),此时该模板对任何搜索词都返回
|
||||
* 最新种子;只有保留了 search 支持的站点/分支能真正按词过滤。UI 的
|
||||
* 提示文案要求用户自行在站点上验证。
|
||||
*
|
||||
* 分类参数必须用 cat<ID>=1 的逐分类开关形式(torrentrss.php 只识别
|
||||
* /^(cat|sou|med|...)\d+$/ 形式的参数),裸 cat=<ID> 会被静默忽略。
|
||||
*
|
||||
* 返回的是后端 search_provider 的 URL 模板:字面量 "%s" 由后端替换为
|
||||
* 搜索词,必须保持未编码。
|
||||
*/
|
||||
|
||||
export interface NexusPhpProviderOptions {
|
||||
baseUrl: string;
|
||||
passkey: string;
|
||||
/** NexusPHP 分类 ID(如 Audiences.me 的剧集/动漫为 402),可用逗号
|
||||
* 分隔多个;留空则不过滤 */
|
||||
categoryId?: string;
|
||||
}
|
||||
|
||||
function parseCategoryIds(input: string | undefined): string[] {
|
||||
return (input ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** 分类 ID 输入校验:空 = 不过滤;否则每个逗号分隔段都必须是纯数字 */
|
||||
export function isValidNexusPhpCategoryIds(input: string): boolean {
|
||||
return parseCategoryIds(input).every((id) => /^\d+$/.test(id));
|
||||
}
|
||||
|
||||
export function buildNexusPhpSearchUrl(
|
||||
options: NexusPhpProviderOptions
|
||||
): string {
|
||||
let base = options.baseUrl.trim().replace(/\/+$/, '');
|
||||
if (!/^https?:\/\//i.test(base)) {
|
||||
base = `https://${base}`;
|
||||
}
|
||||
const passkey = encodeURIComponent(options.passkey.trim());
|
||||
const catParams = parseCategoryIds(options.categoryId)
|
||||
.map((id) => `&cat${encodeURIComponent(id)}=1`)
|
||||
.join('');
|
||||
return `${base}/torrentrss.php?rows=50&linktype=dl&passkey=${passkey}${catParams}&search=%s`;
|
||||
}
|
||||
1
webui/types/dts/auto-imports.d.ts
vendored
1
webui/types/dts/auto-imports.d.ts
vendored
@@ -24,6 +24,7 @@ declare global {
|
||||
const axios: typeof import('../../src/utils/axios')['axios']
|
||||
const beforeAll: typeof import('vitest')['beforeAll']
|
||||
const beforeEach: typeof import('vitest')['beforeEach']
|
||||
const buildNexusPhpSearchUrl: typeof import('../../src/utils/nexusphp')['buildNexusPhpSearchUrl']
|
||||
const chai: typeof import('vitest')['chai']
|
||||
const computed: typeof import('vue')['computed']
|
||||
const countLogLevels: typeof import('../../src/utils/log-parse')['countLogLevels']
|
||||
|
||||
Reference in New Issue
Block a user