Initial commit: Flutter 无书应用项目
This commit is contained in:
525
lib/views/profile/level/api.md
Normal file
525
lib/views/profile/level/api.md
Normal file
@@ -0,0 +1,525 @@
|
||||
# 诗词答题 API 文档
|
||||
|
||||
## 基础信息
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 基础URL | `/api.php` |
|
||||
| 返回格式 | JSON |
|
||||
| 编码 | UTF-8 |
|
||||
| 请求方式 | **GET / POST 都支持** |
|
||||
|
||||
### 请求方式说明
|
||||
|
||||
| 接口 | 推荐方式 | 原因 |
|
||||
|------|----------|------|
|
||||
| 获取题目 (question) | **GET** | 读取操作,简单可缓存 |
|
||||
| 下一题 (next) | **GET** | 读取操作 |
|
||||
| 获取新题 (fetch) | **GET** | 读取操作 |
|
||||
| 提交答案 (answer) | **POST** | 提交操作,更规范 |
|
||||
| 获取提示 (hint) | **GET** | 读取操作 |
|
||||
| 题目列表 (list) | **GET** | 读取操作 |
|
||||
| 刷新缓存 (refresh) | **GET** | 管理操作 |
|
||||
| 状态统计 (stats) | **GET** | 读取操作 |
|
||||
|
||||
## 通用返回格式
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| code | int | 状态码,0=成功,其他=错误 |
|
||||
| msg | string | 提示信息 |
|
||||
| data | object | 返回数据 |
|
||||
|
||||
---
|
||||
|
||||
## 接口列表
|
||||
|
||||
### 1. 获取题目
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api.php?action=question&id=0
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| action | string | 否 | 默认为 question |
|
||||
| id | int | 否 | 题目ID,默认0 |
|
||||
|
||||
**返回**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"id": 0,
|
||||
"total": 10,
|
||||
"question": "欲把西湖比西子,\"________\"",
|
||||
"author": "苏轼",
|
||||
"type": "江南",
|
||||
"grade": "小学",
|
||||
"dynasty": "宋朝",
|
||||
"options": [
|
||||
{"index": 1, "content": "山色空蒙雨亦奇"},
|
||||
{"index": 2, "content": "淡妆浓抹总相宜"},
|
||||
{"index": 3, "content": "门前流水尚能西"},
|
||||
{"index": 4, "content": "拄杖无时夜叩门"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 获取下一题(自动进度)
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api.php?action=next
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| action | string | 是 | 固定为 next |
|
||||
|
||||
**说明**
|
||||
- **无需传 id 参数**,系统自动记住当前进度(使用 Session)
|
||||
- 每次刷新自动跳到下一题
|
||||
- 如果已经是最后一题,自动回到第 0 题(循环)
|
||||
|
||||
**使用方式**
|
||||
| 刷新次数 | 返回的 id |
|
||||
|----------|-----------|
|
||||
| 第 1 次 | 1 |
|
||||
| 第 2 次 | 2 |
|
||||
| 第 3 次 | 3 |
|
||||
| ... | ... |
|
||||
| 第 62 次 | 0 (循环) |
|
||||
|
||||
**返回**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"total": 62,
|
||||
"question": "人生得意须尽欢,\"__________\"",
|
||||
"author": "李白",
|
||||
"type": "豪放",
|
||||
"grade": "高中",
|
||||
"dynasty": "唐朝",
|
||||
"options": [...],
|
||||
"prev_id": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 刷新获取新题(推荐)
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api.php?action=fetch
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| action | string | 是 | 固定为 fetch |
|
||||
|
||||
**说明**
|
||||
- **每次刷新都从百度 API 获取新题目**
|
||||
- 新题目自动写入本地缓存(去重)
|
||||
- 返回本次获取的随机一题
|
||||
- API 失败时自动降级使用本地缓存
|
||||
|
||||
**返回**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"id": null,
|
||||
"total": 65,
|
||||
"question": "人生得意须尽欢,\"__________\"",
|
||||
"author": "李白",
|
||||
"type": "豪放",
|
||||
"grade": "高中",
|
||||
"dynasty": "唐朝",
|
||||
"options": [...],
|
||||
"from_cache": false,
|
||||
"new_questions": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**额外返回字段**
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| from_cache | bool | 是否来自本地缓存 |
|
||||
| new_questions | int | 本次新增题目数量 |
|
||||
|
||||
---
|
||||
|
||||
### 4. 提交答案
|
||||
|
||||
**请求方式 1:GET(简单)**
|
||||
```
|
||||
GET /api.php?action=answer&id=0&answer=2
|
||||
```
|
||||
|
||||
**请求方式 2:POST(推荐)**
|
||||
```
|
||||
POST /api.php
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
action=answer&id=0&answer=2
|
||||
```
|
||||
|
||||
或使用 JSON:
|
||||
```
|
||||
POST /api.php
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"action": "answer",
|
||||
"id": 0,
|
||||
"answer": "2"
|
||||
}
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| action | string | 是 | 固定为 answer |
|
||||
| id | int | 是 | 题目ID |
|
||||
| answer | string | 是 | 答案序号(1-4) |
|
||||
|
||||
**返回**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"id": 0,
|
||||
"correct": true,
|
||||
"your_answer": "2",
|
||||
"correct_answer": "2",
|
||||
"next_id": 1,
|
||||
"has_next": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. 获取提示
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api.php?action=hint&id=0
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| action | string | 是 | 固定为 hint |
|
||||
| id | int | 是 | 题目ID |
|
||||
|
||||
**返回**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"id": 0,
|
||||
"hint": "这是首描写江南的诗,你在小学学过它。",
|
||||
"author": "苏轼",
|
||||
"dynasty": "宋朝"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. 获取题目列表
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api.php?action=list
|
||||
```
|
||||
|
||||
**返回**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"total": 10,
|
||||
"list": [
|
||||
{
|
||||
"id": 0,
|
||||
"question": "欲把西湖比西子,\"________\"...",
|
||||
"author": "苏轼",
|
||||
"dynasty": "宋朝"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. 刷新缓存
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api.php?action=refresh
|
||||
```
|
||||
|
||||
**说明**
|
||||
- 强制从百度 API 获取最新题目
|
||||
- 合并到本地缓存(去重)
|
||||
|
||||
**返回**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"refreshed": true,
|
||||
"total": 15
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 缓存机制
|
||||
|
||||
| 配置 | 值 | 说明 |
|
||||
|------|------|------|
|
||||
| 缓存文件 | `data/questions.json` | 本地JSON文件 |
|
||||
| 过期时间 | **永不过期** | 永久保存 |
|
||||
| 去重方式 | 按 `question_content` | 相同题目不重复存储 |
|
||||
| 降级策略 | API失败自动使用本地缓存 | 原始URL失效也能使用 |
|
||||
| API 限频 | 5秒内只请求一次 | 防止并发请求百度 |
|
||||
|
||||
### 缓存文件结构
|
||||
|
||||
```json
|
||||
{
|
||||
"updated": "2024-01-01 12:00:00",
|
||||
"count": 10,
|
||||
"questions": [...]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 性能优化
|
||||
|
||||
### 已实现优化
|
||||
|
||||
| 优化项 | 说明 |
|
||||
|--------|------|
|
||||
| 静态变量缓存 | 同一请求内多次读取只加载一次文件 |
|
||||
| API 请求锁 | 5秒内多人请求只发一次 API |
|
||||
| 原子写入 | 先写临时文件再 rename,防止数据损坏 |
|
||||
| 去重优化 | 只在写入时去重,读取时不处理 |
|
||||
| 超时控制 | API 超时 5 秒,连接超时 3 秒 |
|
||||
|
||||
### 性能预估
|
||||
|
||||
| 场景 | 响应时间 |
|
||||
|------|----------|
|
||||
| 读取本地缓存 | ~2-5ms |
|
||||
| API 请求成功 | ~200-500ms |
|
||||
| API 限频降级 | ~1ms(直接读缓存) |
|
||||
| 100人并发 | 无压力(读缓存为主) |
|
||||
|
||||
### 查看状态
|
||||
|
||||
```
|
||||
GET /api.php?action=stats
|
||||
```
|
||||
|
||||
返回:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"total_questions": 149,
|
||||
"cache_file_size": "40.5 KB",
|
||||
"last_updated": "2026-03-29 05:04:04",
|
||||
"memory_usage": "256 KB"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 错误码
|
||||
|
||||
| code | 说明 |
|
||||
|------|------|
|
||||
| 0 | 成功 |
|
||||
| 400 | 参数错误 |
|
||||
| 404 | 题目不存在 |
|
||||
|
||||
---
|
||||
|
||||
## 使用示例
|
||||
|
||||
### JavaScript
|
||||
```javascript
|
||||
// 获取第一题
|
||||
fetch('/api.php?action=question&id=0')
|
||||
.then(r => r.json())
|
||||
.then(d => console.log(d.data.question));
|
||||
|
||||
// 获取下一题(自动进度,无需传 id)
|
||||
fetch('/api.php?action=next')
|
||||
.then(r => r.json())
|
||||
.then(d => console.log(d.data.id, d.data.question));
|
||||
|
||||
// 提交答案
|
||||
fetch('/api.php?action=answer&id=0&answer=2')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if(d.data.correct) {
|
||||
console.log('回答正确!');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Flutter/Dart
|
||||
```dart
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
final dio = Dio();
|
||||
|
||||
// 获取题目
|
||||
Future<Map> getQuestion(int id) async {
|
||||
final res = await dio.get('/api.php', queryParameters: {
|
||||
'action': 'question',
|
||||
'id': id,
|
||||
});
|
||||
return res.data['data'];
|
||||
}
|
||||
|
||||
// 获取下一题(自动进度,无需传 id)
|
||||
Future<Map> getNextQuestion() async {
|
||||
final res = await dio.get('/api.php', queryParameters: {
|
||||
'action': 'next',
|
||||
});
|
||||
return res.data['data'];
|
||||
}
|
||||
|
||||
// 提交答案(POST 方式)
|
||||
Future<bool> checkAnswer(int id, String answer) async {
|
||||
final res = await dio.post('/api.php', data: {
|
||||
'action': 'answer',
|
||||
'id': id,
|
||||
'answer': answer,
|
||||
});
|
||||
return res.data['data']['correct'];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## App 集成建议
|
||||
|
||||
### 方案 1:App 自己管理进度(推荐)
|
||||
|
||||
使用 `?action=question` 接口,App 完全控制进度:
|
||||
|
||||
```dart
|
||||
class QuizApi {
|
||||
int _currentId = 0;
|
||||
int _total = 0;
|
||||
|
||||
Future<Map> getQuestion() async {
|
||||
final res = await dio.get('/api.php', queryParameters: {
|
||||
'action': 'question',
|
||||
'id': _currentId,
|
||||
});
|
||||
final data = res.data['data'];
|
||||
_total = data['total'] ?? 0;
|
||||
return data;
|
||||
}
|
||||
|
||||
Future<Map> submitAnswer(String answer) async {
|
||||
final res = await dio.post('/api.php', data: {
|
||||
'action': 'answer',
|
||||
'id': _currentId,
|
||||
'answer': answer,
|
||||
});
|
||||
return res.data['data'];
|
||||
}
|
||||
|
||||
void nextQuestion() {
|
||||
_currentId++;
|
||||
if (_currentId >= _total) {
|
||||
_currentId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
int get currentId => _currentId;
|
||||
}
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- 不依赖 Session,App 完全控制进度
|
||||
- 可以随时跳转任意题目
|
||||
- 适合多端同步
|
||||
|
||||
---
|
||||
|
||||
### 方案 2:使用自动进度
|
||||
|
||||
使用 `?action=next` 接口,API 自动管理:
|
||||
|
||||
```dart
|
||||
Future<Map> getNextQuestion() async {
|
||||
final res = await dio.get('/api.php', queryParameters: {
|
||||
'action': 'next',
|
||||
});
|
||||
return res.data['data'];
|
||||
}
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- 简单,无需管理 ID
|
||||
- 自动循环
|
||||
|
||||
**缺点**:
|
||||
- 依赖 Session,需要保持 cookie
|
||||
- 无法自由跳转题目
|
||||
|
||||
---
|
||||
|
||||
### 方案 3:不断获取新题
|
||||
|
||||
使用 `?action=fetch` 接口,每次都从百度获取新题:
|
||||
|
||||
```dart
|
||||
Future<Map> getNewQuestion() async {
|
||||
final res = await dio.get('/api.php', queryParameters: {
|
||||
'action': 'fetch',
|
||||
});
|
||||
return res.data['data'];
|
||||
}
|
||||
```
|
||||
|
||||
**适用场景**:
|
||||
- 想要不断扩展题库
|
||||
- 用户每次刷新都可能看到新题
|
||||
706
lib/views/profile/level/distinguish.dart
Normal file
706
lib/views/profile/level/distinguish.dart
Normal file
@@ -0,0 +1,706 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../constants/app_constants.dart';
|
||||
import '../../../controllers/sqlite_storage_controller.dart';
|
||||
|
||||
/// 时间: 2026-03-28
|
||||
/// 功能: 答题记录页面
|
||||
/// 介绍: 显示用户的诗词答题记录列表,包括题目、标签、是否答对等信息
|
||||
/// 最新变化: 添加统计数据弹窗功能
|
||||
|
||||
class DistinguishPage extends StatefulWidget {
|
||||
const DistinguishPage({super.key});
|
||||
|
||||
@override
|
||||
State<DistinguishPage> createState() => _DistinguishPageState();
|
||||
}
|
||||
|
||||
class _DistinguishPageState extends State<DistinguishPage> {
|
||||
// 答题记录列表
|
||||
List<Map<String, dynamic>> _answerRecords = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
// 统计数据
|
||||
int _totalQuestions = 0;
|
||||
int _correctAnswers = 0;
|
||||
int _wrongAnswers = 0;
|
||||
double _correctRate = 0.0;
|
||||
double _wrongRate = 0.0;
|
||||
double _averageTime = 0.0;
|
||||
int _hintCount = 0;
|
||||
int _skipCount = 0;
|
||||
String _poetryLevel = '未知';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadAnswerRecords();
|
||||
}
|
||||
|
||||
/// 加载答题记录
|
||||
Future<void> _loadAnswerRecords() async {
|
||||
try {
|
||||
// 获取答题记录列表
|
||||
List<String> records = await SQLiteStorageController.getStringList(
|
||||
'poetryAnswerRecords',
|
||||
defaultValue: [],
|
||||
);
|
||||
|
||||
// 解析记录
|
||||
_answerRecords = records
|
||||
.map((record) {
|
||||
try {
|
||||
return jsonDecode(record) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
print('解析记录失败: $e');
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
})
|
||||
.where((record) => record.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
// 按时间倒序排列
|
||||
_answerRecords.sort((a, b) {
|
||||
final timeA = a['answerTime'] ?? '';
|
||||
final timeB = b['answerTime'] ?? '';
|
||||
return timeB.compareTo(timeA);
|
||||
});
|
||||
|
||||
// 加载统计数据
|
||||
await _loadStatistics();
|
||||
} catch (e) {
|
||||
print('加载答题记录失败: $e');
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载统计数据
|
||||
Future<void> _loadStatistics() async {
|
||||
try {
|
||||
_totalQuestions = await SQLiteStorageController.getInt(
|
||||
'totalQuestions',
|
||||
defaultValue: 0,
|
||||
);
|
||||
_correctAnswers = await SQLiteStorageController.getInt(
|
||||
'correctAnswers',
|
||||
defaultValue: 0,
|
||||
);
|
||||
_wrongAnswers = await SQLiteStorageController.getInt(
|
||||
'wrongAnswers',
|
||||
defaultValue: 0,
|
||||
);
|
||||
int totalTime = await SQLiteStorageController.getInt(
|
||||
'totalTime',
|
||||
defaultValue: 0,
|
||||
);
|
||||
_hintCount = await SQLiteStorageController.getInt(
|
||||
'hintCount',
|
||||
defaultValue: 0,
|
||||
);
|
||||
_skipCount = await SQLiteStorageController.getInt(
|
||||
'skipCount',
|
||||
defaultValue: 0,
|
||||
);
|
||||
|
||||
// 计算正确率和错误率
|
||||
if (_totalQuestions > 0) {
|
||||
_correctRate = (_correctAnswers / _totalQuestions) * 100;
|
||||
_wrongRate = (_wrongAnswers / _totalQuestions) * 100;
|
||||
_averageTime = totalTime / _totalQuestions;
|
||||
}
|
||||
|
||||
// 计算诗词水平
|
||||
_calculatePoetryLevel();
|
||||
} catch (e) {
|
||||
print('加载统计数据失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算诗词水平
|
||||
void _calculatePoetryLevel() {
|
||||
if (_totalQuestions < 5) {
|
||||
_poetryLevel = '未知(答题数量不足)';
|
||||
} else if (_correctRate >= 90) {
|
||||
_poetryLevel = '诗词大师 🏆';
|
||||
} else if (_correctRate >= 70) {
|
||||
_poetryLevel = '诗词达人 ⭐';
|
||||
} else if (_correctRate >= 50) {
|
||||
_poetryLevel = '诗词爱好者 📚';
|
||||
} else {
|
||||
_poetryLevel = '诗词初学者 🌱';
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示统计弹窗
|
||||
void _showStatisticsDialog() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => _buildStatisticsSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建统计弹窗
|
||||
Widget _buildStatisticsSheet() {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 顶部拖动条
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 标题
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppConstants.primaryColor.withAlpha(20),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.analytics_outlined,
|
||||
color: AppConstants.primaryColor,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'答题记录',
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 统计卡片
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppConstants.primaryColor.withAlpha(10),
|
||||
Colors.white,
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppConstants.primaryColor.withAlpha(30),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildStatRow('已答题', '$_totalQuestions 题'),
|
||||
_buildStatRow('正确', '$_correctAnswers 题', isGreen: true),
|
||||
_buildStatRow('错误', '$_wrongAnswers 题', isRed: true),
|
||||
_buildStatRow(
|
||||
'正确率',
|
||||
'${_correctRate.toStringAsFixed(1)}%',
|
||||
isGreen: _correctRate >= 60,
|
||||
),
|
||||
_buildStatRow(
|
||||
'错误率',
|
||||
'${_wrongRate.toStringAsFixed(1)}%',
|
||||
isRed: _wrongRate > 40,
|
||||
),
|
||||
_buildStatRow(
|
||||
'平均用时',
|
||||
'${_averageTime.toStringAsFixed(1)} 秒',
|
||||
),
|
||||
_buildStatRow('提示次数', '$_hintCount 次'),
|
||||
_buildStatRow('跳过次数', '$_skipCount 次'),
|
||||
const Divider(height: 24),
|
||||
_buildStatRow('诗词水平', _poetryLevel, isHighlight: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 复制按钮
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppConstants.primaryColor,
|
||||
AppConstants.primaryColor.withAlpha(200),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppConstants.primaryColor.withAlpha(80),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ElevatedButton(
|
||||
onPressed: _copyStatisticsToClipboard,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.copy, color: Colors.white, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'复制数据发送给AI评估',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建统计行
|
||||
Widget _buildStatRow(
|
||||
String label,
|
||||
String value, {
|
||||
bool isGreen = false,
|
||||
bool isRed = false,
|
||||
bool isHighlight = false,
|
||||
}) {
|
||||
Color valueColor = Colors.black87;
|
||||
if (isGreen) valueColor = Colors.green;
|
||||
if (isRed) valueColor = Colors.red;
|
||||
if (isHighlight) valueColor = AppConstants.primaryColor;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: TextStyle(fontSize: 15, color: Colors.grey[700])),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: valueColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 复制统计数据到剪贴板
|
||||
Future<void> _copyStatisticsToClipboard() async {
|
||||
String content =
|
||||
'''
|
||||
🎓 诗词答题记录评估报告
|
||||
|
||||
📊 基础数据统计
|
||||
━━━━━━━━━━━━━━━━
|
||||
• 已答题数:$_totalQuestions 题
|
||||
• 正确数量:$_correctAnswers 题
|
||||
• 错误数量:$_wrongAnswers 题
|
||||
• 正确率:${_correctRate.toStringAsFixed(1)}%
|
||||
• 错误率:${_wrongRate.toStringAsFixed(1)}%
|
||||
• 平均用时:${_averageTime.toStringAsFixed(1)} 秒/题
|
||||
|
||||
📈 辅助数据
|
||||
━━━━━━━━━━━━━━━━
|
||||
• 提示次数:$_hintCount 次
|
||||
• 跳过次数:$_skipCount 次
|
||||
|
||||
🏆 诗词水平评估
|
||||
━━━━━━━━━━━━━━━━
|
||||
$_poetryLevel
|
||||
|
||||
💡 AI评估提示
|
||||
━━━━━━━━━━━━━━━━
|
||||
请根据以上答题数据,综合评估我的诗词水平,并给出:
|
||||
1. 当前诗词水平的详细分析
|
||||
2. 薄弱环节和改进建议
|
||||
3. 推荐学习的诗词类型或朝代
|
||||
4. 适合的诗词学习路径建议
|
||||
|
||||
感谢您的评估!
|
||||
''';
|
||||
|
||||
await Clipboard.setData(ClipboardData(text: content));
|
||||
|
||||
// 关闭弹窗
|
||||
Navigator.pop(context);
|
||||
|
||||
// 显示复制成功提示
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('统计数据已复制,可发送给AI进行评估'),
|
||||
duration: Duration(seconds: 2),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 清空答题记录
|
||||
Future<void> _clearRecords() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('确认清空'),
|
||||
content: const Text('确定要清空所有答题记录吗?此操作不可恢复。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('确定', style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
try {
|
||||
await SQLiteStorageController.setStringList('poetryAnswerRecords', []);
|
||||
setState(() {
|
||||
_answerRecords = [];
|
||||
});
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('答题记录已清空')));
|
||||
} catch (e) {
|
||||
print('清空记录失败: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 格式化时间
|
||||
String _formatTime(String? isoTime) {
|
||||
if (isoTime == null || isoTime.isEmpty) return '未知时间';
|
||||
try {
|
||||
final dateTime = DateTime.parse(isoTime);
|
||||
return DateFormat('MM-dd HH:mm').format(dateTime);
|
||||
} catch (e) {
|
||||
return '未知时间';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'答题记录',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20),
|
||||
),
|
||||
backgroundColor: AppConstants.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
flexibleSpace: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppConstants.primaryColor,
|
||||
AppConstants.primaryColor.withAlpha(180),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
// 统计按钮
|
||||
IconButton(
|
||||
onPressed: _showStatisticsDialog,
|
||||
icon: const Icon(Icons.analytics_outlined),
|
||||
tooltip: '查看统计',
|
||||
),
|
||||
// 清空按钮
|
||||
if (_answerRecords.isNotEmpty)
|
||||
IconButton(
|
||||
onPressed: _clearRecords,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: '清空记录',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Container(
|
||||
color: Colors.grey[50],
|
||||
child: SafeArea(
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _answerRecords.isEmpty
|
||||
? _buildEmptyView()
|
||||
: _buildRecordList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建空记录视图
|
||||
Widget _buildEmptyView() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.menu_book_outlined, size: 80, color: Colors.grey[300]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'暂无答题记录',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: Colors.grey[500],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'快去答题吧!',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[400]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建记录列表
|
||||
Widget _buildRecordList() {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _answerRecords.length,
|
||||
itemBuilder: (context, index) {
|
||||
final record = _answerRecords[index];
|
||||
return _buildRecordCard(record, index);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建记录卡片
|
||||
Widget _buildRecordCard(Map<String, dynamic> record, int index) {
|
||||
final question = record['question'] ?? '未知题目';
|
||||
final author = record['author'] ?? '未知作者';
|
||||
final tags = (record['tags'] as List<dynamic>?)?.cast<String>() ?? [];
|
||||
final isCorrect = record['isCorrect'] ?? false;
|
||||
final answerTime = _formatTime(record['answerTime']);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withAlpha(10),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题和答对/答错标识
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 序号
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: AppConstants.primaryColor.withAlpha(20),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppConstants.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 题目
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
question,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'—— $author',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 答对/答错标识
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isCorrect
|
||||
? Colors.green.withAlpha(20)
|
||||
: Colors.red.withAlpha(20),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isCorrect ? Icons.check_circle : Icons.cancel,
|
||||
size: 14,
|
||||
color: isCorrect ? Colors.green : Colors.red,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
isCorrect ? '答对' : '答错',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isCorrect ? Colors.green : Colors.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 标签和时间
|
||||
Row(
|
||||
children: [
|
||||
// 标签
|
||||
Expanded(
|
||||
child: tags.isNotEmpty
|
||||
? Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: tags.map((tag) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppConstants.primaryColor.withAlpha(15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: AppConstants.primaryColor.withAlpha(
|
||||
50,
|
||||
),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
tag,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppConstants.primaryColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
)
|
||||
: Text(
|
||||
'暂无标签',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[400],
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 时间
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.access_time, size: 12, color: Colors.grey[400]),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
answerTime,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
123
lib/views/profile/level/flow-anim.dart
Normal file
123
lib/views/profile/level/flow-anim.dart
Normal file
@@ -0,0 +1,123 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../constants/app_constants.dart';
|
||||
|
||||
/// 流动边框装饰器
|
||||
class FlowingBorderDecoration extends Decoration {
|
||||
final Animation<double> animation;
|
||||
final Color color;
|
||||
final double width;
|
||||
|
||||
const FlowingBorderDecoration({
|
||||
required this.animation,
|
||||
required this.color,
|
||||
this.width = 4.0,
|
||||
});
|
||||
|
||||
@override
|
||||
BoxPainter createBoxPainter([VoidCallback? onChanged]) {
|
||||
return _FlowingBorderPainter(
|
||||
animation: animation,
|
||||
color: color,
|
||||
width: width,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FlowingBorderPainter extends BoxPainter {
|
||||
final Animation<double> animation;
|
||||
final Color color;
|
||||
final double width;
|
||||
|
||||
_FlowingBorderPainter({
|
||||
required this.animation,
|
||||
required this.color,
|
||||
required this.width,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
|
||||
final size = configuration.size!;
|
||||
final rect = offset & size;
|
||||
final radius = Radius.circular(16);
|
||||
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = width
|
||||
..style = PaintingStyle.stroke
|
||||
..shader = LinearGradient(
|
||||
colors: [color.withAlpha(0), color, color.withAlpha(0)],
|
||||
stops: const [0.0, 0.5, 1.0],
|
||||
transform: GradientRotation(2 * pi * animation.value),
|
||||
).createShader(rect);
|
||||
|
||||
canvas.drawRRect(RRect.fromRectAndRadius(rect, radius), paint);
|
||||
}
|
||||
}
|
||||
|
||||
/// 流动边框容器
|
||||
class FlowingBorderContainer extends StatefulWidget {
|
||||
final Widget child;
|
||||
final Duration duration;
|
||||
final Color color;
|
||||
final double width;
|
||||
final bool autoStart;
|
||||
|
||||
const FlowingBorderContainer({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.duration = const Duration(seconds: 10),
|
||||
this.color = AppConstants.primaryColor,
|
||||
this.width = 4.0,
|
||||
this.autoStart = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FlowingBorderContainer> createState() => _FlowingBorderContainerState();
|
||||
}
|
||||
|
||||
class _FlowingBorderContainerState extends State<FlowingBorderContainer>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _animation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(duration: widget.duration, vsync: this);
|
||||
_animation = Tween<double>(
|
||||
begin: 0,
|
||||
end: 1,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.linear));
|
||||
|
||||
if (widget.autoStart) {
|
||||
_controller.repeat();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(widget.width),
|
||||
decoration: FlowingBorderDecoration(
|
||||
animation: _animation,
|
||||
color: widget.color,
|
||||
width: widget.width,
|
||||
),
|
||||
child: widget.child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
613
lib/views/profile/level/level-jilu.dart
Normal file
613
lib/views/profile/level/level-jilu.dart
Normal file
@@ -0,0 +1,613 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../../utils/http/http_client.dart';
|
||||
import '../../../services/network_listener_service.dart';
|
||||
|
||||
/// 时间: 2026-03-28
|
||||
/// 功能: 诗词答题逻辑管理器
|
||||
/// 介绍: 处理诗词答题的网络请求和业务逻辑
|
||||
/// 最新变化: 添加离线模式支持,离线时从本地缓存加载题目
|
||||
|
||||
class PoetryLevelManager with NetworkListenerMixin {
|
||||
int _currentIndex = 0;
|
||||
int _total = 0;
|
||||
List<int> _shuffledIds = [];
|
||||
final Random _random = Random();
|
||||
|
||||
// 离线模式相关
|
||||
bool _isOfflineMode = false;
|
||||
List<Map<String, dynamic>> _offlineQuestions = [];
|
||||
bool _isOnline = true;
|
||||
|
||||
/// 获取当前题目 ID
|
||||
int get currentId =>
|
||||
_shuffledIds.isNotEmpty ? _shuffledIds[_currentIndex] : 0;
|
||||
|
||||
/// 获取当前题目索引
|
||||
int get currentIndex => _currentIndex;
|
||||
|
||||
/// 获取题目总数
|
||||
int get total => _total;
|
||||
|
||||
/// 是否已初始化
|
||||
bool get isInitialized =>
|
||||
_shuffledIds.isNotEmpty || _offlineQuestions.isNotEmpty;
|
||||
|
||||
/// 是否为离线模式
|
||||
bool get isOfflineMode => _isOfflineMode;
|
||||
|
||||
/// 检查网络状态
|
||||
Future<bool> _checkNetworkStatus() async {
|
||||
try {
|
||||
final networkStatus = NetworkListenerService().currentStatus;
|
||||
return networkStatus != NetworkStatus.error;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否为离线状态(用户设置)
|
||||
Future<bool> _checkOfflineSetting() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return !(prefs.getBool('personal_card_online') ?? true);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否有缓存数据
|
||||
Future<bool> _hasCachedData() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final quizData = prefs.getStringList('offline_quiz_data') ?? [];
|
||||
return quizData.isNotEmpty;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载离线缓存数据
|
||||
Future<void> _loadOfflineCache() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final quizData = prefs.getStringList('offline_quiz_data') ?? [];
|
||||
|
||||
_offlineQuestions = [];
|
||||
for (final item in quizData) {
|
||||
try {
|
||||
final map = _parseStringToMap(item);
|
||||
if (map.isNotEmpty) {
|
||||
_offlineQuestions.add(map);
|
||||
}
|
||||
} catch (e) {
|
||||
print('解析离线数据失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
if (_offlineQuestions.isNotEmpty) {
|
||||
_total = _offlineQuestions.length;
|
||||
_shuffledIds = List.generate(_total, (index) => index);
|
||||
_shuffleList(_shuffledIds);
|
||||
_currentIndex = 0;
|
||||
}
|
||||
} catch (e) {
|
||||
print('加载离线缓存失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析字符串为Map
|
||||
Map<String, dynamic> _parseStringToMap(String str) {
|
||||
final result = <String, dynamic>{};
|
||||
|
||||
print('开始解析字符串: $str');
|
||||
|
||||
try {
|
||||
// 尝试JSON解析
|
||||
final jsonMap = jsonDecode(str);
|
||||
if (jsonMap is Map<String, dynamic>) {
|
||||
print('JSON解析成功: $jsonMap');
|
||||
return jsonMap;
|
||||
}
|
||||
} catch (e) {
|
||||
print('JSON解析失败: $e');
|
||||
// 不是JSON格式,尝试其他解析方式
|
||||
}
|
||||
|
||||
// 尝试解析 Map 字符串格式
|
||||
if (str.startsWith('{') && str.endsWith('}')) {
|
||||
final content = str.substring(1, str.length - 1);
|
||||
final pairs = content.split(', ');
|
||||
|
||||
for (final pair in pairs) {
|
||||
final colonIndex = pair.indexOf(': ');
|
||||
if (colonIndex > 0) {
|
||||
final key = pair.substring(0, colonIndex).replaceAll('"', '');
|
||||
var value = pair.substring(colonIndex + 2).replaceAll('"', '');
|
||||
|
||||
// 尝试转换数字
|
||||
if (int.tryParse(value) != null) {
|
||||
result[key] = int.parse(value);
|
||||
} else if (double.tryParse(value) != null) {
|
||||
result[key] = double.parse(value);
|
||||
} else if (value == 'true') {
|
||||
result[key] = true;
|
||||
} else if (value == 'false') {
|
||||
result[key] = false;
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print('解析结果: $result');
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 初始化题目列表(打乱顺序)
|
||||
Future<PoetryInitResult> initializeQuestions() async {
|
||||
try {
|
||||
// 检查网络状态
|
||||
_isOnline = await _checkNetworkStatus();
|
||||
final isOfflineSetting = await _checkOfflineSetting();
|
||||
|
||||
// 判断是否使用离线模式
|
||||
if (!_isOnline || isOfflineSetting) {
|
||||
_isOfflineMode = true;
|
||||
|
||||
// 检查是否有缓存数据
|
||||
final hasCache = await _hasCachedData();
|
||||
if (!hasCache) {
|
||||
return PoetryInitResult(
|
||||
success: false,
|
||||
message: '离线模式下无缓存数据,请先下载或检查网络连接',
|
||||
needDownload: true,
|
||||
);
|
||||
}
|
||||
|
||||
// 加载离线缓存
|
||||
await _loadOfflineCache();
|
||||
|
||||
if (_offlineQuestions.isEmpty) {
|
||||
return PoetryInitResult(
|
||||
success: false,
|
||||
message: '离线缓存数据为空,请先下载数据',
|
||||
needDownload: true,
|
||||
);
|
||||
}
|
||||
|
||||
return PoetryInitResult(
|
||||
success: true,
|
||||
message: '已加载离线缓存 ${_offlineQuestions.length} 条题目',
|
||||
isOffline: true,
|
||||
);
|
||||
}
|
||||
|
||||
// 在线模式
|
||||
_isOfflineMode = false;
|
||||
|
||||
// 先调用 fetch 获取新题
|
||||
final fetchResponse = await HttpClient.get(
|
||||
'poe/api.php',
|
||||
queryParameters: {'action': 'fetch'},
|
||||
);
|
||||
|
||||
if (fetchResponse.isSuccess) {
|
||||
final fetchData = fetchResponse.jsonData;
|
||||
if (fetchData['code'] == 0) {
|
||||
_total = fetchData['data']['total'] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 再调用 refresh 刷新缓存
|
||||
final refreshResponse = await HttpClient.get(
|
||||
'poe/api.php',
|
||||
queryParameters: {'action': 'refresh'},
|
||||
);
|
||||
|
||||
if (refreshResponse.isSuccess) {
|
||||
final refreshData = refreshResponse.jsonData;
|
||||
if (refreshData['code'] == 0) {
|
||||
_total = refreshData['data']['total'] ?? _total;
|
||||
}
|
||||
}
|
||||
|
||||
// 生成并打乱题目 ID 列表
|
||||
_generateShuffledIds();
|
||||
_currentIndex = 0;
|
||||
|
||||
return PoetryInitResult(
|
||||
success: true,
|
||||
message: '已加载在线题库 $_total 条题目',
|
||||
isOffline: false,
|
||||
);
|
||||
} catch (e) {
|
||||
print('初始化题目失败: $e');
|
||||
|
||||
// 尝试加载离线缓存
|
||||
final hasCache = await _hasCachedData();
|
||||
if (hasCache) {
|
||||
_isOfflineMode = true;
|
||||
await _loadOfflineCache();
|
||||
|
||||
if (_offlineQuestions.isNotEmpty) {
|
||||
return PoetryInitResult(
|
||||
success: true,
|
||||
message: '网络异常,已加载离线缓存 ${_offlineQuestions.length} 条题目',
|
||||
isOffline: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return PoetryInitResult(
|
||||
success: false,
|
||||
message: '初始化题目失败: $e',
|
||||
needDownload: !hasCache,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成并打乱题目 ID 列表
|
||||
void _generateShuffledIds() {
|
||||
_shuffledIds = List.generate(_total, (index) => index);
|
||||
_shuffleList(_shuffledIds);
|
||||
}
|
||||
|
||||
/// 打乱列表顺序(Fisher-Yates 算法)
|
||||
void _shuffleList(List<int> list) {
|
||||
for (int i = list.length - 1; i > 0; i--) {
|
||||
int j = _random.nextInt(i + 1);
|
||||
int temp = list[i];
|
||||
list[i] = list[j];
|
||||
list[j] = temp;
|
||||
}
|
||||
}
|
||||
|
||||
/// 下一题
|
||||
void nextQuestion() {
|
||||
if (_shuffledIds.isEmpty && _offlineQuestions.isEmpty) return;
|
||||
|
||||
_currentIndex++;
|
||||
if (_currentIndex >= _total) {
|
||||
_currentIndex = 0;
|
||||
// 循环时重新打乱顺序
|
||||
if (_shuffledIds.isNotEmpty) {
|
||||
_shuffleList(_shuffledIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 上一题
|
||||
void previousQuestion() {
|
||||
if (_shuffledIds.isEmpty && _offlineQuestions.isEmpty) return;
|
||||
|
||||
_currentIndex--;
|
||||
if (_currentIndex < 0) {
|
||||
_currentIndex = _total - 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载题目
|
||||
Future<PoetryQuestionResult> loadQuestion() async {
|
||||
if (isNetworkLoading('load_question')) {
|
||||
return PoetryQuestionResult(success: false, message: '正在加载中...');
|
||||
}
|
||||
|
||||
if (_shuffledIds.isEmpty && _offlineQuestions.isEmpty) {
|
||||
return PoetryQuestionResult(success: false, message: '题目列表未初始化');
|
||||
}
|
||||
|
||||
// 离线模式:从缓存加载
|
||||
if (_isOfflineMode && _offlineQuestions.isNotEmpty) {
|
||||
final questionIndex = _shuffledIds[_currentIndex];
|
||||
if (questionIndex >= 0 && questionIndex < _offlineQuestions.length) {
|
||||
final questionData = _offlineQuestions[questionIndex];
|
||||
|
||||
// 构建标准格式的题目数据
|
||||
final formattedData = _formatOfflineQuestion(questionData);
|
||||
|
||||
return PoetryQuestionResult(
|
||||
success: true,
|
||||
data: formattedData,
|
||||
questionId: questionIndex,
|
||||
questionIndex: _currentIndex,
|
||||
isOffline: true,
|
||||
);
|
||||
}
|
||||
|
||||
return PoetryQuestionResult(success: false, message: '题目索引越界');
|
||||
}
|
||||
|
||||
startNetworkLoading('load_question');
|
||||
|
||||
try {
|
||||
final id = currentId;
|
||||
final response = await HttpClient.get(
|
||||
'poe/api.php',
|
||||
queryParameters: {'action': 'question', 'id': id.toString()},
|
||||
);
|
||||
|
||||
if (response.isSuccess) {
|
||||
final data = response.jsonData;
|
||||
if (data['code'] == 0) {
|
||||
final questionData = data['data'];
|
||||
return PoetryQuestionResult(
|
||||
success: true,
|
||||
data: questionData,
|
||||
questionId: id,
|
||||
questionIndex: _currentIndex,
|
||||
isOffline: false,
|
||||
);
|
||||
} else {
|
||||
return PoetryQuestionResult(
|
||||
success: false,
|
||||
message: data['msg'] ?? '获取题目失败',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return PoetryQuestionResult(
|
||||
success: false,
|
||||
message: '网络错误: ${response.statusCode}',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return PoetryQuestionResult(success: false, message: '加载失败: $e');
|
||||
} finally {
|
||||
endNetworkLoading('load_question');
|
||||
}
|
||||
}
|
||||
|
||||
/// 格式化离线题目数据
|
||||
Map<String, dynamic> _formatOfflineQuestion(Map<String, dynamic> data) {
|
||||
print('格式化离线题目,原始数据: $data');
|
||||
|
||||
// 检查是否已经是标准格式
|
||||
if (data.containsKey('question') && data.containsKey('options')) {
|
||||
final options = data['options'];
|
||||
print('发现options字段,类型: ${options.runtimeType}, 值: $options');
|
||||
// 确保options是List类型
|
||||
if (options is List) {
|
||||
print('options已经是List类型,直接返回');
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试从缓存数据中提取字段
|
||||
final result = <String, dynamic>{
|
||||
'id': data['id'] ?? 0,
|
||||
'question': data['question'] ?? data['question_content'] ?? '未知题目',
|
||||
'author': data['author'] ?? '未知作者',
|
||||
'type': data['type'] ?? '',
|
||||
'grade': data['grade'] ?? '',
|
||||
'dynasty': data['dynasty'] ?? '',
|
||||
'options': <Map<String, dynamic>>[],
|
||||
};
|
||||
|
||||
print('构建基础数据: $result');
|
||||
|
||||
// 尝试解析options字段
|
||||
dynamic optionsData = data['options'];
|
||||
if (optionsData != null) {
|
||||
print('开始解析options,类型: ${optionsData.runtimeType}');
|
||||
if (optionsData is List) {
|
||||
// 已经是List,直接使用
|
||||
result['options'] = optionsData;
|
||||
print('options是List,直接使用');
|
||||
} else if (optionsData is String) {
|
||||
// 是String,尝试解析为List
|
||||
try {
|
||||
print('options是String,尝试解析: $optionsData');
|
||||
final parsedOptions = jsonDecode(optionsData);
|
||||
print('解析结果类型: ${parsedOptions.runtimeType}');
|
||||
if (parsedOptions is List) {
|
||||
result['options'] = parsedOptions;
|
||||
print('options解析成功为List');
|
||||
}
|
||||
} catch (e) {
|
||||
print('解析options字符串失败: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print('当前options: ${result['options']}');
|
||||
|
||||
// 如果没有有效的options,尝试从其他字段构建
|
||||
if ((result['options'] as List).isEmpty) {
|
||||
print('options为空,尝试从其他字段构建');
|
||||
final options = <Map<String, dynamic>>[];
|
||||
for (int i = 1; i <= 4; i++) {
|
||||
final optionKey = 'option_$i';
|
||||
if (data.containsKey(optionKey)) {
|
||||
options.add({'index': i, 'content': data[optionKey]});
|
||||
print('从$optionKey构建选项');
|
||||
}
|
||||
}
|
||||
result['options'] = options;
|
||||
}
|
||||
|
||||
print('最终格式化结果: $result');
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 提交答案
|
||||
Future<PoetryAnswerResult> submitAnswer(int questionId, int answer) async {
|
||||
// 离线模式:本地验证答案
|
||||
if (_isOfflineMode && _offlineQuestions.isNotEmpty) {
|
||||
if (questionId >= 0 && questionId < _offlineQuestions.length) {
|
||||
final questionData = _offlineQuestions[questionId];
|
||||
final correctAnswer =
|
||||
questionData['correct_answer'] ??
|
||||
questionData['answer'] ??
|
||||
questionData['correct'];
|
||||
|
||||
// 比较答案
|
||||
bool isCorrect = false;
|
||||
if (correctAnswer != null) {
|
||||
if (correctAnswer is int) {
|
||||
isCorrect = correctAnswer == answer;
|
||||
} else if (correctAnswer is String) {
|
||||
isCorrect = correctAnswer == answer.toString();
|
||||
}
|
||||
}
|
||||
|
||||
return PoetryAnswerResult(
|
||||
success: true,
|
||||
message: isCorrect ? '回答正确!' : '回答错误',
|
||||
isCorrect: isCorrect,
|
||||
nextQuestion: null,
|
||||
);
|
||||
}
|
||||
|
||||
return PoetryAnswerResult(success: false, message: '题目不存在');
|
||||
}
|
||||
|
||||
startNetworkLoading('submit_answer');
|
||||
|
||||
try {
|
||||
final response = await HttpClient.get(
|
||||
'poe/api.php',
|
||||
queryParameters: {
|
||||
'action': 'answer',
|
||||
'id': questionId.toString(),
|
||||
'answer': answer.toString(),
|
||||
},
|
||||
);
|
||||
|
||||
if (response.isSuccess) {
|
||||
final data = response.jsonData;
|
||||
if (data['code'] == 0) {
|
||||
return PoetryAnswerResult(
|
||||
success: true,
|
||||
message: data['msg'],
|
||||
isCorrect: data['data']['correct'] == true,
|
||||
nextQuestion: data['data']['has_next'] == true
|
||||
? {'id': data['data']['next_id']}
|
||||
: null,
|
||||
);
|
||||
} else {
|
||||
return PoetryAnswerResult(
|
||||
success: false,
|
||||
message: data['msg'] ?? '提交失败',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return PoetryAnswerResult(
|
||||
success: false,
|
||||
message: '网络错误: ${response.statusCode}',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return PoetryAnswerResult(success: false, message: '提交失败: $e');
|
||||
} finally {
|
||||
endNetworkLoading('submit_answer');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取提示
|
||||
Future<PoetryHintResult> getHint(int questionId) async {
|
||||
// 离线模式:从缓存数据获取提示
|
||||
if (_isOfflineMode && _offlineQuestions.isNotEmpty) {
|
||||
if (questionId >= 0 && questionId < _offlineQuestions.length) {
|
||||
final questionData = _offlineQuestions[questionId];
|
||||
final hint =
|
||||
questionData['hint'] ??
|
||||
'作者: ${questionData['author'] ?? '未知'},朝代: ${questionData['dynasty'] ?? '未知'}';
|
||||
|
||||
return PoetryHintResult(success: true, message: hint);
|
||||
}
|
||||
|
||||
return PoetryHintResult(success: false, message: '题目不存在');
|
||||
}
|
||||
|
||||
startNetworkLoading('get_hint');
|
||||
|
||||
try {
|
||||
final response = await HttpClient.get(
|
||||
'poe/api.php',
|
||||
queryParameters: {'action': 'hint', 'id': questionId.toString()},
|
||||
);
|
||||
|
||||
if (response.isSuccess) {
|
||||
final data = response.jsonData;
|
||||
if (data['code'] == 0) {
|
||||
return PoetryHintResult(success: true, message: data['data']['hint']);
|
||||
} else {
|
||||
return PoetryHintResult(
|
||||
success: false,
|
||||
message: data['msg'] ?? '获取提示失败',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return PoetryHintResult(
|
||||
success: false,
|
||||
message: '网络错误: ${response.statusCode}',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return PoetryHintResult(success: false, message: '获取提示失败: $e');
|
||||
} finally {
|
||||
endNetworkLoading('get_hint');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 初始化结果
|
||||
class PoetryInitResult {
|
||||
final bool success;
|
||||
final String? message;
|
||||
final bool isOffline;
|
||||
final bool needDownload;
|
||||
|
||||
PoetryInitResult({
|
||||
required this.success,
|
||||
this.message,
|
||||
this.isOffline = false,
|
||||
this.needDownload = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// 题目加载结果
|
||||
class PoetryQuestionResult {
|
||||
final bool success;
|
||||
final String? message;
|
||||
final Map<String, dynamic>? data;
|
||||
final int questionId;
|
||||
final int questionIndex;
|
||||
final bool isOffline;
|
||||
|
||||
PoetryQuestionResult({
|
||||
required this.success,
|
||||
this.message,
|
||||
this.data,
|
||||
this.questionId = 0,
|
||||
this.questionIndex = 0,
|
||||
this.isOffline = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// 答案提交结果
|
||||
class PoetryAnswerResult {
|
||||
final bool success;
|
||||
final String? message;
|
||||
final bool isCorrect;
|
||||
final Map<String, dynamic>? nextQuestion;
|
||||
|
||||
PoetryAnswerResult({
|
||||
required this.success,
|
||||
this.message,
|
||||
this.isCorrect = false,
|
||||
this.nextQuestion,
|
||||
});
|
||||
}
|
||||
|
||||
/// 提示获取结果
|
||||
class PoetryHintResult {
|
||||
final bool success;
|
||||
final String? message;
|
||||
|
||||
PoetryHintResult({required this.success, this.message});
|
||||
}
|
||||
1370
lib/views/profile/level/poetry.dart
Normal file
1370
lib/views/profile/level/poetry.dart
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user