mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-04 06:31:13 +00:00
Compare commits
5 Commits
2dcf8004ab
...
c3b29f1ee6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3b29f1ee6 | ||
|
|
3e85d18ab5 | ||
|
|
df48c01ce4 | ||
|
|
209776a9e8 | ||
|
|
09dae7269a |
54
.github/workflows/release.yml
vendored
54
.github/workflows/release.yml
vendored
@ -125,18 +125,54 @@ jobs:
|
|||||||
cd "$TMPDIR"
|
cd "$TMPDIR"
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# 1) 下载 QQ x64
|
# 1) 下载 QQ x64 (使用缓存)
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# JS_URL="https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js"
|
# JS_URL="https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js"
|
||||||
# JS_URL="https://slave.docadan488.workers.dev/proxy?url=https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js"
|
# JS_URL="https://slave.docadan488.workers.dev/proxy?url=https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js"
|
||||||
# NT_URL=$(curl -fsSL "$JS_URL" | grep -oP '"ntDownloadX64Url"\s*:\s*"\K[^"]+')
|
# NT_URL=$(curl -fsSL "$JS_URL" | grep -oP '"ntDownloadX64Url"\s*:\s*"\K[^"]+')
|
||||||
NT_URL="https://dldir1v6.qq.com/qqfile/qq/QQNT/eb263b35/QQ9.9.23.42086_x64.exe"
|
NT_URL="https://dldir1v6.qq.com/qqfile/qq/QQNT/32876254/QQ9.9.27.45627_x64.exe"
|
||||||
QQ_ZIP="$(basename "$NT_URL")"
|
QQ_ZIP="$(basename "$NT_URL")"
|
||||||
aria2c -x16 -s16 -k1M -o "$QQ_ZIP" "$NT_URL"
|
# 根据 URL 生成缓存键
|
||||||
|
QQ_CACHE_KEY="qq-x64-$(echo "$NT_URL" | md5sum | cut -d' ' -f1)"
|
||||||
|
echo "QQ_CACHE_KEY=$QQ_CACHE_KEY" >> $GITHUB_ENV
|
||||||
|
echo "QQ_ZIP=$QQ_ZIP" >> $GITHUB_ENV
|
||||||
|
echo "NT_URL=$NT_URL" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Cache QQ x64 Installer
|
||||||
|
id: cache-qq
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/qq-cache
|
||||||
|
key: ${{ env.QQ_CACHE_KEY }}
|
||||||
|
|
||||||
|
- name: Download and Extract QQ x64
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TMPDIR=$(mktemp -d)
|
||||||
|
cd "$TMPDIR"
|
||||||
|
|
||||||
|
QQ_CACHE_DIR="$HOME/qq-cache"
|
||||||
|
mkdir -p "$QQ_CACHE_DIR"
|
||||||
|
|
||||||
|
if [ -f "$QQ_CACHE_DIR/$QQ_ZIP" ]; then
|
||||||
|
echo "Using cached QQ installer: $QQ_ZIP"
|
||||||
|
cp "$QQ_CACHE_DIR/$QQ_ZIP" "$QQ_ZIP"
|
||||||
|
else
|
||||||
|
echo "Downloading QQ installer: $QQ_ZIP"
|
||||||
|
aria2c -x16 -s16 -k1M -o "$QQ_ZIP" "$NT_URL"
|
||||||
|
cp "$QQ_ZIP" "$QQ_CACHE_DIR/$QQ_ZIP"
|
||||||
|
fi
|
||||||
|
|
||||||
QQ_EXTRACT="$TMPDIR/qq_extracted"
|
QQ_EXTRACT="$TMPDIR/qq_extracted"
|
||||||
mkdir -p "$QQ_EXTRACT"
|
mkdir -p "$QQ_EXTRACT"
|
||||||
7z x -y -o"$QQ_EXTRACT" "$QQ_ZIP" >/dev/null
|
7z x -y -o"$QQ_EXTRACT" "$QQ_ZIP" >/dev/null
|
||||||
|
echo "QQ_EXTRACT=$QQ_EXTRACT" >> $GITHUB_ENV
|
||||||
|
echo "WORK_TMPDIR=$TMPDIR" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Download Node.js and Assemble NapCat.Shell.Windows.Node.zip
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$WORK_TMPDIR"
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# 2) 下载 Node.js Windows x64 zip 22.11.0
|
# 2) 下载 Node.js Windows x64 zip 22.11.0
|
||||||
@ -146,7 +182,7 @@ jobs:
|
|||||||
NODE_ZIP="node-v$NODE_VER-win-x64.zip"
|
NODE_ZIP="node-v$NODE_VER-win-x64.zip"
|
||||||
aria2c -x1 -s1 -k1M -o "$NODE_ZIP" "$NODE_URL"
|
aria2c -x1 -s1 -k1M -o "$NODE_ZIP" "$NODE_URL"
|
||||||
|
|
||||||
NODE_EXTRACT="$TMPDIR/node_extracted"
|
NODE_EXTRACT="$WORK_TMPDIR/node_extracted"
|
||||||
mkdir -p "$NODE_EXTRACT"
|
mkdir -p "$NODE_EXTRACT"
|
||||||
unzip -q "$NODE_ZIP" -d "$NODE_EXTRACT"
|
unzip -q "$NODE_ZIP" -d "$NODE_EXTRACT"
|
||||||
|
|
||||||
@ -164,11 +200,18 @@ jobs:
|
|||||||
# -----------------------------
|
# -----------------------------
|
||||||
# 5) 拷贝 QQ 文件到 NapCat.Shell.Windows.Node
|
# 5) 拷贝 QQ 文件到 NapCat.Shell.Windows.Node
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
QQ_TARGETS=("avif_convert.dll" "broadcast_ipc.dll" "config.json" "libglib-2.0-0.dll" "libgobject-2.0-0.dll" "libvips-42.dll" "ncnn.dll" "opencv.dll" "package.json" "QBar.dll" "wrapper.node")
|
QQ_TARGETS=("avif_convert.dll" "broadcast_ipc.dll" "config.json" "libglib-2.0-0.dll" "libgobject-2.0-0.dll" "libvips-42.dll" "ncnn.dll" "opencv.dll" "package.json" "QBar.dll" "wrapper.node" "LightQuic.dll")
|
||||||
for name in "${QQ_TARGETS[@]}"; do
|
for name in "${QQ_TARGETS[@]}"; do
|
||||||
find "$QQ_EXTRACT" -iname "$name" -exec cp -a {} "$OUT_DIR" \; || true
|
find "$QQ_EXTRACT" -iname "$name" -exec cp -a {} "$OUT_DIR" \; || true
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# 5.1) 拷贝 win64 目录下的文件
|
||||||
|
# -----------------------------
|
||||||
|
mkdir -p "$OUT_DIR/win64"
|
||||||
|
find "$QQ_EXTRACT" -ipath "*/win64/SSOShareInfoHelper64.dll" -exec cp -a {} "$OUT_DIR/win64/" \; || true
|
||||||
|
find "$QQ_EXTRACT" -ipath "*/win64/parent-ipc-core-x64.dll" -exec cp -a {} "$OUT_DIR/win64/" \; || true
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# 6) 拷贝仓库文件 napcat.bat 和 index.js
|
# 6) 拷贝仓库文件 napcat.bat 和 index.js
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
@ -178,6 +221,7 @@ jobs:
|
|||||||
# -----------------------------
|
# -----------------------------
|
||||||
# 7) 拷贝 Node.exe 到 NapCat.Shell.Windows.Node
|
# 7) 拷贝 Node.exe 到 NapCat.Shell.Windows.Node
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
|
NODE_VER="22.11.0"
|
||||||
cp -a "$NODE_EXTRACT/node-v$NODE_VER-win-x64/node.exe" "$OUT_DIR/" || true
|
cp -a "$NODE_EXTRACT/node-v$NODE_VER-win-x64/node.exe" "$OUT_DIR/" || true
|
||||||
|
|
||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
|
|||||||
2
packages/napcat-core/external/appid.json
vendored
2
packages/napcat-core/external/appid.json
vendored
@ -521,7 +521,7 @@
|
|||||||
},
|
},
|
||||||
"9.9.27-45627": {
|
"9.9.27-45627": {
|
||||||
"appid": 537340060,
|
"appid": 537340060,
|
||||||
"qua": "V1_WIN_NQ_9.9.26_45627_GW_B"
|
"qua": "V1_WIN_NQ_9.9.27_45627_GW_B"
|
||||||
},
|
},
|
||||||
"6.9.88-44725": {
|
"6.9.88-44725": {
|
||||||
"appid": 537337594,
|
"appid": 537337594,
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export interface LoginInitConfig {
|
|||||||
commonPath: string;
|
commonPath: string;
|
||||||
clientVer: string;
|
clientVer: string;
|
||||||
hostName: string;
|
hostName: string;
|
||||||
|
externalVersion: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PasswordLoginRetType {
|
export interface PasswordLoginRetType {
|
||||||
|
|||||||
@ -1,15 +1,17 @@
|
|||||||
import { GeneralCallResult } from './common';
|
import { GeneralCallResult } from './common';
|
||||||
|
|
||||||
export interface NodeIKernelNodeMiscService {
|
export interface NodeIKernelNodeMiscService {
|
||||||
writeVersionToRegistry(version: string): void;
|
writeVersionToRegistry (version: string): void;
|
||||||
|
|
||||||
getMiniAppPath(): unknown;
|
getMiniAppPath (): unknown;
|
||||||
|
|
||||||
setMiniAppVersion(version: string): unknown;
|
setMiniAppVersion (version: string): unknown;
|
||||||
|
|
||||||
wantWinScreenOCR(imagepath: string): Promise<GeneralCallResult>;
|
wantWinScreenOCR (imagepath: string): Promise<GeneralCallResult>;
|
||||||
|
|
||||||
SendMiniAppMsg(arg1: string, arg2: string, arg3: string): unknown;
|
SendMiniAppMsg (arg1: string, arg2: string, arg3: string): unknown;
|
||||||
|
|
||||||
startNewMiniApp(appfile: string, params: string): unknown;
|
startNewMiniApp (appfile: string, params: string): unknown;
|
||||||
|
|
||||||
|
getQimei36WithNewSdk (): Promise<string>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -87,9 +87,9 @@ export interface NodeQQNTWrapperUtil {
|
|||||||
|
|
||||||
fullWordToHalfWord (word: string): unknown;
|
fullWordToHalfWord (word: string): unknown;
|
||||||
|
|
||||||
getNTUserDataInfoConfig (): unknown;
|
getNTUserDataInfoConfig (): Promise<string>;
|
||||||
|
|
||||||
pathIsReadableAndWriteable (path: string): unknown; // 直接的猜测
|
pathIsReadableAndWriteable (path: string, type: number): Promise<number>; // type 2 , result 0 成功
|
||||||
|
|
||||||
resetUserDataSavePathToDocument (): unknown;
|
resetUserDataSavePathToDocument (): unknown;
|
||||||
|
|
||||||
@ -158,7 +158,7 @@ export interface NodeIQQNTStartupSessionWrapper {
|
|||||||
stop (): void;
|
stop (): void;
|
||||||
start (): void;
|
start (): void;
|
||||||
createWithModuleList (uk: unknown): unknown;
|
createWithModuleList (uk: unknown): unknown;
|
||||||
getSessionIdList (): unknown;
|
getSessionIdList (): Promise<Map<unknown, unknown>>;
|
||||||
}
|
}
|
||||||
export interface NodeIQQNTWrapperSession {
|
export interface NodeIQQNTWrapperSession {
|
||||||
getNTWrapperSession (str: string): NodeIQQNTWrapperSession;
|
getNTWrapperSession (str: string): NodeIQQNTWrapperSession;
|
||||||
|
|||||||
@ -32,6 +32,7 @@ if (versionFolders.length === 0) {
|
|||||||
const BASE_DIR = path.join(versionsDir, selectedFolder, 'resources', 'app');
|
const BASE_DIR = path.join(versionsDir, selectedFolder, 'resources', 'app');
|
||||||
console.log(`BASE_DIR: ${BASE_DIR}`);
|
console.log(`BASE_DIR: ${BASE_DIR}`);
|
||||||
const TARGET_DIR = path.join(__dirname, 'dist');
|
const TARGET_DIR = path.join(__dirname, 'dist');
|
||||||
|
const TARGET_WIN64_DIR = path.join(__dirname, 'dist', 'win64');
|
||||||
const QQNT_FILE = path.join(__dirname, 'QQNT.dll');
|
const QQNT_FILE = path.join(__dirname, 'QQNT.dll');
|
||||||
const NAPCAT_MJS_PATH = path.join(__dirname, '..', 'napcat-shell', 'dist', 'napcat.mjs');
|
const NAPCAT_MJS_PATH = path.join(__dirname, '..', 'napcat-shell', 'dist', 'napcat.mjs');
|
||||||
|
|
||||||
@ -46,6 +47,12 @@ const itemsToCopy = [
|
|||||||
'package.json',
|
'package.json',
|
||||||
'QBar.dll',
|
'QBar.dll',
|
||||||
'wrapper.node',
|
'wrapper.node',
|
||||||
|
'LightQuic.dll'
|
||||||
|
];
|
||||||
|
|
||||||
|
const win64ItemsToCopy = [
|
||||||
|
'SSOShareInfoHelper64.dll',
|
||||||
|
'parent-ipc-core-x64.dll'
|
||||||
];
|
];
|
||||||
|
|
||||||
async function copyAll () {
|
async function copyAll () {
|
||||||
@ -53,13 +60,23 @@ async function copyAll () {
|
|||||||
const configPath = path.join(TARGET_DIR, 'config.json');
|
const configPath = path.join(TARGET_DIR, 'config.json');
|
||||||
const allItemsExist = await fs.pathExists(qqntDllPath) &&
|
const allItemsExist = await fs.pathExists(qqntDllPath) &&
|
||||||
await fs.pathExists(configPath) &&
|
await fs.pathExists(configPath) &&
|
||||||
(await Promise.all(itemsToCopy.map(item => fs.pathExists(path.join(TARGET_DIR, item))))).every(exists => exists);
|
(await Promise.all(itemsToCopy.map(item => fs.pathExists(path.join(TARGET_DIR, item))))).every(exists => exists) &&
|
||||||
|
(await Promise.all(win64ItemsToCopy.map(item => fs.pathExists(path.join(TARGET_WIN64_DIR, item))))).every(exists => exists);
|
||||||
|
|
||||||
if (!allItemsExist) {
|
if (!allItemsExist) {
|
||||||
console.log('Copying required files...');
|
console.log('Copying required files...');
|
||||||
await fs.ensureDir(TARGET_DIR);
|
await fs.ensureDir(TARGET_DIR);
|
||||||
|
await fs.ensureDir(TARGET_WIN64_DIR);
|
||||||
await fs.copy(QQNT_FILE, qqntDllPath, { overwrite: true });
|
await fs.copy(QQNT_FILE, qqntDllPath, { overwrite: true });
|
||||||
await fs.copy(path.join(versionsDir, 'config.json'), configPath, { overwrite: true });
|
await fs.copy(path.join(versionsDir, 'config.json'), configPath, { overwrite: true });
|
||||||
|
|
||||||
|
// 复制 win64 目录下的文件
|
||||||
|
await Promise.all(win64ItemsToCopy.map(async (item) => {
|
||||||
|
await fs.copy(path.join(BASE_DIR, 'win64', item), path.join(TARGET_WIN64_DIR, item), { overwrite: true });
|
||||||
|
console.log(`Copied ${item} to win64`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 复制根目录下的文件
|
||||||
await Promise.all(itemsToCopy.map(async (item) => {
|
await Promise.all(itemsToCopy.map(async (item) => {
|
||||||
await fs.copy(path.join(BASE_DIR, item), path.join(TARGET_DIR, item), { overwrite: true });
|
await fs.copy(path.join(BASE_DIR, item), path.join(TARGET_DIR, item), { overwrite: true });
|
||||||
console.log(`Copied ${item}`);
|
console.log(`Copied ${item}`);
|
||||||
|
|||||||
@ -109,6 +109,7 @@ async function initializeLoginService (
|
|||||||
commonPath: dataPathGlobal,
|
commonPath: dataPathGlobal,
|
||||||
clientVer: basicInfoWrapper.getFullQQVersion(),
|
clientVer: basicInfoWrapper.getFullQQVersion(),
|
||||||
hostName: hostname,
|
hostName: hostname,
|
||||||
|
externalVersion: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,24 +1,157 @@
|
|||||||
const CACHE_NAME = 'napcat-webui-v{{VERSION}}';
|
/**
|
||||||
const ASSETS_TO_CACHE = [
|
* NapCat WebUI Service Worker
|
||||||
'/webui/'
|
*
|
||||||
];
|
* 路由缓存策略设计:
|
||||||
|
*
|
||||||
|
* 【永不缓存 - Network Only】
|
||||||
|
* - /api/* WebUI API
|
||||||
|
* - /plugin/:id/api/* 插件 API
|
||||||
|
* - /files/theme.css 动态主题 CSS
|
||||||
|
* - /webui/fonts/CustomFont.woff 用户自定义字体
|
||||||
|
* - WebSocket / SSE 连接
|
||||||
|
*
|
||||||
|
* 【强缓存 - Cache First】
|
||||||
|
* - /webui/assets/* 前端静态资源(带 hash)
|
||||||
|
* - /webui/fonts/* 内置字体(排除 CustomFont)
|
||||||
|
* - q1.qlogo.cn QQ 头像
|
||||||
|
*
|
||||||
|
* 【网络优先 - Network First】
|
||||||
|
* - /webui/* (HTML 导航) SPA 页面
|
||||||
|
* - /plugin/:id/page/* 插件页面
|
||||||
|
* - /plugin/:id/files/* 插件文件系统静态资源
|
||||||
|
*
|
||||||
|
* 【后台更新 - Stale-While-Revalidate】
|
||||||
|
* - /plugin/:id/mem/* 插件内存静态资源
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CACHE_NAME = 'napcat-webui-v{{VERSION}}';
|
||||||
|
|
||||||
|
// 缓存配置
|
||||||
|
const CACHE_CONFIG = {
|
||||||
|
// 静态资源缓存最大条目数
|
||||||
|
MAX_STATIC_ENTRIES: 200,
|
||||||
|
// QQ 头像缓存最大条目数
|
||||||
|
MAX_AVATAR_ENTRIES: 100,
|
||||||
|
// 插件资源缓存最大条目数
|
||||||
|
MAX_PLUGIN_ENTRIES: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ 路由匹配辅助函数 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为永不缓存的请求
|
||||||
|
*/
|
||||||
|
function isNeverCache (url, request) {
|
||||||
|
// WebUI API
|
||||||
|
if (url.pathname.startsWith('/api/')) return true;
|
||||||
|
|
||||||
|
// 插件 API: /plugin/:id/api/*
|
||||||
|
if (/^\/plugin\/[^/]+\/api(\/|$)/.test(url.pathname)) return true;
|
||||||
|
|
||||||
|
// 动态主题 CSS
|
||||||
|
if (url.pathname === '/files/theme.css' || url.pathname.endsWith('/files/theme.css')) return true;
|
||||||
|
|
||||||
|
// 用户自定义字体
|
||||||
|
if (url.pathname.includes('/webui/fonts/CustomFont.woff')) return true;
|
||||||
|
|
||||||
|
// WebSocket 升级请求
|
||||||
|
if (request.headers.get('Upgrade') === 'websocket') return true;
|
||||||
|
|
||||||
|
// SSE 请求
|
||||||
|
if (request.headers.get('Accept') === 'text/event-stream') return true;
|
||||||
|
|
||||||
|
// Socket 相关
|
||||||
|
if (url.pathname.includes('/socket')) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为 WebUI 静态资源(强缓存)
|
||||||
|
*/
|
||||||
|
function isWebUIStaticAsset (url) {
|
||||||
|
// /webui/assets/* - 前端构建产物(带 hash)
|
||||||
|
if (url.pathname.startsWith('/webui/assets/')) return true;
|
||||||
|
|
||||||
|
// /webui/fonts/* - 内置字体(排除 CustomFont)
|
||||||
|
if (url.pathname.startsWith('/webui/fonts/') &&
|
||||||
|
!url.pathname.includes('CustomFont.woff')) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为外部头像(强缓存)
|
||||||
|
*/
|
||||||
|
function isQLogoAvatar (url) {
|
||||||
|
return url.hostname === 'q1.qlogo.cn' || url.hostname === 'q2.qlogo.cn';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为插件文件系统静态资源(网络优先)
|
||||||
|
*/
|
||||||
|
function isPluginStaticFiles (url) {
|
||||||
|
// /plugin/:id/files/*
|
||||||
|
return /^\/plugin\/[^/]+\/files(\/|$)/.test(url.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为插件内存静态资源(Stale-While-Revalidate)
|
||||||
|
*/
|
||||||
|
function isPluginMemoryAsset (url) {
|
||||||
|
// /plugin/:id/mem/*
|
||||||
|
return /^\/plugin\/[^/]+\/mem(\/|$)/.test(url.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为插件页面(Network First)
|
||||||
|
*/
|
||||||
|
function isPluginPage (url) {
|
||||||
|
// /plugin/:id/page/*
|
||||||
|
return /^\/plugin\/[^/]+\/page(\/|$)/.test(url.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 缓存管理函数 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 限制缓存条目数量
|
||||||
|
*/
|
||||||
|
async function trimCache (cacheName, maxEntries) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const keys = await cache.keys();
|
||||||
|
if (keys.length > maxEntries) {
|
||||||
|
// 删除最早的条目
|
||||||
|
const deleteCount = keys.length - maxEntries;
|
||||||
|
for (let i = 0; i < deleteCount; i++) {
|
||||||
|
await cache.delete(keys[i]);
|
||||||
|
}
|
||||||
|
console.log(`[SW] Trimmed ${deleteCount} entries from cache`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按类型获取缓存限制
|
||||||
|
*/
|
||||||
|
function getCacheLimitForRequest (url) {
|
||||||
|
if (isQLogoAvatar(url)) return CACHE_CONFIG.MAX_AVATAR_ENTRIES;
|
||||||
|
if (isPluginStaticFiles(url) || isPluginMemoryAsset(url)) return CACHE_CONFIG.MAX_PLUGIN_ENTRIES;
|
||||||
|
return CACHE_CONFIG.MAX_STATIC_ENTRIES;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Service Worker 生命周期 ============
|
||||||
|
|
||||||
// 安装阶段:预缓存核心文件
|
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
self.skipWaiting(); // 强制立即接管
|
console.log('[SW] Installing new version:', CACHE_NAME);
|
||||||
event.waitUntil(
|
self.skipWaiting();
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
|
||||||
// 这里的资源如果加载失败不应该阻断 SW 安装
|
|
||||||
return cache.addAll(ASSETS_TO_CACHE).catch(err => console.warn('Failed to cache core assets', err));
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 激活阶段:清理旧缓存
|
|
||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
|
console.log('[SW] Activating new version:', CACHE_NAME);
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys().then((cacheNames) => {
|
(async () => {
|
||||||
return Promise.all(
|
// 清理所有旧版本缓存
|
||||||
|
const cacheNames = await caches.keys();
|
||||||
|
await Promise.all(
|
||||||
cacheNames.map((cacheName) => {
|
cacheNames.map((cacheName) => {
|
||||||
if (cacheName.startsWith('napcat-webui-') && cacheName !== CACHE_NAME) {
|
if (cacheName.startsWith('napcat-webui-') && cacheName !== CACHE_NAME) {
|
||||||
console.log('[SW] Deleting old cache:', cacheName);
|
console.log('[SW] Deleting old cache:', cacheName);
|
||||||
@ -26,107 +159,178 @@ self.addEventListener('activate', (event) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
})
|
// 立即接管所有客户端
|
||||||
|
await self.clients.claim();
|
||||||
|
})()
|
||||||
);
|
);
|
||||||
self.clients.claim(); // 立即控制所有客户端
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 拦截请求
|
// ============ 请求拦截 ============
|
||||||
|
|
||||||
self.addEventListener('fetch', (event) => {
|
self.addEventListener('fetch', (event) => {
|
||||||
const url = new URL(event.request.url);
|
const url = new URL(event.request.url);
|
||||||
|
const request = event.request;
|
||||||
|
|
||||||
// 1. API 请求:仅网络 (Network Only)
|
// 1. 永不缓存的请求 - Network Only
|
||||||
if (url.pathname.startsWith('/api/') || url.pathname.includes('/socket')) {
|
if (isNeverCache(url, request)) {
|
||||||
|
// 不调用 respondWith,让请求直接穿透到网络
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 强缓存策略 (Cache First)
|
// 2. WebUI 静态资源 - Cache First
|
||||||
// - 外部 QQ 头像 (q1.qlogo.cn)
|
if (isWebUIStaticAsset(url)) {
|
||||||
// - 静态资源 (assets, fonts)
|
event.respondWith(cacheFirst(request, url));
|
||||||
// - 常见静态文件后缀
|
|
||||||
const isQLogo = url.hostname === 'q1.qlogo.cn';
|
|
||||||
const isCustomFont = url.pathname.includes('CustomFont.woff'); // 用户自定义字体,不强缓存
|
|
||||||
const isThemeCss = url.pathname.includes('files/theme.css'); // 主题 CSS,不强缓存
|
|
||||||
const isStaticAsset = url.pathname.includes('/webui/assets/') ||
|
|
||||||
url.pathname.includes('/webui/fonts/');
|
|
||||||
const isStaticFile = /\.(js|css|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot|ico)$/i.test(url.pathname);
|
|
||||||
|
|
||||||
if (!isCustomFont && !isThemeCss && (isQLogo || isStaticAsset || isStaticFile)) {
|
|
||||||
event.respondWith(
|
|
||||||
caches.match(event.request).then((response) => {
|
|
||||||
if (response) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 跨域请求 (qlogo) 需要 mode: 'no-cors' 才能缓存 opaque response,
|
|
||||||
// 但 fetch(event.request) 默认会继承 request 的 mode。
|
|
||||||
// 如果是 img标签发起的请求,通常 mode 是 no-cors 或 cors。
|
|
||||||
// 对于 opaque response (status 0), cache API 允许缓存。
|
|
||||||
return fetch(event.request).then((response) => {
|
|
||||||
// 对 qlogo 允许 status 0 (opaque)
|
|
||||||
// 对其他资源要求 status 200
|
|
||||||
const isValidResponse = response && (
|
|
||||||
response.status === 200 ||
|
|
||||||
response.type === 'basic' ||
|
|
||||||
(isQLogo && response.type === 'opaque')
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isValidResponse) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseToCache = response.clone();
|
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
|
||||||
cache.put(event.request, responseToCache);
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. HTML 页面 / 导航请求 -> 网络优先 (Network First)
|
// 3. QQ 头像 - Cache First(支持 opaque response)
|
||||||
if (event.request.mode === 'navigate') {
|
if (isQLogoAvatar(url)) {
|
||||||
event.respondWith(
|
event.respondWith(cacheFirstWithOpaque(request, url));
|
||||||
fetch(event.request)
|
|
||||||
.then((response) => {
|
|
||||||
const responseToCache = response.clone();
|
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
|
||||||
cache.put(event.request, responseToCache);
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
return caches.match(event.request);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 其他 Same-Origin 请求 -> Stale-While-Revalidate
|
// 4. 插件文件系统静态资源 - Network First
|
||||||
// 优先返回缓存,同时后台更新缓存,保证下次访问是新的
|
if (isPluginStaticFiles(url)) {
|
||||||
|
event.respondWith(networkFirst(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 插件内存静态资源 - Stale-While-Revalidate
|
||||||
|
if (isPluginMemoryAsset(url)) {
|
||||||
|
event.respondWith(staleWhileRevalidate(request, url));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 插件页面 - Network First
|
||||||
|
if (isPluginPage(url)) {
|
||||||
|
event.respondWith(networkFirst(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. HTML 导航请求 - Network First
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
event.respondWith(networkFirst(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 其他同源请求 - Network Only(避免意外缓存)
|
||||||
if (url.origin === self.location.origin) {
|
if (url.origin === self.location.origin) {
|
||||||
event.respondWith(
|
// 不缓存,直接穿透
|
||||||
caches.match(event.request).then((cachedResponse) => {
|
|
||||||
const fetchPromise = fetch(event.request).then((networkResponse) => {
|
|
||||||
if (networkResponse && networkResponse.status === 200) {
|
|
||||||
const responseToCache = networkResponse.clone();
|
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
|
||||||
cache.put(event.request, responseToCache);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return networkResponse;
|
|
||||||
});
|
|
||||||
// 如果有缓存,返回缓存;否则等待网络
|
|
||||||
return cachedResponse || fetchPromise;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认:网络优先
|
// 9. 其他外部请求 - Network Only
|
||||||
event.respondWith(
|
return;
|
||||||
fetch(event.request).catch(() => caches.match(event.request))
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ 缓存策略实现 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache First 策略
|
||||||
|
* 优先从缓存返回,缓存未命中则从网络获取并缓存
|
||||||
|
*/
|
||||||
|
async function cacheFirst (request, url) {
|
||||||
|
const cachedResponse = await caches.match(request);
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const networkResponse = await fetch(request);
|
||||||
|
if (networkResponse && networkResponse.status === 200) {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
cache.put(request, networkResponse.clone());
|
||||||
|
// 异步清理缓存
|
||||||
|
trimCache(CACHE_NAME, getCacheLimitForRequest(url));
|
||||||
|
}
|
||||||
|
return networkResponse;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SW] Cache First fetch failed:', error);
|
||||||
|
return new Response('Network error', { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache First 策略(支持 opaque response,用于跨域头像)
|
||||||
|
*/
|
||||||
|
async function cacheFirstWithOpaque (request, url) {
|
||||||
|
const cachedResponse = await caches.match(request);
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const networkResponse = await fetch(request);
|
||||||
|
// opaque response 的 status 是 0,但仍可缓存
|
||||||
|
const isValidResponse = networkResponse && (
|
||||||
|
networkResponse.status === 200 ||
|
||||||
|
networkResponse.type === 'opaque'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isValidResponse) {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
cache.put(request, networkResponse.clone());
|
||||||
|
trimCache(CACHE_NAME, getCacheLimitForRequest(url));
|
||||||
|
}
|
||||||
|
return networkResponse;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SW] Cache First (opaque) fetch failed:', error);
|
||||||
|
return new Response('Network error', { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Network First 策略
|
||||||
|
* 优先从网络获取,网络失败则返回缓存
|
||||||
|
*/
|
||||||
|
async function networkFirst (request) {
|
||||||
|
try {
|
||||||
|
const networkResponse = await fetch(request);
|
||||||
|
if (networkResponse && networkResponse.status === 200) {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
cache.put(request, networkResponse.clone());
|
||||||
|
}
|
||||||
|
return networkResponse;
|
||||||
|
} catch (error) {
|
||||||
|
console.log('[SW] Network First: network failed, trying cache');
|
||||||
|
const cachedResponse = await caches.match(request);
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
return new Response('Offline', { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stale-While-Revalidate 策略
|
||||||
|
* 立即返回缓存(如果有),同时后台更新缓存
|
||||||
|
*/
|
||||||
|
async function staleWhileRevalidate (request, url) {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
const cachedResponse = await cache.match(request);
|
||||||
|
|
||||||
|
// 后台刷新缓存
|
||||||
|
const fetchPromise = fetch(request).then((networkResponse) => {
|
||||||
|
if (networkResponse && networkResponse.status === 200) {
|
||||||
|
cache.put(request, networkResponse.clone());
|
||||||
|
trimCache(CACHE_NAME, getCacheLimitForRequest(url));
|
||||||
|
}
|
||||||
|
return networkResponse;
|
||||||
|
}).catch((error) => {
|
||||||
|
console.log('[SW] SWR background fetch failed:', error);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果有缓存,立即返回缓存
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有缓存,等待网络
|
||||||
|
const networkResponse = await fetchPromise;
|
||||||
|
if (networkResponse) {
|
||||||
|
return networkResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Network error', { status: 503 });
|
||||||
|
}
|
||||||
|
|||||||
@ -132,8 +132,8 @@ export default function ExtensionPage () {
|
|||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
{tab.icon && <span>{tab.icon}</span>}
|
{tab.icon && <span>{tab.icon}</span>}
|
||||||
<span
|
<span
|
||||||
className='cursor-pointer hover:underline'
|
className='cursor-pointer hover:underline truncate max-w-[6rem] md:max-w-none'
|
||||||
title='点击在新窗口打开'
|
title={`插件:${tab.pluginName}\n点击在新窗口打开`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
openInNewWindow(tab.pluginId, tab.path);
|
openInNewWindow(tab.pluginId, tab.path);
|
||||||
@ -141,7 +141,7 @@ export default function ExtensionPage () {
|
|||||||
>
|
>
|
||||||
{tab.title}
|
{tab.title}
|
||||||
</span>
|
</span>
|
||||||
<span className='text-xs text-default-400'>({tab.pluginName})</span>
|
<span className='text-xs text-default-400 hidden md:inline'>({tab.pluginName})</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import toast from 'react-hot-toast';
|
|||||||
import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io';
|
import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { EventSourcePolyfill } from 'event-source-polyfill';
|
import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||||
|
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||||
|
|
||||||
import PluginStoreCard, { InstallStatus } from '@/components/display_card/plugin_store_card';
|
import PluginStoreCard, { InstallStatus } from '@/components/display_card/plugin_store_card';
|
||||||
import PluginManager, { PluginItem } from '@/controllers/plugin_manager';
|
import PluginManager, { PluginItem } from '@/controllers/plugin_manager';
|
||||||
@ -226,68 +227,70 @@ export default function PluginStorePage () {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||||
|
const hasBackground = !!backgroundImage;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<title>插件商店 - NapCat WebUI</title>
|
<title>插件商店 - NapCat WebUI</title>
|
||||||
<div className="p-2 md:p-4 relative">
|
<div className="p-2 md:p-4 relative">
|
||||||
{/* 头部 */}
|
{/* 固定头部区域 */}
|
||||||
<div className="flex mb-6 items-center justify-between flex-wrap gap-4">
|
<div className={clsx(
|
||||||
<div className="flex items-center gap-4">
|
'sticky top-14 z-10 backdrop-blur-sm py-4 px-4 rounded-sm mb-4 -mx-2 md:-mx-4 -mt-2 md:-mt-4 transition-colors',
|
||||||
<h1 className="text-2xl font-bold">插件商店</h1>
|
hasBackground
|
||||||
<Button
|
? 'bg-white/20 dark:bg-black/10'
|
||||||
isIconOnly
|
: 'bg-transparent'
|
||||||
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
|
)}>
|
||||||
radius="full"
|
{/* 头部 */}
|
||||||
onPress={() => loadPlugins(true)}
|
<div className="flex mb-4 items-center justify-between flex-wrap gap-4">
|
||||||
isLoading={loading}
|
<div className="flex items-center gap-4">
|
||||||
>
|
<h1 className="text-2xl font-bold">插件商店</h1>
|
||||||
<IoMdRefresh size={24} />
|
<Button
|
||||||
</Button>
|
isIconOnly
|
||||||
|
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
|
||||||
|
radius="full"
|
||||||
|
onPress={() => loadPlugins(true)}
|
||||||
|
isLoading={loading}
|
||||||
|
>
|
||||||
|
<IoMdRefresh size={24} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 商店列表源卡片 */}
|
||||||
|
<Card className="bg-default-100/50 backdrop-blur-md shadow-sm">
|
||||||
|
<CardBody className="py-2 px-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-default-500">列表源:</span>
|
||||||
|
<span className="text-sm font-medium">{getStoreSourceDisplayName()}</span>
|
||||||
|
</div>
|
||||||
|
<Tooltip content="切换列表源">
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
onPress={() => setStoreSourceModalOpen(true)}
|
||||||
|
>
|
||||||
|
<IoMdSettings size={16} />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 商店列表源卡片 */}
|
{/* 搜索框 */}
|
||||||
<Card className="bg-default-100/50 backdrop-blur-md shadow-sm">
|
<div className="mb-4">
|
||||||
<CardBody className="py-2 px-3">
|
<Input
|
||||||
<div className="flex items-center gap-3">
|
placeholder="搜索插件名称、描述、作者或标签..."
|
||||||
<div className="flex items-center gap-2">
|
startContent={<IoMdSearch className="text-default-400" />}
|
||||||
<span className="text-xs text-default-500">列表源:</span>
|
value={searchQuery}
|
||||||
<span className="text-sm font-medium">{getStoreSourceDisplayName()}</span>
|
onValueChange={setSearchQuery}
|
||||||
</div>
|
className="max-w-md"
|
||||||
<Tooltip content="切换列表源">
|
/>
|
||||||
<Button
|
</div>
|
||||||
isIconOnly
|
|
||||||
size="sm"
|
|
||||||
variant="light"
|
|
||||||
onPress={() => setStoreSourceModalOpen(true)}
|
|
||||||
>
|
|
||||||
<IoMdSettings size={16} />
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 搜索框 */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<Input
|
|
||||||
placeholder="搜索插件名称、描述、作者或标签..."
|
|
||||||
startContent={<IoMdSearch className="text-default-400" />}
|
|
||||||
value={searchQuery}
|
|
||||||
onValueChange={setSearchQuery}
|
|
||||||
className="max-w-md"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 标签页 */}
|
|
||||||
<div className="relative">
|
|
||||||
{/* 加载遮罩 - 只遮住插件列表区域 */}
|
|
||||||
{loading && (
|
|
||||||
<div className="absolute inset-0 bg-zinc-500/10 z-30 flex justify-center items-center backdrop-blur-sm rounded-lg">
|
|
||||||
<Spinner size='lg' />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
{/* 标签页导航 */}
|
||||||
<Tabs
|
<Tabs
|
||||||
aria-label="Plugin Store Categories"
|
aria-label="Plugin Store Categories"
|
||||||
className="max-w-full"
|
className="max-w-full"
|
||||||
@ -296,32 +299,43 @@ export default function PluginStorePage () {
|
|||||||
classNames={{
|
classNames={{
|
||||||
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
|
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
|
||||||
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
|
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
|
||||||
|
panel: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<Tab
|
<Tab
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
title={`${tab.title} (${tab.count})`}
|
title={`${tab.title} (${tab.count})`}
|
||||||
>
|
/>
|
||||||
<EmptySection isEmpty={!categorizedPlugins[tab.key]?.length} />
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-x-2 gap-y-4">
|
|
||||||
{categorizedPlugins[tab.key]?.map((plugin) => {
|
|
||||||
const installInfo = getPluginInstallInfo(plugin);
|
|
||||||
return (
|
|
||||||
<PluginStoreCard
|
|
||||||
key={plugin.id}
|
|
||||||
data={plugin}
|
|
||||||
installStatus={installInfo.status}
|
|
||||||
installedVersion={installInfo.installedVersion}
|
|
||||||
onInstall={() => handleInstall(plugin)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Tab>
|
|
||||||
))}
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 插件列表区域 */}
|
||||||
|
<div className="relative">
|
||||||
|
{/* 加载遮罩 - 只遮住插件列表区域 */}
|
||||||
|
{loading && (
|
||||||
|
<div className="absolute inset-0 bg-zinc-500/10 z-30 flex justify-center items-center backdrop-blur-sm rounded-lg">
|
||||||
|
<Spinner size='lg' />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EmptySection isEmpty={!categorizedPlugins[activeTab]?.length} />
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-x-2 gap-y-4">
|
||||||
|
{categorizedPlugins[activeTab]?.map((plugin) => {
|
||||||
|
const installInfo = getPluginInstallInfo(plugin);
|
||||||
|
return (
|
||||||
|
<PluginStoreCard
|
||||||
|
key={plugin.id}
|
||||||
|
data={plugin}
|
||||||
|
installStatus={installInfo.status}
|
||||||
|
installedVersion={installInfo.installedVersion}
|
||||||
|
onInstall={() => handleInstall(plugin)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 商店列表源选择弹窗 */}
|
{/* 商店列表源选择弹窗 */}
|
||||||
|
|||||||
@ -30,6 +30,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
'/api': backendDebugUrl,
|
'/api': backendDebugUrl,
|
||||||
'/files': backendDebugUrl,
|
'/files': backendDebugUrl,
|
||||||
|
'/plugin': backendDebugUrl,
|
||||||
'/webui/fonts/CustomFont.woff': backendDebugUrl,
|
'/webui/fonts/CustomFont.woff': backendDebugUrl,
|
||||||
'/webui/sw.js': backendDebugUrl,
|
'/webui/sw.js': backendDebugUrl,
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user