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

@@ -85,6 +85,14 @@ function RootLayout() {
gestureDirection: "horizontal",
}}
/>
<Stack.Screen
name="creator"
options={{
animation: "slide_from_right",
gestureEnabled: true,
gestureDirection: "horizontal",
}}
/>
</Stack>
</ErrorBoundary>
<MiniPlayer />

209
app/creator/[mid].tsx Normal file
View File

@@ -0,0 +1,209 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
View,
Text,
FlatList,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { getUploaderInfo, getUploaderVideos } from '../../services/bilibili';
import type { VideoItem } from '../../services/types';
import { useTheme } from '../../utils/theme';
import { formatCount, formatDuration } from '../../utils/format';
import { proxyImageUrl, coverImageUrl } from '../../utils/imageUrl';
import { useSettingsStore } from '../../store/settingsStore';
const PAGE_SIZE = 20;
export default function CreatorScreen() {
const { mid: midStr } = useLocalSearchParams<{ mid: string }>();
const mid = Number(midStr);
const router = useRouter();
const theme = useTheme();
const trafficSaving = useSettingsStore(s => s.trafficSaving);
const [info, setInfo] = useState<{
name: string; face: string; sign: string; follower: number; archiveCount: number;
} | null>(null);
const [videos, setVideos] = useState<VideoItem[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [infoLoading, setInfoLoading] = useState(true);
const loadingRef = React.useRef(false);
useEffect(() => {
getUploaderInfo(mid)
.then(setInfo)
.catch(() => {})
.finally(() => setInfoLoading(false));
loadVideos(1, true);
}, [mid]);
const loadVideos = useCallback(async (pn: number, reset = false) => {
if (loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
try {
const { videos: newVideos, total: t } = await getUploaderVideos(mid, pn, PAGE_SIZE);
setTotal(t);
setVideos(prev => reset ? newVideos : [...prev, ...newVideos]);
setPage(pn);
} catch {}
finally {
loadingRef.current = false;
setLoading(false);
}
}, [mid]);
const hasMore = videos.length < total;
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]} edges={['top', 'left', 'right']}>
{/* Top bar */}
<View style={[styles.topBar, { backgroundColor: theme.card, borderBottomColor: theme.border }]}>
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
<Ionicons name="chevron-back" size={24} color={theme.text} />
</TouchableOpacity>
<Text style={[styles.topTitle, { color: theme.text }]} numberOfLines={1}>
{info?.name ?? 'UP主主页'}
</Text>
<View style={styles.backBtn} />
</View>
<FlatList
data={videos}
keyExtractor={item => item.bvid}
showsVerticalScrollIndicator={false}
onEndReached={() => { if (hasMore && !loading) loadVideos(page + 1); }}
onEndReachedThreshold={0.3}
windowSize={7}
maxToRenderPerBatch={6}
removeClippedSubviews
ListHeaderComponent={
infoLoading ? (
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : info ? (
<View style={[styles.profileCard, { backgroundColor: theme.card, borderBottomColor: theme.border }]}>
<Image
source={{ uri: proxyImageUrl(info.face) }}
style={styles.avatar}
contentFit="cover"
recyclingKey={String(mid)}
/>
<Text style={[styles.name, { color: theme.text }]}>{info.name}</Text>
{info.sign ? (
<Text style={[styles.sign, { color: theme.textSub }]} numberOfLines={2}>{info.sign}</Text>
) : null}
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={[styles.statNum, { color: theme.text }]}>{formatCount(info.follower)}</Text>
<Text style={[styles.statLabel, { color: theme.textSub }]}></Text>
</View>
<View style={[styles.statDivider, { backgroundColor: theme.border }]} />
<View style={styles.statItem}>
<Text style={[styles.statNum, { color: theme.text }]}>{formatCount(info.archiveCount)}</Text>
<Text style={[styles.statLabel, { color: theme.textSub }]}></Text>
</View>
</View>
<Text style={[styles.videoListHeader, { color: theme.textSub }]}>
{total}
</Text>
</View>
) : null
}
renderItem={({ item }) => (
<TouchableOpacity
style={[styles.videoRow, { backgroundColor: theme.card, borderBottomColor: theme.border }]}
onPress={() => router.push(`/video/${item.bvid}` as any)}
activeOpacity={0.85}
>
<View style={styles.thumbWrap}>
<Image
source={{ uri: coverImageUrl(item.pic, trafficSaving ? 'normal' : 'hd') }}
style={styles.thumb}
contentFit="cover"
recyclingKey={item.bvid}
transition={200}
/>
<View style={styles.durationBadge}>
<Text style={styles.durationText}>{formatDuration(item.duration)}</Text>
</View>
</View>
<View style={styles.videoInfo}>
<Text style={[styles.videoTitle, { color: theme.text }]} numberOfLines={2}>
{item.title}
</Text>
<View style={styles.videoMeta}>
<Ionicons name="play" size={11} color={theme.textSub} />
<Text style={[styles.metaText, { color: theme.textSub }]}>{formatCount(item.stat.view)}</Text>
</View>
</View>
</TouchableOpacity>
)}
ListEmptyComponent={
!loading && !infoLoading ? (
<Text style={[styles.emptyTxt, { color: theme.textSub }]}></Text>
) : null
}
ListFooterComponent={
loading ? <ActivityIndicator style={styles.loader} color="#00AEEC" /> : null
}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1 },
topBar: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
paddingVertical: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
},
backBtn: { padding: 4, width: 32 },
topTitle: { flex: 1, fontSize: 16, fontWeight: '600', textAlign: 'center' },
profileCard: {
alignItems: 'center',
paddingTop: 24,
paddingBottom: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
marginBottom: 4,
},
avatar: { width: 72, height: 72, borderRadius: 36, marginBottom: 10 },
name: { fontSize: 18, fontWeight: '700', marginBottom: 6 },
sign: { fontSize: 13, textAlign: 'center', paddingHorizontal: 24, marginBottom: 12, lineHeight: 19 },
statsRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 16 },
statItem: { alignItems: 'center', paddingHorizontal: 24 },
statNum: { fontSize: 18, fontWeight: '700' },
statLabel: { fontSize: 12, marginTop: 2 },
statDivider: { width: 1, height: 28 },
videoListHeader: { alignSelf: 'flex-start', paddingHorizontal: 14, fontSize: 13, paddingBottom: 8 },
loader: { marginVertical: 24 },
videoRow: {
flexDirection: 'row',
paddingHorizontal: 12,
paddingVertical: 10,
borderBottomWidth: StyleSheet.hairlineWidth,
gap: 10,
},
thumbWrap: { width: 120, height: 68, borderRadius: 4, overflow: 'hidden', flexShrink: 0, position: 'relative' },
thumb: { width: 120, height: 68 },
durationBadge: {
position: 'absolute', bottom: 3, right: 3,
backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 3, paddingHorizontal: 4, paddingVertical: 1,
},
durationText: { color: '#fff', fontSize: 10 },
videoInfo: { flex: 1, justifyContent: 'space-between', paddingVertical: 2 },
videoTitle: { fontSize: 13, lineHeight: 18 },
videoMeta: { flexDirection: 'row', alignItems: 'center', gap: 3 },
metaText: { fontSize: 12 },
emptyTxt: { textAlign: 'center', padding: 40 },
});

5
app/creator/_layout.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { Stack } from 'expo-router';
export default function CreatorLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native';
import React, { useEffect, useState, useCallback } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
@@ -7,6 +7,7 @@ import { useAuthStore } from '../store/authStore';
import { useSettingsStore } from '../store/settingsStore';
import { useTheme } from '../utils/theme';
import { useCheckUpdate } from '../hooks/useCheckUpdate';
import { getImageCacheSize, clearImageCache, formatBytes } from '../utils/cache';
export default function SettingsScreen() {
const router = useRouter();
@@ -14,6 +15,34 @@ export default function SettingsScreen() {
const { darkMode, setDarkMode, trafficSaving, setTrafficSaving } = useSettingsStore();
const theme = useTheme();
const { currentVersion, isChecking, downloadProgress, checkUpdate } = useCheckUpdate();
const [cacheSize, setCacheSize] = useState<number | null>(null);
const [clearingCache, setClearingCache] = useState(false);
const refreshCacheSize = useCallback(async () => {
const size = await getImageCacheSize();
setCacheSize(size);
}, []);
useEffect(() => {
refreshCacheSize();
}, []);
const handleClearCache = async () => {
Alert.alert('清除缓存', '确定要清除所有缓存吗?', [
{ text: '取消', style: 'cancel' },
{
text: '清除',
style: 'destructive',
onPress: async () => {
setClearingCache(true);
await clearImageCache();
setClearingCache(false);
setCacheSize(0);
Alert.alert('已完成', '缓存已清除');
},
},
]);
};
const handleLogout = async () => {
await logout();
@@ -63,18 +92,18 @@ export default function SettingsScreen() {
<Text style={[styles.sectionLabel, { color: theme.textSub }]}></Text>
<View style={styles.optionRow}>
<TouchableOpacity
style={[styles.option, !darkMode && styles.optionActive]}
style={[styles.option, { backgroundColor: theme.inputBg }, !darkMode && styles.optionActive]}
onPress={() => setDarkMode(false)}
activeOpacity={0.7}
>
<Text style={[styles.optionText, !darkMode && styles.optionTextActive]}></Text>
<Text style={[styles.optionText, { color: theme.text }, !darkMode && styles.optionTextActive]}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.option, darkMode && styles.optionActive]}
style={[styles.option, { backgroundColor: theme.inputBg }, darkMode && styles.optionActive]}
onPress={() => setDarkMode(true)}
activeOpacity={0.7}
>
<Text style={[styles.optionText, darkMode && styles.optionTextActive]}></Text>
<Text style={[styles.optionText, { color: theme.text }, darkMode && styles.optionTextActive]}></Text>
</TouchableOpacity>
</View>
</View>
@@ -83,18 +112,18 @@ export default function SettingsScreen() {
<Text style={[styles.sectionLabel, { color: theme.textSub }]}></Text>
<View style={styles.optionRow}>
<TouchableOpacity
style={[styles.option, !trafficSaving && styles.optionActive]}
style={[styles.option, { backgroundColor: theme.inputBg }, !trafficSaving && styles.optionActive]}
onPress={() => setTrafficSaving(false)}
activeOpacity={0.7}
>
<Text style={[styles.optionText, !trafficSaving && styles.optionTextActive]}></Text>
<Text style={[styles.optionText, { color: theme.text }, !trafficSaving && styles.optionTextActive]}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.option, trafficSaving && styles.optionActive]}
style={[styles.option, { backgroundColor: theme.inputBg }, trafficSaving && styles.optionActive]}
onPress={() => setTrafficSaving(true)}
activeOpacity={0.7}
>
<Text style={[styles.optionText, trafficSaving && styles.optionTextActive]}></Text>
<Text style={[styles.optionText, { color: theme.text }, trafficSaving && styles.optionTextActive]}></Text>
</TouchableOpacity>
</View>
{trafficSaving && (
@@ -104,9 +133,32 @@ export default function SettingsScreen() {
)}
</View>
<View style={[styles.section, { backgroundColor: theme.card, borderColor: theme.border }]}>
<Text style={[styles.sectionLabel, { color: theme.textSub }]}></Text>
<View style={styles.cacheRow}>
<View>
<Text style={[styles.cacheLabel, { color: theme.text }]}></Text>
<Text style={[styles.cacheValue, { color: theme.textSub }]}>
{cacheSize === null ? '计算中...' : formatBytes(cacheSize)}
</Text>
</View>
<TouchableOpacity
style={[styles.clearBtn, clearingCache && { opacity: 0.5 }]}
onPress={handleClearCache}
disabled={clearingCache}
activeOpacity={0.7}
>
{clearingCache
? <ActivityIndicator size="small" color="#fff" />
: <Text style={styles.clearBtnText}></Text>
}
</TouchableOpacity>
</View>
</View>
{isLoggedIn && (
<TouchableOpacity style={styles.logoutBtn} onPress={handleLogout} activeOpacity={0.8}>
<Text style={styles.logoutText}>退</Text>
<TouchableOpacity style={[styles.logoutBtn, { borderColor: theme.danger }]} onPress={handleLogout} activeOpacity={0.8}>
<Text style={[styles.logoutText, { color: theme.danger }]}>退</Text>
</TouchableOpacity>
)}
</SafeAreaView>
@@ -143,10 +195,9 @@ const styles = StyleSheet.create({
paddingHorizontal: 10,
paddingVertical: 2,
borderRadius: 16,
backgroundColor: '#f0f0f0',
},
optionActive: { backgroundColor: '#00AEEC' },
optionText: { fontSize: 13, color: '#333', fontWeight: '500' },
optionText: { fontSize: 13, fontWeight: '500' },
optionTextActive: { color: '#fff', fontWeight: '600' },
hint: { fontSize: 12, marginTop: 8 },
versionRow: {
@@ -162,13 +213,28 @@ const styles = StyleSheet.create({
paddingVertical: 6,
},
updateBtnText: { fontSize: 14, color: '#00AEEC', fontWeight: '600' },
cacheRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
cacheLabel: { fontSize: 14 },
cacheValue: { fontSize: 12, marginTop: 2 },
clearBtn: {
backgroundColor: '#00AEEC',
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 16,
minWidth: 80,
alignItems: 'center',
},
clearBtnText: { color: '#fff', fontSize: 13, fontWeight: '600' },
logoutBtn: {
margin: 24,
paddingVertical: 12,
borderRadius: 8,
borderWidth: 1,
borderColor: '#ff4757',
alignItems: 'center',
},
logoutText: { fontSize: 15, color: '#ff4757', fontWeight: '600' },
logoutText: { fontSize: 15, fontWeight: '600' },
});