diff --git a/README.md b/README.md
index b801c32..c8264f6 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,7 @@
- 注册、登录、退出;每位用户有独立的文档与文件夹。
- 批量上传 PDF、PNG、JPG、WEBP 和 UTF-8 TXT;重复内容提示。
- 文档标题修改、文件夹归档、类型与处理状态筛选、分页。
+- 多级文件夹(最多 8 层)、重命名、整体移动、面包屑导航和包含子目录的搜索。
- 有文字层的 PDF 直接提取;扫描 PDF 和图片通过 Tesseract OCR 识别。
- 后台队列、任务状态、失败后重试、中断任务恢复。
- 文件名和识别文字关键词搜索,展示匹配位置附近的摘要。
@@ -61,6 +62,18 @@ $env:TESSDATA_PREFIX = 'C:\Program Files\Tesseract-OCR\tessdata'
这些环境变量需在启动网页和 worker 的同一个终端中设置。若简体中文语言包未安装,英语模型无法保证中文识别质量。
+### 按层级整理文档
+
+在文件夹页面点击“新建文件夹”,默认在当前目录下创建;也可在窗口中选择其他上级目录。通过“重命名或移动”可以调整目录及其全部子目录,同一位置的名称必须唯一。上传和文档归档的下拉菜单显示完整路径,方便区分不同位置的同名目录。
+
+文件夹页面默认只显示当前目录的文档;勾选“包含子文件夹”后,搜索、类型筛选和分页会覆盖整棵子目录。面包屑和子文件夹卡片支持在手机上逐级浏览。
+
+
+
+只能移除没有子文件夹的目录。文档(包括回收站中的文档)会保留在上级目录;移除顶层目录时归入“待归档”。重命名和移动只更新数据库中的归档关系,原文件不会改名或迁移。
+
+从旧版升级时,先停止服务并备份完整数据目录。启动脚本会自动迁移数据库,已有文件夹保留为顶层目录。使用同名子目录后,不能直接退回旧版的单层目录数据库结构。
+
### 演示资料
```powershell
@@ -97,6 +110,23 @@ $env:FOLDERDOCS_TEST_OCR = '1'
.\.venv\Scripts\python.exe manage.py test
```
+浏览器回归检查仅使用虚构资料。先安装 Playwright(`npm install --no-save playwright`,或通过 `NODE_PATH` 使用已有安装);Windows 默认使用已安装的 Edge,可设置 `PLAYWRIGHT_CHANNEL` 选择其他已安装的浏览器。
+
+```powershell
+# 新建独立测试数据目录,避免修改个人文档;下面的终端使用相同环境变量。
+$env:FOLDERDOCS_DATA_DIR = "$PWD\qa-output\browser-data"
+$env:FOLDERDOCS_TEST_ACCOUNT = "$env:FOLDERDOCS_DATA_DIR\account.json"
+$env:FOLDERDOCS_TEST_URL = 'http://127.0.0.1:8766'
+.\.venv\Scripts\python.exe manage.py migrate --noinput
+.\.venv\Scripts\python.exe qa\prepare_demo.py
+.\scripts\start.ps1 -Port 8766
+# 在另一个配置相同环境变量的终端:
+node qa\browser.cjs
+node qa\folders.cjs
+```
+
+检查覆盖上传、识别、下载、回收站,以及多级目录创建、移动、递归搜索、文件内容保留和手机导航;截图写入 `docs/screenshots/`,账号密码留在被忽略的 `qa-output/` 中。
+
## 结构与数据
```text
@@ -116,9 +146,10 @@ data/ SQLite 数据库、密钥、原文件、预览图(不提交
## 第一版边界与后续计划
-当前限制:每次 1–20 个文件、单个文件 25 MB、PDF 最多 100 页;扫描页仅对前 30 页执行 OCR(其余页会提示未识别),文字型页面仍提取全文。文件夹为单层。数据库中的文字保存最多 200 万字符。
+当前限制:每次 1–20 个文件、单个文件 25 MB、PDF 最多 100 页;扫描页仅对前 30 页执行 OCR(其余页会提示未识别),文字型页面仍提取全文。文件夹最多 8 层,每个名称最多 80 个字符。数据库中的文字保存最多 200 万字符。
-- [ ] 多级文件夹与共享文件夹。
+- [x] 多级文件夹、重命名与整体移动。
+- [ ] 共享文件夹。
- [ ] 有效期分享链接、撤销分享与邀请注册。
- [ ] NAS 原位扫描及增量索引。
- [ ] PostgreSQL 与专业全文检索。
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 1bc5dff..506e459 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -18,6 +18,18 @@ SQLite + one Python worker keep the installation usable without Redis, Linux, or
The data directory, local secret, user uploads, native OCR environment, and demo credentials are excluded from Git. Startup binds only 127.0.0.1. Registration is open for local users in this milestone.
+## Folder hierarchy
+
+Folders have an optional parent within the same owner's hierarchy, with a maximum of eight levels (roots count as level one). Names are trimmed and unique among siblings; root names are separately constrained because their parent is NULL. A database check rejects self-parenting. Application validation rejects foreign parents, cycles and moves that would exceed the depth limit anywhere in the moved subtree.
+
+Create, rename/move and delete requests begin an atomic transaction with a no-op write to the owner's user row before reading the hierarchy. This serializes SQLite hierarchy mutations and takes a row lock on databases with row-level locking, so concurrent moves validate against committed structure. The tree for navigation, paths, direct document counts and descendant filtering is built from one owner-scoped query.
+
+Only leaf folders can be removed. Active and trashed documents are reparented to the removed folder's parent, or to the inbox for a root; their file paths, content, processing status and trash state are unchanged. The parent relation uses RESTRICT to protect direct folder deletion while allowing an entire owner cascade. Folder moves retain UUIDs and move descendants through their existing relations; they do not touch physical originals or previews.
+
+The library defaults to direct-folder documents. `recursive=1` expands the selected folder filter to its owner-scoped descendants, including itself, before applying search, state, type and pagination. Sidebar counts remain direct counts. Complete paths distinguish same-name folders in upload and document move controls. Breadcrumbs and child tiles allow mobile navigation where the sidebar tree is hidden.
+
+Migration 0002 preserves all existing folders as roots and leaves document associations and metadata intact. Back up the full data directory before upgrading; rollback to the old global name constraint is unsupported once duplicate names are used in different parents.
+
## Verified acceptance paths
- Text/PDF/image upload -> queued document -> worker -> searchable text and unmodified source.
@@ -29,7 +41,11 @@ The data directory, local secret, user uploads, native OCR environment, and demo
- Delete during processing -> lease invalidation -> discarded late result.
- Stale processing lease -> rescheduled task.
- Mutations require POST and CSRF protection.
+- Existing single-level database -> upgraded roots and unchanged document metadata.
+- Nested create -> rename/move subtree -> recursive search -> original bytes unchanged.
+- Self/descendant/foreign parent or too-deep move -> rejection without partial update.
+- Leaf removal -> active and trashed documents in parent; parent removal with children -> rejection.
## Deferred work
-Nested folders, sharing, NAS indexing, automatic backup, scalable search and Linux/Docker deployment are separate milestones. Folder names currently organize database entries; they do not rename the original directory hierarchy.
+Sharing, NAS indexing, automatic backup, scalable search and Linux/Docker deployment are separate milestones. Folder names organize database entries; they do not rename the original directory hierarchy.
diff --git a/docs/screenshots/library.png b/docs/screenshots/library.png
index 69afa58..f508537 100644
Binary files a/docs/screenshots/library.png and b/docs/screenshots/library.png differ
diff --git a/docs/screenshots/mobile.png b/docs/screenshots/mobile.png
index 92db0ac..b0c191d 100644
Binary files a/docs/screenshots/mobile.png and b/docs/screenshots/mobile.png differ
diff --git a/docs/screenshots/nested-folders-mobile.png b/docs/screenshots/nested-folders-mobile.png
new file mode 100644
index 0000000..4422a9f
Binary files /dev/null and b/docs/screenshots/nested-folders-mobile.png differ
diff --git a/docs/screenshots/nested-folders.png b/docs/screenshots/nested-folders.png
new file mode 100644
index 0000000..f757b6c
Binary files /dev/null and b/docs/screenshots/nested-folders.png differ
diff --git a/documents/folders.py b/documents/folders.py
new file mode 100644
index 0000000..fd60e86
--- /dev/null
+++ b/documents/folders.py
@@ -0,0 +1,34 @@
+"""Owner-scoped folder tree and serialized hierarchy mutations."""
+from django.contrib.auth import get_user_model
+from django.db.models import Count, F, Q
+from .models import Folder
+
+
+def lock_hierarchy(owner):
+ # First write in an atomic transaction: serializes SQLite mutations, and
+ # acquires the owner's row lock on databases with row-level locking.
+ get_user_model().objects.filter(pk=owner.pk).update(last_login=F('last_login'))
+
+
+def folder_tree(owner):
+ rows = list(Folder.objects.filter(owner=owner).annotate(
+ document_count=Count('document', filter=Q(document__deleted_at__isnull=True))))
+ by_parent = {}
+ for item in rows:
+ by_parent.setdefault(item.parent_id, []).append(item)
+ result = []
+
+ def visit(parent, ancestors):
+ for item in by_parent.get(parent, []):
+ item.depth = len(ancestors)
+ item.child_count = len(by_parent.get(item.pk, []))
+ item.ancestors = ancestors
+ item.path = ' / '.join([node.name for node in ancestors] + [item.name])
+ item.descendant_ids = {item.pk}
+ result.append(item)
+ visit(item.pk, ancestors + [item])
+ for child in by_parent.get(item.pk, []):
+ item.descendant_ids.update(child.descendant_ids)
+
+ visit(None, [])
+ return result
diff --git a/documents/migrations/0002_remove_folder_unique_owner_folder_folder_parent_and_more.py b/documents/migrations/0002_remove_folder_unique_owner_folder_folder_parent_and_more.py
new file mode 100644
index 0000000..709182e
--- /dev/null
+++ b/documents/migrations/0002_remove_folder_unique_owner_folder_folder_parent_and_more.py
@@ -0,0 +1,52 @@
+# Generated by Django 5.2.18 on 2026-10-14 08:06
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("documents", "0001_initial"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.RemoveConstraint(
+ model_name="folder",
+ name="unique_owner_folder",
+ ),
+ migrations.AddField(
+ model_name="folder",
+ name="parent",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.RESTRICT,
+ related_name="children",
+ to="documents.folder",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="folder",
+ constraint=models.UniqueConstraint(
+ condition=models.Q(("parent__isnull", True)),
+ fields=("owner", "name"),
+ name="unique_root_folder",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="folder",
+ constraint=models.UniqueConstraint(
+ fields=("owner", "parent", "name"), name="unique_sibling_folder"
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="folder",
+ constraint=models.CheckConstraint(
+ condition=models.Q(("parent", models.F("id")), _negated=True),
+ name="folder_not_own_parent",
+ ),
+ ),
+ ]
diff --git a/documents/models.py b/documents/models.py
index a06ac02..475a8ed 100644
--- a/documents/models.py
+++ b/documents/models.py
@@ -1,17 +1,48 @@
import uuid
from django.conf import settings
+from django.core.exceptions import ValidationError
from django.db import models
class Folder(models.Model):
+ MAX_DEPTH = 8
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
+ parent = models.ForeignKey('self', on_delete=models.RESTRICT, null=True, blank=True, related_name='children')
name = models.CharField(max_length=80)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['name']
- constraints = [models.UniqueConstraint(fields=['owner', 'name'], name='unique_owner_folder')]
+ constraints = [
+ models.UniqueConstraint(fields=['owner', 'name'], condition=models.Q(parent__isnull=True), name='unique_root_folder'),
+ models.UniqueConstraint(fields=['owner', 'parent', 'name'], name='unique_sibling_folder'),
+ models.CheckConstraint(condition=~models.Q(parent=models.F('id')), name='folder_not_own_parent'),
+ ]
+
+ def clean(self):
+ super().clean()
+ self.name = self.name.strip()
+ if not self.name or len(self.name) > 80:
+ raise ValidationError('文件夹名称需要 1–80 个字符。')
+ graph = dict(Folder.objects.filter(owner_id=self.owner_id).values_list('pk', 'parent_id'))
+ if self.parent_id and self.parent_id not in graph:
+ raise ValidationError('上级文件夹不存在或不属于当前用户。')
+ graph[self.pk] = self.parent_id
+ for node in graph:
+ seen = set()
+ current = node
+ while current is not None:
+ if current in seen:
+ raise ValidationError('不能将文件夹移动到自身或其子文件夹中。')
+ seen.add(current)
+ if len(seen) > self.MAX_DEPTH:
+ raise ValidationError(f'文件夹最多支持 {self.MAX_DEPTH} 层,请选择更靠近根目录的位置。')
+ if current not in graph:
+ raise ValidationError('文件夹层级无效。')
+ current = graph[current]
+ if Folder.objects.filter(owner_id=self.owner_id, parent_id=self.parent_id, name=self.name).exclude(pk=self.pk).exists():
+ raise ValidationError('同一位置已存在同名文件夹。')
def original_path(instance, filename):
diff --git a/documents/test_folders.py b/documents/test_folders.py
new file mode 100644
index 0000000..61f3895
--- /dev/null
+++ b/documents/test_folders.py
@@ -0,0 +1,231 @@
+"""Hierarchy permissions, data preservation and upgrade regression checks."""
+import tempfile
+from pathlib import Path
+
+from django.contrib.auth import get_user_model
+from django.core.files.uploadedfile import SimpleUploadedFile
+from django.db import IntegrityError, connection, transaction
+from django.db.migrations.executor import MigrationExecutor
+from django.db.models.deletion import RestrictedError
+from django.test import Client, TestCase, TransactionTestCase, override_settings
+from django.utils import timezone
+
+from .folders import folder_tree
+from .models import Document, Folder
+from .processing import process_next
+
+
+class FolderHierarchyTests(TestCase):
+ def setUp(self):
+ temp = tempfile.TemporaryDirectory()
+ media = override_settings(MEDIA_ROOT=Path(temp.name))
+ media.enable()
+ self.addCleanup(temp.cleanup)
+ self.addCleanup(media.disable)
+ self.alice = get_user_model().objects.create_user('alice')
+ self.bob = get_user_model().objects.create_user('bob')
+ self.client.force_login(self.alice)
+ self.home = Folder.objects.create(owner=self.alice, name='家庭')
+ self.bills = Folder.objects.create(owner=self.alice, name='账单', parent=self.home)
+ self.year = Folder.objects.create(owner=self.alice, name='2026', parent=self.bills)
+ self.work = Folder.objects.create(owner=self.alice, name='工作')
+ self.foreign = Folder.objects.create(owner=self.bob, name='BOB-PRIVATE')
+
+ def edit(self, folder, parent=None, name=None, **kwargs):
+ return self.client.post(f'/folders/{folder.pk}/edit/', {
+ 'name': folder.name if name is None else name,
+ 'parent': str(parent.pk) if parent else '',
+ }, **kwargs)
+
+ def upload(self, folder, token):
+ self.client.post('/upload/', {'folder': str(folder.pk),
+ 'files': SimpleUploadedFile(token + '.txt', ('Search token ' + token).encode())})
+ doc = Document.objects.get(owner=self.alice, title=token)
+ self.assertTrue(process_next())
+ doc.refresh_from_db()
+ return doc
+
+ def test_duplicate_names_only_rejected_in_same_parent(self):
+ self.client.post('/folders/create/', {'name': ' 账单 ', 'parent': str(self.work.pk)})
+ self.assertTrue(Folder.objects.filter(parent=self.work, name='账单').exists())
+ before = Folder.objects.count()
+ for name, parent in [('家庭', ''), ('账单', str(self.home.pk)), (' ', ''), ('a' * 81, '')]:
+ self.client.post('/folders/create/', {'name': name, 'parent': parent})
+ self.assertEqual(Folder.objects.count(), before)
+ self.edit(self.work, name='家庭')
+ self.work.refresh_from_db()
+ self.assertEqual(self.work.name, '工作')
+ other = Folder.objects.get(parent=self.work, name='账单')
+ self.edit(other, parent=self.home)
+ other.refresh_from_db()
+ self.assertEqual(other.parent_id, self.work.pk)
+
+ def test_hierarchy_routes_reject_foreign_and_malformed_ids(self):
+ for parent in [str(self.foreign.pk), 'bad-id']:
+ self.assertEqual(self.client.post('/folders/create/', {'name': 'new', 'parent': parent}).status_code, 404)
+ self.assertEqual(self.client.post(f'/folders/{self.bills.pk}/edit/', {'name': 'new', 'parent': parent}).status_code, 404)
+ for suffix in ['edit', 'delete']:
+ self.assertEqual(self.client.post(f'/folders/{self.foreign.pk}/{suffix}/', {'name': 'new'}).status_code, 404)
+ self.bills.refresh_from_db()
+ self.assertEqual(self.bills.parent_id, self.home.pk)
+ page = self.client.get('/')
+ self.assertNotContains(page, 'BOB-PRIVATE')
+ self.assertEqual(self.client.get('/', {'folder': str(self.foreign.pk), 'recursive': '1'}).status_code, 404)
+
+ def test_cycle_and_descendant_moves_rejected_without_partial_rename(self):
+ for target in [self.home, self.bills, self.year]:
+ response = self.edit(self.home, parent=target, name='should-not-save', follow=True)
+ self.assertContains(response, '不能将文件夹移动到自身或其子文件夹中')
+ self.home.refresh_from_db()
+ self.assertIsNone(self.home.parent_id)
+ self.assertEqual(self.home.name, '家庭')
+ page = self.client.get('/', {'folder': str(self.home.pk)})
+ self.assertEqual([item.pk for item in page.context['move_targets']], [self.work.pk])
+
+ def test_depth_limit_includes_entire_moving_subtree(self):
+ parent = self.work
+ for depth in range(2, 9):
+ self.client.post('/folders/create/', {'name': f'level-{depth}', 'parent': str(parent.pk)})
+ parent = Folder.objects.get(name=f'level-{depth}', owner=self.alice)
+ before = Folder.objects.count()
+ response = self.client.post('/folders/create/', {'name': 'level-9', 'parent': str(parent.pk)}, follow=True)
+ self.assertContains(response, '最多支持 8 层')
+ self.assertEqual(Folder.objects.count(), before)
+ # The moved root itself fits at level 7, but its grandchild would be level 9.
+ sixth = Folder.objects.get(name='level-6')
+ response = self.edit(self.home, parent=sixth, follow=True)
+ self.assertContains(response, '最多支持 8 层')
+ self.home.refresh_from_db()
+ self.assertIsNone(self.home.parent_id)
+ fifth = Folder.objects.get(name='level-5')
+ self.edit(self.home, parent=fifth)
+ self.home.refresh_from_db()
+ self.assertEqual(self.home.parent_id, fifth.pk)
+ self.assertEqual(next(item.depth for item in folder_tree(self.alice) if item.pk == self.year.pk), 7)
+
+ def test_rename_move_subtree_preserves_originals_search_and_folder_ids(self):
+ doc = self.upload(self.year, 'FOLDER-MOVE-42')
+ original_path = doc.file.name
+ original = Path(doc.file.path).read_bytes()
+ self.edit(self.bills, parent=self.work, name='发票')
+ doc.refresh_from_db()
+ self.year.refresh_from_db()
+ self.assertEqual(doc.folder_id, self.year.pk)
+ self.assertEqual(self.year.parent_id, self.bills.pk)
+ self.assertEqual(doc.file.name, original_path)
+ self.assertEqual(Path(doc.file.path).read_bytes(), original)
+ page = self.client.get('/', {'folder': str(self.work.pk), 'recursive': '1', 'q': 'FOLDER-MOVE-42'})
+ self.assertEqual(page.context['page'].paginator.count, 1)
+ self.assertContains(page, '工作 / 发票 / 2026')
+ page = self.client.get('/', {'folder': str(self.year.pk)})
+ self.assertEqual([item.name for item in page.context['breadcrumbs']], ['工作', '发票', '2026'])
+ self.edit(self.bills, name='发票')
+ self.assertEqual(next(item.path for item in folder_tree(self.alice) if item.pk == self.year.pk), '发票 / 2026')
+
+ def test_recursive_filters_pagination_and_tree_counts(self):
+ direct = self.upload(self.home, 'DIRECT')
+ trashed = self.upload(self.year, 'TRASHED')
+ self.client.post(f'/documents/{trashed.pk}/delete/')
+ for index in range(13):
+ Document.objects.create(owner=self.alice, folder=self.year, title=f'SEARCH-{index}',
+ original_name=f'{index}.txt', file=f'originals/test-{index}.txt', kind='txt',
+ size=1, checksum=str(index), status='ready', text='MATCH-TOKEN')
+ Document.objects.create(owner=self.bob, folder=self.foreign, title='MATCH-TOKEN',
+ original_name='private.txt', kind='txt', size=1, checksum='private', status='ready')
+ page = self.client.get('/', {'folder': str(self.home.pk)})
+ self.assertEqual([doc.pk for doc in page.context['page']], [direct.pk])
+ params = {'folder': str(self.home.pk), 'recursive': '1', 'q': 'MATCH-TOKEN', 'kind': 'txt', 'state': 'ready', 'page': '2'}
+ page = self.client.get('/', params)
+ self.assertEqual(page.context['page'].paginator.count, 13)
+ self.assertEqual(len(page.context['page']), 1)
+ self.assertContains(page, 'name="recursive" value="1"')
+ self.assertIn('recursive=1', page.context['filter_query'])
+ self.assertNotIn('page=', page.context['filter_query'])
+ tree = {item.pk: item for item in page.context['folders']}
+ self.assertEqual(tree[self.home.pk].document_count, 1)
+ self.assertEqual(tree[self.year.pk].document_count, 13)
+ self.assertEqual(tree[self.home.pk].child_count, 1)
+ self.assertNotIn(self.foreign.pk, tree)
+ trash = self.client.get('/', {'folder': str(self.home.pk), 'recursive': '1', 'view': 'trash'})
+ self.assertEqual([doc.pk for doc in trash.context['page']], [trashed.pk])
+
+ def test_leaf_removal_reparents_active_and_trashed_documents(self):
+ active = self.upload(self.year, 'KEEP-ACTIVE')
+ trashed = self.upload(self.year, 'KEEP-TRASH')
+ self.client.post(f'/documents/{trashed.pk}/delete/')
+ originals = {doc.pk: (doc.file.name, Path(doc.file.path).read_bytes()) for doc in [active, trashed]}
+ response = self.client.post(f'/folders/{self.year.pk}/delete/')
+ self.assertRedirects(response, '/?folder=' + str(self.bills.pk))
+ for doc in [active, trashed]:
+ doc.refresh_from_db()
+ self.assertEqual(doc.folder_id, self.bills.pk)
+ self.assertEqual(doc.file.name, originals[doc.pk][0])
+ self.assertEqual(Path(doc.file.path).read_bytes(), originals[doc.pk][1])
+ self.assertIsNotNone(trashed.deleted_at)
+ self.client.post(f'/documents/{trashed.pk}/restore/')
+ trashed.refresh_from_db()
+ self.assertIsNone(trashed.deleted_at)
+ self.assertEqual(trashed.folder_id, self.bills.pk)
+
+ def test_parent_removal_blocked_and_database_restricts_direct_delete(self):
+ doc = self.upload(self.bills, 'KEEP-PARENT')
+ page = self.client.post(f'/folders/{self.bills.pk}/delete/', follow=True)
+ self.assertContains(page, '请先移动或移除子文件夹')
+ self.assertTrue(Folder.objects.filter(pk=self.bills.pk).exists())
+ doc.refresh_from_db()
+ self.assertEqual(doc.folder_id, self.bills.pk)
+ with self.assertRaises(RestrictedError):
+ self.home.delete()
+ # An owner's full cascade may remove the whole hierarchy together.
+ owner_id = self.alice.pk
+ self.alice.delete()
+ self.assertFalse(Folder.objects.filter(owner_id=owner_id).exists())
+ self.assertTrue(Folder.objects.filter(pk=self.foreign.pk).exists())
+
+ def test_post_csrf_and_escaping(self):
+ for url in ['/folders/create/', f'/folders/{self.home.pk}/edit/', f'/folders/{self.home.pk}/delete/']:
+ self.assertEqual(self.client.get(url).status_code, 405)
+ strict = Client(enforce_csrf_checks=True)
+ strict.force_login(self.alice)
+ self.assertEqual(strict.post(url, {'name': 'new'}).status_code, 403)
+ self.client.post('/folders/create/', {'name': '', 'parent': str(self.work.pk)})
+ page = self.client.get('/', {'folder': str(self.work.pk)})
+ self.assertContains(page, '<script>alert(1)</script>')
+ self.assertNotContains(page, '')
+ self.client.logout()
+ self.assertEqual(self.edit(self.home).status_code, 302)
+
+ def test_database_constraints_for_roots_siblings_and_self_parent(self):
+ for values in [{'name': '家庭'}, {'name': '账单', 'parent': self.home},
+ {'name': 'self', 'id': self.work.pk, 'parent': self.work}]:
+ with self.assertRaises(IntegrityError), transaction.atomic():
+ if 'id' in values:
+ Folder.objects.filter(pk=self.work.pk).update(parent=self.work)
+ else:
+ Folder.objects.create(owner=self.alice, **values)
+
+
+class FolderUpgradeTests(TransactionTestCase):
+ def test_upgrade_keeps_existing_roots_and_document_metadata(self):
+ old_target = [('documents', '0001_initial')]
+ latest = [('documents', '0002_remove_folder_unique_owner_folder_folder_parent_and_more')]
+ executor = MigrationExecutor(connection)
+ self.addCleanup(lambda: MigrationExecutor(connection).migrate(latest))
+ executor.migrate(old_target)
+ old = executor.loader.project_state(old_target).apps
+ owner = old.get_model('auth', 'User').objects.create(username='legacy-user')
+ folder = old.get_model('documents', 'Folder').objects.create(owner=owner, name='旧文件夹')
+ doc = old.get_model('documents', 'Document').objects.create(owner=owner, folder=folder,
+ title='旧文档', original_name='original.txt', file='originals/legacy.txt', size=42,
+ checksum='legacy-checksum', kind='txt', status='ready', text='legacy text', deleted_at=timezone.now())
+ MigrationExecutor(connection).migrate(latest)
+ upgraded = Folder.objects.get(pk=folder.pk)
+ retained = Document.objects.get(pk=doc.pk)
+ self.assertIsNone(upgraded.parent_id)
+ self.assertEqual(upgraded.name, '旧文件夹')
+ self.assertEqual(retained.folder_id, folder.pk)
+ self.assertEqual(retained.file.name, 'originals/legacy.txt')
+ self.assertEqual(retained.checksum, 'legacy-checksum')
+ self.assertEqual(retained.text, 'legacy text')
+ self.assertIsNotNone(retained.deleted_at)
+ Folder.objects.create(owner_id=owner.pk, name='旧文件夹', parent=upgraded)
diff --git a/documents/views.py b/documents/views.py
index c8d7319..74e0044 100644
--- a/documents/views.py
+++ b/documents/views.py
@@ -14,6 +14,7 @@
from django.utils import timezone
from django.views.decorators.http import require_POST
from .forms import UploadForm
+from .folders import folder_tree, lock_hierarchy
from .models import Document, Folder
@@ -46,11 +47,18 @@ def library(request):
active = all_docs.filter(deleted_at__isnull=True)
mode = request.GET.get('view', 'all')
folder = selected_folder(request, request.GET.get('folder'))
+ folders = folder_tree(request.user)
+ folder_map = {item.pk: item for item in folders}
+ if folder:
+ folder = folder_map.get(folder.pk)
+ if folder is None:
+ raise Http404('文件夹已移除。')
docs = all_docs.filter(deleted_at__isnull=False) if mode == 'trash' else active
if mode == 'inbox':
docs = docs.filter(folder__isnull=True)
+ recursive = request.GET.get('recursive') == '1'
if folder:
- docs = docs.filter(folder=folder)
+ docs = docs.filter(folder_id__in=folder.descendant_ids) if recursive else docs.filter(folder=folder)
query = request.GET.get('q', '').strip()[:200]
if query:
docs = docs.filter(Q(title__icontains=query) | Q(original_name__icontains=query) | Q(text__icontains=query))
@@ -62,14 +70,17 @@ def library(request):
docs = docs.filter(kind=kind)
page = Paginator(docs.select_related('folder'), 12).get_page(request.GET.get('page'))
for doc in page:
+ doc.folder_path = folder_map[doc.folder_id].path if doc.folder_id in folder_map else '待归档'
position = doc.text.casefold().find(query.casefold()) if query else -1
doc.snippet = doc.text[max(0, position - 35):max(0, position - 35) + 140].replace('\n', ' ')
filters = request.GET.copy()
filters.pop('page', None)
return render(request, 'documents/library.html', {
- 'page': page, 'folders': Folder.objects.filter(owner=request.user).annotate(
- document_count=Count('document', filter=Q(document__deleted_at__isnull=True))),
- 'folder': folder, 'query': query, 'mode': mode, 'state': state, 'kind': kind,
+ 'page': page, 'folders': folders,
+ 'child_folders': [item for item in folders if item.parent_id == (folder.pk if folder else None)],
+ 'breadcrumbs': folder.ancestors + [folder] if folder else [],
+ 'move_targets': [item for item in folders if not folder or item.pk not in folder.descendant_ids],
+ 'folder': folder, 'query': query, 'mode': mode, 'state': state, 'kind': kind, 'recursive': recursive,
'stats': active.aggregate(total=Count('id'), ready=Count('id', filter=Q(status='ready')),
pending=Count('id', filter=Q(status__in=['pending', 'processing'])), bytes=Sum('size')),
'trash_count': all_docs.filter(deleted_at__isnull=False).count(),
@@ -81,25 +92,56 @@ def library(request):
@require_POST
def create_folder(request):
name = request.POST.get('name', '').strip()
- if not name or len(name) > 80:
- messages.error(request, '文件夹名称需要 1–80 个字符。')
- return redirect('library')
try:
with transaction.atomic():
- folder = Folder.objects.create(owner=request.user, name=name)
+ lock_hierarchy(request.user)
+ parent = selected_folder(request, request.POST.get('parent'))
+ folder = Folder(owner=request.user, name=name, parent=parent)
+ folder.full_clean(validate_unique=False, validate_constraints=False)
+ folder.save()
+ except ValidationError as exc:
+ messages.error(request, ' '.join(exc.messages))
+ return redirect('library')
except IntegrityError:
messages.error(request, '已存在同名文件夹。')
return redirect('library')
return redirect('/?folder=' + str(folder.pk))
+@login_required
+@require_POST
+def edit_folder(request, pk):
+ try:
+ with transaction.atomic():
+ lock_hierarchy(request.user)
+ folder = get_object_or_404(Folder, pk=pk, owner=request.user)
+ folder.parent = selected_folder(request, request.POST.get('parent'))
+ folder.name = request.POST.get('name', '').strip()
+ folder.full_clean(validate_unique=False, validate_constraints=False)
+ folder.save(update_fields=['name', 'parent'])
+ except ValidationError as exc:
+ messages.error(request, ' '.join(exc.messages))
+ except IntegrityError:
+ messages.error(request, '同一位置已存在同名文件夹。')
+ else:
+ messages.success(request, '文件夹已更新,文档原文件保持不变。')
+ return redirect('/?folder=' + str(pk))
+
+
@login_required
@require_POST
def delete_folder(request, pk):
- folder = get_object_or_404(Folder, pk=pk, owner=request.user)
- folder.delete()
- messages.success(request, '文件夹已移除,文档保留在待归档中。')
- return redirect('library')
+ with transaction.atomic():
+ lock_hierarchy(request.user)
+ folder = get_object_or_404(Folder, pk=pk, owner=request.user)
+ if folder.children.exists():
+ messages.error(request, '请先移动或移除子文件夹,再移除当前文件夹。')
+ return redirect('/?folder=' + str(pk))
+ parent = folder.parent
+ Document.objects.filter(owner=request.user, folder=folder).update(folder=parent)
+ folder.delete()
+ messages.success(request, '文件夹已移除,文档保留在上级文件夹中。' if parent else '文件夹已移除,文档保留在待归档中。')
+ return redirect('/?folder=' + str(parent.pk) if parent else 'library')
@login_required
@@ -142,8 +184,7 @@ def detail(request, pk):
doc = owned(request, pk)
active = Document.objects.filter(owner=request.user, deleted_at__isnull=True)
return render(request, 'documents/detail.html', {
- 'doc': doc, 'folders': Folder.objects.filter(owner=request.user).annotate(
- document_count=Count('document', filter=Q(document__deleted_at__isnull=True))),
+ 'doc': doc, 'folders': folder_tree(request.user),
'stats': {'total': active.count()}, 'inbox_count': active.filter(folder__isnull=True).count(),
'trash_count': Document.objects.filter(owner=request.user, deleted_at__isnull=False).count(),
})
diff --git a/folderdocs/urls.py b/folderdocs/urls.py
index 81ee79d..aff82ab 100644
--- a/folderdocs/urls.py
+++ b/folderdocs/urls.py
@@ -8,6 +8,7 @@
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
path('register/', views.register, name='register'),
path('folders/create/', views.create_folder, name='create_folder'),
+ path('folders//edit/', views.edit_folder, name='edit_folder'),
path('folders//delete/', views.delete_folder, name='delete_folder'),
path('upload/', views.upload, name='upload'),
path('documents//', views.detail, name='detail'),
diff --git a/qa/browser.cjs b/qa/browser.cjs
index 172f477..ed56f9a 100644
--- a/qa/browser.cjs
+++ b/qa/browser.cjs
@@ -5,7 +5,7 @@ const assert = require('assert/strict');
const { chromium } = require('playwright');
const root = path.resolve(__dirname, '..');
const url = process.env.FOLDERDOCS_TEST_URL || 'http://127.0.0.1:8765';
-const account = JSON.parse(fs.readFileSync(path.join(root, 'qa-output', 'account.json'), 'utf8'));
+const account = JSON.parse(fs.readFileSync(process.env.FOLDERDOCS_TEST_ACCOUNT || path.join(root, 'qa-output', 'account.json'), 'utf8'));
const folderName = '浏览器验证-' + Date.now();
(async () => {
@@ -57,7 +57,7 @@ const folderName = '浏览器验证-' + Date.now();
page.once('dialog', dialog => dialog.accept());
await page.getByRole('button', { name: '永久删除', exact: true }).click();
await page.goto(url + '/');
- await page.getByRole('link', { name: folderName, exact: false }).click();
+ await page.locator('.folder-nav').getByRole('link', { name: folderName, exact: false }).click();
page.once('dialog', dialog => dialog.accept());
await page.getByRole('button', { name: '移除文件夹', exact: true }).click();
await page.goto(url + '/');
diff --git a/qa/folders.cjs b/qa/folders.cjs
new file mode 100644
index 0000000..368cb09
--- /dev/null
+++ b/qa/folders.cjs
@@ -0,0 +1,105 @@
+// Hierarchy browser regression checks against synthetic demo data only.
+const fs = require('fs');
+const path = require('path');
+const assert = require('assert/strict');
+const { chromium } = require('playwright');
+const root = path.resolve(__dirname, '..');
+const url = process.env.FOLDERDOCS_TEST_URL || 'http://127.0.0.1:8765';
+const account = JSON.parse(fs.readFileSync(process.env.FOLDERDOCS_TEST_ACCOUNT || path.join(root, 'qa-output', 'account.json'), 'utf8'));
+
+(async () => {
+ const browser = await chromium.launch({ headless: true, channel: process.env.PLAYWRIGHT_CHANNEL || 'msedge' });
+ const context = await browser.newContext({ viewport: { width: 1440, height: 1050 } });
+ const page = await context.newPage();
+ const errors = [];
+ page.on('pageerror', error => errors.push(error.message));
+ const folderId = () => new URL(page.url()).searchParams.get('folder');
+ const breadcrumb = async () => (await page.locator('.breadcrumb').innerText()).replace(/\s+/g, ' ').trim();
+ const navigate = action => Promise.all([page.waitForNavigation(), action()]);
+ async function originalBytes(href) {
+ const response = await context.request.get(url + href);
+ assert.equal(response.status(), 200);
+ const bytes = await response.body();
+ assert.equal(bytes.subarray(0, 5).toString(), '%PDF-');
+ return bytes;
+ }
+ async function createFolder(name) {
+ const existing = page.locator('.folder-tile strong').getByText(name, { exact: true });
+ if (await existing.count()) {
+ await navigate(() => existing.click());
+ return folderId();
+ }
+ await page.getByRole('button', { name: '在此新建文件夹', exact: true }).click();
+ await page.locator('#folder-dialog input[name=name]').fill(name);
+ await navigate(() => page.getByRole('button', { name: '创建文件夹', exact: true }).click());
+ return folderId();
+ }
+ try {
+ await page.goto(url + '/login/');
+ await page.locator('#id_username').fill(account.username);
+ await page.locator('#id_password').fill(account.password);
+ await navigate(() => page.getByRole('button', { name: '登录我的空间' }).click());
+ const homeHref = await page.locator('.folder-nav a[title="家庭账单"]').getAttribute('href');
+ const workHref = await page.locator('.folder-nav a[title="合同与凭证"]').getAttribute('href');
+ const work = new URL(workHref, url).searchParams.get('folder');
+ await page.goto(url + homeHref);
+ const home = folderId();
+ const year = await createFolder('2026');
+ const receipts = await createFolder('电器凭证');
+ assert.equal(await breadcrumb(), '我的空间 / 文档库 / 家庭账单 / 2026 / 电器凭证');
+ await page.goto(url + '/');
+ await page.getByRole('link', { name: '家用电器购买凭证', exact: true }).click();
+ const detailUrl = page.url();
+ const fileHref = await page.getByRole('link', { name: '下载原文件' }).getAttribute('href');
+ const original = await originalBytes(fileHref);
+ await page.locator('select[name=folder]').selectOption(receipts);
+ assert.equal(await page.locator(`select[name=folder] option[value="${receipts}"]`).innerText(), '家庭账单 / 2026 / 电器凭证');
+ await navigate(() => page.getByRole('button', { name: '保存修改', exact: true }).click());
+ await page.goto(url + homeHref);
+ assert.equal(await page.getByRole('link', { name: '家用电器购买凭证', exact: true }).count(), 0);
+ await navigate(() => page.getByRole('checkbox', { name: '包含子文件夹' }).check());
+ assert.equal(await page.getByRole('link', { name: '家用电器购买凭证', exact: true }).count(), 1);
+ await page.getByRole('searchbox', { name: '搜索文档' }).fill('FD-2026-014');
+ await navigate(() => page.getByRole('button', { name: '搜索', exact: true }).click());
+ assert.equal(new URL(page.url()).searchParams.get('recursive'), '1');
+ assert.equal(await page.locator('.document-card').count(), 1);
+ await page.goto(url + '/?folder=' + year);
+ assert.equal(await page.getByRole('button', { name: '移除文件夹', exact: true }).isDisabled(), true);
+ await page.getByRole('button', { name: '重命名或移动', exact: true }).click();
+ assert.equal(await page.locator(`#edit-folder-dialog option[value="${year}"], #edit-folder-dialog option[value="${receipts}"]`).count(), 0);
+ await page.locator('#edit-folder-dialog input[name=name]').fill('2026 年');
+ await page.locator('#edit-folder-dialog select[name=parent]').selectOption(work);
+ await navigate(() => page.getByRole('button', { name: '保存文件夹', exact: true }).click());
+ assert.match(await breadcrumb(), /合同与凭证 \/ 2026 年/);
+ await page.locator('.folder-tile').getByText('电器凭证', { exact: true }).click();
+ assert.equal(folderId(), receipts);
+ assert.match(await breadcrumb(), /合同与凭证 \/ 2026 年 \/ 电器凭证/);
+ const preserved = await originalBytes(fileHref);
+ assert.equal(Buffer.compare(original, preserved), 0);
+ await page.goto(detailUrl);
+ assert.equal(await page.locator('select[name=folder]').inputValue(), receipts);
+ await page.goto(url + '/?folder=' + year + '&recursive=1');
+ await page.screenshot({ path: path.join(root, 'docs/screenshots/nested-folders.png'), fullPage: true });
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.screenshot({ path: path.join(root, 'docs/screenshots/nested-folders-mobile.png'), fullPage: true });
+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
+ await page.locator('.folder-tile').getByText('电器凭证', { exact: true }).click();
+ page.once('dialog', dialog => dialog.accept());
+ await navigate(() => page.getByRole('button', { name: '移除文件夹', exact: true }).click());
+ assert.equal(folderId(), year);
+ assert.equal(await page.getByRole('link', { name: '家用电器购买凭证', exact: true }).count(), 1);
+ // Restore the demo so the check is repeatable without losing documents.
+ await page.goto(detailUrl);
+ await page.locator('select[name=folder]').selectOption(home);
+ await navigate(() => page.getByRole('button', { name: '保存修改', exact: true }).click());
+ await page.goto(url + '/?folder=' + year);
+ page.once('dialog', dialog => dialog.accept());
+ await navigate(() => page.getByRole('button', { name: '移除文件夹', exact: true }).click());
+ assert.equal(folderId(), work);
+ assert.deepEqual(errors, []);
+ console.log('Hierarchy browser checks passed: create, breadcrumbs, recursive search, rename, subtree move, identical original, safe removal, mobile navigation.');
+ } finally {
+ await context.close();
+ await browser.close();
+ }
+})().catch(error => { console.error(error); process.exit(1); });
diff --git a/qa/prepare_demo.py b/qa/prepare_demo.py
index 3becfe2..30256f6 100644
--- a/qa/prepare_demo.py
+++ b/qa/prepare_demo.py
@@ -14,7 +14,8 @@
output = ROOT / 'qa-output'
output.mkdir(exist_ok=True)
-account_file = output / 'account.json'
+account_file = Path(os.environ.get('FOLDERDOCS_TEST_ACCOUNT', output / 'account.json'))
+account_file.parent.mkdir(parents=True, exist_ok=True)
if not account_file.exists():
stream = io.StringIO()
call_command('seed_demo', username='demo', stdout=stream)
@@ -22,4 +23,4 @@
password = next(line.split(': ', 1)[1] for line in lines if line.startswith('Demo password: '))
account_file.write_text(json.dumps({'username': 'demo', 'password': password}), encoding='utf-8')
call_command('worker', drain=True)
-print('Synthetic demo ready; browser credentials saved in ignored qa-output/account.json.')
+print('Synthetic demo ready; browser credentials saved in the selected QA account file.')
diff --git a/static/app.css b/static/app.css
index 2be9c5a..0ac7cf4 100644
--- a/static/app.css
+++ b/static/app.css
@@ -1,2 +1,19 @@
@charset "UTF-8";
:root{--ink:#23352d;--muted:#7c8780;--green:#2d624b;--mint:#e6eee8;--line:#e6e9e3;--bg:#f6f7f3;--white:#fff;--radius:14px}*{box-sizing:border-box}body{margin:0;color:var(--ink);background:var(--bg);font-family:"Segoe UI","Microsoft YaHei",sans-serif;font-size:14px;line-height:1.6}a{color:inherit;text-decoration:none}button,input,select{font:inherit}button,a,input,select{outline-offset:4px}button{cursor:pointer}button:disabled{cursor:wait;opacity:.6}button,input,select{border:1px solid var(--line)}input,select{background:#fff;color:var(--ink);border-radius:8px;padding:11px 12px;min-width:0}input:focus,select:focus{outline:2px solid #b9d7c4;border-color:var(--green)}.sidebar{position:fixed;top:0;bottom:0;left:0;width:244px;background:#eef1ea;border-right:1px solid var(--line);padding:31px 22px 20px;display:flex;flex-direction:column}.brand{display:flex;align-items:center;gap:12px;font-size:22px;font-weight:700;letter-spacing:-.7px}.brand-mark{width:36px;height:36px;background:var(--green);border-radius:11px;color:white;display:flex;align-items:center;justify-content:center;font-size:27px;line-height:1;position:relative}.brand-mark span{color:#c1d9a2}.workspace-label{font-size:11px;margin:37px 10px 15px;color:var(--muted);display:flex;align-items:center;justify-content:space-between}.workspace-label span{font-size:9px;letter-spacing:1px}.nav-item{display:flex;align-items:center;gap:13px;min-height:45px;padding:10px 12px;margin:4px 0;border-radius:8px;color:#627065;font-size:13px}.nav-item>span:first-child{font-size:22px;line-height:1;width:20px;color:#809084}.nav-item small{margin-left:auto;font-size:11px;color:#8a978b}.nav-item.active{background:#dde7d9;color:#294c36;font-weight:600}.nav-item.active>span:first-child{color:#396349}.nav-item:hover{background:#e4ebe0}.section-label{display:flex;align-items:center;justify-content:space-between;margin:26px 10px 9px;font-size:11px;color:#7c8a7f}.icon-button{background:none;border:0;border-radius:5px;font-size:22px;color:#7f8d83;line-height:1;padding:4px}.icon-button:hover{background:#dce5d8}.folder-nav{overflow:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sidebar-hint{font-size:12px;color:var(--muted);padding:0 12px}.sidebar-bottom{margin-top:auto;padding-top:26px}.privacy-card{padding:17px 14px;background:#e5ebde;border-radius:10px;font-size:11px}.privacy-card strong{font-weight:600}.privacy-card p{color:#829079;margin:8px 0 0;line-height:1.7}.privacy-dot{display:inline-block;width:6px;height:6px;background:#769659;border-radius:50%;margin-right:8px}.account{display:flex;align-items:center;gap:11px;padding-top:22px}.avatar{background:#d8e2cf;color:#4d6650;width:32px;height:32px;border-radius:50%;display:grid;place-items:center;font-weight:600}.account strong,.account small{display:block;font-size:12px}.account small{font-size:10px;color:var(--muted)}.account form{margin-left:auto}.main{margin-left:244px;min-height:100vh;padding:0 42px;display:flex;flex-direction:column}.topbar{height:79px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);flex-shrink:0}.breadcrumb{font-size:12px;color:#849188}.breadcrumb span{padding:0 14px;color:#b6bfb8}.local-badge{font-size:11px;color:#728574}.local-badge i{display:inline-block;background:#7ba277;width:6px;height:6px;border-radius:50%;margin-right:8px}.page-heading{display:flex;align-items:center;justify-content:space-between;gap:20px;margin:40px 0 31px}.eyebrow{font-size:10px;letter-spacing:2px;color:#84957e;font-weight:600;margin-bottom:9px}h1{font-size:29px;font-weight:650;letter-spacing:-.6px;margin:0;line-height:1.4}.page-heading p{font-size:12px;color:var(--muted);margin:11px 0 0}.button{display:inline-flex;align-items:center;justify-content:center;gap:9px;padding:10px 17px;background:#fff;color:var(--ink);border:1px solid #dbe1d8;border-radius:8px;font-size:12px;font-weight:600;white-space:nowrap}.button.primary{color:#fff;background:var(--green);border-color:var(--green)}.button:hover{filter:brightness(.96)}.button span{font-size:20px;line-height:1}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:17px;margin-bottom:34px}.stats>div{padding:21px 19px;background:#fff;border:1px solid var(--line);border-radius:12px;display:flex;align-items:center;gap:15px}.stat-icon{width:40px;height:40px;border-radius:11px;background:#edf0e9;color:#7c8970;display:grid;place-items:center;font-size:23px;flex-shrink:0}.stat-icon.mint{background:#edf3ee;color:#619978}.stat-icon.sand{background:#f7f1e7;color:#b29b6e}.stat-icon.lilac{background:#f0eef5;color:#9a87b1}.stats small{color:var(--muted);font-size:11px;display:block}.stats strong{font-size:25px;font-weight:600;display:block;margin-top:1px;line-height:1.5}.stats em{font-size:10px;font-style:normal;color:#93a08e;font-weight:400}.stats strong.storage-value{font-size:20px}.library-panel{background:#fff;border:1px solid var(--line);border-radius:14px;overflow:hidden}.library-toolbar{display:flex;align-items:center;justify-content:space-between;gap:15px;padding:24px 25px 15px}h2{font-size:15px;font-weight:600;margin:0}.library-toolbar h2 span{font-size:10px;background:#f0f2ed;padding:3px 7px;margin-left:7px;border-radius:5px;color:#83907d}.search{display:flex;align-items:center;background:#f7f8f5;border:1px solid #e9ede5;border-radius:8px;padding-left:11px;width:min(390px,60%)}.search span{font-size:24px;color:#8b9788}.search input{width:100%;background:none;border:0;padding:8px 9px;font-size:11px}.search button{background:none;border:0;padding:8px 13px;font-size:11px;color:var(--green);white-space:nowrap}.filter-row{display:flex;align-items:center;gap:9px;padding:0 25px 19px}.filter-row select{font-size:11px;padding:6px 9px;color:#74836f;background:#fafbf8;border-color:#e8ede3}.sort-label{margin-left:auto;font-size:10px;color:#a3ada0}.document-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:20px;padding:0 25px 25px}.document-card{border:1px solid #e7ebe4;border-radius:11px;overflow:hidden;transition:transform .18s,box-shadow .18s;background:#fff}.document-card:hover{transform:translateY(-3px);box-shadow:0 8px 24px #293b2810}.preview{height:186px;display:flex;justify-content:center;align-items:flex-start;position:relative;background:#f0f1ea;overflow:hidden;padding:19px 28px 0}.preview img{max-width:100%;width:160px;height:auto;box-shadow:0 3px 14px #1a302915;display:block}.preview-image{background:#eeeae3}.preview-txt{background:#ebefec}.type-badge{position:absolute;left:13px;bottom:12px;border-radius:4px;font-size:9px;letter-spacing:.5px;color:#647158;background:#ffffffe0;border:1px solid #e5e9de;padding:2px 6px}.paper-placeholder{width:158px;min-height:210px;background:#fff;padding:26px 20px;box-shadow:0 3px 14px #1a302915}.paper-placeholder span{font-size:15px;color:#476b53;letter-spacing:2px;font-weight:700}.paper-placeholder i{height:5px;background:#e8ece6;display:block;margin-top:16px}.paper-placeholder i:nth-child(3){width:65%}.paper-placeholder i:nth-child(4){width:90%}.card-body{padding:15px 15px 13px}.document-title{display:block;font-size:12px;font-weight:600;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.document-meta{font-size:10px;color:#99a292;margin:5px 0 14px}.document-meta span{padding:0 5px}.card-bottom{border-top:1px solid #edf0e9;padding-top:11px;display:flex;align-items:center;justify-content:space-between;gap:5px}.folder-tag{font-size:10px;color:#87957f;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.status{font-size:9px;display:inline-flex;align-items:center;gap:5px;padding:3px 6px;border-radius:4px;white-space:nowrap}.status:before{content:"";width:4px;height:4px;border-radius:50%;background:currentColor}.status-ready{background:#edf4ec;color:#659268}.status-pending,.status-processing{background:#f8f2e7;color:#b19356}.status-failed{background:#fcf0ed;color:#bc786b}.panel-footer{border-top:1px solid var(--line);padding:16px 25px;display:flex;align-items:center;justify-content:space-between;font-size:10px;color:#9aa591}.panel-footer>div{display:flex;gap:15px;align-items:center}.panel-footer a{color:var(--green)}.footer{display:flex;align-items:center;justify-content:space-between;font-size:10px;color:#a8b09f;padding:25px 0;margin-top:auto;gap:20px}.footer>span:first-child{font-weight:600;letter-spacing:.5px}.empty-state{text-align:center;padding:48px 20px 55px}.empty-icon{font-size:48px;color:#7e9879;background:#f0f3eb;border-radius:18px;width:84px;margin:0 auto 19px}.empty-state h3{font-size:17px;margin:0 0 9px;font-weight:600}.empty-state p{font-size:12px;color:var(--muted);margin:0 0 21px}.empty-state small{display:block;font-size:10px;color:#9ca695;margin-top:14px}.messages{padding-top:15px}.message{font-size:12px;background:#eaf1e4;border:1px solid #dce7d2;border-radius:8px;padding:9px 13px;margin-bottom:6px}.message.error{background:#faece8;border-color:#efd7ce;color:#a76456}.message.info{background:#f3f0e4}.text-button{background:none;border:0;font-size:12px;color:var(--green);padding:7px 0}.danger{color:#b4756c}.folder-delete{margin:16px 0 0}.restore-form{margin-top:7px}.restore-form button{font-size:10px}.snippet{font-size:11px;color:#73806d;background:#f7f8f3;padding:8px;border-radius:5px;max-height:70px;overflow:hidden}dialog{border:1px solid #dce5d8;border-radius:17px;padding:27px;width:min(470px,calc(100% - 36px));box-shadow:0 25px 80px #23352d22;color:var(--ink)}dialog::backdrop{background:#182d2566;backdrop-filter:blur(3px)}.dialog-heading{display:flex;justify-content:space-between;align-items:center}.dialog-heading h2{font-size:19px}dialog p{font-size:12px;color:var(--muted)}label{display:block;font-size:12px;color:#65715f;margin:16px 0}label input,label select{display:block;width:100%;margin-top:7px}dialog form>.button{width:100%;margin-top:8px}.upload-zone{position:relative;border:1px dashed #bdcbb7;background:#f6f8f2;border-radius:12px;text-align:center;padding:26px 18px;overflow:hidden}.upload-zone input{opacity:0;position:absolute;inset:0;cursor:pointer;margin:0;padding:0}.upload-zone strong,.upload-zone>span{display:block;font-size:12px}.upload-zone>span:last-of-type{font-size:10px;color:#97a18e;margin-top:6px}.upload-zone .upload-icon{font-size:32px;color:#658262}.upload-zone.dragging{background:#e3eedb;border-color:var(--green)}.file-selection{word-break:break-all;font-size:11px}.detail-heading h1{font-size:27px;overflow-wrap:anywhere}.detail-layout{display:grid;grid-template-columns:minmax(0,1fr) 280px;gap:23px}.viewer-panel,.document-inspector,.extracted-panel{background:white;border:1px solid var(--line);border-radius:12px;overflow:hidden}.viewer-heading{display:flex;justify-content:space-between;align-items:center;padding:18px 22px;border-bottom:1px solid var(--line)}.viewer-heading span{font-size:10px;color:var(--muted)}.viewer-panel iframe{border:0;width:100%;height:680px;display:block}.image-viewer{background:#eef0e9;padding:24px;min-height:400px;text-align:center}.image-viewer img{max-width:100%;max-height:680px}.document-inspector section{padding:21px;border-bottom:1px solid var(--line)}.document-inspector section:last-child{border-bottom:0}.document-inspector h2{margin-bottom:17px}.document-inspector .status{font-size:11px}.document-inspector dl{display:grid;grid-template-columns:1fr 1fr;font-size:11px;gap:12px;margin-top:22px}.document-inspector dt{color:#8d9a86}.document-inspector dd{margin:0;text-align:right}.document-inspector .button{width:100%}.error-note,.info-note{font-size:11px;padding:10px;background:#f9f4e8;border-radius:6px;overflow-wrap:anywhere}.error-note{background:#faece8;color:#a16b61}.extracted-panel{margin-top:25px;padding:23px}.extracted-panel h2 small{font-size:10px;color:#95a08c;margin-left:9px}pre{white-space:pre-wrap;word-break:break-word;font-family:inherit;font-size:12px;line-height:1.9}.extracted-panel pre{max-height:350px;overflow:auto;color:#687664;margin:18px 0 0}.text-preview{padding:20px;color:#687664;height:680px;overflow:auto;margin:0}.auth-layout{min-height:100vh;display:grid;grid-template-columns:1.1fr 1fr}.auth-story{background:#e9efe3;padding:40px 60px;display:flex;flex-direction:column;justify-content:space-between;gap:60px}.auth-story h1{font-size:37px;line-height:1.5;margin-bottom:19px}.auth-story p{font-size:13px;color:#7c8e74;line-height:2}.auth-story>small{font-size:11px;letter-spacing:1px;color:#8a9b7e}.auth-form{display:grid;place-items:center;padding:45px;background:#fafbf8}.auth-card{width:100%;max-width:390px}.auth-badge{display:inline-block;padding:5px 10px;background:#edf1e7;font-size:10px;color:#779465;border-radius:5px;margin-bottom:19px}.auth-card h2{font-size:27px}.auth-card>p{color:#87927d;font-size:12px;margin-bottom:25px}.auth-card form>p{font-size:12px;margin:19px 0}.auth-card input{display:block;width:100%;margin-top:8px}.auth-card .button{width:100%;margin-top:16px}.helptext{display:block;font-size:10px;color:#96a18d;margin-top:6px}.helptext ul{padding-left:17px}.errorlist{background:#faeae4;color:#aa6c59;border-radius:8px;padding:12px 12px 12px 27px;font-size:11px}.auth-switch{border-top:1px solid var(--line);padding-top:24px;text-align:center}.auth-switch a{font-weight:600;color:var(--green)}.auth-illustration{margin:52px 0 10px;position:relative;height:240px}.illustration-folder{width:320px;height:210px;background:#b9cda4;border-radius:15px;position:relative;transform:rotate(-6deg);padding:17px 20px;box-shadow:0 18px 38px #45612b15}.illustration-folder:before{content:"";position:absolute;top:-15px;left:0;width:115px;height:25px;background:#b9cda4;border-radius:13px 17px 0 0}.illustration-folder>span{font-size:11px;color:#6b8759;letter-spacing:2px}.illustration-paper{position:absolute;top:48px;left:33px;right:-25px;height:177px;background:#fffdf7;border-radius:5px;transform:rotate(12deg);padding:25px;box-shadow:0 7px 20px #627b4712}.illustration-paper b{font-size:13px;color:#7e8b70}.illustration-paper i{display:block;background:#e5e9dd;height:4px;margin-top:15px;width:87%}.illustration-paper i:nth-of-type(2){width:63%}.illustration-paper em{display:block;font-size:8px;font-style:normal;color:#abb59b;margin-top:21px;letter-spacing:1px}.register-layout{max-width:430px;margin:0 auto;min-height:100vh;padding:40px 20px}.register-layout .brand{margin-bottom:45px}.register-layout .auth-card{max-width:100%}@media(min-width:1550px){.main{padding:0 65px}.document-grid{grid-template-columns:repeat(4,minmax(0,1fr))}.preview{height:210px}}@media(max-width:1150px){.sidebar{width:210px;padding:27px 15px}.main{margin-left:210px;padding:0 25px}.stats{gap:10px}.stats>div{padding:16px 12px;gap:9px}.stat-icon{width:30px;height:34px;font-size:19px}.stats strong{font-size:22px}.document-grid{gap:13px;padding:0 18px 18px}.preview{padding:18px 20px 0}.library-toolbar{padding:20px 18px 15px}.filter-row{padding-left:18px;padding-right:18px}.auth-story{padding:35px}.auth-story h1{font-size:31px}.detail-layout{grid-template-columns:minmax(0,1fr) 250px}}@media(max-width:850px){.stats{grid-template-columns:repeat(2,1fr)}.document-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.page-heading{align-items:flex-start}h1{font-size:25px}.detail-layout{grid-template-columns:1fr}.document-inspector{display:grid;grid-template-columns:1fr 1fr}.document-inspector .document-actions{grid-column:1/-1}.auth-layout{grid-template-columns:1fr}.auth-story{display:none}.auth-form{min-height:100vh}.sort-label{display:none}}@media(max-width:600px){.sidebar{position:static;width:100%;padding:16px 18px;flex-direction:row;align-items:center;gap:20px;border-bottom:1px solid var(--line)}.sidebar .brand{font-size:19px}.sidebar .brand-mark{width:29px;height:29px;font-size:22px}.sidebar>.workspace-label,.sidebar>.section-label,.sidebar>.folder-nav,.sidebar-bottom{display:none}.sidebar>nav{display:flex;margin-left:auto}.sidebar>nav .nav-item{font-size:11px;padding:6px;gap:5px;min-height:35px}.sidebar>nav .nav-item small,.sidebar>nav .nav-item>span{display:none}.main{margin-left:0;padding:0 17px}.topbar{height:55px}.page-heading{margin:27px 0 23px;flex-wrap:wrap}.page-heading p{font-size:11px}.eyebrow{font-size:8px}h1{font-size:25px}.stats{margin-bottom:24px}.stats>div{padding:15px 12px}.library-toolbar{flex-wrap:wrap;padding:20px 15px 13px}.search{width:100%;max-width:none}.filter-row{padding:0 15px 15px}.document-grid{padding:0 13px 16px;gap:10px}.preview{height:135px;padding:15px 14px 0}.preview img{width:120px}.paper-placeholder{padding:16px 14px;min-height:160px}.paper-placeholder span{font-size:12px}.card-body{padding:10px}.document-title{font-size:11px}.document-meta{font-size:9px}.card-bottom{flex-direction:column;align-items:flex-start;gap:6px}.panel-footer{padding:14px}.footer{font-size:9px}.local-badge{font-size:9px}.document-inspector{grid-template-columns:1fr}.detail-heading h1{font-size:23px}.viewer-panel iframe{height:480px}.auth-form{padding:28px}.auth-card h2{font-size:26px}}
+/* Folder hierarchy: bounded indentation and paths that fit narrow screens. */
+.folder-nav .folder-link{padding-left:calc(12px + var(--folder-depth,0)*12px)}
+.breadcrumb{display:flex;align-items:center;flex-wrap:wrap;gap:8px;min-width:0;overflow-wrap:anywhere}
+.breadcrumb a{color:inherit}.breadcrumb a[aria-current=page]{color:var(--ink);font-weight:600}
+.folder-section{margin-bottom:24px}.folder-section-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px}
+.folder-section-heading h2{font-size:17px;margin:0}.folder-actions{display:flex;gap:8px;flex-wrap:wrap}
+.folder-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}
+.folder-tile{display:flex;align-items:center;gap:12px;border:1px solid #dfe6e2;background:#fff;border-radius:12px;padding:16px;min-width:0;color:inherit}
+.folder-tile:hover{border-color:#83a294;background:#f7faf8}.folder-tile-icon{font-size:30px;color:#567864}
+.folder-tile>div{flex:1;min-width:0}.folder-tile strong{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px}
+.folder-tile small{display:block;color:#78857e;font-size:12px;margin-top:5px}.folder-hint{color:#78857e;font-size:13px}
+.filter-row .recursive-filter{display:flex;align-items:center;gap:7px;font-size:13px;margin:0;white-space:nowrap}
+.recursive-filter input{width:auto;margin:0;accent-color:#426650}.card-bottom .folder-tag{max-width:60%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
+.folder-delete small{margin-left:12px;color:#78857e}.folder-delete button:disabled{opacity:.45;cursor:not-allowed}
+@media(max-width:1100px){.folder-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
+@media(max-width:700px){.folder-grid{grid-template-columns:1fr}.folder-section-heading{align-items:flex-start;flex-direction:column}.folder-actions .button{padding:9px 12px}.topbar{gap:10px;height:auto;min-height:68px}.local-badge{flex-shrink:0}.folder-delete small{display:block;margin:8px 0}}
+.filter-row{flex-wrap:wrap}.breadcrumb span{padding:0 3px}.page-heading h1{overflow-wrap:anywhere}
diff --git a/templates/base.html b/templates/base.html
index 89424e8..429dd02 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -14,7 +14,7 @@
文件夹