feat(leetcode): 添加 LeetCode 解题进度展示

- 在 LeetCodeCard 中添加解题进度卡片,展示用户解题情况
- 在 SettingsScreen 中添加 LeetCode User Slug 配置项
- 更新 authService 以支持保存和加载 LeetCode User Slug
- 优化 LeetCode 数据获取逻辑,同时获取提交记录和解题进度
This commit is contained in:
高手 2025-06-09 19:40:42 +08:00
parent 7b1b7288eb
commit 7720d44658
3 changed files with 155 additions and 31 deletions

View File

@ -12,6 +12,7 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> { class _SettingsScreenState extends State<SettingsScreen> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _leetcodeController = TextEditingController(); final _leetcodeController = TextEditingController();
final _leetcodeUserSlugController = TextEditingController();
final _giteaController = TextEditingController(); final _giteaController = TextEditingController();
final _giteaUsernameController = TextEditingController(); final _giteaUsernameController = TextEditingController();
final _kodboxController = TextEditingController(); final _kodboxController = TextEditingController();
@ -25,6 +26,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override @override
void dispose() { void dispose() {
_leetcodeController.dispose(); _leetcodeController.dispose();
_leetcodeUserSlugController.dispose();
_giteaController.dispose(); _giteaController.dispose();
_giteaUsernameController.dispose(); _giteaUsernameController.dispose();
_kodboxController.dispose(); _kodboxController.dispose();
@ -38,6 +40,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
setState(() { setState(() {
_leetcodeController.text = _leetcodeController.text =
authService.credentials['leetcode_cookie'] ?? ''; authService.credentials['leetcode_cookie'] ?? '';
_leetcodeUserSlugController.text =
authService.credentials['leetcode_user_slug'] ?? '';
_giteaController.text = authService.credentials['gitea_token'] ?? ''; _giteaController.text = authService.credentials['gitea_token'] ?? '';
_giteaUsernameController.text = _giteaUsernameController.text =
authService.credentials['gitea_username'] ?? ''; authService.credentials['gitea_username'] ?? '';
@ -55,6 +59,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
_leetcodeController.text, _leetcodeController.text,
); );
} }
if (_leetcodeUserSlugController.text.isNotEmpty) {
await authService.saveConfigs(
'leetcode_user_slug',
_leetcodeUserSlugController.text,
);
}
if (_giteaController.text.isNotEmpty) { if (_giteaController.text.isNotEmpty) {
await authService.saveConfigs('gitea_token', _giteaController.text); await authService.saveConfigs('gitea_token', _giteaController.text);
} }
@ -99,6 +109,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
obscureText: true, obscureText: true,
), ),
const SizedBox(height: 16),
TextFormField(
controller: _leetcodeUserSlugController,
decoration: const InputDecoration(
labelText: 'User Slug',
hintText: '请输入 LeetCode User Slug',
border: OutlineInputBorder(),
),
),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),

View File

@ -18,12 +18,17 @@ class AuthService extends ChangeNotifier {
Future<void> loadConfigs() async { Future<void> loadConfigs() async {
final leetcodeCookie = await _storage.read(key: 'leetcode_cookie'); final leetcodeCookie = await _storage.read(key: 'leetcode_cookie');
final leetcodeUserSlug = await _storage.read(key: 'leetcode_user_slug');
final giteaToken = await _storage.read(key: 'gitea_token'); final giteaToken = await _storage.read(key: 'gitea_token');
final giteaUserName = await _storage.read(key: 'gitea_username'); final giteaUserName = await _storage.read(key: 'gitea_username');
final kodboxToken = await _storage.read(key: 'kodbox_token'); final kodboxToken = await _storage.read(key: 'kodbox_token');
if (leetcodeCookie != null) { if (leetcodeCookie != null) {
_credentials['leetcode_cookie'] = leetcodeCookie; _credentials['leetcode_cookie'] = leetcodeCookie;
} }
if (leetcodeUserSlug != null) {
_credentials['leetcode_user_slug'] = leetcodeUserSlug;
}
if (giteaToken != null) { if (giteaToken != null) {
_credentials['gitea_token'] = giteaToken; _credentials['gitea_token'] = giteaToken;
} }

View File

@ -14,10 +14,11 @@ class LeetCodeCard extends StatefulWidget {
class _LeetCodeCardState extends State<LeetCodeCard> { class _LeetCodeCardState extends State<LeetCodeCard> {
List<dynamic> _submissions = []; List<dynamic> _submissions = [];
Map<String, dynamic>? _progress;
bool _isLoading = false; bool _isLoading = false;
String? _error; String? _error;
Future<void> _fetchSubmissions() async { Future<void> _fetchData() async {
setState(() { setState(() {
_isLoading = true; _isLoading = true;
_error = null; _error = null;
@ -26,12 +27,17 @@ class _LeetCodeCardState extends State<LeetCodeCard> {
try { try {
final authService = Provider.of<AuthService>(context, listen: false); final authService = Provider.of<AuthService>(context, listen: false);
final cookie = authService.credentials['leetcode_cookie']; final cookie = authService.credentials['leetcode_cookie'];
final userSlug = authService.credentials['leetcode_user_slug'];
if (cookie == null || cookie.isEmpty) { if (cookie == null || cookie.isEmpty) {
throw Exception('未配置 LeetCode Cookie'); throw Exception('未配置 LeetCode Cookie');
} }
if (userSlug == null || userSlug.isEmpty) {
throw Exception('未配置 LeetCode User Slug');
}
final response = await http.post( //
final submissionsResponse = await http.post(
Uri.parse('https://leetcode.cn/graphql/noj-go/'), Uri.parse('https://leetcode.cn/graphql/noj-go/'),
headers: {'Content-Type': 'application/json', 'Cookie': cookie}, headers: {'Content-Type': 'application/json', 'Cookie': cookie},
body: jsonEncode({ body: jsonEncode({
@ -49,19 +55,56 @@ class _LeetCodeCardState extends State<LeetCodeCard> {
} }
} }
''', ''',
'variables': {'userSlug': 'keen-wilsonv9s'}, 'variables': {'userSlug': userSlug},
'operationName': 'recentAcSubmissions', 'operationName': 'recentAcSubmissions',
}), }),
); );
if (response.statusCode == 200) { //
final data = jsonDecode(response.body); final progressResponse = await http.post(
Uri.parse('https://leetcode.cn/graphql/'),
headers: {'Content-Type': 'application/json', 'Cookie': cookie},
body: jsonEncode({
'query': '''
query userProfileUserQuestionProgressV2(\$userSlug: String!) {
userProfileUserQuestionProgressV2(userSlug: \$userSlug) {
numAcceptedQuestions {
count
difficulty
}
numFailedQuestions {
count
difficulty
}
numUntouchedQuestions {
count
difficulty
}
userSessionBeatsPercentage {
difficulty
percentage
}
totalQuestionBeatsPercentage
}
}
''',
'variables': {'userSlug': userSlug},
'operationName': 'userProfileUserQuestionProgressV2',
}),
);
if (submissionsResponse.statusCode == 200 &&
progressResponse.statusCode == 200) {
final submissionsData = jsonDecode(submissionsResponse.body);
final progressData = jsonDecode(progressResponse.body);
setState(() { setState(() {
_submissions = data['data']['recentACSubmissions']; _submissions = submissionsData['data']['recentACSubmissions'];
_progress = progressData['data']['userProfileUserQuestionProgressV2'];
_isLoading = false; _isLoading = false;
}); });
} else { } else {
throw Exception('请求失败: ${response.statusCode}'); throw Exception('请求失败: ${submissionsResponse.statusCode}');
} }
} catch (e) { } catch (e) {
setState(() { setState(() {
@ -74,7 +117,50 @@ class _LeetCodeCardState extends State<LeetCodeCard> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_fetchSubmissions(); _fetchData();
}
Widget _buildProgressCard() {
if (_progress == null) return const SizedBox.shrink();
final acceptedQuestions = _progress!['numAcceptedQuestions'] as List;
final totalBeatsPercentage =
_progress!['totalQuestionBeatsPercentage'] as double;
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'解题进度',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
...acceptedQuestions.map((q) {
final difficulty = q['difficulty'] as String;
final count = q['count'] as int;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [Text(difficulty), Text('$count')],
),
);
}).toList(),
const Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('击败用户'),
Text('${totalBeatsPercentage.toStringAsFixed(1)}%'),
],
),
],
),
),
);
} }
@override @override
@ -94,7 +180,7 @@ class _LeetCodeCardState extends State<LeetCodeCard> {
), ),
IconButton( IconButton(
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
onPressed: _isLoading ? null : _fetchSubmissions, onPressed: _isLoading ? null : _fetchData,
), ),
], ],
), ),
@ -115,31 +201,45 @@ class _LeetCodeCardState extends State<LeetCodeCard> {
), ),
), ),
) )
else if (_submissions.isEmpty)
const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Text('暂无提交记录'),
),
)
else else
Expanded( Expanded(
child: ListView.builder( child: SingleChildScrollView(
itemCount: _submissions.length, child: Column(
itemBuilder: (context, index) { children: [
final submission = _submissions[index]; _buildProgressCard(),
final question = submission['question']; const SizedBox(height: 16),
if (_submissions.isEmpty)
const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Text('暂无提交记录'),
),
)
else
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _submissions.length,
itemBuilder: (context, index) {
final submission = _submissions[index];
final question = submission['question'];
return ListTile( return ListTile(
title: Text( title: Text(
question['translatedTitle'] ?? question['title'], question['translatedTitle'] ??
), question['title'],
subtitle: Text( ),
'提交时间: ${timeago.format(DateTime.fromMillisecondsSinceEpoch(submission['submitTime'] * 1000), locale: 'zh_CN')}', subtitle: Text(
), '提交时间: ${timeago.format(DateTime.fromMillisecondsSinceEpoch(submission['submitTime'] * 1000), locale: 'zh_CN')}',
trailing: Text('#${question['questionFrontendId']}'), ),
); trailing: Text(
}, '#${question['questionFrontendId']}',
),
);
},
),
],
),
), ),
), ),
], ],