mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-07 23:18:38 +08:00
feat: 深色模式完善 + UP主主页 + 缓存管理
- 深色模式:补全 settings 页选项按钮、退出登录按钮主题色 - UP主主页:新增 /creator/[mid] 路由,展示 UP 主信息、粉丝数、视频列表 - bilibili.ts 新增 getUploaderInfo / getUploaderVideos API - 视频详情页 UP 主行可点击跳转主页 - 缓存管理:settings 页新增「存储」分区,显示缓存大小并支持一键清除 - 新增 utils/cache.ts(计算大小 + 清除 expo-image/文件系统缓存)
This commit is contained in:
@@ -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
209
app/creator/[mid].tsx
Normal 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
5
app/creator/_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function CreatorLayout() {
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user