feat: 深色模式完善 + UP主主页 + 缓存管理

- 深色模式:补全 settings 页选项按钮、退出登录按钮主题色
- UP主主页:新增 /creator/[mid] 路由,展示 UP 主信息、粉丝数、视频列表
  - bilibili.ts 新增 getUploaderInfo / getUploaderVideos API
  - 视频详情页 UP 主行可点击跳转主页
- 缓存管理:settings 页新增「存储」分区,显示缓存大小并支持一键清除
  - 新增 utils/cache.ts(计算大小 + 清除 expo-image/文件系统缓存)
This commit is contained in:
Developer
2026-03-26 00:06:41 +08:00
parent 8a45211fd6
commit 8f733b6c34
7 changed files with 502 additions and 27 deletions

View File

@@ -2,9 +2,10 @@ import axios from 'axios';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Platform } from 'react-native';
import pako from 'pako';
import type { VideoItem, Comment, PlayUrlResponse, QRCodeInfo, VideoShotData, DanmakuItem, LiveRoom, LiveRoomDetail, LiveAnchorInfo, LiveStreamInfo } from './types';
import type { VideoItem, Comment, PlayUrlResponse, QRCodeInfo, VideoShotData, DanmakuItem, LiveRoom, LiveRoomDetail, LiveAnchorInfo, LiveStreamInfo, SearchSuggestItem, HotSearchItem } from './types';
import { signWbi } from '../utils/wbi';
import { parseDanmakuXml } from '../utils/danmaku';
import { getSecure } from '../utils/secureStorage';
const isWeb = Platform.OS === 'web';
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
@@ -46,7 +47,7 @@ const api = axios.create({
api.interceptors.request.use(async (config) => {
const [sessdata, buvid3] = await Promise.all([
AsyncStorage.getItem('SESSDATA'),
getSecure('SESSDATA'),
getBuvid3(),
]);
if (isWeb) {
@@ -61,6 +62,23 @@ api.interceptors.request.use(async (config) => {
return config;
});
// ─── Request deduplication ──────────────────────────────────────────────────
// Prevents identical concurrent requests (same URL + params) from hitting the network twice.
const inflightRequests = new Map<string, Promise<any>>();
function dedupeKey(url: string, params?: Record<string, any>): string {
return params ? `${url}?${JSON.stringify(params)}` : url;
}
/** Wraps an async API call so that concurrent calls with the same key share one promise. */
function dedupe<T>(key: string, fn: () => Promise<T>): Promise<T> {
const existing = inflightRequests.get(key);
if (existing) return existing as Promise<T>;
const promise = fn().finally(() => { inflightRequests.delete(key); });
inflightRequests.set(key, promise);
return promise;
}
// WBI key cache (rotates ~daily)
let wbiKeys: { imgKey: string; subKey: string } | null = null;
let wbiKeysTimestamp = 0;
@@ -109,9 +127,11 @@ export async function getPopularVideos(pn = 1): Promise<VideoItem[]> {
return res.data.data.list as VideoItem[];
}
export async function getVideoDetail(bvid: string): Promise<VideoItem> {
const res = await api.get('/x/web-interface/view', { params: { bvid } });
return res.data.data as VideoItem;
export function getVideoDetail(bvid: string): Promise<VideoItem> {
return dedupe(dedupeKey('/x/web-interface/view', { bvid }), async () => {
const res = await api.get('/x/web-interface/view', { params: { bvid } });
return res.data.data as VideoItem;
});
}
export async function getVideoRelated(bvid: string): Promise<VideoItem[]> {
@@ -120,15 +140,17 @@ export async function getVideoRelated(bvid: string): Promise<VideoItem[]> {
return items as VideoItem[];
}
export async function getPlayUrl(bvid: string, cid: number, qn = 64): Promise<PlayUrlResponse> {
export function getPlayUrl(bvid: string, cid: number, qn = 64): Promise<PlayUrlResponse> {
const isAndroid = Platform.OS === 'android';
// 1488 = 16(DASH)|64(HDR)|128(4K)|256(杜比全景声)|1024(杜比视界)
const FNVAL_ANDROID = 16 | 64 | 128 | 256 | 1024;
const params = isAndroid
? { bvid, cid, qn, fnval: FNVAL_ANDROID, fourk: 1 }
: { bvid, cid, qn, fnval: 0, platform: 'html5', fourk: 1 };
const res = await api.get('/x/player/playurl', { params });
return res.data.data as PlayUrlResponse;
return dedupe(dedupeKey('/x/player/playurl', params), async () => {
const res = await api.get('/x/player/playurl', { params });
return res.data.data as PlayUrlResponse;
});
}
export async function getPlayUrlForDownload(
@@ -158,6 +180,47 @@ export async function getUploaderStat(mid: number): Promise<{ follower: number;
};
}
export async function getUploaderInfo(mid: number): Promise<{ name: string; face: string; sign: string; follower: number; archiveCount: number }> {
const res = await api.get('/x/web-interface/card', { params: { mid } });
const data = res.data.data ?? {};
return {
name: data.card?.name ?? '',
face: data.card?.face ?? '',
sign: data.card?.sign ?? '',
follower: data.follower ?? 0,
archiveCount: data.archive_count ?? 0,
};
}
export async function getUploaderVideos(mid: number, pn = 1, ps = 20): Promise<{ videos: VideoItem[]; total: number }> {
const res = await api.get('/x/space/wbi/arc/search', {
params: { mid, pn, ps, order: 'pubdate', platform: 'web' },
});
const vlist: any[] = res.data?.data?.list?.vlist ?? [];
const total: number = res.data?.data?.page?.count ?? 0;
const videos: VideoItem[] = vlist.map((v: any) => ({
bvid: v.bvid,
aid: v.aid ?? 0,
title: v.title,
pic: v.pic ? (v.pic.startsWith('//') ? `https:${v.pic}` : v.pic) : '',
owner: { mid, name: v.author ?? '', face: '' },
stat: {
view: v.play ?? 0,
danmaku: v.video_review ?? 0,
reply: v.comment ?? 0,
like: 0,
coin: 0,
favorite: 0,
},
duration: v.length ? parseDuration(v.length) : 0,
desc: v.description ?? '',
cid: v.cid ?? 0,
pages: [],
ugc_season: undefined,
}));
return { videos, total };
}
export async function getUserInfo(): Promise<{ face: string; uname: string; mid: number }> {
const res = await api.get('/x/web-interface/nav');
const { face, uname, mid } = res.data.data;
@@ -341,10 +404,12 @@ function parseDuration(s: string): number {
return parts.length === 2 ? parts[0] * 60 + parts[1] : parts[0] * 3600 + parts[1] * 60 + parts[2];
}
export async function searchVideos(keyword: string, page = 1): Promise<VideoItem[]> {
export async function searchVideos(keyword: string, page = 1, order = ''): Promise<VideoItem[]> {
const { imgKey, subKey } = await getWbiKeys();
const params: Record<string, any> = { keyword, search_type: 'video', page, page_size: 20 };
if (order) params.order = order;
const signed = signWbi(
{ keyword, search_type: 'video', page, page_size: 20 },
params,
imgKey,
subKey,
);
@@ -385,7 +450,6 @@ export async function getLiveDanmakuHistory(roomId: number): Promise<{
const room: any[] = res.data?.data?.room ?? [];
const admin: any[] = res.data?.data?.admin ?? [];
const adminMsgs = admin.map((a: any) => a.text ?? '').filter(Boolean);
console.log(adminMsgs,'adminMsgs')
const danmakus = room.map((m: any) => ({
time: 0,
mode: 1 as const,
@@ -460,3 +524,31 @@ export async function getFollowedLiveRooms(): Promise<LiveRoom[]> {
parent_area_name: r.area_v2_parent_name ?? '',
}));
}
export async function getSearchSuggest(term: string): Promise<SearchSuggestItem[]> {
try {
const res = await api.get('/x/web-interface/search/suggest', {
params: { term, main_ver: 'v1', highlight: '' },
});
const tags: any[] = res.data?.result?.tag ?? [];
return tags.map((t: any) => ({ value: t.value ?? '', ref: t.ref ?? 0 }));
} catch {
return [];
}
}
export async function getHotSearch(): Promise<HotSearchItem[]> {
try {
const res = await api.get('/x/web-interface/wbi/search/square', {
params: { limit: 10 },
});
const trending: any[] = res.data?.data?.trending?.list ?? [];
return trending.map((t: any) => ({
keyword: t.keyword ?? '',
show_name: t.show_name ?? t.keyword ?? '',
icon: t.icon,
}));
} catch {
return [];
}
}