feat: initial commit - GitHub Stars Manager

This commit is contained in:
EchoZenith
2026-04-27 23:58:12 +08:00
commit 9283271b48
20 changed files with 12781 additions and 0 deletions

44
.gitignore vendored Normal file
View File

@@ -0,0 +1,44 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
# environment variables
.env
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android

111
App.js Normal file
View File

@@ -0,0 +1,111 @@
import { useState, useEffect } from 'react';
import { View, ActivityIndicator, Text, StyleSheet } from 'react-native';
import { initDatabase, getGitHubToken } from './services/database';
import TokenInput from './components/TokenInput';
import HomeScreen from './screens/HomeScreen';
import SettingsScreen from './screens/SettingsScreen';
import RepoDetailScreen from './screens/RepoDetailScreen';
import CategoryManageScreen from './screens/CategoryManageScreen';
export default function App() {
// 当前显示的页面loading / token_input / home / settings / repo_detail / category_manage
const [screen, setScreen] = useState('loading');
const [selectedRepo, setSelectedRepo] = useState(null);
// 启动时检查是否已存在 Token决定进入首页或 Token 输入页
useEffect(() => {
checkToken();
}, []);
const checkToken = async () => {
try {
await initDatabase();
const token = await getGitHubToken();
setScreen(token ? 'home' : 'token_input');
} catch (e) {
console.error('初始化失败:', e);
setScreen('token_input');
}
};
const handleTokenSaved = () => {
setScreen('home');
};
const handleTokenExpired = () => {
setScreen('token_input');
};
const handleOpenRepoDetail = (repo) => {
setSelectedRepo(repo);
setScreen('repo_detail');
};
const handleCloseRepoDetail = () => {
setSelectedRepo(null);
setScreen('home');
};
// 根据 screen 状态渲染对应页面
if (screen === 'loading') {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#0366d6" />
<Text style={styles.loadingText}>启动中...</Text>
</View>
);
}
if (screen === 'token_input') {
return <TokenInput onTokenSaved={handleTokenSaved} />;
}
if (screen === 'settings') {
return (
<SettingsScreen
onGoBack={() => setScreen('home')}
onTokenExpired={handleTokenExpired}
/>
);
}
if (screen === 'repo_detail' && selectedRepo) {
return (
<RepoDetailScreen
repo={selectedRepo}
onGoBack={handleCloseRepoDetail}
/>
);
}
if (screen === 'category_manage') {
return (
<CategoryManageScreen
onGoBack={() => setScreen('home')}
/>
);
}
return (
<HomeScreen
onTokenExpired={handleTokenExpired}
onOpenSettings={() => setScreen('settings')}
onOpenRepoDetail={handleOpenRepoDetail}
onOpenCategoryManage={() => setScreen('category_manage')}
/>
);
}
const styles = StyleSheet.create({
center: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f5f5f5',
},
loadingText: {
marginTop: 10,
color: '#888',
fontSize: 14,
},
});

110
README.md Normal file
View File

@@ -0,0 +1,110 @@
# GithubStarsManager
一个使用 React Native (Expo) 构建的移动端应用,用于管理和浏览你在 GitHub 上星标的仓库。
## 功能
- **同步星标仓库** — 通过 GitHub API 一键同步你所有星标的仓库
- **智能分类** — 自动根据仓库的语言、描述等特征对仓库进行分类
- **分类管理** — 手动调整仓库分类,支持拖拽排序
- **搜索筛选** — 按分类快速筛选仓库
- **数据本地存储** — 所有数据保存在本地 SQLite 数据库中,无需担心隐私问题
## 技术栈
| 技术 | 用途 |
|------|------|
| [React Native](https://reactnative.dev/) 0.81 | 跨平台移动框架 |
| [Expo](https://expo.dev/) SDK 54 | 开发工具链与原生模块管理 |
| [expo-sqlite](https://docs.expo.dev/versions/latest/sdk/sqlite/) | 本地数据持久化 |
| [react-native-markdown-display](https://github.com/iamacup/react-native-markdown-display) | Markdown 渲染 |
| [react-native-syntax-highlighter](https://github.com/conorhastings/react-native-syntax-highlighter) | 代码语法高亮 |
| [@expo/vector-icons](https://docs.expo.dev/guides/icons/) | UI 图标系统 |
## 快速开始
### 前置条件
- Node.js >= 18
- npm 或 yarn
- GitHub 个人访问令牌Personal Access Token
### 获取 GitHub Token
1. 访问 [GitHub Settings > Developer settings > Personal access tokens](https://github.com/settings/tokens)
2. 点击 **Generate new token (classic)**
3. 勾选 `repo` `user` 权限范围
4. 生成并复制 Token
### 安装与运行
```bash
# 克隆仓库
git clone https://github.com/EchoZenith/GithubStarsManager-Android.git
cd GithubStarsManager-Android
# 安装依赖
npm install
# 启动开发服务器
npx expo start
# 或在 Android 设备/模拟器上直接运行
npx expo run:android
```
首次启动时会提示输入 GitHub Token粘贴后即可开始同步仓库。
## 项目结构
```
GithubStarsManager/
├── assets/ # 应用图标与静态资源
│ ├── icon.png # 主图标
│ ├── adaptive-icon.png # Android 自适应图标
│ ├── splash-icon.png # 启动页图标
│ └── favicon.png # Web 图标
├── components/ # 可复用组件
│ ├── RepoItem.js # 仓库列表项
│ └── TokenInput.js # Token 输入组件
├── screens/ # 页面
│ ├── HomeScreen.js # 首页:仓库列表与分类浏览
│ ├── RepoDetailScreen.js # 仓库详情与 README 展示
│ ├── CategoryManageScreen.js # 分类管理
│ └── SettingsScreen.js # 设置页
├── services/ # 业务逻辑
│ ├── github.js # GitHub API 封装
│ ├── database.js # SQLite 数据库操作
│ └── categorizer.js # 自动分类引擎
├── App.js # 应用入口与路由
├── app.json # Expo 配置
└── package.json # 依赖管理
```
## 自动分类
应用内置了基于语言和关键词的自动分类引擎,支持 13 个分类:
| 分类 | 匹配逻辑 |
|------|---------|
| Web 应用 | HTML/CSS/JS/TS + 相关关键词 |
| 移动应用 | Java/Kotlin/Dart/Swift + 相关关键词 |
| 桌面应用 | C++/C#/Go/Rust/Zig + 相关关键词 |
| AI/机器学习 | Python + 机器学习相关关键词 |
| 数据库 | SQL + 数据库相关关键词 |
| 开发工具 | Shell/Dockerfile + 工具类关键词 |
| 安全工具 | 安全相关关键词 |
| 游戏 | 游戏开发相关关键词 |
| 设计工具 | UI/UX 相关关键词 |
| 效率工具 | 效率工具类关键词 |
| 教育学习 | Awesome/教程/文档类关键词 |
| 社交网络 | 社交/通讯类关键词 |
| 数据分析 | R/Julia + 数据分析类关键词 |
## 致谢
感谢 [AmintaCCCP/GithubStarsManager](https://github.com/AmintaCCCP/GithubStarsManager) 仓库提供的灵感与参考。
## 许可证
[MIT](LICENSE)

36
app.json Normal file
View File

@@ -0,0 +1,36 @@
{
"expo": {
"name": "GithubStarsManager",
"slug": "github-stars-manager",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"edgeToEdgeEnabled": true,
"package": "com.echozenith.githubstarsmanager"
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-sqlite"
],
"extra": {
"appRepo": "EchoZenith/GithubStarsManager-Android"
}
}
}

BIN
assets/adaptive-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 888 B

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

BIN
assets/splash-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

134
components/RepoItem.js Normal file
View File

@@ -0,0 +1,134 @@
import { StyleSheet, Text, View, TouchableOpacity, Image } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
// 仓库列表项卡片组件显示仓库头像、名称、描述、星标数、Fork 数、分类标签
export default function RepoItem({ item, onPress, onLongPress, showCategory }) {
const categories = item.categories || [];
return (
<TouchableOpacity
style={styles.repoItem}
onPress={() => onPress?.(item)}
onLongPress={() => onLongPress?.(item)}
>
<View style={styles.header}>
{item.owner_avatar_url ? (
<Image
source={{ uri: item.owner_avatar_url }}
style={styles.avatar}
/>
) : null}
<View style={styles.headerText}>
<Text style={styles.repoName}>{item.full_name}</Text>
{item.language ? (
<Text style={styles.language}>{item.language}</Text>
) : null}
</View>
</View>
<Text style={styles.repoDesc} numberOfLines={2}>
{item.description || '暂无描述'}
</Text>
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Ionicons name="star" size={14} color="#f0ad4e" />
<Text style={styles.stat}>{item.stargazers_count}</Text>
</View>
<View style={styles.statItem}>
<Ionicons name="git-branch-outline" size={14} color="#555" />
<Text style={styles.stat}>{item.forks_count}</Text>
</View>
</View>
{showCategory && categories.length > 0 ? (
<View style={styles.badgesRow}>
{categories.map((cat) => (
<View
key={cat.id}
style={[styles.categoryBadge, { backgroundColor: cat.color || '#0366d6' }]}
>
<Text style={styles.categoryBadgeText}>{cat.name}</Text>
</View>
))}
</View>
) : null}
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
repoItem: {
backgroundColor: '#fff',
padding: 14,
marginVertical: 5,
marginHorizontal: 12,
borderRadius: 10,
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 3,
},
header: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 6,
},
avatar: {
width: 28,
height: 28,
borderRadius: 14,
marginRight: 8,
},
headerText: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
repoName: {
fontSize: 15,
fontWeight: '600',
color: '#0366d6',
flex: 1,
},
language: {
fontSize: 12,
color: '#888',
marginLeft: 8,
},
repoDesc: {
marginTop: 4,
color: '#666',
fontSize: 13,
lineHeight: 18,
},
statsRow: {
flexDirection: 'row',
marginTop: 8,
},
statItem: {
flexDirection: 'row',
alignItems: 'center',
marginRight: 16,
gap: 4,
},
stat: {
fontSize: 13,
color: '#555',
},
badgesRow: {
flexDirection: 'row',
flexWrap: 'wrap',
marginTop: 8,
gap: 6,
},
categoryBadge: {
paddingHorizontal: 10,
paddingVertical: 3,
borderRadius: 12,
},
categoryBadgeText: {
color: '#fff',
fontSize: 11,
fontWeight: '500',
},
});

294
components/TokenInput.js Normal file
View File

@@ -0,0 +1,294 @@
import { useState, useEffect } from 'react';
import {
View, Text, TextInput, TouchableOpacity,
StyleSheet, Alert, ActivityIndicator, Linking,
Platform, KeyboardAvoidingView, BackHandler
} from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { Ionicons } from '@expo/vector-icons';
import { setGitHubToken } from '../services/database';
import { fetchStarredRepos } from '../services/github';
// Token 输入页:输入/验证 GitHub Personal Access Token
export default function TokenInput({ onTokenSaved, onBack }) {
const [token, setToken] = useState('');
const [verifying, setVerifying] = useState(false);
const [visible, setVisible] = useState(false);
const [showHelp, setShowHelp] = useState(false);
// Android 硬件返回按钮
useEffect(() => {
const onBackPress = () => {
if (onBack) {
onBack();
return true;
}
Alert.alert('退出应用', '确定要退出吗?', [
{ text: '取消', style: 'cancel' },
{ text: '退出', style: 'destructive', onPress: () => BackHandler.exitApp() },
]);
return true;
};
const subscription = BackHandler.addEventListener('hardwareBackPress', onBackPress);
return () => subscription.remove();
}, [onBack]);
// 验证 Token调用 GitHub API 确认有效后再保存
const handleVerify = async () => {
const trimmed = token.trim();
if (!trimmed) {
Alert.alert('提示', '请输入 GitHub Token');
return;
}
setVerifying(true);
try {
await fetchStarredRepos(trimmed);
await setGitHubToken(trimmed);
onTokenSaved();
} catch (e) {
Alert.alert('验证失败', e.message);
} finally {
setVerifying(false);
}
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<StatusBar style="dark" />
{onBack ? (
<View style={styles.topBar}>
<TouchableOpacity style={styles.topBarBack} onPress={onBack}>
<Ionicons name="arrow-back" size={24} color="#333" />
</TouchableOpacity>
<Text style={styles.topBarTitle}>设置 Token</Text>
<View style={styles.topBarBack} />
</View>
) : null}
<View style={styles.content}>
<View style={styles.iconWrap}>
<Ionicons name="logo-github" size={64} color="#0366d6" />
</View>
<Text style={styles.title}>GitHub Stars</Text>
<Text style={styles.subtitle}>
请输入你的 GitHub Personal Access Token 以同步星标仓库
</Text>
<View style={styles.inputWrap}>
<TextInput
style={styles.input}
value={token}
onChangeText={setToken}
placeholder="ghp_xxxxxxxxxxxxxxxxxxxx"
placeholderTextColor="#bbb"
secureTextEntry={!visible}
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity
style={styles.eyeBtn}
onPress={() => setVisible(!visible)}
>
<Ionicons
name={visible ? 'eye-off' : 'eye'}
size={20}
color="#999"
/>
</TouchableOpacity>
</View>
<TouchableOpacity
style={[styles.verifyBtn, (!token.trim() || verifying) && styles.verifyBtnDisabled]}
onPress={handleVerify}
disabled={!token.trim() || verifying}
>
{verifying ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<>
<Ionicons name="checkmark-circle" size={20} color="#fff" />
<Text style={styles.verifyBtnText}>验证并保存</Text>
</>
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.helpBtn}
onPress={() => setShowHelp(!showHelp)}
>
<Ionicons name="help-circle-outline" size={16} color="#0366d6" />
<Text style={styles.helpBtnText}>如何创建 GitHub Token</Text>
<Ionicons
name={showHelp ? 'chevron-up' : 'chevron-down'}
size={16}
color="#0366d6"
/>
</TouchableOpacity>
{showHelp ? (
<View style={styles.helpPanel}>
<Text style={styles.helpStep}>
1. 访问 GitHub Settings Developer settings Personal access tokens
</Text>
<Text style={styles.helpStep}>
2. 点击 "Generate new token (classic)"
</Text>
<Text style={styles.helpStep}>
3. 选择权限范围repo user
</Text>
<Text style={styles.helpStep}>
4. 复制生成的 token 并粘贴到上方输入框
</Text>
<TouchableOpacity
style={styles.helpLink}
onPress={() => Linking.openURL('https://github.com/settings/tokens')}
>
<Ionicons name="open-outline" size={14} color="#0366d6" />
<Text style={styles.helpLinkText}>前往 GitHub 生成 Token</Text>
</TouchableOpacity>
</View>
) : null}
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
topBar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingTop: Platform.OS === 'ios' ? 50 : 40,
paddingBottom: 10,
paddingHorizontal: 16,
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: '#e8e8e8',
},
topBarBack: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
topBarTitle: {
fontSize: 17,
fontWeight: '600',
color: '#1a1a1a',
},
content: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 32,
},
iconWrap: {
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: '#f0f7ff',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 20,
},
title: {
fontSize: 26,
fontWeight: '700',
color: '#1a1a1a',
marginBottom: 10,
},
subtitle: {
fontSize: 14,
color: '#888',
textAlign: 'center',
lineHeight: 20,
marginBottom: 28,
paddingHorizontal: 10,
},
inputWrap: {
flexDirection: 'row',
alignItems: 'center',
width: '100%',
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 12,
backgroundColor: '#fff',
marginBottom: 16,
},
input: {
flex: 1,
padding: 14,
fontSize: 14,
color: '#333',
},
eyeBtn: {
padding: 14,
},
verifyBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#0366d6',
padding: 16,
borderRadius: 12,
width: '100%',
gap: 8,
},
verifyBtnDisabled: {
backgroundColor: '#99c9ff',
},
verifyBtnText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
helpBtn: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 20,
gap: 4,
},
helpBtnText: {
fontSize: 13,
color: '#0366d6',
},
helpPanel: {
width: '100%',
backgroundColor: '#f0f7ff',
borderRadius: 12,
padding: 16,
marginTop: 12,
borderWidth: 1,
borderColor: '#d0e3ff',
},
helpStep: {
fontSize: 13,
color: '#444',
lineHeight: 20,
marginBottom: 8,
paddingLeft: 4,
},
helpLink: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginTop: 8,
paddingVertical: 10,
backgroundColor: '#fff',
borderRadius: 8,
borderWidth: 1,
borderColor: '#0366d6',
gap: 6,
},
helpLinkText: {
fontSize: 13,
color: '#0366d6',
fontWeight: '500',
},
});

8
index.js Normal file
View File

@@ -0,0 +1,8 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);

9438
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "clwy-app",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web"
},
"dependencies": {
"@types/react": "~19.1.10",
"@types/react-native": "^0.72.8",
"expo": "~54.0.33",
"expo-constants": "~18.0.13",
"expo-dev-client": "~6.0.20",
"expo-sqlite": "~16.0.10",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-dotenv": "^3.4.11",
"react-native-markdown-display": "^7.0.2",
"react-native-syntax-highlighter": "^2.1.0",
"typescript": "~5.9.2"
},
"private": true
}

View File

@@ -0,0 +1,380 @@
import { useState, useEffect, useRef } from 'react';
import {
View, Text, StyleSheet, TouchableOpacity, ScrollView,
TextInput, Alert, ActivityIndicator, Platform, BackHandler
} from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { Ionicons } from '@expo/vector-icons';
import {
getAllCategories, addCategory, updateCategory, deleteCategory,
getUncategorizedRepos, batchSetRepoCategories, getRepoCountByCategory
} from '../services/database';
import { runAutoCategorize } from '../services/categorizer';
// 可选的颜色列表(给分类标签选择用)
const CAT_COLORS = ['#0366d6', '#28a745', '#d73a4a', '#6f42c1', '#e36209', '#19b5a0', '#f0ad4e', '#8b5cf6', '#1abc9c', '#3498db', '#9b59b6', '#e67e22', '#2c3e50'];
// 分类管理页:查看/新增/编辑/删除分类
export default function CategoryManageScreen({ onGoBack }) {
const [categories, setCategories] = useState([]);
const [stats, setStats] = useState({});
const [loading, setLoading] = useState(true);
const goBackRef = useRef(onGoBack);
goBackRef.current = onGoBack;
// 表单状态
const [showForm, setShowForm] = useState(false);
const [editingCat, setEditingCat] = useState(null);
const [formName, setFormName] = useState('');
const [formColor, setFormColor] = useState(CAT_COLORS[0]);
// 加载分类列表及各分类的仓库数量
const loadData = async () => {
const cats = await getAllCategories();
setCategories(cats);
const counts = await getRepoCountByCategory();
const statsMap = {};
for (const c of counts) {
statsMap[c.id] = c.repo_count;
}
setStats(statsMap);
setLoading(false);
};
useEffect(() => {
loadData();
}, []);
// Android 硬件返回按钮
useEffect(() => {
const onBackPress = () => {
goBackRef.current();
return true;
};
const subscription = BackHandler.addEventListener('hardwareBackPress', onBackPress);
return () => subscription.remove();
}, []);
const openAddForm = () => {
setEditingCat(null);
setFormName('');
setFormColor(CAT_COLORS[0]);
setShowForm(true);
};
const openEditForm = (cat) => {
setEditingCat(cat);
setFormName(cat.name);
setFormColor(cat.color);
setShowForm(true);
};
const closeForm = () => {
setShowForm(false);
setEditingCat(null);
setFormName('');
};
// 保存分类:新增时自动触发未分类仓库的自动归类
const handleSave = async () => {
const trimmed = formName.trim();
if (!trimmed) {
Alert.alert('提示', '分类名称不能为空');
return;
}
try {
if (editingCat) {
await updateCategory(editingCat.id, trimmed, formColor);
} else {
await addCategory(trimmed, formColor);
// 新增分类后自动将未分类仓库匹配到新分类
const cats = await getAllCategories();
await runAutoCategorize(cats, getUncategorizedRepos, batchSetRepoCategories);
}
closeForm();
await loadData();
} catch (e) {
Alert.alert('错误', e.message || '保存失败');
}
};
// 删除分类(该分类下的仓库变为未分类)
const handleDelete = (cat) => {
Alert.alert(
'删除分类',
`确定要删除「${cat.name}」吗?该分类下的仓库将变为未分类状态。`,
[
{ text: '取消', style: 'cancel' },
{
text: '删除',
style: 'destructive',
onPress: async () => {
await deleteCategory(cat.id);
await loadData();
},
},
]
);
};
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#0366d6" />
</View>
);
}
return (
<View style={styles.container}>
<StatusBar style="dark" />
<View style={styles.header}>
<TouchableOpacity style={styles.headerBtn} onPress={onGoBack}>
<Ionicons name="arrow-back" size={24} color="#333" />
</TouchableOpacity>
<Text style={styles.headerTitle}>分类管理</Text>
<TouchableOpacity style={styles.headerBtn} onPress={openAddForm}>
<Ionicons name="add" size={26} color="#0366d6" />
</TouchableOpacity>
</View>
<ScrollView style={styles.scroll}>
{showForm ? (
<View style={styles.formCard}>
<Text style={styles.formTitle}>
{editingCat ? '编辑分类' : '新建分类'}
</Text>
<Text style={styles.fieldLabel}>名称</Text>
<TextInput
style={styles.input}
value={formName}
onChangeText={setFormName}
placeholder="输入分类名称"
placeholderTextColor="#bbb"
autoFocus
/>
<Text style={styles.fieldLabel}>颜色</Text>
<View style={styles.colorPicker}>
{CAT_COLORS.map((color) => (
<TouchableOpacity
key={color}
onPress={() => setFormColor(color)}
style={[
styles.colorOption,
{ backgroundColor: color },
formColor === color && styles.colorOptionSelected,
]}
/>
))}
</View>
<View style={styles.formActions}>
<TouchableOpacity style={styles.cancelBtn} onPress={closeForm}>
<Text style={styles.cancelBtnText}>取消</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.saveBtn} onPress={handleSave}>
<Text style={styles.saveBtnText}>
{editingCat ? '更新' : '创建'}
</Text>
</TouchableOpacity>
</View>
</View>
) : null}
{categories.map((cat) => (
<View key={cat.id} style={styles.catItem}>
<View style={styles.catItemLeft}>
<View style={[styles.catDot, { backgroundColor: cat.color }]} />
<View style={styles.catItemInfo}>
<Text style={styles.catItemName}>{cat.name}</Text>
<Text style={styles.catItemCount}>
{stats[cat.id] || 0} 个仓库
</Text>
</View>
</View>
<View style={styles.catItemActions}>
<TouchableOpacity
style={styles.catActionBtn}
onPress={() => openEditForm(cat)}
>
<Ionicons name="pencil" size={18} color="#0366d6" />
</TouchableOpacity>
<TouchableOpacity
style={styles.catActionBtn}
onPress={() => handleDelete(cat)}
>
<Ionicons name="trash" size={18} color="#d73a4a" />
</TouchableOpacity>
</View>
</View>
))}
<View style={{ height: 40 }} />
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
center: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: '#fff',
paddingTop: Platform.OS === 'ios' ? 50 : 40,
paddingBottom: 10,
paddingHorizontal: 8,
borderBottomWidth: 1,
borderBottomColor: '#e8e8e8',
},
headerBtn: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
headerTitle: {
fontSize: 17,
fontWeight: '600',
color: '#1a1a1a',
},
scroll: {
flex: 1,
paddingHorizontal: 16,
paddingTop: 12,
},
formCard: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
marginBottom: 12,
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 3,
},
formTitle: {
fontSize: 16,
fontWeight: '600',
color: '#1a1a1a',
marginBottom: 12,
},
fieldLabel: {
fontSize: 13,
fontWeight: '600',
color: '#666',
marginBottom: 6,
marginTop: 8,
},
input: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 10,
padding: 12,
fontSize: 15,
color: '#333',
backgroundColor: '#fafafa',
},
colorPicker: {
flexDirection: 'row',
gap: 10,
marginBottom: 8,
flexWrap: 'wrap',
},
colorOption: {
width: 36,
height: 36,
borderRadius: 18,
borderWidth: 3,
borderColor: 'transparent',
},
colorOptionSelected: {
borderColor: '#333',
},
formActions: {
flexDirection: 'row',
gap: 10,
marginTop: 12,
},
cancelBtn: {
flex: 1,
padding: 14,
borderRadius: 10,
alignItems: 'center',
borderWidth: 1,
borderColor: '#ddd',
},
cancelBtnText: {
fontSize: 15,
color: '#666',
fontWeight: '500',
},
saveBtn: {
flex: 1,
padding: 14,
borderRadius: 10,
alignItems: 'center',
backgroundColor: '#0366d6',
},
saveBtnText: {
fontSize: 15,
color: '#fff',
fontWeight: '600',
},
catItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#fff',
borderRadius: 12,
padding: 14,
marginBottom: 8,
elevation: 1,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
},
catItemLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
catDot: {
width: 14,
height: 14,
borderRadius: 7,
marginRight: 12,
},
catItemInfo: {
flex: 1,
},
catItemName: {
fontSize: 15,
fontWeight: '500',
color: '#333',
},
catItemCount: {
fontSize: 12,
color: '#999',
marginTop: 2,
},
catItemActions: {
flexDirection: 'row',
gap: 8,
},
catActionBtn: {
padding: 6,
},
});

405
screens/HomeScreen.js Normal file
View File

@@ -0,0 +1,405 @@
import { useState, useEffect, useCallback } from 'react';
import {
View, Text, FlatList, StyleSheet, TouchableOpacity,
Alert, ActivityIndicator, RefreshControl,
ScrollView, Platform, BackHandler
} from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { Ionicons } from '@expo/vector-icons';
import RepoItem from '../components/RepoItem';
import { fetchStarredRepos, TokenExpiredError } from '../services/github';
import {
initDatabase, getAllCategories, saveRepos, getAllRepos, getReposByCategory,
getUncategorizedRepos, getGitHubToken, batchSetRepoCategories
} from '../services/database';
import { runAutoCategorize } from '../services/categorizer';
// 首页:仓库列表、分类标签栏、同步入口
export default function HomeScreen({ onTokenExpired, onOpenSettings, onOpenRepoDetail, onOpenCategoryManage }) {
const [categories, setCategories] = useState([]);
// selectedCategory: null=全部, 0=未分类, >0=具体分类 ID
const [selectedCategory, setSelectedCategory] = useState(null);
const [repos, setRepos] = useState([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [syncing, setSyncing] = useState(false);
const [syncInfo, setSyncInfo] = useState('');
// 按分类加载仓库列表
const loadRepos = useCallback(async (catId) => {
if (catId === null || catId === undefined) {
const allRepos = await getAllRepos();
setRepos(allRepos);
} else if (catId === 0) {
const uncatRepos = await getUncategorizedRepos();
setRepos(uncatRepos);
} else {
const catRepos = await getReposByCategory(catId);
setRepos(catRepos);
}
}, []);
// 加载所有数据:分类 + 仓库列表
const loadData = useCallback(async () => {
try {
await initDatabase();
const cats = await getAllCategories();
setCategories(cats);
await loadRepos(selectedCategory);
} catch (e) {
console.error('加载数据失败:', e);
} finally {
setLoading(false);
}
}, [selectedCategory, loadRepos]);
useEffect(() => {
loadData();
}, [loadData]);
// Android 硬件返回按钮退出应用
useEffect(() => {
const onBackPress = () => {
Alert.alert('退出应用', '确定要退出吗?', [
{ text: '取消', style: 'cancel' },
{ text: '退出', style: 'destructive', onPress: () => BackHandler.exitApp() },
]);
return true;
};
const subscription = BackHandler.addEventListener('hardwareBackPress', onBackPress);
return () => subscription.remove();
}, []);
const onSelectCategory = async (catId) => {
setSelectedCategory(catId);
await loadRepos(catId);
};
// 从 GitHub 同步星标仓库,保存到本地后执行自动分类
const handleSync = async () => {
const token = await getGitHubToken();
if (!token) {
onTokenExpired();
return;
}
setSyncing(true);
setSyncInfo('正在从 GitHub 获取星标仓库...');
try {
const reposData = await fetchStarredRepos(token);
const count = await saveRepos(reposData);
const cats = await getAllCategories();
await runAutoCategorize(cats, getUncategorizedRepos, batchSetRepoCategories);
setSyncInfo(`同步完成,新增 ${count} 个仓库(共 ${reposData.length} 个)`);
setCategories(cats);
await loadRepos(selectedCategory);
setTimeout(() => setSyncInfo(''), 3000);
} catch (e) {
if (e instanceof TokenExpiredError) {
onTokenExpired();
} else {
Alert.alert('同步失败', e.message);
}
} finally {
setSyncing(false);
}
};
const handleRefresh = async () => {
setRefreshing(true);
try {
await handleSync();
} finally {
setRefreshing(false);
}
};
// 长按仓库时提示去分类管理页操作
const handleRepoLongPress = () => {
Alert.alert('管理分类', '请前往分类管理页面设置仓库分类', [
{ text: '取消', style: 'cancel' },
{ text: '前往', onPress: onOpenCategoryManage },
]);
};
const currentCategoryName = selectedCategory === null
? '全部仓库'
: selectedCategory === 0
? '未分类'
: categories.find(c => c.id === selectedCategory)?.name || '全部仓库';
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#0366d6" />
<Text style={styles.loadingText}>加载中...</Text>
</View>
);
}
return (
<View style={styles.container}>
<StatusBar style="dark" />
<View style={styles.header}>
<View style={styles.headerTop}>
<Text style={styles.headerTitle}>GitHub Stars</Text>
<View style={styles.headerActions}>
<TouchableOpacity
style={styles.headerBtn}
onPress={handleSync}
disabled={syncing}
>
<Ionicons
name={syncing ? 'sync' : 'cloud-download'}
size={22}
color="#0366d6"
/>
</TouchableOpacity>
<TouchableOpacity
style={styles.headerBtn}
onPress={onOpenCategoryManage}
>
<Ionicons name="folder-open" size={22} color="#0366d6" />
</TouchableOpacity>
<TouchableOpacity
style={styles.headerBtn}
onPress={onOpenSettings}
>
<Ionicons name="settings-outline" size={22} color="#0366d6" />
</TouchableOpacity>
</View>
</View>
{syncInfo ? (
<Text style={styles.syncInfo}>{syncInfo}</Text>
) : null}
</View>
<View style={styles.categoryTabs}>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<TouchableOpacity
style={[
styles.categoryTab,
selectedCategory === null && styles.categoryTabActive,
]}
onPress={() => onSelectCategory(null)}
>
<Text
style={[
styles.categoryTabText,
selectedCategory === null && styles.categoryTabTextActive,
]}
>
全部
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.categoryTab,
selectedCategory === 0 && styles.categoryTabActive,
]}
onPress={() => onSelectCategory(0)}
>
<Text
style={[
styles.categoryTabText,
selectedCategory === 0 && styles.categoryTabTextActive,
]}
>
未分类
</Text>
</TouchableOpacity>
{categories.map((cat) => (
<TouchableOpacity
key={cat.id}
style={[
styles.categoryTab,
selectedCategory === cat.id && styles.categoryTabActive,
]}
onPress={() => onSelectCategory(cat.id)}
>
<View
style={[styles.categoryDot, { backgroundColor: cat.color }]}
/>
<Text
style={[
styles.categoryTabText,
selectedCategory === cat.id && styles.categoryTabTextActive,
]}
>
{cat.name}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{currentCategoryName}</Text>
<Text style={styles.sectionCount}>{repos.length} 个仓库</Text>
</View>
{syncing ? (
<View style={styles.syncingBar}>
<ActivityIndicator size="small" color="#fff" />
<Text style={styles.syncingText}>同步中...</Text>
</View>
) : null}
<FlatList
data={repos}
renderItem={({ item }) => (
<RepoItem
item={item}
showCategory={selectedCategory === null || selectedCategory === 0}
onPress={onOpenRepoDetail}
onLongPress={handleRepoLongPress}
/>
)}
keyExtractor={(item) => String(item.id)}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
ListEmptyComponent={
<View style={styles.empty}>
<Ionicons name="star-outline" size={48} color="#ccc" />
<Text style={styles.emptyText}>
{selectedCategory === 0
? '没有未分类的仓库'
: '暂无仓库数据\n点击右上角 ☁️ 按钮从 GitHub 同步'}
</Text>
</View>
}
contentContainerStyle={repos.length === 0 ? styles.emptyContainer : styles.listContent}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
center: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 10,
color: '#888',
fontSize: 14,
},
header: {
backgroundColor: '#fff',
paddingTop: Platform.OS === 'ios' ? 50 : 40,
paddingBottom: 8,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: '#e8e8e8',
},
headerTop: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
headerTitle: {
fontSize: 24,
fontWeight: '700',
color: '#1a1a1a',
},
headerActions: {
flexDirection: 'row',
gap: 8,
},
headerBtn: {
padding: 8,
borderRadius: 8,
backgroundColor: '#f0f7ff',
},
syncInfo: {
marginTop: 6,
fontSize: 12,
color: '#28a745',
},
categoryTabs: {
backgroundColor: '#fff',
paddingVertical: 10,
paddingLeft: 12,
borderBottomWidth: 1,
borderBottomColor: '#eee',
},
categoryTab: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 7,
marginRight: 8,
borderRadius: 20,
backgroundColor: '#f0f0f0',
},
categoryTabActive: {
backgroundColor: '#0366d6',
},
categoryTabText: {
fontSize: 13,
color: '#666',
fontWeight: '500',
},
categoryTabTextActive: {
color: '#fff',
},
categoryDot: {
width: 8,
height: 8,
borderRadius: 4,
marginRight: 5,
},
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 10,
},
sectionTitle: {
fontSize: 16,
fontWeight: '600',
color: '#333',
},
sectionCount: {
fontSize: 13,
color: '#999',
},
syncingBar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#0366d6',
paddingVertical: 6,
gap: 8,
},
syncingText: {
color: '#fff',
fontSize: 13,
},
listContent: {
paddingBottom: 20,
},
emptyContainer: {
flexGrow: 1,
justifyContent: 'center',
alignItems: 'center',
},
empty: {
alignItems: 'center',
paddingVertical: 40,
},
emptyText: {
marginTop: 12,
fontSize: 14,
color: '#999',
textAlign: 'center',
lineHeight: 20,
},
});

659
screens/RepoDetailScreen.js Normal file
View File

@@ -0,0 +1,659 @@
import { useState, useEffect, useRef } from 'react';
import {
View, Text, StyleSheet, TouchableOpacity, ScrollView,
ActivityIndicator, Image, Linking, Platform, BackHandler, InteractionManager, Dimensions
} from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { Ionicons } from '@expo/vector-icons';
import Markdown from 'react-native-markdown-display';
import SyntaxHighlighter from 'react-native-syntax-highlighter';
import { vs2015 } from 'react-syntax-highlighter/styles/hljs';
import { fetchReadme, TokenExpiredError } from '../services/github';
import { getGitHubToken } from '../services/database';
const SCREEN_WIDTH = Dimensions.get('window').width;
// 检测是否为 SVG 图片链接React Native Image 组件不支持 SVG
function isSvgUrl(url) {
return /\.svg(\?|#|$)/i.test(url) || /\/svg(\?|#|$)/i.test(url);
}
// SVG 图片占位组件:点击后在系统浏览器中打开原始 SVG 文件
function SvgImage({ uri, alt }) {
return (
<TouchableOpacity
style={svgStyles.container}
onPress={() => Linking.openURL(uri)}
activeOpacity={0.7}
>
<View style={svgStyles.placeholder}>
<Ionicons name="image-outline" size={28} color="#999" />
<Text style={svgStyles.placeholderText} numberOfLines={1}>
{alt || 'SVG 图片'}
</Text>
<View style={svgStyles.badge}>
<Ionicons name="open-outline" size={12} color="#fff" />
<Text style={svgStyles.badgeText}>浏览器查看</Text>
</View>
</View>
</TouchableOpacity>
);
}
const svgStyles = StyleSheet.create({
container: {
width: '100%',
marginBottom: 12,
borderRadius: 8,
overflow: 'hidden',
borderWidth: 1,
borderColor: '#e1e4e8',
},
placeholder: {
height: 80,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f6f8fa',
gap: 4,
},
placeholderText: {
fontSize: 13,
color: '#666',
},
badge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#0366d6',
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 10,
gap: 3,
},
badgeText: {
fontSize: 11,
color: '#fff',
},
});
// 根据图片设备像素自动计算合适的高度,避免固定宽高比导致 SVG 徽章变形
function ReadmeImage({ src, alt }) {
const [dimensions, setDimensions] = useState(null);
const isSvg = isSvgUrl(src);
if (isSvg) {
return <SvgImage uri={src} alt={alt} />;
}
return (
<Image
source={{ uri: src }}
style={[
readmeImageStyles.image,
dimensions
? { width: '100%', height: dimensions.height, aspectRatio: undefined }
: { width: '100%', height: 220 },
]}
resizeMode="contain"
onLoad={(e) => {
const { width, height } = e.nativeEvent.source;
if (width && height) {
const maxWidth = SCREEN_WIDTH - 56;
const ratio = Math.min(maxWidth / width, 1);
setDimensions({ width: maxWidth, height: height * ratio });
}
}}
/>
);
}
const readmeImageStyles = StyleSheet.create({
image: {
maxWidth: '100%',
borderRadius: 6,
marginBottom: 12,
},
});
function preprocessMarkdown(markdown, repoFullName, defaultBranch) {
if (!markdown) return markdown;
const [owner, repo] = repoFullName.split('/');
const branch = defaultBranch || 'main';
const rawBaseUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}`;
const githubBaseUrl = `https://github.com/${owner}/${repo}/blob/${branch}`;
let processed = markdown;
// Remove <picture> and <source> tags, keep their content
processed = processed.replace(/<picture[^>]*>/gi, '');
processed = processed.replace(/<\/picture>/gi, '');
processed = processed.replace(/<source[^>]*\/?>/gi, '');
// Convert <a><img>...</a> to markdown link-wrapped image first
processed = processed.replace(
/<a\s+[^>]*href=["']([^"']*)["'][^>]*>\s*(<img[^>]*>)\s*<\/a>/gi,
(match, href, imgTag) => {
const srcMatch = imgTag.match(/src\s*=\s*["']([^"']*)["']/i);
const altMatch = imgTag.match(/alt\s*=\s*["']([^"']*)["']/i);
const src = srcMatch ? srcMatch[1] : '';
const alt = altMatch ? altMatch[1] : 'image';
return `[![${alt}](${src})](${href})`;
}
);
// Convert remaining standalone HTML <img> tags to markdown image syntax
processed = processed.replace(
/<img\s+[^>]*src\s*=\s*["']([^"']*)["'][^>]*\/?>/gi,
(match, src) => {
const altMatch = match.match(/alt\s*=\s*["']([^"']*)["']/i);
const alt = altMatch ? altMatch[1] : 'image';
return `![${alt}](${src})`;
}
);
// Convert remaining HTML <a> tags to markdown link syntax
processed = processed.replace(
/<a\s+[^>]*href\s*=\s*["']([^"']*)["'][^>]*>([^<]*)<\/a>/gi,
(match, href, text) => `[${text.trim()}](${href})`
);
// Strip remaining HTML tags
processed = processed.replace(/<[^>]*>/g, '');
// Resolve relative URLs in markdown images (![alt](url)) and links ([text](url))
processed = processed.replace(
/(!)?\[([^\]]*)\]\(([^)]+)\)/g,
(match, isImage, text, url) => {
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('mailto:')) {
return match;
}
const baseUrl = isImage ? rawBaseUrl : githubBaseUrl;
const cleanUrl = url.replace(/^(\.\/|\/)/, '');
const resolvedUrl = `${baseUrl}/${cleanUrl}`;
return `${isImage || ''}[${text}](${resolvedUrl})`;
}
);
return processed;
}
function detectLanguage(content) {
const firstLine = content.split('\n')[0].trim();
const knownLanguages = {
js: 'javascript', jsx: 'javascript', mjs: 'javascript',
ts: 'typescript', tsx: 'typescript',
py: 'python', rb: 'ruby', rs: 'rust', go: 'go',
java: 'java', kt: 'kotlin', swift: 'swift',
c: 'c', cpp: 'cpp', cs: 'csharp',
html: 'xml', htm: 'xml', xml: 'xml',
css: 'css', scss: 'css', less: 'css',
sh: 'bash', bash: 'bash', zsh: 'bash', powershell: 'powershell',
json: 'json', yml: 'yaml', yaml: 'yaml',
sql: 'sql', php: 'php', r: 'r', dart: 'dart',
diff: 'diff', dockerfile: 'dockerfile', graphql: 'graphql',
};
const ext = firstLine.replace('```', '').toLowerCase();
return knownLanguages[ext] || ext || 'bash';
}
const markdownStyles = {
body: {
color: '#24292e',
fontSize: 15,
lineHeight: 24,
padding: 16,
},
heading1: {
fontSize: 22,
fontWeight: '700',
marginTop: 20,
marginBottom: 10,
paddingBottom: 8,
borderBottomWidth: 1,
borderBottomColor: '#e1e4e8',
},
heading2: {
fontSize: 18,
fontWeight: '600',
marginTop: 18,
marginBottom: 8,
paddingBottom: 6,
borderBottomWidth: 1,
borderBottomColor: '#e1e4e8',
},
heading3: {
fontSize: 16,
fontWeight: '600',
marginTop: 16,
marginBottom: 6,
},
heading4: {
fontSize: 15,
fontWeight: '600',
marginTop: 14,
marginBottom: 4,
},
paragraph: {
marginBottom: 12,
},
list_item: {
marginBottom: 4,
},
bullet_list: {
paddingLeft: 24,
marginBottom: 12,
},
ordered_list: {
paddingLeft: 24,
marginBottom: 12,
},
code_inline: {
backgroundColor: 'rgba(27,31,35,0.05)',
paddingHorizontal: 5,
paddingVertical: 2,
borderRadius: 3,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
fontSize: 13,
color: '#d73a4a',
},
code_block: {
backgroundColor: '#f6f8fa',
padding: 12,
borderRadius: 6,
marginBottom: 12,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
fontSize: 13,
},
fence: {
backgroundColor: '#f6f8fa',
padding: 12,
borderRadius: 6,
marginBottom: 12,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
fontSize: 13,
},
blockquote: {
paddingLeft: 12,
color: '#6a737d',
borderLeftWidth: 4,
borderLeftColor: '#dfe2e5',
marginBottom: 12,
},
link: {
color: '#0366d6',
textDecorationLine: 'underline',
},
table: {
borderWidth: 1,
borderColor: '#dfe2e5',
borderRadius: 4,
marginBottom: 12,
},
thead: {
backgroundColor: '#f6f8fa',
},
th: {
padding: 6,
borderWidth: 1,
borderColor: '#dfe2e5',
fontWeight: '600',
},
td: {
padding: 6,
borderWidth: 1,
borderColor: '#dfe2e5',
},
hr: {
backgroundColor: '#e1e4e8',
height: 1,
marginVertical: 20,
},
image: {
maxWidth: '100%',
height: undefined,
borderRadius: 6,
marginBottom: 12,
},
};
// 重写 react-native-markdown-display 的默认渲染规则
const renderRules = {
// 自定义图片渲染:支持 SVG 占位,普通图片自动计算尺寸
image: (node, children, parent, styles) => {
const { src, alt } = node.attributes;
return (
<ReadmeImage
key={node.key}
src={src}
alt={alt}
/>
);
},
link: (node, children, parent, styles) => {
const { href } = node.attributes;
const childrenArr = Array.isArray(children) ? children : [children];
const hasOnlyText = childrenArr.every(
child => typeof child === 'string' || typeof child === 'number' || child === null
);
if (hasOnlyText) {
return (
<TouchableOpacity key={node.key} onPress={() => Linking.openURL(href)}>
<Text style={styles.link}>{children}</Text>
</TouchableOpacity>
);
}
return (
<TouchableOpacity key={node.key} onPress={() => Linking.openURL(href)}>
{children}
</TouchableOpacity>
);
},
fence: (node, children, parent, styles) => {
const lang = node.sourceInfo ? node.sourceInfo.split(/\s+/)[0] : '';
const code = node.content;
const language = detectLanguage(lang || code);
return (
<View key={node.key} style={codeBlockStyles.wrapper}>
{lang ? (
<View style={codeBlockStyles.langBar}>
<Text style={codeBlockStyles.langText}>{lang}</Text>
</View>
) : null}
<SyntaxHighlighter
highlighter="highlightjs"
style={vs2015}
PreTag={ScrollView}
CodeTag={ScrollView}
fontFamily={Platform.OS === 'ios' ? 'Menlo' : 'monospace'}
fontSize={12}
customStyle={{
padding: 12,
margin: 0,
borderBottomLeftRadius: 8,
borderBottomRightRadius: 8,
}}
>
{code}
</SyntaxHighlighter>
</View>
);
},
};
const codeBlockStyles = StyleSheet.create({
wrapper: {
marginHorizontal: 12,
marginBottom: 12,
borderRadius: 8,
overflow: 'hidden',
},
langBar: {
backgroundColor: '#2d2d2d',
paddingHorizontal: 12,
paddingVertical: 4,
borderTopLeftRadius: 8,
borderTopRightRadius: 8,
},
langText: {
color: '#999',
fontSize: 11,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
},
});
export default function RepoDetailScreen({ repo, onGoBack }) {
const [readme, setReadme] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const goBackRef = useRef(onGoBack);
goBackRef.current = onGoBack;
useEffect(() => {
const onBackPress = () => {
goBackRef.current();
return true;
};
const subscription = BackHandler.addEventListener('hardwareBackPress', onBackPress);
return () => subscription.remove();
}, []);
useEffect(() => {
InteractionManager.runAfterInteractions(() => {
loadReadme();
});
}, []);
const loadReadme = async () => {
try {
const token = await getGitHubToken();
const markdown = await fetchReadme(token, repo.full_name);
const cleaned = markdown ? preprocessMarkdown(markdown, repo.full_name, repo.default_branch) : null;
setReadme(cleaned);
} catch (e) {
if (e instanceof TokenExpiredError) {
setError('Token 已过期,请返回设置页面重新输入');
} else {
setError(e.message || '加载 README 失败');
}
} finally {
setLoading(false);
}
};
const openInBrowser = () => {
if (repo.html_url) {
Linking.openURL(repo.html_url);
}
};
return (
<View style={styles.container}>
<StatusBar style="dark" />
<View style={styles.header}>
<TouchableOpacity style={styles.headerBtn} onPress={onGoBack}>
<Ionicons name="arrow-back" size={24} color="#333" />
</TouchableOpacity>
<Text style={styles.headerTitle} numberOfLines={1}>
{repo.full_name}
</Text>
<TouchableOpacity style={styles.headerBtn} onPress={openInBrowser}>
<Ionicons name="open-outline" size={22} color="#0366d6" />
</TouchableOpacity>
</View>
<View style={styles.infoCard}>
<View style={styles.infoRow}>
{repo.owner_avatar_url ? (
<Image
source={{ uri: repo.owner_avatar_url }}
style={styles.ownerAvatar}
/>
) : null}
<View style={styles.infoText}>
<Text style={styles.repoName}>{repo.full_name}</Text>
<Text style={styles.repoDesc} numberOfLines={3}>
{repo.description || '暂无描述'}
</Text>
</View>
</View>
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={styles.statValue}>{repo.stargazers_count}</Text>
<Text style={styles.statLabel}>Stars</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{repo.forks_count}</Text>
<Text style={styles.statLabel}>Forks</Text>
</View>
{repo.language ? (
<View style={styles.statItem}>
<Text style={styles.statValue}>{repo.language}</Text>
<Text style={styles.statLabel}>Language</Text>
</View>
) : null}
<View style={styles.statItem}>
<Text style={styles.statValue}>{repo.owner_login}</Text>
<Text style={styles.statLabel}>Owner</Text>
</View>
</View>
</View>
<View style={styles.readmeHeader}>
<Ionicons name="book" size={16} color="#555" />
<Text style={styles.readmeTitle}>README.md</Text>
</View>
{loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#0366d6" />
<Text style={styles.loadingText}>正在加载 README...</Text>
</View>
) : error ? (
<View style={styles.loadingContainer}>
<Ionicons name="alert-circle-outline" size={36} color="#d73a4a" />
<Text style={styles.errorText}>{error}</Text>
</View>
) : readme === null ? (
<View style={styles.loadingContainer}>
<Ionicons name="document-text-outline" size={36} color="#ccc" />
<Text style={styles.emptyText}>该仓库没有 README 文件</Text>
</View>
) : (
<ScrollView style={styles.markdownScroll}>
<Markdown style={markdownStyles} rules={renderRules}>
{readme}
</Markdown>
</ScrollView>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: '#fff',
paddingTop: Platform.OS === 'ios' ? 50 : 40,
paddingBottom: 10,
paddingHorizontal: 8,
borderBottomWidth: 1,
borderBottomColor: '#e8e8e8',
},
headerBtn: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
headerTitle: {
flex: 1,
fontSize: 16,
fontWeight: '600',
color: '#1a1a1a',
textAlign: 'center',
},
infoCard: {
backgroundColor: '#fff',
margin: 12,
marginBottom: 0,
borderRadius: 12,
padding: 16,
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 3,
},
infoRow: {
flexDirection: 'row',
alignItems: 'center',
},
ownerAvatar: {
width: 44,
height: 44,
borderRadius: 22,
marginRight: 12,
},
infoText: {
flex: 1,
},
repoName: {
fontSize: 16,
fontWeight: '600',
color: '#0366d6',
marginBottom: 4,
},
repoDesc: {
fontSize: 13,
color: '#666',
lineHeight: 18,
},
statsRow: {
flexDirection: 'row',
marginTop: 14,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: '#f0f0f0',
justifyContent: 'space-around',
},
statItem: {
alignItems: 'center',
},
statValue: {
fontSize: 14,
fontWeight: '600',
color: '#333',
},
statLabel: {
fontSize: 11,
color: '#999',
marginTop: 2,
},
readmeHeader: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 8,
gap: 6,
},
readmeTitle: {
fontSize: 14,
fontWeight: '600',
color: '#555',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
loadingText: {
marginTop: 10,
color: '#888',
fontSize: 14,
},
errorText: {
marginTop: 10,
color: '#d73a4a',
fontSize: 14,
textAlign: 'center',
},
emptyText: {
marginTop: 10,
color: '#999',
fontSize: 14,
},
markdownScroll: {
flex: 1,
backgroundColor: '#fff',
marginHorizontal: 12,
marginBottom: 12,
borderRadius: 12,
},
});

455
screens/SettingsScreen.js Normal file
View File

@@ -0,0 +1,455 @@
import { useState, useEffect, useRef } from 'react';
import {
View, Text, StyleSheet, TouchableOpacity,
Alert, ActivityIndicator, ScrollView, Platform, Linking, BackHandler
} from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { Ionicons } from '@expo/vector-icons';
import {
getGitHubToken, clearGitHubToken, getAllCategories,
getTotalRepoCount, setGitHubToken
} from '../services/database';
import { fetchStarredRepos, checkUpdate } from '../services/github';
import TokenInput from '../components/TokenInput';
// 设置页Token 管理、数据统计、版本更新检查
export default function SettingsScreen({ onGoBack, onTokenExpired }) {
const [token, setToken] = useState(null);
const [stats, setStats] = useState({ repos: 0, categories: 0 });
const [showTokenInput, setShowTokenInput] = useState(false);
const [verifying, setVerifying] = useState(false);
const [checkingUpdate, setCheckingUpdate] = useState(false);
const [updateInfo, setUpdateInfo] = useState(null);
const goBackRef = useRef(onGoBack);
goBackRef.current = onGoBack;
// 加载已保存的 Token 和数据统计
const loadSettings = async () => {
const savedToken = await getGitHubToken();
setToken(savedToken);
const cats = await getAllCategories();
const total = await getTotalRepoCount();
setStats({ repos: total, categories: cats.length });
};
useEffect(() => {
loadSettings();
}, []);
// Android 硬件返回按钮
useEffect(() => {
const onBackPress = () => {
goBackRef.current();
return true;
};
const subscription = BackHandler.addEventListener('hardwareBackPress', onBackPress);
return () => subscription.remove();
}, []);
const handleChangeToken = () => {
setShowTokenInput(true);
};
// 验证当前 Token 是否仍然有效
const handleVerifyToken = async () => {
setVerifying(true);
try {
await fetchStarredRepos(token);
Alert.alert('验证成功', 'Token 有效');
} catch (e) {
Alert.alert('验证失败', e.message);
} finally {
setVerifying(false);
}
};
// 清除 Token需用户确认
const handleClearToken = () => {
Alert.alert(
'清除 Token',
'确定要清除 GitHub Token 吗?清除后需要重新输入才能同步数据。',
[
{ text: '取消', style: 'cancel' },
{
text: '清除',
style: 'destructive',
onPress: async () => {
await clearGitHubToken();
onTokenExpired();
},
},
]
);
};
const handleTokenSaved = async () => {
setShowTokenInput(false);
await loadSettings();
};
// 检查 GitHub Releases 是否有新版本
const handleCheckUpdate = async () => {
setCheckingUpdate(true);
setUpdateInfo(null);
const savedToken = await getGitHubToken();
const result = await checkUpdate(savedToken);
setUpdateInfo(result);
setCheckingUpdate(false);
if (result.error) {
Alert.alert('检查更新', result.error);
} else if (result.hasUpdate) {
Alert.alert(
'发现新版本',
`当前版本v${result.currentVersion}\n最新版本v${result.latestVersion}\n\n${result.releaseName || ''}\n\n${result.releaseBody ? result.releaseBody : ''}`,
[
{ text: '取消', style: 'cancel' },
{
text: '前往下载',
onPress: () => {
if (result.releaseUrl) {
Linking.openURL(result.releaseUrl);
}
},
},
]
);
} else {
Alert.alert('检查更新', result.message || '已是最新版本');
}
};
if (showTokenInput) {
return <TokenInput onTokenSaved={handleTokenSaved} onBack={() => setShowTokenInput(false)} />;
}
const maskedToken = token
? token.slice(0, 8) + '••••••••' + token.slice(-4)
: '';
return (
<View style={styles.container}>
<StatusBar style="dark" />
<View style={styles.header}>
<TouchableOpacity style={styles.backBtn} onPress={onGoBack}>
<Ionicons name="arrow-back" size={24} color="#333" />
</TouchableOpacity>
<Text style={styles.headerTitle}>设置</Text>
<View style={styles.backBtn} />
</View>
<ScrollView style={styles.scroll}>
<View style={styles.section}>
<Text style={styles.sectionTitle}>GitHub 账号</Text>
<View style={styles.card}>
<View style={styles.row}>
<Ionicons name="key" size={20} color="#0366d6" />
<Text style={styles.rowLabel}>访问令牌</Text>
</View>
<Text style={styles.tokenText}>
{token ? maskedToken : '未设置'}
</Text>
<View style={styles.tokenActions}>
<TouchableOpacity
style={styles.tokenBtn}
onPress={handleChangeToken}
>
<Ionicons name="create" size={16} color="#fff" />
<Text style={styles.tokenBtnText}>
{token ? '修改' : '设置'}
</Text>
</TouchableOpacity>
{token ? (
<>
<TouchableOpacity
style={[styles.tokenBtn, styles.tokenBtnOutline]}
onPress={handleVerifyToken}
disabled={verifying}
>
{verifying ? (
<ActivityIndicator size="small" color="#0366d6" />
) : (
<>
<Ionicons name="checkmark" size={16} color="#0366d6" />
<Text style={[styles.tokenBtnText, styles.tokenBtnTextOutline]}>验证</Text>
</>
)}
</TouchableOpacity>
<TouchableOpacity
style={[styles.tokenBtn, styles.tokenBtnDanger]}
onPress={handleClearToken}
>
<Ionicons name="trash" size={16} color="#fff" />
<Text style={styles.tokenBtnText}>清除</Text>
</TouchableOpacity>
</>
) : null}
</View>
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>数据统计</Text>
<View style={styles.statsRow}>
<View style={styles.statCard}>
<Ionicons name="star" size={24} color="#f0ad4e" />
<Text style={styles.statNumber}>{stats.repos}</Text>
<Text style={styles.statLabel}>星标仓库</Text>
</View>
<View style={styles.statCard}>
<Ionicons name="folder" size={24} color="#0366d6" />
<Text style={styles.statNumber}>{stats.categories}</Text>
<Text style={styles.statLabel}>分类</Text>
</View>
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>AI 分析即将推出</Text>
<TouchableOpacity style={styles.card} activeOpacity={0.7}>
<View style={styles.aiPlaceholder}>
<View style={styles.aiIconWrap}>
<Ionicons name="sparkles" size={32} color="#8b5cf6" />
</View>
<Text style={styles.aiTitle}>智能分析你的星标仓库</Text>
<Text style={styles.aiDesc}>
自动分类代码质量评估技术趋势分析等强大功能即将上线
</Text>
<View style={styles.comingBadge}>
<Text style={styles.comingBadgeText}>即将推出</Text>
</View>
</View>
</TouchableOpacity>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>关于</Text>
<TouchableOpacity
style={styles.card}
onPress={handleCheckUpdate}
disabled={checkingUpdate}
activeOpacity={0.7}
>
<View style={styles.aboutRow}>
<View style={styles.aboutLeft}>
<Ionicons name="information-circle-outline" size={20} color="#555" />
<Text style={styles.aboutLabel}>版本</Text>
</View>
<View style={styles.aboutRight}>
{checkingUpdate ? (
<ActivityIndicator size="small" color="#0366d6" />
) : updateInfo && updateInfo.hasUpdate ? (
<View style={styles.updateBadge}>
<Text style={styles.updateBadgeText}>有新版本</Text>
</View>
) : null}
<Text style={styles.aboutValue}>1.0.0</Text>
<Ionicons name="chevron-forward" size={18} color="#ccc" />
</View>
</View>
</TouchableOpacity>
</View>
<View style={{ height: 40 }} />
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: '#fff',
paddingTop: Platform.OS === 'ios' ? 50 : 40,
paddingBottom: 12,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: '#e8e8e8',
},
backBtn: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
headerTitle: {
fontSize: 18,
fontWeight: '600',
color: '#1a1a1a',
},
scroll: {
flex: 1,
},
section: {
marginTop: 20,
paddingHorizontal: 16,
},
sectionTitle: {
fontSize: 13,
fontWeight: '600',
color: '#888',
marginBottom: 8,
marginLeft: 4,
textTransform: 'uppercase',
},
card: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
elevation: 1,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: 8,
},
rowLabel: {
fontSize: 15,
fontWeight: '500',
color: '#333',
},
tokenText: {
fontSize: 13,
color: '#888',
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
marginBottom: 12,
},
tokenActions: {
flexDirection: 'row',
gap: 10,
},
tokenBtn: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#0366d6',
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 8,
gap: 4,
},
tokenBtnOutline: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: '#0366d6',
},
tokenBtnDanger: {
backgroundColor: '#d73a4a',
},
tokenBtnText: {
color: '#fff',
fontSize: 13,
fontWeight: '500',
},
tokenBtnTextOutline: {
color: '#0366d6',
},
statsRow: {
flexDirection: 'row',
gap: 12,
},
statCard: {
flex: 1,
backgroundColor: '#fff',
borderRadius: 12,
padding: 20,
alignItems: 'center',
elevation: 1,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
},
statNumber: {
fontSize: 28,
fontWeight: '700',
color: '#1a1a1a',
marginTop: 8,
},
statLabel: {
fontSize: 13,
color: '#888',
marginTop: 4,
},
aiPlaceholder: {
alignItems: 'center',
paddingVertical: 10,
},
aiIconWrap: {
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#f5f0ff',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 12,
},
aiTitle: {
fontSize: 16,
fontWeight: '600',
color: '#333',
marginBottom: 6,
},
aiDesc: {
fontSize: 13,
color: '#999',
textAlign: 'center',
lineHeight: 18,
marginBottom: 12,
},
comingBadge: {
backgroundColor: '#f5f0ff',
paddingHorizontal: 14,
paddingVertical: 4,
borderRadius: 12,
},
comingBadgeText: {
fontSize: 12,
color: '#8b5cf6',
fontWeight: '500',
},
aboutRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
aboutLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
aboutRight: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
aboutLabel: {
fontSize: 15,
color: '#333',
},
aboutValue: {
fontSize: 15,
color: '#888',
},
updateBadge: {
backgroundColor: '#fff3cd',
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 8,
},
updateBadgeText: {
fontSize: 11,
color: '#856404',
fontWeight: '500',
},
});

127
services/categorizer.js Normal file
View File

@@ -0,0 +1,127 @@
// 语言 → 分类 的映射表
// 根据仓库主要编程语言直接映射到对应的分类
const languageCategoryMap = {
'HTML': 'Web应用', 'CSS': 'Web应用', 'JavaScript': 'Web应用',
'TypeScript': 'Web应用', 'Vue': 'Web应用', 'Svelte': 'Web应用',
'PHP': 'Web应用', 'Ruby': 'Web应用', 'Elixir': 'Web应用',
'Python': 'AI/机器学习', 'Jupyter Notebook': 'AI/机器学习',
'R': '数据分析', 'Julia': '数据分析',
'Java': '移动应用', 'Kotlin': '移动应用', 'Dart': '移动应用',
'Swift': '移动应用', 'Objective-C': '移动应用',
'C++': '桌面应用', 'C#': '桌面应用', 'Go': '桌面应用',
'Rust': '桌面应用', 'C': '桌面应用', 'Zig': '桌面应用',
'SQL': '数据库', 'PLpgSQL': '数据库',
'Shell': '开发工具', 'Dockerfile': '开发工具', 'Makefile': '开发工具',
'CMake': '开发工具', 'PowerShell': '开发工具',
};
// 关键词规则:每个分类关联一组关键词,与仓库名称/描述匹配
// 关键词越长代表越精确,匹配时按关键词长度加权
const keywordRules = [
{ category: 'AI/机器学习', keywords: ['ai', 'artificial intelligence', 'machine learning', 'deep learning', 'neural', 'llm', 'gpt', 'transformer', 'pytorch', 'tensorflow', 'keras', 'openai', 'langchain', 'rag', 'embedding', 'stable diffusion', 'huggingface', 'nlp', 'computer vision', 'reinforcement learning', 'generative'] },
{ category: 'Web应用', keywords: ['web', 'website', 'api', 'rest', 'graphql', 'frontend', 'backend', 'fullstack', 'react', 'next.js', 'nuxt', 'express', 'django', 'flask', 'spring', 'laravel', 'rails', 'http', 'server', 'middleware', 'cors', 'websocket'] },
{ category: '移动应用', keywords: ['mobile', 'android', 'ios', 'flutter', 'react native', 'swiftui', 'jetpack', 'compose', 'app', 'pwa'] },
{ category: '桌面应用', keywords: ['desktop', 'electron', 'tauri', 'qt', 'gtk', 'winui', 'wpf', 'cli', 'terminal', 'tui'] },
{ category: '数据库', keywords: ['database', 'sql', 'nosql', 'redis', 'postgresql', 'mysql', 'mongodb', 'sqlite', 'cassandra', 'dynamodb', 'elasticsearch', 'orm', 'prisma', 'sequelize'] },
{ category: '开发工具', keywords: ['devtool', 'developer tool', 'ide', 'editor', 'vscode', 'plugin', 'extension', 'compiler', 'linter', 'formatter', 'debugger', 'docker', 'kubernetes', 'k8s', 'ci/cd', 'github action', 'automation', 'scaffold', 'boilerplate', 'template', 'sdk', 'library', 'framework', 'package'] },
{ category: '安全工具', keywords: ['security', 'hack', 'penetration', 'vulnerability', 'exploit', 'encrypt', 'decrypt', 'auth', 'oauth', 'jwt', 'firewall', 'malware', 'ransomware', 'cve', 'bug bounty', 'sast', 'dast'] },
{ category: '游戏', keywords: ['game', 'gaming', 'unity', 'unreal', 'godot', 'sprite', 'animation', 'physics engine', '3d', 'webgl', 'opengl', 'vulkan', 'shader'] },
{ category: '设计工具', keywords: ['design', 'ui', 'ux', 'figma', 'sketch', 'svg', 'icon', 'font', 'typography', 'color', 'theme', 'animation', 'css', 'tailwind', 'bootstrap', 'material'] },
{ category: '效率工具', keywords: ['productivity', 'utility', 'tool', 'workflow', 'automation', 'clipboard', 'note', 'todo', 'calendar', 'password', 'manager', 'sync'] },
{ category: '教育学习', keywords: ['tutorial', 'course', 'learn', 'education', 'documentation', 'book', 'cheatsheet', 'roadmap', 'example', 'demo', 'guide', 'awesome', 'awesome-'] },
{ category: '社交网络', keywords: ['social', 'chat', 'messaging', 'forum', 'community', 'blog', 'feed', 'timeline', 'notification', 'follower', 'friend'] },
{ category: '数据分析', keywords: ['data', 'analytics', 'visualization', 'dashboard', 'chart', 'statistics', 'etl', 'big data', 'spark', 'hadoop', 'pandas', 'numpy', 'scipy', 'tableau', 'metabase', 'superset'] },
];
// 文本标准化:转小写 + 去除首尾空格
function normalize(text) {
return (text || '').toLowerCase().trim();
}
// 关键词评分:遍历关键词列表,匹配到的每个关键词按长度累加得分
function scoreByKeywords(text, keywords) {
const normalized = normalize(text);
let score = 0;
for (const kw of keywords) {
if (normalized.includes(kw)) {
score += kw.length;
}
}
return score;
}
// 对单个仓库进行自动分类(返回最多 3 个最匹配的分类)
export function autoCategorize(repo, categories) {
const lang = repo.language || '';
const name = repo.full_name || '';
const desc = repo.description || '';
const results = [];
// 遍历所有关键词规则进行评分
for (const rule of keywordRules) {
let score = 0;
// 仓库名称权重 2 倍,描述权重 1 倍
score += scoreByKeywords(name, rule.keywords) * 2;
score += scoreByKeywords(desc, rule.keywords);
// 如果编程语言直接匹配当前分类,加基础分
const langMatch = languageCategoryMap[lang];
if (langMatch === rule.category) {
score += 5;
}
if (score > 0) {
const matched = categories.find(c => c.name === rule.category);
if (matched) {
results.push({ category: matched, score });
}
}
}
// 关键词没匹配到:回退到仅按语言映射分类
if (results.length === 0) {
const langCategory = languageCategoryMap[lang];
if (langCategory) {
const matched = categories.find(c => c.name === langCategory);
if (matched) return [matched];
}
return [];
}
// 按得分排序,取最高分的 60% 以上的分类(最多 3 个)
results.sort((a, b) => b.score - a.score);
const maxScore = results[0].score;
return results
.filter(r => r.score >= maxScore * 0.6)
.map(r => r.category)
.slice(0, 3);
}
// 批量处理多个仓库的自动分类
export function autoCategorizeAll(repos, categories) {
const results = [];
for (const repo of repos) {
const matchedCategories = autoCategorize(repo, categories);
results.push({
repoId: repo.id,
repoName: repo.full_name,
categoryIds: matchedCategories.map(c => c.id),
categoryNames: matchedCategories.map(c => c.name),
});
}
return results;
}
// 执行自动分类的完整流程:获取未分类仓库 → 分类匹配 → 批量保存
export async function runAutoCategorize(categories, getUncategorizedRepos, batchSetRepoCategories) {
const uncategorized = await getUncategorizedRepos();
if (uncategorized.length === 0) return 0;
const results = autoCategorizeAll(uncategorized, categories);
const assignments = results.filter(r => r.categoryIds.length > 0).map(r => ({
repoId: r.repoId,
categoryIds: r.categoryIds,
}));
if (assignments.length === 0) return 0;
return await batchSetRepoCategories(assignments);
}

399
services/database.js Normal file
View File

@@ -0,0 +1,399 @@
import * as SQLite from 'expo-sqlite';
let db = null;
let initPromise = null;
// 初始化数据库:建表 + 首次运行时插入默认分类
export async function initDatabase() {
if (initPromise) return initPromise;
initPromise = (async () => {
db = await SQLite.openDatabaseAsync('github_stars.db');
// 分类表
await db.execAsync(
`CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
color TEXT NOT NULL DEFAULT '#0366d6',
sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);`
);
// 星标仓库表
await db.execAsync(
`CREATE TABLE IF NOT EXISTS starred_repos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER UNIQUE NOT NULL,
full_name TEXT NOT NULL,
description TEXT,
html_url TEXT NOT NULL,
language TEXT,
stargazers_count INTEGER DEFAULT 0,
forks_count INTEGER DEFAULT 0,
owner_avatar_url TEXT,
owner_login TEXT,
default_branch TEXT DEFAULT 'main',
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);`
);
// 兼容旧数据库:给已有表添加 default_branch 列(如已存在则静默忽略)
try {
await db.execAsync('ALTER TABLE starred_repos ADD COLUMN default_branch TEXT DEFAULT \'main\'');
} catch (e) {
// Column already exists, ignore
}
// 仓库-分类 多对多关联表
await db.execAsync(
`CREATE TABLE IF NOT EXISTS repo_categories (
repo_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
PRIMARY KEY (repo_id, category_id),
FOREIGN KEY (repo_id) REFERENCES starred_repos(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
);`
);
// 应用设置 KV 表
await db.execAsync(
`CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT
);`
);
// 首次运行时插入 13 个默认分类
const existingCount = await db.getFirstAsync('SELECT COUNT(*) AS count FROM categories');
if (existingCount.count === 0) {
const defaultCategories = [
['Web应用', '#0366d6'],
['移动应用', '#28a745'],
['桌面应用', '#d73a4a'],
['数据库', '#6f42c1'],
['AI/机器学习', '#e36209'],
['开发工具', '#19b5a0'],
['安全工具', '#f0ad4e'],
['游戏', '#8b5cf6'],
['设计工具', '#1abc9c'],
['效率工具', '#3498db'],
['教育学习', '#9b59b6'],
['社交网络', '#e67e22'],
['数据分析', '#2c3e50'],
];
for (let i = 0; i < defaultCategories.length; i++) {
await db.runAsync(
'INSERT OR IGNORE INTO categories (name, color, sort_order) VALUES (?, ?, ?)',
defaultCategories[i][0],
defaultCategories[i][1],
i
);
}
}
})();
return initPromise;
}
// 安全的字符串转换,防止 null/undefined 存入 DB
function safeStr(value) {
if (value == null) return null;
if (typeof value === 'string') return value;
return String(value);
}
// 安全的整数转换
function safeInt(value) {
if (value == null) return 0;
const n = Number(value);
return Number.isFinite(n) ? Math.floor(n) : 0;
}
// === 通用设置KV 存储) ===
export async function getSetting(key) {
await initDatabase();
const row = await db.getFirstAsync(
'SELECT value FROM app_settings WHERE key = ?',
safeStr(key)
);
return row?.value ?? null;
}
export async function setSetting(key, value) {
await initDatabase();
await db.runAsync(
'INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)',
safeStr(key),
safeStr(value)
);
}
export async function deleteSetting(key) {
await initDatabase();
await db.runAsync('DELETE FROM app_settings WHERE key = ?', safeStr(key));
}
// === Token 相关 ===
export async function getGitHubToken() {
return await getSetting('github_token');
}
export async function setGitHubToken(token) {
await setSetting('github_token', token);
}
export async function clearGitHubToken() {
await deleteSetting('github_token');
}
// === 分类 CRUD ===
export async function getAllCategories() {
await initDatabase();
return await db.getAllAsync(
'SELECT * FROM categories ORDER BY sort_order ASC, created_at ASC'
);
}
export async function addCategory(name, color = '#0366d6') {
await initDatabase();
const maxOrder = await db.getFirstAsync(
'SELECT COALESCE(MAX(sort_order), -1) + 1 AS next_order FROM categories'
);
const result = await db.runAsync(
'INSERT INTO categories (name, color, sort_order) VALUES (?, ?, ?)',
safeStr(name),
safeStr(color),
safeInt(maxOrder?.next_order ?? 0)
);
return result.lastInsertRowId;
}
export async function updateCategory(id, name, color) {
await initDatabase();
await db.runAsync(
'UPDATE categories SET name = ?, color = ? WHERE id = ?',
safeStr(name),
safeStr(color),
safeInt(id)
);
}
export async function deleteCategory(id) {
await initDatabase();
await db.runAsync('DELETE FROM repo_categories WHERE category_id = ?', safeInt(id));
await db.runAsync('DELETE FROM categories WHERE id = ?', safeInt(id));
}
// === 仓库数据同步 ===
// 将 GitHub API 返回的仓库列表保存到本地已存在则跳过INSERT OR IGNORE
// 并更新 default_branch 字段以保持最新
export async function saveRepos(repos) {
await initDatabase();
let insertedCount = 0;
const errors = [];
for (const repo of repos) {
try {
await db.runAsync(
`INSERT OR IGNORE INTO starred_repos
(repo_id, full_name, description, html_url, language, stargazers_count, forks_count, owner_avatar_url, owner_login, default_branch)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
safeInt(repo?.id),
safeStr(repo?.full_name),
safeStr(repo?.description),
safeStr(repo?.html_url),
safeStr(repo?.language),
safeInt(repo?.stargazers_count),
safeInt(repo?.forks_count),
safeStr(repo?.owner?.avatar_url),
safeStr(repo?.owner?.login),
safeStr(repo?.default_branch || 'main')
);
// 更新已有记录的 default_branch之前同步的可能没有此字段
await db.runAsync(
`UPDATE starred_repos SET default_branch = ? WHERE repo_id = ? AND default_branch != ?`,
safeStr(repo?.default_branch || 'main'),
safeInt(repo?.id),
safeStr(repo?.default_branch || 'main')
);
insertedCount++;
} catch (e) {
errors.push({ repo: repo?.full_name, error: e.message });
}
}
if (errors.length > 0) {
console.warn('saveRepos errors:', errors.slice(0, 5));
}
return insertedCount;
}
// === 仓库查询(含分类关联) ===
// 通过 LEFT JOIN 将仓库与其所属分类合并成一条结果
// 最终用 formatRepoRows 按 repo_id 聚合多个分类
// 将单行中的分类信息cat_id:::cat_name:::cat_color解析为对象
function formatRepoRow(row) {
if (!row) return row;
const categories = row.categories_raw
? row.categories_raw.split('|||').filter(Boolean).map(part => {
const [id, name, color] = part.split(':::');
return { id: Number(id), name, color };
})
: [];
return {
...row,
categories,
category_name: categories.length > 0 ? categories[0].name : null,
category_color: categories.length > 0 ? categories[0].color : null,
};
}
// 将 LEFT JOIN 产生的多行合并,同一仓库的多个分类拼接到一个字段中
function formatRepoRows(rows) {
const map = new Map();
for (const row of rows) {
const key = row.repo_id;
if (!map.has(key)) {
map.set(key, { ...row, categories_raw: '' });
}
if (row.cat_id) {
const existing = map.get(key);
existing.categories_raw += `${row.cat_id}:::${row.cat_name || ''}:::${row.cat_color || ''}|||`;
}
}
return Array.from(map.values()).map(formatRepoRow);
}
// 基础 SELECT 语句,包含仓库字段 + 关联的分类字段
const REPO_SELECT =
`SELECT r.id, r.repo_id, r.full_name, r.description, r.html_url,
r.language, r.stargazers_count, r.forks_count,
r.owner_avatar_url, r.owner_login, r.default_branch, r.created_at,
rc.category_id AS cat_id, c.name AS cat_name, c.color AS cat_color`;
export async function getAllRepos() {
await initDatabase();
const rows = await db.getAllAsync(
`${REPO_SELECT}
FROM starred_repos r
LEFT JOIN repo_categories rc ON r.id = rc.repo_id
LEFT JOIN categories c ON rc.category_id = c.id
ORDER BY r.created_at DESC`
);
return formatRepoRows(rows);
}
export async function getReposByCategory(categoryId) {
await initDatabase();
const rows = await db.getAllAsync(
`${REPO_SELECT}
FROM starred_repos r
INNER JOIN repo_categories rc ON r.id = rc.repo_id
LEFT JOIN categories c ON rc.category_id = c.id
WHERE rc.category_id = ?
ORDER BY r.created_at DESC`,
safeInt(categoryId)
);
return formatRepoRows(rows);
}
export async function getUncategorizedRepos() {
await initDatabase();
const rows = await db.getAllAsync(
`${REPO_SELECT}
FROM starred_repos r
LEFT JOIN repo_categories rc ON r.id = rc.repo_id
LEFT JOIN categories c ON rc.category_id = c.id
WHERE rc.repo_id IS NULL
ORDER BY r.created_at DESC`
);
return formatRepoRows(rows);
}
// === 仓库-分类 关联操作 ===
export async function getRepoCategories(repoId) {
await initDatabase();
return await db.getAllAsync(
`SELECT c.id, c.name, c.color
FROM repo_categories rc
JOIN categories c ON rc.category_id = c.id
WHERE rc.repo_id = ?
ORDER BY c.sort_order ASC`,
safeInt(repoId)
);
}
// 先删后插:清除旧关联后重新设置(用于手动调整分类)
export async function setRepoCategories(repoId, categoryIds) {
await initDatabase();
await db.runAsync('DELETE FROM repo_categories WHERE repo_id = ?', safeInt(repoId));
for (const catId of categoryIds) {
if (catId == null) continue;
await db.runAsync(
'INSERT OR IGNORE INTO repo_categories (repo_id, category_id) VALUES (?, ?)',
safeInt(repoId),
safeInt(catId)
);
}
}
export async function addRepoCategory(repoId, categoryId) {
await initDatabase();
await db.runAsync(
'INSERT OR IGNORE INTO repo_categories (repo_id, category_id) VALUES (?, ?)',
safeInt(repoId),
safeInt(categoryId)
);
}
export async function removeRepoCategory(repoId, categoryId) {
await initDatabase();
await db.runAsync(
'DELETE FROM repo_categories WHERE repo_id = ? AND category_id = ?',
safeInt(repoId),
safeInt(categoryId)
);
}
// 批量设置仓库分类(用于自动分类引擎)
export async function batchSetRepoCategories(assignments) {
await initDatabase();
let count = 0;
for (const { repoId, categoryIds } of assignments) {
if (!categoryIds || categoryIds.length === 0) continue;
try {
for (const catId of categoryIds) {
await db.runAsync(
'INSERT OR IGNORE INTO repo_categories (repo_id, category_id) VALUES (?, ?)',
safeInt(repoId),
safeInt(catId)
);
}
count++;
} catch (e) {
console.warn('batchSetRepoCategories error:', repoId, e.message);
}
}
return count;
}
// === 统计查询 ===
export async function getRepoCountByCategory() {
await initDatabase();
return await db.getAllAsync(
`SELECT c.id, c.name, c.color, COUNT(rc.repo_id) AS repo_count
FROM categories c
LEFT JOIN repo_categories rc ON c.id = rc.category_id
GROUP BY c.id
ORDER BY c.sort_order ASC`
);
}
export async function getTotalRepoCount() {
await initDatabase();
const result = await db.getFirstAsync(
'SELECT COUNT(*) AS count FROM starred_repos'
);
return result?.count ?? 0;
}

154
services/github.js Normal file
View File

@@ -0,0 +1,154 @@
import Constants from 'expo-constants';
const GITHUB_API_URL = 'https://api.github.com';
// 用于检查更新的仓库地址,从 app.json extra 中读取
const APP_REPO = Constants.expoConfig?.extra?.appRepo || 'EchoZenith/GithubStarsManager-Android';
// 当前版本号从 app.json 中读取version 字段)
const CURRENT_VERSION = Constants.expoConfig?.version || '1.0.0';
// 自定义错误类型Token 过期或无效时抛出,供上层 UI 捕获后跳转 Token 输入页
class TokenExpiredError extends Error {
constructor(message) {
super(message);
this.name = 'TokenExpiredError';
}
}
export { TokenExpiredError };
// 构建 GitHub API 请求头
function buildHeaders(token) {
return {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json',
};
}
// 分页获取用户所有星标仓库(每页 100 条,自动翻页直到取完)
export async function fetchStarredRepos(token) {
if (!token) {
throw new TokenExpiredError('请先输入 GitHub Token');
}
let allRepos = [];
let page = 1;
let hasMore = true;
while (hasMore) {
const response = await fetch(
`${GITHUB_API_URL}/user/starred?per_page=100&page=${page}`,
{
method: 'GET',
headers: buildHeaders(token),
}
);
if (response.status === 401) {
throw new TokenExpiredError('GitHub Token 已过期或无效,请重新输入');
}
if (!response.ok) {
throw new Error(`GitHub API 错误!状态码: ${response.status}`);
}
const data = await response.json();
if (data.length === 0) {
hasMore = false;
} else {
allRepos = allRepos.concat(data);
page++;
}
}
return allRepos;
}
// 获取仓库的 README 原始内容raw 格式直接返回 markdown 文本)
export async function fetchReadme(token, fullName) {
if (!token) {
throw new TokenExpiredError('请先输入 GitHub Token');
}
const response = await fetch(
`${GITHUB_API_URL}/repos/${fullName}/readme`,
{
method: 'GET',
headers: {
...buildHeaders(token),
Accept: 'application/vnd.github.raw',
},
}
);
if (response.status === 401) {
throw new TokenExpiredError('GitHub Token 已过期或无效,请重新输入');
}
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`获取 README 失败!状态码: ${response.status}`);
}
return await response.text();
}
// 检查应用更新:对比当前版本与 GitHub Releases 最新版本号
export async function checkUpdate(token) {
try {
const headers = {
Accept: 'application/vnd.github.v3+json',
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(
`${GITHUB_API_URL}/repos/${APP_REPO}/releases/latest`,
{
method: 'GET',
headers,
}
);
if (response.status === 404) {
return { hasUpdate: false, error: null, message: '未找到发布版本' };
}
if (!response.ok) {
return { hasUpdate: false, error: '检查更新失败', message: null };
}
const data = await response.json();
const latestVersion = data.tag_name.replace(/^v/, '');
const currentParts = CURRENT_VERSION.split('.').map(Number);
const latestParts = latestVersion.split('.').map(Number);
// 逐段比较版本号major.minor.patch
let hasUpdate = false;
for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
const cur = currentParts[i] || 0;
const lat = latestParts[i] || 0;
if (lat > cur) { hasUpdate = true; break; }
if (lat < cur) break;
}
return {
hasUpdate,
currentVersion: CURRENT_VERSION,
latestVersion,
releaseUrl: data.html_url,
releaseName: data.name || data.tag_name,
releaseBody: data.body ? data.body.split('\n').slice(0, 5).join('\n') : '',
publishedAt: data.published_at,
error: null,
message: hasUpdate
? `发现新版本 v${latestVersion}`
: '已是最新版本',
};
} catch (e) {
return { hasUpdate: false, error: e.message || '网络错误', message: null };
}
}