{{ skill.name }}
+{{ skill.skillKey }}
+ {{ skill.description }}
+{{ skill.triggersText }}
+ +diff --git a/agents.md b/agents.md index 6ea0af3..dd52b63 100644 --- a/agents.md +++ b/agents.md @@ -12,13 +12,13 @@ - 前端类型定义: `web/src/types` - 前端 mock: `web/src/mock/handlers` + `web/src/mock/state.ts` - 前端鉴权状态: `web/src/store/user.ts` -- 后端入口: `app/src/main.py` -- 后端路由: `app/src/routers` -- 后端服务层: `app/src/services` -- 后端依赖/鉴权: `app/src/core/deps.py` -- 后端 schema: `app/src/schemas` -- 后端模型: `app/src/models/tables_*.py` -- 数据库会话: `app/src/db` +- 后端入口: `app/src/fileflash/main.py` +- 后端路由: `app/src/fileflash/routers` +- 后端服务层: `app/src/fileflash/services` +- 后端依赖/鉴权: `app/src/fileflash/core/deps.py` +- 后端 schema: `app/src/fileflash/schemas` +- 后端模型: `app/src/fileflash/models/tables_*.py` +- 数据库会话: `app/src/fileflash/db` ## 3. 全局接口契约 @@ -75,7 +75,7 @@ 2. `web/src/api` 3. `web/src/mock/handlers` + `web/src/mock/state.ts` 4. 对应页面/store - 5. 后端 `app/src/schemas` + `app/src/routers/services` + 5. 后端 `app/src/fileflash/schemas` + `app/src/fileflash/routers/services` - 鉴权状态规则: - 仅持久化 `accessToken`(当前策略) - 刷新流程依赖 Cookie(`axios.withCredentials = true`) @@ -103,7 +103,7 @@ - 前端类型检查: `bun run check`(`web` 目录) - 前端构建: `bun run build`(`web` 目录) - 后端测试: `uv run pytest`(`app` 目录) -- 后端启动冒烟: `uv run python -c "from src.main import app; print(app.title)"`(`app` 目录) +- 后端启动冒烟: `uv run python -c "from fileflash.main import app; print(app.title)"`(`app` 目录) ## 10. 安全与配置要求 diff --git a/app/src/.env.example b/app/.env.example similarity index 71% rename from app/src/.env.example rename to app/.env.example index 5399e13..b466c14 100644 --- a/app/src/.env.example +++ b/app/.env.example @@ -3,7 +3,10 @@ FF_DB_URI=postgresql://root:password@localhost:5432/fileflash # DATABASE_URL=postgresql://root:password@localhost:5432/fileflash APP_ENV=development -JWT_SECRET_KEY=please-change-me +JWT_SECRET_KEY=please-set-at-least-32-bytes-secret-key +# Optional dedicated HMAC key for token hash persistence. +# Falls back to JWT_SECRET_KEY when omitted. +# TOKEN_HASH_SECRET=please-set-at-least-32-bytes-and-different-from-jwt-secret ACCESS_TOKEN_EXPIRE_MINUTES=4320 REFRESH_TOKEN_EXPIRE_DAYS=7 @@ -23,12 +26,14 @@ UPLOAD_CHUNK_SIZE_DEFAULT=5242880 UPLOAD_CHUNK_SIZE_MIN=1048576 UPLOAD_CHUNK_SIZE_MAX=16777216 UPLOAD_SINGLE_FILE_SIZE_MAX=5368709120 +STARRED_ITEMS_LIMIT=20 UPLOAD_SESSION_TTL_HOURS=24 UPLOAD_TEMP_PREFIX=tmp UPLOAD_OBJECT_PREFIX=objects WORKER_POLL_INTERVAL_SECONDS=2 WORKER_CONCURRENCY=2 +WORKER_PROCESS_COUNT=1 WORKER_TASK_TIMEOUT_SECONDS=900 WORKER_DEFAULT_MAX_ATTEMPTS=5 WORKER_RETRY_BACKOFF_SECONDS=30,120,600,1800,7200 @@ -60,8 +65,15 @@ AGENT_MCP_ENDPOINTS=[] # FFPROBE_BINARY=ffprobe # Optional SMTP settings for real email delivery. -# MAIL_FROM=no-reply@example.com -# MAIL_USERNAME= -# MAIL_PASSWORD= -# MAIL_SERVER= -# MAIL_PORT=587 +# In development, EMAIL_VERIFY_BASE_URL defaults to http://localhost:8080 when empty. +# EMAIL_VERIFY_BASE_URL=http://localhost:5173 +# For providers like 163, MAIL_FROM should match MAIL_USERNAME. +# MAIL_FROM=your-account@example.com +# MAIL_SERVER=smtp.example.com +# MAIL_PORT=465 +# MAIL_USERNAME=your-account@example.com +# MAIL_PASSWORD=replace-with-app-password +# MAIL_STARTTLS=false +# MAIL_SSL_TLS=true +# MAIL_USE_CREDENTIALS=true +# MAIL_VALIDATE_CERTS=true diff --git a/app/README.md b/app/README.md index e69de29..1ca8092 100644 --- a/app/README.md +++ b/app/README.md @@ -0,0 +1,36 @@ +## Run Backend (API + Workers) + +Use one command to start backend API and file workers together: + +```bash +uv run python -m fileflash.scripts.run_with_workers +``` + +Common options: + +```bash +# custom host/port +uv run python -m fileflash.scripts.run_with_workers --host 127.0.0.1 --port 8080 + +# start multiple worker processes +uv run python -m fileflash.scripts.run_with_workers --worker-count 2 + +# API only (without workers) +uv run python -m fileflash.scripts.run_with_workers --no-worker +``` + +Notes: +- This runner starts `uvicorn fileflash.main:app` and `python -m fileflash.workers.consumer`. +- If any subprocess exits, the runner stops all other subprocesses. +- If your environment resolves project scripts correctly, `uv run fileflash-dev` is equivalent. + +## Database Migration Requirement + +Before starting API processes, ensure Flyway migrations are fully applied (including `V10__identity_avatar.sql` and later). + +Recommended startup order: +1. Start PostgreSQL +2. Run Flyway migrate +3. Start API (`uv run fileflash`) or runner (`uv run fileflash-dev`) + +If the schema is outdated, API startup will fail fast with an explicit compatibility error. diff --git a/app/pyproject.toml b/app/pyproject.toml index bcd8655..6a8e1c7 100644 --- a/app/pyproject.toml +++ b/app/pyproject.toml @@ -21,7 +21,8 @@ dependencies = [ "python-multipart>=0.0.24", ] [project.scripts] -fileflash = "main:main" +fileflash = "fileflash.main:main" +fileflash-dev = "fileflash.scripts.run_with_workers:main" [dependency-groups] dev = [ @@ -43,3 +44,10 @@ target-version = "py312" [tool.ruff.lint] select = ["E", "F", "I", "UP", "B"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/fileflash"] \ No newline at end of file diff --git a/app/src/db/engine.py b/app/src/db/engine.py deleted file mode 100644 index 02d46f3..0000000 --- a/app/src/db/engine.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import annotations - -from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine - -from ..core.settings import get_settings - -settings = get_settings() - -engine: AsyncEngine = create_async_engine( - settings.async_database_url, - echo=False, - pool_pre_ping=True, -) - - -async def verify_database_connection() -> None: - async with engine.connect() as connection: - await connection.execute(text("SELECT 1")) diff --git a/app/src/__init__.py b/app/src/fileflash/__init__.py similarity index 100% rename from app/src/__init__.py rename to app/src/fileflash/__init__.py diff --git a/app/src/agents/__init__.py b/app/src/fileflash/agents/__init__.py similarity index 100% rename from app/src/agents/__init__.py rename to app/src/fileflash/agents/__init__.py diff --git a/app/src/agents/harness/__init__.py b/app/src/fileflash/agents/harness/__init__.py similarity index 100% rename from app/src/agents/harness/__init__.py rename to app/src/fileflash/agents/harness/__init__.py diff --git a/app/src/agents/harness/budget.py b/app/src/fileflash/agents/harness/budget.py similarity index 100% rename from app/src/agents/harness/budget.py rename to app/src/fileflash/agents/harness/budget.py diff --git a/app/src/agents/harness/checkpoint.py b/app/src/fileflash/agents/harness/checkpoint.py similarity index 100% rename from app/src/agents/harness/checkpoint.py rename to app/src/fileflash/agents/harness/checkpoint.py diff --git a/app/src/agents/harness/cost.py b/app/src/fileflash/agents/harness/cost.py similarity index 100% rename from app/src/agents/harness/cost.py rename to app/src/fileflash/agents/harness/cost.py diff --git a/app/src/agents/harness/events.py b/app/src/fileflash/agents/harness/events.py similarity index 100% rename from app/src/agents/harness/events.py rename to app/src/fileflash/agents/harness/events.py diff --git a/app/src/agents/harness/memory.py b/app/src/fileflash/agents/harness/memory.py similarity index 100% rename from app/src/agents/harness/memory.py rename to app/src/fileflash/agents/harness/memory.py diff --git a/app/src/agents/harness/policy.py b/app/src/fileflash/agents/harness/policy.py similarity index 100% rename from app/src/agents/harness/policy.py rename to app/src/fileflash/agents/harness/policy.py diff --git a/app/src/agents/harness/prompt.py b/app/src/fileflash/agents/harness/prompt.py similarity index 100% rename from app/src/agents/harness/prompt.py rename to app/src/fileflash/agents/harness/prompt.py diff --git a/app/src/agents/harness/router.py b/app/src/fileflash/agents/harness/router.py similarity index 100% rename from app/src/agents/harness/router.py rename to app/src/fileflash/agents/harness/router.py diff --git a/app/src/agents/runtime/__init__.py b/app/src/fileflash/agents/runtime/__init__.py similarity index 100% rename from app/src/agents/runtime/__init__.py rename to app/src/fileflash/agents/runtime/__init__.py diff --git a/app/src/agents/runtime/execute_runner.py b/app/src/fileflash/agents/runtime/execute_runner.py similarity index 100% rename from app/src/agents/runtime/execute_runner.py rename to app/src/fileflash/agents/runtime/execute_runner.py diff --git a/app/src/agents/runtime/plan_runner.py b/app/src/fileflash/agents/runtime/plan_runner.py similarity index 100% rename from app/src/agents/runtime/plan_runner.py rename to app/src/fileflash/agents/runtime/plan_runner.py diff --git a/app/src/agents/runtime/subagent_runner.py b/app/src/fileflash/agents/runtime/subagent_runner.py similarity index 100% rename from app/src/agents/runtime/subagent_runner.py rename to app/src/fileflash/agents/runtime/subagent_runner.py diff --git a/app/src/core/__init__.py b/app/src/fileflash/core/__init__.py similarity index 100% rename from app/src/core/__init__.py rename to app/src/fileflash/core/__init__.py diff --git a/app/src/core/deps.py b/app/src/fileflash/core/deps.py similarity index 91% rename from app/src/core/deps.py rename to app/src/fileflash/core/deps.py index c2d078f..c489641 100644 --- a/app/src/core/deps.py +++ b/app/src/fileflash/core/deps.py @@ -21,11 +21,13 @@ from ..services.agent import ExecuteService, McpService, MemoryService, PlanService, SessionService, SettingsService, SkillService from ..services.auth import AuthService from ..services.background_jobs import BackgroundJobService +from ..services.email_delivery import VerificationEmailDeliveryService from ..services.file import FileService from ..services.folder import FolderService from ..services.job_queue import RedisStreamJobQueue from ..services.messaging import InProcessAuthEventPublisher from ..services.rate_limiter import RedisRateLimiter +from ..services.registration_email_domain_rule import RegistrationEmailDomainRuleService from ..services.share import ShareService from ..services.upload import UploadService from ..s3 import MinioObjectStorageClient @@ -108,15 +110,23 @@ def get_auth_service( settings=settings, rate_limiter=rate_limiter, event_publisher=event_publisher, + verification_email_delivery=VerificationEmailDeliveryService(settings=settings), ) +def get_registration_email_domain_rule_service( + db: AsyncSession = Depends(get_db), +) -> RegistrationEmailDomainRuleService: + return RegistrationEmailDomainRuleService(db=db) + + def get_upload_service( db: AsyncSession = Depends(get_db), settings: Settings = Depends(get_settings_dep), storage: MinioObjectStorageClient = Depends(get_object_storage), + jobs: BackgroundJobService = Depends(get_background_job_service), ) -> UploadService: - return UploadService(db=db, settings=settings, storage=storage) + return UploadService(db=db, settings=settings, storage=storage, jobs=jobs) def get_share_service( @@ -138,14 +148,23 @@ def get_archive_service( def get_file_service( db: AsyncSession = Depends(get_db), storage: MinioObjectStorageClient = Depends(get_object_storage), + settings: Settings = Depends(get_settings_dep), ) -> FileService: - return FileService(db=db, storage=storage) + return FileService( + db=db, + storage=storage, + starred_items_limit=settings.starred_items_limit, + ) def get_folder_service( db: AsyncSession = Depends(get_db), + settings: Settings = Depends(get_settings_dep), ) -> FolderService: - return FolderService(db=db) + return FolderService( + db=db, + starred_items_limit=settings.starred_items_limit, + ) diff --git a/app/src/core/errors.py b/app/src/fileflash/core/errors.py similarity index 100% rename from app/src/core/errors.py rename to app/src/fileflash/core/errors.py diff --git a/app/src/core/http_headers.py b/app/src/fileflash/core/http_headers.py similarity index 100% rename from app/src/core/http_headers.py rename to app/src/fileflash/core/http_headers.py diff --git a/app/src/core/middleware.py b/app/src/fileflash/core/middleware.py similarity index 100% rename from app/src/core/middleware.py rename to app/src/fileflash/core/middleware.py diff --git a/app/src/core/mime.py b/app/src/fileflash/core/mime.py similarity index 100% rename from app/src/core/mime.py rename to app/src/fileflash/core/mime.py diff --git a/app/src/core/security.py b/app/src/fileflash/core/security.py similarity index 91% rename from app/src/core/security.py rename to app/src/fileflash/core/security.py index 01658f4..63f3dda 100644 --- a/app/src/core/security.py +++ b/app/src/fileflash/core/security.py @@ -1,6 +1,7 @@ from __future__ import annotations import hashlib +import hmac import secrets import uuid from datetime import UTC, datetime, timedelta @@ -26,8 +27,9 @@ def create_refresh_token() -> str: return secrets.token_urlsafe(48) -def hash_token(token: str) -> str: - return hashlib.sha256(token.encode("utf-8")).hexdigest() +def hash_token(token: str, settings: Settings) -> str: + secret = settings.effective_token_hash_secret.encode("utf-8") + return hmac.new(secret, token.encode("utf-8"), hashlib.sha256).hexdigest() def create_access_token(user_id: int, settings: Settings) -> str: diff --git a/app/src/core/settings.py b/app/src/fileflash/core/settings.py similarity index 68% rename from app/src/core/settings.py rename to app/src/fileflash/core/settings.py index 5827015..bc7e066 100644 --- a/app/src/core/settings.py +++ b/app/src/fileflash/core/settings.py @@ -3,6 +3,8 @@ from functools import lru_cache from os import cpu_count from pathlib import Path +from typing import ClassVar +from urllib.parse import urlsplit from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict @@ -14,8 +16,10 @@ def _default_worker_concurrency() -> int: class Settings(BaseSettings): + MIN_SECRET_LENGTH: ClassVar[int] = 32 + model_config = SettingsConfigDict( - env_file=str(Path(__file__).resolve().parents[1] / ".env"), + env_file=str(Path(__file__).resolve().parents[3] / ".env"), env_file_encoding="utf-8", extra="ignore", ) @@ -31,6 +35,7 @@ class Settings(BaseSettings): default="change-this-in-production-please-use-32-plus-bytes", alias="JWT_SECRET_KEY", ) + token_hash_secret: str | None = Field(default=None, alias="TOKEN_HASH_SECRET") jwt_algorithm: str = "HS256" access_token_expire_minutes: int = 60 * 24 * 3 refresh_token_expire_days: int = 7 @@ -45,6 +50,17 @@ class Settings(BaseSettings): redis_url: str | None = Field(default=None, alias="REDIS_URL") rabbitmq_url: str | None = Field(default=None, alias="RABBITMQ_URL") + email_verify_base_url: str = Field(default="", alias="EMAIL_VERIFY_BASE_URL") + mail_from: str | None = Field(default=None, alias="MAIL_FROM") + mail_server: str | None = Field(default=None, alias="MAIL_SERVER") + mail_port: int = Field(default=587, alias="MAIL_PORT") + mail_username: str | None = Field(default=None, alias="MAIL_USERNAME") + mail_password: str | None = Field(default=None, alias="MAIL_PASSWORD") + mail_starttls: bool = Field(default=True, alias="MAIL_STARTTLS") + mail_ssl_tls: bool = Field(default=False, alias="MAIL_SSL_TLS") + mail_use_credentials: bool = Field(default=True, alias="MAIL_USE_CREDENTIALS") + mail_validate_certs: bool = Field(default=True, alias="MAIL_VALIDATE_CERTS") + object_storage_endpoint: str = Field(default="localhost:9000", alias="OBJECT_STORAGE_ENDPOINT") object_storage_access_key: str = Field(default="admin", alias="OBJECT_STORAGE_ACCESS_KEY") object_storage_secret_key: str = Field(default="minio-admin", alias="OBJECT_STORAGE_SECRET_KEY") @@ -56,6 +72,7 @@ class Settings(BaseSettings): upload_chunk_size_min: int = Field(default=1 * 1024 * 1024, alias="UPLOAD_CHUNK_SIZE_MIN") upload_chunk_size_max: int = Field(default=16 * 1024 * 1024, alias="UPLOAD_CHUNK_SIZE_MAX") upload_single_file_size_max: int = Field(default=5 * 1024 * 1024 * 1024, alias="UPLOAD_SINGLE_FILE_SIZE_MAX") + starred_items_limit: int = Field(default=20, alias="STARRED_ITEMS_LIMIT") upload_session_ttl_hours: int = Field(default=24, alias="UPLOAD_SESSION_TTL_HOURS") upload_temp_prefix: str = Field(default="tmp", alias="UPLOAD_TEMP_PREFIX") upload_object_prefix: str = Field(default="objects", alias="UPLOAD_OBJECT_PREFIX") @@ -82,6 +99,7 @@ class Settings(BaseSettings): default_factory=_default_worker_concurrency, alias="WORKER_CONCURRENCY", ) + worker_process_count: int = Field(default=1, alias="WORKER_PROCESS_COUNT") worker_task_timeout_seconds: int = Field(default=900, alias="WORKER_TASK_TIMEOUT_SECONDS") worker_default_max_attempts: int = Field(default=5, alias="WORKER_DEFAULT_MAX_ATTEMPTS") worker_retry_backoff_seconds: str = Field( @@ -151,6 +169,28 @@ def access_token_ttl_seconds(self) -> int: def refresh_token_ttl_seconds(self) -> int: return self.refresh_token_expire_days * 24 * 60 * 60 + @property + def effective_token_hash_secret(self) -> str: + secret = (self.token_hash_secret or "").strip() + if secret: + return secret + return self.jwt_secret_key + + @property + def security_configuration_issues(self) -> tuple[str, ...]: + issues: list[str] = [] + if len(self.jwt_secret_key.encode("utf-8")) < self.MIN_SECRET_LENGTH: + issues.append(f"JWT_SECRET_KEY must be at least {self.MIN_SECRET_LENGTH} bytes") + token_hash_secret = (self.token_hash_secret or "").strip() + if token_hash_secret and len(token_hash_secret.encode("utf-8")) < self.MIN_SECRET_LENGTH: + issues.append(f"TOKEN_HASH_SECRET must be at least {self.MIN_SECRET_LENGTH} bytes") + return tuple(issues) + + def assert_runtime_security(self) -> None: + issues = self.security_configuration_issues + if issues: + raise ValueError("; ".join(issues)) + @property def worker_retry_backoff_schedule(self) -> tuple[int, ...]: values: list[int] = [] @@ -184,6 +224,45 @@ def is_development_env(self) -> bool: def is_production_env(self) -> bool: return self.normalized_app_env in {"prod", "production"} + @property + def normalized_email_verify_base_url(self) -> str: + base_url = self.email_verify_base_url.strip() + if not base_url and self.is_development_env: + base_url = "http://localhost:8080" + if base_url and "://" not in base_url: + base_url = f"http://{base_url}" + return base_url.rstrip("/") + + @property + def mail_configuration_issues(self) -> tuple[str, ...]: + issues: list[str] = [] + if self.mail_port <= 0: + issues.append("MAIL_PORT must be a positive integer") + base_url = self.normalized_email_verify_base_url + if not base_url: + issues.append("EMAIL_VERIFY_BASE_URL is required") + parsed_base_url = urlsplit(base_url) if base_url else None + if parsed_base_url and parsed_base_url.scheme not in {"http", "https"}: + issues.append("EMAIL_VERIFY_BASE_URL must start with http:// or https://") + if parsed_base_url and not parsed_base_url.netloc: + issues.append("EMAIL_VERIFY_BASE_URL must include host") + if not (self.mail_from or "").strip(): + issues.append("MAIL_FROM is required") + if not (self.mail_server or "").strip(): + issues.append("MAIL_SERVER is required") + if self.mail_ssl_tls and self.mail_starttls: + issues.append("MAIL_SSL_TLS and MAIL_STARTTLS cannot both be true") + if self.mail_use_credentials: + if not (self.mail_username or "").strip(): + issues.append("MAIL_USERNAME is required when MAIL_USE_CREDENTIALS=true") + if not (self.mail_password or "").strip(): + issues.append("MAIL_PASSWORD is required when MAIL_USE_CREDENTIALS=true") + return tuple(issues) + + @property + def is_mail_configured(self) -> bool: + return len(self.mail_configuration_issues) == 0 + @property def agent_mcp_endpoints(self) -> tuple[str, ...]: raw = self.agent_mcp_endpoints_raw.strip() diff --git a/app/src/db/__init__.py b/app/src/fileflash/db/__init__.py similarity index 100% rename from app/src/db/__init__.py rename to app/src/fileflash/db/__init__.py diff --git a/app/src/db/deps.py b/app/src/fileflash/db/deps.py similarity index 100% rename from app/src/db/deps.py rename to app/src/fileflash/db/deps.py diff --git a/app/src/fileflash/db/engine.py b/app/src/fileflash/db/engine.py new file mode 100644 index 0000000..b0e8254 --- /dev/null +++ b/app/src/fileflash/db/engine.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine + +from ..core.settings import get_settings + +settings = get_settings() + +engine: AsyncEngine = create_async_engine( + settings.async_database_url, + echo=False, + pool_pre_ping=True, +) + + +async def verify_database_connection() -> None: + async with engine.connect() as connection: + await connection.execute(text("SELECT 1")) + + +async def verify_schema_compatibility() -> None: + async with engine.connect() as connection: + if not await _public_table_has_column(connection, table_name="user", column_name="avatar"): + raise RuntimeError( + "Database schema is outdated: missing column public.user.avatar. " + "Run Flyway migrations (at least V10__identity_avatar.sql) before starting the API." + ) + if not await _public_table_exists(connection, table_name="registration_email_domain_rule"): + raise RuntimeError( + "Database schema is outdated: missing table public.registration_email_domain_rule. " + "Run Flyway migrations (at least V11__identity_registration_email_domain_rule.sql) before starting the API." + ) + + +async def _public_table_has_column(connection: AsyncConnection, *, table_name: str, column_name: str) -> bool: + result = await connection.execute( + text( + """ + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = :table_name + AND column_name = :column_name + LIMIT 1 + """ + ), + {"table_name": table_name, "column_name": column_name}, + ) + return result.scalar() == 1 + + +async def _public_table_exists(connection: AsyncConnection, *, table_name: str) -> bool: + result = await connection.execute( + text( + """ + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = :table_name + AND table_type = 'BASE TABLE' + LIMIT 1 + """ + ), + {"table_name": table_name}, + ) + return result.scalar() == 1 diff --git a/app/src/db/session.py b/app/src/fileflash/db/session.py similarity index 100% rename from app/src/db/session.py rename to app/src/fileflash/db/session.py diff --git a/app/src/db/transaction.py b/app/src/fileflash/db/transaction.py similarity index 100% rename from app/src/db/transaction.py rename to app/src/fileflash/db/transaction.py diff --git a/app/src/exceptions/__init__.py b/app/src/fileflash/exceptions/__init__.py similarity index 100% rename from app/src/exceptions/__init__.py rename to app/src/fileflash/exceptions/__init__.py diff --git a/app/src/main.py b/app/src/fileflash/main.py similarity index 81% rename from app/src/main.py rename to app/src/fileflash/main.py index 1d27a9f..fbc194c 100644 --- a/app/src/main.py +++ b/app/src/fileflash/main.py @@ -13,7 +13,7 @@ from .core.deps import get_object_storage, get_rate_limiter from .core.errors import ApiError, api_success from .core.middleware import EmailVerificationGateMiddleware -from .db.engine import verify_database_connection +from .db.engine import verify_database_connection, verify_schema_compatibility from .routers import api_router from .s3 import ObjectStorageError from .services.dev_seed import initialize_dev_accounts @@ -24,7 +24,15 @@ @asynccontextmanager async def lifespan(_app: FastAPI): + settings.assert_runtime_security() + mail_issues = list(settings.mail_configuration_issues) + logger.info( + "Mail delivery readiness: configured=%s, issues=%s", + settings.is_mail_configured, + mail_issues, + ) await verify_database_connection() + await verify_schema_compatibility() try: await get_object_storage().ensure_bucket() except ObjectStorageError: @@ -58,7 +66,7 @@ async def health(): def main() -> None: - uvicorn.run("src.main:app", host="0.0.0.0", port=8080, reload=False) + uvicorn.run("fileflash.main:app", host="0.0.0.0", port=8080, reload=False) if __name__ == "__main__": diff --git a/app/src/models/__init__.py b/app/src/fileflash/models/__init__.py similarity index 95% rename from app/src/models/__init__.py rename to app/src/fileflash/models/__init__.py index eb58a85..575fa97 100644 --- a/app/src/models/__init__.py +++ b/app/src/fileflash/models/__init__.py @@ -21,6 +21,7 @@ Notification, ObjectScanResult, PasswordResetToken, + RegistrationEmailDomainRule, SecurityEvent, Share, ShareAccessLog, @@ -59,6 +60,7 @@ "Notification", "ObjectScanResult", "PasswordResetToken", + "RegistrationEmailDomainRule", "SecurityEvent", "Share", "ShareAccessLog", diff --git a/app/src/models/base.py b/app/src/fileflash/models/base.py similarity index 100% rename from app/src/models/base.py rename to app/src/fileflash/models/base.py diff --git a/app/src/models/enums.py b/app/src/fileflash/models/enums.py similarity index 100% rename from app/src/models/enums.py rename to app/src/fileflash/models/enums.py diff --git a/app/src/models/pg.py b/app/src/fileflash/models/pg.py similarity index 100% rename from app/src/models/pg.py rename to app/src/fileflash/models/pg.py diff --git a/app/src/models/tables.py b/app/src/fileflash/models/tables.py similarity index 95% rename from app/src/models/tables.py rename to app/src/fileflash/models/tables.py index d921030..0040f7b 100644 --- a/app/src/models/tables.py +++ b/app/src/fileflash/models/tables.py @@ -28,6 +28,7 @@ from .tables_identity import ( EmailVerificationToken, PasswordResetToken, + RegistrationEmailDomainRule, User, UserGroup, UserGroupMember, @@ -68,6 +69,7 @@ "Notification", "ObjectScanResult", "PasswordResetToken", + "RegistrationEmailDomainRule", "SecurityEvent", "Share", "ShareAccessLog", diff --git a/app/src/models/tables_access_share.py b/app/src/fileflash/models/tables_access_share.py similarity index 100% rename from app/src/models/tables_access_share.py rename to app/src/fileflash/models/tables_access_share.py diff --git a/app/src/models/tables_agent.py b/app/src/fileflash/models/tables_agent.py similarity index 100% rename from app/src/models/tables_agent.py rename to app/src/fileflash/models/tables_agent.py diff --git a/app/src/models/tables_audit_security.py b/app/src/fileflash/models/tables_audit_security.py similarity index 100% rename from app/src/models/tables_audit_security.py rename to app/src/fileflash/models/tables_audit_security.py diff --git a/app/src/models/tables_identity.py b/app/src/fileflash/models/tables_identity.py similarity index 88% rename from app/src/models/tables_identity.py rename to app/src/fileflash/models/tables_identity.py index 2b70fbe..ff24dc1 100644 --- a/app/src/models/tables_identity.py +++ b/app/src/fileflash/models/tables_identity.py @@ -215,9 +215,33 @@ class UserSession(Base): revoked_at: Mapped[datetime | None] = mapped_column(DateTime) +class RegistrationEmailDomainRule(Base): + __tablename__ = "registration_email_domain_rule" + __table_args__ = ( + Index("uk_registration_email_domain_rule_name_ci", text("(LOWER(name))"), unique=True), + Index("idx_registration_email_domain_rule_enabled", "enabled"), + ) + + rule_id: Mapped[int] = mapped_column(BigInteger, Identity(), primary_key=True) + name: Mapped[str] = mapped_column(String(120), nullable=False) + pattern: Mapped[str] = mapped_column(String(512), nullable=False) + enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("TRUE")) + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=text("CURRENT_TIMESTAMP"), + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=text("CURRENT_TIMESTAMP"), + ) + + __all__ = [ "EmailVerificationToken", "PasswordResetToken", + "RegistrationEmailDomainRule", "User", "UserGroup", "UserGroupMember", diff --git a/app/src/models/tables_storage.py b/app/src/fileflash/models/tables_storage.py similarity index 100% rename from app/src/models/tables_storage.py rename to app/src/fileflash/models/tables_storage.py diff --git a/app/src/models/tables_worker.py b/app/src/fileflash/models/tables_worker.py similarity index 100% rename from app/src/models/tables_worker.py rename to app/src/fileflash/models/tables_worker.py diff --git a/app/src/models/types.py b/app/src/fileflash/models/types.py similarity index 100% rename from app/src/models/types.py rename to app/src/fileflash/models/types.py diff --git a/app/src/models/user.py b/app/src/fileflash/models/user.py similarity index 100% rename from app/src/models/user.py rename to app/src/fileflash/models/user.py diff --git a/app/src/repositories/__init__.py b/app/src/fileflash/repositories/__init__.py similarity index 100% rename from app/src/repositories/__init__.py rename to app/src/fileflash/repositories/__init__.py diff --git a/app/src/repositories/agent/__init__.py b/app/src/fileflash/repositories/agent/__init__.py similarity index 100% rename from app/src/repositories/agent/__init__.py rename to app/src/fileflash/repositories/agent/__init__.py diff --git a/app/src/repositories/agent/action_log.py b/app/src/fileflash/repositories/agent/action_log.py similarity index 100% rename from app/src/repositories/agent/action_log.py rename to app/src/fileflash/repositories/agent/action_log.py diff --git a/app/src/repositories/agent/contracts.py b/app/src/fileflash/repositories/agent/contracts.py similarity index 100% rename from app/src/repositories/agent/contracts.py rename to app/src/fileflash/repositories/agent/contracts.py diff --git a/app/src/repositories/agent/mcp.py b/app/src/fileflash/repositories/agent/mcp.py similarity index 100% rename from app/src/repositories/agent/mcp.py rename to app/src/fileflash/repositories/agent/mcp.py diff --git a/app/src/repositories/agent/memory.py b/app/src/fileflash/repositories/agent/memory.py similarity index 100% rename from app/src/repositories/agent/memory.py rename to app/src/fileflash/repositories/agent/memory.py diff --git a/app/src/repositories/agent/plan.py b/app/src/fileflash/repositories/agent/plan.py similarity index 100% rename from app/src/repositories/agent/plan.py rename to app/src/fileflash/repositories/agent/plan.py diff --git a/app/src/repositories/agent/settings.py b/app/src/fileflash/repositories/agent/settings.py similarity index 100% rename from app/src/repositories/agent/settings.py rename to app/src/fileflash/repositories/agent/settings.py diff --git a/app/src/repositories/agent/skill.py b/app/src/fileflash/repositories/agent/skill.py similarity index 100% rename from app/src/repositories/agent/skill.py rename to app/src/fileflash/repositories/agent/skill.py diff --git a/app/src/repositories/agent/work_session.py b/app/src/fileflash/repositories/agent/work_session.py similarity index 100% rename from app/src/repositories/agent/work_session.py rename to app/src/fileflash/repositories/agent/work_session.py diff --git a/app/src/routers/__init__.py b/app/src/fileflash/routers/__init__.py similarity index 84% rename from app/src/routers/__init__.py rename to app/src/fileflash/routers/__init__.py index b1ddf55..2f14ff0 100644 --- a/app/src/routers/__init__.py +++ b/app/src/fileflash/routers/__init__.py @@ -1,6 +1,7 @@ from fastapi import APIRouter from .auth import router as auth_router +from .admin_registration_email_domain_rules import router as admin_registration_email_domain_rules_router from .files import router as files_router from .folders import router as folders_router from .jobs import router as jobs_router @@ -13,6 +14,7 @@ api_router = APIRouter() api_router.include_router(auth_router) +api_router.include_router(admin_registration_email_domain_rules_router) api_router.include_router(files_router) api_router.include_router(folders_router) api_router.include_router(jobs_router) diff --git a/app/src/fileflash/routers/admin_registration_email_domain_rules.py b/app/src/fileflash/routers/admin_registration_email_domain_rules.py new file mode 100644 index 0000000..13e6971 --- /dev/null +++ b/app/src/fileflash/routers/admin_registration_email_domain_rules.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from fastapi import APIRouter, Depends + +from ..core.deps import get_registration_email_domain_rule_service, require_admin +from ..core.errors import api_success +from ..models.tables_identity import User +from ..schemas.registration_email_domain_rule import ( + CreateRegistrationEmailDomainRuleRequest, + ListRegistrationEmailDomainRulesQuery, + UpdateRegistrationEmailDomainRuleRequest, +) +from ..services.registration_email_domain_rule import RegistrationEmailDomainRuleService + +router = APIRouter(prefix="/admin/registration-email-domain-rules", tags=["admin"]) + + +@router.get("") +async def list_registration_email_domain_rules( + query: ListRegistrationEmailDomainRulesQuery = Depends(), + _: User = Depends(require_admin), + service: RegistrationEmailDomainRuleService = Depends(get_registration_email_domain_rule_service), +): + data = await service.list_rules(query=query) + return api_success(data=data.model_dump(by_alias=True), message="Rules fetched successfully") + + +@router.post("") +async def create_registration_email_domain_rule( + payload: CreateRegistrationEmailDomainRuleRequest, + _: User = Depends(require_admin), + service: RegistrationEmailDomainRuleService = Depends(get_registration_email_domain_rule_service), +): + item = await service.create_rule(payload=payload) + return api_success( + data=item.model_dump(by_alias=True), + code=201, + status_code=201, + message="Rule created successfully", + ) + + +@router.patch("/{rule_id}") +async def update_registration_email_domain_rule( + rule_id: int, + payload: UpdateRegistrationEmailDomainRuleRequest, + _: User = Depends(require_admin), + service: RegistrationEmailDomainRuleService = Depends(get_registration_email_domain_rule_service), +): + item = await service.update_rule(rule_id=rule_id, payload=payload) + return api_success(data=item.model_dump(by_alias=True), message="Rule updated successfully") + + +@router.delete("/{rule_id}") +async def delete_registration_email_domain_rule( + rule_id: int, + _: User = Depends(require_admin), + service: RegistrationEmailDomainRuleService = Depends(get_registration_email_domain_rule_service), +): + await service.delete_rule(rule_id=rule_id) + return api_success( + data={ + "ruleId": str(rule_id), + "deletedAt": datetime.now(UTC).isoformat(), + }, + message="Rule deleted successfully", + ) + + +__all__ = ["router"] + diff --git a/app/src/routers/agent.py b/app/src/fileflash/routers/agent.py similarity index 100% rename from app/src/routers/agent.py rename to app/src/fileflash/routers/agent.py diff --git a/app/src/routers/agent_skills.py b/app/src/fileflash/routers/agent_skills.py similarity index 100% rename from app/src/routers/agent_skills.py rename to app/src/fileflash/routers/agent_skills.py diff --git a/app/src/routers/auth.py b/app/src/fileflash/routers/auth.py similarity index 100% rename from app/src/routers/auth.py rename to app/src/fileflash/routers/auth.py diff --git a/app/src/routers/files.py b/app/src/fileflash/routers/files.py similarity index 100% rename from app/src/routers/files.py rename to app/src/fileflash/routers/files.py diff --git a/app/src/routers/folders.py b/app/src/fileflash/routers/folders.py similarity index 100% rename from app/src/routers/folders.py rename to app/src/fileflash/routers/folders.py diff --git a/app/src/routers/jobs.py b/app/src/fileflash/routers/jobs.py similarity index 100% rename from app/src/routers/jobs.py rename to app/src/fileflash/routers/jobs.py diff --git a/app/src/routers/me.py b/app/src/fileflash/routers/me.py similarity index 100% rename from app/src/routers/me.py rename to app/src/fileflash/routers/me.py diff --git a/app/src/routers/recycle.py b/app/src/fileflash/routers/recycle.py similarity index 100% rename from app/src/routers/recycle.py rename to app/src/fileflash/routers/recycle.py diff --git a/app/src/routers/shares.py b/app/src/fileflash/routers/shares.py similarity index 57% rename from app/src/routers/shares.py rename to app/src/fileflash/routers/shares.py index 204d118..4341854 100644 --- a/app/src/routers/shares.py +++ b/app/src/fileflash/routers/shares.py @@ -27,6 +27,64 @@ def _extract_bearer_token(authorization: str | None) -> str | None: return authorization.split(" ", 1)[1].strip() or None +def _extract_share_stream( + value: tuple[object, ...], +) -> tuple[object, str, str, int, dict[str, str] | None]: + if len(value) == 5: + stream, filename, content_type, status_code, headers = value + if isinstance(filename, str) and isinstance(content_type, str) and isinstance(status_code, int) and isinstance(headers, dict): + return stream, filename, content_type, status_code, headers + if len(value) == 4: + stream, filename, content_type, status_code = value + if isinstance(filename, str) and isinstance(content_type, str) and isinstance(status_code, int): + return stream, filename, content_type, status_code, None + if len(value) == 3: + stream, filename, content_type = value + if isinstance(filename, str) and isinstance(content_type, str): + return stream, filename, content_type, 200, None + + from ..core.errors import ApiError + + raise ApiError(status_code=500, code=500, message="Invalid shared stream response") + + +def _sanitize_stream_headers( + *, + headers: dict[str, str] | None, + filename: str, + disposition: str, +) -> dict[str, str]: + fallback_content_disposition = build_content_disposition(filename, disposition=disposition) + if headers is None: + return {"Content-Disposition": fallback_content_disposition} + + sanitized: dict[str, str] = {} + has_content_disposition = False + for key, value in headers.items(): + key_text = str(key) + value_text = str(value) + header_name = key_text.strip().lower() + + if header_name == "content-disposition": + has_content_disposition = True + try: + value_text.encode("latin-1") + sanitized[key_text] = value_text + except UnicodeEncodeError: + sanitized[key_text] = fallback_content_disposition + continue + + try: + value_text.encode("latin-1") + except UnicodeEncodeError: + continue + sanitized[key_text] = value_text + + if not has_content_disposition: + sanitized["Content-Disposition"] = fallback_content_disposition + return sanitized + + @router.post("") async def create_share( payload: CreateShareRequest, @@ -133,6 +191,7 @@ async def save_share_to_my_space( async def download_shared_file( share_link: str, authorization: str | None = Header(default=None), + range_header: str | None = Header(default=None, alias="Range"), client_ip: str = Depends(get_client_ip), user_agent: str | None = Depends(get_user_agent), share_service: ShareService = Depends(get_share_service), @@ -143,21 +202,33 @@ async def download_shared_file( raise ApiError(status_code=401, code=401, message="Missing share access token") - stream, filename, content_type = await share_service.get_shared_file_stream( - share_link=share_link, - share_access_token=token, - action="download", - ip_address=client_ip, - user_agent=user_agent, - ) - headers = {"Content-Disposition": build_content_disposition(filename, disposition="attachment")} - return StreamingResponse(stream, media_type=content_type, headers=headers) + if hasattr(share_service, "get_shared_file_download_stream_response"): + raw = await share_service.get_shared_file_download_stream_response( + share_link=share_link, + share_access_token=token, + action="download", + range_header=range_header, + ip_address=client_ip, + user_agent=user_agent, + ) + else: + raw = await share_service.get_shared_file_stream( + share_link=share_link, + share_access_token=token, + action="download", + ip_address=client_ip, + user_agent=user_agent, + ) + stream, filename, content_type, status_code, headers = _extract_share_stream(tuple(raw)) + response_headers = _sanitize_stream_headers(headers=headers, filename=filename, disposition="attachment") + return StreamingResponse(stream, media_type=content_type, headers=response_headers, status_code=status_code) @router.get("/{share_link}/preview") async def preview_shared_file( share_link: str, authorization: str | None = Header(default=None), + range_header: str | None = Header(default=None, alias="Range"), client_ip: str = Depends(get_client_ip), user_agent: str | None = Depends(get_user_agent), share_service: ShareService = Depends(get_share_service), @@ -168,13 +239,24 @@ async def preview_shared_file( raise ApiError(status_code=401, code=401, message="Missing share access token") - stream, filename, content_type = await share_service.get_shared_file_stream( - share_link=share_link, - share_access_token=token, - action="preview", - ip_address=client_ip, - user_agent=user_agent, - ) - headers = {"Content-Disposition": build_content_disposition(filename, disposition="inline")} - return StreamingResponse(stream, media_type=content_type, headers=headers) + if hasattr(share_service, "get_shared_file_download_stream_response"): + raw = await share_service.get_shared_file_download_stream_response( + share_link=share_link, + share_access_token=token, + action="preview", + range_header=range_header, + ip_address=client_ip, + user_agent=user_agent, + ) + else: + raw = await share_service.get_shared_file_stream( + share_link=share_link, + share_access_token=token, + action="preview", + ip_address=client_ip, + user_agent=user_agent, + ) + stream, filename, content_type, status_code, headers = _extract_share_stream(tuple(raw)) + response_headers = _sanitize_stream_headers(headers=headers, filename=filename, disposition="inline") + return StreamingResponse(stream, media_type=content_type, headers=response_headers, status_code=status_code) diff --git a/app/src/routers/storage.py b/app/src/fileflash/routers/storage.py similarity index 100% rename from app/src/routers/storage.py rename to app/src/fileflash/routers/storage.py diff --git a/app/src/routers/uploads.py b/app/src/fileflash/routers/uploads.py similarity index 89% rename from app/src/routers/uploads.py rename to app/src/fileflash/routers/uploads.py index 0497ef3..9996c87 100644 --- a/app/src/routers/uploads.py +++ b/app/src/fileflash/routers/uploads.py @@ -6,6 +6,7 @@ from ..core.errors import api_success from ..models.tables_identity import User from ..schemas.file import MergeChunksRequest, UploadPreflightRequest +from ..schemas.job import to_background_job_response from ..services.upload import UploadService router = APIRouter(prefix="/uploads", tags=["uploads"]) @@ -47,14 +48,14 @@ async def merge_chunks( current_user: User = Depends(get_current_user), upload_service: UploadService = Depends(get_upload_service), ): - response = await upload_service.merge_chunks( + job = await upload_service.enqueue_merge_job( user_id=current_user.user_id, upload_id=upload_id, payload=payload, ) return api_success( - data=response.model_dump(by_alias=True), - message="File uploaded successfully", + data=to_background_job_response(job).model_dump(by_alias=True), + message="Upload merge job created", code=201, status_code=201, ) diff --git a/app/src/s3/__init__.py b/app/src/fileflash/s3/__init__.py similarity index 100% rename from app/src/s3/__init__.py rename to app/src/fileflash/s3/__init__.py diff --git a/app/src/s3/minio_client.py b/app/src/fileflash/s3/minio_client.py similarity index 59% rename from app/src/s3/minio_client.py rename to app/src/fileflash/s3/minio_client.py index de4c057..536f0f1 100644 --- a/app/src/s3/minio_client.py +++ b/app/src/fileflash/s3/minio_client.py @@ -4,6 +4,7 @@ import hashlib import io import logging +import os from dataclasses import dataclass from typing import Iterable @@ -78,12 +79,14 @@ def from_settings(cls, settings: Settings) -> "MinioObjectStorageClient": region=settings.object_storage_region, ) - async def ensure_bucket(self) -> None: + async def ensure_bucket(self, *, bucket_name: str | None = None) -> None: + resolved_bucket = self._resolve_bucket_name(bucket_name) + def _run() -> None: try: - if self._client.bucket_exists(self.bucket_name): + if self._client.bucket_exists(resolved_bucket): return - self._client.make_bucket(self.bucket_name, location=self.region) + self._client.make_bucket(resolved_bucket, location=self.region) except S3Error as exc: if exc.code in {"BucketAlreadyOwnedByYou", "BucketAlreadyExists"}: return @@ -91,7 +94,7 @@ def _run() -> None: except Exception as exc: # noqa: BLE001 logger.exception( "Object storage availability check failed for bucket=%s", - self.bucket_name, + resolved_bucket, ) raise ObjectStorageUnavailableError("Object storage unavailable") from exc @@ -109,12 +112,20 @@ def _classify_s3_error(self, exc: S3Error) -> ObjectStorageError: return ObjectStorageAuthError(f"Object storage authentication failed: {code}") return ObjectStorageUnavailableError(f"Object storage unavailable: {code}") - async def put_bytes(self, *, object_key: str, data: bytes, content_type: str) -> ObjectWriteResult: - await self.ensure_bucket() + async def put_bytes( + self, + *, + object_key: str, + data: bytes, + content_type: str, + bucket_name: str | None = None, + ) -> ObjectWriteResult: + resolved_bucket = self._resolve_bucket_name(bucket_name) + await self.ensure_bucket(bucket_name=resolved_bucket) def _run() -> ObjectWriteResult: result = self._client.put_object( - self.bucket_name, + resolved_bucket, object_key, io.BytesIO(data), len(data), @@ -124,19 +135,28 @@ def _run() -> ObjectWriteResult: return await asyncio.to_thread(_run) - async def compose_object(self, *, object_key: str, source_keys: list[str]) -> ObjectWriteResult: - await self.ensure_bucket() + async def compose_object( + self, + *, + object_key: str, + source_keys: list[str], + bucket_name: str | None = None, + ) -> ObjectWriteResult: + resolved_bucket = self._resolve_bucket_name(bucket_name) + await self.ensure_bucket(bucket_name=resolved_bucket) def _run() -> ObjectWriteResult: - sources = [ComposeSource(self.bucket_name, source_key) for source_key in source_keys] - result = self._client.compose_object(self.bucket_name, object_key, sources) + sources = [ComposeSource(resolved_bucket, source_key) for source_key in source_keys] + result = self._client.compose_object(resolved_bucket, object_key, sources) return ObjectWriteResult(etag=result.etag, version_id=result.version_id) return await asyncio.to_thread(_run) - async def stat_object(self, *, object_key: str) -> ObjectStat: + async def stat_object(self, *, object_key: str, bucket_name: str | None = None) -> ObjectStat: + resolved_bucket = self._resolve_bucket_name(bucket_name) + def _run() -> ObjectStat: - stat = self._client.stat_object(self.bucket_name, object_key) + stat = self._client.stat_object(resolved_bucket, object_key) return ObjectStat( size=stat.size, etag=getattr(stat, "etag", None), @@ -146,21 +166,25 @@ def _run() -> ObjectStat: return await asyncio.to_thread(_run) - async def remove_object(self, *, object_key: str) -> None: + async def remove_object(self, *, object_key: str, bucket_name: str | None = None) -> None: + resolved_bucket = self._resolve_bucket_name(bucket_name) + def _run() -> None: - self._client.remove_object(self.bucket_name, object_key) + self._client.remove_object(resolved_bucket, object_key) await asyncio.to_thread(_run) - async def remove_objects(self, *, object_keys: Iterable[str]) -> None: + async def remove_objects(self, *, object_keys: Iterable[str], bucket_name: str | None = None) -> None: keys = [key for key in object_keys if key] if not keys: return + resolved_bucket = self._resolve_bucket_name(bucket_name) + def _run() -> None: errors = list( self._client.remove_objects( - self.bucket_name, + resolved_bucket, (DeleteObject(key) for key in keys), ) ) @@ -170,10 +194,12 @@ def _run() -> None: await asyncio.to_thread(_run) - async def compute_object_hash(self, *, object_key: str, algorithm: str) -> str: + async def compute_object_hash(self, *, object_key: str, algorithm: str, bucket_name: str | None = None) -> str: + resolved_bucket = self._resolve_bucket_name(bucket_name) + def _run() -> str: hasher = hashlib.new(algorithm) - response = self._client.get_object(self.bucket_name, object_key) + response = self._client.get_object(resolved_bucket, object_key) try: for chunk in response.stream(1024 * 1024): hasher.update(chunk) @@ -184,10 +210,17 @@ def _run() -> str: return await asyncio.to_thread(_run) - async def iter_object(self, *, object_key: str, chunk_size: int = 1024 * 1024) -> AsyncIterator[bytes]: - await self.ensure_bucket() + async def iter_object( + self, + *, + object_key: str, + chunk_size: int = 1024 * 1024, + bucket_name: str | None = None, + ) -> AsyncIterator[bytes]: + resolved_bucket = self._resolve_bucket_name(bucket_name) + await self.ensure_bucket(bucket_name=resolved_bucket) - response = await asyncio.to_thread(self._client.get_object, self.bucket_name, object_key) + response = await asyncio.to_thread(self._client.get_object, resolved_bucket, object_key) try: while True: chunk = await asyncio.to_thread(response.read, chunk_size) @@ -205,8 +238,10 @@ async def iter_object_range( start: int, end: int, chunk_size: int = 1024 * 1024, + bucket_name: str | None = None, ) -> AsyncIterator[bytes]: - await self.ensure_bucket() + resolved_bucket = self._resolve_bucket_name(bucket_name) + await self.ensure_bucket(bucket_name=resolved_bucket) if start < 0 or end < start: raise ValueError("Invalid byte range") @@ -214,7 +249,7 @@ async def iter_object_range( length = end - start + 1 response = await asyncio.to_thread( self._client.get_object, - self.bucket_name, + resolved_bucket, object_key, start, length, @@ -231,3 +266,59 @@ async def iter_object_range( finally: await asyncio.to_thread(response.close) await asyncio.to_thread(response.release_conn) + + async def fget_object( + self, + *, + object_key: str, + file_path: str, + bucket_name: str | None = None, + ) -> ObjectWriteResult: + resolved_bucket = self._resolve_bucket_name(bucket_name) + + def _run() -> ObjectWriteResult: + result = self._client.fget_object(resolved_bucket, object_key, file_path) + return ObjectWriteResult(etag=getattr(result, "etag", None), version_id=getattr(result, "version_id", None)) + + return await asyncio.to_thread(_run) + + async def fput_object( + self, + *, + object_key: str, + file_path: str, + content_type: str, + bucket_name: str | None = None, + ) -> ObjectWriteResult: + resolved_bucket = self._resolve_bucket_name(bucket_name) + await self.ensure_bucket(bucket_name=resolved_bucket) + + def _run() -> ObjectWriteResult: + result = self._client.fput_object( + resolved_bucket, + object_key, + file_path, + content_type=content_type, + ) + return ObjectWriteResult(etag=getattr(result, "etag", None), version_id=getattr(result, "version_id", None)) + + return await asyncio.to_thread(_run) + + async def object_exists(self, *, object_key: str, bucket_name: str | None = None) -> bool: + try: + await self.stat_object(object_key=object_key, bucket_name=bucket_name) + except S3Error as exc: + if exc.code in {"NoSuchKey", "NoSuchObject", "NoSuchBucket"}: + return False + raise + except Exception: + return False + return True + + @staticmethod + def file_size(file_path: str) -> int: + return int(os.path.getsize(file_path)) + + def _resolve_bucket_name(self, bucket_name: str | None) -> str: + value = (bucket_name or "").strip() + return value or self.bucket_name diff --git a/app/src/s3/s3.py b/app/src/fileflash/s3/s3.py similarity index 100% rename from app/src/s3/s3.py rename to app/src/fileflash/s3/s3.py diff --git a/app/src/schemas/__init__.py b/app/src/fileflash/schemas/__init__.py similarity index 93% rename from app/src/schemas/__init__.py rename to app/src/fileflash/schemas/__init__.py index 080cc8d..39e1ac4 100644 --- a/app/src/schemas/__init__.py +++ b/app/src/fileflash/schemas/__init__.py @@ -73,6 +73,12 @@ PermissionItem, UpdatePermissionRequest, ) +from .registration_email_domain_rule import ( + CreateRegistrationEmailDomainRuleRequest, + ListRegistrationEmailDomainRulesQuery, + RegistrationEmailDomainRuleItem, + UpdateRegistrationEmailDomainRuleRequest, +) from .recycle import ( ClearRecycleBinResponse, GetRecycleBinQuery, @@ -209,8 +215,10 @@ "PaginationMeta", "PermissionItem", "PermanentDeleteResponse", + "CreateRegistrationEmailDomainRuleRequest", "RateLimitRule", "RateLimitStatus", + "RegistrationEmailDomainRuleItem", "RecycleBinItem", "RegisterRequest", "RemoveGroupMemberResponse", @@ -235,11 +243,13 @@ "StorageUserItem", "StorageUsersList", "SystemHealth", + "ListRegistrationEmailDomainRulesQuery", "ToggleFileStarRequest", "ToggleFolderStarRequest", "TokenResponse", "UpdatePermissionRequest", "UpdateProfileRequest", + "UpdateRegistrationEmailDomainRuleRequest", "UpdateShareSettingsRequest", "UpdateStorageQuotaRequest", "UpdateStorageQuotaResponse", diff --git a/app/src/schemas/agent_skill.py b/app/src/fileflash/schemas/agent_skill.py similarity index 100% rename from app/src/schemas/agent_skill.py rename to app/src/fileflash/schemas/agent_skill.py diff --git a/app/src/schemas/archive.py b/app/src/fileflash/schemas/archive.py similarity index 100% rename from app/src/schemas/archive.py rename to app/src/fileflash/schemas/archive.py diff --git a/app/src/schemas/auth.py b/app/src/fileflash/schemas/auth.py similarity index 100% rename from app/src/schemas/auth.py rename to app/src/fileflash/schemas/auth.py diff --git a/app/src/schemas/common.py b/app/src/fileflash/schemas/common.py similarity index 100% rename from app/src/schemas/common.py rename to app/src/fileflash/schemas/common.py diff --git a/app/src/schemas/file.py b/app/src/fileflash/schemas/file.py similarity index 95% rename from app/src/schemas/file.py rename to app/src/fileflash/schemas/file.py index 8ce5a88..d9dd1ab 100644 --- a/app/src/schemas/file.py +++ b/app/src/fileflash/schemas/file.py @@ -24,6 +24,7 @@ class FileItem(CamelModel): folder_id: str permission: Literal["read", "write", "owner"] | None = None is_starred: bool | None = None + media_optimization: MediaOptimization | None = None class FolderItem(CamelModel): @@ -150,6 +151,13 @@ class FileDetails(FileItem): status: bool +class MediaOptimization(CamelModel): + status: Literal["queued", "running", "ready", "failed"] + media_type: Literal["audio", "video"] + optimized_mime_type: str | None = None + updated_at: datetime + + class RenameFileRequest(CamelModel): file_name: str = Field(min_length=1, max_length=255) diff --git a/app/src/schemas/job.py b/app/src/fileflash/schemas/job.py similarity index 100% rename from app/src/schemas/job.py rename to app/src/fileflash/schemas/job.py diff --git a/app/src/schemas/log.py b/app/src/fileflash/schemas/log.py similarity index 100% rename from app/src/schemas/log.py rename to app/src/fileflash/schemas/log.py diff --git a/app/src/schemas/notification.py b/app/src/fileflash/schemas/notification.py similarity index 100% rename from app/src/schemas/notification.py rename to app/src/fileflash/schemas/notification.py diff --git a/app/src/schemas/permission.py b/app/src/fileflash/schemas/permission.py similarity index 100% rename from app/src/schemas/permission.py rename to app/src/fileflash/schemas/permission.py diff --git a/app/src/schemas/recycle.py b/app/src/fileflash/schemas/recycle.py similarity index 100% rename from app/src/schemas/recycle.py rename to app/src/fileflash/schemas/recycle.py diff --git a/app/src/fileflash/schemas/registration_email_domain_rule.py b/app/src/fileflash/schemas/registration_email_domain_rule.py new file mode 100644 index 0000000..fcff322 --- /dev/null +++ b/app/src/fileflash/schemas/registration_email_domain_rule.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import Field + +from .common import CamelModel, PageQuery + + +class RegistrationEmailDomainRuleItem(CamelModel): + rule_id: str + name: str + pattern: str + enabled: bool + created_at: datetime + updated_at: datetime + + +class ListRegistrationEmailDomainRulesQuery(PageQuery): + query_text: str | None = None + enabled: bool | None = None + + +class CreateRegistrationEmailDomainRuleRequest(CamelModel): + name: str = Field(min_length=1, max_length=120) + pattern: str = Field(min_length=1, max_length=512) + enabled: bool = True + + +class UpdateRegistrationEmailDomainRuleRequest(CamelModel): + name: str | None = Field(default=None, min_length=1, max_length=120) + pattern: str | None = Field(default=None, min_length=1, max_length=512) + enabled: bool | None = None + diff --git a/app/src/schemas/share.py b/app/src/fileflash/schemas/share.py similarity index 100% rename from app/src/schemas/share.py rename to app/src/fileflash/schemas/share.py diff --git a/app/src/schemas/storage.py b/app/src/fileflash/schemas/storage.py similarity index 100% rename from app/src/schemas/storage.py rename to app/src/fileflash/schemas/storage.py diff --git a/app/src/schemas/system.py b/app/src/fileflash/schemas/system.py similarity index 100% rename from app/src/schemas/system.py rename to app/src/fileflash/schemas/system.py diff --git a/app/src/schemas/user.py b/app/src/fileflash/schemas/user.py similarity index 100% rename from app/src/schemas/user.py rename to app/src/fileflash/schemas/user.py diff --git a/app/src/schemas/user_group.py b/app/src/fileflash/schemas/user_group.py similarity index 100% rename from app/src/schemas/user_group.py rename to app/src/fileflash/schemas/user_group.py diff --git a/app/src/scripts/__init__.py b/app/src/fileflash/scripts/__init__.py similarity index 100% rename from app/src/scripts/__init__.py rename to app/src/fileflash/scripts/__init__.py diff --git a/app/src/scripts/init_dev_accounts.py b/app/src/fileflash/scripts/init_dev_accounts.py similarity index 100% rename from app/src/scripts/init_dev_accounts.py rename to app/src/fileflash/scripts/init_dev_accounts.py diff --git a/app/src/fileflash/scripts/run_with_workers.py b/app/src/fileflash/scripts/run_with_workers.py new file mode 100644 index 0000000..7f2db6d --- /dev/null +++ b/app/src/fileflash/scripts/run_with_workers.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +import argparse +import signal +import subprocess +import sys +import time +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path + +from redis import Redis + +from ..core.settings import get_settings + + +@dataclass(slots=True) +class ManagedProcess: + name: str + process: subprocess.Popen[bytes] + command: list[str] + + +def _build_parser() -> argparse.ArgumentParser: + settings = get_settings() + default_worker_count = max(1, settings.worker_process_count) + parser = argparse.ArgumentParser( + description="Run FileFlash backend API with worker processes.", + ) + parser.add_argument("--host", default="0.0.0.0", help="API host (default: 0.0.0.0)") + parser.add_argument("--port", type=int, default=8080, help="API port (default: 8080)") + parser.add_argument( + "--reload", + action="store_true", + help="Enable uvicorn auto-reload for API process.", + ) + parser.add_argument( + "--worker-count", + type=int, + default=default_worker_count, + help=( + "Number of file worker consumer processes " + f"(default from WORKER_PROCESS_COUNT: {default_worker_count})." + ), + ) + parser.add_argument( + "--no-worker", + action="store_true", + help="Start API only (without file workers).", + ) + return parser + + +def _spawn_process(name: str, command: list[str], cwd: Path) -> ManagedProcess: + popen_kwargs: dict[str, object] = {"cwd": str(cwd)} + if sys.platform == "win32": + # Needed so CTRL_BREAK_EVENT can be delivered to child process group. + popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + proc = subprocess.Popen( + command, + **popen_kwargs, + ) + return ManagedProcess(name=name, process=proc, command=command) + + +def _format_cmd(command: list[str]) -> str: + return " ".join(command) + + +def _stop_process(managed: ManagedProcess, *, timeout_sec: float = 8.0) -> None: + proc = managed.process + if proc.poll() is not None: + return + + try: + if sys.platform == "win32": + proc.send_signal(signal.CTRL_BREAK_EVENT) + else: + proc.terminate() + proc.wait(timeout=timeout_sec) + return + except Exception: + pass + + try: + proc.kill() + proc.wait(timeout=timeout_sec) + except Exception: + pass + + +def _validate_redis_for_workers(env: Mapping[str, str] | None = None) -> tuple[bool, str]: + redis_url = (env or {}).get("REDIS_URL", "").strip() + if not redis_url: + settings = get_settings() + redis_url = (settings.redis_url or "").strip() + if not redis_url.strip(): + return ( + False, + "[run-with-workers] worker startup preflight failed: REDIS_URL is not set.", + ) + + client: Redis | None = None + try: + client = Redis.from_url(redis_url, socket_connect_timeout=2.0, socket_timeout=2.0) + client.ping() + except Exception as exc: + return ( + False, + ( + "[run-with-workers] worker startup preflight failed: " + f"cannot connect to Redis at {redis_url}. error={type(exc).__name__}: {exc}" + ), + ) + finally: + try: + client.close() + except Exception: + pass + + return True, "" + + +def main() -> int: + parser = _build_parser() + args = parser.parse_args() + + if args.worker_count < 1: + parser.error("--worker-count must be >= 1") + + cwd = Path(__file__).resolve().parents[2] + python = sys.executable + + processes: list[ManagedProcess] = [] + try: + if not args.no_worker: + ok, error_message = _validate_redis_for_workers() + if not ok: + print(error_message, file=sys.stderr) + return 2 + + api_cmd = [ + python, + "-m", + "uvicorn", + "fileflash.main:app", + "--host", + str(args.host), + "--port", + str(args.port), + ] + if args.reload: + api_cmd.append("--reload") + + api_proc = _spawn_process("api", api_cmd, cwd) + processes.append(api_proc) + print(f"[run-with-workers] started {api_proc.name}: {_format_cmd(api_cmd)}") + + if not args.no_worker: + for index in range(args.worker_count): + worker_name = f"worker-{index + 1}" + worker_cmd = [python, "-m", "fileflash.workers.consumer"] + worker_proc = _spawn_process(worker_name, worker_cmd, cwd) + processes.append(worker_proc) + print(f"[run-with-workers] started {worker_name}: {_format_cmd(worker_cmd)}") + + while True: + for managed in processes: + exit_code = managed.process.poll() + if exit_code is not None: + print( + f"[run-with-workers] process exited: {managed.name} code={exit_code}", + file=sys.stderr, + ) + return int(exit_code) + time.sleep(0.5) + except KeyboardInterrupt: + print("[run-with-workers] shutdown requested, stopping all processes...") + return 0 + finally: + for managed in reversed(processes): + _stop_process(managed) + code = managed.process.poll() + print(f"[run-with-workers] stopped {managed.name} code={code}") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/app/src/services/__init__.py b/app/src/fileflash/services/__init__.py similarity index 81% rename from app/src/services/__init__.py rename to app/src/fileflash/services/__init__.py index 00498ef..e523bba 100644 --- a/app/src/services/__init__.py +++ b/app/src/fileflash/services/__init__.py @@ -2,11 +2,13 @@ from .agent import ExecuteService, McpService, MemoryService, PlanService, SessionService, SettingsService, SkillService from .auth import AuthService from .background_jobs import BackgroundJobService +from .email_delivery import VerificationEmailDeliveryService from .file import FileService from .folder import FolderService from .job_queue import JobQueuePublisher, RedisStreamJobQueue from .messaging import AuthEventPublisher, InProcessAuthEventPublisher from .rate_limiter import RedisRateLimiter +from .registration_email_domain_rule import RegistrationEmailDomainRuleService from .share import ShareService from .upload import UploadService @@ -15,6 +17,7 @@ "AuthService", "ArchiveService", "BackgroundJobService", + "VerificationEmailDeliveryService", "ExecuteService", "FileService", "FolderService", @@ -28,6 +31,7 @@ "InProcessAuthEventPublisher", "RedisStreamJobQueue", "RedisRateLimiter", + "RegistrationEmailDomainRuleService", "ShareService", "UploadService", ] diff --git a/app/src/services/agent/__init__.py b/app/src/fileflash/services/agent/__init__.py similarity index 100% rename from app/src/services/agent/__init__.py rename to app/src/fileflash/services/agent/__init__.py diff --git a/app/src/services/agent/execute_service.py b/app/src/fileflash/services/agent/execute_service.py similarity index 100% rename from app/src/services/agent/execute_service.py rename to app/src/fileflash/services/agent/execute_service.py diff --git a/app/src/services/agent/mcp_service.py b/app/src/fileflash/services/agent/mcp_service.py similarity index 100% rename from app/src/services/agent/mcp_service.py rename to app/src/fileflash/services/agent/mcp_service.py diff --git a/app/src/services/agent/memory_service.py b/app/src/fileflash/services/agent/memory_service.py similarity index 100% rename from app/src/services/agent/memory_service.py rename to app/src/fileflash/services/agent/memory_service.py diff --git a/app/src/services/agent/plan_service.py b/app/src/fileflash/services/agent/plan_service.py similarity index 100% rename from app/src/services/agent/plan_service.py rename to app/src/fileflash/services/agent/plan_service.py diff --git a/app/src/services/agent/session_service.py b/app/src/fileflash/services/agent/session_service.py similarity index 100% rename from app/src/services/agent/session_service.py rename to app/src/fileflash/services/agent/session_service.py diff --git a/app/src/services/agent/settings_service.py b/app/src/fileflash/services/agent/settings_service.py similarity index 100% rename from app/src/services/agent/settings_service.py rename to app/src/fileflash/services/agent/settings_service.py diff --git a/app/src/services/agent/skill_service.py b/app/src/fileflash/services/agent/skill_service.py similarity index 100% rename from app/src/services/agent/skill_service.py rename to app/src/fileflash/services/agent/skill_service.py diff --git a/app/src/services/archive.py b/app/src/fileflash/services/archive.py similarity index 100% rename from app/src/services/archive.py rename to app/src/fileflash/services/archive.py diff --git a/app/src/services/auth.py b/app/src/fileflash/services/auth.py similarity index 88% rename from app/src/services/auth.py rename to app/src/fileflash/services/auth.py index 98197fc..cbd01a6 100644 --- a/app/src/services/auth.py +++ b/app/src/fileflash/services/auth.py @@ -19,38 +19,52 @@ ) from ..models.enums import FileStatus, FolderStatus, FolderType, UiLanguage, UserRole, UserStatus from ..models.tables_audit_security import Log -from ..models.tables_identity import EmailVerificationToken, PasswordResetToken, User, UserPreference, UserSession +from ..models.tables_identity import ( + EmailVerificationToken, + PasswordResetToken, + User, + UserPreference, + UserSession, +) from ..models.tables_storage import File, Folder from ..schemas.auth import ForgotPasswordResponse, RegisterRequest, RegisterResponseData, TokenResponse from ..schemas.common import PaginatedData, PaginationMeta from ..schemas.user import ( ActivityItem, BreakdownDetail, + ChangePasswordRequest, + GetActivityLogQuery, + StorageStats, UpdateAvatarRequest, UpdateProfileRequest, UpdateUserPreferenceRequest, + User as UserSchema, + UserPreference as UserPreferenceSchema, UserProfile, ) -from ..schemas.user import User as UserSchema -from ..schemas.user import ChangePasswordRequest, GetActivityLogQuery, StorageStats -from ..schemas.user import UserPreference as UserPreferenceSchema -from ..schemas.user import UpdateProfileRequest, UpdateUserPreferenceRequest, UserProfile +from .email_delivery import EmailDeliveryConfigurationError, EmailDeliveryError, VerificationEmailDeliveryService from .messaging import AuthEventPublisher from .rate_limiter import RedisRateLimiter +from .registration_email_domain_rule import RegistrationEmailDomainRuleService class AuthService: + MIN_VERIFICATION_TOKEN_LENGTH = 16 + MIN_RESET_TOKEN_LENGTH = 16 + def __init__( self, db: AsyncSession, settings: Settings, rate_limiter: RedisRateLimiter, event_publisher: AuthEventPublisher, + verification_email_delivery: VerificationEmailDeliveryService, ) -> None: self.db = db self.settings = settings self.rate_limiter = rate_limiter self.event_publisher = event_publisher + self.verification_email_delivery = verification_email_delivery async def register( self, @@ -65,6 +79,7 @@ async def register( window_seconds=self.settings.register_rate_window_seconds, message="Too many registration attempts, please try again later", ) + await RegistrationEmailDomainRuleService(self.db).assert_email_allowed(email=payload.email) existing_user = await self.db.scalar( select(User).where( @@ -104,12 +119,16 @@ async def register( "auth.email_verification_requested", { "userId": str(user.user_id), - "email": user.email, - "token": verification_token, "expiresInMinutes": self.settings.email_verification_expire_minutes, "userAgent": user_agent or "", }, ) + await self._send_verification_email_or_raise( + event_name="auth.email_verification_requested", + email=user.email, + token=verification_token, + expires_in_minutes=self.settings.email_verification_expire_minutes, + ) user_schema = self._to_user_schema(user=user, preference=preference) return RegisterResponseData(user=user_schema) @@ -173,7 +192,7 @@ async def _operation() -> tuple[TokenResponse, str]: self.db.add( UserSession( user_id=user.user_id, - refresh_token_hash=hash_token(refresh_token), + refresh_token_hash=hash_token(refresh_token, self.settings), client_type="web", ip_address=client_ip, user_agent=user_agent, @@ -217,7 +236,7 @@ async def refresh( ) -> tuple[TokenResponse, str]: async def _operation() -> tuple[TokenResponse, str]: now = datetime.now(UTC) - token_hash = hash_token(refresh_token) + token_hash = hash_token(refresh_token, self.settings) await apply_local_lock_timeout(self.db) session = await self.db.scalar( select(UserSession) @@ -243,7 +262,7 @@ async def _operation() -> tuple[TokenResponse, str]: next_refresh_token = create_refresh_token() next_session = UserSession( user_id=user.user_id, - refresh_token_hash=hash_token(next_refresh_token), + refresh_token_hash=hash_token(next_refresh_token, self.settings), client_type=session.client_type, device_id=session.device_id, device_name=session.device_name, @@ -277,7 +296,7 @@ async def logout(self, *, refresh_token: str | None) -> None: return now = datetime.now(UTC) - token_hash = hash_token(refresh_token) + token_hash = hash_token(refresh_token, self.settings) session = await self.db.scalar( select(UserSession).where( and_( @@ -309,7 +328,7 @@ async def forgot_password( now = datetime.now(UTC) user = await self.db.scalar(select(User).where(func.lower(User.email) == email.lower())) if user: - reset_token = await self._create_password_reset_token( + await self._create_password_reset_token( user_id=user.user_id, now=now, client_ip=client_ip, @@ -321,8 +340,6 @@ async def forgot_password( { "requestId": request_id, "userId": str(user.user_id), - "email": user.email, - "token": reset_token, "expiresInMinutes": self.settings.password_reset_expire_minutes, }, ) @@ -335,7 +352,8 @@ async def forgot_password( async def reset_password(self, *, token: str, new_password: str) -> None: async def _operation() -> None: now = datetime.now(UTC) - token_hash_value = hash_token(token) + self._assert_token_length(token=token, minimum=self.MIN_RESET_TOKEN_LENGTH, message="Invalid or expired reset token") + token_hash_value = hash_token(token, self.settings) await apply_local_lock_timeout(self.db) reset_record = await self.db.scalar( select(PasswordResetToken) @@ -388,7 +406,12 @@ async def _operation() -> None: async def verify_email(self, *, token: str) -> None: async def _operation() -> None: now = datetime.now(UTC) - token_hash_value = hash_token(token) + self._assert_token_length( + token=token, + minimum=self.MIN_VERIFICATION_TOKEN_LENGTH, + message="Invalid or expired verification token", + ) + token_hash_value = hash_token(token, self.settings) await apply_local_lock_timeout(self.db) verification_record = await self.db.scalar( select(EmailVerificationToken) @@ -451,12 +474,16 @@ async def resend_verification( "auth.email_verification_resent", { "userId": str(user.user_id), - "email": user.email, - "token": token, "expiresInMinutes": self.settings.email_verification_expire_minutes, "userAgent": user_agent or "", }, ) + await self._send_verification_email_or_raise( + event_name="auth.email_verification_resent", + email=user.email, + token=token, + expires_in_minutes=self.settings.email_verification_expire_minutes, + ) async def get_profile(self, *, user_id: int) -> UserProfile: user = await self.db.get(User, user_id) @@ -533,6 +560,7 @@ async def _operation() -> tuple[UserProfile, str | None]: if not email: raise ApiError(status_code=400, code=400, message="email cannot be empty") if email.lower() != user.email.lower(): + await RegistrationEmailDomainRuleService(self.db).assert_email_allowed(email=email) email_exists = await self.db.scalar( select(User.user_id).where( and_( @@ -578,12 +606,16 @@ async def _operation() -> tuple[UserProfile, str | None]: "auth.email_verification_requested", { "userId": str(profile.user_id), - "email": profile.email, - "token": verification_token, "expiresInMinutes": self.settings.email_verification_expire_minutes, "userAgent": user_agent or "", }, ) + await self._send_verification_email_or_raise( + event_name="auth.email_verification_requested", + email=profile.email, + token=verification_token, + expires_in_minutes=self.settings.email_verification_expire_minutes, + ) return profile @@ -620,7 +652,7 @@ async def change_password( if payload.old_password == payload.new_password: raise ApiError(status_code=400, code=400, message="newPassword must be different from oldPassword") - token_hash = hash_token(current_refresh_token) if current_refresh_token else None + token_hash = hash_token(current_refresh_token, self.settings) if current_refresh_token else None async def _operation() -> None: await apply_local_lock_timeout(self.db) @@ -801,10 +833,11 @@ async def _ensure_rate_limit( async def _create_email_verification_token(self, *, user_id: int, now: datetime) -> str: token = secrets.token_urlsafe(32) + await self._invalidate_active_verification_tokens(user_id=user_id, now=now) self.db.add( EmailVerificationToken( user_id=user_id, - token_hash=hash_token(token), + token_hash=hash_token(token, self.settings), expire_at=now + timedelta(minutes=self.settings.email_verification_expire_minutes), ) ) @@ -822,7 +855,7 @@ async def _create_password_reset_token( self.db.add( PasswordResetToken( user_id=user_id, - token_hash=hash_token(token), + token_hash=hash_token(token, self.settings), expire_at=now + timedelta(minutes=self.settings.password_reset_expire_minutes), requester_ip=client_ip, user_agent=user_agent, @@ -830,6 +863,56 @@ async def _create_password_reset_token( ) return token + async def _invalidate_active_verification_tokens(self, *, user_id: int, now: datetime) -> None: + rows = await self.db.scalars( + select(EmailVerificationToken) + .where( + and_( + EmailVerificationToken.user_id == user_id, + EmailVerificationToken.verified_at.is_(None), + EmailVerificationToken.expire_at > now, + ) + ) + .with_for_update() + ) + for row in rows: + row.verified_at = now + + async def _send_verification_email_or_raise( + self, + *, + event_name: str, + email: str, + token: str, + expires_in_minutes: int, + ) -> None: + try: + await self.verification_email_delivery.send_verification_email( + email=email, + token=token, + expires_in_minutes=expires_in_minutes, + ) + except (EmailDeliveryConfigurationError, EmailDeliveryError, ValueError) as exc: + await self.event_publisher.publish( + "auth.email_verification_delivery_failed", + { + "eventName": event_name, + }, + ) + message = "Verification email service is unavailable" + if isinstance(exc, EmailDeliveryConfigurationError): + message = str(exc) + raise ApiError( + status_code=503, + code=503, + message=message, + ) from exc + + @staticmethod + def _assert_token_length(*, token: str, minimum: int, message: str) -> None: + if len(token.strip()) < minimum: + raise ApiError(status_code=400, code=400, message=message) + async def _get_user_preference(self, user_id: int) -> UserPreference | None: statement: Select[tuple[UserPreference]] = select(UserPreference).where(UserPreference.user_id == user_id) return await self.db.scalar(statement) diff --git a/app/src/services/background_jobs.py b/app/src/fileflash/services/background_jobs.py similarity index 86% rename from app/src/services/background_jobs.py rename to app/src/fileflash/services/background_jobs.py index f92759f..4ad5147 100644 --- a/app/src/services/background_jobs.py +++ b/app/src/fileflash/services/background_jobs.py @@ -112,17 +112,26 @@ async def enqueue_transcode_job( self, db: AsyncSession, *, - input_path: str, - output_path: str | None = None, - object_id: int | None = None, + source_bucket_name: str, + source_object_key: str, + source_object_id: int, + output_bucket_name: str, + output_object_key: str, + file_id: int | None = None, requested_by: int | None = None, idempotency_key: str | None = None, ) -> BackgroundJob: - payload: dict[str, Any] = {"inputPath": input_path} - if output_path is not None: - payload["outputPath"] = output_path - if object_id is not None: - payload["objectId"] = object_id + payload: dict[str, Any] = { + "sourceBucketName": source_bucket_name, + "sourceObjectKey": source_object_key, + "sourceObjectId": source_object_id, + "outputBucketName": output_bucket_name, + "outputObjectKey": output_object_key, + } + if file_id is not None: + payload["fileId"] = file_id + if requested_by is not None: + payload["requestedBy"] = requested_by return await self.enqueue( db, task_type="task.transcode", @@ -134,7 +143,8 @@ async def enqueue_transcode_job( def _build_queue_message(job: BackgroundJob) -> WorkerJobMessage: payload = dict(job.payload or {}) - payload.setdefault("jobId", job.job_id) + if payload.get("jobId") in (None, ""): + payload["jobId"] = job.job_id if job.requested_by is not None: payload.setdefault("requestedBy", job.requested_by) return WorkerJobMessage( diff --git a/app/src/services/dev_seed.py b/app/src/fileflash/services/dev_seed.py similarity index 100% rename from app/src/services/dev_seed.py rename to app/src/fileflash/services/dev_seed.py diff --git a/app/src/fileflash/services/email_delivery.py b/app/src/fileflash/services/email_delivery.py new file mode 100644 index 0000000..42de5f0 --- /dev/null +++ b/app/src/fileflash/services/email_delivery.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from dataclasses import dataclass +from urllib.parse import quote + +from fastapi_mail import ConnectionConfig, FastMail, MessageSchema, MessageType, MultipartSubtypeEnum + +from ..core.settings import Settings + + +class EmailDeliveryConfigurationError(RuntimeError): + pass + + +class EmailDeliveryError(RuntimeError): + pass + + +@dataclass(slots=True) +class VerificationEmailPayload: + email: str + token: str + expires_in_minutes: int + + +class VerificationEmailDeliveryService: + def __init__(self, settings: Settings) -> None: + self.settings = settings + + async def send_verification_email( + self, + *, + email: str, + token: str, + expires_in_minutes: int, + ) -> None: + payload = VerificationEmailPayload( + email=email.strip(), + token=token.strip(), + expires_in_minutes=expires_in_minutes, + ) + self._validate_payload(payload) + config = self._build_connection_config() + verification_link = self._build_verification_link(payload.token) + message = MessageSchema( + subject="Verify your FileFlash email", + recipients=[payload.email], + body=self._build_html_body( + verification_link=verification_link, + expires_in_minutes=payload.expires_in_minutes, + ), + alternative_body=self._build_text_body( + verification_link=verification_link, + expires_in_minutes=payload.expires_in_minutes, + ), + subtype=MessageType.html, + multipart_subtype=MultipartSubtypeEnum.alternative, + ) + try: + await FastMail(config).send_message(message) + except Exception as exc: # noqa: BLE001 + raise EmailDeliveryError("Failed to send verification email") from exc + + def _build_connection_config(self) -> ConnectionConfig: + issues = self.settings.mail_configuration_issues + if issues: + raise EmailDeliveryConfigurationError(f"Mail delivery is not configured: {', '.join(issues)}") + + return ConnectionConfig( + MAIL_USERNAME=(self.settings.mail_username or "").strip(), + MAIL_PASSWORD=(self.settings.mail_password or "").strip(), + MAIL_FROM=(self.settings.mail_from or "").strip(), + MAIL_PORT=self.settings.mail_port, + MAIL_SERVER=(self.settings.mail_server or "").strip(), + MAIL_STARTTLS=self.settings.mail_starttls, + MAIL_SSL_TLS=self.settings.mail_ssl_tls, + USE_CREDENTIALS=self.settings.mail_use_credentials, + VALIDATE_CERTS=self.settings.mail_validate_certs, + ) + + def _build_verification_link(self, token: str) -> str: + base = self.settings.normalized_email_verify_base_url + encoded_token = quote(token, safe="") + return f"{base}/verify-email?token={encoded_token}" + + @staticmethod + def _build_text_body(*, verification_link: str, expires_in_minutes: int) -> str: + return ( + "Welcome to FileFlash.\n\n" + "Please verify your email by opening the following link:\n" + f"{verification_link}\n\n" + f"This link expires in {expires_in_minutes} minutes." + ) + + @staticmethod + def _build_html_body(*, verification_link: str, expires_in_minutes: int) -> str: + return ( + "" + "" + "
" + "" + "" + ""
+ "
|
` panel shows the most recent submit payload for sanity. + +- [ ] **Step 1: Edit `pages/__dev/Library.vue`** + +In the existing ` + + ++ + + + + +``` + +- [ ] **Step 2: Verify line count** + +```bash +wc -l web/src/pages/login/Login.vue +``` + +Expected: ≤ 100. If over, condense `saved` derivation inline and remove the `initial` computed (pass `:initial="saved"` directly). + +- [ ] **Step 3: Run check + tests** + +```bash +cd web && bun run check && bun run test +``` + +Expected: type-check clean. Tests untouched (none specifically target Login). If `vue-tsc` complains about the `var(--weight-semibold)` / `var(--tracking-wide)` references being unknown — they're CSS variables defined in `web/src/styles/tokens/type.css`, so tsc won't flag them. If you misnamed a token (e.g. `--font-weight-semibold` legacy), grep tokens: + +```bash +grep -nE "^\s*--weight-|--tracking-" src/styles/tokens/type.css +``` + +and align. + +- [ ] **Step 4: Commit** + +```bash +git add web/src/pages/login/Login.vue +git commit -m "refactor(pages/login): rewrite Login against AuthForm (≤100 lines)" +``` + +--- + +### Task 4: Rewrite Register.vue (≤ 100 lines, Chinese strings preserved) + +**Files:** +- Modify: `web/src/pages/register/Register.vue` (was 225 lines → target ≤ 100) + +- [ ] **Step 1: Replace the whole file** + +```vue + + + ++ Mock Test Accounts + admin / admin123 (administrator) + demo / demo123 (regular user) ++ + +Forgot password + + + Need an account? +Create one + ++ + 已有账号? + + +``` + +- [ ] **Step 2: Verify line count** + +```bash +wc -l web/src/pages/register/Register.vue +``` + +Expected: ≤ 100. + +- [ ] **Step 3: Run check + tests** + +```bash +cd web && bun run check && bun run test +``` + +Expected: green. + +- [ ] **Step 4: Commit** + +```bash +git add web/src/pages/register/Register.vue +git commit -m "refactor(pages/register): rewrite Register against AuthForm (≤100 lines)" +``` + +--- + +### Task 5: Rewrite ForgotPassword.vue (≤ 100 lines, Chinese strings preserved) + +**Files:** +- Modify: `web/src/pages/forgot-password/ForgotPassword.vue` (was 151 lines → target ≤ 100) + +- [ ] **Step 1: Replace the whole file** + +```vue + + + +前往登录 + ++ + + +``` + +- [ ] **Step 2: Verify line count + tests** + +```bash +wc -l web/src/pages/forgot-password/ForgotPassword.vue && cd web && bun run check && bun run test +``` + +Expected: ≤ 100, green. + +- [ ] **Step 3: Commit** + +```bash +git add web/src/pages/forgot-password/ForgotPassword.vue +git commit -m "refactor(pages/forgot-password): rewrite against AuthForm (≤100 lines)" +``` + +--- + +### Task 6: Rewrite VerifyEmail.vue (≤ 100 lines, inline atoms — no AuthForm) + +**Files:** +- Modify: `web/src/pages/verify-email/VerifyEmail.vue` (was 195 lines → target ≤ 100) + +**Design notes:** +- No form fields → AuthForm isn't a fit. Use atoms (`Text`, `Spinner`, `Dot`) + `Button` molecule directly. +- 4 visual states: `pending` (token-driven verifying), `idle` (waiting for user to check email), `success` (verified), `error` (token invalid / failed). +- Dot color encodes state via the existing `Dot` atom tones (`accent` | `success` | `warning` | `error` | `info` — confirmed). Use `success` / `error` / `accent` (pending) / `info` (idle). +- The page lives inside `BareLayout` — already provides centering and background. The page just renders one centered `返回登录 + +` ≤ 420px wide. + +- [ ] **Step 1: Replace the whole file** + +```vue + + + + + ++ + + + +``` + +- [ ] **Step 2: Sanity-check the Dot atom signature (already verified in plan but re-check after pull)** + +```bash +grep -nE "tone\??:|'success'|'error'|'accent'|'info'" src/components/atoms/Dot.vue +``` + +Expected: shows tones `'accent' | 'success' | 'warning' | 'error' | 'info'`. The page already targets only `success` / `error` / `accent` / `info`. If the atom signature has drifted since this plan was written, adapt `dotTone` to use only available tones — **do not modify the Dot atom in this task**. + +- [ ] **Step 3: Verify line count + tests** + +```bash +wc -l web/src/pages/verify-email/VerifyEmail.vue && cd web && bun run check && bun run test +``` + +Expected: ≤ 100, green. + +- [ ] **Step 4: Commit** + +```bash +git add web/src/pages/verify-email/VerifyEmail.vue +git commit -m "refactor(pages/verify-email): rewrite against atoms/molecules (≤100 lines)" +``` + +--- + +## Phase D — Verification + +### Task 7: Full pipeline + token discipline grep + +- [ ] **Step 1: Full pipeline** + +```bash +cd web && bun run test && bun run check && bun run build +``` + +Expected: all green, build artifact produced. + +- [ ] **Step 2: Token discipline — grep new auth code for legacy color/font references** + +```bash +cd web && grep -nE "#[0-9a-fA-F]{3,8}|--color-[a-z]|cubic-bezier\(0.4|translateY\(-1px\)|Manrope|backdrop-filter|linear-gradient" \ + src/components/organisms/auth/*.vue \ + src/pages/login/Login.vue \ + src/pages/register/Register.vue \ + src/pages/forgot-password/ForgotPassword.vue \ + src/pages/verify-email/VerifyEmail.vue +``` + +Expected: 0 hits. If any sneak in, replace with token references. The acceptable exceptions are documented hex values inside `web/src/styles/tokens/*.css` only. + +- [ ] **Step 3: Border-radius audit on new auth files** + +```bash +cd web && grep -nE "border-radius" \ + src/components/organisms/auth/*.vue \ + src/pages/login/Login.vue \ + src/pages/register/Register.vue \ + src/pages/forgot-password/ForgotPassword.vue \ + src/pages/verify-email/VerifyEmail.vue +``` + +Expected: only `var(--radius-sm)` / `var(--radius-md)` / `0`. No literal pixel values like `10px` / `18px` / `42px` for radius. + +- [ ] **Step 4: Line-count audit** + +```bash +wc -l src/pages/login/Login.vue \ + src/pages/register/Register.vue \ + src/pages/forgot-password/ForgotPassword.vue \ + src/pages/verify-email/VerifyEmail.vue +``` + +Expected: each file ≤ 100. Total ≤ 400. + +- [ ] **Step 5: Manual smoke test — 12-combo coverage** + +Run `cd web && bun run dev`. For each route below, cycle `data-accent` (lime/amber/oxide), `data-theme` (dark/light), and `data-motion` (spring/tight/reduced) via DevTools (`document.documentElement.dataset.accent = 'amber'` etc.). Confirm no visual breakage. + +**Per-route checklist:** + +`/login`: +- a. Page renders inside AuthLayout (brand block + card). +- b. Username + password + Remember me visible; mock-account hint visible. +- c. Submit with `admin/admin123` → land on `/files` (assuming backend supports the mock — fall back to checking a 401 error renders cleanly in the error row). +- d. Submit with wrong password → red error block under fields. +- e. Toggle Remember me + valid login → reload → username pre-filled, checkbox pre-checked. +- f. Untick Remember me + valid login → reload → fields reset, no localStorage leak. +- g. Click "Create one" → navigates to `/register`, no full-page fade. +- h. Click "Forgot password" → navigates to `/forgot-password`. +- i. Click eye → password text becomes visible. +- j. While submitting, button shows spinner and is disabled. + +`/register`: +- a. 4 fields visible, Chinese labels. +- b. Submit with mismatched passwords → "两次输入的密码不一致。" appears, no API call. +- c. Submit valid → land on `/verify-email` or `/login` based on backend response. +- d. Click "前往登录" → navigates to `/login`. + +`/forgot-password`: +- a. Single email field, Chinese subtitle. +- b. Submit valid email → success block "重置邮件已发送,请检查邮箱。". +- c. Simulate failure (offline) → red error block. +- d. Click "返回登录" → `/login`. + +`/verify-email`: +- a. Hit `/verify-email` without query → idle dot + initial English copy + (if authed) resend button. +- b. Hit `/verify-email?token=BAD` → pending dot, then error message. +- c. Authed + already verified → success dot + "Your email has already been verified.". +- d. Click "Back to login" → `/login`. Click "Enter files" (when authed) → `/files`. +- e. Click resend → button enters loading, success/error block updates accordingly. + +**Cross-route invariants:** +- Switching `data-accent` retints submit button, checkbox, secondary links, dot, and status borders. +- Switching `data-theme` flips surfaces; text remains WCAG AA readable. +- Switching `data-motion="reduced"` removes spring/fade animation. +- No `console.warn` / `console.error` in DevTools across any of the above. + +- [ ] **Step 6: If anything fails** + +Fix in a follow-up commit on the same task. Do not declare P5 done with broken parity. + +- [ ] **Step 7: No commit if all green** + +Otherwise add a `fix(p5):+ + +Verify your email ++++ {{ message }} ++VERIFYING TOKEN {{ resendMessage }}+{{ resendError }}+ + + + +` commit per fix. + +--- + +### Task 8: Update progress memory + +**Files:** +- Modify: `C:\Users\xc150\.claude\projects\D--pyprj-fileflash\memory\frontend_redesign_progress.md` + +- [ ] **Step 1: Move P5 from "进行中 / 待开始" into "已完成"** + +Read the file, then add an entry after the P4 row in the same format: + +``` +- **P5 Public Auth Flow**(2026-05-12)— 新组件 `organisms/auth/AuthForm.vue`(login/register/forgot 三模式 + 9 个 spec 通过)+ 4 个页面全部重写:Login X 行(旧 267)/ Register Y 行(旧 225)/ ForgotPassword Z 行(旧 151)/ VerifyEmail W 行(旧 195)。VerifyEmail 走 BareLayout,不用 AuthForm(无表单字段,直接拼 atoms/molecules)。dev library 加 `Organisms · Auth` 段含 mode picker。AuthLayout 仍用旧 themeStore(P6 替换)。 +``` + +Replace `X/Y/Z/W` with the actual `wc -l` outputs from Task 7 Step 4. + +Then remove `**P5**` from "进行中 / 待开始" section. + +- [ ] **Step 2: Commit a chore entry to the repo** + +```bash +git commit --allow-empty -m "chore(progress): mark P5 Public Auth Flow complete" +``` + +(The memory file lives outside the repo, so its update is not staged; an empty commit records the milestone for git history.) + +--- + +## Self-Review checklist + +After all tasks land, run this once. + +1. **Spec coverage** — spec §3.1 calls for `organisms/auth/AuthForm.vue` for Login/Register/ForgotPassword. ✅ Created in Task 1. Spec §4 P5 row lists all 4 pages — ✅ all 4 rewritten in Tasks 3–6. Spec §3.3 dev library coverage — ✅ Task 2. + +2. **Pages ≤ 100 lines** — Task 7 Step 4 grep confirms. + +3. **Token discipline** — Task 7 Step 2 grep returns empty. + +4. **No new `common/*` imports from new auth files** — grep: + +```bash +cd web && grep -nE "from '\.\./\.\./common/" \ + src/components/organisms/auth/*.vue \ + src/pages/login/Login.vue \ + src/pages/register/Register.vue \ + src/pages/forgot-password/ForgotPassword.vue \ + src/pages/verify-email/VerifyEmail.vue +``` + +Expected: empty. + +5. **Sharp edges** — Task 7 Step 3 grep confirms. + +6. **Build + test green** — Task 7 Step 1. + +7. **Manual smoke covers all 12 token combos × all 4 routes** — Task 7 Step 5. + +If any check fails, add a fix commit before marking P5 done. + +--- + +## Execution Handoff + +**Plan complete and saved to `docs/superpowers/plans/2026-05-11-frontend-redesign-p5-public-auth-flow.md`. Two execution options:** + +**1. Subagent-Driven (recommended)** — dispatch a fresh subagent per task, review between tasks, fast iteration. + +**2. Inline Execution** — execute tasks in this session using `superpowers:executing-plans`, batch execution with checkpoints. + +**Which approach?** diff --git a/docs/superpowers/plans/2026-05-12-frontend-redesign-p4-other-file-surfaces.md b/docs/superpowers/plans/2026-05-12-frontend-redesign-p4-other-file-surfaces.md new file mode 100644 index 0000000..fd059af --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-frontend-redesign-p4-other-file-surfaces.md @@ -0,0 +1,124 @@ +# P4 Other File Surfaces · Implementation Plan + +**Spec reference**: `docs/superpowers/specs/2026-05-11-frontend-quality-redesign-design.md` §3.1, §4 (P4 row), §9 (acceptance criteria) +**Predecessor**: P3 Core File Path (verified green: 246 tests / typecheck clean as of 2026-05-12) +**Goal**: Migrate Shared / Trash / ShareAccess pages to the Industrial Dashboard system. Each rewritten page file ≤ 100 lines. All visual responsibility moves into organisms under `components/organisms/`. Legacy CSS tokens (`var(--color-border)`, `var(--color-bg-primary)`, `var(--border-radius-*)`) eliminated from these surfaces. + +## Scope + +3 page files in scope: + +| Page | Current LOC | Target LOC | +|---|---|---| +| `pages/shared/SharedWithMe.vue` | 386 | ≤ 100 | +| `pages/trash/Trash.vue` | 258 | ≤ 100 | +| `pages/share/ShareAccess.vue` | 363 | ≤ 100 | + +Note on spec wording "复用 FileTable": after reviewing the data shapes, the three surfaces have distinct column sets that do **not** map cleanly onto `FileTable`'s `ContentItem` contract. Building shared sibling table organisms keeps `FileTable` focused. We will still **reuse atoms, molecules, EmptyState, and the design tokens** — that's the real reuse target. + +## New Organisms + +### `components/organisms/sharing/` (new folder) +- `SharedReceivedTable.vue` — header + rows for `SharedItem[]`. Columns: checkbox, name + type tag, sharedBy, permission, sharedAt (mono), accept-action. Emits `toggle`, `toggle-all`, `accept`. +- `SharedLinksTable.vue` — header + rows for `Share[]`. Columns: resource name + type, share-link code, visits/downloads (mono), createdAt (mono), copy/delete actions. Emits `copy`, `delete`. +- `SharedBatchBar.vue` — selection summary + "Accept Selected" action. Mirrors `BulkActionBar` pattern (count + actions; floating overlay via Transition). +- `index.ts` — public barrel + +### `components/organisms/trash/` (new folder) +- `TrashTable.vue` — header + rows for `RecycleBinItem[]`. Columns: icon + name, originalPath, deletedAt (mono), expires-in (mono, accent tint when ≤ 7 days), restore/delete actions. Emits `restore`, `permanent-delete`. +- `index.ts` + +### `components/organisms/share/` (new folder; not to be confused with `sharing/` above) +- `ShareInfoCard.vue` — read-only metadata card for an accessed share. Rows: Type, Name, Size (mono), Expires, Password. Uses small uppercase labels per design system. +- `ShareAccessPanel.vue` — gate panel. Two modes: password-protected (TextField + Unlock) or open-access (single "Get Access" button). Emits `request-access`. +- `ShareActionsPanel.vue` — post-access actions: Preview / Download (file only) / Save to My Space. Emits `preview`, `download`, `save`. +- `index.ts` + +### `components/organisms/files/` +- `EmptyState.vue` — extend with `variant: 'loading' | 'empty' | 'no-results' | 'error'`. The `'error'` variant is new (replaces the inline `.state.error` block in ShareAccess). Already has `loading`/`empty`/`no-results` from P3; just adds the error case. + +## Page Rewrites + +### `pages/shared/SharedWithMe.vue` (~80 lines target) +- ` @@ -104,19 +123,19 @@ const handleConfirm = () => { diff --git a/web/src/components/common/SelectFolderDialog.vue b/web/src/components/common/SelectFolderDialog.vue index 43987b2..e5b470d 100644 --- a/web/src/components/common/SelectFolderDialog.vue +++ b/web/src/components/common/SelectFolderDialog.vue @@ -1,6 +1,7 @@ + + +{{ promptText }}
-Loading...-No folders available.+{{ t('move.dialog.loading') }}+{{ t('move.dialog.empty') }}{ > {{ rootText }}-+ + + + + + + + ++ + + diff --git a/web/src/components/molecules/Modal.spec.ts b/web/src/components/molecules/Modal.spec.ts new file mode 100644 index 0000000..a9b98c2 --- /dev/null +++ b/web/src/components/molecules/Modal.spec.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { mount } from '../../test/mount'; +import Modal from './Modal.vue'; + +const cleanup = () => { + // Teleport leaves nodes in document.body; clear between tests. + document.body.replaceChildren(); +}; + +describe('molecules/Modal', () => { + afterEach(cleanup); + + it('open=false does not mount the body content', () => { + mount(Modal, { + props: { open: false }, + slots: { default: () => 'Hello' }, + attachTo: document.body, + }); + expect(document.querySelector('.ff-modal__body')).toBeNull(); + }); + + it('open=true renders header / default / footer slots', () => { + mount(Modal, { + props: { open: true }, + slots: { + header: () => 'Title', + default: () => 'Body', + footer: () => 'Foot', + }, + attachTo: document.body, + }); + expect(document.querySelector('.ff-modal__head')?.textContent).toContain('Title'); + expect(document.querySelector('.ff-modal__body')?.textContent).toContain('Body'); + expect(document.querySelector('.ff-modal__foot')?.textContent).toContain('Foot'); + }); + + it('ESC keypress emits close', async () => { + const w = mount(Modal, { + props: { open: true }, + slots: { default: () => 'x' }, + attachTo: document.body, + }); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + await w.vm.$nextTick(); + expect(w.emitted('close')).toHaveLength(1); + }); + + it('click on scrim emits close; click on panel does not', async () => { + const w = mount(Modal, { + props: { open: true }, + slots: { default: () => 'x' }, + attachTo: document.body, + }); + const scrim = document.querySelector('.ff-modal__scrim') as HTMLElement; + const panel = document.querySelector('.ff-modal__panel') as HTMLElement; + expect(scrim).not.toBeNull(); + expect(panel).not.toBeNull(); + scrim.click(); + panel.click(); + await w.vm.$nextTick(); + expect(w.emitted('close')).toHaveLength(1); + }); +}); diff --git a/web/src/components/molecules/Modal.vue b/web/src/components/molecules/Modal.vue new file mode 100644 index 0000000..8437865 --- /dev/null +++ b/web/src/components/molecules/Modal.vue @@ -0,0 +1,94 @@ + + + +Drop file or click to browse + ++ + + + diff --git a/web/src/components/molecules/Pagination.spec.ts b/web/src/components/molecules/Pagination.spec.ts new file mode 100644 index 0000000..94d52f7 --- /dev/null +++ b/web/src/components/molecules/Pagination.spec.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '../../test/mount'; +import Pagination from './Pagination.vue'; + +describe('molecules/Pagination', () => { + it('renders 5 page buttons when total=50, pageSize=10', () => { + const w = mount(Pagination, { props: { page: 1, pageSize: 10, total: 50 } }); + // 5 numeric page buttons + prev + next = 7 + const buttons = w.findAll('button'); + expect(buttons.length).toBe(7); + // Numeric labels 1..5 present + const labels = buttons.map((b) => b.text()); + for (const n of ['1', '2', '3', '4', '5']) { + expect(labels).toContain(n); + } + }); + + it('prev disabled at page 1, next disabled at last page', () => { + const a = mount(Pagination, { props: { page: 1, pageSize: 10, total: 50 } }); + const aButtons = a.findAll('button'); + expect(aButtons[0].attributes('disabled')).toBeDefined(); + expect(aButtons[aButtons.length - 1].attributes('disabled')).toBeUndefined(); + + const b = mount(Pagination, { props: { page: 5, pageSize: 10, total: 50 } }); + const bButtons = b.findAll('button'); + expect(bButtons[0].attributes('disabled')).toBeUndefined(); + expect(bButtons[bButtons.length - 1].attributes('disabled')).toBeDefined(); + }); + + it('clicking a numeric page button emits update:page with that number', async () => { + const w = mount(Pagination, { props: { page: 1, pageSize: 10, total: 50 } }); + const btn3 = w.findAll('button').find((b) => b.text() === '3'); + expect(btn3).toBeTruthy(); + await btn3!.trigger('click'); + expect(w.emitted('update:page')?.[0]).toEqual([3]); + }); + + it('renders nothing when total <= pageSize', () => { + const w = mount(Pagination, { props: { page: 1, pageSize: 10, total: 5 } }); + expect(w.find('nav').exists()).toBe(false); + }); +}); diff --git a/web/src/components/molecules/Pagination.vue b/web/src/components/molecules/Pagination.vue new file mode 100644 index 0000000..5fa6134 --- /dev/null +++ b/web/src/components/molecules/Pagination.vue @@ -0,0 +1,105 @@ + + + + + + + diff --git a/web/src/components/molecules/Select.spec.ts b/web/src/components/molecules/Select.spec.ts new file mode 100644 index 0000000..04af91b --- /dev/null +++ b/web/src/components/molecules/Select.spec.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { mount } from '../../test/mount'; +import Select from './Select.vue'; + +const cleanup = () => document.body.replaceChildren(); + +const OPTS = [ + { value: 'a', label: 'Apple' }, + { value: 'b', label: 'Banana' }, + { value: 'c', label: 'Cherry' }, +]; + +describe('molecules/Select', () => { + afterEach(cleanup); + + it('renders the label of the option whose value === modelValue', () => { + const w = mount(Select, { props: { modelValue: 'b', options: OPTS } }); + expect(w.text()).toContain('Banana'); + }); + + it('renders the placeholder when no option matches', () => { + const w = mount(Select, { + props: { modelValue: 'zz', options: OPTS, placeholder: 'Pick one' }, + }); + expect(w.text()).toContain('Pick one'); + }); + + it('click trigger opens menu; click option emits and hides menu', async () => { + const w = mount(Select, { + props: { modelValue: 'a', options: OPTS }, + attachTo: document.body, + }); + expect(w.find('.ff-select__menu').exists()).toBe(false); + await w.find('.ff-select__trigger').trigger('click'); + expect(w.find('.ff-select__menu').exists()).toBe(true); + const items = w.findAll('.ff-select__option'); + expect(items.length).toBe(3); + await items[2].trigger('click'); + expect(w.emitted('update:modelValue')?.[0]).toEqual(['c']); + expect(w.find('.ff-select__menu').exists()).toBe(false); + }); + + it('Esc on open menu closes it', async () => { + const w = mount(Select, { + props: { modelValue: 'a', options: OPTS }, + attachTo: document.body, + }); + await w.find('.ff-select__trigger').trigger('click'); + expect(w.find('.ff-select__menu').exists()).toBe(true); + await w.find('.ff-select').trigger('keydown', { key: 'Escape' }); + expect(w.find('.ff-select__menu').exists()).toBe(false); + }); +}); diff --git a/web/src/components/molecules/Select.vue b/web/src/components/molecules/Select.vue new file mode 100644 index 0000000..a0a65ad --- /dev/null +++ b/web/src/components/molecules/Select.vue @@ -0,0 +1,170 @@ + + + ++ ++ +++++ ++ ++ ++ + + ++ + + diff --git a/web/src/components/molecules/index.ts b/web/src/components/molecules/index.ts index e49527e..971e577 100644 --- a/web/src/components/molecules/index.ts +++ b/web/src/components/molecules/index.ts @@ -14,3 +14,7 @@ export { default as SegmentedControl } from './SegmentedControl.vue'; export type { SegmentedOption } from './SegmentedControl.vue'; export { default as Toolbar } from './Toolbar.vue'; export { default as Avatar } from './Avatar.vue'; +export { default as Modal } from './Modal.vue'; +export { default as Pagination } from './Pagination.vue'; +export { default as FileDrop } from './FileDrop.vue'; +export { default as Select } from './Select.vue'; diff --git a/web/src/components/organisms/agent/PlanActionRow.vue b/web/src/components/organisms/agent/PlanActionRow.vue new file mode 100644 index 0000000..d623058 --- /dev/null +++ b/web/src/components/organisms/agent/PlanActionRow.vue @@ -0,0 +1,60 @@ + + + ++ ++ + + diff --git a/web/src/components/organisms/agent/PlanInspector.vue b/web/src/components/organisms/agent/PlanInspector.vue new file mode 100644 index 0000000..217e825 --- /dev/null +++ b/web/src/components/organisms/agent/PlanInspector.vue @@ -0,0 +1,128 @@ + + + + + + + diff --git a/web/src/components/organisms/agent/SessionItem.vue b/web/src/components/organisms/agent/SessionItem.vue new file mode 100644 index 0000000..9617f6b --- /dev/null +++ b/web/src/components/organisms/agent/SessionItem.vue @@ -0,0 +1,67 @@ + + + +{{ JSON.stringify(action.input, null, 2) }}+++ + + diff --git a/web/src/components/organisms/agent/SessionList.spec.ts b/web/src/components/organisms/agent/SessionList.spec.ts new file mode 100644 index 0000000..6efdec5 --- /dev/null +++ b/web/src/components/organisms/agent/SessionList.spec.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '../../../test/mount'; +import SessionList from './SessionList.vue'; +import type { Session } from '../../../composables/useAgentSession'; + +const session = (id: string, title = 'X'): Session => ({ + id, + title, + messages: [], + createdAt: '2026-05-20T00:00:00Z', + updatedAt: '2026-05-20T00:00:00Z', +}); + +describe('organisms/agent/SessionList', () => { + it('renders empty placeholder when no sessions', () => { + const w = mount(SessionList, { props: { sessions: [], activeId: null } }); + expect(w.text()).toContain('No sessions yet'); + }); + + it('emits select with id when an item is clicked', async () => { + const w = mount(SessionList, { + props: { sessions: [session('a'), session('b')], activeId: 'a' }, + }); + const items = w.findAll('.ff-si'); + expect(items.length).toBe(2); + await items[1].trigger('click'); + expect(w.emitted('select')?.[0]).toEqual(['b']); + }); + + it('+ button emits create', async () => { + const w = mount(SessionList, { props: { sessions: [], activeId: null } }); + await w.find('header button').trigger('click'); + expect(w.emitted('create')).toHaveLength(1); + }); +}); diff --git a/web/src/components/organisms/agent/SessionList.vue b/web/src/components/organisms/agent/SessionList.vue new file mode 100644 index 0000000..1a14b58 --- /dev/null +++ b/web/src/components/organisms/agent/SessionList.vue @@ -0,0 +1,65 @@ + + + + + + + diff --git a/web/src/components/organisms/agent/SkillCard.spec.ts b/web/src/components/organisms/agent/SkillCard.spec.ts new file mode 100644 index 0000000..ae0863a --- /dev/null +++ b/web/src/components/organisms/agent/SkillCard.spec.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '../../../test/mount'; +import SkillCard from './SkillCard.vue'; +import type { AgentSkillItem } from '../../../types/skill'; + +const skill: AgentSkillItem = { + skillId: 'id-1', + skillKey: 'org.tidy', + name: 'Tidy', + description: 'Organize files', + triggersText: 'organize', + toolWhitelist: [], + planTemplate: {}, + inputsSchema: {}, + outputsSchema: {}, + visibility: 'private', + ownerUserId: 'u-1', + createdAt: '', + updatedAt: '', +}; + +describe('organisms/agent/SkillCard', () => { + it('renders skill.name and skill.skillKey', () => { + const w = mount(SkillCard, { props: { skill } }); + expect(w.text()).toContain('Tidy'); + expect(w.text()).toContain('org.tidy'); + }); + + it('editable=true shows Edit + Delete buttons; clicks emit', async () => { + const w = mount(SkillCard, { props: { skill, editable: true } }); + const buttons = w.findAll('button'); + expect(buttons.length).toBeGreaterThanOrEqual(2); + const edit = buttons.find((b) => /edit/i.test(b.text()))!; + const del = buttons.find((b) => /delete/i.test(b.text()))!; + await edit.trigger('click'); + await del.trigger('click'); + expect(w.emitted('edit')).toHaveLength(1); + expect(w.emitted('delete')).toHaveLength(1); + }); + + it('editable=false shows neither button', () => { + const w = mount(SkillCard, { props: { skill, editable: false } }); + const buttons = w.findAll('button'); + expect(buttons.length).toBe(0); + }); +}); diff --git a/web/src/components/organisms/agent/SkillCard.vue b/web/src/components/organisms/agent/SkillCard.vue new file mode 100644 index 0000000..a761ecc --- /dev/null +++ b/web/src/components/organisms/agent/SkillCard.vue @@ -0,0 +1,65 @@ + + + ++ {{ session.title }} + {{ relativeTime(session.updatedAt) }} +++ + + + + diff --git a/web/src/components/organisms/agent/SkillEditorPanel.vue b/web/src/components/organisms/agent/SkillEditorPanel.vue new file mode 100644 index 0000000..5c929e2 --- /dev/null +++ b/web/src/components/organisms/agent/SkillEditorPanel.vue @@ -0,0 +1,171 @@ + + + ++ +{{ skill.name }}
+{{ + skill.visibility + }} +{{ skill.skillKey }}+{{ skill.description }}
+{{ skill.triggersText }}
+ ++ {{ editingKey ? 'Edit Skill' : 'New Skill' }} + + + + + + + + + diff --git a/web/src/components/organisms/agent/SkillImportPanel.vue b/web/src/components/organisms/agent/SkillImportPanel.vue new file mode 100644 index 0000000..53e2f17 --- /dev/null +++ b/web/src/components/organisms/agent/SkillImportPanel.vue @@ -0,0 +1,132 @@ + + + ++ + + + diff --git a/web/src/components/organisms/agent/TaskInputDock.vue b/web/src/components/organisms/agent/TaskInputDock.vue new file mode 100644 index 0000000..437cfe9 --- /dev/null +++ b/web/src/components/organisms/agent/TaskInputDock.vue @@ -0,0 +1,91 @@ + + + + + + + diff --git a/web/src/components/organisms/agent/TaskTimeline.vue b/web/src/components/organisms/agent/TaskTimeline.vue new file mode 100644 index 0000000..4b676e7 --- /dev/null +++ b/web/src/components/organisms/agent/TaskTimeline.vue @@ -0,0 +1,111 @@ + + + ++ IMPORT SKILLS + + ++ + Drop a .json file or click to browse + + + + +{{ error }}+ ++ ++ ++ RESULTS + ++
+- +
+{{ r.skillKey }}+{{ r.action }} +++ + + diff --git a/web/src/components/organisms/agent/TurnEntry.spec.ts b/web/src/components/organisms/agent/TurnEntry.spec.ts new file mode 100644 index 0000000..b361478 --- /dev/null +++ b/web/src/components/organisms/agent/TurnEntry.spec.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '../../../test/mount'; +import TurnEntry from './TurnEntry.vue'; +import type { AgentTurn } from '../../../composables/useAgentSession'; + +const baseTurn = (overrides: PartialTIMELINE +++Type a task below to get started.
++ +++ = {}): AgentTurn => ({ + user: { + id: 'u-1', + role: 'user', + content: 'do it', + status: 'succeeded', + timestamp: '2026-05-20T00:00:00Z', + }, + agent: { + id: 'a-1', + role: 'agent', + content: '', + status: 'succeeded', + timestamp: '2026-05-20T00:00:00Z', + planHash: 'hash-1', + planResult: { + planJobId: 'p-1', + planHash: 'hash-1', + chosenSkill: null, + proposedActions: [], + summary: 'plan summary text', + requiresConfirmation: false, + costEstimate: { tokens: 100, toolCalls: 2, durationSecEstimate: 5 }, + }, + ...overrides, + }, +}); + +describe('organisms/agent/TurnEntry', () => { + it('renders the plan summary text', () => { + const w = mount(TurnEntry, { + props: { turn: baseTurn(), policy: 'confirm', focused: false }, + }); + expect(w.text()).toContain('plan summary text'); + }); + + it('hides Execute button when policy=planOnly', () => { + const w = mount(TurnEntry, { + props: { turn: baseTurn(), policy: 'planOnly', focused: false }, + }); + const buttons = w.findAll('button').map((b) => b.text()); + expect(buttons.some((t) => /execute/i.test(t))).toBe(false); + }); + + it('Cancel button present when running, clicking emits cancel', async () => { + const w = mount(TurnEntry, { + props: { turn: baseTurn({ status: 'running' }), policy: 'confirm', focused: false }, + }); + const cancelBtn = w.findAll('button').find((b) => /cancel/i.test(b.text())); + expect(cancelBtn).toBeTruthy(); + await cancelBtn!.trigger('click'); + expect(w.emitted('cancel')).toHaveLength(1); + }); +}); diff --git a/web/src/components/organisms/agent/TurnEntry.vue b/web/src/components/organisms/agent/TurnEntry.vue new file mode 100644 index 0000000..8905948 --- /dev/null +++ b/web/src/components/organisms/agent/TurnEntry.vue @@ -0,0 +1,209 @@ + + + + @@ -39,4 +44,7 @@ const t = localeStore.t; gap: 12px; color: var(--text-dim); } +.empty-state[data-variant="error"] { + color: var(--status-error); +} diff --git a/web/src/components/organisms/files/FileDetailPanel.vue b/web/src/components/organisms/files/FileDetailPanel.vue index 51828b9..3167841 100644 --- a/web/src/components/organisms/files/FileDetailPanel.vue +++ b/web/src/components/organisms/files/FileDetailPanel.vue @@ -14,7 +14,6 @@ import type { FileItem } from '../../../types/file'; GlobalWorkerOptions.workerSrc = pdfWorkerUrl; const props = defineProps<{ file: FileItem | null }>(); -const emit = defineEmits<{ (e: 'close'): void }>(); const isLoading = ref(false); const isPdfRendering = ref(false); @@ -373,7 +372,6 @@ onUnmounted(() => {+ + + + diff --git a/web/src/components/organisms/agent/index.ts b/web/src/components/organisms/agent/index.ts new file mode 100644 index 0000000..16d4510 --- /dev/null +++ b/web/src/components/organisms/agent/index.ts @@ -0,0 +1,10 @@ +export { default as SessionItem } from './SessionItem.vue'; +export { default as SessionList } from './SessionList.vue'; +export { default as PlanActionRow } from './PlanActionRow.vue'; +export { default as TurnEntry } from './TurnEntry.vue'; +export { default as TaskTimeline } from './TaskTimeline.vue'; +export { default as TaskInputDock } from './TaskInputDock.vue'; +export { default as PlanInspector } from './PlanInspector.vue'; +export { default as SkillCard } from './SkillCard.vue'; +export { default as SkillEditorPanel } from './SkillEditorPanel.vue'; +export { default as SkillImportPanel } from './SkillImportPanel.vue'; diff --git a/web/src/components/organisms/auth/AuthForm.spec.ts b/web/src/components/organisms/auth/AuthForm.spec.ts new file mode 100644 index 0000000..e74a8f7 --- /dev/null +++ b/web/src/components/organisms/auth/AuthForm.spec.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from 'vitest'; +import { nextTick } from 'vue'; +import { mount } from '../../../test/mount'; +import AuthForm from './AuthForm.vue'; + +const loginLabels = { + identifier: 'Username or Email', identifierPlaceholder: 'Enter username or email', + password: 'Password', passwordPlaceholder: 'Enter password', + rememberMe: 'Remember me', +}; +const registerLabels = { + username: 'Username', usernamePlaceholder: 'Enter username', + email: 'Email', emailPlaceholder: 'Enter email', + password: 'Password', passwordPlaceholder: 'Enter password', + confirmPassword: 'Confirm', confirmPasswordPlaceholder: 'Re-enter password', +}; +const forgotLabels = { + email: 'Email', emailPlaceholder: 'Enter email', +}; + +describe('AuthForm', () => { + it('renders title and subtitle', () => { + const wrapper = mount(AuthForm, { + props: { + mode: 'login', title: 'Sign in', subtitle: 'Manage files', + submitLabel: 'SIGN IN', labels: loginLabels, + }, + }); + expect(wrapper.text()).toContain('Sign in'); + expect(wrapper.text()).toContain('Manage files'); + }); + + it('emits submit with login payload', async () => { + const wrapper = mount(AuthForm, { + props: { + mode: 'login', title: 'Sign in', submitLabel: 'SIGN IN', labels: loginLabels, + }, + }); + const text = wrapper.find('input[type="text"]'); + const pw = wrapper.find('input[type="password"]'); + const cb = wrapper.find('input[type="checkbox"]'); + await text.setValue('alice'); + await pw.setValue('hunter2'); + await cb.setValue(true); + await wrapper.find('form').trigger('submit.prevent'); + const evt = wrapper.emitted('submit'); + expect(evt).toBeTruthy(); + expect(evt?.[0]?.[0]).toEqual({ + mode: 'login', + values: { identifier: 'alice', password: 'hunter2', rememberMe: true }, + }); + }); + + it('emits submit with register payload', async () => { + const wrapper = mount(AuthForm, { + props: { + mode: 'register', title: 'Sign up', submitLabel: 'REGISTER', labels: registerLabels, + }, + }); + const text = wrapper.find('input[type="text"]'); + const email = wrapper.find('input[type="email"]'); + const passwords = wrapper.findAll('input[type="password"]'); + expect(passwords.length).toBe(2); + await text.setValue('bob'); + await email.setValue('bob@example.com'); + await passwords[0].setValue('pw1'); + await passwords[1].setValue('pw2'); + await wrapper.find('form').trigger('submit.prevent'); + expect(wrapper.emitted('submit')?.[0]?.[0]).toEqual({ + mode: 'register', + values: { username: 'bob', email: 'bob@example.com', password: 'pw1', confirmPassword: 'pw2' }, + }); + }); + + it('emits submit with forgot payload', async () => { + const wrapper = mount(AuthForm, { + props: { + mode: 'forgot', title: 'Forgot', submitLabel: 'SEND', labels: forgotLabels, + }, + }); + const email = wrapper.find('input[type="email"]'); + await email.setValue('carol@example.com'); + await wrapper.find('form').trigger('submit.prevent'); + expect(wrapper.emitted('submit')?.[0]?.[0]).toEqual({ + mode: 'forgot', values: { email: 'carol@example.com' }, + }); + }); + + it('renders errorMessage in role=status', () => { + const wrapper = mount(AuthForm, { + props: { + mode: 'forgot', title: 't', submitLabel: 'go', labels: forgotLabels, + errorMessage: 'Email not found', + }, + }); + const status = wrapper.find('[role="status"]'); + expect(status.exists()).toBe(true); + expect(status.text()).toContain('Email not found'); + expect(status.classes()).toContain('ff-auth__msg--error'); + }); + + it('renders successMessage in role=status', () => { + const wrapper = mount(AuthForm, { + props: { + mode: 'forgot', title: 't', submitLabel: 'go', labels: forgotLabels, + successMessage: 'Email sent', + }, + }); + const status = wrapper.find('[role="status"]'); + expect(status.exists()).toBe(true); + expect(status.text()).toContain('Email sent'); + expect(status.classes()).toContain('ff-auth__msg--success'); + }); + + it('disables submit button when isSubmitting is true', () => { + const wrapper = mount(AuthForm, { + props: { + mode: 'forgot', title: 't', submitLabel: 'go', labels: forgotLabels, + isSubmitting: true, + }, + }); + const btn = wrapper.find('button[type="submit"]'); + expect((btn.element as HTMLButtonElement).disabled).toBe(true); + }); + + it('prefills login fields from initial prop', async () => { + const wrapper = mount(AuthForm, { + props: { + mode: 'login', title: 't', submitLabel: 'go', labels: loginLabels, + initial: { identifier: 'alice', rememberMe: true }, + }, + }); + await nextTick(); + const text = wrapper.find('input[type="text"]'); + const cb = wrapper.find('input[type="checkbox"]'); + expect((text.element as HTMLInputElement).value).toBe('alice'); + expect((cb.element as HTMLInputElement).checked).toBe(true); + }); + + it('toggles password visibility via eye button', async () => { + const wrapper = mount(AuthForm, { + props: { + mode: 'login', title: 't', submitLabel: 'go', labels: loginLabels, + }, + }); + const pwInput = wrapper.find('input[type="password"]'); + expect(pwInput.exists()).toBe(true); + await wrapper.find('[data-test="toggle-password"]').trigger('click'); + expect(wrapper.find('input[type="password"]').exists()).toBe(false); + expect(wrapper.find('input[type="text"]').exists()).toBe(true); + }); +}); diff --git a/web/src/components/organisms/auth/AuthForm.vue b/web/src/components/organisms/auth/AuthForm.vue new file mode 100644 index 0000000..e57062b --- /dev/null +++ b/web/src/components/organisms/auth/AuthForm.vue @@ -0,0 +1,282 @@ + + + + + + + diff --git a/web/src/components/organisms/auth/index.ts b/web/src/components/organisms/auth/index.ts new file mode 100644 index 0000000..4096797 --- /dev/null +++ b/web/src/components/organisms/auth/index.ts @@ -0,0 +1,2 @@ +export { default as AuthForm } from './AuthForm.vue'; +export type { AuthSubmitPayload, LoginValues, RegisterValues, ForgotValues, AuthFormLabels } from './AuthForm.vue'; diff --git a/web/src/components/organisms/files/EmptyState.vue b/web/src/components/organisms/files/EmptyState.vue index 351d9e0..5c07521 100644 --- a/web/src/components/organisms/files/EmptyState.vue +++ b/web/src/components/organisms/files/EmptyState.vue @@ -3,8 +3,9 @@ import { Icon, Spinner, Text } from '../../atoms'; import { useLocaleStore } from '../../../store/locale'; defineProps<{ - variant: 'loading' | 'empty' | 'no-results'; + variant: 'loading' | 'empty' | 'no-results' | 'error'; query?: string; + message?: string; }>(); const localeStore = useLocaleStore(); @@ -15,17 +16,21 @@ const t = localeStore.t;+ ++++ + ++ {{ turn.user.content }} + {{ formatTime(turn.user.timestamp) }} +++++ AGENT + {{ + turn.agent.status + }} + + + + ++ {{ turn.agent.planResult.summary }} +
+ ++ + ++ + COST + + tokens+ ++ + + calls + + + est + + + WARN ++ ++
+- {{ w }}
+{{ turn.agent.errorMessage }}+ ++ + ++- {{ t('files.empty.loading') }} +{{ message || t('files.empty.loading') }} - {{ t('files.empty.folderEmpty') }} -{{ t('files.empty.emptyHint') }} +{{ message || t('files.empty.folderEmpty') }} +{{ t('files.empty.emptyHint') }} - +{{ t('files.empty.noMatch') }} "{{ query }}" + ++ {{ message || 'Something went wrong.' }} +{{ file.name }}
-@@ -471,7 +469,6 @@ onUnmounted(() => { font-size: var(--text-small); } -.detail__close, .detail__action, .detail__pdf-btn { height: var(--row-h); @@ -485,12 +482,6 @@ onUnmounted(() => { transition: background-color var(--mo-duration-fast) var(--mo-easing), color var(--mo-duration-fast) var(--mo-easing); } -.detail__close { - width: var(--row-h); - padding: 0; -} - -.detail__close:hover, .detail__action:hover, .detail__pdf-btn:hover:not(:disabled) { background: var(--surface-inset); diff --git a/web/src/components/organisms/files/FilePreviewDialog.vue b/web/src/components/organisms/files/FilePreviewDialog.vue index f7299b0..b0ce844 100644 --- a/web/src/components/organisms/files/FilePreviewDialog.vue +++ b/web/src/components/organisms/files/FilePreviewDialog.vue @@ -54,7 +54,7 @@ const onOverlayClick = (ev: MouseEvent) => { ×diff --git a/web/src/components/organisms/files/FileRow.spec.ts b/web/src/components/organisms/files/FileRow.spec.ts index 3977496..460ef59 100644 --- a/web/src/components/organisms/files/FileRow.spec.ts +++ b/web/src/components/organisms/files/FileRow.spec.ts @@ -1,113 +1,34 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { mount } from '../../../test/mount'; import FileRow from './FileRow.vue'; -const folder = { - id: 'fo1', - name: 'Pics', - itemType: 'folder' as const, - size: 0, - ownerName: '', - updatedAt: '2026-05-01T12:00:00Z', - createdAt: '2026-05-01T12:00:00Z', - parentFolderId: null, - isStarred: false, -}; -const file = { - id: 'fi1', - name: 'report.pdf', - itemType: 'file' as const, - size: 2048, - mimeType: 'application/pdf', - ownerName: '', - updatedAt: '2026-05-02T08:30:00Z', - createdAt: '2026-05-02T08:30:00Z', - folderId: 'fo1', - isStarred: true, -}; - -describe('FileRow', () => { - it('renders name', () => { - const wrapper = mount(FileRow, { - props: { item: file, selected: false, renaming: false, renameValue: '' }, - }); - expect(wrapper.text()).toContain('report.pdf'); - }); - - it('single click emits select with item + modifiers (shift=false default)', async () => { - const wrapper = mount(FileRow, { - props: { item: file, selected: false, renaming: false, renameValue: '' }, - }); - await wrapper.find('.row').trigger('click', { shiftKey: false }); - const payloads = wrapper.emitted('select'); - expect(payloads).toHaveLength(1); - const p = payloads![0][0] as { item: { id: string }; modifiers: { shift: boolean } }; - expect(p.item.id).toBe('fi1'); - expect(p.modifiers.shift).toBe(false); - }); - - it('shift+click sets modifiers.shift = true', async () => { - const wrapper = mount(FileRow, { - props: { item: file, selected: false, renaming: false, renameValue: '' }, - }); - await wrapper.find('.row').trigger('click', { shiftKey: true }); - const p = wrapper.emitted('select')![0][0] as { modifiers: { shift: boolean } }; - expect(p.modifiers.shift).toBe(true); - }); - - it('dblclick emits activate with the item', async () => { - const wrapper = mount(FileRow, { - props: { item: file, selected: false, renaming: false, renameValue: '' }, - }); - await wrapper.find('.row').trigger('dblclick'); - expect(wrapper.emitted('activate')?.[0]?.[0]).toStrictEqual(file); - }); - - it('renaming suppresses dblclick activate and single-click select', async () => { - const wrapper = mount(FileRow, { - props: { item: file, selected: false, renaming: true, renameValue: 'report.pdf' }, - }); - await wrapper.find('.row').trigger('click'); - await wrapper.find('.row').trigger('dblclick'); - expect(wrapper.emitted('activate')).toBeUndefined(); - expect(wrapper.emitted('select')).toBeUndefined(); - }); - - it('emits toggleSelect when checkbox toggled', async () => { - const wrapper = mount(FileRow, { - props: { item: file, selected: false, renaming: false, renameValue: '' }, - }); - await wrapper.find('input[type="checkbox"]').setValue(true); - expect(wrapper.emitted('toggleSelect')?.[0]?.[0]).toBe(file.id); - }); - - it('shows "--" for folder size', () => { - const wrapper = mount(FileRow, { - props: { item: folder, selected: false, renaming: false, renameValue: '' }, - }); - expect(wrapper.text()).toContain('--'); - }); - - it('emits toggleStar', async () => { - const wrapper = mount(FileRow, { - props: { item: file, selected: false, renaming: false, renameValue: '' }, - }); - await wrapper.find('.row__star').trigger('click'); - expect(wrapper.emitted('toggleStar')?.[0]?.[0]).toStrictEqual(file); - }); - - it('temp folder while renaming carries data-temp-folder-row', () => { - const tempFolder = { ...folder, id: 'temp-new-folder-1', name: '' }; - const wrapper = mount(FileRow, { - props: { item: tempFolder, selected: false, renaming: true, renameValue: '' }, - }); - expect(wrapper.find('.row').attributes('data-temp-folder-row')).toBe('temp-new-folder-1'); - }); - - it('non-temp folder while renaming does NOT carry data-temp-folder-row', () => { - const wrapper = mount(FileRow, { - props: { item: folder, selected: false, renaming: true, renameValue: 'Pics' }, - }); - expect(wrapper.find('.row').attributes('data-temp-folder-row')).toBeUndefined(); +describe('FileRow media optimization label', () => { + it('renders processing label for queued optimization status', () => { + const wrapper = mount(FileRow as any, { + props: { + item: { + itemType: 'file', + id: 'f1', + name: 'video.mp4', + size: 1024, + mimeType: 'video/mp4', + ownerName: 'owner', + updatedAt: '2026-05-12T00:00:00Z', + createdAt: '2026-05-12T00:00:00Z', + folderId: 'root', + mediaOptimization: { + status: 'queued', + mediaType: 'video', + updatedAt: '2026-05-12T00:00:00Z', + }, + }, + selected: false, + renaming: false, + renameValue: '', + }, + }); + + expect(wrapper.text()).toContain('处理中'); }); }); + diff --git a/web/src/components/organisms/files/FileRow.vue b/web/src/components/organisms/files/FileRow.vue index 4cb063d..c0a1f38 100644 --- a/web/src/components/organisms/files/FileRow.vue +++ b/web/src/components/organisms/files/FileRow.vue @@ -11,6 +11,7 @@ const props = defineProps<{ selected: boolean; renaming: boolean; renameValue: string; + registerRenameInput?: (itemId: string, el: HTMLInputElement | null) => void; }>(); const localeStore = useLocaleStore(); @@ -47,6 +48,19 @@ const isArchiveFile = (f: FileItem) => { const formatTime = (s: string) => new Date(s).toLocaleString(); const formatSize = (b: number) => `${(b / 1024).toFixed(1)} KB`; +const mediaOptimizationLabel = (item: ContentItem): string | null => { + if (item.itemType !== 'file' || !item.mediaOptimization) { + return null; + } + const status = item.mediaOptimization.status; + if (status === 'queued' || status === 'running') return t('files.mediaOptimization.processing'); + if (status === 'ready') return t('files.mediaOptimization.ready'); + return t('files.mediaOptimization.failedFallback'); +}; +const mediaOptimizationStatus = (item: ContentItem): string | null => { + if (item.itemType !== 'file' || !item.mediaOptimization) return null; + return item.mediaOptimization.status; +}; const onRowClick = (ev: MouseEvent) => { if (props.renaming) return; @@ -94,6 +108,7 @@ const isTempRow = (item: ContentItem): item is FolderItem => :aria-label="item.isStarred ? t('files.table.aria.unstar') : t('files.table.aria.star')" @click.stop="emit('toggleStar', item)" > --+ + @@ -117,7 +132,16 @@ const isTempRow = (item: ContentItem): item is FolderItem => -- - {{ formatTime(item.updatedAt) }}++{{ formatTime(item.updatedAt) }}++ {{ mediaOptimizationLabel(item) }} ++@@ -193,6 +217,7 @@ const isTempRow = (item: ContentItem): item is FolderItem => color: var(--text-dim); cursor: pointer; padding: 0; + flex-shrink: 0; } .row__star--on { color: var(--ac); } .row__star:hover { color: var(--ac); } @@ -202,6 +227,12 @@ const isTempRow = (item: ContentItem): item is FolderItem => color: var(--text-secondary); font-size: 12.5px; } +.row__media-opt { + font-size: 11px; + color: var(--text-dim); +} +.row__media-opt--ready { color: #10b981; } +.row__media-opt--failed { color: #f59e0b; } .row__menu { width: 26px; height: 26px; background: transparent; diff --git a/web/src/components/organisms/files/FileTable.spec.ts b/web/src/components/organisms/files/FileTable.spec.ts index 215947f..073ab31 100644 --- a/web/src/components/organisms/files/FileTable.spec.ts +++ b/web/src/components/organisms/files/FileTable.spec.ts @@ -89,4 +89,31 @@ describe('FileTable', () => { const p = wrapper.emitted('select')![0][0] as { modifiers: { shift: boolean } }; expect(p.modifiers.shift).toBe(true); }); + + it('grid mode: temp folder in renaming state has temp row marker', () => { + const tempItems = [ + { + id: 'temp-new-folder-1', + name: '新建文件夹-20260513-120000', + itemType: 'folder' as const, + size: 0, + ownerName: '', + createdAt: '2026-05-13T12:00:00Z', + updatedAt: '2026-05-13T12:00:00Z', + parentFolderId: 'root', + isStarred: false, + }, + ]; + const wrapper = mount(FileTable, { + props: { + ...baseProps, + mode: 'grid' as const, + items: tempItems, + renamingId: 'temp-new-folder-1', + renameValue: '新建文件夹-20260513-120000', + }, + }); + + expect(wrapper.find('[data-temp-folder-row="temp-new-folder-1"]').exists()).toBe(true); + }); }); diff --git a/web/src/components/organisms/files/FileTable.vue b/web/src/components/organisms/files/FileTable.vue index 6e38e2c..b6530dc 100644 --- a/web/src/components/organisms/files/FileTable.vue +++ b/web/src/components/organisms/files/FileTable.vue @@ -18,6 +18,7 @@ const props = defineProps<{ renameValue: string; sortKey: SortKey; sortDirection: 'asc' | 'desc'; + registerRenameInput?: (itemId: string, el: HTMLInputElement | null) => void; }>(); const localeStore = useLocaleStore(); @@ -68,6 +69,22 @@ const isArchiveFile = (f: FileItem) => { return n.endsWith('.zip') || n.endsWith('.7z') || n.endsWith('.tar') || n.endsWith('.tar.gz') || n.endsWith('.tgz') || n.endsWith('.gz'); }; +const mediaOptimizationLabel = (item: ContentItem): string | null => { + if (item.itemType !== 'file' || !item.mediaOptimization) { + return null; + } + const status = item.mediaOptimization.status; + if (status === 'queued' || status === 'running') return t('files.mediaOptimization.processing'); + if (status === 'ready') return t('files.mediaOptimization.ready'); + return t('files.mediaOptimization.failedFallback'); +}; +const mediaOptimizationStatus = (item: ContentItem): string | null => { + if (item.itemType !== 'file' || !item.mediaOptimization) return null; + return item.mediaOptimization.status; +}; + +const isTempFolder = (item: ContentItem): item is FolderItem => + item.itemType === 'folder' && item.id.startsWith('temp-new-folder'); @@ -101,6 +118,7 @@ const isArchiveFile = (f: FileItem) => { :selected="isSelected(item.id)" :renaming="renamingId === item.id" :rename-value="renameValue" + :register-rename-input="registerRenameInput" @update:rename-value="emit('update:renameValue', $event)" @toggle-select="emit('toggleSelect', $event)" @select="emit('select', $event)" @@ -125,6 +143,7 @@ const isArchiveFile = (f: FileItem) => { :key="item.id" class="card" :class="{ 'card--selected': isSelected(item.id) }" + :data-temp-folder-row="renamingId === item.id && isTempFolder(item) ? item.id : null" draggable="true" @click.stop="emit('select', { item, modifiers: { shift: $event.shiftKey } })" @dblclick="renamingId === item.id ? null : emit('activate', item)" @@ -141,7 +160,7 @@ const isArchiveFile = (f: FileItem) => { @click.stop="emit('toggleStar', item)" :aria-label="item.isStarred ? t('files.table.aria.unstar') : t('files.table.aria.star')" > - + {
{ /> {{ item.name }}++ {{ mediaOptimizationLabel(item) }} +@@ -267,9 +294,10 @@ const isArchiveFile = (f: FileItem) => { color: var(--text-dim); cursor: pointer; padding: 0; + flex-shrink: 0; } .card__star--on { color: var(--ac); } -.card__icon { width: 48px; height: 48px; } +.card__icon { width: 48px; height: 48px; flex-shrink: 0; } .card__name { width: 100%; text-align: center; @@ -277,6 +305,14 @@ const isArchiveFile = (f: FileItem) => { color: var(--text-primary); word-break: break-all; } +.card__media-opt { + width: 100%; + text-align: center; + font-size: 11px; + color: var(--text-dim); +} +.card__media-opt--ready { color: #10b981; } +.card__media-opt--failed { color: #f59e0b; } .card__rename { width: 100%; background: var(--surface-inset); diff --git a/web/src/components/organisms/share/ShareAccessPanel.spec.ts b/web/src/components/organisms/share/ShareAccessPanel.spec.ts new file mode 100644 index 0000000..7e1fa11 --- /dev/null +++ b/web/src/components/organisms/share/ShareAccessPanel.spec.ts @@ -0,0 +1,33 @@ +import { beforeEach, describe, it, expect } from 'vitest'; +import { mount } from '../../../test/mount'; +import ShareAccessPanel from './ShareAccessPanel.vue'; + +describe('ShareAccessPanel', () => { + beforeEach(() => { + localStorage.setItem('fileflash-locale', 'en-US'); + }); + + it('shows password form when protected', () => { + const w = mount(ShareAccessPanel, { + props: { passwordProtected: true, password: '', isAccessing: false }, + }); + expect(w.find('input[type="password"]').exists()).toBe(true); + expect(w.text()).toContain('Unlock'); + }); + + it('shows open-access form when not protected', () => { + const w = mount(ShareAccessPanel, { + props: { passwordProtected: false, password: '', isAccessing: false }, + }); + expect(w.find('input[type="password"]').exists()).toBe(false); + expect(w.text()).toContain('Get Access'); + }); + + it('emits request-access on button click', async () => { + const w = mount(ShareAccessPanel, { + props: { passwordProtected: false, password: '', isAccessing: false }, + }); + await w.find('button').trigger('click'); + expect(w.emitted('request-access')).toBeTruthy(); + }); +}); diff --git a/web/src/components/organisms/share/ShareAccessPanel.vue b/web/src/components/organisms/share/ShareAccessPanel.vue new file mode 100644 index 0000000..511cdc5 --- /dev/null +++ b/web/src/components/organisms/share/ShareAccessPanel.vue @@ -0,0 +1,74 @@ + + + + + + + diff --git a/web/src/components/organisms/share/ShareActionsPanel.spec.ts b/web/src/components/organisms/share/ShareActionsPanel.spec.ts new file mode 100644 index 0000000..195665e --- /dev/null +++ b/web/src/components/organisms/share/ShareActionsPanel.spec.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, it, expect } from 'vitest'; +import { mount } from '../../../test/mount'; +import ShareActionsPanel from './ShareActionsPanel.vue'; + +const baseProps = { + isFile: true, isFolder: false, + canPreview: true, canDownload: true, + isPreviewing: false, isDownloading: false, isSaving: false, +}; + +describe('ShareActionsPanel', () => { + beforeEach(() => { + localStorage.setItem('fileflash-locale', 'en-US'); + }); + + it('renders preview + download for file mode', () => { + const w = mount(ShareActionsPanel, { props: baseProps }); + expect(w.text()).toContain('Preview'); + expect(w.text()).toContain('Download'); + expect(w.text()).toContain('Save to My Space'); + }); + + it('hides preview + download for folder mode', () => { + const w = mount(ShareActionsPanel, { props: { ...baseProps, isFile: false, isFolder: true } }); + expect(w.text()).not.toContain('Preview'); + expect(w.text()).not.toContain('Download'); + expect(w.text()).toContain('Save Folder'); + }); + + it('emits preview/download/save', async () => { + const w = mount(ShareActionsPanel, { props: baseProps }); + const buttons = w.findAll('button'); + await buttons.filter((b) => b.text().includes('Preview'))[0].trigger('click'); + await buttons.filter((b) => b.text().includes('Download'))[0].trigger('click'); + await buttons.filter((b) => b.text().includes('Save'))[0].trigger('click'); + expect(w.emitted('preview')).toBeTruthy(); + expect(w.emitted('download')).toBeTruthy(); + expect(w.emitted('save')).toBeTruthy(); + }); +}); diff --git a/web/src/components/organisms/share/ShareActionsPanel.vue b/web/src/components/organisms/share/ShareActionsPanel.vue new file mode 100644 index 0000000..2b75c96 --- /dev/null +++ b/web/src/components/organisms/share/ShareActionsPanel.vue @@ -0,0 +1,70 @@ + + + + + + + diff --git a/web/src/components/organisms/share/ShareInfoCard.spec.ts b/web/src/components/organisms/share/ShareInfoCard.spec.ts new file mode 100644 index 0000000..d0b5674 --- /dev/null +++ b/web/src/components/organisms/share/ShareInfoCard.spec.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, it, expect } from 'vitest'; +import { mount } from '../../../test/mount'; +import ShareInfoCard from './ShareInfoCard.vue'; +import type { Share } from '../../../types/share'; + +const share: Share = { + shareId: 's1', shareLink: 'abc', itemType: 'file', + itemInfo: { id: 'f', name: 'doc.pdf', size: 2048, mimeType: 'application/pdf' }, + settings: { passwordProtected: true, expireAt: '2026-12-01', allowDownload: true, allowPreview: true }, + createdAt: '2026-05-01T00:00:00Z', +}; + +describe('ShareInfoCard', () => { + beforeEach(() => { + localStorage.setItem('fileflash-locale', 'en-US'); + }); + + it('renders all metadata rows', () => { + const w = mount(ShareInfoCard, { props: { share } }); + expect(w.text()).toContain('File'); + expect(w.text()).toContain('doc.pdf'); + expect(w.text()).toContain('Required'); + expect(w.text()).toContain('2026-12-01'); + }); + + it('shows Never when no expiry', () => { + const noExpiry = { ...share, settings: { ...share.settings, expireAt: null, passwordProtected: false } }; + const w = mount(ShareInfoCard, { props: { share: noExpiry } }); + expect(w.text()).toContain('Never'); + expect(w.text()).toContain('Not required'); + }); +}); diff --git a/web/src/components/organisms/share/ShareInfoCard.vue b/web/src/components/organisms/share/ShareInfoCard.vue new file mode 100644 index 0000000..667357e --- /dev/null +++ b/web/src/components/organisms/share/ShareInfoCard.vue @@ -0,0 +1,75 @@ + + + + + + + diff --git a/web/src/components/organisms/share/index.ts b/web/src/components/organisms/share/index.ts new file mode 100644 index 0000000..b8cb282 --- /dev/null +++ b/web/src/components/organisms/share/index.ts @@ -0,0 +1,3 @@ +export { default as ShareInfoCard } from './ShareInfoCard.vue'; +export { default as ShareAccessPanel } from './ShareAccessPanel.vue'; +export { default as ShareActionsPanel } from './ShareActionsPanel.vue'; diff --git a/web/src/components/organisms/sharing/SharedBatchBar.spec.ts b/web/src/components/organisms/sharing/SharedBatchBar.spec.ts new file mode 100644 index 0000000..8641776 --- /dev/null +++ b/web/src/components/organisms/sharing/SharedBatchBar.spec.ts @@ -0,0 +1,24 @@ +import { beforeEach, describe, it, expect } from 'vitest'; +import { mount } from '../../../test/mount'; +import SharedBatchBar from './SharedBatchBar.vue'; + +describe('SharedBatchBar', () => { + beforeEach(() => { + localStorage.setItem('fileflash-locale', 'en-US'); + }); + + it('hidden when count = 0', () => { + const w = mount(SharedBatchBar, { props: { count: 0 } }); + expect(w.find('.shared-batch').exists()).toBe(false); + }); + + it('emits accept + clear', async () => { + const w = mount(SharedBatchBar, { props: { count: 2 } }); + expect(w.text()).toContain('2'); + const buttons = w.findAll('button'); + await buttons.filter((b) => b.text().includes('Accept'))[0].trigger('click'); + await buttons.filter((b) => b.text().includes('Clear'))[0].trigger('click'); + expect(w.emitted('accept')).toBeTruthy(); + expect(w.emitted('clear')).toBeTruthy(); + }); +}); diff --git a/web/src/components/organisms/sharing/SharedBatchBar.vue b/web/src/components/organisms/sharing/SharedBatchBar.vue new file mode 100644 index 0000000..3369ff9 --- /dev/null +++ b/web/src/components/organisms/sharing/SharedBatchBar.vue @@ -0,0 +1,50 @@ + + + + + + + diff --git a/web/src/components/organisms/sharing/SharedLinksTable.spec.ts b/web/src/components/organisms/sharing/SharedLinksTable.spec.ts new file mode 100644 index 0000000..2640ca2 --- /dev/null +++ b/web/src/components/organisms/sharing/SharedLinksTable.spec.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, it, expect } from 'vitest'; +import { mount } from '../../../test/mount'; +import SharedLinksTable from './SharedLinksTable.vue'; +import type { Share } from '../../../types/share'; + +const items: Share[] = [ + { + shareId: 's1', shareLink: 'abc123', itemType: 'file', + itemInfo: { id: 'f1', name: 'report.pdf', size: 1024, mimeType: 'application/pdf' }, + settings: { passwordProtected: false, expireAt: null, allowDownload: true, allowPreview: true }, + createdAt: '2026-05-01T00:00:00Z', visitCount: 3, downloadCount: 1, + }, +]; + +describe('SharedLinksTable', () => { + beforeEach(() => { + localStorage.setItem('fileflash-locale', 'en-US'); + }); + + it('renders rows', () => { + const w = mount(SharedLinksTable, { props: { items } }); + expect(w.text()).toContain('report.pdf'); + expect(w.text()).toContain('abc123'); + expect(w.text()).toContain('3 / 1'); + }); + + it('emits copy + delete', async () => { + const w = mount(SharedLinksTable, { props: { items } }); + const buttons = w.findAll('button'); + await buttons.filter((b) => b.text().includes('Copy'))[0].trigger('click'); + await buttons.filter((b) => b.text().includes('Delete'))[0].trigger('click'); + expect(w.emitted('copy')).toBeTruthy(); + expect(w.emitted('delete')).toBeTruthy(); + }); +}); diff --git a/web/src/components/organisms/sharing/SharedLinksTable.vue b/web/src/components/organisms/sharing/SharedLinksTable.vue new file mode 100644 index 0000000..75f231b --- /dev/null +++ b/web/src/components/organisms/sharing/SharedLinksTable.vue @@ -0,0 +1,104 @@ + + + + ++ + + diff --git a/web/src/components/organisms/sharing/SharedReceivedTable.spec.ts b/web/src/components/organisms/sharing/SharedReceivedTable.spec.ts new file mode 100644 index 0000000..d8d6e8b --- /dev/null +++ b/web/src/components/organisms/sharing/SharedReceivedTable.spec.ts @@ -0,0 +1,28 @@ +import { beforeEach, describe, it, expect } from 'vitest'; +import { mount } from '../../../test/mount'; +import SharedReceivedTable from './SharedReceivedTable.vue'; +import type { SharedItem } from '../../../types/share'; + +const items: SharedItem[] = [ + { itemType: 'file', id: 'a', name: 'a.txt', size: 100, sharedBy: 'alice', permission: 'read', sharedAt: '2026-05-01T00:00:00Z' }, + { itemType: 'folder', id: 'b', name: 'docs', size: 0, sharedBy: 'bob', permission: 'write', sharedAt: '2026-05-02T00:00:00Z' }, +]; + +describe('SharedReceivedTable', () => { + beforeEach(() => { + localStorage.setItem('fileflash-locale', 'en-US'); + }); + + it('renders header + rows', () => { + const w = mount(SharedReceivedTable, { props: { items, selection: new Set() } }); + expect(w.findAll('.shared-table__row')).toHaveLength(2); + expect(w.text()).toContain('a.txt'); + expect(w.text()).toContain('alice'); + }); + + it('emits accept when accept button clicked', async () => { + const w = mount(SharedReceivedTable, { props: { items, selection: new Set() } }); + await w.findAll('button').filter((b) => b.text().includes('Accept'))[0].trigger('click'); + expect(w.emitted('accept')).toBeTruthy(); + }); +}); diff --git a/web/src/components/organisms/sharing/SharedReceivedTable.vue b/web/src/components/organisms/sharing/SharedReceivedTable.vue new file mode 100644 index 0000000..2624c9c --- /dev/null +++ b/web/src/components/organisms/sharing/SharedReceivedTable.vue @@ -0,0 +1,126 @@ + + + + + + + diff --git a/web/src/components/organisms/sharing/index.ts b/web/src/components/organisms/sharing/index.ts new file mode 100644 index 0000000..2820a38 --- /dev/null +++ b/web/src/components/organisms/sharing/index.ts @@ -0,0 +1,3 @@ +export { default as SharedReceivedTable } from './SharedReceivedTable.vue'; +export { default as SharedLinksTable } from './SharedLinksTable.vue'; +export { default as SharedBatchBar } from './SharedBatchBar.vue'; diff --git a/web/src/components/organisms/shell/LeftSidebar.spec.ts b/web/src/components/organisms/shell/LeftSidebar.spec.ts new file mode 100644 index 0000000..6139bd2 --- /dev/null +++ b/web/src/components/organisms/shell/LeftSidebar.spec.ts @@ -0,0 +1,184 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { nextTick } from 'vue'; +import { mount } from '../../../test/mount'; +import LeftSidebar from './LeftSidebar.vue'; +import { useFileStore } from '../../../store/file'; +import { eventBus } from '../../../utils/eventBus'; + +const { + getStarredFilesMock, + getFolderPathMock, + pushMock, +} = vi.hoisted(() => ({ + getStarredFilesMock: vi.fn(), + getFolderPathMock: vi.fn(), + pushMock: vi.fn(async () => undefined), +})); + +vi.mock('../../../api/file', () => ({ + getStarredFiles: getStarredFilesMock, +})); + +vi.mock('../../../api/folder', () => ({ + getFolderPath: getFolderPathMock, +})); + +vi.mock('vue-router', () => ({ + useRouter: () => ({ + push: pushMock, + currentRoute: { value: { path: '/shared' } }, + }), +})); + +const baseTimestamp = '2026-05-13T10:00:00Z'; + +const starredFile = { + itemType: 'file' as const, + id: 'file-1', + name: 'notes.txt', + size: 120, + mimeType: 'text/plain', + ownerName: 'You', + updatedAt: baseTimestamp, + createdAt: baseTimestamp, + folderId: 'folder-1', + isStarred: true, +}; + +const starredFolder = { + itemType: 'folder' as const, + id: 'folder-2', + name: 'Design', + size: 320, + ownerName: 'You', + updatedAt: baseTimestamp, + createdAt: baseTimestamp, + parentFolderId: 'root', + isStarred: true, +}; + +const flush = async () => { + await Promise.resolve(); + await nextTick(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await Promise.resolve(); + await nextTick(); +}; + +describe('LeftSidebar', () => { + beforeEach(() => { + getStarredFilesMock.mockReset(); + getFolderPathMock.mockReset(); + pushMock.mockReset(); + getStarredFilesMock.mockResolvedValue({ + items: [starredFile, starredFolder], + pagination: { + totalItems: 2, + totalPages: 1, + perPage: 20, + currentPage: 1, + hasPrev: false, + hasNext: false, + }, + }); + getFolderPathMock.mockImplementation(async (folderId: string) => ({ + fullPath: `My Files/${folderId}`, + pathItems: [ + { folderId: 'root', name: 'My Files' }, + { folderId, name: `Path-${folderId}` }, + ], + })); + }); + + it('renders Starred section above Workspace Tree and shows path subtitle', async () => { + const wrapper = mount(LeftSidebar, { + props: { collapsed: false }, + global: { + stubs: { + 'router-link': { template: '++ +{{ t('sharing.table.links.resource') }} +{{ t('sharing.table.links.shareLink') }} +{{ t('sharing.table.links.visitsDownloads') }} +{{ t('sharing.table.links.createdAt') }} + +++++ +{{ share.itemInfo.name }} +{{ formatItemType(share.itemType) }} +++ +{{ share.shareLink }}++ {{ share.visitCount || 0 }} / {{ share.downloadCount || 0 }} ++ +{{ formatTime(share.createdAt) }}+ ++ + ++' }, + FileTreeNode: true, + StorageStatusWidget: true, + }, + }, + }); + await flush(); + await flush(); + + const labels = wrapper.findAll('.tree-section .ff-text'); + expect(labels[0]?.text()).toContain('Starred'); + expect(labels[1]?.text()).toMatch(/工作区目录|Workspace Tree/); + expect(getFolderPathMock).toHaveBeenCalledWith('folder-1'); + expect(getFolderPathMock).toHaveBeenCalledWith('folder-2'); + + const subtitles = wrapper.findAll('.starred-path'); + expect(subtitles).toHaveLength(2); + expect(subtitles[0]?.text()).toContain('Path-folder-1'); + expect(subtitles[1]?.text()).toContain('Path-folder-2'); + wrapper.unmount(); + }); + + it('clicking starred file opens preview via fileStore.previewFile', async () => { + const wrapper = mount(LeftSidebar, { + props: { collapsed: false }, + global: { + stubs: { + 'router-link': { template: ' ' }, + FileTreeNode: true, + StorageStatusWidget: true, + }, + }, + }); + await flush(); + const fileStore = useFileStore(); + + const firstRow = wrapper.findAll('.starred-row')[0]; + await firstRow.trigger('click'); + + expect(fileStore.previewFile).toMatchObject({ id: 'file-1', itemType: 'file' }); + wrapper.unmount(); + }); + + it('clicking starred folder navigates to /files and target folder', async () => { + const wrapper = mount(LeftSidebar, { + props: { collapsed: false }, + global: { + stubs: { + 'router-link': { template: ' ' }, + FileTreeNode: true, + StorageStatusWidget: true, + }, + }, + }); + await flush(); + const fileStore = useFileStore(); + const navigateSpy = vi.spyOn(fileStore, 'navigateToFolder'); + + const folderRow = wrapper.findAll('.starred-row')[1]; + await folderRow.trigger('click'); + + expect(pushMock).toHaveBeenCalledWith('/files'); + expect(navigateSpy).toHaveBeenCalledWith('folder-2'); + wrapper.unmount(); + }); + + it('refresh-file-tree event refreshes starred data', async () => { + const wrapper = mount(LeftSidebar, { + props: { collapsed: false }, + global: { + stubs: { + 'router-link': { template: ' ' }, + FileTreeNode: true, + StorageStatusWidget: true, + }, + }, + }); + await flush(); + const before = getStarredFilesMock.mock.calls.length; + + eventBus.emit('refresh-file-tree'); + await flush(); + + expect(getStarredFilesMock.mock.calls.length).toBeGreaterThan(before); + wrapper.unmount(); + }); +}); diff --git a/web/src/components/organisms/shell/LeftSidebar.vue b/web/src/components/organisms/shell/LeftSidebar.vue index 67c0917..e381863 100644 --- a/web/src/components/organisms/shell/LeftSidebar.vue +++ b/web/src/components/organisms/shell/LeftSidebar.vue @@ -1,7 +1,10 @@ @@ -64,10 +149,34 @@ onUnmounted(() => { eventBus.off('refresh-file-tree', refreshTree); }); -Workspace --+- + + +{{ t('sidebar.starred') }} ++ ++Loading...++ {{ t('sidebar.starredEmpty') }} +++ {{ t('sidebar.workspaceTree') }} ++++ @@ -95,6 +204,77 @@ onUnmounted(() => { eventBus.off('refresh-file-tree', refreshTree); }); .nav-link.active { background: rgba(var(--ac-rgb), 0.12); color: var(--ac); font-weight: var(--weight-medium); } .left-sidebar.collapsed .nav-link { justify-content: center; padding: 0; } .link-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.tree-panel { min-height: 0; flex: 1; display: flex; flex-direction: column; border-top: 1px solid var(--border-subtle); padding-top: var(--sp-md); gap: var(--sp-sm); } +.tree-panel { + min-height: 0; + flex: 1; + display: flex; + flex-direction: column; + border-top: 1px solid var(--border-subtle); + padding-top: var(--sp-md); + gap: var(--sp-md); +} +.tree-section { + min-height: 0; + display: flex; + flex-direction: column; + gap: var(--sp-sm); +} .tree-scroll { flex: 1; overflow: auto; padding-right: 4px; } +.starred-list { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 180px; + overflow: auto; + padding-right: 4px; +} +.starred-row { + height: var(--row-h); + width: 100%; + border: none; + background: transparent; + color: var(--text-secondary); + border-radius: var(--radius-sm); + display: flex; + align-items: center; + gap: 10px; + padding: 0 10px; + cursor: pointer; + text-align: left; +} +.starred-row > :first-child { + flex-shrink: 0; +} +.starred-row:hover { + background: var(--surface-inset); + color: var(--text-primary); +} +.starred-meta { + min-width: 0; + display: flex; + flex-direction: column; + line-height: 1.2; +} +.starred-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-primary); + font-size: 12.5px; +} +.starred-path { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-dim); + font-size: 11px; +} +.starred-state { + height: var(--row-h); + display: flex; + align-items: center; + color: var(--text-dim); + font-size: 11px; + padding: 0 10px; +} diff --git a/web/src/components/organisms/trash/TrashTable.spec.ts b/web/src/components/organisms/trash/TrashTable.spec.ts new file mode 100644 index 0000000..aa945c7 --- /dev/null +++ b/web/src/components/organisms/trash/TrashTable.spec.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, it, expect } from 'vitest'; +import { mount } from '../../../test/mount'; +import TrashTable from './TrashTable.vue'; +import type { RecycleBinItem } from '../../../types/file'; + +const items: RecycleBinItem[] = [ + { itemType: 'file', id: 'a', name: 'a.txt', originalPath: '/foo/bar', size: 100, deletedAt: '2026-05-01T00:00:00Z', autoDeleteAt: '2026-06-01T00:00:00Z', daysUntilPermanentDelete: 14, canRestore: true, restoreConflicts: false }, + { itemType: 'file', id: 'b', name: 'b.txt', originalPath: '/foo', size: 200, deletedAt: '2026-05-05T00:00:00Z', autoDeleteAt: '2026-05-15T00:00:00Z', daysUntilPermanentDelete: 3, canRestore: true, restoreConflicts: false }, +]; + +describe('TrashTable', () => { + beforeEach(() => { + localStorage.setItem('fileflash-locale', 'en-US'); + }); + + it('renders rows', () => { + const w = mount(TrashTable, { props: { items } }); + expect(w.text()).toContain('a.txt'); + expect(w.text()).toContain('/foo/bar'); + expect(w.text()).toContain('14 days'); + }); + + it('flags near-expiry items', () => { + const w = mount(TrashTable, { props: { items } }); + expect(w.findAll('.trash-table__cell--warning').length).toBeGreaterThan(0); + }); + + it('emits restore + permanent-delete', async () => { + const w = mount(TrashTable, { props: { items } }); + const buttons = w.findAll('button'); + await buttons.filter((b) => b.text().includes('Restore'))[0].trigger('click'); + await buttons.filter((b) => b.text().includes('Delete'))[0].trigger('click'); + expect(w.emitted('restore')).toBeTruthy(); + expect(w.emitted('permanent-delete')).toBeTruthy(); + }); +}); diff --git a/web/src/components/organisms/trash/TrashTable.vue b/web/src/components/organisms/trash/TrashTable.vue new file mode 100644 index 0000000..58b79f1 --- /dev/null +++ b/web/src/components/organisms/trash/TrashTable.vue @@ -0,0 +1,99 @@ + + + + ++ + + diff --git a/web/src/components/organisms/trash/index.ts b/web/src/components/organisms/trash/index.ts new file mode 100644 index 0000000..d00d5d8 --- /dev/null +++ b/web/src/components/organisms/trash/index.ts @@ -0,0 +1 @@ +export { default as TrashTable } from './TrashTable.vue'; diff --git a/web/src/components/templates/AgentLayout.vue b/web/src/components/templates/AgentLayout.vue index 6fade97..49b7dad 100644 --- a/web/src/components/templates/AgentLayout.vue +++ b/web/src/components/templates/AgentLayout.vue @@ -1,18 +1,15 @@++ +{{ t('trash.table.name') }} +{{ t('trash.table.originalLocation') }} +{{ t('trash.table.deletedAt') }} +{{ t('trash.table.expiresIn') }} + +++++ ++
{{ item.name }} +{{ item.originalPath }}+{{ formatTime(item.deletedAt) }}++ {{ formatDays(item.daysUntilPermanentDelete) }} ++ ++ + ++-diff --git a/web/src/composables/useAgentSession.spec.ts b/web/src/composables/useAgentSession.spec.ts new file mode 100644 index 0000000..0fddafc --- /dev/null +++ b/web/src/composables/useAgentSession.spec.ts @@ -0,0 +1,228 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { nextTick } from 'vue'; + +vi.mock('../api/agent', () => ({ + planAgentTask: vi.fn(), + executeAgentPlan: vi.fn(), + cancelAgentJob: vi.fn(), + getAgentJob: vi.fn(), +})); + +vi.mock('../store/user', () => ({ + useUserStore: () => ({ user: { userId: 'u-1' } }), +})); + +import * as agentApi from '../api/agent'; + +const STORAGE_KEY = 'fileflash.agent.sessions.v1'; + +const planResult = { + planJobId: 'job-1', + planHash: 'hash-1', + chosenSkill: null, + proposedActions: [], + summary: 'do it', + requiresConfirmation: false, + costEstimate: { tokens: 10, toolCalls: 1, durationSecEstimate: 1 }, +}; + +const execResult = { + planJobId: 'job-1', + executeJobId: 'job-2', + summary: 'done', + appliedActions: 1, + skippedActions: 0, + warnings: [], + finishedAt: '2026-05-20T00:00:00Z', +}; + +const loadComposable = async () => { + const mod = await import('./useAgentSession'); + return mod; +}; + +describe('useAgentSession', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + vi.useFakeTimers(); + }); + + afterEach(async () => { + const { __resetForTests } = await loadComposable(); + __resetForTests(); + vi.useRealTimers(); + }); + + it('createSession adds a session, sets active, persists to localStorage', async () => { + const { default: useAgentSession } = await loadComposable(); + const { sessions, activeSessionId, createSession } = useAgentSession(); + createSession(); + expect(sessions.value.length).toBe(1); + expect(activeSessionId.value).toBe(sessions.value[0].id); + await nextTick(); + const stored = localStorage.getItem(STORAGE_KEY); + expect(stored).toBeTruthy(); + expect(JSON.parse(stored!).length).toBe(1); + }); + + it('createSession reuses existing empty session and does not grow list', async () => { + const { default: useAgentSession } = await loadComposable(); + const { sessions, activeSessionId, createSession } = useAgentSession(); + const first = createSession(); + const second = createSession(); + + expect(sessions.value.length).toBe(1); + expect(second.id).toBe(first.id); + expect(activeSessionId.value).toBe(first.id); + }); + + it('createSession creates new only when there is no empty session', async () => { + vi.mocked(agentApi.planAgentTask).mockResolvedValue({ + jobId: 'job-1', + status: 'pending', + taskType: 'agent.plan', + }); + vi.mocked(agentApi.getAgentJob).mockResolvedValue({ + jobId: 'job-1', + status: 'succeeded', + result: planResult, + } as any); + + const { default: useAgentSession } = await loadComposable(); + const { sessions, taskInput, sendMessage, createSession } = useAgentSession(); + + createSession(); + taskInput.value = 'hello'; + await sendMessage(); + + expect(sessions.value.length).toBe(1); + const firstId = sessions.value[0].id; + + const created = createSession(); + expect(sessions.value.length).toBe(2); + expect(created.id).not.toBe(firstId); + + const reused = createSession(); + expect(sessions.value.length).toBe(2); + expect(reused.id).toBe(created.id); + }); + + it('sendMessage plans and polls to succeeded', async () => { + vi.mocked(agentApi.planAgentTask).mockResolvedValue({ + jobId: 'job-1', + status: 'pending', + taskType: 'agent.plan', + }); + vi.mocked(agentApi.getAgentJob).mockResolvedValue({ + jobId: 'job-1', + status: 'succeeded', + result: planResult, + } as any); + + const { default: useAgentSession } = await loadComposable(); + const { taskInput, sendMessage, activeSession } = useAgentSession(); + taskInput.value = 'hello'; + await sendMessage(); + + const conv = activeSession.value!; + expect(conv.messages.length).toBe(2); + expect(conv.messages[0].role).toBe('user'); + expect(conv.messages[1].role).toBe('agent'); + expect(conv.messages[1].planResult?.planHash).toBe('hash-1'); + expect(conv.messages[1].planHash).toBe('hash-1'); + expect(conv.messages[1].status).toBe('succeeded'); + expect(agentApi.planAgentTask).toHaveBeenCalled(); + }); + + it('runExecute calls executeAgentPlan and polls to succeeded', async () => { + vi.mocked(agentApi.planAgentTask).mockResolvedValue({ + jobId: 'job-1', + status: 'pending', + taskType: 'agent.plan', + }); + vi.mocked(agentApi.getAgentJob) + .mockResolvedValueOnce({ jobId: 'job-1', status: 'succeeded', result: planResult } as any) + .mockResolvedValueOnce({ jobId: 'job-2', status: 'succeeded', result: execResult } as any); + vi.mocked(agentApi.executeAgentPlan).mockResolvedValue({ + jobId: 'job-2', + status: 'pending', + taskType: 'agent.execute', + }); + + const { default: useAgentSession } = await loadComposable(); + const { taskInput, sendMessage, runExecute, activeTurns } = useAgentSession(); + taskInput.value = 'hello'; + await sendMessage(); + + const turn = activeTurns.value[0]; + await runExecute(turn.agent); + expect(agentApi.executeAgentPlan).toHaveBeenCalled(); + expect(turn.agent.executeResult?.executeJobId).toBe('job-2'); + expect(turn.agent.status).toBe('succeeded'); + }); + + it('cancel calls cancelAgentJob and clears polling for that turn', async () => { + vi.mocked(agentApi.planAgentTask).mockResolvedValue({ + jobId: 'job-1', + status: 'pending', + taskType: 'agent.plan', + }); + // Keep job 'running' so interval is scheduled + vi.mocked(agentApi.getAgentJob).mockResolvedValue({ + jobId: 'job-1', + status: 'running', + } as any); + vi.mocked(agentApi.cancelAgentJob).mockResolvedValue({ + jobId: 'job-1', + status: 'canceled', + canceledAt: '2026-05-20T00:00:00Z', + }); + + const { default: useAgentSession } = await loadComposable(); + const { taskInput, sendMessage, cancel, activeTurns } = useAgentSession(); + taskInput.value = 'hello'; + await sendMessage(); + + const turn = activeTurns.value[0]; + const callsBefore = vi.mocked(agentApi.getAgentJob).mock.calls.length; + await cancel(turn.agent); + expect(agentApi.cancelAgentJob).toHaveBeenCalled(); + // Advance time to ensure timer wouldn't fire again + await vi.advanceTimersByTimeAsync(3000); + const callsAfter = vi.mocked(agentApi.getAgentJob).mock.calls.length; + expect(callsAfter).toBe(callsBefore); + }); + + it('reload — sessions persist via localStorage', async () => { + const { default: useAgentSession, __resetForTests } = await loadComposable(); + const a = useAgentSession(); + a.createSession(); + a.sessions.value[0].title = 'TestRun'; + await nextTick(); + + __resetForTests(); + const b = useAgentSession(); + expect(b.sessions.value.length).toBe(1); + expect(b.sessions.value[0].title).toBe('TestRun'); + }); + + it('reload deduplicates multiple empty sessions from localStorage', async () => { + const now = '2026-05-20T00:00:00Z'; + localStorage.setItem( + STORAGE_KEY, + JSON.stringify([ + { id: 's-1', title: 'New session', messages: [], createdAt: now, updatedAt: now }, + { id: 's-2', title: 'New session', messages: [], createdAt: now, updatedAt: now }, + ]), + ); + + const { default: useAgentSession } = await loadComposable(); + const { sessions, activeSessionId } = useAgentSession(); + + expect(sessions.value.length).toBe(1); + expect(sessions.value[0].id).toBe('s-1'); + expect(activeSessionId.value).toBe('s-1'); + expect(JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]')).toHaveLength(1); + }); +}); diff --git a/web/src/composables/useAgentSession.ts b/web/src/composables/useAgentSession.ts new file mode 100644 index 0000000..06e8c71 --- /dev/null +++ b/web/src/composables/useAgentSession.ts @@ -0,0 +1,414 @@ +import { computed, onScopeDispose, ref, watch, type Ref } from 'vue'; +import { + cancelAgentJob, + executeAgentPlan, + getAgentJob, + planAgentTask, +} from '../api/agent'; +import { useUserStore } from '../store/user'; +import type { + AgentExecutionPolicy, + AgentExecutionResult, + AgentPlanResult, + PlanAgentRequest, +} from '../types/agent'; + +export type MsgStatus = 'pending' | 'running' | 'succeeded' | 'failed' | 'canceled'; + +export interface ChatMessage { + id: string; + role: 'user' | 'agent'; + content: string; + status: MsgStatus; + planJobId?: string; + planHash?: string; + planResult?: AgentPlanResult; + executeJobId?: string; + executeResult?: AgentExecutionResult; + errorMessage?: string; + timestamp: string; +} + +export interface Session { + id: string; + title: string; + messages: ChatMessage[]; + createdAt: string; + updatedAt: string; +} + +export interface AgentTurn { + user: ChatMessage; + agent: ChatMessage; +} + +const STORAGE_KEY = 'fileflash.agent.sessions.v1'; + +const isTerminalStatus = (s?: string | null) => + s === 'succeeded' || s === 'failed' || s === 'canceled'; + +const isEmptySession = (session: Session) => session.messages.length === 0; + +const toTurns = (messages: ChatMessage[]): AgentTurn[] => { + const out: AgentTurn[] = []; + let i = 0; + while (i < messages.length - 1) { + const u = messages[i]; + const a = messages[i + 1]; + if (u.role === 'user' && a.role === 'agent') { + out.push({ user: u, agent: a }); + i += 2; + } else { + i += 1; + } + } + return out; +}; + +const normalizeSessions = (value: unknown): Session[] => { + if (!Array.isArray(value)) return []; + const out: Session[] = []; + let keptEmpty = false; + + for (const raw of value) { + if (!raw || typeof raw !== 'object') continue; + const record = raw as Record- +- -- -- + + ++ ; + if (typeof record.id !== 'string' || !Array.isArray(record.messages)) continue; + + const now = new Date().toISOString(); + const session: Session = { + id: record.id, + title: typeof record.title === 'string' ? record.title : 'New session', + messages: record.messages as ChatMessage[], + createdAt: typeof record.createdAt === 'string' ? record.createdAt : now, + updatedAt: typeof record.updatedAt === 'string' ? record.updatedAt : now, + }; + + if (isEmptySession(session)) { + if (keptEmpty) continue; + keptEmpty = true; + } + out.push(session); + } + return out; +}; + +const loadSessions = (): { sessions: Session[]; shouldPersist: boolean } => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return { sessions: [], shouldPersist: false }; + const parsed = JSON.parse(raw); + const sessions = normalizeSessions(parsed); + const shouldPersist = JSON.stringify(parsed) !== JSON.stringify(sessions); + return { sessions, shouldPersist }; + } catch { + return { sessions: [], shouldPersist: false }; + } +}; + +const persistSessions = (sessions: Session[]) => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions)); + } catch { + // quota or privacy-mode: ignore + } +}; + +let msgCounter = 0; +const nextMsgId = () => `msg-${Date.now()}-${++msgCounter}`; +const nextSessionId = () => + `sess-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + +interface SessionState { + sessions: Ref ; + activeSessionId: Ref ; + policy: Ref ; + taskInput: Ref ; + isSending: Ref ; + pollTimers: Map >; +} + +let _state: SessionState | null = null; + +const getState = (): SessionState => { + if (_state) return _state; + const loaded = loadSessions(); + const sessions = ref (loaded.sessions); + const activeSessionId = ref (sessions.value[0]?.id ?? null); + const policy = ref ('confirm'); + const taskInput = ref (''); + const isSending = ref (false); + const pollTimers = new Map >(); + + watch(sessions, (v) => persistSessions(v), { deep: true }); + if (loaded.shouldPersist) persistSessions(sessions.value); + + _state = { sessions, activeSessionId, policy, taskInput, isSending, pollTimers }; + return _state; +}; + +export const __resetForTests = () => { + if (_state) { + _state.pollTimers.forEach((t) => clearInterval(t)); + _state.pollTimers.clear(); + } + _state = null; +}; + +const buildPlanPayload = (input: string, policy: AgentExecutionPolicy): PlanAgentRequest => ({ + input, + context: { + rootFolderId: 'root', + selectedFileIds: [], + selectedFolderIds: [], + currentPath: '/My Files', + }, + executionPolicy: policy, + dataPolicy: { + allowFileContent: false, + maxReadBytes: 1048576, + allowedMimeTypes: ['*/*'], + }, + hints: { preferSkillId: null, maxSteps: 12, budgetTokens: 8000 }, +}); + +export default function useAgentSession() { + const s = getState(); + const userStore = useUserStore(); + + const activeSession = computed(() => { + if (!s.activeSessionId.value) return null; + return s.sessions.value.find((c) => c.id === s.activeSessionId.value) ?? null; + }); + + const activeTurns = computed (() => toTurns(activeSession.value?.messages ?? [])); + + const stopPolling = (key: string) => { + const t = s.pollTimers.get(key); + if (t) { + clearInterval(t); + s.pollTimers.delete(key); + } + }; + + const stopAllPolling = () => { + s.pollTimers.forEach((t) => clearInterval(t)); + s.pollTimers.clear(); + }; + + const createSession = () => { + const empty = s.sessions.value.find(isEmptySession); + if (empty) { + stopAllPolling(); + s.activeSessionId.value = empty.id; + s.taskInput.value = ''; + return empty; + } + + const id = nextSessionId(); + const now = new Date().toISOString(); + const session: Session = { + id, + title: 'New session', + messages: [], + createdAt: now, + updatedAt: now, + }; + s.sessions.value.unshift(session); + s.activeSessionId.value = id; + s.taskInput.value = ''; + stopAllPolling(); + return session; + }; + + const switchSession = (id: string) => { + if (s.activeSessionId.value === id) return; + stopAllPolling(); + s.activeSessionId.value = id; + s.taskInput.value = ''; + }; + + const deleteSession = (id: string) => { + const idx = s.sessions.value.findIndex((c) => c.id === id); + if (idx === -1) return; + s.sessions.value.splice(idx, 1); + if (s.activeSessionId.value === id) { + stopAllPolling(); + s.activeSessionId.value = s.sessions.value.length + ? s.sessions.value[Math.min(idx, s.sessions.value.length - 1)].id + : null; + } + }; + + const resetActiveSession = () => { + if (!activeSession.value) return; + stopAllPolling(); + activeSession.value.messages = []; + activeSession.value.title = 'New session'; + s.isSending.value = false; + }; + + const ensureSession = (): Session => activeSession.value ?? createSession(); + + async function pollPlanJob(msg: ChatMessage, jobId: string): Promise { + const timerKey = `${msg.id}:plan`; + stopPolling(timerKey); + + const tick = async () => { + try { + const job = await getAgentJob (jobId); + msg.status = (job.status as MsgStatus) || 'running'; + + if (job.status === 'succeeded' && job.result) { + msg.planResult = job.result; + msg.planHash = job.result.planHash; + } + if (job.status === 'failed' || job.status === 'canceled') { + msg.errorMessage = job.errorMessage || 'Plan failed.'; + } + if (isTerminalStatus(job.status)) { + stopPolling(timerKey); + if (msg.planResult && s.policy.value === 'autopilot') { + await runExecute(msg); + } + } + } catch { + // network blips: skip this tick + } + }; + + await tick(); + if (!isTerminalStatus(msg.status)) { + s.pollTimers.set(timerKey, setInterval(tick, 1200)); + } + } + + async function pollExecuteJob(msg: ChatMessage, jobId: string): Promise { + const timerKey = `${msg.id}:execute`; + stopPolling(timerKey); + + const tick = async () => { + try { + const job = await getAgentJob (jobId); + msg.status = (job.status as MsgStatus) || 'running'; + + if (job.status === 'succeeded' && job.result) { + msg.executeResult = job.result; + } + if (job.status === 'failed' || job.status === 'canceled') { + msg.errorMessage = job.errorMessage || 'Execute failed.'; + } + if (isTerminalStatus(job.status)) stopPolling(timerKey); + } catch { + // skip + } + }; + + await tick(); + if (!isTerminalStatus(msg.status)) { + s.pollTimers.set(timerKey, setInterval(tick, 1200)); + } + } + + async function sendMessage(): Promise { + const input = s.taskInput.value.trim(); + if (!input || s.isSending.value) return; + const session = ensureSession(); + s.isSending.value = true; + + const now = new Date().toISOString(); + const userMsg: ChatMessage = { + id: nextMsgId(), + role: 'user', + content: input, + status: 'succeeded', + timestamp: now, + }; + const agentMsg: ChatMessage = { + id: nextMsgId(), + role: 'agent', + content: '', + status: 'pending', + timestamp: now, + }; + session.messages.push(userMsg, agentMsg); + session.updatedAt = now; + if (session.title === 'New session') { + session.title = input.slice(0, 40) + (input.length > 40 ? '…' : ''); + } + s.taskInput.value = ''; + + const reactiveAgent = session.messages[session.messages.length - 1]; + + try { + const res = await planAgentTask(buildPlanPayload(input, s.policy.value)); + reactiveAgent.planJobId = res.jobId; + reactiveAgent.status = 'pending'; + await pollPlanJob(reactiveAgent, res.jobId); + } catch { + reactiveAgent.status = 'failed'; + reactiveAgent.errorMessage = 'Plan failed.'; + } finally { + s.isSending.value = false; + } + } + + async function runExecute(msg: ChatMessage): Promise { + if (!msg.planResult || !msg.planHash) return; + msg.status = 'running'; + msg.errorMessage = ''; + msg.executeResult = undefined; + + try { + const res = await executeAgentPlan({ + planJobId: msg.planResult.planJobId, + planHash: msg.planHash, + approval: { + confirmedBy: userStore.user?.userId || 'current-user', + confirmedAt: new Date().toISOString(), + }, + }); + msg.executeJobId = res.jobId; + await pollExecuteJob(msg, res.jobId); + } catch { + msg.status = 'failed'; + msg.errorMessage = 'Execute failed.'; + } + } + + async function cancel(msg: ChatMessage): Promise { + const jobId = msg.executeJobId || msg.planJobId; + stopPolling(`${msg.id}:plan`); + stopPolling(`${msg.id}:execute`); + if (!jobId) return; + try { + await cancelAgentJob(jobId); + msg.status = 'canceled'; + } catch { + msg.errorMessage = 'Cancel failed.'; + } + } + + onScopeDispose(() => { + // Each component disposal: leave singleton state intact but clean + // its own listeners (none to clean here beyond polling, which + // stays with the singleton). + }); + + return { + sessions: s.sessions, + activeSessionId: s.activeSessionId, + activeSession, + activeTurns, + policy: s.policy, + taskInput: s.taskInput, + isSending: s.isSending, + createSession, + switchSession, + deleteSession, + resetActiveSession, + sendMessage, + runExecute, + cancel, + }; +} diff --git a/web/src/composables/useAgentSkills.spec.ts b/web/src/composables/useAgentSkills.spec.ts new file mode 100644 index 0000000..0108c97 --- /dev/null +++ b/web/src/composables/useAgentSkills.spec.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { nextTick } from 'vue'; + +vi.mock('../api/skill', () => ({ + listAgentSkills: vi.fn(), + createCustomSkill: vi.fn(), + updateCustomSkill: vi.fn(), + deleteCustomSkill: vi.fn(), + importGlobalSkills: vi.fn(), +})); + +vi.mock('../store/user', () => ({ + useUserStore: () => ({ user: { userId: 'u-1', role: 'admin' } }), +})); + +import * as skillApi from '../api/skill'; + +const fakePage = (items: any[]) => ({ + items, + pagination: { + totalItems: items.length, + page: 1, + perPage: 20, + totalPages: 1, + currentPage: 1, + hasPrev: false, + hasNext: false, + }, +}); + +const skill = (k: string, v: 'global' | 'private' = 'global') => ({ + skillId: 'id-' + k, + skillKey: k, + name: k.toUpperCase(), + description: 'desc', + triggersText: null, + toolWhitelist: [], + planTemplate: {}, + inputsSchema: {}, + outputsSchema: {}, + visibility: v, + ownerUserId: null, + createdAt: '', + updatedAt: '', +}); + +const loadComposable = async () => await import('./useAgentSkills'); + +describe('useAgentSkills', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(async () => { + const { __resetForTests } = await loadComposable(); + __resetForTests(); + vi.useRealTimers(); + }); + + it('loadMarketplace populates marketplace.value', async () => { + vi.mocked(skillApi.listAgentSkills).mockResolvedValueOnce(fakePage([skill('a'), skill('b')])); + const { default: useAgentSkills } = await loadComposable(); + const { marketplace, loadMarketplace } = useAgentSkills(); + await loadMarketplace(); + expect(marketplace.value?.items.length).toBe(2); + expect(marketplace.value?.items[0].skillKey).toBe('a'); + }); + + it('setting queryText debounces and reloads both lists once', async () => { + vi.mocked(skillApi.listAgentSkills).mockResolvedValue(fakePage([])); + const { default: useAgentSkills } = await loadComposable(); + const { queryText } = useAgentSkills(); + vi.mocked(skillApi.listAgentSkills).mockClear(); + queryText.value = 'foo'; + queryText.value = 'foobar'; + await nextTick(); + // Not called yet (debounced) + expect(skillApi.listAgentSkills).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(260); + // Called twice — one marketplace, one mySkills + expect(skillApi.listAgentSkills).toHaveBeenCalledTimes(2); + }); + + it('createSkill calls createCustomSkill then reloads mySkills', async () => { + vi.mocked(skillApi.createCustomSkill).mockResolvedValue(skill('new', 'private') as any); + vi.mocked(skillApi.listAgentSkills).mockResolvedValue(fakePage([skill('new', 'private')])); + const { default: useAgentSkills } = await loadComposable(); + const { createSkill, mySkills } = useAgentSkills(); + await createSkill({ + name: 'New', + description: 'd', + triggersText: null, + toolWhitelist: [], + planTemplate: {}, + inputsSchema: {}, + outputsSchema: {}, + }); + expect(skillApi.createCustomSkill).toHaveBeenCalled(); + expect(mySkills.value?.items[0].skillKey).toBe('new'); + }); + + it('submitImport parses array form and reloads marketplace', async () => { + vi.mocked(skillApi.importGlobalSkills).mockResolvedValue({ + results: [{ skillKey: 'a', action: 'created' }], + }); + vi.mocked(skillApi.listAgentSkills).mockResolvedValue(fakePage([skill('a')])); + const { default: useAgentSkills } = await loadComposable(); + const { submitImport, importResults } = useAgentSkills(); + await submitImport({ + mode: 'upsert', + jsonText: JSON.stringify([{ skillKey: 'a', name: 'A', description: 'd' }]), + }); + expect(skillApi.importGlobalSkills).toHaveBeenCalled(); + expect(importResults.value.length).toBe(1); + expect(importResults.value[0].skillKey).toBe('a'); + }); +}); diff --git a/web/src/composables/useAgentSkills.ts b/web/src/composables/useAgentSkills.ts new file mode 100644 index 0000000..a1e070f --- /dev/null +++ b/web/src/composables/useAgentSkills.ts @@ -0,0 +1,232 @@ +import { reactive, ref, watch, type Ref } from 'vue'; +import { useDebounceFn } from '@vueuse/core'; +import { + createCustomSkill, + deleteCustomSkill, + importGlobalSkills, + listAgentSkills, + updateCustomSkill, +} from '../api/skill'; +import type { + AgentSkillItem, + ImportAgentSkillItem, + ImportAgentSkillMode, + ImportAgentSkillResult, +} from '../types/skill'; +import type { PaginatedData } from '../types/base'; + +export interface SkillForm { + name: string; + description: string; + triggersText: string | null; + toolWhitelist: string[]; + planTemplate: Record ; + inputsSchema: Record ; + outputsSchema: Record ; +} + +interface SkillsState { + marketplace: Ref | null>; + mySkills: Ref | null>; + marketplacePage: Ref ; + mySkillsPage: Ref ; + isMarketplaceLoading: Ref ; + isMySkillsLoading: Ref ; + queryText: Ref ; + editingKey: Ref ; + editorOpen: Ref ; + editorLoading: Ref ; + form: SkillForm; + importResults: Ref ; + importLoading: Ref ; + watcherStopper: (() => void) | null; +} + +const PER_PAGE = 20; + +const blankForm = (): SkillForm => ({ + name: '', + description: '', + triggersText: null, + toolWhitelist: [], + planTemplate: {}, + inputsSchema: {}, + outputsSchema: {}, +}); + +let _state: SkillsState | null = null; + +const getState = (): SkillsState => { + if (_state) return _state; + _state = { + marketplace: ref | null>(null), + mySkills: ref | null>(null), + marketplacePage: ref(1), + mySkillsPage: ref(1), + isMarketplaceLoading: ref(false), + isMySkillsLoading: ref(false), + queryText: ref(''), + editingKey: ref (null), + editorOpen: ref(false), + editorLoading: ref(false), + form: reactive(blankForm()), + importResults: ref ([]), + importLoading: ref(false), + watcherStopper: null, + }; + return _state; +}; + +export const __resetForTests = () => { + if (_state?.watcherStopper) _state.watcherStopper(); + _state = null; +}; + +export default function useAgentSkills() { + const s = getState(); + + const loadMarketplace = async (): Promise => { + s.isMarketplaceLoading.value = true; + try { + s.marketplace.value = await listAgentSkills({ + page: s.marketplacePage.value, + perPage: PER_PAGE, + visibility: 'global', + queryText: s.queryText.value.trim() || undefined, + }); + } finally { + s.isMarketplaceLoading.value = false; + } + }; + + const loadMySkills = async (): Promise => { + s.isMySkillsLoading.value = true; + try { + s.mySkills.value = await listAgentSkills({ + page: s.mySkillsPage.value, + perPage: PER_PAGE, + visibility: 'private', + queryText: s.queryText.value.trim() || undefined, + }); + } finally { + s.isMySkillsLoading.value = false; + } + }; + + const debouncedSearch = useDebounceFn(async () => { + s.marketplacePage.value = 1; + s.mySkillsPage.value = 1; + await Promise.all([loadMarketplace(), loadMySkills()]); + }, 250); + + if (!s.watcherStopper) { + s.watcherStopper = watch(s.queryText, () => { + void debouncedSearch(); + }); + } + + const openNewSkill = (): void => { + s.editingKey.value = null; + Object.assign(s.form, blankForm()); + s.editorOpen.value = true; + }; + + const openEditSkill = (skill: AgentSkillItem): void => { + s.editingKey.value = skill.skillKey; + s.form.name = skill.name; + s.form.description = skill.description; + s.form.triggersText = skill.triggersText ?? null; + s.form.toolWhitelist = [...(skill.toolWhitelist ?? [])]; + s.form.planTemplate = { ...(skill.planTemplate ?? {}) }; + s.form.inputsSchema = { ...(skill.inputsSchema ?? {}) }; + s.form.outputsSchema = { ...(skill.outputsSchema ?? {}) }; + s.editorOpen.value = true; + }; + + const closeEditor = (): void => { + s.editorOpen.value = false; + s.editorLoading.value = false; + }; + + const createSkill = async (payload: SkillForm): Promise => { + s.editorLoading.value = true; + try { + const created = await createCustomSkill(payload); + await loadMySkills(); + return created; + } finally { + s.editorLoading.value = false; + } + }; + + const updateSkill = async (skillKey: string, payload: SkillForm): Promise => { + s.editorLoading.value = true; + try { + const updated = await updateCustomSkill(skillKey, payload); + await loadMySkills(); + return updated; + } finally { + s.editorLoading.value = false; + } + }; + + const saveSkill = async (payload: SkillForm): Promise => { + if (s.editingKey.value) await updateSkill(s.editingKey.value, payload); + else await createSkill(payload); + closeEditor(); + }; + + const removeSkill = async (skillKey: string): Promise => { + await deleteCustomSkill(skillKey); + await loadMySkills(); + }; + + const submitImport = async (args: { + mode: ImportAgentSkillMode; + jsonText: string; + }): Promise => { + s.importResults.value = []; + if (!args.jsonText.trim()) return; + s.importLoading.value = true; + try { + const parsed = JSON.parse(args.jsonText); + let items: ImportAgentSkillItem[]; + if (Array.isArray(parsed)) items = parsed as ImportAgentSkillItem[]; + else if (parsed && typeof parsed === 'object' && Array.isArray(parsed.items)) + items = parsed.items as ImportAgentSkillItem[]; + else throw new Error('Import JSON must be an array or { items: [...] }.'); + + const response = await importGlobalSkills({ mode: args.mode, items }); + s.importResults.value = response.results || []; + await loadMarketplace(); + } finally { + s.importLoading.value = false; + } + }; + + return { + marketplace: s.marketplace, + mySkills: s.mySkills, + marketplacePage: s.marketplacePage, + mySkillsPage: s.mySkillsPage, + isMarketplaceLoading: s.isMarketplaceLoading, + isMySkillsLoading: s.isMySkillsLoading, + queryText: s.queryText, + editingKey: s.editingKey, + editorOpen: s.editorOpen, + editorLoading: s.editorLoading, + form: s.form, + importResults: s.importResults, + importLoading: s.importLoading, + loadMarketplace, + loadMySkills, + openNewSkill, + openEditSkill, + closeEditor, + createSkill, + updateSkill, + saveSkill, + removeSkill, + submitImport, + }; +} diff --git a/web/src/composables/useBatchActions.ts b/web/src/composables/useBatchActions.ts index 5c10c4b..f7e6add 100644 --- a/web/src/composables/useBatchActions.ts +++ b/web/src/composables/useBatchActions.ts @@ -1,6 +1,7 @@ import type { Ref } from 'vue'; import { batchDownloadFiles, batchFiles } from '../api/file'; import { useFileStore } from '../store/file'; +import { useLocaleStore } from '../store/locale'; import { useSettingsStore } from '../store/settings'; import { ui } from '../utils/ui'; @@ -10,6 +11,8 @@ export function useBatchActions( ) { const fileStore = useFileStore(); const settingsStore = useSettingsStore(); + const localeStore = useLocaleStore(); + const t = localeStore.t; const handleBatchDownload = async () => { if (selectedItems.value.size === 0) return; @@ -37,12 +40,15 @@ export function useBatchActions( a.remove(); window.URL.revokeObjectURL(url); clearSelection(); - ui.toast({ type: 'success', message: `Downloaded ${selected.length} item(s).` }); + ui.toast({ + type: 'success', + message: t('files.batch.download.toast.success').replace('{count}', String(selected.length)), + }); } catch (error) { console.error('Batch download failed:', error); ui.toast({ type: 'error', - message: 'Failed to download selected files.', + message: t('files.batch.download.toast.failed'), duration: 4200, }); } @@ -53,9 +59,9 @@ export function useBatchActions( if (settingsStore.settings.confirmDelete) { const confirmed = await ui.confirm({ - title: 'Move To Trash', - message: `Move ${selectedItems.value.size} selected item(s) to trash?`, - confirmText: 'Move', + title: t('files.batch.delete.confirm.title'), + message: t('files.batch.delete.confirm.message').replace('{count}', String(selectedItems.value.size)), + confirmText: t('files.batch.delete.confirm.confirmText'), danger: true, }); if (!confirmed) return; @@ -71,13 +77,16 @@ export function useBatchActions( const result = await batchFiles({ action: 'delete', fileIds, folderIds }); if (!result) throw new Error('Delete failed'); clearSelection(); - await fileStore.fetchFolderContents(fileStore.currentFolderId || 'root'); - ui.toast({ type: 'success', message: `Moved ${idsToDelete.length} item(s) to trash.` }); + await fileStore.fetchFolderContents(fileStore.currentFolderId || 'root', { silent: true }); + ui.toast({ + type: 'success', + message: t('files.batch.delete.toast.success').replace('{count}', String(idsToDelete.length)), + }); } catch (error) { console.error('Batch delete failed:', error); ui.toast({ type: 'error', - message: 'Failed to move selected items to trash.', + message: t('files.batch.delete.toast.failed'), duration: 4200, }); } diff --git a/web/src/composables/useFileActions.spec.ts b/web/src/composables/useFileActions.spec.ts new file mode 100644 index 0000000..edbfec0 --- /dev/null +++ b/web/src/composables/useFileActions.spec.ts @@ -0,0 +1,226 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ref } from 'vue'; +import type { FolderItem } from '../types/file'; + +const { + createFolderMock, + deleteFolderMock, + renameFolderMock, + batchFilesMock, + deleteFileMock, + downloadFileMock, + renameFileMock, + getSharesMock, + toastMock, + confirmMock, + eventEmitMock, + newFolderCancelInstallMock, + newFolderCancelUninstallMock, +} = vi.hoisted(() => ({ + createFolderMock: vi.fn(async () => ({ id: 'f-created', name: 'created', itemType: 'folder' })), + deleteFolderMock: vi.fn(async () => ({})), + renameFolderMock: vi.fn(async () => ({ id: 'f-1', name: 'renamed', itemType: 'folder' })), + batchFilesMock: vi.fn(async () => ({ processed: 0, succeeded: 0, failed: 0, results: [] })), + deleteFileMock: vi.fn(async () => ({})), + downloadFileMock: vi.fn(async () => new Blob()), + renameFileMock: vi.fn(async () => ({ id: 'file-1', name: 'renamed', itemType: 'file' })), + getSharesMock: vi.fn(async () => ({ items: [], pagination: { hasNext: false } })), + toastMock: vi.fn(), + confirmMock: vi.fn(async () => true), + eventEmitMock: vi.fn(), + newFolderCancelInstallMock: vi.fn(), + newFolderCancelUninstallMock: vi.fn(), +})); + +const mockFileStore = { + items: [] as FolderItem[], + currentFolderId: 'root' as string | null, + fetchFolderContents: vi.fn(async () => {}), +}; + +vi.mock('../store/file', () => ({ + useFileStore: () => mockFileStore, +})); + +vi.mock('../store/settings', () => ({ + useSettingsStore: () => ({ + settings: { + confirmDelete: false, + }, + }), +})); + +vi.mock('../store/locale', () => ({ + useLocaleStore: () => ({ + t: (key: string) => { + const m: Record = { + 'files.toolbar.newFolder': '新建文件夹', + 'files.owner.you': 'You', + 'files.rename.toast.createdFolder': '已创建文件夹“{folderName}”。', + 'files.rename.toast.createFailed': '创建文件夹失败。', + 'files.rename.toast.renamed': '已重命名为“{newName}”。', + 'files.rename.toast.renameFailed': '重命名失败。', + 'files.toast.newFolderCanceled': '已取消新建文件夹', + }; + return m[key] || key; + }, + }), +})); + +vi.mock('../api/folder', () => ({ + createFolder: createFolderMock, + deleteFolder: deleteFolderMock, + renameFolder: renameFolderMock, +})); + +vi.mock('../api/file', () => ({ + batchFiles: batchFilesMock, + deleteFile: deleteFileMock, + downloadFile: downloadFileMock, + renameFile: renameFileMock, +})); + +vi.mock('../api/share', () => ({ + getShares: getSharesMock, +})); + +vi.mock('../utils/eventBus', () => ({ + eventBus: { + emit: eventEmitMock, + }, +})); + +vi.mock('../utils/ui', () => ({ + ui: { + toast: toastMock, + confirm: confirmMock, + }, +})); + +vi.mock('./useNewFolderCancel', () => ({ + useNewFolderCancel: () => ({ + install: newFolderCancelInstallMock, + uninstall: newFolderCancelUninstallMock, + }), +})); + +import { useFileActions } from './useFileActions'; + +function makeFolder(id: string, name: string): FolderItem { + return { + itemType: 'folder', + id, + name, + size: 0, + ownerName: 'You', + updatedAt: '2026-05-13T00:00:00Z', + createdAt: '2026-05-13T00:00:00Z', + parentFolderId: 'root', + permission: 'owner', + }; +} + +describe('useFileActions new folder flow', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 4, 13, 14, 52, 30)); + mockFileStore.items = []; + mockFileStore.currentFolderId = 'root'; + mockFileStore.fetchFolderContents.mockClear(); + + createFolderMock.mockClear(); + deleteFolderMock.mockClear(); + renameFolderMock.mockClear(); + batchFilesMock.mockClear(); + deleteFileMock.mockClear(); + downloadFileMock.mockClear(); + renameFileMock.mockClear(); + getSharesMock.mockClear(); + toastMock.mockClear(); + confirmMock.mockClear(); + eventEmitMock.mockClear(); + newFolderCancelInstallMock.mockClear(); + newFolderCancelUninstallMock.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('creates temp folder with localized default name and enters renaming', () => { + const actions = useFileActions(ref('root')); + + actions.handleCreateFolder(); + + expect(mockFileStore.items).toHaveLength(1); + expect(mockFileStore.items[0]?.name).toBe('新建文件夹-20260513-145230'); + expect(actions.renameInputValue.value).toBe('新建文件夹-20260513-145230'); + expect(actions.renamingItemId.value?.startsWith('temp-new-folder-')).toBe(true); + expect(newFolderCancelInstallMock).toHaveBeenCalledTimes(1); + }); + + it('appends sequence suffix when creating multiple folders in the same second', () => { + const actions = useFileActions(ref('root')); + + actions.handleCreateFolder(); + actions.handleCreateFolder(); + actions.handleCreateFolder(); + + expect(mockFileStore.items[0]?.name).toBe('新建文件夹-20260513-145230-3'); + expect(mockFileStore.items[1]?.name).toBe('新建文件夹-20260513-145230-2'); + expect(mockFileStore.items[2]?.name).toBe('新建文件夹-20260513-145230'); + }); + + it('registerRenameInput + startRename focuses and selects input', async () => { + const actions = useFileActions(ref('root')); + const folder = makeFolder('folder-1', 'Docs'); + mockFileStore.items = [folder]; + const input = document.createElement('input'); + const focusSpy = vi.spyOn(input, 'focus'); + const selectSpy = vi.spyOn(input, 'select'); + + const task = actions.startRename(folder); + actions.registerRenameInput(folder.id, input); + await task; + + expect(actions.renamingItemId.value).toBe(folder.id); + expect(actions.renameInputValue.value).toBe('Docs'); + expect(focusSpy).toHaveBeenCalledTimes(1); + expect(selectSpy).toHaveBeenCalledTimes(1); + }); + + it('empty name on finishRename cancels temp folder and does not create', async () => { + const actions = useFileActions(ref('root')); + actions.handleCreateFolder(); + actions.renameInputValue.value = ' '; + + await actions.finishRename(); + + expect(createFolderMock).not.toHaveBeenCalled(); + expect(mockFileStore.items.find((item) => item.id.startsWith('temp-new-folder-'))).toBeUndefined(); + expect(actions.renamingItemId.value).toBe(null); + }); + + it('non-empty temp folder name on finishRename creates folder (including unchanged default name)', async () => { + const actions = useFileActions(ref('root')); + actions.handleCreateFolder(); + const createdName = actions.renameInputValue.value; + + await actions.finishRename(); + + expect(createFolderMock).toHaveBeenCalledTimes(1); + expect(createFolderMock).toHaveBeenCalledWith({ folderName: createdName, parentFolderId: 'root' }); + expect(mockFileStore.fetchFolderContents).toHaveBeenCalledWith('root', { silent: true }); + expect(actions.renamingItemId.value).toBe(null); + }); + + it('cancelRename removes temp folder immediately', () => { + const actions = useFileActions(ref('root')); + actions.handleCreateFolder(); + + actions.cancelRename(); + + expect(mockFileStore.items.find((item) => item.id.startsWith('temp-new-folder-'))).toBeUndefined(); + expect(actions.renamingItemId.value).toBe(null); + }); +}); diff --git a/web/src/composables/useFileActions.ts b/web/src/composables/useFileActions.ts index 6e224b8..18caaa2 100644 --- a/web/src/composables/useFileActions.ts +++ b/web/src/composables/useFileActions.ts @@ -12,11 +12,23 @@ import { ui } from '../utils/ui'; import { useNewFolderCancel } from './useNewFolderCancel'; type ShareHandling = 'keep' | 'revoke'; +const TEMP_NEW_FOLDER_PREFIX = 'temp-new-folder'; + +function formatLocalTimestamp(date: Date): string { + const yyyy = String(date.getFullYear()); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const dd = String(date.getDate()).padStart(2, '0'); + const hh = String(date.getHours()).padStart(2, '0'); + const mi = String(date.getMinutes()).padStart(2, '0'); + const ss = String(date.getSeconds()).padStart(2, '0'); + return `${yyyy}${mm}${dd}-${hh}${mi}${ss}`; +} export function useFileActions(currentFolderId: Ref ) { const fileStore = useFileStore(); const settingsStore = useSettingsStore(); const localeStore = useLocaleStore(); + const t = localeStore.t; const renamingItemId = ref (null); const renameInputValue = ref(''); const renameInput = ref (null); @@ -35,7 +47,7 @@ export function useFileActions(currentFolderId: Ref ) { renameInputValue, onCancel: () => { const tempId = renamingItemId.value; - if (tempId && tempId.startsWith('temp-new-folder')) { + if (tempId && tempId.startsWith(TEMP_NEW_FOLDER_PREFIX)) { fileStore.items = fileStore.items.filter((i) => i.id !== tempId); } cancelRename(); @@ -43,18 +55,30 @@ export function useFileActions(currentFolderId: Ref ) { }, }); + const registerRenameInput = (itemId: string, el: HTMLInputElement | null) => { + if (renamingItemId.value !== itemId) return; + renameInput.value = el; + if (el) { + void nextTick().then(() => { + el.focus(); + el.select(); + }); + } + }; + const startRename = async (item: ContentItem) => { renamingItemId.value = item.id; renameInputValue.value = item.name; + renameInput.value = null; await nextTick(); - renameInput.value?.focus(); }; const cancelRename = () => { - if (renamingItemId.value && renamingItemId.value.startsWith('temp-new-folder')) { + if (renamingItemId.value && renamingItemId.value.startsWith(TEMP_NEW_FOLDER_PREFIX)) { fileStore.items = fileStore.items.filter((i) => i.id !== renamingItemId.value); } newFolderCancel.uninstall(); + renameInput.value = null; renamingItemId.value = null; renameInputValue.value = ''; isRenaming.value = false; @@ -65,20 +89,32 @@ export function useFileActions(currentFolderId: Ref ) { isRenaming.value = true; const item = fileStore.items.find((i) => i.id === renamingItemId.value); - if (!item || renameInputValue.value === item.name || renameInputValue.value.trim() === '') { + if (!item) { cancelRename(); return; } const newName = renameInputValue.value.trim(); + if (newName === '') { + cancelRename(); + return; + } + const isTempFolder = item.id.startsWith(TEMP_NEW_FOLDER_PREFIX); + if (!isTempFolder && newName === item.name) { + cancelRename(); + return; + } - if (item.id.startsWith('temp-new-folder')) { + if (isTempFolder) { try { await createFolder({ folderName: newName, parentFolderId: currentFolderId.value || 'root' }); - await fileStore.fetchFolderContents(currentFolderId.value || 'root'); - ui.toast({ type: 'success', message: `Created folder "${newName}".` }); + await fileStore.fetchFolderContents(currentFolderId.value || 'root', { silent: true }); + ui.toast({ + type: 'success', + message: t('files.rename.toast.createdFolder').replace('{folderName}', newName), + }); } catch (error) { console.error(`Failed to create folder "${newName}":`, error); - ui.toast({ type: 'error', message: 'Folder creation failed.' }); + ui.toast({ type: 'error', message: t('files.rename.toast.createFailed') }); fileStore.items = fileStore.items.filter((i) => i.id !== item.id); } finally { cancelRename(); @@ -94,10 +130,10 @@ export function useFileActions(currentFolderId: Ref ) { : await renameFile(item.id, { fileName: newName }); const index = fileStore.items.findIndex((i) => i.id === item.id); if (index !== -1) fileStore.items[index].name = updatedItem.name; - ui.toast({ type: 'success', message: `Renamed to "${newName}".` }); + ui.toast({ type: 'success', message: t('files.rename.toast.renamed').replace('{newName}', newName) }); } catch (error) { console.error(`Failed to rename ${item.name}:`, error); - ui.toast({ type: 'error', message: 'Rename failed.' }); + ui.toast({ type: 'error', message: t('files.rename.toast.renameFailed') }); } finally { cancelRename(); eventBus.emit('refresh-file-tree'); @@ -107,9 +143,9 @@ export function useFileActions(currentFolderId: Ref ) { const handleDelete = async (item: ContentItem) => { if (settingsStore.settings.confirmDelete) { const confirmed = await ui.confirm({ - title: 'Move To Trash', - message: `Move "${item.name}" to trash?`, - confirmText: 'Move', + title: t('files.delete.confirm.title'), + message: t('files.delete.confirm.message').replace('{itemName}', item.name), + confirmText: t('files.delete.confirm.confirmText'), danger: true, }); if (!confirmed) return; @@ -121,12 +157,12 @@ export function useFileActions(currentFolderId: Ref ) { } else { await deleteFile(item.id); } - await fileStore.fetchFolderContents(fileStore.currentFolderId || 'root'); + await fileStore.fetchFolderContents(fileStore.currentFolderId || 'root', { silent: true }); eventBus.emit('refresh-file-tree'); - ui.toast({ type: 'success', message: `"${item.name}" moved to trash.` }); + ui.toast({ type: 'success', message: t('files.delete.toast.success').replace('{itemName}', item.name) }); } catch (error) { console.error(`Failed to delete ${item.name}:`, error); - ui.toast({ type: 'error', message: 'Failed to move item to trash.' }); + ui.toast({ type: 'error', message: t('files.delete.toast.failed') }); } }; @@ -164,7 +200,7 @@ export function useFileActions(currentFolderId: Ref ) { ]; if (!fileIds.length && !folderIds.length) { - ui.toast({ type: 'warning', message: 'No movable items found.' }); + ui.toast({ type: 'warning', message: t('files.move.toast.noMovable') }); return; } @@ -177,31 +213,36 @@ export function useFileActions(currentFolderId: Ref ) { shareHandling, }); - await fileStore.fetchFolderContents(currentFolderId.value || 'root'); + await fileStore.fetchFolderContents(currentFolderId.value || 'root', { silent: true }); eventBus.emit('refresh-file-tree'); const firstError = result.results.find((item) => !item.success)?.message; if (result.succeeded === 0) { + const reason = firstError || t('files.move.reason.noneMoved'); ui.toast({ type: 'error', - message: `Move failed. ${firstError || 'No items were moved.'}`, + message: t('files.move.toast.failedNoneMoved').replace('{reason}', reason), duration: 4200, }); return; } if (result.failed > 0) { + const reason = firstError || t('files.move.reason.someFailed'); ui.toast({ type: 'warning', - message: `Moved ${result.succeeded}/${result.processed}. ${firstError || 'Some items failed.'}`, + message: t('files.move.toast.partial') + .replace('{succeeded}', String(result.succeeded)) + .replace('{processed}', String(result.processed)) + .replace('{reason}', reason), duration: 4200, }); } else { - ui.toast({ type: 'success', message: `Moved ${result.succeeded} item(s).` }); + ui.toast({ type: 'success', message: t('files.move.toast.success').replace('{count}', String(result.succeeded)) }); } } catch (error) { console.error('Batch move failed:', error); - ui.toast({ type: 'error', message: 'Batch move failed.' }); + ui.toast({ type: 'error', message: t('files.move.toast.failed') }); } }; @@ -228,7 +269,7 @@ export function useFileActions(currentFolderId: Ref ) { const startMoveForSelection = async (sourceItemIds: string[]) => { const sourceItems = fileStore.items.filter((item) => sourceItemIds.includes(item.id)); if (!sourceItems.length) { - ui.toast({ type: 'warning', message: 'Please select at least one item.' }); + ui.toast({ type: 'warning', message: t('files.move.toast.selectAtLeastOne') }); return; } await openMoveDialog(sourceItems); @@ -250,13 +291,23 @@ export function useFileActions(currentFolderId: Ref ) { }; const handleCreateFolder = () => { - const tempId = `temp-new-folder-${Date.now()}`; + const now = new Date(); + const baseName = `${t('files.toolbar.newFolder')}-${formatLocalTimestamp(now)}`; + const existingNames = new Set(fileStore.items.map((item) => item.name)); + let defaultFolderName = baseName; + let index = 2; + while (existingNames.has(defaultFolderName)) { + defaultFolderName = `${baseName}-${index}`; + index += 1; + } + + const tempId = `${TEMP_NEW_FOLDER_PREFIX}-${Date.now()}`; const tempFolder: FolderItem = { itemType: 'folder', id: tempId, - name: '', + name: defaultFolderName, size: 0, - ownerName: 'You', + ownerName: t('files.owner.you'), updatedAt: new Date().toISOString(), createdAt: new Date().toISOString(), parentFolderId: currentFolderId.value, @@ -288,14 +339,14 @@ export function useFileActions(currentFolderId: Ref ) { window.URL.revokeObjectURL(url); } catch (error) { console.error(`Failed to download file ${file.name}:`, error); - ui.toast({ type: 'error', message: 'Download failed.' }); + ui.toast({ type: 'error', message: t('files.download.toast.failed') }); } }; return { renamingItemId, renameInputValue, - renameInput, + registerRenameInput, itemToMove, moveItemCount, moveHasActiveShare, diff --git a/web/src/composables/useNewFolderCancel.spec.ts b/web/src/composables/useNewFolderCancel.spec.ts index 58be6db..a3ead42 100644 --- a/web/src/composables/useNewFolderCancel.spec.ts +++ b/web/src/composables/useNewFolderCancel.spec.ts @@ -16,6 +16,17 @@ function makeRow(tempId: string) { return row; } +function makeGridCard(tempId: string) { + const card = document.createElement('div'); + card.className = 'card'; + card.setAttribute('data-temp-folder-row', tempId); + const input = document.createElement('input'); + input.className = 'card__rename'; + card.appendChild(input); + document.body.appendChild(card); + return card; +} + function makeMarker(attr: 'data-ui-toast' | 'data-dropdown-menu') { const el = document.createElement('div'); el.setAttribute(attr, ''); @@ -61,6 +72,19 @@ describe('useNewFolderCancel', () => { expect(onCancel).not.toHaveBeenCalled(); }); + it('pointerdown inside a temp grid card input does NOT cancel', () => { + const renameInputValue = ref(''); + const onCancel = vi.fn(); + const c = useNewFolderCancel({ renameInputValue, onCancel }); + const card = makeGridCard('temp-2-grid'); + + c.install('temp-2-grid'); + const input = card.querySelector('input') as HTMLInputElement; + input.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); + + expect(onCancel).not.toHaveBeenCalled(); + }); + it('pointerdown on a toast does NOT cancel', () => { const renameInputValue = ref(''); const onCancel = vi.fn(); diff --git a/web/src/composables/useShareAccess.ts b/web/src/composables/useShareAccess.ts new file mode 100644 index 0000000..fa5085f --- /dev/null +++ b/web/src/composables/useShareAccess.ts @@ -0,0 +1,105 @@ +import { computed, ref, type Ref } from 'vue'; +import { accessShare, downloadSharedFile, getShareDetails, previewSharedFile, saveShare } from '../api/share'; +import { useLocaleStore } from '../store/locale'; +import type { AccessShareResponseData, Share } from '../types/share'; + +export function useShareAccess(shareLink: Ref ) { + const localeStore = useLocaleStore(); + const t = localeStore.t; + + const share = ref (null); + const accessData = ref (null); + const password = ref(''); + const error = ref(''); + const statusMessage = ref(''); + + const isLoading = ref(false); + const isAccessing = ref(false); + const isSaving = ref(false); + const isDownloading = ref(false); + const isPreviewing = ref(false); + + const isFile = computed(() => share.value?.itemType === 'file'); + const isFolder = computed(() => share.value?.itemType === 'folder'); + const passwordProtected = computed(() => Boolean(share.value?.settings.passwordProtected)); + const canDownload = computed(() => Boolean(accessData.value?.accessUrls.download)); + const canPreview = computed(() => Boolean(accessData.value?.accessUrls.preview)); + + const loadShare = async () => { + error.value = ''; statusMessage.value = ''; isLoading.value = true; + try { + share.value = await getShareDetails(shareLink.value); + if (!share.value.settings.passwordProtected) await requestAccess(); + } catch (e) { + console.error('Failed to load share', e); + error.value = t('share.status.loadFailed'); + } + finally { isLoading.value = false; } + }; + + const requestAccess = async () => { + if (!share.value) return; + isAccessing.value = true; error.value = ''; statusMessage.value = ''; + try { + accessData.value = await accessShare(shareLink.value, password.value.trim() ? { password: password.value.trim() } : {}); + statusMessage.value = t('share.status.accessGranted'); + } catch (e) { + console.error('Failed to access share', e); + error.value = passwordProtected.value ? t('share.status.invalidPasswordOrExpired') : t('share.status.expiredOrUnavailable'); + } + finally { isAccessing.value = false; } + }; + + const handleDownload = async () => { + if (!accessData.value || !isFile.value || !canDownload.value) return; + isDownloading.value = true; error.value = ''; statusMessage.value = ''; + try { + const blob = await downloadSharedFile(shareLink.value, accessData.value.accessToken); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = share.value?.itemInfo.name || 'download'; + document.body.appendChild(a); a.click(); a.remove(); + window.URL.revokeObjectURL(url); + } catch (e) { + console.error('Failed to download shared file', e); + error.value = t('share.status.downloadFailed'); + } + finally { isDownloading.value = false; } + }; + + const handlePreview = async () => { + if (!accessData.value || !isFile.value || !canPreview.value) return; + isPreviewing.value = true; error.value = ''; statusMessage.value = ''; + try { + const blob = await previewSharedFile(shareLink.value, accessData.value.accessToken); + const url = window.URL.createObjectURL(blob); + window.open(url, '_blank', 'noopener,noreferrer'); + setTimeout(() => window.URL.revokeObjectURL(url), 30_000); + } catch (e) { + console.error('Failed to preview shared file', e); + error.value = t('share.status.previewFailed'); + } + finally { isPreviewing.value = false; } + }; + + const saveToFolder = async (targetFolderId: string) => { + if (!accessData.value) return; + isSaving.value = true; error.value = ''; statusMessage.value = ''; + try { + const resp = await saveShare(shareLink.value, { targetFolderId, shareAccessToken: accessData.value.accessToken }); + const itemTypeLabel = resp.itemType === 'folder' ? t('share.itemType.folder') : t('share.itemType.file'); + statusMessage.value = t('share.status.savedSuccess').replace('{itemType}', itemTypeLabel); + } catch (e) { + console.error('Failed to save share', e); + error.value = t('share.status.saveFailed'); + } + finally { isSaving.value = false; } + }; + + return { + share, accessData, password, error, statusMessage, + isLoading, isAccessing, isSaving, isDownloading, isPreviewing, + isFile, isFolder, passwordProtected, canDownload, canPreview, + loadShare, requestAccess, handleDownload, handlePreview, saveToFolder, + }; +} diff --git a/web/src/composables/useSharingCenter.ts b/web/src/composables/useSharingCenter.ts new file mode 100644 index 0000000..762c06d --- /dev/null +++ b/web/src/composables/useSharingCenter.ts @@ -0,0 +1,87 @@ +import { computed, ref } from 'vue'; +import { acceptSharedItem, deleteShare, getSharedItems, getShares } from '../api/share'; +import { useLocaleStore } from '../store/locale'; +import { useFileSelection } from './useFileSelection'; +import type { Share, SharedItem } from '../types/share'; +import { ui } from '../utils/ui'; + +export type SharedTab = 'received' | 'links'; + +export function useSharingCenter() { + const localeStore = useLocaleStore(); + const t = localeStore.t; + const activeTab = ref ('received'); + const isLoading = ref(false); + const sharedItems = ref ([]); + const myShares = ref ([]); + + const selection = useFileSelection(); + const showBatch = computed(() => selection.selectedCount.value > 0 && activeTab.value === 'received'); + + const loadReceived = async () => { sharedItems.value = (await getSharedItems({ page: 1, perPage: 50, sort: 'sharedAt', order: 'desc' })).items; }; + const loadLinks = async () => { myShares.value = (await getShares({ page: 1, perPage: 50 })).items; }; + + const loadData = async () => { + isLoading.value = true; + try { activeTab.value === 'received' ? await loadReceived() : await loadLinks(); } + finally { isLoading.value = false; } + }; + + const switchTab = async (tab: SharedTab) => { + activeTab.value = tab; selection.clear(); await loadData(); + }; + + const toggleAll = (next: boolean) => { + if (next) sharedItems.value.forEach((i) => selection.selectedItems.value.add(i.id)); + else selection.clear(); + }; + + const acceptOne = async (item: SharedItem) => { + try { await acceptSharedItem(item.id); await loadReceived(); } + catch (e) { console.error('Failed to accept shared item', e); } + }; + + const acceptSelected = async () => { + if (!selection.selectedItems.value.size) return; + await Promise.allSettled(Array.from(selection.selectedItems.value).map((id) => acceptSharedItem(id))); + selection.clear(); + await loadReceived(); + }; + + const removeShare = async (share: Share) => { + const ok = await ui.confirm({ + title: t('sharing.confirm.deleteLink.title'), + message: t('sharing.confirm.deleteLink.message').replace('{shareLink}', share.shareLink), + confirmText: t('sharing.confirm.deleteLink.confirm'), + danger: true, + }); + if (!ok) return; + try { + await deleteShare(share.shareLink); + myShares.value = myShares.value.filter((e) => e.shareLink !== share.shareLink); + ui.toast({ type: 'success', message: t('sharing.toast.linkDeleted') }); + } catch (e) { + console.error('Failed to delete share link', e); + ui.toast({ type: 'error', message: t('sharing.toast.linkDeleteFailed') }); + } + }; + + const copyShare = async (share: Share) => { + const link = `${window.location.origin}/share/${share.shareLink}`; + try { + await navigator.clipboard.writeText(link); + ui.toast({ type: 'success', message: t('sharing.toast.linkCopied') }); + } catch { + await ui.copyText({ + title: t('sharing.copyDialog.title'), + message: t('sharing.copyDialog.message'), + text: link, + }); + } + }; + + return { + activeTab, isLoading, sharedItems, myShares, selection, showBatch, + loadData, switchTab, toggleAll, acceptOne, acceptSelected, removeShare, copyShare, + }; +} diff --git a/web/src/composables/useUpload.ts b/web/src/composables/useUpload.ts index d27a24a..902b52c 100644 --- a/web/src/composables/useUpload.ts +++ b/web/src/composables/useUpload.ts @@ -111,7 +111,7 @@ export function useUpload(currentFolderId: Ref ) { } else { // If upload completes but no file data is returned (e.g. second upload), // refresh the folder to ensure consistency. - await fileStore.fetchFolderContents(currentFolderId.value || 'root'); + await fileStore.fetchFolderContents(currentFolderId.value || 'root', { silent: true }); if (settingsStore.settings.uploadCompleteNotification) { ui.toast({ type: 'success', diff --git a/web/src/i18n/messages.ts b/web/src/i18n/messages.ts index 48002be..35b6764 100644 --- a/web/src/i18n/messages.ts +++ b/web/src/i18n/messages.ts @@ -21,6 +21,8 @@ export type LocaleKey = | 'sidebar.myFiles' | 'sidebar.shared' | 'sidebar.recycleBin' + | 'sidebar.starred' + | 'sidebar.starredEmpty' | 'sidebar.workspaceTree' | 'sidebar.storage' | 'sidebar.skills' @@ -128,6 +130,70 @@ export type LocaleKey = | 'settings.actions.export' | 'settings.actions.import' | 'settings.actions.reset' + | 'settings.resetSuccess' + | 'settings.appearance.theme.label' + | 'settings.appearance.theme.description' + | 'settings.appearance.theme.light' + | 'settings.appearance.theme.dark' + | 'settings.appearance.compactMode.label' + | 'settings.appearance.compactMode.description' + | 'settings.appearance.defaultFileView.label' + | 'settings.appearance.defaultFileView.description' + | 'settings.appearance.defaultFileView.option.list' + | 'settings.appearance.defaultFileView.option.grid' + | 'settings.appearance.defaultFileView.option.tiles' + | 'settings.appearance.showFileExtensions.label' + | 'settings.appearance.showFileExtensions.description' + | 'settings.uploads.maxConcurrentUploads.label' + | 'settings.uploads.maxConcurrentUploads.description' + | 'settings.uploads.chunkSize.label' + | 'settings.uploads.chunkSize.description' + | 'settings.uploads.autoRetryFailedUploads.label' + | 'settings.uploads.autoRetryFailedUploads.description' + | 'settings.uploads.retryAttempts.label' + | 'settings.uploads.retryAttempts.description' + | 'settings.files.itemsPerPage.label' + | 'settings.files.itemsPerPage.description' + | 'settings.files.showHiddenFiles.label' + | 'settings.files.showHiddenFiles.description' + | 'settings.files.autoRefreshInterval.label' + | 'settings.files.autoRefreshInterval.description' + | 'settings.files.autoDeleteDays.label' + | 'settings.files.autoDeleteDays.description' + | 'settings.files.autoDeleteDays.option.7' + | 'settings.files.autoDeleteDays.option.14' + | 'settings.files.autoDeleteDays.option.30' + | 'settings.files.autoDeleteDays.option.60' + | 'settings.files.autoDeleteDays.option.90' + | 'settings.files.confirmDelete.label' + | 'settings.files.confirmDelete.description' + | 'settings.notifications.desktop.label' + | 'settings.notifications.desktop.description' + | 'settings.notifications.sound.label' + | 'settings.notifications.sound.description' + | 'settings.notifications.uploadComplete.label' + | 'settings.notifications.uploadComplete.description' + | 'settings.notifications.error.label' + | 'settings.notifications.error.description' + | 'settings.security.sessionTimeout.label' + | 'settings.security.sessionTimeout.description' + | 'settings.security.sessionTimeout.option.disabled' + | 'settings.security.sessionTimeout.option.30m' + | 'settings.security.sessionTimeout.option.1h' + | 'settings.security.sessionTimeout.option.2h' + | 'settings.security.sessionTimeout.option.4h' + | 'settings.security.sessionTimeout.option.8h' + | 'settings.security.requirePasswordForSensitiveActions.label' + | 'settings.security.requirePasswordForSensitiveActions.description' + | 'settings.advanced.debugMode.label' + | 'settings.advanced.debugMode.description' + | 'settings.advanced.cacheDuration.label' + | 'settings.advanced.cacheDuration.description' + | 'settings.advanced.cacheDuration.option.1h' + | 'settings.advanced.cacheDuration.option.6h' + | 'settings.advanced.cacheDuration.option.12h' + | 'settings.advanced.cacheDuration.option.24h' + | 'settings.advanced.cacheDuration.option.72h' | 'files.toolbar.view.list' | 'files.toolbar.view.grid' | 'files.toolbar.searchTag' @@ -170,13 +236,193 @@ export type LocaleKey = | 'files.action.delete' | 'files.action.star' | 'files.action.unstar' + | 'files.mediaOptimization.processing' + | 'files.mediaOptimization.ready' + | 'files.mediaOptimization.failedFallback' | 'files.folder.loading' | 'files.folder.noSubfolders' | 'files.upload.toast.success' | 'files.upload.toast.failed' | 'files.upload.toast.unknownError' + | 'files.star.toast.failed' + | 'files.star.toast.unknownError' + | 'files.rename.toast.createdFolder' + | 'files.rename.toast.createFailed' + | 'files.rename.toast.renamed' + | 'files.rename.toast.renameFailed' + | 'files.delete.confirm.title' + | 'files.delete.confirm.message' + | 'files.delete.confirm.confirmText' + | 'files.delete.toast.success' + | 'files.delete.toast.failed' + | 'files.move.toast.noMovable' + | 'files.move.toast.failedNoneMoved' + | 'files.move.toast.partial' + | 'files.move.toast.success' + | 'files.move.toast.failed' + | 'files.move.reason.noneMoved' + | 'files.move.reason.someFailed' + | 'files.move.toast.selectAtLeastOne' + | 'files.download.toast.failed' + | 'files.batch.download.toast.success' + | 'files.batch.download.toast.failed' + | 'files.batch.delete.confirm.title' + | 'files.batch.delete.confirm.message' + | 'files.batch.delete.confirm.confirmText' + | 'files.batch.delete.toast.success' + | 'files.batch.delete.toast.failed' | 'files.root.myFiles' | 'files.owner.you' + | 'sharing.page.title' + | 'sharing.page.description' + | 'sharing.tab.sharedWithMe' + | 'sharing.tab.myShareLinks' + | 'sharing.empty.received' + | 'sharing.empty.links' + | 'sharing.itemType.file' + | 'sharing.itemType.folder' + | 'sharing.permission.read' + | 'sharing.permission.write' + | 'sharing.permission.admin' + | 'sharing.table.received.name' + | 'sharing.table.received.sharedBy' + | 'sharing.table.received.permission' + | 'sharing.table.received.sharedAt' + | 'sharing.table.received.accept' + | 'sharing.table.links.resource' + | 'sharing.table.links.shareLink' + | 'sharing.table.links.visitsDownloads' + | 'sharing.table.links.createdAt' + | 'sharing.table.links.copy' + | 'sharing.table.links.delete' + | 'sharing.batch.selected' + | 'sharing.batch.acceptSelected' + | 'sharing.batch.clear' + | 'sharing.confirm.deleteLink.title' + | 'sharing.confirm.deleteLink.message' + | 'sharing.confirm.deleteLink.confirm' + | 'sharing.toast.linkDeleted' + | 'sharing.toast.linkDeleteFailed' + | 'sharing.toast.linkCopied' + | 'sharing.copyDialog.title' + | 'sharing.copyDialog.message' + | 'trash.page.title' + | 'trash.page.description' + | 'trash.page.clearBin' + | 'trash.page.empty' + | 'trash.confirm.restore.title' + | 'trash.confirm.restore.message' + | 'trash.confirm.restore.confirm' + | 'trash.confirm.delete.title' + | 'trash.confirm.delete.message' + | 'trash.confirm.delete.confirm' + | 'trash.confirm.clear.title' + | 'trash.confirm.clear.message' + | 'trash.confirm.clear.confirm' + | 'trash.toast.restored' + | 'trash.toast.restoreFailed' + | 'trash.toast.deleted' + | 'trash.toast.deleteFailed' + | 'trash.toast.cleared' + | 'trash.toast.clearFailed' + | 'trash.table.name' + | 'trash.table.originalLocation' + | 'trash.table.deletedAt' + | 'trash.table.expiresIn' + | 'trash.table.days' + | 'trash.table.restore' + | 'trash.table.delete' + | 'share.page.title' + | 'share.page.linkCode' + | 'share.page.saveDialogTitle' + | 'share.page.saveDialogConfirm' + | 'share.page.needAccessFirst' + | 'share.itemType.file' + | 'share.itemType.folder' + | 'share.info.type' + | 'share.info.name' + | 'share.info.size' + | 'share.info.expires' + | 'share.info.password' + | 'share.info.never' + | 'share.info.passwordRequired' + | 'share.info.passwordNotRequired' + | 'share.access.title' + | 'share.access.passwordLabel' + | 'share.access.passwordPlaceholder' + | 'share.access.checking' + | 'share.access.unlock' + | 'share.access.accessing' + | 'share.access.getAccess' + | 'share.actions.title' + | 'share.actions.loading' + | 'share.actions.preview' + | 'share.actions.downloading' + | 'share.actions.download' + | 'share.actions.saving' + | 'share.actions.saveFolder' + | 'share.actions.save' + | 'share.status.loadFailed' + | 'share.status.accessGranted' + | 'share.status.invalidPasswordOrExpired' + | 'share.status.expiredOrUnavailable' + | 'share.status.downloadFailed' + | 'share.status.previewFailed' + | 'share.status.savedSuccess' + | 'share.status.saveFailed' + | 'share.dialog.title' + | 'share.dialog.subtitle' + | 'share.dialog.close' + | 'share.dialog.section.collaborators' + | 'share.dialog.searchPlaceholder' + | 'share.dialog.searching' + | 'share.dialog.result.userGroup' + | 'share.dialog.emptyCollaborators' + | 'share.dialog.collaborator.user' + | 'share.dialog.collaborator.group' + | 'share.dialog.permission.read' + | 'share.dialog.permission.write' + | 'share.dialog.permission.admin' + | 'share.dialog.remove' + | 'share.dialog.section.publicLink' + | 'share.dialog.publicDescription' + | 'share.dialog.generatingLink' + | 'share.dialog.copy' + | 'share.dialog.passwordProtected' + | 'share.dialog.passwordPlaceholder' + | 'share.dialog.regenerate' + | 'share.dialog.allowDownload' + | 'share.dialog.allowPreview' + | 'share.dialog.expireDate' + | 'share.dialog.clear' + | 'share.dialog.saving' + | 'share.dialog.saveSettings' + | 'share.dialog.settings.passwordUpdated' + | 'share.dialog.settings.saved' + | 'share.dialog.settings.saveFailed' + | 'share.dialog.settings.regenerated' + | 'share.dialog.settings.regenerateFailed' + | 'share.dialog.settings.passwordCopied' + | 'share.dialog.settings.linkCopied' + | 'share.dialog.copyPassword.title' + | 'share.dialog.copyPassword.message' + | 'share.dialog.copyLink.title' + | 'share.dialog.copyLink.message' + | 'share.dialog.publicHiddenNotice' + | 'share.dialog.done' + | 'move.dialog.title.single' + | 'move.dialog.title.multiple' + | 'move.dialog.title.default' + | 'move.dialog.prompt' + | 'move.dialog.confirm' + | 'move.dialog.root' + | 'move.dialog.selectDestinationWarning' + | 'move.dialog.shareHandling.title' + | 'move.dialog.shareHandling.keep' + | 'move.dialog.shareHandling.revoke' + | 'move.dialog.loading' + | 'move.dialog.empty' + | 'move.dialog.cancel' | 'footer.termsOfService' | 'footer.privacyPolicy'; @@ -206,6 +452,8 @@ export const LOCALE_MESSAGES: Record = { 'sidebar.myFiles': '我的文件', 'sidebar.shared': '共享', 'sidebar.recycleBin': '回收站', + 'sidebar.starred': 'Starred', + 'sidebar.starredEmpty': '暂无收藏', 'sidebar.workspaceTree': '工作区目录', 'sidebar.storage': '存储', 'sidebar.skills': '技能', @@ -313,6 +561,70 @@ export const LOCALE_MESSAGES: Record = { 'settings.actions.export': '导出设置', 'settings.actions.import': '导入设置', 'settings.actions.reset': '重置所有设置', + 'settings.resetSuccess': '设置已重置。', + 'settings.appearance.theme.label': '主题', + 'settings.appearance.theme.description': '选择您喜欢的应用主题', + 'settings.appearance.theme.light': '浅色', + 'settings.appearance.theme.dark': '深色', + 'settings.appearance.compactMode.label': '紧凑模式', + 'settings.appearance.compactMode.description': '减少界面间距,显示更多内容', + 'settings.appearance.defaultFileView.label': '默认文件视图', + 'settings.appearance.defaultFileView.description': '选择文件列表的默认显示方式', + 'settings.appearance.defaultFileView.option.list': '列表视图', + 'settings.appearance.defaultFileView.option.grid': '网格视图', + 'settings.appearance.defaultFileView.option.tiles': '瓦片视图', + 'settings.appearance.showFileExtensions.label': '显示文件扩展名', + 'settings.appearance.showFileExtensions.description': '在文件名中显示文件扩展名', + 'settings.uploads.maxConcurrentUploads.label': '最大并发上传数', + 'settings.uploads.maxConcurrentUploads.description': '同时上传的最大文件数量', + 'settings.uploads.chunkSize.label': '分块大小', + 'settings.uploads.chunkSize.description': '大文件分块上传的块大小 (MB)', + 'settings.uploads.autoRetryFailedUploads.label': '自动重试失败的上传', + 'settings.uploads.autoRetryFailedUploads.description': '网络错误时自动重试上传', + 'settings.uploads.retryAttempts.label': '重试次数', + 'settings.uploads.retryAttempts.description': '上传失败时的最大重试次数', + 'settings.files.itemsPerPage.label': '每页显示项目数', + 'settings.files.itemsPerPage.description': '文件列表每页显示的项目数量', + 'settings.files.showHiddenFiles.label': '显示隐藏文件', + 'settings.files.showHiddenFiles.description': '显示以点(.)开头的隐藏文件', + 'settings.files.autoRefreshInterval.label': '自动刷新间隔', + 'settings.files.autoRefreshInterval.description': '文件列表自动刷新的时间间隔 (秒,0 表示禁用)', + 'settings.files.autoDeleteDays.label': '回收站自动清理', + 'settings.files.autoDeleteDays.description': '回收站中文件的自动删除天数', + 'settings.files.autoDeleteDays.option.7': '7 天', + 'settings.files.autoDeleteDays.option.14': '14 天', + 'settings.files.autoDeleteDays.option.30': '30 天', + 'settings.files.autoDeleteDays.option.60': '60 天', + 'settings.files.autoDeleteDays.option.90': '90 天', + 'settings.files.confirmDelete.label': '删除确认', + 'settings.files.confirmDelete.description': '删除文件时显示确认对话框', + 'settings.notifications.desktop.label': '桌面通知', + 'settings.notifications.desktop.description': '启用系统桌面通知', + 'settings.notifications.sound.label': '声音通知', + 'settings.notifications.sound.description': '操作完成时播放提示音', + 'settings.notifications.uploadComplete.label': '上传完成通知', + 'settings.notifications.uploadComplete.description': '文件上传完成时显示通知', + 'settings.notifications.error.label': '错误通知', + 'settings.notifications.error.description': '发生错误时显示通知', + 'settings.security.sessionTimeout.label': '会话超时时间', + 'settings.security.sessionTimeout.description': '自动登出的空闲时间 (分钟,0 表示禁用)', + 'settings.security.sessionTimeout.option.disabled': '禁用', + 'settings.security.sessionTimeout.option.30m': '30 分钟', + 'settings.security.sessionTimeout.option.1h': '1 小时', + 'settings.security.sessionTimeout.option.2h': '2 小时', + 'settings.security.sessionTimeout.option.4h': '4 小时', + 'settings.security.sessionTimeout.option.8h': '8 小时', + 'settings.security.requirePasswordForSensitiveActions.label': '敏感操作密码确认', + 'settings.security.requirePasswordForSensitiveActions.description': '执行删除、分享等敏感操作时要求密码确认', + 'settings.advanced.debugMode.label': '调试模式', + 'settings.advanced.debugMode.description': '启用详细的调试信息输出', + 'settings.advanced.cacheDuration.label': '缓存持续时间', + 'settings.advanced.cacheDuration.description': '本地缓存的保持时间 (小时)', + 'settings.advanced.cacheDuration.option.1h': '1 小时', + 'settings.advanced.cacheDuration.option.6h': '6 小时', + 'settings.advanced.cacheDuration.option.12h': '12 小时', + 'settings.advanced.cacheDuration.option.24h': '24 小时', + 'settings.advanced.cacheDuration.option.72h': '72 小时', 'files.toolbar.view.list': '列表', 'files.toolbar.view.grid': '网格', 'files.toolbar.searchTag': '搜索', @@ -355,13 +667,193 @@ export const LOCALE_MESSAGES: Record = { 'files.action.delete': '删除', 'files.action.star': '收藏', 'files.action.unstar': '取消收藏', + 'files.mediaOptimization.processing': '处理中', + 'files.mediaOptimization.ready': '已优化', + 'files.mediaOptimization.failedFallback': '优化失败,已回退原文件', 'files.folder.loading': '加载中...', 'files.folder.noSubfolders': '暂无子文件夹', 'files.upload.toast.success': '已上传 {fileName}。', 'files.upload.toast.failed': '上传 {fileName} 失败:{reason}', 'files.upload.toast.unknownError': '未知错误', + 'files.star.toast.failed': '收藏操作失败:{reason}', + 'files.star.toast.unknownError': '未知错误', + 'files.rename.toast.createdFolder': '已创建文件夹“{folderName}”。', + 'files.rename.toast.createFailed': '创建文件夹失败。', + 'files.rename.toast.renamed': '已重命名为“{newName}”。', + 'files.rename.toast.renameFailed': '重命名失败。', + 'files.delete.confirm.title': '移动到回收站', + 'files.delete.confirm.message': '将“{itemName}”移动到回收站?', + 'files.delete.confirm.confirmText': '移动', + 'files.delete.toast.success': '“{itemName}”已移动到回收站。', + 'files.delete.toast.failed': '移动到回收站失败。', + 'files.move.toast.noMovable': '没有可移动的项目。', + 'files.move.toast.failedNoneMoved': '移动失败。{reason}', + 'files.move.toast.partial': '已移动 {succeeded}/{processed}。{reason}', + 'files.move.toast.success': '已移动 {count} 个项目。', + 'files.move.toast.failed': '批量移动失败。', + 'files.move.reason.noneMoved': '没有项目被移动。', + 'files.move.reason.someFailed': '部分项目移动失败。', + 'files.move.toast.selectAtLeastOne': '请至少选择一个项目。', + 'files.download.toast.failed': '下载失败。', + 'files.batch.download.toast.success': '已下载 {count} 个项目。', + 'files.batch.download.toast.failed': '下载所选文件失败。', + 'files.batch.delete.confirm.title': '移动到回收站', + 'files.batch.delete.confirm.message': '将所选的 {count} 个项目移动到回收站?', + 'files.batch.delete.confirm.confirmText': '移动', + 'files.batch.delete.toast.success': '已将 {count} 个项目移动到回收站。', + 'files.batch.delete.toast.failed': '移动所选项目到回收站失败。', 'files.root.myFiles': '我的文件', 'files.owner.you': '你', + 'sharing.page.title': '共享中心', + 'sharing.page.description': '管理收到的共享内容和你创建的共享链接。', + 'sharing.tab.sharedWithMe': '共享给我', + 'sharing.tab.myShareLinks': '我的共享链接', + 'sharing.empty.received': '还没有收到共享文件。', + 'sharing.empty.links': '还没有创建共享链接。', + 'sharing.itemType.file': '文件', + 'sharing.itemType.folder': '文件夹', + 'sharing.permission.read': '只读', + 'sharing.permission.write': '可编辑', + 'sharing.permission.admin': '管理员', + 'sharing.table.received.name': '名称', + 'sharing.table.received.sharedBy': '共享人', + 'sharing.table.received.permission': '权限', + 'sharing.table.received.sharedAt': '共享时间', + 'sharing.table.received.accept': '接收', + 'sharing.table.links.resource': '资源', + 'sharing.table.links.shareLink': '共享链接', + 'sharing.table.links.visitsDownloads': '访问 / 下载', + 'sharing.table.links.createdAt': '创建时间', + 'sharing.table.links.copy': '复制', + 'sharing.table.links.delete': '删除', + 'sharing.batch.selected': '已选', + 'sharing.batch.acceptSelected': '接收所选', + 'sharing.batch.clear': '清除', + 'sharing.confirm.deleteLink.title': '删除共享链接', + 'sharing.confirm.deleteLink.message': '确定删除共享链接 {shareLink} 吗?', + 'sharing.confirm.deleteLink.confirm': '删除', + 'sharing.toast.linkDeleted': '共享链接已删除。', + 'sharing.toast.linkDeleteFailed': '删除共享链接失败。', + 'sharing.toast.linkCopied': '共享链接已复制。', + 'sharing.copyDialog.title': '复制共享链接', + 'sharing.copyDialog.message': '剪贴板不可用,请手动复制此链接:', + 'trash.page.title': '回收站', + 'trash.page.description': '项目会保留最多 30 天,之后系统会自动清理。', + 'trash.page.clearBin': '清空回收站', + 'trash.page.empty': '回收站为空。', + 'trash.confirm.restore.title': '恢复项目', + 'trash.confirm.restore.message': '确定恢复“{itemName}”吗?', + 'trash.confirm.restore.confirm': '恢复', + 'trash.confirm.delete.title': '永久删除', + 'trash.confirm.delete.message': '确定永久删除“{itemName}”吗?此操作无法撤销。', + 'trash.confirm.delete.confirm': '删除', + 'trash.confirm.clear.title': '清空回收站', + 'trash.confirm.clear.message': '确定清空整个回收站吗?此操作无法撤销。', + 'trash.confirm.clear.confirm': '清空', + 'trash.toast.restored': '已恢复“{itemName}”。', + 'trash.toast.restoreFailed': '恢复失败。', + 'trash.toast.deleted': '已删除“{itemName}”。', + 'trash.toast.deleteFailed': '永久删除失败。', + 'trash.toast.cleared': '回收站已清空。', + 'trash.toast.clearFailed': '清空回收站失败。', + 'trash.table.name': '名称', + 'trash.table.originalLocation': '原始位置', + 'trash.table.deletedAt': '删除时间', + 'trash.table.expiresIn': '剩余时间', + 'trash.table.days': '{days} 天', + 'trash.table.restore': '恢复', + 'trash.table.delete': '删除', + 'share.page.title': '共享链接', + 'share.page.linkCode': '链接码:', + 'share.page.saveDialogTitle': '保存到我的空间', + 'share.page.saveDialogConfirm': '保存到此处', + 'share.page.needAccessFirst': '请先获取共享访问权限。', + 'share.itemType.file': '文件', + 'share.itemType.folder': '文件夹', + 'share.info.type': '类型', + 'share.info.name': '名称', + 'share.info.size': '大小', + 'share.info.expires': '有效期', + 'share.info.password': '密码', + 'share.info.never': '永不过期', + 'share.info.passwordRequired': '需要', + 'share.info.passwordNotRequired': '不需要', + 'share.access.title': '访问权限', + 'share.access.passwordLabel': '密码', + 'share.access.passwordPlaceholder': '输入密码', + 'share.access.checking': '校验中...', + 'share.access.unlock': '解锁', + 'share.access.accessing': '访问中...', + 'share.access.getAccess': '获取访问权限', + 'share.actions.title': '操作', + 'share.actions.loading': '加载中...', + 'share.actions.preview': '预览', + 'share.actions.downloading': '下载中...', + 'share.actions.download': '下载', + 'share.actions.saving': '保存中...', + 'share.actions.saveFolder': '将文件夹保存到我的空间', + 'share.actions.save': '保存到我的空间', + 'share.status.loadFailed': '无法加载共享内容,链接可能无效或已过期。', + 'share.status.accessGranted': '访问已授权。', + 'share.status.invalidPasswordOrExpired': '密码错误或共享已过期。', + 'share.status.expiredOrUnavailable': '共享已过期或暂不可用。', + 'share.status.downloadFailed': '下载失败。', + 'share.status.previewFailed': '预览失败。', + 'share.status.savedSuccess': '保存成功({itemType})。', + 'share.status.saveFailed': '保存失败,请确认你已登录且邮箱已验证。', + 'share.dialog.title': '共享:{itemName}', + 'share.dialog.subtitle': '管理协作者权限和公开链接访问。', + 'share.dialog.close': '关闭弹窗', + 'share.dialog.section.collaborators': '协作者权限', + 'share.dialog.searchPlaceholder': '搜索用户或用户组', + 'share.dialog.searching': '搜索中...', + 'share.dialog.result.userGroup': '用户组', + 'share.dialog.emptyCollaborators': '暂无协作者。', + 'share.dialog.collaborator.user': '用户', + 'share.dialog.collaborator.group': '用户组', + 'share.dialog.permission.read': '只读', + 'share.dialog.permission.write': '可编辑', + 'share.dialog.permission.admin': '管理员', + 'share.dialog.remove': '移除', + 'share.dialog.section.publicLink': '公开链接', + 'share.dialog.publicDescription': '配置密码、到期时间和下载/预览权限。', + 'share.dialog.generatingLink': '正在生成链接...', + 'share.dialog.copy': '复制', + 'share.dialog.passwordProtected': '密码保护', + 'share.dialog.passwordPlaceholder': '留空则自动生成', + 'share.dialog.regenerate': '重新生成', + 'share.dialog.allowDownload': '允许下载', + 'share.dialog.allowPreview': '允许预览', + 'share.dialog.expireDate': '到期日期', + 'share.dialog.clear': '清除', + 'share.dialog.saving': '保存中...', + 'share.dialog.saveSettings': '保存设置', + 'share.dialog.settings.passwordUpdated': '密码已更新,请及时复制。', + 'share.dialog.settings.saved': '共享设置已保存。', + 'share.dialog.settings.saveFailed': '保存设置失败。', + 'share.dialog.settings.regenerated': '已生成新密码,请及时复制。', + 'share.dialog.settings.regenerateFailed': '重新生成密码失败。', + 'share.dialog.settings.passwordCopied': '密码已复制。', + 'share.dialog.settings.linkCopied': '链接已复制。', + 'share.dialog.copyPassword.title': '复制密码', + 'share.dialog.copyPassword.message': '剪贴板不可用,请手动复制此密码:', + 'share.dialog.copyLink.title': '复制链接', + 'share.dialog.copyLink.message': '剪贴板不可用,请手动复制此链接:', + 'share.dialog.publicHiddenNotice': '当前仅在弹窗中隐藏公开链接,已有链接仍保持可用。', + 'share.dialog.done': '完成', + 'move.dialog.title.single': '移动“{itemName}”', + 'move.dialog.title.multiple': '移动 {count} 个项目', + 'move.dialog.title.default': '移动', + 'move.dialog.prompt': '选择新的位置:', + 'move.dialog.confirm': '移动到此处', + 'move.dialog.root': '我的文件(根目录)', + 'move.dialog.selectDestinationWarning': '请选择目标文件夹。', + 'move.dialog.shareHandling.title': '共享链接处理', + 'move.dialog.shareHandling.keep': '保留现有共享链接', + 'move.dialog.shareHandling.revoke': '移动后撤销现有共享链接', + 'move.dialog.loading': '加载中...', + 'move.dialog.empty': '暂无可用文件夹。', + 'move.dialog.cancel': '取消', 'footer.termsOfService': '使用条款', 'footer.privacyPolicy': '隐私政策', }, @@ -386,6 +878,8 @@ export const LOCALE_MESSAGES: Record = { 'sidebar.myFiles': 'My Files', 'sidebar.shared': 'Shared', 'sidebar.recycleBin': 'Recycle Bin', + 'sidebar.starred': 'Starred', + 'sidebar.starredEmpty': 'No starred items', 'sidebar.workspaceTree': 'Workspace Tree', 'sidebar.storage': 'Storage', 'sidebar.skills': 'Skills', @@ -493,6 +987,70 @@ export const LOCALE_MESSAGES: Record = { 'settings.actions.export': 'Export Settings', 'settings.actions.import': 'Import Settings', 'settings.actions.reset': 'Reset All Settings', + 'settings.resetSuccess': 'Settings reset.', + 'settings.appearance.theme.label': 'Theme', + 'settings.appearance.theme.description': 'Choose your preferred app theme', + 'settings.appearance.theme.light': 'Light', + 'settings.appearance.theme.dark': 'Dark', + 'settings.appearance.compactMode.label': 'Compact Mode', + 'settings.appearance.compactMode.description': 'Reduce spacing to show more content', + 'settings.appearance.defaultFileView.label': 'Default File View', + 'settings.appearance.defaultFileView.description': 'Choose the default display mode for file lists', + 'settings.appearance.defaultFileView.option.list': 'List View', + 'settings.appearance.defaultFileView.option.grid': 'Grid View', + 'settings.appearance.defaultFileView.option.tiles': 'Tiles View', + 'settings.appearance.showFileExtensions.label': 'Show File Extensions', + 'settings.appearance.showFileExtensions.description': 'Display file extensions in filenames', + 'settings.uploads.maxConcurrentUploads.label': 'Max Concurrent Uploads', + 'settings.uploads.maxConcurrentUploads.description': 'Maximum number of files uploaded at the same time', + 'settings.uploads.chunkSize.label': 'Chunk Size', + 'settings.uploads.chunkSize.description': 'Chunk size for large file uploads (MB)', + 'settings.uploads.autoRetryFailedUploads.label': 'Auto Retry Failed Uploads', + 'settings.uploads.autoRetryFailedUploads.description': 'Automatically retry uploads on network errors', + 'settings.uploads.retryAttempts.label': 'Retry Attempts', + 'settings.uploads.retryAttempts.description': 'Maximum retry attempts when an upload fails', + 'settings.files.itemsPerPage.label': 'Items Per Page', + 'settings.files.itemsPerPage.description': 'Number of items displayed per page in file lists', + 'settings.files.showHiddenFiles.label': 'Show Hidden Files', + 'settings.files.showHiddenFiles.description': 'Display hidden files that start with a dot (.)', + 'settings.files.autoRefreshInterval.label': 'Auto Refresh Interval', + 'settings.files.autoRefreshInterval.description': 'Automatic refresh interval for file lists (seconds, 0 to disable)', + 'settings.files.autoDeleteDays.label': 'Trash Auto Cleanup', + 'settings.files.autoDeleteDays.description': 'Days before files in trash are automatically deleted', + 'settings.files.autoDeleteDays.option.7': '7 days', + 'settings.files.autoDeleteDays.option.14': '14 days', + 'settings.files.autoDeleteDays.option.30': '30 days', + 'settings.files.autoDeleteDays.option.60': '60 days', + 'settings.files.autoDeleteDays.option.90': '90 days', + 'settings.files.confirmDelete.label': 'Delete Confirmation', + 'settings.files.confirmDelete.description': 'Show a confirmation dialog before deleting files', + 'settings.notifications.desktop.label': 'Desktop Notifications', + 'settings.notifications.desktop.description': 'Enable system desktop notifications', + 'settings.notifications.sound.label': 'Sound Notifications', + 'settings.notifications.sound.description': 'Play a sound when actions complete', + 'settings.notifications.uploadComplete.label': 'Upload Complete Notifications', + 'settings.notifications.uploadComplete.description': 'Show notifications when uploads finish', + 'settings.notifications.error.label': 'Error Notifications', + 'settings.notifications.error.description': 'Show notifications when errors occur', + 'settings.security.sessionTimeout.label': 'Session Timeout', + 'settings.security.sessionTimeout.description': 'Idle time before automatic logout (minutes, 0 to disable)', + 'settings.security.sessionTimeout.option.disabled': 'Disabled', + 'settings.security.sessionTimeout.option.30m': '30 minutes', + 'settings.security.sessionTimeout.option.1h': '1 hour', + 'settings.security.sessionTimeout.option.2h': '2 hours', + 'settings.security.sessionTimeout.option.4h': '4 hours', + 'settings.security.sessionTimeout.option.8h': '8 hours', + 'settings.security.requirePasswordForSensitiveActions.label': 'Password Confirmation for Sensitive Actions', + 'settings.security.requirePasswordForSensitiveActions.description': 'Require password confirmation for sensitive actions like delete or share', + 'settings.advanced.debugMode.label': 'Debug Mode', + 'settings.advanced.debugMode.description': 'Enable detailed debug output', + 'settings.advanced.cacheDuration.label': 'Cache Duration', + 'settings.advanced.cacheDuration.description': 'How long local cache is retained (hours)', + 'settings.advanced.cacheDuration.option.1h': '1 hour', + 'settings.advanced.cacheDuration.option.6h': '6 hours', + 'settings.advanced.cacheDuration.option.12h': '12 hours', + 'settings.advanced.cacheDuration.option.24h': '24 hours', + 'settings.advanced.cacheDuration.option.72h': '72 hours', 'files.toolbar.view.list': 'List', 'files.toolbar.view.grid': 'Grid', 'files.toolbar.searchTag': 'Search', @@ -535,13 +1093,193 @@ export const LOCALE_MESSAGES: Record = { 'files.action.delete': 'Delete', 'files.action.star': 'Star', 'files.action.unstar': 'Unstar', + 'files.mediaOptimization.processing': 'Processing', + 'files.mediaOptimization.ready': 'Optimized', + 'files.mediaOptimization.failedFallback': 'Optimization failed, fallback to source', 'files.folder.loading': 'Loading...', 'files.folder.noSubfolders': 'No subfolders', 'files.upload.toast.success': 'Uploaded {fileName}.', 'files.upload.toast.failed': 'Upload of {fileName} failed: {reason}', 'files.upload.toast.unknownError': 'Unknown error', + 'files.star.toast.failed': 'Failed to update star status: {reason}', + 'files.star.toast.unknownError': 'Unknown error', + 'files.rename.toast.createdFolder': 'Created folder "{folderName}".', + 'files.rename.toast.createFailed': 'Folder creation failed.', + 'files.rename.toast.renamed': 'Renamed to "{newName}".', + 'files.rename.toast.renameFailed': 'Rename failed.', + 'files.delete.confirm.title': 'Move To Trash', + 'files.delete.confirm.message': 'Move "{itemName}" to trash?', + 'files.delete.confirm.confirmText': 'Move', + 'files.delete.toast.success': '"{itemName}" moved to trash.', + 'files.delete.toast.failed': 'Failed to move item to trash.', + 'files.move.toast.noMovable': 'No movable items found.', + 'files.move.toast.failedNoneMoved': 'Move failed. {reason}', + 'files.move.toast.partial': 'Moved {succeeded}/{processed}. {reason}', + 'files.move.toast.success': 'Moved {count} item(s).', + 'files.move.toast.failed': 'Batch move failed.', + 'files.move.reason.noneMoved': 'No items were moved.', + 'files.move.reason.someFailed': 'Some items failed.', + 'files.move.toast.selectAtLeastOne': 'Please select at least one item.', + 'files.download.toast.failed': 'Download failed.', + 'files.batch.download.toast.success': 'Downloaded {count} item(s).', + 'files.batch.download.toast.failed': 'Failed to download selected files.', + 'files.batch.delete.confirm.title': 'Move To Trash', + 'files.batch.delete.confirm.message': 'Move {count} selected item(s) to trash?', + 'files.batch.delete.confirm.confirmText': 'Move', + 'files.batch.delete.toast.success': 'Moved {count} item(s) to trash.', + 'files.batch.delete.toast.failed': 'Failed to move selected items to trash.', 'files.root.myFiles': 'My Files', 'files.owner.you': 'You', + 'sharing.page.title': 'Sharing Center', + 'sharing.page.description': 'Manage received items and links you shared with others.', + 'sharing.tab.sharedWithMe': 'Shared With Me', + 'sharing.tab.myShareLinks': 'My Share Links', + 'sharing.empty.received': 'No files shared with you.', + 'sharing.empty.links': 'No share links created yet.', + 'sharing.itemType.file': 'File', + 'sharing.itemType.folder': 'Folder', + 'sharing.permission.read': 'Read', + 'sharing.permission.write': 'Write', + 'sharing.permission.admin': 'Admin', + 'sharing.table.received.name': 'Name', + 'sharing.table.received.sharedBy': 'Shared By', + 'sharing.table.received.permission': 'Permission', + 'sharing.table.received.sharedAt': 'Shared At', + 'sharing.table.received.accept': 'Accept', + 'sharing.table.links.resource': 'Resource', + 'sharing.table.links.shareLink': 'Share Link', + 'sharing.table.links.visitsDownloads': 'Visits / Downloads', + 'sharing.table.links.createdAt': 'Created At', + 'sharing.table.links.copy': 'Copy', + 'sharing.table.links.delete': 'Delete', + 'sharing.batch.selected': 'SELECTED', + 'sharing.batch.acceptSelected': 'Accept Selected', + 'sharing.batch.clear': 'Clear', + 'sharing.confirm.deleteLink.title': 'Delete Share Link', + 'sharing.confirm.deleteLink.message': 'Delete share link {shareLink}?', + 'sharing.confirm.deleteLink.confirm': 'Delete', + 'sharing.toast.linkDeleted': 'Share link deleted.', + 'sharing.toast.linkDeleteFailed': 'Failed to delete share link.', + 'sharing.toast.linkCopied': 'Share link copied.', + 'sharing.copyDialog.title': 'Copy Share Link', + 'sharing.copyDialog.message': 'Clipboard is unavailable. Copy this link manually:', + 'trash.page.title': 'Recycle Bin', + 'trash.page.description': 'Items are kept for up to 30 days before automatic cleanup.', + 'trash.page.clearBin': 'Clear Bin', + 'trash.page.empty': 'Recycle bin is empty.', + 'trash.confirm.restore.title': 'Restore Item', + 'trash.confirm.restore.message': 'Restore "{itemName}"?', + 'trash.confirm.restore.confirm': 'Restore', + 'trash.confirm.delete.title': 'Permanent Delete', + 'trash.confirm.delete.message': 'Permanently delete "{itemName}"? This cannot be undone.', + 'trash.confirm.delete.confirm': 'Delete', + 'trash.confirm.clear.title': 'Clear Recycle Bin', + 'trash.confirm.clear.message': 'Clear entire recycle bin? This cannot be undone.', + 'trash.confirm.clear.confirm': 'Clear', + 'trash.toast.restored': 'Restored "{itemName}".', + 'trash.toast.restoreFailed': 'Restore failed.', + 'trash.toast.deleted': 'Deleted "{itemName}".', + 'trash.toast.deleteFailed': 'Permanent delete failed.', + 'trash.toast.cleared': 'Recycle bin cleared.', + 'trash.toast.clearFailed': 'Clear recycle bin failed.', + 'trash.table.name': 'Name', + 'trash.table.originalLocation': 'Original Location', + 'trash.table.deletedAt': 'Deleted At', + 'trash.table.expiresIn': 'Expires In', + 'trash.table.days': '{days} days', + 'trash.table.restore': 'Restore', + 'trash.table.delete': 'Delete', + 'share.page.title': 'Shared Link', + 'share.page.linkCode': 'Link code:', + 'share.page.saveDialogTitle': 'Save to My Space', + 'share.page.saveDialogConfirm': 'Save Here', + 'share.page.needAccessFirst': 'Please access the share first.', + 'share.itemType.file': 'File', + 'share.itemType.folder': 'Folder', + 'share.info.type': 'Type', + 'share.info.name': 'Name', + 'share.info.size': 'Size', + 'share.info.expires': 'Expires', + 'share.info.password': 'Password', + 'share.info.never': 'Never', + 'share.info.passwordRequired': 'Required', + 'share.info.passwordNotRequired': 'Not required', + 'share.access.title': 'Access', + 'share.access.passwordLabel': 'Password', + 'share.access.passwordPlaceholder': 'Enter password', + 'share.access.checking': 'Checking...', + 'share.access.unlock': 'Unlock', + 'share.access.accessing': 'Accessing...', + 'share.access.getAccess': 'Get Access', + 'share.actions.title': 'Actions', + 'share.actions.loading': 'Loading...', + 'share.actions.preview': 'Preview', + 'share.actions.downloading': 'Downloading...', + 'share.actions.download': 'Download', + 'share.actions.saving': 'Saving...', + 'share.actions.saveFolder': 'Save Folder to My Space', + 'share.actions.save': 'Save to My Space', + 'share.status.loadFailed': 'Unable to load share. The link may be invalid or expired.', + 'share.status.accessGranted': 'Access granted.', + 'share.status.invalidPasswordOrExpired': 'Invalid password or share expired.', + 'share.status.expiredOrUnavailable': 'Share expired or unavailable.', + 'share.status.downloadFailed': 'Download failed.', + 'share.status.previewFailed': 'Preview failed.', + 'share.status.savedSuccess': 'Saved successfully ({itemType}).', + 'share.status.saveFailed': 'Save failed. Please make sure you are logged in and verified.', + 'share.dialog.title': 'Share: {itemName}', + 'share.dialog.subtitle': 'Manage collaborator permissions and public link access.', + 'share.dialog.close': 'Close dialog', + 'share.dialog.section.collaborators': 'Collaborator Permissions', + 'share.dialog.searchPlaceholder': 'Search users or groups', + 'share.dialog.searching': 'Searching...', + 'share.dialog.result.userGroup': 'User group', + 'share.dialog.emptyCollaborators': 'No collaborators configured.', + 'share.dialog.collaborator.user': 'User', + 'share.dialog.collaborator.group': 'Group', + 'share.dialog.permission.read': 'Read', + 'share.dialog.permission.write': 'Write', + 'share.dialog.permission.admin': 'Admin', + 'share.dialog.remove': 'Remove', + 'share.dialog.section.publicLink': 'Public Link', + 'share.dialog.publicDescription': 'Configure password, expiry date, and download/preview permissions.', + 'share.dialog.generatingLink': 'Generating link...', + 'share.dialog.copy': 'Copy', + 'share.dialog.passwordProtected': 'Password protected', + 'share.dialog.passwordPlaceholder': 'Leave blank to auto-generate', + 'share.dialog.regenerate': 'Regenerate', + 'share.dialog.allowDownload': 'Allow download', + 'share.dialog.allowPreview': 'Allow preview', + 'share.dialog.expireDate': 'Expire date', + 'share.dialog.clear': 'Clear', + 'share.dialog.saving': 'Saving...', + 'share.dialog.saveSettings': 'Save settings', + 'share.dialog.settings.passwordUpdated': 'Password updated. Copy it now.', + 'share.dialog.settings.saved': 'Share settings saved.', + 'share.dialog.settings.saveFailed': 'Failed to save settings.', + 'share.dialog.settings.regenerated': 'New password generated. Copy it now.', + 'share.dialog.settings.regenerateFailed': 'Failed to regenerate password.', + 'share.dialog.settings.passwordCopied': 'Password copied.', + 'share.dialog.settings.linkCopied': 'Link copied.', + 'share.dialog.copyPassword.title': 'Copy Password', + 'share.dialog.copyPassword.message': 'Clipboard is unavailable. Copy this password manually:', + 'share.dialog.copyLink.title': 'Copy Link', + 'share.dialog.copyLink.message': 'Clipboard is unavailable. Copy this link manually:', + 'share.dialog.publicHiddenNotice': 'Public link hidden in this dialog. Existing links are kept.', + 'share.dialog.done': 'Done', + 'move.dialog.title.single': 'Move "{itemName}"', + 'move.dialog.title.multiple': 'Move {count} items', + 'move.dialog.title.default': 'Move', + 'move.dialog.prompt': 'Choose a new location:', + 'move.dialog.confirm': 'Move Here', + 'move.dialog.root': 'My Files (Root)', + 'move.dialog.selectDestinationWarning': 'Please select a destination folder.', + 'move.dialog.shareHandling.title': 'Shared Link Handling', + 'move.dialog.shareHandling.keep': 'Keep active share links', + 'move.dialog.shareHandling.revoke': 'Revoke active share links after move', + 'move.dialog.loading': 'Loading...', + 'move.dialog.empty': 'No folders available.', + 'move.dialog.cancel': 'Cancel', 'footer.termsOfService': 'Terms of Service', 'footer.privacyPolicy': 'Privacy Policy', }, diff --git a/web/src/mock/handlers/auth.ts b/web/src/mock/handlers/auth.ts index 94d0a16..7de9153 100644 --- a/web/src/mock/handlers/auth.ts +++ b/web/src/mock/handlers/auth.ts @@ -1,9 +1,38 @@ import Mock from 'mockjs'; -import { addLog, addNotification, createMockId, getCurrentUser, mockUsers, setCurrentUser } from '../state'; +import { + addLog, + addNotification, + createMockId, + getCurrentUser, + mockRegistrationEmailDomainRules, + mockUsers, + setCurrentUser, +} from '../state'; let activeRefreshSessionUserId: string | null = null; const verificationTokenMap = new Map (); +function extractDomain(email: string) { + const at = email.lastIndexOf('@'); + if (at < 0) return ''; + return email.slice(at + 1).trim().toLowerCase(); +} + +function isAllowedEmailDomain(email: string) { + const enabledRules = mockRegistrationEmailDomainRules.filter((item) => item.enabled); + if (!enabledRules.length) return false; + const domain = extractDomain(email); + if (!domain) return false; + return enabledRules.some((item) => { + try { + const regex = new RegExp(`^${item.pattern}$`); + return regex.test(domain); + } catch { + return false; + } + }); +} + function buildUserPayload(user: (typeof mockUsers)[number]) { return { userId: user.userId, @@ -81,6 +110,15 @@ export const setupAuthMocks = () => { }; } + if (!isAllowedEmailDomain(String(email))) { + return { + success: false, + code: 400, + message: '邮箱后缀不被允许,请更换邮箱', + data: null, + }; + } + const exists = mockUsers.some((user) => user.username.toLowerCase() === String(username).toLowerCase() || user.email.toLowerCase() === String(email).toLowerCase(), diff --git a/web/src/mock/handlers/file.ts b/web/src/mock/handlers/file.ts index c400296..26b7308 100644 --- a/web/src/mock/handlers/file.ts +++ b/web/src/mock/handlers/file.ts @@ -1,7 +1,7 @@ import JSZip from 'jszip'; import Mock from 'mockjs'; import { addLog, addNotification, createMockId, mockJobs, mockShares } from '../state'; -import { vfsApi, type VfsNode } from '../vfs'; +import { STARRED_ITEMS_LIMIT, vfsApi, type VfsNode } from '../vfs'; const MINIMAL_VALID_PDF_BASE64 = 'JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvQ29udGVudHMgNCAwIFIgL1Jlc291cmNlcyA8PCAvRm9udCA8PCAvRjEgNSAwIFIgPj4gPj4gPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA0NCA+PgpzdHJlYW0KQlQKL0YxIDI0IFRmCjEwMCA3MDAgVGQKKEhlbGxvLCBQREYhKSBUagpFVAplbmRzdHJlYW0KZW5kb2JqCjUgMCBvYmoKPDwgL1R5cGUgL0ZvbnQgL1N1YnR5cGUgL1R5cGUxIC9CYXNlRm9udCAvSGVsdmV0aWNhID4+CmVuZG9iagp4cmVmCjAgNgowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCjAwMDAwMDAyNzAgMDAwMDAgbiAKMDAwMDAwMDM2MyAwMDAwMCBuIAp0cmFpbGVyCjw8IC9TaXplIDYgL1Jvb3QgMSAwIFIgPj4Kc3RhcnR4cmVmCjQ0MwolJUVPRgo='; @@ -46,6 +46,7 @@ function nodeToItem(node: VfsNode) { folderId: node.parent || 'root', permission: node.permission || 'owner', isStarred: node.isStarred || false, + mediaOptimization: node.mediaOptimization, }; } @@ -83,6 +84,30 @@ function nowIso() { return new Date().toISOString(); } +function mockError(code: number, message: string) { + return { + success: false, + code, + message, + data: null, + timestamp: nowIso(), + }; +} + +function resolvePreviewNode(file: VfsNode) { + const optimization = file.mediaOptimization; + if (!optimization) { + return file; + } + if (optimization.status === 'ready') { + return { + ...file, + mimeType: optimization.optimizedMimeType || file.mimeType, + }; + } + return file; +} + function splitFileName(name: string) { const dotIndex = name.lastIndexOf('.'); if (dotIndex > 0) { @@ -571,7 +596,7 @@ export const setupFileMocks = () => { }; } - return buildMockFileBlob(node); + return buildMockFileBlob(resolvePreviewNode(node)); }); Mock.mock(/\/api\/v1\/files\/([^/]+)\/archive\/preview$/, 'post', (options) => { @@ -755,12 +780,22 @@ export const setupFileMocks = () => { Mock.mock(/\/api\/v1\/files\/([^/]+)\/star$/, 'patch', (options) => { const fileId = (options.url.match(/\/api\/v1\/files\/([^/]+)\/star/) || [])[1]; const { isStarred } = JSON.parse(options.body || '{}'); - const node = vfsApi.setStarred(fileId, Boolean(isStarred)); + const node = vfsApi.get(fileId); + if (!node || node.type !== 'file' || node.isTrashed) { + return mockError(404, 'File not found'); + } + + const next = Boolean(isStarred); + if (next && !node.isStarred && vfsApi.getStarred().length >= STARRED_ITEMS_LIMIT) { + return mockError(400, `已达收藏上限 ${STARRED_ITEMS_LIMIT}`); + } + + const updatedNode = vfsApi.setStarred(fileId, next); return { success: true, code: 200, - data: nodeToItem(node), + data: nodeToItem(updatedNode), }; }); diff --git a/web/src/mock/handlers/folder.ts b/web/src/mock/handlers/folder.ts index d4411c8..4feb231 100644 --- a/web/src/mock/handlers/folder.ts +++ b/web/src/mock/handlers/folder.ts @@ -1,11 +1,25 @@ import Mock from 'mockjs'; import { addLog, mockShares } from '../state'; -import { vfsApi, type VfsNode } from '../vfs'; +import { STARRED_ITEMS_LIMIT, vfsApi, type VfsNode } from '../vfs'; function parseUrl(url: string) { return new URL(url, 'http://localhost'); } +function nowIso() { + return new Date().toISOString(); +} + +function mockError(code: number, message: string) { + return { + success: false, + code, + message, + data: null, + timestamp: nowIso(), + }; +} + function nodeToItem(node: VfsNode) { if (node.type === 'folder') { return { @@ -34,6 +48,7 @@ function nodeToItem(node: VfsNode) { folderId: node.parent || 'root', permission: node.permission || 'owner', isStarred: node.isStarred || false, + mediaOptimization: node.mediaOptimization, }; } @@ -213,7 +228,16 @@ export const setupFolderMocks = () => { Mock.mock(/\/api\/v1\/folders\/([^/]+)\/star$/, 'patch', (options) => { const folderId = (options.url.match(/\/api\/v1\/folders\/([^/]+)\/star/) || [])[1]; const { isStarred } = JSON.parse(options.body || '{}'); - const updated = vfsApi.setStarred(folderId, Boolean(isStarred)); + const node = vfsApi.get(folderId); + if (!node || node.type !== 'folder' || node.isTrashed) { + return mockError(404, 'Folder not found'); + } + + const next = Boolean(isStarred); + if (next && !node.isStarred && vfsApi.getStarred().length >= STARRED_ITEMS_LIMIT) { + return mockError(400, `已达收藏上限 ${STARRED_ITEMS_LIMIT}`); + } + const updated = vfsApi.setStarred(folderId, next); return { success: true, diff --git a/web/src/mock/handlers/jobs.ts b/web/src/mock/handlers/jobs.ts index bfaec44..1e091d3 100644 --- a/web/src/mock/handlers/jobs.ts +++ b/web/src/mock/handlers/jobs.ts @@ -2,6 +2,49 @@ import Mock from 'mockjs'; import { mockJobs } from '../state'; export const setupJobsMocks = () => { + mockJobs['job_transcode_demo'] = { + jobId: 'job_transcode_demo', + taskType: 'task.transcode', + status: 'succeeded', + priority: 100, + payload: { + sourceBucketName: 'fileflash', + sourceObjectKey: 'objects/u1/sample-video', + sourceObjectId: 1001, + outputBucketName: 'fileflash', + outputObjectKey: 'optimized/transcode/v1/object-1001/sample-mp4-v1.mp4', + fileId: 1001, + }, + result: { + mediaType: 'video', + optimizedMimeType: 'video/mp4', + transcodeProfile: { + version: 'mp4-v1', + container: 'mp4', + videoCodec: 'h264', + audioCodec: 'aac', + }, + metadata: { + durationMs: 12000, + width: 1280, + height: 720, + bitrate: 1200000, + sampleRate: 44100, + }, + }, + errorMessage: null, + attempt: 0, + maxAttempts: 5, + scheduledAt: new Date().toISOString(), + startedAt: new Date().toISOString(), + finishedAt: new Date().toISOString(), + traceId: 'mock-job-transcode', + idempotencyKey: 'object:1001:transcode:mp4-v1', + requestedBy: '1', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + Mock.mock(/\/api\/v1\/jobs\/([^/?]+)(?:\?.*)?$/, 'get', (options) => { const jobId = (options.url.match(/\/api\/v1\/jobs\/([^/?]+)/) || [])[1]; const job = jobId ? mockJobs[jobId] : undefined; diff --git a/web/src/mock/handlers/share.ts b/web/src/mock/handlers/share.ts index 184d310..dbebaef 100644 --- a/web/src/mock/handlers/share.ts +++ b/web/src/mock/handlers/share.ts @@ -36,6 +36,19 @@ function buildMockFileBlob(file: { name: string; mimeType?: string; content?: st return new Blob([`Binary file: ${file.name}`], { type: file.mimeType || 'application/octet-stream' }); } +function resolveSharedPreviewNode(node: any) { + if (!node?.mediaOptimization) { + return node; + } + if (node.mediaOptimization.status === 'ready') { + return { + ...node, + mimeType: node.mediaOptimization.optimizedMimeType || node.mimeType, + }; + } + return node; +} + function generatePassword() { return Mock.Random.string('number', 6); } @@ -404,7 +417,7 @@ export const setupShareMocks = () => { }; } - return buildMockFileBlob(node); + return buildMockFileBlob(resolveSharedPreviewNode(node)); }); Mock.mock(/\/api\/v1\/shared-items(?:\?.*)?$/, 'get', (options) => { diff --git a/web/src/mock/handlers/upload.ts b/web/src/mock/handlers/upload.ts index 0f185a4..b6881fc 100644 --- a/web/src/mock/handlers/upload.ts +++ b/web/src/mock/handlers/upload.ts @@ -1,5 +1,5 @@ import Mock from 'mockjs'; -import { addLog, addNotification } from '../state'; +import { addLog, addNotification, mockJobs } from '../state'; import { vfsApi } from '../vfs'; import { arrayBufferToBase64 } from '../../utils/hash'; @@ -421,77 +421,116 @@ export const setupUploadMocks = () => { Mock.mock(/\/api\/v1\/uploads\/([^/]+)\/merge$/, 'post', async (options) => { const uploadId = (options.url.match(/\/api\/v1\/uploads\/([^/]+)\/merge/) || [])[1]; - const session = sessions.get(uploadId); - - if (!session) { - return { - success: false, - code: 404, - message: 'Upload session not found', - data: null, - }; - } - - const sortedIndexes = [...session.uploadedChunkIndexes].sort((a, b) => a - b); - const sortedChunks = sortedIndexes.map((index) => session.chunks.get(index)).filter(Boolean) as Blob[]; - - if (!sortedChunks.length) { - return { - success: false, - code: 400, - message: 'No uploaded chunks found', - data: null, - }; - } - - const mergedBlob = new Blob(sortedChunks, { type: session.mimeType }); - const buffer = await mergedBlob.arrayBuffer(); - const base64Content = arrayBufferToBase64(buffer); - - const created = vfsApi.createFile( - session.parentId, - session.fileName, - mergedBlob.size, - session.mimeType, - base64Content, - ); - - const node = vfsApi.get(created.id); - if (node) { - node.hash = session.fileHash; - node.virusStatus = 'clean'; - node.updatedAt = new Date().toISOString(); - } - - sessions.delete(uploadId); - hashToSessionId.delete(session.fileHash); - - for (const batch of batchSessions.values()) { - const target = batch.items.find((item) => item.fileHash === session.fileHash); - if (target) { - target.status = 'COMPLETE'; - target.fileId = created.id; - batch.updatedAt = new Date().toISOString(); - } - } - - addLog('file_upload', { fileId: created.id, fileName: created.name, size: created.size || 0 }); - addNotification(`Upload complete: ${created.name}`, true); + const mergeRequest = JSON.parse(options.body || '{}'); + const now = new Date().toISOString(); + const jobId = `job_${Mock.Random.guid()}`; + const job = { + jobId, + taskType: 'task.upload_merge', + status: 'pending', + priority: 100, + payload: { + userId: 1, + uploadId, + mergeRequest, + }, + result: {}, + errorMessage: null as string | null, + attempt: 0, + maxAttempts: 5, + scheduledAt: now, + startedAt: null as string | null, + finishedAt: null as string | null, + traceId: `mock-${jobId}`, + idempotencyKey: `upload:1:${uploadId}:merge:mock`, + cancelRequestedAt: null as string | null, + requestedBy: '1', + createdAt: now, + updatedAt: now, + }; + mockJobs[jobId] = job as any; + + setTimeout(() => { + void (async () => { + const runningAt = new Date().toISOString(); + job.status = 'running'; + job.startedAt = runningAt; + job.updatedAt = runningAt; + + try { + const session = sessions.get(uploadId); + if (!session) { + throw new Error('Upload session not found'); + } + + const sortedIndexes = [...session.uploadedChunkIndexes].sort((a, b) => a - b); + const sortedChunks = sortedIndexes.map((index) => session.chunks.get(index)).filter(Boolean) as Blob[]; + if (!sortedChunks.length) { + throw new Error('No uploaded chunks found'); + } + + const mergedBlob = new Blob(sortedChunks, { type: session.mimeType }); + const buffer = await mergedBlob.arrayBuffer(); + const base64Content = arrayBufferToBase64(buffer); + + const created = vfsApi.createFile( + session.parentId, + session.fileName, + mergedBlob.size, + session.mimeType, + base64Content, + ); + const node = vfsApi.get(created.id); + if (node) { + node.hash = session.fileHash; + node.virusStatus = 'clean'; + node.updatedAt = new Date().toISOString(); + } + + sessions.delete(uploadId); + hashToSessionId.delete(session.fileHash); + + for (const batch of batchSessions.values()) { + const target = batch.items.find((item) => item.fileHash === session.fileHash); + if (target) { + target.status = 'COMPLETE'; + target.fileId = created.id; + batch.updatedAt = new Date().toISOString(); + } + } + + addLog('file_upload', { fileId: created.id, fileName: created.name, size: created.size || 0 }); + addNotification(`Upload complete: ${created.name}`, true); + + job.status = 'succeeded'; + job.result = { + fileId: created.id, + fileName: created.name, + fileSize: created.size || 0, + mimeType: created.mimeType || 'application/octet-stream', + folderId: created.parent || 'root', + objectHash: session.fileHash, + createdAt: created.createdAt, + downloadUrl: `/api/v1/files/${created.id}/download`, + }; + job.errorMessage = null; + } catch (error) { + const message = error instanceof Error ? error.message : 'Merge failed'; + job.status = 'failed'; + job.errorMessage = message; + } finally { + const finishedAt = new Date().toISOString(); + job.finishedAt = finishedAt; + job.updatedAt = finishedAt; + } + })(); + }, 120); return { success: true, code: 201, - message: 'File created successfully', - data: { - fileId: created.id, - fileName: created.name, - fileSize: created.size || 0, - mimeType: created.mimeType || 'application/octet-stream', - folderId: created.parent || 'root', - objectHash: session.fileHash, - createdAt: created.createdAt, - downloadUrl: `/api/v1/files/${created.id}/download`, - }, + message: 'Upload merge job created', + data: job, }; }); }; diff --git a/web/src/mock/handlers/user.ts b/web/src/mock/handlers/user.ts index 7cf23ac..d57fdc0 100644 --- a/web/src/mock/handlers/user.ts +++ b/web/src/mock/handlers/user.ts @@ -1,5 +1,13 @@ import Mock from 'mockjs'; -import { addLog, addNotification, getCurrentUser, mockLogs, mockUsers, paginate } from '../state'; +import { + addLog, + addNotification, + getCurrentUser, + mockLogs, + mockRegistrationEmailDomainRules, + mockUsers, + paginate, +} from '../state'; const profileGroups = [ { @@ -14,6 +22,27 @@ const profileGroups = [ }, ]; +function extractDomain(email: string) { + const at = email.lastIndexOf('@'); + if (at < 0) return ''; + return email.slice(at + 1).trim().toLowerCase(); +} + +function isAllowedEmailDomain(email: string) { + const enabledRules = mockRegistrationEmailDomainRules.filter((item) => item.enabled); + if (!enabledRules.length) return false; + const domain = extractDomain(email); + if (!domain) return false; + return enabledRules.some((item) => { + try { + const regex = new RegExp(`^${item.pattern}$`); + return regex.test(domain); + } catch { + return false; + } + }); +} + export const setupUserMocks = () => { Mock.mock(/\/api\/v1\/users(?:\?.*)?$/, 'get', (options) => { const url = new URL(options.url, 'http://localhost'); @@ -151,6 +180,144 @@ export const setupUserMocks = () => { }; }); + Mock.mock(/\/api\/v1\/admin\/registration-email-domain-rules(?:\?.*)?$/, 'get', (options) => { + const url = new URL(options.url, 'http://localhost'); + const page = Number(url.searchParams.get('page') || 1); + const perPage = Number(url.searchParams.get('perPage') || 20); + const queryText = (url.searchParams.get('queryText') || '').trim().toLowerCase(); + const enabledRaw = url.searchParams.get('enabled'); + + let filtered = mockRegistrationEmailDomainRules.slice(); + if (enabledRaw === 'true' || enabledRaw === 'false') { + const enabledValue = enabledRaw === 'true'; + filtered = filtered.filter((item) => item.enabled === enabledValue); + } + if (queryText) { + filtered = filtered.filter((item) => + item.name.toLowerCase().includes(queryText) || item.pattern.toLowerCase().includes(queryText), + ); + } + + return { + success: true, + code: 200, + data: paginate(filtered, page, perPage), + }; + }); + + Mock.mock(/\/api\/v1\/admin\/registration-email-domain-rules$/, 'post', (options) => { + const { name, pattern, enabled } = JSON.parse(options.body || '{}'); + if (!name || !pattern) { + return { + success: false, + code: 400, + message: 'name and pattern are required', + data: null, + }; + } + + const duplicate = mockRegistrationEmailDomainRules.find( + (item) => item.name.toLowerCase() === String(name).toLowerCase(), + ); + if (duplicate) { + return { + success: false, + code: 409, + message: 'Rule name already exists', + data: null, + }; + } + try { + new RegExp(`^${String(pattern)}$`); + } catch { + return { + success: false, + code: 400, + message: 'Invalid regex pattern', + data: null, + }; + } + + const item = { + ruleId: String(Date.now()), + name: String(name), + pattern: String(pattern), + enabled: enabled !== false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + mockRegistrationEmailDomainRules.unshift(item); + addLog('admin_registration_email_domain_rule_create', { ruleId: item.ruleId }); + return { + success: true, + code: 201, + data: item, + }; + }); + + Mock.mock(/\/api\/v1\/admin\/registration-email-domain-rules\/([^/]+)$/, 'patch', (options) => { + const ruleId = (options.url.match(/\/api\/v1\/admin\/registration-email-domain-rules\/([^/]+)/) || [])[1]; + const payload = JSON.parse(options.body || '{}'); + const target = mockRegistrationEmailDomainRules.find((item) => item.ruleId === ruleId); + if (!target) { + return { + success: false, + code: 404, + message: 'Rule not found', + data: null, + }; + } + if (typeof payload.name === 'string' && payload.name.trim()) { + target.name = payload.name.trim(); + } + if (typeof payload.pattern === 'string' && payload.pattern.trim()) { + try { + new RegExp(`^${payload.pattern.trim()}$`); + } catch { + return { + success: false, + code: 400, + message: 'Invalid regex pattern', + data: null, + }; + } + target.pattern = payload.pattern.trim(); + } + if (typeof payload.enabled === 'boolean') { + target.enabled = payload.enabled; + } + target.updatedAt = new Date().toISOString(); + addLog('admin_registration_email_domain_rule_update', { ruleId: target.ruleId }); + return { + success: true, + code: 200, + data: target, + }; + }); + + Mock.mock(/\/api\/v1\/admin\/registration-email-domain-rules\/([^/]+)$/, 'delete', (options) => { + const ruleId = (options.url.match(/\/api\/v1\/admin\/registration-email-domain-rules\/([^/]+)/) || [])[1]; + const index = mockRegistrationEmailDomainRules.findIndex((item) => item.ruleId === ruleId); + if (index < 0) { + return { + success: false, + code: 404, + message: 'Rule not found', + data: null, + }; + } + mockRegistrationEmailDomainRules.splice(index, 1); + addLog('admin_registration_email_domain_rule_delete', { ruleId }); + return { + success: true, + code: 200, + data: { + ruleId, + deletedAt: new Date().toISOString(), + }, + }; + }); + Mock.mock(/\/api\/v1\/me\/profile$/, 'get', () => { const user = getCurrentUser(); @@ -181,7 +348,19 @@ export const setupUserMocks = () => { const user = getCurrentUser(); if (username) user.username = username; - if (email) user.email = email; + if (email) { + if (!isAllowedEmailDomain(String(email))) { + return { + success: false, + code: 400, + message: '邮箱后缀不被允许,请更换邮箱', + data: null, + }; + } + user.email = email; + user.emailVerified = false; + user.emailVerifiedAt = null; + } return { success: true, diff --git a/web/src/mock/state.ts b/web/src/mock/state.ts index 312180f..8789787 100644 --- a/web/src/mock/state.ts +++ b/web/src/mock/state.ts @@ -6,6 +6,7 @@ import type { LogItem } from '../types/log'; import type { User, UserPreference } from '../types/user'; import type { BackgroundJob } from '../types/file'; import type { AgentSkillItem } from '../types/skill'; +import type { RegistrationEmailDomainRuleItem } from '../types/registration-email-domain-rule'; export type MockUserRecord = User & { status: 'active' | 'suspended'; @@ -327,6 +328,8 @@ export const mockLogs: LogItem[] = Array.from({ length: 40 }).map((_, index) => }; }); +export const mockRegistrationEmailDomainRules: RegistrationEmailDomainRuleItem[] = []; + export function createMockId(prefix: string) { return `${prefix}_${Mock.Random.string('number', 6)}`; } diff --git a/web/src/mock/vfs.ts b/web/src/mock/vfs.ts index d01ff2f..c9232c3 100644 --- a/web/src/mock/vfs.ts +++ b/web/src/mock/vfs.ts @@ -15,9 +15,16 @@ export interface VfsNode { isTrashed?: boolean; deletedAt?: string; isStarred?: boolean; + starredAt?: string; hash?: string; virusStatus?: 'clean' | 'pending' | 'flagged'; thumbnailUrl?: string; + mediaOptimization?: { + status: 'queued' | 'running' | 'ready' | 'failed'; + mediaType: 'audio' | 'video'; + optimizedMimeType?: string; + updatedAt: string; + }; } export interface Vfs { @@ -25,6 +32,7 @@ export interface Vfs { } const VFS_STORAGE_KEY = import.meta.env.VFS_STORAGE_KEY || 'fileflash-vfs'; +export const STARRED_ITEMS_LIMIT = 20; function nowIso() { return new Date().toISOString(); @@ -76,6 +84,7 @@ const initialVfs: Vfs = { updatedAt: nowIso(), permission: 'owner', isStarred: true, + starredAt: nowIso(), hash: 'mock-hash-file1', virusStatus: 'clean', }, @@ -164,7 +173,14 @@ const initialVfs: Vfs = { updatedAt: nowIso(), permission: 'owner', isStarred: true, + starredAt: nowIso(), virusStatus: 'clean', + mediaOptimization: { + status: 'ready', + mediaType: 'audio', + optimizedMimeType: 'audio/mp4', + updatedAt: nowIso(), + }, }, file9: { id: 'file9', @@ -177,6 +193,11 @@ const initialVfs: Vfs = { updatedAt: nowIso(), permission: 'owner', virusStatus: 'clean', + mediaOptimization: { + status: 'running', + mediaType: 'video', + updatedAt: nowIso(), + }, }, }; @@ -475,13 +496,23 @@ export const vfsApi = { } node.isStarred = isStarred; + node.starredAt = isStarred ? nowIso() : undefined; node.updatedAt = nowIso(); saveVfs(); return node; }, getStarred: (): VfsNode[] => { - return Object.values(vfs).filter((node) => !node.isTrashed && node.id !== 'root' && node.isStarred); + return Object.values(vfs) + .filter((node) => !node.isTrashed && node.id !== 'root' && node.isStarred) + .sort((left, right) => { + const leftTs = new Date(left.starredAt || left.updatedAt || left.createdAt).getTime(); + const rightTs = new Date(right.starredAt || right.updatedAt || right.createdAt).getTime(); + if (rightTs !== leftTs) { + return rightTs - leftTs; + } + return right.id.localeCompare(left.id); + }); }, delete: (id: string) => { diff --git a/web/src/pages/__dev/Library.vue b/web/src/pages/__dev/Library.vue index 8cb2bee..088c4d4 100644 --- a/web/src/pages/__dev/Library.vue +++ b/web/src/pages/__dev/Library.vue @@ -3,13 +3,24 @@ import { ref } from 'vue'; import * as A from '../../components/atoms'; import * as M from '../../components/molecules'; import * as F from '../../components/organisms/files'; +import * as Sh from '../../components/organisms/sharing'; +import * as Tr from '../../components/organisms/trash'; +import * as Sa from '../../components/organisms/share'; +import * as Ag from '../../components/organisms/agent'; +import { AuthForm } from '../../components/organisms/auth'; +import type { AuthSubmitPayload } from '../../components/organisms/auth'; import { useFilePreview } from '../../composables/useFilePreview'; import type { FileItem } from '../../types/file'; +import type { AgentTurn, Session } from '../../composables/useAgentSession'; +import type { AgentSkillItem, ImportAgentSkillResult } from '../../types/skill'; const sections = [ 'Tokens', 'Atoms · Text', 'Atoms · Numbers', 'Atoms · Visual', 'Atoms · Form', 'Molecules · Action', 'Molecules · Input', 'Molecules · Display', 'Molecules · Nav', - 'Organisms · Files', + 'Molecules · Forms', + 'Organisms · Files', 'Organisms · Sharing', 'Organisms · Trash', 'Organisms · Share', + 'Organisms · Auth', + 'Organisms · Agent', ] as const; type Section = typeof sections[number]; @@ -46,6 +57,56 @@ const filesSelection = ref(new Set (['demo-a'])); const filesRenamingId = ref (null); const filesRenameValue = ref(''); +// Sharing / Trash / Share demo state +const sharedSelection = ref(new Set ()); +const sharedDemoItems = [ + { itemType: 'file' as const, id: 's1', name: 'plan.docx', size: 4096, sharedBy: 'alice', permission: 'read' as const, sharedAt: '2026-05-09T10:00:00Z' }, + { itemType: 'folder' as const, id: 's2', name: 'designs', size: 0, sharedBy: 'bob', permission: 'write' as const, sharedAt: '2026-05-08T12:30:00Z' }, +]; +const linksDemoItems = [ + { shareId: 'l1', shareLink: 'abc123', itemType: 'file' as const, itemInfo: { id: 'f1', name: 'report.pdf', size: 1024, mimeType: 'application/pdf' }, settings: { passwordProtected: false, expireAt: null, allowDownload: true, allowPreview: true }, createdAt: '2026-05-01T00:00:00Z', visitCount: 12, downloadCount: 4 }, +]; +const trashDemoItems = [ + { itemType: 'file' as const, id: 't1', name: 'draft.md', originalPath: '/notes', size: 200, deletedAt: '2026-05-09T18:00:00Z', autoDeleteAt: '2026-06-08T18:00:00Z', daysUntilPermanentDelete: 27, canRestore: true, restoreConflicts: false }, + { itemType: 'file' as const, id: 't2', name: 'expired.zip', originalPath: '/archives', size: 1024, deletedAt: '2026-05-05T08:00:00Z', autoDeleteAt: '2026-05-15T08:00:00Z', daysUntilPermanentDelete: 3, canRestore: false, restoreConflicts: true }, +]; +const shareDemo = { shareId: 's', shareLink: 'xyz789', itemType: 'file' as const, itemInfo: { id: 'f', name: 'big-report.pdf', size: 1_245_184, mimeType: 'application/pdf' }, settings: { passwordProtected: true, expireAt: '2026-12-01', allowDownload: true, allowPreview: true }, createdAt: '2026-05-01T00:00:00Z' }; +const sharePassword = ref(''); + +// Auth organism demo +const authMode = ref<'login' | 'register' | 'forgot'>('login'); +const authError = ref(''); +const authSuccess = ref(''); +const lastAuthSubmit = ref(''); +const authLabelsByMode = { + login: { + identifier: 'Username or Email', identifierPlaceholder: 'Enter username or email', + password: 'Password', passwordPlaceholder: 'Enter password', + rememberMe: 'Remember me', + }, + register: { + username: 'Username', usernamePlaceholder: 'Enter username', + email: 'Email', emailPlaceholder: 'Enter email', + password: 'Password', passwordPlaceholder: 'Enter password', + confirmPassword: 'Confirm', confirmPasswordPlaceholder: 'Re-enter password', + }, + forgot: { + email: 'Email', emailPlaceholder: 'Enter email', + }, +} as const; +const authTitleByMode = { login: 'Sign in to FileFlash', register: 'Create account', forgot: 'Reset password' } as const; +const authSubmitLabelByMode = { login: 'SIGN IN', register: 'REGISTER', forgot: 'SEND LINK' } as const; +const authModeOpts = [ + { value: 'login', label: 'LOGIN' }, + { value: 'register', label: 'REGISTER' }, + { value: 'forgot', label: 'FORGOT' }, +]; +function onAuthSubmit(payload: AuthSubmitPayload) { + lastAuthSubmit.value = JSON.stringify(payload, null, 2); + authSuccess.value = 'Demo: payload captured'; + authError.value = ''; +} + const demoItems = [ { id: 'demo-a', name: 'README.md', itemType: 'file' as const, @@ -132,6 +193,79 @@ const swatches = [ '--status-error', '--status-info', ]; + +// — P7 demo state: Molecules · Forms — +const modalOpen = ref(false); +const pgPage = ref(2); +const selectVal = ref ('confirm'); +const selectOpts = [ + { value: 'planOnly', label: 'PLAN ONLY' }, + { value: 'confirm', label: 'CONFIRM' }, + { value: 'autopilot', label: 'AUTOPILOT' }, +]; + +// — P7 demo state: Organisms · Agent — +const agSessions: Session[] = [ + { id: 's1', title: 'Organize my screenshots', messages: [], createdAt: '2026-05-19T08:00:00Z', updatedAt: '2026-05-19T08:00:00Z' }, + { id: 's2', title: 'Find duplicates in /photos', messages: [], createdAt: '2026-05-18T08:00:00Z', updatedAt: '2026-05-18T08:00:00Z' }, + { id: 's3', title: 'Tag invoices', messages: [], createdAt: '2026-05-15T08:00:00Z', updatedAt: '2026-05-15T08:00:00Z' }, +]; +const agActiveId = ref ('s1'); + +const agTaskInput = ref('Sort by year then month'); +const agPolicy = ref<'planOnly' | 'confirm' | 'autopilot'>('confirm'); + +const makeTurn = (status: 'pending' | 'running' | 'succeeded' | 'failed' | 'canceled', withPlan = true): AgentTurn => ({ + user: { id: `u-${status}`, role: 'user', content: 'Sort by year then month', status: 'succeeded', timestamp: '2026-05-20T00:00:00Z' }, + agent: { + id: `a-${status}`, role: 'agent', content: '', status, + timestamp: '2026-05-20T00:00:00Z', + planHash: withPlan && status !== 'pending' ? 'h-' + status : undefined, + planResult: withPlan && status !== 'pending' ? { + planJobId: 'job-' + status, planHash: 'h-' + status, + chosenSkill: { id: 'sk', name: 'Tidy Photos' }, + proposedActions: [ + { step: 1, tool: 'list', input: { path: '/photos' }, sideEffect: 'read' }, + { step: 2, tool: 'move', input: { from: '/photos', to: '/photos/2026/05' }, sideEffect: 'write' }, + ], + summary: 'Group photos by year/month under /photos/YYYY/MM', + requiresConfirmation: true, + costEstimate: { tokens: 320, toolCalls: 4, durationSecEstimate: 8 }, + } : undefined, + executeResult: status === 'succeeded' ? { + planJobId: 'job-' + status, executeJobId: 'exec-' + status, + summary: 'Moved 24 files into 3 folders.', + appliedActions: 24, skippedActions: 0, + warnings: ['Skipped 2 files (read-only)'], finishedAt: '2026-05-20T00:00:00Z', + } : undefined, + errorMessage: status === 'failed' ? 'Plan failed: tool not allowed.' : undefined, + }, +}); + +const agTurns: AgentTurn[] = [ + makeTurn('pending', false), + makeTurn('running'), + makeTurn('succeeded'), + makeTurn('failed'), + makeTurn('canceled'), +]; +const agFocusedId = ref ('a-succeeded'); +const agFocusedTurn = agTurns.find((t) => t.agent.id === agFocusedId.value) ?? null; + +const agSkill = (visibility: 'global' | 'private', name: string, key: string): AgentSkillItem => ({ + skillId: 'id-' + key, skillKey: key, name, description: 'Move and tag files by simple rules.', + triggersText: 'organize, tidy', toolWhitelist: ['list', 'move', 'tag'], + planTemplate: {}, inputsSchema: {}, outputsSchema: {}, + visibility, ownerUserId: 'u-1', createdAt: '', updatedAt: '', +}); +const agSkillGlobal = agSkill('global', 'Tidy Photos', 'tidy.photos'); +const agSkillPrivate = agSkill('private', 'My Invoice Sorter', 'me.invoices'); + +const agEditorOpen = ref(false); +const agImportResults: ImportAgentSkillResult[] = [ + { skillKey: 'tidy.photos', action: 'updated' }, + { skillKey: 'classify.invoices', action: 'created' }, +]; @@ -289,6 +423,36 @@ const swatches = [ + +Molecules · Forms + +Modal +++Open modal ++ EXAMPLE DIALOG + + +This is the modal body — Teleport-rendered, scrim-dismissable, Esc closes.
+ +Cancel +Confirm + +Pagination · 50 items, page-size 10 ++ + FileDrop +++ +Drop JSON or click to browse +Disabled state +Select ++ + + Organisms · Files @@ -342,6 +506,160 @@ const swatches = [Last interaction: {{ filesLastInteraction || '—' }} + + +Organisms · Sharing + +SharedReceivedTable +{ const next = new Set(sharedSelection); next.has(id) ? next.delete(id) : next.add(id); sharedSelection = next; }" + @toggle-all="(n) => sharedSelection = n ? new Set(sharedDemoItems.map((i) => i.id)) : new Set()" + @accept="() => {}" /> + + SharedReceivedTable · empty ++ + SharedLinksTable +{}" @delete="() => {}" /> + + SharedBatchBar · count = 2 +{}" @clear="() => {}" /> + + + +Organisms · Trash + +TrashTable +{}" @permanent-delete="() => {}" /> + + TrashTable · empty ++ + + +Organisms · Share + +ShareInfoCard ++ + ShareAccessPanel · password mode +{}" /> + + ShareAccessPanel · open mode +{}" @request-access="() => {}" /> + + ShareActionsPanel · file +{}" @download="() => {}" @save="() => {}" /> + + ShareActionsPanel · folder +{}" @download="() => {}" @save="() => {}" /> + + + +Organisms · Auth + +Mode +(authMode = v as 'login' | 'register' | 'forgot')" + /> + + ++ ++ + ++ Mock + admin / admin123 ++ + + Forgot password + + + Demo footer + +Last payload +{{ lastAuthSubmit }}+Submit the form above to see the typed payload. ++ @@ -359,4 +677,47 @@ const swatches = [ .sw { display: flex; align-items: center; gap: 10px; padding: 10px; border: 1px solid var(--border-subtle); } .sw-block { width: 28px; height: 28px; border: 1px solid var(--border-subtle); flex-shrink: 0; } code { font-family: var(--font-mono); font-size: var(--text-data); color: var(--ac); } +.auth-demo { + max-width: 420px; + padding: var(--sp-xl); + background: var(--surface-raised); + border: 1px solid var(--border-default); + margin-top: 16px; +} +.auth-demo-hint { + display: flex; flex-direction: column; gap: 2px; + padding: var(--sp-sm) var(--sp-md); + background: var(--surface-inset); + border: 1px solid var(--border-subtle); + font-family: var(--font-mono); + font-size: var(--text-small); + color: var(--text-secondary); + margin-top: var(--sp-sm); +} +.auth-demo-hint strong { + color: var(--text-primary); + font-weight: var(--weight-semibold); + font-size: var(--text-label); + letter-spacing: var(--tracking-wider); + text-transform: uppercase; +} +.auth-demo-link { color: var(--ac); font-size: var(--text-small); cursor: pointer; } +.auth-demo-pre { + background: var(--surface-inset); + border: 1px solid var(--border-subtle); + padding: 12px; + font-family: var(--font-mono); + font-size: var(--text-data); + color: var(--text-secondary); + max-width: 420px; + white-space: pre-wrap; +} +.agent-demo { + padding: var(--sp-md); + background: var(--surface-base); + border: 1px solid var(--border-subtle); + display: block; +} +.agent-demo--col { display: flex; flex-direction: column; gap: var(--sp-md); } +.agent-demo--row { display: flex; gap: var(--sp-md); align-items: flex-start; } diff --git a/web/src/pages/agent/AgentLayout.vue b/web/src/pages/agent/AgentLayout.vue index 002f629..bb4fdab 100644 --- a/web/src/pages/agent/AgentLayout.vue +++ b/web/src/pages/agent/AgentLayout.vue @@ -1,80 +1,52 @@Organisms · Agent + +SessionList — empty +++ ++ SessionList — 3 items +++ +(agActiveId = id)" + /> + TurnEntry — all 5 status variants +++ ++ PlanInspector — empty + populated +++ ++ + TaskInputDock — idle + disabled +++ ++ + SkillCard — global + private (editable) +++ ++ + SkillEditorPanel +++Open editor ++ + SkillImportPanel — with results ++ -diff --git a/web/src/pages/agent/skills/AgentSkills.vue b/web/src/pages/agent/skills/AgentSkills.vue index 1f6098c..b804cc8 100644 --- a/web/src/pages/agent/skills/AgentSkills.vue +++ b/web/src/pages/agent/skills/AgentSkills.vue @@ -1,514 +1,92 @@ -- --{{ t('agent.pageTitle') }}
-{{ t('agent.pageDescription') }}
-- +- {{ t('agent.nav.workspace') }} - -- {{ t('agent.nav.skills') }} - -+ [ FILEFLASH · AGENT ] + - -+ ++ ++ -- + + +++ +Registration Email Domain Rules
++ + +++ + + + ++ ++++++ {{ rule.name }} + {{ rule.pattern }} +++ {{ rule.enabled ? 'enabled' : 'disabled' }} + + + ++Send Notification
@@ -556,6 +672,22 @@ onMounted(loadDashboardData); min-height: 72px; } +.notice-form input[type="text"] { + border: 1px solid var(--color-border); + border-radius: 8px; + background-color: var(--color-bg-primary); + padding: 8px; + min-width: 180px; +} + +.inline-switch { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--color-text-secondary); + font-size: 13px; +} + .badge { font-size: 12px; padding: 3px 8px; diff --git a/web/src/pages/files/MyFiles.spec.ts b/web/src/pages/files/MyFiles.spec.ts new file mode 100644 index 0000000..c704d77 --- /dev/null +++ b/web/src/pages/files/MyFiles.spec.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { nextTick } from 'vue'; +import { mount } from '../../test/mount'; +import MyFiles from './MyFiles.vue'; +import { useFileStore } from '../../store/file'; +import { eventBus } from '../../utils/eventBus'; + +const { + openPreviewMock, + getFolderContentsMock, + getFolderPathMock, + toggleFileStarMock, + toggleFolderStarMock, + toastMock, +} = vi.hoisted(() => ({ + openPreviewMock: vi.fn(), + getFolderContentsMock: vi.fn(async () => ({ items: [] })), + getFolderPathMock: vi.fn(async () => ({ pathItems: [{ folderId: 'root', name: 'My Files' }] })), + toggleFileStarMock: vi.fn(async () => ({})), + toggleFolderStarMock: vi.fn(async () => ({})), + toastMock: vi.fn(), +})); + +vi.mock('../../composables/useFilePreview', () => ({ + useFilePreview: () => ({ + openPreview: openPreviewMock, + }), +})); + +vi.mock('../../api/file', () => ({ + toggleFileStar: toggleFileStarMock, +})); + +vi.mock('../../api/folder', () => ({ + getFolderContents: getFolderContentsMock, + getFolderPath: getFolderPathMock, + toggleFolderStar: toggleFolderStarMock, +})); + +vi.mock('../../utils/ui', () => ({ + ui: { + toast: toastMock, + confirm: vi.fn(), + promptText: vi.fn(), + copyText: vi.fn(), + resolveConfirm: vi.fn(), + resolvePrompt: vi.fn(), + dismissToast: vi.fn(), + }, + uiState: { + confirm: null, + prompt: null, + toasts: [], + }, +})); + +describe('MyFiles', () => { + beforeEach(() => { + openPreviewMock.mockReset(); + getFolderContentsMock.mockClear(); + getFolderPathMock.mockClear(); + toggleFileStarMock.mockReset(); + toggleFolderStarMock.mockReset(); + toastMock.mockReset(); + }); + + it('activating archive file opens extract dialog and skips normal preview', async () => { + const wrapper = mount(MyFiles); + const fileStore = useFileStore(); + fileStore.items = [{ + itemType: 'file', + id: 'zip-file', + name: 'archive.zip', + size: 123, + mimeType: 'application/zip', + ownerName: 'tester', + createdAt: '2026-05-12T00:00:00Z', + updatedAt: '2026-05-12T00:00:00Z', + folderId: 'root', + }]; + await nextTick(); + + const row = wrapper.findAll('.row')[0]; + await row.trigger('dblclick'); + await nextTick(); + + expect(openPreviewMock).not.toHaveBeenCalled(); + const dialog = wrapper.findComponent({ name: 'ExtractArchiveDialog' }); + expect(dialog.props('isVisible')).toBe(true); + expect((dialog.props('file') as { id: string }).id).toBe('zip-file'); + }); + + it('activating non-archive file keeps normal preview behavior', async () => { + const wrapper = mount(MyFiles); + const fileStore = useFileStore(); + fileStore.items = [{ + itemType: 'file', + id: 'txt-file', + name: 'notes.txt', + size: 123, + mimeType: 'text/plain', + ownerName: 'tester', + createdAt: '2026-05-12T00:00:00Z', + updatedAt: '2026-05-12T00:00:00Z', + folderId: 'root', + }]; + await nextTick(); + + const row = wrapper.findAll('.row')[0]; + await row.trigger('dblclick'); + await nextTick(); + + expect(openPreviewMock).toHaveBeenCalledTimes(1); + expect(openPreviewMock.mock.calls[0][0]).toMatchObject({ id: 'txt-file' }); + const dialog = wrapper.findComponent({ name: 'ExtractArchiveDialog' }); + expect(dialog.props('isVisible')).toBe(false); + }); + + it('star success emits refresh-file-tree event', async () => { + const wrapper = mount(MyFiles); + const fileStore = useFileStore(); + const emitSpy = vi.spyOn(eventBus, 'emit'); + + const fileItem = { + itemType: 'file' as const, + id: 'txt-file', + name: 'notes.txt', + size: 123, + mimeType: 'text/plain', + ownerName: 'tester', + createdAt: '2026-05-12T00:00:00Z', + updatedAt: '2026-05-12T00:00:00Z', + folderId: 'root', + isStarred: false, + }; + fileStore.items = [fileItem]; + await nextTick(); + + const table = wrapper.findComponent({ name: 'FileTable' }); + table.vm.$emit('toggleStar', fileItem); + await nextTick(); + await Promise.resolve(); + + expect(toggleFileStarMock).toHaveBeenCalledWith('txt-file', true); + expect(emitSpy).toHaveBeenCalledWith('refresh-file-tree'); + emitSpy.mockRestore(); + }); + + it('star failure shows toast with backend message', async () => { + toggleFileStarMock.mockRejectedValueOnce({ + response: { data: { message: '已达收藏上限 20' } }, + }); + + const wrapper = mount(MyFiles); + const fileStore = useFileStore(); + + const fileItem = { + itemType: 'file' as const, + id: 'txt-file', + name: 'notes.txt', + size: 123, + mimeType: 'text/plain', + ownerName: 'tester', + createdAt: '2026-05-12T00:00:00Z', + updatedAt: '2026-05-12T00:00:00Z', + folderId: 'root', + isStarred: false, + }; + fileStore.items = [fileItem]; + await nextTick(); + + const table = wrapper.findComponent({ name: 'FileTable' }); + table.vm.$emit('toggleStar', fileItem); + await nextTick(); + await Promise.resolve(); + + expect(toastMock).toHaveBeenCalledTimes(1); + expect((toastMock.mock.calls[0]?.[0] as { type: string; message: string }).type).toBe('error'); + expect((toastMock.mock.calls[0]?.[0] as { type: string; message: string }).message).toContain('已达收藏上限 20'); + }); +}); diff --git a/web/src/pages/files/MyFiles.vue b/web/src/pages/files/MyFiles.vue index e16cfda..9ed1642 100644 --- a/web/src/pages/files/MyFiles.vue +++ b/web/src/pages/files/MyFiles.vue @@ -20,6 +20,8 @@ import ShareDialog from '../../components/common/ShareDialog.vue'; import ExtractArchiveDialog from './components/ExtractArchiveDialog.vue'; import { eventBus } from '../../utils/eventBus'; import type { ContentItem, FileItem } from '../../types/file'; +import { isArchiveFileName } from '../../utils/archive'; +import { ui } from '../../utils/ui'; const fileStore = useFileStore(); const localeStore = useLocaleStore(); @@ -71,18 +73,41 @@ const onItemActivate = (item: ContentItem) => { fileStore.navigateToFolder(item.id); return; } + if (isArchiveFileName(item.name)) { + fileToExtract.value = item; + isExtractDialogVisible.value = true; + return; + } fileStore.previewFile = item; - openPreview(item as FileItem); + openPreview(item); }; const onClearSelection = () => selection.clear(); +const resolveStarErrorMessage = (error: unknown): string => { + const maybeResponseMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message; + if (typeof maybeResponseMessage === 'string' && maybeResponseMessage.trim()) { + return maybeResponseMessage.trim(); + } + if (error instanceof Error && error.message.trim()) { + return error.message.trim(); + } + return t('files.star.toast.unknownError'); +}; + const onToggleStar = async (item: ContentItem) => { const next = !item.isStarred; try { if (item.itemType === 'file') await toggleFileStar(item.id, next); else await toggleFolderStar(item.id, next); const f = fileStore.items.find((e) => e.id === item.id); if (f) f.isStarred = next; - } catch (e) { console.error('Failed to update star status', e); } + eventBus.emit('refresh-file-tree'); + } catch (e) { + const reason = resolveStarErrorMessage(e); + ui.toast({ + type: 'error', + message: t('files.star.toast.failed').replace('{reason}', reason), + }); + } }; const navigateBC = (id: string) => { isSearching.value = false; searchQuery.value = ''; searchResults.value = []; fileStore.navigateToFolder(id); }; @@ -90,7 +115,7 @@ let timer: number | null = null; watch(() => [settings.value.autoRefreshInterval, currentFolderId.value], () => { if (timer !== null) { window.clearInterval(timer); timer = null; } const s = Number(settings.value.autoRefreshInterval || 0); if (s <= 0) return; - timer = window.setInterval(() => fileStore.fetchFolderContents(currentFolderId.value || 'root'), s * 1000); + timer = window.setInterval(() => fileStore.fetchFolderContents(currentFolderId.value || 'root', { silent: true }), s * 1000); }, { immediate: true }); onMounted(() => { fileStore.fetchFolderContents('root'); eventBus.on('move-items', drag.onSidebarMove); eventBus.on('search-files', onSearchEvt); }); @@ -113,20 +138,14 @@ onUnmounted(() => { eventBus.off('move-items', drag.onSidebarMove); eventBus.off -diff --git a/web/src/pages/files/components/ExtractArchiveDialog.spec.ts b/web/src/pages/files/components/ExtractArchiveDialog.spec.ts new file mode 100644 index 0000000..23f9fc2 --- /dev/null +++ b/web/src/pages/files/components/ExtractArchiveDialog.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest'; +import { mount } from '../../../test/mount'; +import ExtractArchiveDialog from './ExtractArchiveDialog.vue'; +import MoveItemDialog from '../../../components/common/MoveItemDialog.vue'; + +vi.mock('../../../store/file', () => ({ + useFileStore: () => ({ + navigateToFolder: vi.fn(), + }), +})); + +vi.mock('../../../utils/eventBus', () => ({ + eventBus: { + emit: vi.fn(), + }, +})); + +vi.mock('../../../api/file', () => ({ + requestArchivePreview: vi.fn(async () => ({ jobId: 'job-preview-1', status: 'pending', result: {} })), + requestArchiveExtract: vi.fn(async () => ({ jobId: 'job-extract-1', status: 'pending', result: {} })), +})); + +vi.mock('../../../api/folder', () => ({ + getFolderContents: vi.fn(async () => ({ items: [] })), + getFolderPath: vi.fn(async () => ({ pathItems: [{ folderId: 'root', name: 'My Files' }] })), +})); + +vi.mock('../../../api/job', () => ({ + getJob: vi.fn(async () => ({ + jobId: 'job-preview-1', + status: 'succeeded', + result: { + entries: [], + summary: { + fileCount: 0, + dirCount: 0, + totalUncompressedBytes: 0, + truncated: false, + }, + }, + })), +})); + +describe('ExtractArchiveDialog', () => { + it('uses modern tree variant for destination folder picker', () => { + const wrapper = mount(ExtractArchiveDialog, { + props: { + isVisible: true, + file: { + itemType: 'file', + id: 'zip-file', + name: 'archive.zip', + size: 123, + mimeType: 'application/zip', + ownerName: 'tester', + createdAt: '2026-05-12T00:00:00Z', + updatedAt: '2026-05-12T00:00:00Z', + folderId: 'root', + }, + currentFolderId: 'root', + }, + }); + + const picker = wrapper.findComponent(MoveItemDialog); + expect(picker.exists()).toBe(true); + expect(picker.props('treeVariant')).toBe('modern'); + }); +}); diff --git a/web/src/pages/files/components/ExtractArchiveDialog.vue b/web/src/pages/files/components/ExtractArchiveDialog.vue index b6f894e..4634527 100644 --- a/web/src/pages/files/components/ExtractArchiveDialog.vue +++ b/web/src/pages/files/components/ExtractArchiveDialog.vue @@ -301,6 +301,7 @@ onUnmounted(() => { :is-visible="isFolderPickerVisible" :item-to-move="file" :enable-share-handling="false" + tree-variant="modern" title="Select destination folder" prompt="Choose a folder to extract into:" confirm-text="Select" diff --git a/web/src/pages/files/components/FileItemsView.vue b/web/src/pages/files/components/FileItemsView.vue index 4b39852..d0dc7dd 100644 --- a/web/src/pages/files/components/FileItemsView.vue +++ b/web/src/pages/files/components/FileItemsView.vue @@ -268,6 +268,7 @@ const isArchiveFile = (file: FileItem) => { width: 22px; height: 22px; object-fit: contain; + flex-shrink: 0; } .star-btn { @@ -282,6 +283,7 @@ const isArchiveFile = (file: FileItem) => { align-items: center; justify-content: center; padding: 0; + flex-shrink: 0; transition: border-color 0.2s ease, transform 0.2s ease; } @@ -350,8 +352,8 @@ const isArchiveFile = (file: FileItem) => { } .star-menu-btn .star-icon { - width: 14px; - height: 14px; + width: 16px; + height: 16px; } .star-menu-btn .star-icon.active path { @@ -409,6 +411,7 @@ const isArchiveFile = (file: FileItem) => { height: 62px; object-fit: contain; margin-top: 8px; + flex-shrink: 0; } .grid-name { diff --git a/web/src/pages/forgot-password/ForgotPassword.vue b/web/src/pages/forgot-password/ForgotPassword.vue index 1fec7b6..04fb914 100644 --- a/web/src/pages/forgot-password/ForgotPassword.vue +++ b/web/src/pages/forgot-password/ForgotPassword.vue @@ -1,151 +1,47 @@ -- {{ t('files.drag.dropToUpload') }}-- - - { eventBus.off('move-items', drag.onSidebarMove); eventBus.off @start-move="a.startMove" @start-share="a.startShare" @delete="a.handleDelete" @dragstart="drag.onDragItemStart" @drop-on-folder="drag.onFolderDrop" @sort="setSort" /> + + + { eventBus.off('move-items', drag.onSidebarMove); eventBus.off @close="a.isShareDialogVisible.value = false" /> + + + + -+ + - - diff --git a/web/src/pages/login/Login.vue b/web/src/pages/login/Login.vue index 3392869..c22443e 100644 --- a/web/src/pages/login/Login.vue +++ b/web/src/pages/login/Login.vue @@ -1,267 +1,100 @@ -- - - - - -找回密码
-输入注册邮箱,我们将发送密码重置链接。
--diff --git a/web/src/pages/register/Register.vue b/web/src/pages/register/Register.vue index e48b549..a186004 100644 --- a/web/src/pages/register/Register.vue +++ b/web/src/pages/register/Register.vue @@ -1,225 +1,56 @@ -- Sign in to FileFlash
-Manage cloud files, team sharing, recycle restore, and admin operations.
- -++ + diff --git a/web/src/pages/privacy/PrivacyPolicy.vue b/web/src/pages/privacy/PrivacyPolicy.vue index 51ee989..9fe30ac 100644 --- a/web/src/pages/privacy/PrivacyPolicy.vue +++ b/web/src/pages/privacy/PrivacyPolicy.vue @@ -9,7 +9,7 @@ const { locale } = storeToRefs(localeStore); const isZh = computed(() => locale.value === 'zh-CN'); -const LAST_UPDATED = '2026-05-11'; +const LAST_UPDATED = '2026-05-20'; @@ -114,6 +114,17 @@ const LAST_UPDATED = '2026-05-11';+ + Mock Test Accounts admin / admin123 (administrator) demo / demo123 (regular user)- - - - - + +Forgot password + + Need an account?Create one - -12. 联系我们
如你对本隐私政策有任何疑问或请求,请通过本服务内提供的支持/反馈渠道与我们联系。
+ ++ @@ -205,6 +216,17 @@ const LAST_UPDATED = '2026-05-11';13. Agent 自动化处理(专项)
+本服务可能提供 Agent / 自动化任务能力。本节作为对前述条款的补充,说明 Agent 功能涉及的数据处理。
++
+- 13.1 处理数据范围:为生成与执行 Agent 任务,我们可能处理你的指令文本、当前所在路径与所选条目的元数据、Skill 配置、以及在你的 dataPolicy 允许范围内被读取的文件内容。元数据示例:文件名、大小、类型、时间戳、目录结构。
+- 13.2 AI 服务提供方:本服务的 Agent 实现可能由部署方配置使用第三方 AI 服务(例如大型语言模型 API)。若启用,第 13.1 项所述的部分数据可能被传输至该 AI 服务方进行处理。具体提供方、所在地区与该方的数据使用条款取决于部署,请向部署方索取相应说明。如部署方未启用第三方 AI 服务,则相关处理在本服务边界内完成。
+- 13.3 Agent 任务日志与可审计性:为支持审计、故障排查与计费/配额核算,我们会在合理期限内保留 planHash、提议动作(proposedActions)、已应用动作(appliedActions)、warnings、token 与工具调用次数等任务元数据。这些日志通常不包含完整的文件内容。
+- 13.4 你的控制:你可在每次任务中选择执行策略(planOnly / confirm / autopilot);你可以取消执行中的任务;选择 planOnly 时不会产生任何文件改动;通过 dataPolicy 你可控制是否允许 Agent 读取文件内容以及读取范围。
+12. Contact
If you have any questions or requests regarding this Privacy Policy, please contact us via the support/feedback channel available in the Service.
+ ++ 13. Agent Automated Processing (Supplemental)
+The Service may provide Agent / automated-task capabilities. This section supplements the foregoing terms and describes the data processing involved in Agent features.
++
+- 13.1 Data processed. To plan and execute Agent tasks, we may process your instruction text, your current path, metadata of selected items, Skill configuration, and — within the limits of your dataPolicy — file content that is read. Metadata examples: file name, size, type, timestamps, and folder structure.
+- 13.2 AI service providers. The Agent implementation may, depending on deployer configuration, use third-party AI services (for example, large language model APIs). If enabled, some of the data described in 13.1 may be transmitted to that AI service for processing. The specific provider, region, and that party's data-handling terms depend on the deployment; please request such information from the deployer. If the deployer does not enable third-party AI services, the related processing occurs within the Service's boundary.
+- 13.3 Agent task logs and auditability. To support auditing, troubleshooting, and billing/quota accounting, we retain task metadata for a reasonable period, including planHash, proposedActions, appliedActions, warnings, token counts, and tool-call counts. These logs typically do not contain full file content.
+- 13.4 Your controls. You may select the execution policy for each task (planOnly / confirm / autopilot); you may cancel a task during execution; planOnly produces no file changes; via dataPolicy you control whether and to what extent the Agent may read file content.
+-+ + - - diff --git a/web/src/pages/settings/Settings.vue b/web/src/pages/settings/Settings.vue index 7ec48da..917710c 100644 --- a/web/src/pages/settings/Settings.vue +++ b/web/src/pages/settings/Settings.vue @@ -88,7 +88,7 @@ const resetSettings = async () => { }); if (!confirmed) return; settingsStore.resetSettings(); - ui.toast({ type: 'success', message: 'Settings reset.' }); + ui.toast({ type: 'success', message: t('settings.resetSuccess') }); }; const exportSettings = () => { @@ -149,8 +149,8 @@ const importSettings = (event: Event) => {- - - - - -创建 FileFlash 账号
-注册后即可上传、共享、恢复与管理你的文件
--主题
-选择您喜欢的应用主题
+{{ t('settings.appearance.theme.label') }}
+{{ t('settings.appearance.theme.description') }}
@@ -195,8 +195,8 @@ const importSettings = (event: Event) => {@@ -160,7 +160,7 @@ const importSettings = (event: Event) => { @click="themeStore.setTheme('light')" > - 浅色 + {{ t('settings.appearance.theme.light') }}-紧凑模式
-减少界面间距,显示更多内容
+{{ t('settings.appearance.compactMode.label') }}
+{{ t('settings.appearance.compactMode.description') }}