Просмотр исходного кода

feat: major client overhaul

- App name: 题库PLUS → 某人的题库
- Cart: single default cart, removed activation system
- Home: removed pagination, load all problems
- ProblemCard: show body_preview text instead of ID
- Knowledge tree: pass code for proper filtering
- Server: added knowledge_code prefix/exact filter
- Server: added body_preview to problem list response
- Settings: added WebDAV configuration
- Fixed formula rendering (inline math)
nonvioce 3 месяцев назад
Родитель
Сommit
a16c4cf929

+ 1 - 1
tiku-client/android/app/src/main/AndroidManifest.xml

@@ -1,7 +1,7 @@
 <manifest xmlns:android="http://schemas.android.com/apk/res/android">
 <manifest xmlns:android="http://schemas.android.com/apk/res/android">
     <uses-permission android:name="android.permission.INTERNET"/>
     <uses-permission android:name="android.permission.INTERNET"/>
     <application
     <application
-        android:label="题库PLUS"
+        android:label="某人的题库"
         android:usesCleartextTraffic="true"
         android:usesCleartextTraffic="true"
         android:name="${applicationName}"
         android:name="${applicationName}"
         android:icon="@mipmap/ic_launcher">
         android:icon="@mipmap/ic_launcher">

+ 1 - 1
tiku-client/lib/app.dart

@@ -25,7 +25,7 @@ class _TikuAppState extends ConsumerState<TikuApp> {
     final connState = ref.watch(connectionProvider);
     final connState = ref.watch(connectionProvider);
 
 
     return MaterialApp(
     return MaterialApp(
-      title: '题库PLUS',
+      title: '某人的题库',
       debugShowCheckedModeBanner: false,
       debugShowCheckedModeBanner: false,
       theme: ThemeData(
       theme: ThemeData(
         colorSchemeSeed: Colors.blue,
         colorSchemeSeed: Colors.blue,

+ 3 - 0
tiku-client/lib/models/problem.dart

@@ -12,6 +12,7 @@ class Problem {
   final String createdAt;
   final String createdAt;
   final String updatedAt;
   final String updatedAt;
   final String? renderHtml;
   final String? renderHtml;
+  final String bodyPreview;
 
 
   Problem({
   Problem({
     required this.id,
     required this.id,
@@ -25,6 +26,7 @@ class Problem {
     this.createdAt = '',
     this.createdAt = '',
     this.updatedAt = '',
     this.updatedAt = '',
     this.renderHtml,
     this.renderHtml,
+    this.bodyPreview = '',
   });
   });
 
 
   factory Problem.fromJson(Map<String, dynamic> json) {
   factory Problem.fromJson(Map<String, dynamic> json) {
@@ -40,6 +42,7 @@ class Problem {
       createdAt: json['created_at'] ?? '',
       createdAt: json['created_at'] ?? '',
       updatedAt: json['updated_at'] ?? '',
       updatedAt: json['updated_at'] ?? '',
       renderHtml: json['render_html'],
       renderHtml: json['render_html'],
+      bodyPreview: json['body_preview'] ?? '',
     );
     );
   }
   }
 
 

+ 75 - 114
tiku-client/lib/pages/carts/cart_list_page.dart

@@ -1,10 +1,10 @@
 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 'package:shared_preferences/shared_preferences.dart';
 import '../../services/api_service.dart';
 import '../../services/api_service.dart';
 import '../../providers/app_provider.dart';
 import '../../providers/app_provider.dart';
 import '../../models/cart.dart';
 import '../../models/cart.dart';
-import 'cart_detail_page.dart';
+import '../../models/problem.dart';
+import '../problem_detail/problem_detail_page.dart';
 import '../export/export_page.dart';
 import '../export/export_page.dart';
 
 
 class CartListPage extends ConsumerStatefulWidget {
 class CartListPage extends ConsumerStatefulWidget {
@@ -15,8 +15,9 @@ class CartListPage extends ConsumerStatefulWidget {
 }
 }
 
 
 class _CartListPageState extends ConsumerState<CartListPage> {
 class _CartListPageState extends ConsumerState<CartListPage> {
-  List<Cart> _carts = [];
+  Cart? _cart;
   bool _isLoading = false;
   bool _isLoading = false;
+  int _cartId = 0;
 
 
   @override
   @override
   void initState() {
   void initState() {
@@ -24,73 +25,60 @@ class _CartListPageState extends ConsumerState<CartListPage> {
     WidgetsBinding.instance.addPostFrameCallback((_) => _load());
     WidgetsBinding.instance.addPostFrameCallback((_) => _load());
   }
   }
 
 
-  Future<void> _activateCart(int cartId) async {
-    final prefs = await SharedPreferences.getInstance();
-    final current = ref.read(activeCartIdProvider);
-    if (cartId == current) {
-      await prefs.remove('active_cart_id');
-      ref.read(activeCartIdProvider.notifier).state = null;
-      ref.read(cartProblemIdsProvider.notifier).state = {};
-    } else {
-      await prefs.setInt('active_cart_id', cartId);
-      ref.read(activeCartIdProvider.notifier).state = cartId;
-    }
-    _load();
+  Future<void> _ensureCart() async {
+    if (_cart != null) return;
+    final api = ref.read(apiServiceProvider);
+    try {
+      final data = await api.listCarts();
+      final carts = data['carts'] as List? ?? [];
+      if (carts.isEmpty) {
+        final r = await api.createCart('默认');
+        _cartId = r['id'] as int;
+      } else {
+        _cartId = carts.first['id'] as int;
+      }
+    } catch (_) {}
   }
   }
 
 
   Future<void> _load() async {
   Future<void> _load() async {
-    if (!mounted) return;
     setState(() => _isLoading = true);
     setState(() => _isLoading = true);
+    await _ensureCart();
+    if (_cartId == 0) { setState(() => _isLoading = false); return; }
     final api = ref.read(apiServiceProvider);
     final api = ref.read(apiServiceProvider);
     try {
     try {
-      final data = await api.listCarts();
-      if (!mounted) return;
-      setState(() {
-        _carts = (data['carts'] as List?)
-                ?.map((j) => Cart.fromJson(j))
-                .toList() ??
-            [];
-      });
+      final data = await api.cartDetail(_cartId);
+      if (mounted) setState(() => _cart = Cart.fromJson(data));
     } catch (_) {}
     } catch (_) {}
     if (mounted) setState(() => _isLoading = false);
     if (mounted) setState(() => _isLoading = false);
   }
   }
 
 
-  Future<void> _createCart() async {
-    final name = await showDialog<String>(
-      context: context,
-      builder: (ctx) {
-        final controller = TextEditingController();
-        return AlertDialog(
-          title: const Text('新建试题篮'),
-          content: TextField(
-            controller: controller,
-            decoration: const InputDecoration(hintText: '篮子名称', border: OutlineInputBorder()),
-            autofocus: true,
-          ),
-          actions: [
-            TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('取消')),
-            FilledButton(onPressed: () => Navigator.pop(ctx, controller.text.trim()), child: const Text('创建')),
-          ],
-        );
-      },
-    );
-    if (name != null && name.isNotEmpty) {
-      final api = ref.read(apiServiceProvider);
-      await api.createCart(name);
+  Future<void> _removeItem(String pid) async {
+    final api = ref.read(apiServiceProvider);
+    try {
+      await api.removeFromCart(_cartId, pid);
       _load();
       _load();
-    }
+    } catch (_) {}
   }
   }
 
 
   @override
   @override
   Widget build(BuildContext context) {
   Widget build(BuildContext context) {
     final connState = ref.watch(connectionProvider);
     final connState = ref.watch(connectionProvider);
-    final activeCartId = ref.watch(activeCartIdProvider);
 
 
     return Scaffold(
     return Scaffold(
       appBar: AppBar(
       appBar: AppBar(
         title: const Text('试题篮'),
         title: const Text('试题篮'),
         actions: [
         actions: [
-          IconButton(icon: const Icon(Icons.add), onPressed: _createCart, tooltip: '新建篮子'),
+          if (_cart != null && _cart!.items != null && _cart!.items!.isNotEmpty)
+            IconButton(
+              icon: const Icon(Icons.file_download),
+              tooltip: '导出',
+              onPressed: () => Navigator.push(
+                context,
+                MaterialPageRoute(
+                  builder: (_) => ExportPage(cartId: _cartId, cartName: _cart!.name),
+                ),
+              ).then((_) => _load()),
+            ),
           IconButton(icon: const Icon(Icons.refresh), onPressed: _load, tooltip: '刷新'),
           IconButton(icon: const Icon(Icons.refresh), onPressed: _load, tooltip: '刷新'),
         ],
         ],
       ),
       ),
@@ -98,7 +86,7 @@ class _CartListPageState extends ConsumerState<CartListPage> {
           ? const Center(child: Text('请先配置服务器连接'))
           ? const Center(child: Text('请先配置服务器连接'))
           : _isLoading
           : _isLoading
               ? const Center(child: CircularProgressIndicator())
               ? const Center(child: CircularProgressIndicator())
-              : _carts.isEmpty
+              : _cart == null || _cart!.items == null || _cart!.items!.isEmpty
                   ? Center(
                   ? Center(
                       child: Column(
                       child: Column(
                         mainAxisSize: MainAxisSize.min,
                         mainAxisSize: MainAxisSize.min,
@@ -107,85 +95,46 @@ class _CartListPageState extends ConsumerState<CartListPage> {
                           const SizedBox(height: 16),
                           const SizedBox(height: 16),
                           const Text('试题篮为空'),
                           const Text('试题篮为空'),
                           const SizedBox(height: 8),
                           const SizedBox(height: 8),
-                          const Text('点击题库中的 + 按钮添加题目', style: TextStyle(color: Colors.grey)),
-                          const SizedBox(height: 16),
-                          FilledButton.tonal(onPressed: _createCart, child: const Text('新建试题篮')),
+                          const Text('在题库中点击 + 按钮添加题目', style: TextStyle(color: Colors.grey)),
                         ],
                         ],
                       ),
                       ),
                     )
                     )
                   : Column(
                   : Column(
                       children: [
                       children: [
-                        if (activeCartId != null)
-                          Container(
-                            width: double.infinity,
-                            padding: const EdgeInsets.all(8),
-                            color: Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.4),
-                            child: Text(
-                              '当前激活: ${_carts.firstWhere((c) => c.id == activeCartId, orElse: () => _carts.first).name}',
-                              textAlign: TextAlign.center,
-                              style: TextStyle(color: Theme.of(context).colorScheme.primary, fontSize: 13),
-                            ),
-                          )
-                        else
-                          Container(
-                            width: double.infinity,
-                            padding: const EdgeInsets.all(8),
-                            color: Colors.orange.shade50,
-                            child: const Text('未激活试题篮,点击篮子右侧按钮激活', textAlign: TextAlign.center,
-                                style: TextStyle(color: Colors.orange, fontSize: 13)),
+                        Container(
+                          width: double.infinity,
+                          padding: const EdgeInsets.all(8),
+                          color: Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.4),
+                          child: Text(
+                            '共 ${_cart!.items!.length} 题',
+                            textAlign: TextAlign.center,
+                            style: TextStyle(color: Theme.of(context).colorScheme.primary),
                           ),
                           ),
+                        ),
                         Expanded(
                         Expanded(
                           child: RefreshIndicator(
                           child: RefreshIndicator(
                             onRefresh: _load,
                             onRefresh: _load,
                             child: ListView.builder(
                             child: ListView.builder(
-                              itemCount: _carts.length,
+                              itemCount: _cart!.items!.length,
                               itemBuilder: (_, i) {
                               itemBuilder: (_, i) {
-                                final cart = _carts[i];
-                                final isActive = cart.id == activeCartId;
+                                final p = _cart!.items![i];
+                                final preview = p.bodyPreview.isNotEmpty ? p.bodyPreview : p.id;
                                 return Card(
                                 return Card(
                                   margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
                                   margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
-                                  color: isActive ? Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3) : null,
                                   child: ListTile(
                                   child: ListTile(
-                                    leading: Icon(isActive ? Icons.shopping_cart : Icons.folder_outlined,
-                                        color: isActive ? Theme.of(context).colorScheme.primary : null),
-                                    title: Text(cart.name,
-                                        style: TextStyle(fontWeight: isActive ? FontWeight.bold : FontWeight.normal)),
-                                    subtitle: Text('${cart.itemCount} 题'),
-                                    trailing: Row(
-                                      mainAxisSize: MainAxisSize.min,
-                                      children: [
-                                        IconButton(
-                                          icon: Icon(isActive ? Icons.check_circle : Icons.radio_button_unchecked,
-                                              color: isActive ? Theme.of(context).colorScheme.primary : Colors.grey),
-                                          tooltip: isActive ? '取消激活' : '激活此篮',
-                                          onPressed: () => _activateCart(cart.id),
-                                        ),
-                                        IconButton(
-                                          icon: const Icon(Icons.file_download),
-                                          tooltip: '导出',
-                                          onPressed: () => Navigator.push(context,
-                                              MaterialPageRoute(builder: (_) => ExportPage(cartId: cart.id, cartName: cart.name))),
-                                        ),
-                                        PopupMenuButton(
-                                          itemBuilder: (_) => [const PopupMenuItem(value: 'delete', child: Text('删除'))],
-                                          onSelected: (v) async {
-                                            if (v == 'delete') {
-                                              final api = ref.read(apiServiceProvider);
-                                              await api.deleteCart(cart.id);
-                                              if (isActive) {
-                                                ref.read(activeCartIdProvider.notifier).state = null;
-                                                ref.read(cartProblemIdsProvider.notifier).state = {};
-                                              }
-                                              _load();
-                                            }
-                                          },
-                                        ),
-                                      ],
+                                    leading: _typeIcon(p.questionType),
+                                    title: Text(preview, maxLines: 2, overflow: TextOverflow.ellipsis,
+                                        style: const TextStyle(fontSize: 13)),
+                                    subtitle: Text('${p.questionType} · ${p.difficultyLabel}(${p.difficulty})',
+                                        style: TextStyle(fontSize: 11, color: Colors.grey.shade600)),
+                                    trailing: IconButton(
+                                      icon: const Icon(Icons.remove_circle_outline, color: Colors.red),
+                                      onPressed: () => _removeItem(p.id),
                                     ),
                                     ),
-                                    onTap: () async {
-                                      await Navigator.push(context, MaterialPageRoute(builder: (_) => CartDetailPage(cartId: cart.id)));
-                                      _load();
-                                    },
+                                    onTap: () => Navigator.push(
+                                      context,
+                                      MaterialPageRoute(builder: (_) => ProblemDetailPage(problemId: p.id)),
+                                    ).then((_) => _load()),
                                   ),
                                   ),
                                 );
                                 );
                               },
                               },
@@ -196,4 +145,16 @@ class _CartListPageState extends ConsumerState<CartListPage> {
                     ),
                     ),
     );
     );
   }
   }
+
+  Widget _typeIcon(String type) {
+    IconData icon;
+    switch (type) {
+      case '单选题': icon = Icons.radio_button_checked; break;
+      case '多选题': icon = Icons.check_box; break;
+      case '填空题': icon = Icons.edit_note; break;
+      case '解答题': icon = Icons.assignment; break;
+      default: icon = Icons.question_mark;
+    }
+    return Icon(icon, size: 20);
+  }
 }
 }

+ 96 - 266
tiku-client/lib/pages/home/home_page.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 'package:shared_preferences/shared_preferences.dart';
 import '../../services/api_service.dart';
 import '../../services/api_service.dart';
 import '../../providers/app_provider.dart';
 import '../../providers/app_provider.dart';
 import '../../models/problem.dart';
 import '../../models/problem.dart';
@@ -18,87 +17,107 @@ class HomePage extends ConsumerStatefulWidget {
 }
 }
 
 
 class _HomePageState extends ConsumerState<HomePage> {
 class _HomePageState extends ConsumerState<HomePage> {
-  String? _selectedChapter;
+  String? _filterCode;
   String? _selectedType;
   String? _selectedType;
+  int? _viewedCartId;
   List<Problem> _problems = [];
   List<Problem> _problems = [];
   Problem? _selectedProblem;
   Problem? _selectedProblem;
-  int _page = 1;
-  int _total = 0;
   bool _isLoading = false;
   bool _isLoading = false;
-  int? _lastSyncedCartId;
-  final _scrollController = ScrollController();
+  Set<String> _cartIds = {};
 
 
   @override
   @override
   void initState() {
   void initState() {
     super.initState();
     super.initState();
-    WidgetsBinding.instance.addPostFrameCallback((_) async {
-      final prefs = await SharedPreferences.getInstance();
-      final savedId = prefs.getInt('active_cart_id');
-      if (savedId != null && ref.read(activeCartIdProvider) == null) {
-        ref.read(activeCartIdProvider.notifier).state = savedId;
-        await _syncCartItems(savedId);
-      }
+    WidgetsBinding.instance.addPostFrameCallback((_) {
+      _loadCart();
       _loadProblems();
       _loadProblems();
     });
     });
   }
   }
 
 
-  Future<void> _syncCartItems(int cartId) async {
+  Future<void> _loadCart() async {
     final api = ref.read(apiServiceProvider);
     final api = ref.read(apiServiceProvider);
     try {
     try {
-      final data = await api.cartDetail(cartId);
-      final items = data['items'] as List? ?? [];
-      ref.read(cartProblemIdsProvider.notifier).state =
-          items.map((i) => i['id'] as String).toSet();
+      final carts = await api.listCarts();
+      final list = carts['carts'] as List? ?? [];
+      if (list.isEmpty) {
+        // Create default cart
+        await api.createCart('默认');
+        _viewedCartId = null;
+      } else {
+        _viewedCartId = list.first['id'] as int;
+      }
+      // Load cart items
+      if (_viewedCartId != null) {
+        final cart = await api.cartDetail(_viewedCartId!);
+        final items = cart['items'] as List? ?? [];
+        setState(() {
+          _cartIds = items.map((i) => i['id'] as String).toSet();
+        });
+      }
     } catch (_) {}
     } catch (_) {}
   }
   }
 
 
-  @override
-  void dispose() {
-    _scrollController.dispose();
-    super.dispose();
-  }
-
-  Future<void> _loadProblems({bool refresh = false}) async {
+  Future<void> _loadProblems() async {
     if (_isLoading) return;
     if (_isLoading) return;
     setState(() => _isLoading = true);
     setState(() => _isLoading = true);
-
-    if (refresh) _page = 1;
-
     final api = ref.read(apiServiceProvider);
     final api = ref.read(apiServiceProvider);
     try {
     try {
       final data = await api.listProblems(
       final data = await api.listProblems(
-        chapter: _selectedChapter,
+        knowledgeCode: _filterCode,
         questionType: _selectedType,
         questionType: _selectedType,
-        page: _page,
+        page: 1,
+        pageSize: 10000, // All problems, no pagination
       );
       );
-      final items = (data['items'] as List).map((j) => Problem.fromJson(j)).toList();
       setState(() {
       setState(() {
-        if (refresh || _page == 1) {
-          _problems = items;
-        } else {
-          _problems.addAll(items);
-        }
-        _total = data['total'] ?? 0;
+        _problems = (data['items'] as List).map((j) => Problem.fromJson(j)).toList();
       });
       });
-    } catch (_) {
-      // Error handled gracefully
-    } finally {
-      setState(() => _isLoading = false);
-    }
+    } catch (_) {}
+    setState(() => _isLoading = false);
   }
   }
 
 
-  void _selectChapter(String? chapterFilter, String? displayName) {
-    setState(() => _selectedChapter = chapterFilter);
-    _loadProblems(refresh: true);
+  void _selectNode(String? code, String? name) {
+    setState(() => _filterCode = code);
+    _loadProblems();
   }
   }
 
 
   void _selectProblem(Problem p) {
   void _selectProblem(Problem p) {
     setState(() => _selectedProblem = p);
     setState(() => _selectedProblem = p);
   }
   }
 
 
+  Future<void> _addToCart(String problemId) async {
+    final api = ref.read(apiServiceProvider);
+    try {
+      if (_viewedCartId == null) return;
+      await api.addToCart(_viewedCartId!, problemId);
+      final cart = await api.cartDetail(_viewedCartId!);
+      final items = cart['items'] as List? ?? [];
+      setState(() => _cartIds = items.map((i) => i['id'] as String).toSet());
+      if (mounted) {
+        ScaffoldMessenger.of(context).showSnackBar(
+          const SnackBar(content: Text('已加入试题篮'), duration: Duration(seconds: 1)),
+        );
+      }
+    } catch (_) {}
+  }
+
+  Future<void> _removeFromCart(String problemId) async {
+    if (_viewedCartId == null) return;
+    final api = ref.read(apiServiceProvider);
+    try {
+      await api.removeFromCart(_viewedCartId!, problemId);
+      final cart = await api.cartDetail(_viewedCartId!);
+      final items = cart['items'] as List? ?? [];
+      setState(() => _cartIds = items.map((i) => i['id'] as String).toSet());
+      if (mounted) {
+        ScaffoldMessenger.of(context).showSnackBar(
+          const SnackBar(content: Text('已从试题篮移除'), duration: Duration(seconds: 1)),
+        );
+      }
+    } catch (_) {}
+  }
+
   @override
   @override
   Widget build(BuildContext context) {
   Widget build(BuildContext context) {
-    // Responsive: wide = three columns, narrow = single column
     final isWide = MediaQuery.of(context).size.width > 900;
     final isWide = MediaQuery.of(context).size.width > 900;
 
 
     return Scaffold(
     return Scaffold(
@@ -110,73 +129,36 @@ class _HomePageState extends ConsumerState<HomePage> {
             tooltip: '导入题目',
             tooltip: '导入题目',
             onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const ImportPage())),
             onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const ImportPage())),
           ),
           ),
-          if (!isWide && _selectedProblem != null)
-            IconButton(
-              icon: const Icon(Icons.visibility),
-              tooltip: '查看题目',
-              onPressed: () => Navigator.push(
-                context,
-                MaterialPageRoute(
-                  builder: (_) => ProblemDetailPage(problemId: _selectedProblem!.id),
-                ),
-              ),
-            ),
         ],
         ],
       ),
       ),
-      body: isWide ? _buildWideLayout() : _buildNarrowLayout(),
+      body: isWide ? _buildWide() : _buildNarrow(),
     );
     );
   }
   }
 
 
-  Widget _buildWideLayout() {
+  Widget _buildWide() {
     return Row(
     return Row(
       children: [
       children: [
-        // Left: Knowledge tree
         SizedBox(
         SizedBox(
           width: 280,
           width: 280,
-          child: KnowledgeTreeWidget(
-            onSelectChapter: _selectChapter,
-          ),
+          child: KnowledgeTreeWidget(onSelectNode: _selectNode),
         ),
         ),
         const VerticalDivider(width: 1),
         const VerticalDivider(width: 1),
-
-        // Middle: Problem list
-        Expanded(
-          flex: 2,
-          child: _buildProblemList(),
-        ),
-
-        // Right: Preview
+        Expanded(flex: 2, child: _buildList()),
         Expanded(
         Expanded(
           flex: 3,
           flex: 3,
           child: _selectedProblem != null
           child: _selectedProblem != null
-              ? _buildPreview()
+              ? ProblemPreview(problem: _selectedProblem!)
               : const Center(child: Text('点击题目查看预览')),
               : const Center(child: Text('点击题目查看预览')),
         ),
         ),
       ],
       ],
     );
     );
   }
   }
 
 
-  Widget _buildNarrowLayout() {
-    return _buildProblemList();
-  }
-
-  Widget _buildProblemList() {
-    final activeCartId = ref.watch(activeCartIdProvider);
-    final cartIds = ref.watch(cartProblemIdsProvider);
-
-    // Detect active cart change and sync (only once per change, via flag)
-    if (activeCartId != _lastSyncedCartId) {
-      _lastSyncedCartId = activeCartId;
-      if (activeCartId != null) {
-        _syncCartItems(activeCartId);
-      } else {
-        ref.read(cartProblemIdsProvider.notifier).state = {};
-      }
-    }
+  Widget _buildNarrow() => _buildList();
 
 
+  Widget _buildList() {
     return Column(
     return Column(
       children: [
       children: [
-        // Filter bar
         Padding(
         Padding(
           padding: const EdgeInsets.all(8),
           padding: const EdgeInsets.all(8),
           child: Row(
           child: Row(
@@ -188,9 +170,10 @@ class _HomePageState extends ConsumerState<HomePage> {
                     labelText: '题型',
                     labelText: '题型',
                     contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
                     contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
                     border: OutlineInputBorder(),
                     border: OutlineInputBorder(),
+                    isDense: true,
                   ),
                   ),
                   items: const [
                   items: const [
-                    DropdownMenuItem(value: null, child: Text('全部')),
+                    DropdownMenuItem(value: null, child: Text('全部题型')),
                     DropdownMenuItem(value: '单选题', child: Text('单选题')),
                     DropdownMenuItem(value: '单选题', child: Text('单选题')),
                     DropdownMenuItem(value: '多选题', child: Text('多选题')),
                     DropdownMenuItem(value: '多选题', child: Text('多选题')),
                     DropdownMenuItem(value: '填空题', child: Text('填空题')),
                     DropdownMenuItem(value: '填空题', child: Text('填空题')),
@@ -198,197 +181,44 @@ class _HomePageState extends ConsumerState<HomePage> {
                   ],
                   ],
                   onChanged: (v) {
                   onChanged: (v) {
                     setState(() => _selectedType = v);
                     setState(() => _selectedType = v);
-                    _loadProblems(refresh: true);
+                    _loadProblems();
                   },
                   },
                 ),
                 ),
               ),
               ),
               const SizedBox(width: 8),
               const SizedBox(width: 8),
-              Text('共 $_total 题', style: Theme.of(context).textTheme.bodySmall),
+              Text('共 ${_problems.length} 题', style: Theme.of(context).textTheme.bodySmall),
             ],
             ],
           ),
           ),
         ),
         ),
-
-        // List
         Expanded(
         Expanded(
           child: _isLoading && _problems.isEmpty
           child: _isLoading && _problems.isEmpty
               ? const Center(child: CircularProgressIndicator())
               ? const Center(child: CircularProgressIndicator())
               : _problems.isEmpty
               : _problems.isEmpty
-                  ? const Center(child: Text('没有题目,请先导入'))
+                  ? const Center(child: Text('没有题目'))
                   : ListView.builder(
                   : ListView.builder(
-                      controller: _scrollController,
                       itemCount: _problems.length,
                       itemCount: _problems.length,
-                      itemBuilder: (_, i) => ProblemCard(
-                        problem: _problems[i],
-                        isSelected: _selectedProblem?.id == _problems[i].id,
-                        isInCart: activeCartId != null && cartIds.contains(_problems[i].id),
-                        onTap: () {
-                          _selectProblem(_problems[i]);
-                          final isWide = MediaQuery.of(context).size.width > 900;
-                          if (!isWide) {
-                            Navigator.push(
-                              context,
-                              MaterialPageRoute(
-                                builder: (_) => ProblemDetailPage(problemId: _problems[i].id),
-                              ),
-                            );
-                          }
-                        },
-                        onAddToCart: (pid) => _addToCart(pid),
-                        onRemoveFromCart: (pid) => _removeFromCart(pid),
-                      ),
+                      itemBuilder: (_, i) {
+                        final p = _problems[i];
+                        return ProblemCard(
+                          problem: p,
+                          isSelected: _selectedProblem?.id == p.id,
+                          isInCart: _cartIds.contains(p.id),
+                          onTap: () {
+                            _selectProblem(p);
+                            if (!(MediaQuery.of(context).size.width > 900)) {
+                              Navigator.push(
+                                context,
+                                MaterialPageRoute(builder: (_) => ProblemDetailPage(problemId: p.id)),
+                              );
+                            }
+                          },
+                          onAddToCart: _addToCart,
+                          onRemoveFromCart: _removeFromCart,
+                        );
+                      },
                     ),
                     ),
         ),
         ),
       ],
       ],
     );
     );
   }
   }
-
-  Widget _buildPreview() {
-    if (_selectedProblem == null) return const SizedBox.shrink();
-    return ProblemPreview(problem: _selectedProblem!);
-  }
-
-  Future<void> _addToCart(String problemId) async {
-    final api = ref.read(apiServiceProvider);
-    try {
-      final prefs = await SharedPreferences.getInstance();
-      int? activeCartId = ref.read(activeCartIdProvider);
-
-      if (activeCartId == null) {
-        final cartsData = await api.listCarts();
-        final carts = cartsData['carts'] as List? ?? [];
-        if (!mounted) return;
-
-        if (carts.isEmpty) {
-          final result = await api.createCart('默认');
-          activeCartId = result['id'] as int;
-        } else {
-          activeCartId = await showDialog<int>(
-            context: context,
-            builder: (ctx) => _CartPickerDialog(carts: carts, api: api),
-          );
-          if (activeCartId == null || !mounted) return;
-        }
-        await prefs.setInt('active_cart_id', activeCartId!);
-        ref.read(activeCartIdProvider.notifier).state = activeCartId;
-      }
-
-      await api.addToCart(activeCartId, problemId);
-      ref.read(cartProblemIdsProvider.notifier).state = {
-        ...ref.read(cartProblemIdsProvider),
-        problemId,
-      };
-      if (mounted) {
-        ScaffoldMessenger.of(context).showSnackBar(
-          const SnackBar(content: Text('已加入试题篮'), duration: Duration(seconds: 1)),
-        );
-      }
-    } catch (_) {}
-  }
-
-  Future<void> _removeFromCart(String problemId) async {
-    final activeCartId = ref.read(activeCartIdProvider);
-    if (activeCartId == null) return;
-    final api = ref.read(apiServiceProvider);
-    try {
-      await api.removeFromCart(activeCartId, problemId);
-      final updated = {...ref.read(cartProblemIdsProvider)};
-      updated.remove(problemId);
-      ref.read(cartProblemIdsProvider.notifier).state = updated;
-      if (mounted) {
-        ScaffoldMessenger.of(context).showSnackBar(
-          const SnackBar(content: Text('已从试题篮移除'), duration: Duration(seconds: 1)),
-        );
-      }
-    } catch (_) {}
-  }
-}
-
-/// 试题篮选择对话框
-class _CartPickerDialog extends StatefulWidget {
-  final List carts;
-  final dynamic api;
-
-  const _CartPickerDialog({required this.carts, required this.api});
-
-  @override
-  State<_CartPickerDialog> createState() => _CartPickerDialogState();
-}
-
-class _CartPickerDialogState extends State<_CartPickerDialog> {
-  final _newNameController = TextEditingController();
-
-  @override
-  void dispose() {
-    _newNameController.dispose();
-    super.dispose();
-  }
-
-  Future<void> _createAndSelect() async {
-    final name = _newNameController.text.trim();
-    if (name.isEmpty) return;
-
-    final result = await widget.api.createCart(name);
-    final newId = result['id'] as int;
-    if (mounted) Navigator.pop(context, newId);
-  }
-
-  @override
-  Widget build(BuildContext context) {
-    return AlertDialog(
-      title: const Text('选择试题篮'),
-      content: SizedBox(
-        width: 300,
-        child: Column(
-          mainAxisSize: MainAxisSize.min,
-          crossAxisAlignment: CrossAxisAlignment.stretch,
-          children: [
-            if (widget.carts.isEmpty)
-              const Padding(
-                padding: EdgeInsets.only(bottom: 16),
-                child: Text('暂无试题篮,请创建:', style: TextStyle(color: Colors.grey)),
-              ),
-
-            // Existing carts
-            ...widget.carts.map((cart) => ListTile(
-                  leading: const Icon(Icons.folder_outlined),
-                  title: Text(cart['name'] ?? ''),
-                  subtitle: Text('${cart['item_count'] ?? 0} 题'),
-                  onTap: () => Navigator.pop(context, cart['id'] as int),
-                )),
-
-            const Divider(),
-
-            // Create new cart
-            Row(
-              children: [
-                Expanded(
-                  child: TextField(
-                    controller: _newNameController,
-                    decoration: const InputDecoration(
-                      hintText: '新建篮子名称',
-                      border: OutlineInputBorder(),
-                      isDense: true,
-                    ),
-                    onSubmitted: (_) => _createAndSelect(),
-                  ),
-                ),
-                const SizedBox(width: 8),
-                IconButton(
-                  icon: const Icon(Icons.add_circle),
-                  tooltip: '创建并选择',
-                  onPressed: _createAndSelect,
-                ),
-              ],
-            ),
-          ],
-        ),
-      ),
-      actions: [
-        TextButton(
-          onPressed: () => Navigator.pop(context),
-          child: const Text('取消'),
-        ),
-      ],
-    );
-  }
 }
 }

+ 10 - 31
tiku-client/lib/pages/problem_detail/problem_detail_page.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 'package:shared_preferences/shared_preferences.dart';
 import '../../services/api_service.dart';
 import '../../services/api_service.dart';
 import '../../providers/app_provider.dart';
 import '../../providers/app_provider.dart';
 import '../../models/problem.dart';
 import '../../models/problem.dart';
@@ -48,41 +47,21 @@ class _ProblemDetailPageState extends ConsumerState<ProblemDetailPage> {
     if (_problem == null) return;
     if (_problem == null) return;
     final api = ref.read(apiServiceProvider);
     final api = ref.read(apiServiceProvider);
     try {
     try {
-      final prefs = await SharedPreferences.getInstance();
-      final activeCartId = prefs.getInt('active_cart_id');
-
-      if (activeCartId != null) {
-        await api.addToCart(activeCartId, _problem!.id);
-        if (mounted) {
-          ScaffoldMessenger.of(context).showSnackBar(
-            const SnackBar(content: Text('已加入试题篮'), duration: Duration(seconds: 1)),
-          );
-        }
-        return;
-      }
-
-      // Fallback: auto-create default cart and activate
+      // Get or create default cart
       final cartsData = await api.listCarts();
       final cartsData = await api.listCarts();
       final carts = cartsData['carts'] as List? ?? [];
       final carts = cartsData['carts'] as List? ?? [];
+      int cartId;
       if (carts.isEmpty) {
       if (carts.isEmpty) {
         final result = await api.createCart('默认');
         final result = await api.createCart('默认');
-        await prefs.setInt('active_cart_id', result['id'] as int);
-        await api.addToCart(result['id'] as int, _problem!.id);
-        if (mounted) {
-          ScaffoldMessenger.of(context).showSnackBar(
-            const SnackBar(content: Text('已加入试题篮'), duration: Duration(seconds: 1)),
-          );
-        }
+        cartId = result['id'] as int;
       } else {
       } else {
-        // Use first cart
-        final first = carts.first;
-        await prefs.setInt('active_cart_id', first['id'] as int);
-        await api.addToCart(first['id'] as int, _problem!.id);
-        if (mounted) {
-          ScaffoldMessenger.of(context).showSnackBar(
-            const SnackBar(content: Text('已加入试题篮'), duration: Duration(seconds: 1)),
-          );
-        }
+        cartId = carts.first['id'] as int;
+      }
+      await api.addToCart(cartId, _problem!.id);
+      if (mounted) {
+        ScaffoldMessenger.of(context).showSnackBar(
+          const SnackBar(content: Text('已加入试题篮'), duration: Duration(seconds: 1)),
+        );
       }
       }
     } catch (_) {}
     } catch (_) {}
   }
   }

+ 118 - 78
tiku-client/lib/pages/settings/settings_page.dart

@@ -1,5 +1,6 @@
 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';
 
 
 class SettingsPage extends ConsumerStatefulWidget {
 class SettingsPage extends ConsumerStatefulWidget {
@@ -10,8 +11,11 @@ class SettingsPage extends ConsumerStatefulWidget {
 }
 }
 
 
 class _SettingsPageState extends ConsumerState<SettingsPage> {
 class _SettingsPageState extends ConsumerState<SettingsPage> {
-  final _urlController = TextEditingController();
-  final _tokenController = TextEditingController();
+  final _urlCtrl = TextEditingController();
+  final _tokenCtrl = TextEditingController();
+  final _webdavUrlCtrl = TextEditingController();
+  final _webdavUserCtrl = TextEditingController();
+  final _webdavPassCtrl = TextEditingController();
   bool _isConnecting = false;
   bool _isConnecting = false;
   String? _error;
   String? _error;
 
 
@@ -20,45 +24,63 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
     super.initState();
     super.initState();
     final conn = ref.read(connectionProvider);
     final conn = ref.read(connectionProvider);
     if (conn.serverUrl != null) {
     if (conn.serverUrl != null) {
-      _urlController.text = conn.serverUrl!;
+      _urlCtrl.text = conn.serverUrl!;
+      _loadWebDAVConfig();
     }
     }
   }
   }
 
 
+  Future<void> _loadWebDAVConfig() async {
+    final api = ref.read(apiServiceProvider);
+    try {
+      final data = await api.getWebDAVConfig();
+      if (data['configured'] == true) {
+        _webdavUrlCtrl.text = data['url'] ?? '';
+        _webdavUserCtrl.text = data['username'] ?? '';
+      }
+    } catch (_) {}
+  }
+
   @override
   @override
   void dispose() {
   void dispose() {
-    _urlController.dispose();
-    _tokenController.dispose();
+    _urlCtrl.dispose(); _tokenCtrl.dispose();
+    _webdavUrlCtrl.dispose(); _webdavUserCtrl.dispose(); _webdavPassCtrl.dispose();
     super.dispose();
     super.dispose();
   }
   }
 
 
   Future<void> _connect() async {
   Future<void> _connect() async {
-    final url = _urlController.text.trim();
-    final token = _tokenController.text.trim();
-
-    if (url.isEmpty || token.isEmpty) {
-      setState(() => _error = '请输入服务器地址和 Token');
-      return;
-    }
-
-    setState(() {
-      _isConnecting = true;
-      _error = null;
-    });
-
+    final url = _urlCtrl.text.trim(), token = _tokenCtrl.text.trim();
+    if (url.isEmpty || token.isEmpty) { setState(() => _error = '请输入服务器地址和 Token'); return; }
+    setState(() { _isConnecting = true; _error = null; });
     final ok = await ref.read(connectionProvider.notifier).connect(url, token);
     final ok = await ref.read(connectionProvider.notifier).connect(url, token);
-
     if (!mounted) return;
     if (!mounted) return;
-
-    setState(() {
-      _isConnecting = false;
-      if (!ok) _error = '连接失败,请检查地址和 Token';
-    });
+    setState(() { _isConnecting = false; if (!ok) _error = '连接失败'; });
   }
   }
 
 
   Future<void> _disconnect() async {
   Future<void> _disconnect() async {
     await ref.read(connectionProvider.notifier).disconnect();
     await ref.read(connectionProvider.notifier).disconnect();
-    _urlController.clear();
-    _tokenController.clear();
+    _urlCtrl.clear(); _tokenCtrl.clear();
+  }
+
+  Future<void> _saveWebDAV() async {
+    final api = ref.read(apiServiceProvider);
+    try {
+      await api.setWebDAVConfig(
+        _webdavUrlCtrl.text.trim(),
+        _webdavUserCtrl.text.trim(),
+        _webdavPassCtrl.text.trim(),
+      );
+      if (mounted) {
+        ScaffoldMessenger.of(context).showSnackBar(
+          const SnackBar(content: Text('WebDAV 配置已保存')),
+        );
+      }
+    } catch (e) {
+      if (mounted) {
+        ScaffoldMessenger.of(context).showSnackBar(
+          SnackBar(content: Text('保存失败: $e')),
+        );
+      }
+    }
   }
   }
 
 
   @override
   @override
@@ -71,93 +93,112 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
       body: ListView(
       body: ListView(
         padding: const EdgeInsets.all(16),
         padding: const EdgeInsets.all(16),
         children: [
         children: [
-          // 连接状态卡片
+          // 连接状态
           Card(
           Card(
             child: Padding(
             child: Padding(
               padding: const EdgeInsets.all(16),
               padding: const EdgeInsets.all(16),
               child: Column(
               child: Column(
                 crossAxisAlignment: CrossAxisAlignment.start,
                 crossAxisAlignment: CrossAxisAlignment.start,
                 children: [
                 children: [
-                  Row(
-                    children: [
-                      Icon(
-                        isConfigured ? Icons.cloud_done : Icons.cloud_off,
-                        color: isConfigured ? Colors.green : Colors.grey,
-                      ),
-                      const SizedBox(width: 8),
-                      Text(
-                        isConfigured ? '已连接' : '未连接',
-                        style: Theme.of(context).textTheme.titleMedium,
-                      ),
-                    ],
-                  ),
-                  if (isConfigured && connState.serverUrl != null) ...[
-                    const SizedBox(height: 8),
-                    Text('服务器: ${connState.serverUrl}',
-                        style: Theme.of(context).textTheme.bodySmall),
-                  ],
+                  Row(children: [
+                    Icon(isConfigured ? Icons.cloud_done : Icons.cloud_off,
+                        color: isConfigured ? Colors.green : Colors.grey),
+                    const SizedBox(width: 8),
+                    Text(isConfigured ? '已连接' : '未连接',
+                        style: Theme.of(context).textTheme.titleMedium),
+                  ]),
+                  if (isConfigured && connState.serverUrl != null)
+                    Padding(
+                      padding: const EdgeInsets.only(top: 8),
+                      child: Text('服务器: ${connState.serverUrl}',
+                          style: Theme.of(context).textTheme.bodySmall),
+                    ),
                 ],
                 ],
               ),
               ),
             ),
             ),
           ),
           ),
-
           const SizedBox(height: 16),
           const SizedBox(height: 16),
 
 
           // 连接表单
           // 连接表单
           if (!isConfigured) ...[
           if (!isConfigured) ...[
             TextField(
             TextField(
-              controller: _urlController,
+              controller: _urlCtrl,
               decoration: const InputDecoration(
               decoration: const InputDecoration(
-                labelText: '服务器地址',
-                hintText: 'http://192.168.1.100:8765',
-                prefixIcon: Icon(Icons.link),
-                border: OutlineInputBorder(),
+                labelText: '服务器地址', hintText: 'http://数学.top:8765',
+                prefixIcon: Icon(Icons.link), border: OutlineInputBorder(),
               ),
               ),
               keyboardType: TextInputType.url,
               keyboardType: TextInputType.url,
             ),
             ),
             const SizedBox(height: 12),
             const SizedBox(height: 12),
             TextField(
             TextField(
-              controller: _tokenController,
+              controller: _tokenCtrl,
               decoration: const InputDecoration(
               decoration: const InputDecoration(
-                labelText: 'API Token',
-                hintText: '在服务端运行 tiku-server token 获取',
-                prefixIcon: Icon(Icons.key),
-                border: OutlineInputBorder(),
+                labelText: 'API Token', hintText: '在服务端运行 tiku-server token 获取',
+                prefixIcon: Icon(Icons.key), border: OutlineInputBorder(),
               ),
               ),
               obscureText: true,
               obscureText: true,
             ),
             ),
-            if (_error != null) ...[
-              const SizedBox(height: 8),
-              Text(_error!, style: const TextStyle(color: Colors.red)),
-            ],
+            if (_error != null)
+              Padding(padding: const EdgeInsets.only(top: 8), child: Text(_error!, style: const TextStyle(color: Colors.red))),
             const SizedBox(height: 16),
             const SizedBox(height: 16),
             SizedBox(
             SizedBox(
-              width: double.infinity,
-              height: 48,
+              width: double.infinity, height: 48,
               child: FilledButton(
               child: FilledButton(
                 onPressed: _isConnecting ? null : _connect,
                 onPressed: _isConnecting ? null : _connect,
                 child: _isConnecting
                 child: _isConnecting
-                    ? const SizedBox(
-                        height: 20,
-                        width: 20,
-                        child: CircularProgressIndicator(strokeWidth: 2),
-                      )
+                    ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
                     : const Text('连接'),
                     : const Text('连接'),
               ),
               ),
             ),
             ),
-          ] else ...[
-            // 已连接 — 断开按钮
-            SizedBox(
-              width: double.infinity,
-              height: 48,
-              child: OutlinedButton(
-                onPressed: _disconnect,
-                child: const Text('断开连接'),
+          ] else
+            SizedBox(width: double.infinity, height: 48,
+              child: OutlinedButton(onPressed: _disconnect, child: const Text('断开连接'))),
+
+          const SizedBox(height: 24),
+
+          // WebDAV 配置
+          if (isConfigured) ...[
+            Card(
+              child: Padding(
+                padding: const EdgeInsets.all(16),
+                child: Column(
+                  crossAxisAlignment: CrossAxisAlignment.start,
+                  children: [
+                    Text('WebDAV 远程备份', style: Theme.of(context).textTheme.titleMedium),
+                    const SizedBox(height: 12),
+                    TextField(
+                      controller: _webdavUrlCtrl,
+                      decoration: const InputDecoration(
+                        labelText: 'WebDAV 地址', hintText: 'https://nas.example.com/remote.php/dav/files/user/',
+                        border: OutlineInputBorder(), isDense: true,
+                      ),
+                    ),
+                    const SizedBox(height: 8),
+                    TextField(
+                      controller: _webdavUserCtrl,
+                      decoration: const InputDecoration(
+                        labelText: '用户名', border: OutlineInputBorder(), isDense: true,
+                      ),
+                    ),
+                    const SizedBox(height: 8),
+                    TextField(
+                      controller: _webdavPassCtrl,
+                      decoration: const InputDecoration(
+                        labelText: '密码', border: OutlineInputBorder(), isDense: true,
+                      ),
+                      obscureText: true,
+                    ),
+                    const SizedBox(height: 12),
+                    SizedBox(width: double.infinity,
+                      child: FilledButton.tonal(onPressed: _saveWebDAV, child: const Text('保存 WebDAV 配置'))),
+                  ],
+                ),
               ),
               ),
             ),
             ),
           ],
           ],
 
 
-          const SizedBox(height: 32),
+          const SizedBox(height: 24),
+          // 关于
           Card(
           Card(
             child: Padding(
             child: Padding(
               padding: const EdgeInsets.all(16),
               padding: const EdgeInsets.all(16),
@@ -166,8 +207,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
                 children: [
                 children: [
                   Text('关于', style: Theme.of(context).textTheme.titleMedium),
                   Text('关于', style: Theme.of(context).textTheme.titleMedium),
                   const SizedBox(height: 8),
                   const SizedBox(height: 8),
-                  const Text('题库PLUS v1.0.0'),
-                  const Text('C/S 架构数学题库管理系统'),
+                  const Text('某人的题库 v1.0.0'),
                   const Text('服务端: Python + FastAPI'),
                   const Text('服务端: Python + FastAPI'),
                   const Text('客户端: Flutter'),
                   const Text('客户端: Flutter'),
                 ],
                 ],

+ 0 - 6
tiku-client/lib/providers/app_provider.dart

@@ -52,9 +52,3 @@ final connectionProvider = StateNotifierProvider<ConnectionNotifier, ConnectionS
   final api = ref.watch(apiServiceProvider);
   final api = ref.watch(apiServiceProvider);
   return ConnectionNotifier(api);
   return ConnectionNotifier(api);
 });
 });
-
-/// Active cart ID — shared between home page and cart list page
-final activeCartIdProvider = StateProvider<int?>((ref) => null);
-
-/// Problem IDs in the active cart — updated whenever active cart changes
-final cartProblemIdsProvider = StateProvider<Set<String>>((ref) => {});

+ 14 - 0
tiku-client/lib/services/api_service.dart

@@ -106,6 +106,7 @@ class ApiService {
 
 
   Future<Map<String, dynamic>> listProblems({
   Future<Map<String, dynamic>> listProblems({
     String? chapter,
     String? chapter,
+    String? knowledgeCode,
     String? questionType,
     String? questionType,
     String? importType,
     String? importType,
     String? annotated,
     String? annotated,
@@ -117,6 +118,7 @@ class ApiService {
       'page_size': pageSize,
       'page_size': pageSize,
     };
     };
     if (chapter != null && chapter.isNotEmpty) params['chapter'] = chapter;
     if (chapter != null && chapter.isNotEmpty) params['chapter'] = chapter;
+    if (knowledgeCode != null && knowledgeCode.isNotEmpty) params['knowledge_code'] = knowledgeCode;
     if (questionType != null && questionType.isNotEmpty) params['question_type'] = questionType;
     if (questionType != null && questionType.isNotEmpty) params['question_type'] = questionType;
     if (importType != null && importType.isNotEmpty) params['import_type'] = importType;
     if (importType != null && importType.isNotEmpty) params['import_type'] = importType;
     if (annotated != null && annotated.isNotEmpty) params['annotated'] = annotated;
     if (annotated != null && annotated.isNotEmpty) params['annotated'] = annotated;
@@ -125,6 +127,18 @@ class ApiService {
     return response.data;
     return response.data;
   }
   }
 
 
+  // WebDAV
+  Future<Map<String, dynamic>> getWebDAVConfig() async {
+    final response = await _dio.get('/api/admin/backup-config', options: _authOptions);
+    return response.data;
+  }
+
+  Future<void> setWebDAVConfig(String url, String username, String password) async {
+    await _dio.put('/api/admin/backup-config',
+        data: {'url': url, 'username': username, 'password': password},
+        options: _authOptions);
+  }
+
   // ── 题目详情 ──────────────────────────────────────
   // ── 题目详情 ──────────────────────────────────────
 
 
   Future<Map<String, dynamic>> problemDetail(String id) async {
   Future<Map<String, dynamic>> problemDetail(String id) async {

+ 10 - 36
tiku-client/lib/widgets/knowledge_tree.dart

@@ -4,11 +4,10 @@ import '../services/api_service.dart';
 import '../providers/app_provider.dart';
 import '../providers/app_provider.dart';
 import '../models/knowledge.dart';
 import '../models/knowledge.dart';
 
 
-/// 知识点树 Widget — 左侧导航
 class KnowledgeTreeWidget extends ConsumerStatefulWidget {
 class KnowledgeTreeWidget extends ConsumerStatefulWidget {
-  final void Function(String? chapterFilter, String? displayName) onSelectChapter;
+  final Function(String? code, String? name) onSelectNode;
 
 
-  const KnowledgeTreeWidget({super.key, required this.onSelectChapter});
+  const KnowledgeTreeWidget({super.key, required this.onSelectNode});
 
 
   @override
   @override
   ConsumerState<KnowledgeTreeWidget> createState() => _KnowledgeTreeWidgetState();
   ConsumerState<KnowledgeTreeWidget> createState() => _KnowledgeTreeWidgetState();
@@ -32,8 +31,7 @@ class _KnowledgeTreeWidgetState extends ConsumerState<KnowledgeTreeWidget> {
       setState(() {
       setState(() {
         _tree = (data['tree'] as List?)
         _tree = (data['tree'] as List?)
                 ?.map((j) => KnowledgeNode.fromJson(j))
                 ?.map((j) => KnowledgeNode.fromJson(j))
-                .toList() ??
-            [];
+                .toList() ?? [];
         _isLoading = false;
         _isLoading = false;
       });
       });
     } catch (_) {
     } catch (_) {
@@ -45,24 +43,6 @@ class _KnowledgeTreeWidgetState extends ConsumerState<KnowledgeTreeWidget> {
   Widget build(BuildContext context) {
   Widget build(BuildContext context) {
     return Column(
     return Column(
       children: [
       children: [
-        // Search
-        Padding(
-          padding: const EdgeInsets.all(8),
-          child: TextField(
-            decoration: InputDecoration(
-              hintText: '搜索知识点...',
-              prefixIcon: const Icon(Icons.search, size: 20),
-              contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
-              border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
-              isDense: true,
-            ),
-            onChanged: (v) {
-              // TODO: filter tree locally
-            },
-          ),
-        ),
-
-        // Tree
         Expanded(
         Expanded(
           child: _isLoading
           child: _isLoading
               ? const Center(child: CircularProgressIndicator())
               ? const Center(child: CircularProgressIndicator())
@@ -73,10 +53,10 @@ class _KnowledgeTreeWidgetState extends ConsumerState<KnowledgeTreeWidget> {
                       selected: _selectedCode == null,
                       selected: _selectedCode == null,
                       onTap: () {
                       onTap: () {
                         setState(() => _selectedCode = null);
                         setState(() => _selectedCode = null);
-                        widget.onSelectChapter(null, null);
+                        widget.onSelectNode(null, null);
                       },
                       },
                     ),
                     ),
-                    ..._tree.map((node) => _buildTreeNode(node, 0)),
+                    ..._tree.map((node) => _buildNode(node, 0)),
                   ],
                   ],
                 ),
                 ),
         ),
         ),
@@ -84,10 +64,7 @@ class _KnowledgeTreeWidgetState extends ConsumerState<KnowledgeTreeWidget> {
     );
     );
   }
   }
 
 
-  Widget _buildTreeNode(KnowledgeNode node, int depth) {
-    // Build chapter filter string: for top-level nodes, use "XX-name" format
-    final chapterFilter = depth == 0 ? '${node.code}-${node.name}' : node.name;
-
+  Widget _buildNode(KnowledgeNode node, int depth) {
     if (node.children.isEmpty) {
     if (node.children.isEmpty) {
       return ListTile(
       return ListTile(
         contentPadding: EdgeInsets.only(left: 16.0 + depth * 16.0),
         contentPadding: EdgeInsets.only(left: 16.0 + depth * 16.0),
@@ -96,25 +73,22 @@ class _KnowledgeTreeWidgetState extends ConsumerState<KnowledgeTreeWidget> {
         selected: _selectedCode == node.code,
         selected: _selectedCode == node.code,
         onTap: () {
         onTap: () {
           setState(() => _selectedCode = node.code);
           setState(() => _selectedCode = node.code);
-          widget.onSelectChapter(chapterFilter, node.name);
+          widget.onSelectNode(node.code, node.name);
         },
         },
       );
       );
     }
     }
 
 
     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),
-      ),
+      title: Text(node.name, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
       initiallyExpanded: depth < 2,
       initiallyExpanded: depth < 2,
       onExpansionChanged: (expanded) {
       onExpansionChanged: (expanded) {
         if (expanded) {
         if (expanded) {
           setState(() => _selectedCode = node.code);
           setState(() => _selectedCode = node.code);
-          widget.onSelectChapter(chapterFilter, node.name);
+          widget.onSelectNode(node.code, node.name);
         }
         }
       },
       },
-      children: node.children.map((c) => _buildTreeNode(c, depth + 1)).toList(),
+      children: node.children.map((c) => _buildNode(c, depth + 1)).toList(),
     );
     );
   }
   }
 }
 }

+ 42 - 35
tiku-client/lib/widgets/problem_card.dart

@@ -1,7 +1,6 @@
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 import '../models/problem.dart';
 import '../models/problem.dart';
 
 
-/// 题目列表卡片
 class ProblemCard extends StatelessWidget {
 class ProblemCard extends StatelessWidget {
   final Problem problem;
   final Problem problem;
   final bool isSelected;
   final bool isSelected;
@@ -22,6 +21,10 @@ class ProblemCard extends StatelessWidget {
 
 
   @override
   @override
   Widget build(BuildContext context) {
   Widget build(BuildContext context) {
+    final preview = problem.bodyPreview.isNotEmpty
+        ? problem.bodyPreview
+        : '${problem.questionType} · ${problem.difficultyLabel}(${problem.difficulty})';
+
     return Card(
     return Card(
       margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
       margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
       color: isSelected ? Theme.of(context).colorScheme.primaryContainer : null,
       color: isSelected ? Theme.of(context).colorScheme.primaryContainer : null,
@@ -29,60 +32,64 @@ class ProblemCard extends StatelessWidget {
         dense: true,
         dense: true,
         leading: _typeIcon(),
         leading: _typeIcon(),
         title: Text(
         title: Text(
-          '${problem.questionType} · ${problem.difficultyLabel}(${problem.difficulty})',
-          style: const TextStyle(fontSize: 13),
-        ),
-        subtitle: Text(
-          'ID: ${problem.id}',
-          style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
+          preview,
+          maxLines: 3,
+          overflow: TextOverflow.ellipsis,
+          style: const TextStyle(fontSize: 13, height: 1.4),
         ),
         ),
-        trailing: Row(
-          mainAxisSize: MainAxisSize.min,
+        subtitle: Row(
           children: [
           children: [
+            _difficultyChip(context),
+            const SizedBox(width: 4),
             if (!problem.isAnnotated)
             if (!problem.isAnnotated)
-              const Icon(Icons.warning_amber, size: 16, color: Colors.orange),
-            if (isInCart)
-              IconButton(
+              const Icon(Icons.warning_amber, size: 14, color: Colors.orange),
+          ],
+        ),
+        trailing: isInCart
+            ? IconButton(
                 icon: const Icon(Icons.remove_shopping_cart, size: 18, color: Colors.red),
                 icon: const Icon(Icons.remove_shopping_cart, size: 18, color: Colors.red),
                 onPressed: () => onRemoveFromCart?.call(problem.id),
                 onPressed: () => onRemoveFromCart?.call(problem.id),
                 tooltip: '从试题篮移除',
                 tooltip: '从试题篮移除',
               )
               )
-            else
-              IconButton(
+            : IconButton(
                 icon: const Icon(Icons.add_shopping_cart, size: 18),
                 icon: const Icon(Icons.add_shopping_cart, size: 18),
                 onPressed: () => onAddToCart(problem.id),
                 onPressed: () => onAddToCart(problem.id),
                 tooltip: '加入试题篮',
                 tooltip: '加入试题篮',
               ),
               ),
-          ],
-        ),
         onTap: onTap,
         onTap: onTap,
       ),
       ),
     );
     );
   }
   }
 
 
+  Widget _difficultyChip(BuildContext context) {
+    Color color;
+    switch (problem.difficultyLabel) {
+      case '易': color = Colors.green; break;
+      case '中': color = Colors.orange; break;
+      default: color = Colors.red;
+    }
+    return Container(
+      padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
+      decoration: BoxDecoration(
+        color: color.withValues(alpha: 0.15),
+        borderRadius: BorderRadius.circular(8),
+      ),
+      child: Text(
+        problem.difficultyLabel,
+        style: TextStyle(fontSize: 11, color: color, fontWeight: FontWeight.w500),
+      ),
+    );
+  }
+
   Widget _typeIcon() {
   Widget _typeIcon() {
     IconData icon;
     IconData icon;
     Color color;
     Color color;
     switch (problem.questionType) {
     switch (problem.questionType) {
-      case '单选题':
-        icon = Icons.radio_button_checked;
-        color = Colors.blue;
-        break;
-      case '多选题':
-        icon = Icons.check_box;
-        color = Colors.purple;
-        break;
-      case '填空题':
-        icon = Icons.edit_note;
-        color = Colors.orange;
-        break;
-      case '解答题':
-        icon = Icons.assignment;
-        color = Colors.green;
-        break;
-      default:
-        icon = Icons.question_mark;
-        color = Colors.grey;
+      case '单选题': icon = Icons.radio_button_checked; color = Colors.blue; break;
+      case '多选题': icon = Icons.check_box; color = Colors.purple; break;
+      case '填空题': icon = Icons.edit_note; color = Colors.orange; break;
+      case '解答题': icon = Icons.assignment; color = Colors.green; break;
+      default: icon = Icons.question_mark; color = Colors.grey;
     }
     }
     return Icon(icon, color: color, size: 20);
     return Icon(icon, color: color, size: 20);
   }
   }

+ 10 - 0
tiku-server/app/database.py

@@ -141,6 +141,7 @@ def get_all_problems(conn: sqlite3.Connection) -> list[dict]:
 def search_problems(
 def search_problems(
     conn: sqlite3.Connection,
     conn: sqlite3.Connection,
     chapter: str | None = None,
     chapter: str | None = None,
+    knowledge_code: str | None = None,
     question_type: str | None = None,
     question_type: str | None = None,
     import_type: str | None = None,
     import_type: str | None = None,
     annotated: str | None = None,
     annotated: str | None = None,
@@ -154,6 +155,15 @@ def search_problems(
     if chapter:
     if chapter:
         conditions.append("chapters LIKE ?")
         conditions.append("chapters LIKE ?")
         params.append(f'%"{chapter}"%')
         params.append(f'%"{chapter}"%')
+    if knowledge_code:
+        # Prefix match: codes like 01.01 → match "01.01.03.02.01"
+        # Full leaf code has 5 segments; partial has fewer
+        if knowledge_code.count('.') >= 4:  # full leaf code → exact match
+            conditions.append("knowledge_codes LIKE ?")
+            params.append(f'%"{knowledge_code}"%')
+        else:  # partial code → prefix match
+            conditions.append("knowledge_codes LIKE ?")
+            params.append(f'%"{knowledge_code}.%')
     if question_type:
     if question_type:
         conditions.append("question_type = ?")
         conditions.append("question_type = ?")
         params.append(question_type)
         params.append(question_type)

+ 24 - 2
tiku-server/app/routers/problems.py

@@ -35,18 +35,40 @@ def get_stats(request: Request, _=Depends(verify_token)):
 def list_problems(
 def list_problems(
     request: Request,
     request: Request,
     chapter: str | None = Query(None),
     chapter: str | None = Query(None),
+    knowledge_code: str | None = Query(None),
     question_type: str | None = Query(None),
     question_type: str | None = Query(None),
     import_type: str | None = Query(None),
     import_type: str | None = Query(None),
     annotated: str | None = Query(None),
     annotated: str | None = Query(None),
     page: int = Query(1, ge=1),
     page: int = Query(1, ge=1),
-    page_size: int = Query(20, ge=1, le=100),
+    page_size: int = Query(20, ge=1, le=10000),
     _=Depends(verify_token),
     _=Depends(verify_token),
 ):
 ):
     """题目列表(分页+筛选)"""
     """题目列表(分页+筛选)"""
     config = request.app.state.config
     config = request.app.state.config
     conn = get_connection(config.db_path)
     conn = get_connection(config.db_path)
     try:
     try:
-        items, total = search_problems(conn, chapter, question_type, import_type, annotated, page, page_size)
+        items, total = search_problems(
+            conn, chapter=chapter, knowledge_code=knowledge_code,
+            question_type=question_type, import_type=import_type,
+            annotated=annotated, page=page, page_size=page_size
+        )
+
+        # Add body_preview for each item from FTS
+        for item in items:
+            row = conn.execute(
+                "SELECT body FROM problems_fts WHERE problem_id = ?",
+                (item["id"],)
+            ).fetchone()
+            if row and row["body"]:
+                # Get first 80 chars, strip question number
+                body = row["body"]
+                # Strip question number like "1."
+                import re
+                body = re.sub(r'^\d+[..]\s*', '', body)
+                item["body_preview"] = body[:80]
+            else:
+                item["body_preview"] = ""
+
         return {
         return {
             "items": items,
             "items": items,
             "total": total,
             "total": total,