Kaynağa Gözat

docs: update client README for new features

nonvioce 3 ay önce
ebeveyn
işleme
733a823004

+ 39 - 125
tiku-client/README.md

@@ -1,165 +1,79 @@
-# 题库PLUS 客户端
+# 某人的题库 - 客户端
 
 
-C/S 架构数学题库管理系统 — Flutter 跨平台客户端(Android + Web)。
+高中数学题库管理系统 Flutter 客户端(Android)。
 
 
 ## 构建
 ## 构建
 
 
-### 环境要求
-
-- Flutter SDK ≥ 3.44
-- Android SDK 36+(compileSdk 36)
-- JDK 17
-
-### 调试构建
-
 ```bash
 ```bash
-cd tiku-client
 flutter pub get
 flutter pub get
-flutter build apk --debug          # 调试版 APK(签名用 debug.keystore)
-flutter install -d <device_id>      # 安装到设备
-```
 
 
-### 发布构建(签名)
+# 调试版
+flutter build apk --debug
 
 
-```bash
-# 1. 准备签名文件
+# 发布版(需签名文件)
 cp key.jks android/app/key.jks
 cp key.jks android/app/key.jks
 cp keystore.properties android/app/keystore.properties
 cp keystore.properties android/app/keystore.properties
-
-# keystore.properties 格式:
-# storeFile=key.jks
-# storePassword=xxxxx
-# keyAlias=xxxxx
-# keyPassword=xxxxx
-
-# 2. 构建
 flutter build apk --release
 flutter build apk --release
-
 # 输出:build/app/outputs/flutter-apk/app-release.apk
 # 输出:build/app/outputs/flutter-apk/app-release.apk
 ```
 ```
 
 
-> **注意**:`key.jks` 和 `keystore.properties` 已加入 `.gitignore`。
-
-### Web 构建(可选)
-
-```bash
-flutter build web --release
-# 产物在 build/web/,复制到服务端 tiku-server/web/ 目录
-```
-
-## 目录结构
-
-```
-lib/
-├── main.dart                    # 入口,ProviderScope 包裹
-├── app.dart                     # MaterialApp + 底部导航 Shell
-├── models/                      # 数据模型
-│   ├── problem.dart             # 题目(Problem, SearchResult)
-│   ├── knowledge.dart           # 知识点树节点
-│   ├── cart.dart                # 试题篮
-│   └── stats.dart               # 统计数据
-├── services/
-│   └── api_service.dart         # Dio HTTP 客户端 + HMAC 签名拦截器
-├── providers/
-│   └── app_provider.dart        # Riverpod Provider(连接状态、激活篮子)
-├── pages/
-│   ├── home/                    # 题库首页
-│   │   └── home_page.dart       # 三栏布局:知识树 | 题目列表 | 预览
-│   ├── search/                  # 搜索页
-│   │   └── search_page.dart     # FTS5 全文搜索
-│   ├── problem_detail/          # 题目详情
-│   │   └── problem_detail_page.dart  # 渲染预览 + 标注对话框
-│   ├── carts/                   # 试题篮
-│   │   ├── cart_list_page.dart  # 多篮管理 + 激活切换
-│   │   └── cart_detail_page.dart # 篮子详情 + 逐题移除
-│   ├── export/                  # 导出
-│   │   └── export_page.dart     # 生成学生版/教师版 docx
-│   ├── import_/                 # 导入
-│   │   └── import_page.dart     # 系统文件选择器 + 上传进度
-│   └── settings/                # 设置
-│       └── settings_page.dart   # 服务器地址 + Token 配置
-└── widgets/                     # 可复用组件
-    ├── knowledge_tree.dart      # 5层可折叠知识点树
-    ├── problem_card.dart        # 题目列表卡片(加入/移除篮按钮)
-    └── webview_render.dart      # WebView(Android)/ IFrame(Web)题目渲染
-```
-
 ## 首次使用
 ## 首次使用
 
 
-1. 打开应用 → 进入设置页
-2. 输入服务器地址(如 `http://数学.top:8765`)和 API Token
-3. 点击"连接"
-4. 连接成功后自动跳转到题库首页
-
-### 模拟器连接
+1. 打开应用 → 设置页
+2. 输入服务器地址和 Token → 连接
+3. 跳转题库首页
 
 
-Android 模拟器访问宿主机使用 `http://10.0.2.2:8765`。
+| 环境 | 服务器地址 |
+|------|-----------|
+| 模拟器 | `http://10.0.2.2:8765` |
+| 真机 | `http://服务器IP:8765` 或 `http://数学.top:8765` |
 
 
-## 功能导航
+## 页面导航
 
 
 | 标签 | 功能 |
 | 标签 | 功能 |
 |------|------|
 |------|------|
-| **题库** | 知识点树浏览 → 点击章节筛选题目 → 点击题目查看预览(公式+图片) → + 按钮加入试题篮 |
-| **搜索** | 输入关键词 → FTS5 全文搜索 → 点击结果查看详情 |
-| **试题篮** | 激活一个篮子(⚪→✓) → 查看/导出/删除篮子 |
-| **设置** | 服务器地址配置、断开连接 |
+| **题库** | 知识点筛选(🔍按钮)→ 题型筛选 → 题目列表(显示正文摘要)→ 点开预览(公式+图片)→ +/- 管理试题篮 |
+| **搜索** | 关键词全文搜索 |
+| **试题篮** | 查看/移除题目,导出 docx |
+| **设置** | 服务器配置、WebDAV 远程备份配置 |
 
 
-## 试题篮激活机制
+## 知识点筛选
 
 
-1. 进入"试题篮"标签页
-2. 点击篮子右侧的 ⚪ 按钮激活(变为 ✓)
-3. 返回"题库"页,所有 + 按钮加入题目都进这个篮子
-4. 已加入的题目显示红色移除按钮
-5. 切换激活后所有按钮状态自动刷新
+1. 右上角 🔍 按钮 → 弹出知识点树
+2. 展开章节浏览,点击 🔍 图标筛选该节点
+3. 点击叶子节点精确筛选
+4. 「全部题目」清除筛选
+5. 筛选后 AppBar 显示当前筛选名称,✕ 清除
 
 
-## 导入题目
+## 题目显示
 
 
-1. 题库页右上角 + → 进入导入页
-2. 选择入库类型(精选题/经典题/高考题)
-3. 点击上传区域 → 系统文件选择器 → 选择 `.docx` 文件
-4. 点击"开始导入" → 实时查看进度条
-5. 导入完成后显示新增/跳过/错误统计
+- 列表卡片显示题目前 80 字符摘要(body_preview)
+- 不显示 ID
+- 难度标签(易/中/难)+ 题型图标
+- 已加入试题篮的题目显示红色移除按钮
 
 
-## 题目渲染
+## 试题篮
 
 
-题目通过 WebView 加载服务端渲染的完整 HTML 页面(含 MathJax),支持:
-- 行内公式(`\(...\)` LaTeX 语法)
-- 行间公式
-- Base64 内嵌图片
-- 宋体中文排版
-- 答案/解析隐藏/显示切换
+- 单一默认篮子,自动创建
+- 显示题目摘要而非 ID
+- 支持导出学生版/教师版 docx
+- 下拉刷新
 
 
 ## API 认证
 ## API 认证
 
 
-客户端通过 Dio 拦截器自动给每个请求添加签名头:
+Dio 拦截器自动给所有请求添加签名头:
 
 
 ```
 ```
 X-Timestamp: <unix_timestamp>
 X-Timestamp: <unix_timestamp>
 X-Signature: HMAC-SHA256(token, METHOD+PATH+timestamp) → hex
 X-Signature: HMAC-SHA256(token, METHOD+PATH+timestamp) → hex
 ```
 ```
 
 
-签名在 Dio 拦截器中透明计算,业务代码无需关心。
-
 ## 依赖
 ## 依赖
 
 
 | 包 | 用途 |
 | 包 | 用途 |
 |------|------|
 |------|------|
-| `flutter_riverpod` | 状态管理 |
-| `dio` | HTTP 客户端 |
-| `webview_flutter` | WebView 题目渲染 |
-| `shared_preferences` | 服务器配置本地持久化 |
-| `crypto` | HMAC-SHA256 签名 |
-| `intl` | 国际化 |
-
-## Android 权限
-
-```xml
-<uses-permission android:name="android.permission.INTERNET"/>
-<application android:usesCleartextTraffic="true" ...>
-```
-
-网络访问 + 明文 HTTP(内网使用)。
-
-## 图标
-
-应用图标为蓝底白 Σ(求和符号),通过 Python PIL 生成各密度 PNG,含自适应图标前景层。
+| flutter_riverpod | 状态管理 |
+| dio | HTTP 客户端 |
+| webview_flutter | WebView 题目渲染(公式+图片) |
+| shared_preferences | 服务器配置本地持久化 |
+| crypto | HMAC-SHA256 签名 |

+ 56 - 2
tiku-client/lib/pages/home/home_page.dart

@@ -18,6 +18,7 @@ class HomePage extends ConsumerStatefulWidget {
 
 
 class _HomePageState extends ConsumerState<HomePage> {
 class _HomePageState extends ConsumerState<HomePage> {
   String? _filterCode;
   String? _filterCode;
+  String? _filterName;
   String? _selectedType;
   String? _selectedType;
   int? _viewedCartId;
   int? _viewedCartId;
   List<Problem> _problems = [];
   List<Problem> _problems = [];
@@ -76,7 +77,7 @@ class _HomePageState extends ConsumerState<HomePage> {
   }
   }
 
 
   void _selectNode(String? code, String? name) {
   void _selectNode(String? code, String? name) {
-    setState(() => _filterCode = code);
+    setState(() { _filterCode = code; _filterName = name; });
     _loadProblems();
     _loadProblems();
   }
   }
 
 
@@ -122,8 +123,21 @@ class _HomePageState extends ConsumerState<HomePage> {
 
 
     return Scaffold(
     return Scaffold(
       appBar: AppBar(
       appBar: AppBar(
-        title: const Text('题库'),
+        title: _filterName != null
+            ? Text(_filterName!, style: const TextStyle(fontSize: 16))
+            : const Text('题库'),
         actions: [
         actions: [
+          if (_filterName != null)
+            IconButton(
+              icon: const Icon(Icons.clear, size: 20),
+              tooltip: '清除筛选',
+              onPressed: () => _selectNode(null, null),
+            ),
+          IconButton(
+            icon: const Icon(Icons.filter_list),
+            tooltip: '知识点筛选',
+            onPressed: () => _showFilterSheet(context),
+          ),
           IconButton(
           IconButton(
             icon: const Icon(Icons.add),
             icon: const Icon(Icons.add),
             tooltip: '导入题目',
             tooltip: '导入题目',
@@ -135,6 +149,25 @@ class _HomePageState extends ConsumerState<HomePage> {
     );
     );
   }
   }
 
 
+  void _showFilterSheet(BuildContext context) {
+    showModalBottomSheet(
+      context: context,
+      builder: (ctx) => DraggableScrollableSheet(
+        initialChildSize: 0.7,
+        minChildSize: 0.3,
+        maxChildSize: 0.9,
+        expand: false,
+        builder: (_, scrollCtrl) => SizedBox(
+          height: double.infinity,
+          child: KnowledgeTreeWidget(onSelectNode: (code, name) {
+            _selectNode(code, name);
+            Navigator.pop(ctx);
+          }),
+        ),
+      ),
+    );
+  }
+
   Widget _buildWide() {
   Widget _buildWide() {
     return Row(
     return Row(
       children: [
       children: [
@@ -159,6 +192,27 @@ class _HomePageState extends ConsumerState<HomePage> {
   Widget _buildList() {
   Widget _buildList() {
     return Column(
     return Column(
       children: [
       children: [
+        // Filter chip
+        if (_filterName != null)
+          Container(
+            width: double.infinity,
+            padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
+            color: Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3),
+            child: Row(
+              children: [
+                Icon(Icons.filter_alt, size: 16, color: Theme.of(context).colorScheme.primary),
+                const SizedBox(width: 4),
+                Expanded(
+                  child: Text(_filterName!, style: TextStyle(color: Theme.of(context).colorScheme.primary, fontSize: 13)),
+                ),
+                GestureDetector(
+                  onTap: () => _selectNode(null, null),
+                  child: Icon(Icons.close, size: 16, color: Theme.of(context).colorScheme.primary),
+                ),
+              ],
+            ),
+          ),
+
         Padding(
         Padding(
           padding: const EdgeInsets.all(8),
           padding: const EdgeInsets.all(8),
           child: Row(
           child: Row(

+ 24 - 7
tiku-client/lib/widgets/knowledge_tree.dart

@@ -1,6 +1,5 @@
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
-import '../services/api_service.dart';
 import '../providers/app_provider.dart';
 import '../providers/app_provider.dart';
 import '../models/knowledge.dart';
 import '../models/knowledge.dart';
 
 
@@ -49,6 +48,7 @@ class _KnowledgeTreeWidgetState extends ConsumerState<KnowledgeTreeWidget> {
               : ListView(
               : ListView(
                   children: [
                   children: [
                     ListTile(
                     ListTile(
+                      leading: const Icon(Icons.select_all, size: 18),
                       title: const Text('全部题目'),
                       title: const Text('全部题目'),
                       selected: _selectedCode == null,
                       selected: _selectedCode == null,
                       onTap: () {
                       onTap: () {
@@ -66,9 +66,11 @@ class _KnowledgeTreeWidgetState extends ConsumerState<KnowledgeTreeWidget> {
 
 
   Widget _buildNode(KnowledgeNode node, int depth) {
   Widget _buildNode(KnowledgeNode node, int depth) {
     if (node.children.isEmpty) {
     if (node.children.isEmpty) {
+      // Leaf node: click to filter
       return ListTile(
       return ListTile(
         contentPadding: EdgeInsets.only(left: 16.0 + depth * 16.0),
         contentPadding: EdgeInsets.only(left: 16.0 + depth * 16.0),
         dense: true,
         dense: true,
+        leading: const Icon(Icons.circle, size: 6),
         title: Text(node.name, style: const TextStyle(fontSize: 13)),
         title: Text(node.name, style: const TextStyle(fontSize: 13)),
         selected: _selectedCode == node.code,
         selected: _selectedCode == node.code,
         onTap: () {
         onTap: () {
@@ -78,16 +80,31 @@ class _KnowledgeTreeWidgetState extends ConsumerState<KnowledgeTreeWidget> {
       );
       );
     }
     }
 
 
+    // Branch node: expand to browse, click filter icon to filter
     return ExpansionTile(
     return ExpansionTile(
       tilePadding: EdgeInsets.only(left: depth * 16.0),
       tilePadding: EdgeInsets.only(left: depth * 16.0),
-      title: Text(node.name, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
-      initiallyExpanded: depth < 2,
-      onExpansionChanged: (expanded) {
-        if (expanded) {
+      leading: GestureDetector(
+        onTap: () {
           setState(() => _selectedCode = node.code);
           setState(() => _selectedCode = node.code);
           widget.onSelectNode(node.code, node.name);
           widget.onSelectNode(node.code, node.name);
-        }
-      },
+        },
+        child: Icon(
+          Icons.filter_alt_outlined,
+          size: 16,
+          color: _selectedCode == node.code
+              ? Theme.of(context).colorScheme.primary
+              : Colors.grey,
+        ),
+      ),
+      title: Text(node.name,
+          style: TextStyle(
+            fontSize: 13,
+            fontWeight: FontWeight.w500,
+            color: _selectedCode == node.code
+                ? Theme.of(context).colorScheme.primary
+                : null,
+          )),
+      initiallyExpanded: depth < 2,
       children: node.children.map((c) => _buildNode(c, depth + 1)).toList(),
       children: node.children.map((c) => _buildNode(c, depth + 1)).toList(),
     );
     );
   }
   }

+ 17 - 1
tiku-server/app/database.py

@@ -375,7 +375,23 @@ def get_cart(conn: sqlite3.Connection, cart_id: int) -> dict | None:
         (cart_id,)
         (cart_id,)
     ).fetchall()
     ).fetchall()
     result = _row_to_dict(row)
     result = _row_to_dict(row)
-    result["items"] = [_row_to_dict(item) for item in items]
+    item_dicts = []
+    for item in items:
+        d = _row_to_dict(item)
+        # Add body_preview from FTS
+        fts_row = conn.execute(
+            "SELECT body FROM problems_fts WHERE problem_id = ?",
+            (d["id"],)
+        ).fetchone()
+        if fts_row and fts_row["body"]:
+            body = fts_row["body"]
+            import re
+            body = re.sub(r'^\d+[..]\s*', '', body)
+            d["body_preview"] = body[:80]
+        else:
+            d["body_preview"] = ""
+        item_dicts.append(d)
+    result["items"] = item_dicts
     return result
     return result