feat: initial commit - GitHub Stars Manager
This commit is contained in:
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal 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
111
App.js
Normal 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
110
README.md
Normal 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
36
app.json
Normal 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
BIN
assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
BIN
assets/favicon.png
Normal file
BIN
assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 888 B |
BIN
assets/icon.png
Normal file
BIN
assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
BIN
assets/splash-icon.png
Normal file
BIN
assets/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
134
components/RepoItem.js
Normal file
134
components/RepoItem.js
Normal 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
294
components/TokenInput.js
Normal 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
8
index.js
Normal 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
9438
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal 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
|
||||||
|
}
|
||||||
380
screens/CategoryManageScreen.js
Normal file
380
screens/CategoryManageScreen.js
Normal 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
405
screens/HomeScreen.js
Normal 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
659
screens/RepoDetailScreen.js
Normal 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 `[](${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 ``;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 () 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
455
screens/SettingsScreen.js
Normal 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
127
services/categorizer.js
Normal 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
399
services/database.js
Normal 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
154
services/github.js
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user