From f7746238762160d0a83629944027eea5957dcea5 Mon Sep 17 00:00:00 2001
From: 0neStep <146049978+AperturePlus@users.noreply.github.com>
Date: Sat, 21 Mar 2026 15:25:28 +0800
Subject: [PATCH 1/8] deleted old sql schema files
---
docs/schema-mariadb.sql | 358 ---------
docs/schema-postgres.sql | 1624 --------------------------------------
2 files changed, 1982 deletions(-)
delete mode 100644 docs/schema-mariadb.sql
delete mode 100644 docs/schema-postgres.sql
diff --git a/docs/schema-mariadb.sql b/docs/schema-mariadb.sql
deleted file mode 100644
index 8e0556a..0000000
--- a/docs/schema-mariadb.sql
+++ /dev/null
@@ -1,358 +0,0 @@
--- mysql or mariadb
-CREATE DATABASE IF NOT EXISTS kepan
- CHARACTER SET utf8mb4
- COLLATE utf8mb4_unicode_ci;
-
-USE kepan;
-
--- 用户表
-CREATE TABLE IF NOT EXISTS `user` (
- user_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '用户ID',
- username VARCHAR(100) NOT NULL COMMENT '用户名',
- email VARCHAR(255) NOT NULL COMMENT '邮箱',
- password_hash VARCHAR(255) NOT NULL COMMENT 'hash密码',
- storage_limit BIGINT NOT NULL DEFAULT 10737418240 COMMENT '存储空间上限(10GB)',
- storage_used BIGINT NOT NULL DEFAULT 0 COMMENT '已用存储空间(由应用层维护)',
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间',
- PRIMARY KEY (user_id),
- UNIQUE KEY uk_username (username),
- UNIQUE KEY uk_email (email)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户信息表';
-
-ALTER TABLE `user` ADD COLUMN `role` VARCHAR(50) NOT NULL DEFAULT 'USER' COMMENT '用户角色 (e.g., USER, ADMIN)' AFTER `password_hash`;
--- 用户组与成员表
-CREATE TABLE IF NOT EXISTS user_group (
- id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
- name VARCHAR(100) NOT NULL UNIQUE,
- description VARCHAR(255)
-);
-
--- 用户组与成员表
-CREATE TABLE IF NOT EXISTS user_group_member (
- id INT PRIMARY KEY AUTO_INCREMENT,
- user_id BIGINT UNSIGNED NOT NULL,
- group_id BIGINT UNSIGNED NOT NULL,
- role VARCHAR(50) NOT NULL DEFAULT 'member',
- FOREIGN KEY (user_id) REFERENCES user(user_id) ON DELETE CASCADE,
- FOREIGN KEY (group_id) REFERENCES user_group(id) ON DELETE CASCADE,
- UNIQUE KEY (user_id, group_id)
-);
-
-
-CREATE TABLE IF NOT EXISTS `storage_object` (
- object_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '物理对象ID',
- object_hash CHAR(64) NOT NULL COMMENT '内容哈希 SHA256,用于去重/秒传',
- bucket_name VARCHAR(100) NOT NULL COMMENT 'MinIO bucket 名称',
- object_key VARCHAR(1024) NOT NULL COMMENT 'MinIO 对象键',
- object_size BIGINT UNSIGNED NOT NULL COMMENT '对象大小(Bytes)',
- etag VARCHAR(128) DEFAULT NULL COMMENT 'MinIO / S3 ETag',
- version_id VARCHAR(255) DEFAULT NULL COMMENT '对象版本ID,开启版本控制时使用',
- content_type VARCHAR(255) DEFAULT NULL COMMENT '对象Content-Type',
- storage_class VARCHAR(50) DEFAULT NULL COMMENT '存储类型,可预留',
- upload_status ENUM('uploading', 'active', 'deleted', 'failed') NOT NULL DEFAULT 'active' COMMENT '上传状态',
- ref_count INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '被多少逻辑文件引用',
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '首次写入时间',
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
- deleted_at TIMESTAMP NULL DEFAULT NULL COMMENT '逻辑删除时间',
- PRIMARY KEY (object_id),
- UNIQUE KEY uk_object_hash (object_hash),
- UNIQUE KEY uk_bucket_object_key (bucket_name, object_key),
- KEY idx_upload_status (upload_status)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='MinIO物理对象表';
-
-
--- 文件夹表:增加软删除
-CREATE TABLE IF NOT EXISTS `folder` (
- folder_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '文件夹ID',
- owner_id BIGINT UNSIGNED NOT NULL COMMENT '所有者ID',
- parent_folder_id BIGINT UNSIGNED NULL COMMENT '父文件夹ID (NULL表示根目录)',
- folder_name VARCHAR(255) NOT NULL COMMENT '文件夹名称',
- `size` BIGINT NOT NULL DEFAULT 0 COMMENT '文件夹总大小(由应用层或触发器维护)',
- `status` ENUM('active', 'deleted') NOT NULL DEFAULT 'active' COMMENT '状态',
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
- deleted_at TIMESTAMP NULL COMMENT '删除时间',
- PRIMARY KEY (folder_id),
- FOREIGN KEY (owner_id) REFERENCES user(user_id) ON DELETE CASCADE,
- FOREIGN KEY (parent_folder_id) REFERENCES folder(folder_id) ON DELETE CASCADE,
- UNIQUE KEY uk_folder_name_in_parent (parent_folder_id, folder_name, owner_id, status)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文件夹信息表';
-
--- 文件表
-CREATE TABLE IF NOT EXISTS `file` (
- file_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '文件逻辑ID',
- uploader_id BIGINT UNSIGNED NOT NULL COMMENT '上传者ID',
- owner_id BIGINT UNSIGNED NOT NULL COMMENT '文件所属者ID',
- folder_id BIGINT UNSIGNED NOT NULL COMMENT '所属文件夹ID',
- file_name VARCHAR(255) NOT NULL COMMENT '显示文件名',
- file_ext VARCHAR(50) DEFAULT NULL COMMENT '文件扩展名',
- mime_type VARCHAR(255) DEFAULT NULL COMMENT 'MIME类型',
- storage_object_id BIGINT UNSIGNED NOT NULL COMMENT '关联物理对象ID',
- file_size BIGINT UNSIGNED NOT NULL COMMENT '逻辑文件大小',
- is_latest BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否当前最新版本',
- status ENUM('active', 'deleted') NOT NULL DEFAULT 'active' COMMENT '逻辑状态',
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
- deleted_at TIMESTAMP NULL DEFAULT NULL COMMENT '删除时间',
- PRIMARY KEY (file_id),
- FOREIGN KEY (uploader_id) REFERENCES `user`(user_id) ON DELETE CASCADE,
- FOREIGN KEY (owner_id) REFERENCES `user`(user_id) ON DELETE CASCADE,
- FOREIGN KEY (folder_id) REFERENCES folder(folder_id) ON DELETE CASCADE,
- FOREIGN KEY (storage_object_id) REFERENCES storage_object(object_id),
- UNIQUE KEY uk_file_name_in_folder (folder_id, file_name, owner_id, status),
- KEY idx_storage_object_id (storage_object_id),
- KEY idx_owner_folder (owner_id, folder_id, status)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文件逻辑信息表';
-
--- ACL表:访问控制列表
-CREATE TABLE IF NOT EXISTS acl (
- id INT PRIMARY KEY AUTO_INCREMENT,
- file_id BIGINT UNSIGNED DEFAULT NULL,
- folder_id BIGINT UNSIGNED DEFAULT NULL,
- user_id BIGINT UNSIGNED DEFAULT NULL,
- group_id BIGINT UNSIGNED DEFAULT NULL,
- permission VARCHAR(100) NOT NULL,
- FOREIGN KEY (file_id) REFERENCES file(file_id) ON DELETE CASCADE,
- FOREIGN KEY (folder_id) REFERENCES folder(folder_id) ON DELETE CASCADE,
- FOREIGN KEY (user_id) REFERENCES user(user_id) ON DELETE CASCADE,
- FOREIGN KEY (group_id) REFERENCES user_group(id) ON DELETE CASCADE,
- CHECK ((user_id IS NOT NULL OR group_id IS NOT NULL) AND (file_id IS NOT NULL OR folder_id IS NOT NULL))
-);
-
--- 分享表
-CREATE TABLE IF NOT EXISTS share (
- share_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
- user_id BIGINT UNSIGNED NOT NULL,
- resource_type VARCHAR(50) NOT NULL COMMENT '分享的资源类型 (file, folder)',
- file_id BIGINT UNSIGNED DEFAULT NULL,
- folder_id BIGINT UNSIGNED DEFAULT NULL,
- share_link VARCHAR(255) UNIQUE NOT NULL,
- share_type VARCHAR(50) NOT NULL DEFAULT 'public' COMMENT '分享类型 (public, private, password)',
- password_hash VARCHAR(255) DEFAULT NULL,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- expire_time TIMESTAMP NULL DEFAULT NULL,
- visit_count INT UNSIGNED NOT NULL DEFAULT 0,
- download_count INT UNSIGNED NOT NULL DEFAULT 0,
- PRIMARY KEY (share_id),
- FOREIGN KEY (user_id) REFERENCES `user`(user_id) ON DELETE CASCADE,
- FOREIGN KEY (file_id) REFERENCES `file`(file_id) ON DELETE CASCADE,
- FOREIGN KEY (folder_id) REFERENCES folder(folder_id) ON DELETE CASCADE,
- CHECK ((file_id IS NOT NULL AND folder_id IS NULL) OR (file_id IS NULL AND folder_id IS NOT NULL))
-);
-
--- 日志表
-CREATE TABLE IF NOT EXISTS `log` (
- id INT PRIMARY KEY AUTO_INCREMENT,
- user_id BIGINT UNSIGNED NOT NULL,
- operation VARCHAR(255) NOT NULL,
- details TEXT, -- 记录更详细的信息,如移动的源和目标路径
- ip_address VARCHAR(45),
- performed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (user_id) REFERENCES `user`(user_id) ON DELETE CASCADE
-);
-
--- 通知表
-CREATE TABLE IF NOT EXISTS `notification` (
- id INT PRIMARY KEY AUTO_INCREMENT,
- user_id BIGINT UNSIGNED NOT NULL,
- message VARCHAR(255) NOT NULL,
- is_read BOOLEAN DEFAULT FALSE,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (user_id) REFERENCES `user`(user_id) ON DELETE CASCADE
-);
-
-
--- ----------------------------
--- View structure for v_file_folder_details
--- ----------------------------
-CREATE OR REPLACE VIEW `v_file_folder_details` AS
-SELECT
- 'file' AS item_type,
- f.file_id AS id,
- f.file_name AS name,
- so.object_size AS size,
- f.mime_type,
- f.folder_id AS parent_id,
- f.owner_id AS owner_id,
- u.username AS owner_name,
- f.created_at,
- f.updated_at
-FROM file f
-JOIN user u ON f.owner_id = u.user_id
-JOIN storage_object so ON f.storage_object_id = so.object_id
-WHERE f.status = 'active' AND so.upload_status = 'active'
-UNION ALL
-SELECT
- 'folder' AS item_type,
- fo.folder_id AS id,
- fo.folder_name AS name,
- fo.size,
- 'inode/directory' AS mime_type,
- fo.parent_folder_id AS parent_id,
- fo.owner_id AS owner_id,
- u.username AS owner_name,
- fo.created_at,
- fo.updated_at
-FROM folder fo
-JOIN user u ON fo.owner_id = u.user_id
-WHERE fo.status = 'active';
-
--- ----------------------------
--- View structure for v_user_permissions
--- ----------------------------
-CREATE OR REPLACE VIEW v_user_permissions AS
-SELECT
- acl.id AS acl_id,
- u.user_id AS user_id,
- u.username AS user_name,
- COALESCE(f.file_id, fl.folder_id) AS item_id,
- COALESCE(f.file_name, fl.folder_name) AS item_name,
- CASE
- WHEN f.file_id IS NOT NULL THEN 'file'
- ELSE 'folder'
- END AS item_type,
- acl.permission
-FROM
- acl
-JOIN
- `user` u ON acl.user_id = u.user_id
-LEFT JOIN
- `file` f ON acl.file_id = f.file_id
-LEFT JOIN
- folder fl ON acl.folder_id = fl.folder_id
-WHERE
- acl.user_id IS NOT NULL;
-
--- ----------------------------
--- View structure for v_user_storage_summary
--- ----------------------------
-CREATE OR REPLACE VIEW `v_user_storage_summary` AS
-SELECT
- `user_id`,
- `username`,
- `storage_limit`,
- `storage_used`
-FROM `user`;
-
--- ----------------------------
--- View structure for v_shared_with_me (FIXED)
--- ----------------------------
-CREATE OR REPLACE VIEW v_shared_with_me AS
--- 通过用户组共享给我的
-SELECT
- vffd.item_type,
- vffd.id AS item_id,
- vffd.name AS item_name,
- vffd.owner_name AS shared_by,
- acl.permission,
- ugm.user_id AS shared_to_user_id
-FROM acl
-JOIN user_group_member ugm ON acl.group_id = ugm.group_id
--- 修复后的 JOIN 条件: 确保 ACL 和视图中的项目类型和ID都匹配
-JOIN v_file_folder_details vffd
- ON (acl.file_id IS NOT NULL AND vffd.item_type = 'file' AND acl.file_id = vffd.id)
- OR (acl.folder_id IS NOT NULL AND vffd.item_type = 'folder' AND acl.folder_id = vffd.id)
-WHERE acl.group_id IS NOT NULL
-UNION
--- 直接共享给我的
-SELECT
- vffd.item_type,
- vffd.id AS item_id,
- vffd.name AS item_name,
- vffd.owner_name AS shared_by,
- acl.permission,
- acl.user_id AS shared_to_user_id
-FROM acl
--- 修复后的 JOIN 条件: 确保 ACL 和视图中的项目类型和ID都匹配
-JOIN v_file_folder_details vffd
- ON (acl.file_id IS NOT NULL AND vffd.item_type = 'file' AND acl.file_id = vffd.id)
- OR (acl.folder_id IS NOT NULL AND vffd.item_type = 'folder' AND acl.folder_id = vffd.id)
-WHERE acl.user_id IS NOT NULL;
-
-
--- ----------------------------
--- View structure for v_full_path (Requires MySQL 8.0+ or MariaDB 10.2.2+)
--- ----------------------------
-CREATE OR REPLACE VIEW v_full_path AS
-WITH RECURSIVE folder_path (id, name, path) AS (
- -- 根目录
- SELECT
- folder_id,
- folder_name,
- CAST(folder_name AS CHAR(2048))
- FROM folder
- WHERE parent_folder_id IS NULL
- UNION ALL
- -- 递归查找子目录
- SELECT
- f.folder_id,
- f.folder_name,
- CONCAT(fp.path, '/', f.folder_name)
- FROM folder AS f JOIN folder_path AS fp ON f.parent_folder_id = fp.id
-)
-SELECT id, path FROM folder_path;
-
--- ----------------------------
--- View structure for v_user_recycle_bin
--- ----------------------------
-CREATE OR REPLACE VIEW v_user_recycle_bin AS
-SELECT
- 'file' AS item_type,
- f.file_id AS id,
- f.file_name AS name,
- so.size,
- f.folder_id AS parent_id,
- f.uploader_id AS owner_id,
- u.username AS owner_name,
- f.deleted_at
-FROM file f
-JOIN user u ON f.uploader_id = u.user_id
-JOIN storage_object so ON f.object_hash = so.hash
-WHERE f.status = 'deleted' AND f.deleted_at IS NOT NULL
-UNION ALL
-SELECT
- 'folder' AS item_type,
- fo.folder_id AS id,
- fo.folder_name AS name,
- fo.size,
- fo.parent_folder_id AS parent_id,
- fo.owner_id AS owner_id,
- u.username AS owner_name,
- fo.deleted_at
-FROM folder fo
-JOIN user u ON fo.owner_id = u.user_id
-WHERE fo.status = 'deleted' AND fo.deleted_at IS NOT NULL;
-
-CREATE TABLE IF NOT EXISTS `upload_task` (
- task_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '上传任务ID',
- user_id BIGINT UNSIGNED NOT NULL COMMENT '发起者',
- bucket_name VARCHAR(100) NOT NULL COMMENT '目标bucket',
- object_key VARCHAR(1024) NOT NULL COMMENT '目标对象键',
- object_hash CHAR(64) DEFAULT NULL COMMENT '预计算哈希,可为空',
- total_size BIGINT UNSIGNED NOT NULL COMMENT '总大小',
- upload_id VARCHAR(255) DEFAULT NULL COMMENT 'MinIO multipart upload id',
- upload_mode ENUM('single', 'multipart') NOT NULL DEFAULT 'single',
- status ENUM('init', 'uploading', 'completed', 'aborted', 'failed') NOT NULL DEFAULT 'init',
- expired_at TIMESTAMP NULL DEFAULT NULL COMMENT '任务过期时间',
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
- PRIMARY KEY (task_id),
- FOREIGN KEY (user_id) REFERENCES `user`(user_id) ON DELETE CASCADE,
- KEY idx_user_status (user_id, status),
- UNIQUE KEY uk_upload_target (bucket_name, object_key, upload_id)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='上传任务表';
-
-CREATE TABLE IF NOT EXISTS `upload_task_part` (
- id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
- task_id BIGINT UNSIGNED NOT NULL,
- part_number INT NOT NULL COMMENT '分片序号',
- etag VARCHAR(128) DEFAULT NULL COMMENT '该分片上传后的ETag',
- part_size BIGINT UNSIGNED NOT NULL COMMENT '分片大小',
- status ENUM('pending', 'uploaded') NOT NULL DEFAULT 'pending',
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
- PRIMARY KEY (id),
- FOREIGN KEY (task_id) REFERENCES upload_task(task_id) ON DELETE CASCADE,
- UNIQUE KEY uk_task_part (task_id, part_number)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='上传分片表';
\ No newline at end of file
diff --git a/docs/schema-postgres.sql b/docs/schema-postgres.sql
deleted file mode 100644
index 76fab83..0000000
--- a/docs/schema-postgres.sql
+++ /dev/null
@@ -1,1624 +0,0 @@
-
-
--- =========================
--- Enum types
--- =========================
-DO $$
-BEGIN
- IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'upload_status_enum') THEN
- CREATE TYPE upload_status_enum AS ENUM ('uploading', 'active', 'deleted', 'failed');
- END IF;
-
- IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'folder_status_enum') THEN
- CREATE TYPE folder_status_enum AS ENUM ('active', 'deleted');
- END IF;
-
- IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'file_status_enum') THEN
- CREATE TYPE file_status_enum AS ENUM ('active', 'deleted');
- END IF;
-
- IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'upload_mode_enum') THEN
- CREATE TYPE upload_mode_enum AS ENUM ('single', 'multipart');
- END IF;
-
- IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'upload_task_status_enum') THEN
- CREATE TYPE upload_task_status_enum AS ENUM ('init', 'uploading', 'completed', 'aborted', 'failed');
- END IF;
-
- IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'upload_part_status_enum') THEN
- CREATE TYPE upload_part_status_enum AS ENUM ('pending', 'uploaded', 'failed');
- END IF;
-
- IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_status_enum') THEN
- CREATE TYPE user_status_enum AS ENUM ('pending_verification', 'active', 'locked', 'disabled');
- END IF;
-
- IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'folder_type_enum') THEN
- CREATE TYPE folder_type_enum AS ENUM ('normal', 'root', 'system');
- END IF;
-
- IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'share_status_enum') THEN
- CREATE TYPE share_status_enum AS ENUM ('active', 'expired', 'revoked', 'deleted');
- END IF;
-
- IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'share_member_status_enum') THEN
- CREATE TYPE share_member_status_enum AS ENUM ('pending', 'accepted', 'rejected', 'revoked');
- END IF;
-
- IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'favorite_item_type_enum') THEN
- CREATE TYPE favorite_item_type_enum AS ENUM ('file', 'folder');
- END IF;
-
- IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'view_mode_enum') THEN
- CREATE TYPE view_mode_enum AS ENUM ('list', 'grid');
- END IF;
-
- IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'sort_by_enum') THEN
- CREATE TYPE sort_by_enum AS ENUM ('name', 'size', 'created_at', 'updated_at', 'last_accessed_at');
- END IF;
-
- IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'sort_direction_enum') THEN
- CREATE TYPE sort_direction_enum AS ENUM ('asc', 'desc');
- END IF;
-
- IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'preview_status_enum') THEN
- CREATE TYPE preview_status_enum AS ENUM ('pending', 'ready', 'failed');
- END IF;
-
- IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'scan_result_enum') THEN
- CREATE TYPE scan_result_enum AS ENUM ('pending', 'clean', 'infected', 'blocked', 'failed');
- END IF;
-END
-$$;
-
-CREATE EXTENSION IF NOT EXISTS pg_trgm;
-
--- =========================
--- updated_at trigger
--- =========================
-CREATE OR REPLACE FUNCTION set_updated_at()
-RETURNS TRIGGER AS $$
-BEGIN
- NEW.updated_at = CURRENT_TIMESTAMP;
- RETURN NEW;
-END;
-$$ LANGUAGE plpgsql;
-
--- =========================
--- 用户表
--- =========================
-CREATE TABLE IF NOT EXISTS "user" (
- user_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
- username VARCHAR(100) NOT NULL,
- email VARCHAR(255) NOT NULL,
- password_hash VARCHAR(255) NOT NULL,
- role VARCHAR(50) NOT NULL DEFAULT 'USER',
- status user_status_enum NOT NULL DEFAULT 'active',
- email_verified BOOLEAN NOT NULL DEFAULT FALSE,
- email_verified_at TIMESTAMP NULL,
- storage_limit BIGINT NOT NULL DEFAULT 10737418240,
- storage_used BIGINT NOT NULL DEFAULT 0,
- last_login_at TIMESTAMP NULL,
- failed_login_count INTEGER NOT NULL DEFAULT 0,
- locked_until TIMESTAMP NULL,
- password_changed_at TIMESTAMP NULL,
- deleted_at TIMESTAMP NULL,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
-);
-
-CREATE UNIQUE INDEX IF NOT EXISTS uk_user_username_ci ON "user" ((LOWER(username)));
-CREATE UNIQUE INDEX IF NOT EXISTS uk_user_email_ci ON "user" ((LOWER(email)));
-CREATE INDEX IF NOT EXISTS idx_user_status ON "user" (status);
-CREATE INDEX IF NOT EXISTS idx_user_locked_until ON "user" (locked_until);
-
-CREATE TRIGGER trg_user_updated_at
-BEFORE UPDATE ON "user"
-FOR EACH ROW
-EXECUTE FUNCTION set_updated_at();
-
--- =========================
--- 用户组
--- =========================
-CREATE TABLE IF NOT EXISTS user_group (
- id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
- name VARCHAR(100) NOT NULL UNIQUE,
- description VARCHAR(255)
-);
-
-CREATE TABLE IF NOT EXISTS user_group_member (
- id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
- user_id BIGINT NOT NULL,
- group_id BIGINT NOT NULL,
- role VARCHAR(50) NOT NULL DEFAULT 'member',
- CONSTRAINT fk_ugm_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE,
- CONSTRAINT fk_ugm_group FOREIGN KEY (group_id) REFERENCES user_group(id) ON DELETE CASCADE,
- CONSTRAINT uk_user_group UNIQUE (user_id, group_id)
-);
-
--- =========================
--- 存储对象表
--- =========================
-CREATE TABLE IF NOT EXISTS storage_object (
- object_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
- object_hash CHAR(64),
- hash_algorithm VARCHAR(32) NOT NULL DEFAULT 'sha256',
- bucket_name VARCHAR(100) NOT NULL,
- object_key VARCHAR(1024) NOT NULL,
- object_size BIGINT NOT NULL,
- etag VARCHAR(128),
- version_id VARCHAR(255),
- content_type VARCHAR(255),
- storage_class VARCHAR(50),
- upload_status upload_status_enum NOT NULL DEFAULT 'active',
- scan_status scan_result_enum NOT NULL DEFAULT 'pending',
- moderation_status VARCHAR(32) NOT NULL DEFAULT 'pending',
- quarantined_at TIMESTAMP NULL,
- last_scanned_at TIMESTAMP NULL,
- metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
- ref_count INTEGER NOT NULL DEFAULT 1,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- deleted_at TIMESTAMP NULL,
- CONSTRAINT uk_bucket_object_key UNIQUE (bucket_name, object_key)
-);
-
-CREATE UNIQUE INDEX IF NOT EXISTS uk_storage_object_hash_algo_size
- ON storage_object (hash_algorithm, object_hash, object_size)
- WHERE object_hash IS NOT NULL;
-
-CREATE INDEX IF NOT EXISTS idx_storage_object_scan_status ON storage_object (scan_status);
-CREATE INDEX IF NOT EXISTS idx_storage_object_moderation_status ON storage_object (moderation_status);
-
-CREATE INDEX IF NOT EXISTS idx_storage_object_upload_status
-ON storage_object(upload_status);
-
-CREATE TRIGGER trg_storage_object_updated_at
-BEFORE UPDATE ON storage_object
-FOR EACH ROW
-EXECUTE FUNCTION set_updated_at();
-
--- =========================
--- 文件夹表
--- =========================
-CREATE TABLE IF NOT EXISTS folder (
- folder_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
- owner_id BIGINT NOT NULL,
- parent_folder_id BIGINT NULL,
- folder_name VARCHAR(255) NOT NULL,
- cached_size BIGINT NOT NULL DEFAULT 0,
- status folder_status_enum NOT NULL DEFAULT 'active',
- folder_type folder_type_enum NOT NULL DEFAULT 'normal',
- deleted_by BIGINT NULL,
- restored_at TIMESTAMP NULL,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- deleted_at TIMESTAMP NULL,
- CONSTRAINT fk_folder_owner FOREIGN KEY (owner_id) REFERENCES "user"(user_id) ON DELETE CASCADE,
- CONSTRAINT fk_folder_parent FOREIGN KEY (parent_folder_id) REFERENCES folder(folder_id) ON DELETE CASCADE,
- CONSTRAINT fk_folder_deleted_by FOREIGN KEY (deleted_by) REFERENCES "user"(user_id) ON DELETE SET NULL
-);
-
-CREATE UNIQUE INDEX IF NOT EXISTS uk_folder_root_name_active
- ON folder (owner_id, folder_name)
- WHERE parent_folder_id IS NULL AND status = 'active';
-
-CREATE UNIQUE INDEX IF NOT EXISTS uk_folder_child_name_active
- ON folder (owner_id, parent_folder_id, folder_name)
- WHERE parent_folder_id IS NOT NULL AND status = 'active';
-
-CREATE INDEX IF NOT EXISTS idx_folder_owner_parent_status ON folder (owner_id, parent_folder_id, status);
-CREATE INDEX IF NOT EXISTS idx_folder_name_trgm ON folder USING gin ((LOWER(folder_name)) gin_trgm_ops);
-
-CREATE TRIGGER trg_folder_updated_at
-BEFORE UPDATE ON folder
-FOR EACH ROW
-EXECUTE FUNCTION set_updated_at();
-
--- =========================
--- 文件表
--- =========================
-CREATE TABLE IF NOT EXISTS "file" (
- file_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
- uploader_id BIGINT NOT NULL,
- owner_id BIGINT NOT NULL,
- folder_id BIGINT NOT NULL,
- file_name VARCHAR(255) NOT NULL,
- file_ext VARCHAR(50),
- mime_type VARCHAR(255),
- storage_object_id BIGINT NOT NULL,
- file_size BIGINT NOT NULL,
- is_latest BOOLEAN NOT NULL DEFAULT TRUE,
- status file_status_enum NOT NULL DEFAULT 'active',
- deleted_by BIGINT NULL,
- restored_at TIMESTAMP NULL,
- last_accessed_at TIMESTAMP NULL,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- deleted_at TIMESTAMP NULL,
- CONSTRAINT fk_file_uploader FOREIGN KEY (uploader_id) REFERENCES "user"(user_id) ON DELETE CASCADE,
- CONSTRAINT fk_file_owner FOREIGN KEY (owner_id) REFERENCES "user"(user_id) ON DELETE CASCADE,
- CONSTRAINT fk_file_folder FOREIGN KEY (folder_id) REFERENCES folder(folder_id) ON DELETE CASCADE,
- CONSTRAINT fk_file_storage_object FOREIGN KEY (storage_object_id) REFERENCES storage_object(object_id),
- CONSTRAINT fk_file_deleted_by FOREIGN KEY (deleted_by) REFERENCES "user"(user_id) ON DELETE SET NULL
-);
-
-CREATE UNIQUE INDEX IF NOT EXISTS uk_file_name_active_in_folder
- ON "file" (owner_id, folder_id, file_name)
- WHERE status = 'active';
-
-CREATE INDEX IF NOT EXISTS idx_file_last_accessed_at ON "file" (last_accessed_at);
-CREATE INDEX IF NOT EXISTS idx_file_name_trgm ON "file" USING gin ((LOWER(file_name)) gin_trgm_ops);
-
-CREATE INDEX IF NOT EXISTS idx_file_storage_object_id
-ON "file"(storage_object_id);
-
-CREATE INDEX IF NOT EXISTS idx_file_owner_folder
-ON "file"(owner_id, folder_id, status);
-
-CREATE TRIGGER trg_file_updated_at
-BEFORE UPDATE ON "file"
-FOR EACH ROW
-EXECUTE FUNCTION set_updated_at();
-
--- =========================
--- ACL
--- =========================
-CREATE TABLE IF NOT EXISTS acl (
- id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
- file_id BIGINT NULL,
- folder_id BIGINT NULL,
- user_id BIGINT NULL,
- group_id BIGINT NULL,
- permission VARCHAR(100) NOT NULL,
- permission_role VARCHAR(50) NOT NULL DEFAULT 'viewer',
- can_preview BOOLEAN NOT NULL DEFAULT TRUE,
- can_download BOOLEAN NOT NULL DEFAULT TRUE,
- can_save BOOLEAN NOT NULL DEFAULT FALSE,
- can_share BOOLEAN NOT NULL DEFAULT FALSE,
- expire_at TIMESTAMP NULL,
- granted_by BIGINT NULL,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- CONSTRAINT fk_acl_file FOREIGN KEY (file_id) REFERENCES "file"(file_id) ON DELETE CASCADE,
- CONSTRAINT fk_acl_folder FOREIGN KEY (folder_id) REFERENCES folder(folder_id) ON DELETE CASCADE,
- CONSTRAINT fk_acl_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE,
- CONSTRAINT fk_acl_group FOREIGN KEY (group_id) REFERENCES user_group(id) ON DELETE CASCADE,
- CONSTRAINT fk_acl_granted_by FOREIGN KEY (granted_by) REFERENCES "user"(user_id) ON DELETE SET NULL,
- CONSTRAINT chk_acl_subject_exactly_one CHECK (num_nonnulls(user_id, group_id) = 1),
- CONSTRAINT chk_acl_resource_exactly_one CHECK (num_nonnulls(file_id, folder_id) = 1)
-);
-
-CREATE UNIQUE INDEX IF NOT EXISTS uk_acl_file_user
- ON acl (file_id, user_id)
- WHERE file_id IS NOT NULL AND user_id IS NOT NULL;
-
-CREATE UNIQUE INDEX IF NOT EXISTS uk_acl_file_group
- ON acl (file_id, group_id)
- WHERE file_id IS NOT NULL AND group_id IS NOT NULL;
-
-CREATE UNIQUE INDEX IF NOT EXISTS uk_acl_folder_user
- ON acl (folder_id, user_id)
- WHERE folder_id IS NOT NULL AND user_id IS NOT NULL;
-
-CREATE UNIQUE INDEX IF NOT EXISTS uk_acl_folder_group
- ON acl (folder_id, group_id)
- WHERE folder_id IS NOT NULL AND group_id IS NOT NULL;
-
-CREATE TRIGGER trg_acl_updated_at
-BEFORE UPDATE ON acl
-FOR EACH ROW
-EXECUTE FUNCTION set_updated_at();
-
--- =========================
--- 分享表
--- =========================
-CREATE TABLE IF NOT EXISTS share (
- share_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
- user_id BIGINT NOT NULL,
- resource_type VARCHAR(50) NOT NULL,
- file_id BIGINT NULL,
- folder_id BIGINT NULL,
- share_link VARCHAR(255) NOT NULL UNIQUE,
- share_code VARCHAR(64) NOT NULL,
- status share_status_enum NOT NULL DEFAULT 'active',
- share_type VARCHAR(50) NOT NULL DEFAULT 'public',
- permission_role VARCHAR(50) NOT NULL DEFAULT 'viewer',
- allow_preview BOOLEAN NOT NULL DEFAULT TRUE,
- allow_download BOOLEAN NOT NULL DEFAULT TRUE,
- allow_save BOOLEAN NOT NULL DEFAULT FALSE,
- allow_reshare BOOLEAN NOT NULL DEFAULT FALSE,
- require_login BOOLEAN NOT NULL DEFAULT FALSE,
- max_visits INTEGER NULL,
- max_downloads INTEGER NULL,
- password_hash VARCHAR(255),
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- last_accessed_at TIMESTAMP NULL,
- expire_time TIMESTAMP NULL,
- visit_count INTEGER NOT NULL DEFAULT 0,
- download_count INTEGER NOT NULL DEFAULT 0,
- CONSTRAINT fk_share_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE,
- CONSTRAINT fk_share_file FOREIGN KEY (file_id) REFERENCES "file"(file_id) ON DELETE CASCADE,
- CONSTRAINT fk_share_folder FOREIGN KEY (folder_id) REFERENCES folder(folder_id) ON DELETE CASCADE,
- CONSTRAINT chk_share_target CHECK (num_nonnulls(file_id, folder_id) = 1),
- CONSTRAINT chk_share_limits CHECK (
- (max_visits IS NULL OR max_visits >= 0)
- AND
- (max_downloads IS NULL OR max_downloads >= 0)
- )
-);
-
-CREATE UNIQUE INDEX IF NOT EXISTS uk_share_code ON share (share_code);
-CREATE INDEX IF NOT EXISTS idx_share_user_status ON share (user_id, status);
-CREATE INDEX IF NOT EXISTS idx_share_expire_time ON share (expire_time);
-
-CREATE TRIGGER trg_share_updated_at
-BEFORE UPDATE ON share
-FOR EACH ROW
-EXECUTE FUNCTION set_updated_at();
-
--- =========================
--- 日志表
--- =========================
-CREATE TABLE IF NOT EXISTS "log" (
- id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
- user_id BIGINT NULL,
- actor_type VARCHAR(50) NOT NULL DEFAULT 'user',
- operation VARCHAR(255) NOT NULL,
- target_type VARCHAR(50) NULL,
- target_id BIGINT NULL,
- result VARCHAR(20) NOT NULL DEFAULT 'success',
- request_id VARCHAR(128) NULL,
- user_agent VARCHAR(255) NULL,
- metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
- details TEXT,
- ip_address VARCHAR(45),
- performed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- CONSTRAINT fk_log_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE SET NULL
-);
-
-CREATE INDEX IF NOT EXISTS idx_log_performed_at ON "log" (performed_at);
-CREATE INDEX IF NOT EXISTS idx_log_user_operation ON "log" (user_id, operation);
-CREATE INDEX IF NOT EXISTS idx_log_target ON "log" (target_type, target_id);
-CREATE INDEX IF NOT EXISTS idx_log_request_id ON "log" (request_id);
-
--- =========================
--- 通知表
--- =========================
-CREATE TABLE IF NOT EXISTS notification (
- id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
- user_id BIGINT NOT NULL,
- title VARCHAR(255) NULL,
- type VARCHAR(50) NOT NULL DEFAULT 'system',
- channel VARCHAR(50) NOT NULL DEFAULT 'in_app',
- message TEXT NOT NULL,
- payload JSONB NOT NULL DEFAULT '{}'::jsonb,
- sent_at TIMESTAMP NULL,
- is_read BOOLEAN DEFAULT FALSE,
- read_at TIMESTAMP NULL,
- status VARCHAR(50) NOT NULL DEFAULT 'pending',
- sender_user_id BIGINT NULL,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- CONSTRAINT fk_notification_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE,
- CONSTRAINT fk_notification_sender_user FOREIGN KEY (sender_user_id) REFERENCES "user"(user_id) ON DELETE SET NULL
-);
-
-CREATE INDEX IF NOT EXISTS idx_notification_user_status ON notification (user_id, status, is_read);
-CREATE INDEX IF NOT EXISTS idx_notification_created_at ON notification (created_at);
-
-CREATE TRIGGER trg_notification_updated_at
-BEFORE UPDATE ON notification
-FOR EACH ROW
-EXECUTE FUNCTION set_updated_at();
-
--- =========================
--- 视图 v_file_folder_details
--- =========================
-CREATE OR REPLACE VIEW v_file_folder_details AS
-SELECT
- 'file' AS item_type,
- f.file_id AS id,
- f.file_name AS name,
- so.object_size AS size,
- f.mime_type,
- f.folder_id AS parent_id,
- f.owner_id AS owner_id,
- u.username AS owner_name,
- f.created_at,
- f.updated_at
-FROM "file" f
-JOIN "user" u ON f.owner_id = u.user_id
-JOIN storage_object so ON f.storage_object_id = so.object_id
-WHERE f.status = 'active' AND so.upload_status = 'active'
-UNION ALL
-SELECT
- 'folder' AS item_type,
- fo.folder_id AS id,
- fo.folder_name AS name,
- fo.cached_size AS size,
- 'inode/directory' AS mime_type,
- fo.parent_folder_id AS parent_id,
- fo.owner_id AS owner_id,
- u.username AS owner_name,
- fo.created_at,
- fo.updated_at
-FROM folder fo
-JOIN "user" u ON fo.owner_id = u.user_id
-WHERE fo.status = 'active';
-
--- =========================
--- 视图 v_user_permissions
--- =========================
-CREATE OR REPLACE VIEW v_user_permissions AS
-SELECT
- acl.id AS acl_id,
- u.user_id AS user_id,
- u.username AS user_name,
- COALESCE(f.file_id, fl.folder_id) AS item_id,
- COALESCE(f.file_name, fl.folder_name) AS item_name,
- CASE
- WHEN f.file_id IS NOT NULL THEN 'file'
- ELSE 'folder'
- END AS item_type,
- acl.permission
-FROM acl
-JOIN "user" u ON acl.user_id = u.user_id
-LEFT JOIN "file" f ON acl.file_id = f.file_id
-LEFT JOIN folder fl ON acl.folder_id = fl.folder_id
-WHERE acl.user_id IS NOT NULL;
-
--- =========================
--- 视图 v_user_storage_summary
--- =========================
-CREATE OR REPLACE VIEW v_user_storage_summary AS
-SELECT
- user_id,
- username,
- storage_limit,
- storage_used
-FROM "user";
-
--- =========================
--- 视图 v_shared_with_me
--- =========================
-CREATE OR REPLACE VIEW v_shared_with_me AS
-SELECT
- vffd.item_type,
- vffd.id AS item_id,
- vffd.name AS item_name,
- vffd.owner_name AS shared_by,
- acl.permission,
- ugm.user_id AS shared_to_user_id
-FROM acl
-JOIN user_group_member ugm ON acl.group_id = ugm.group_id
-JOIN v_file_folder_details vffd
- ON (
- acl.file_id IS NOT NULL AND vffd.item_type = 'file' AND acl.file_id = vffd.id
- ) OR (
- acl.folder_id IS NOT NULL AND vffd.item_type = 'folder' AND acl.folder_id = vffd.id
- )
-WHERE acl.group_id IS NOT NULL
-
-UNION
-
-SELECT
- vffd.item_type,
- vffd.id AS item_id,
- vffd.name AS item_name,
- vffd.owner_name AS shared_by,
- acl.permission,
- acl.user_id AS shared_to_user_id
-FROM acl
-JOIN v_file_folder_details vffd
- ON (
- acl.file_id IS NOT NULL AND vffd.item_type = 'file' AND acl.file_id = vffd.id
- ) OR (
- acl.folder_id IS NOT NULL AND vffd.item_type = 'folder' AND acl.folder_id = vffd.id
- )
-WHERE acl.user_id IS NOT NULL;
-
--- =========================
--- 视图 v_full_path
--- =========================
-CREATE OR REPLACE VIEW v_full_path AS
-WITH RECURSIVE folder_path (id, name, path) AS (
- SELECT
- folder_id,
- folder_name,
- CAST(folder_name AS VARCHAR(2048))
- FROM folder
- WHERE parent_folder_id IS NULL
-
- UNION ALL
-
- SELECT
- f.folder_id,
- f.folder_name,
- fp.path || '/' || f.folder_name
- FROM folder f
- JOIN folder_path fp ON f.parent_folder_id = fp.id
-)
-SELECT id, path
-FROM folder_path;
-
--- =========================
--- 视图 v_user_recycle_bin
--- 注意:原 SQL 这里有明显字段不一致问题,我按现有表结构修正为可执行版本
--- =========================
-CREATE OR REPLACE VIEW v_user_recycle_bin AS
-SELECT
- 'file' AS item_type,
- f.file_id AS id,
- f.file_name AS name,
- so.object_size AS size,
- f.folder_id AS parent_id,
- f.uploader_id AS owner_id,
- u.username AS owner_name,
- f.deleted_at
-FROM "file" f
-JOIN "user" u ON f.uploader_id = u.user_id
-JOIN storage_object so ON f.storage_object_id = so.object_id
-WHERE f.status = 'deleted' AND f.deleted_at IS NOT NULL
-
-UNION ALL
-
-SELECT
- 'folder' AS item_type,
- fo.folder_id AS id,
- fo.folder_name AS name,
- fo.cached_size AS size,
- fo.parent_folder_id AS parent_id,
- fo.owner_id AS owner_id,
- u.username AS owner_name,
- fo.deleted_at
-FROM folder fo
-JOIN "user" u ON fo.owner_id = u.user_id
-WHERE fo.status = 'deleted' AND fo.deleted_at IS NOT NULL;
-
--- =========================
--- 上传任务表
--- =========================
-CREATE TABLE IF NOT EXISTS upload_task (
- task_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
- user_id BIGINT NOT NULL,
- folder_id BIGINT NULL,
- file_name VARCHAR(255) NULL,
- mime_type VARCHAR(255) NULL,
- bucket_name VARCHAR(100) NOT NULL,
- object_key VARCHAR(1024) NOT NULL,
- object_hash CHAR(64),
- total_size BIGINT NOT NULL,
- chunk_size BIGINT NULL,
- uploaded_bytes BIGINT NOT NULL DEFAULT 0,
- client_file_id VARCHAR(255) NULL,
- upload_id VARCHAR(255),
- upload_mode upload_mode_enum NOT NULL DEFAULT 'single',
- status upload_task_status_enum NOT NULL DEFAULT 'init',
- last_error TEXT NULL,
- completed_at TIMESTAMP NULL,
- expired_at TIMESTAMP NULL,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- CONSTRAINT fk_upload_task_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE,
- CONSTRAINT fk_upload_task_folder FOREIGN KEY (folder_id) REFERENCES folder(folder_id) ON DELETE SET NULL,
- CONSTRAINT uk_upload_target UNIQUE (bucket_name, object_key, upload_id)
-);
-
-CREATE INDEX IF NOT EXISTS idx_upload_task_user_status
-ON upload_task(user_id, status);
-
-CREATE INDEX IF NOT EXISTS idx_upload_task_expired_at ON upload_task (expired_at);
-
-CREATE UNIQUE INDEX IF NOT EXISTS uk_upload_task_user_client_file_id
- ON upload_task (user_id, client_file_id)
- WHERE client_file_id IS NOT NULL;
-
-CREATE TRIGGER trg_upload_task_updated_at
-BEFORE UPDATE ON upload_task
-FOR EACH ROW
-EXECUTE FUNCTION set_updated_at();
-
--- =========================
--- 上传分片表
--- =========================
-CREATE TABLE IF NOT EXISTS upload_task_part (
- id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
- task_id BIGINT NOT NULL,
- part_number INTEGER NOT NULL,
- etag VARCHAR(128),
- part_size BIGINT NOT NULL,
- status upload_part_status_enum NOT NULL DEFAULT 'pending',
- checksum CHAR(64) NULL,
- uploaded_at TIMESTAMP NULL,
- retry_count INTEGER NOT NULL DEFAULT 0,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- CONSTRAINT fk_upload_task_part_task FOREIGN KEY (task_id) REFERENCES upload_task(task_id) ON DELETE CASCADE,
- CONSTRAINT uk_task_part UNIQUE (task_id, part_number)
-);
-
-CREATE INDEX IF NOT EXISTS idx_upload_task_part_task_status ON upload_task_part (task_id, status);
-
-CREATE TRIGGER trg_upload_task_part_updated_at
-BEFORE UPDATE ON upload_task_part
-FOR EACH ROW
-EXECUTE FUNCTION set_updated_at();
-
--- =========================
--- 2) 新增表
--- =========================
-
--- 密码找回 token
-CREATE TABLE IF NOT EXISTS password_reset_token (
- token_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
- user_id BIGINT NOT NULL,
- token_hash CHAR(64) NOT NULL UNIQUE,
- expire_at TIMESTAMP NOT NULL,
- used_at TIMESTAMP NULL,
- requester_ip VARCHAR(45) NULL,
- user_agent VARCHAR(255) NULL,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- CONSTRAINT fk_password_reset_token_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE
-);
-
-CREATE INDEX IF NOT EXISTS idx_password_reset_token_user_expire
- ON password_reset_token (user_id, expire_at);
-
--- 邮箱验证 token
-CREATE TABLE IF NOT EXISTS email_verification_token (
- token_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
- user_id BIGINT NOT NULL,
- token_hash CHAR(64) NOT NULL UNIQUE,
- expire_at TIMESTAMP NOT NULL,
- verified_at TIMESTAMP NULL,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- CONSTRAINT fk_email_verification_token_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE
-);
-
-CREATE INDEX IF NOT EXISTS idx_email_verification_token_user_expire
- ON email_verification_token (user_id, expire_at);
-
--- 会话表 / refresh token
-CREATE TABLE IF NOT EXISTS user_session (
- session_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
- user_id BIGINT NOT NULL,
- refresh_token_hash CHAR(64) NOT NULL UNIQUE,
- client_type VARCHAR(50) NOT NULL DEFAULT 'web',
- device_id VARCHAR(255) NULL,
- device_name VARCHAR(255) NULL,
- ip_address VARCHAR(45) NULL,
- user_agent VARCHAR(255) NULL,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- last_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- expire_at TIMESTAMP NOT NULL,
- revoked_at TIMESTAMP NULL,
- CONSTRAINT fk_user_session_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE
-);
-
-CREATE INDEX IF NOT EXISTS idx_user_session_user_expire
- ON user_session (user_id, expire_at);
-
--- 分享成员:支撑“分享给别人”和“接受他人分享”
-CREATE TABLE IF NOT EXISTS share_member (
- id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
- share_id BIGINT NOT NULL,
- user_id BIGINT NULL,
- group_id BIGINT NULL,
- status share_member_status_enum NOT NULL DEFAULT 'pending',
- target_folder_id BIGINT NULL,
- accepted_at TIMESTAMP NULL,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- CONSTRAINT fk_share_member_share FOREIGN KEY (share_id) REFERENCES share(share_id) ON DELETE CASCADE,
- CONSTRAINT fk_share_member_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE,
- CONSTRAINT fk_share_member_group FOREIGN KEY (group_id) REFERENCES user_group(id) ON DELETE CASCADE,
- CONSTRAINT fk_share_member_target_folder FOREIGN KEY (target_folder_id) REFERENCES folder(folder_id) ON DELETE SET NULL,
- CONSTRAINT chk_share_member_subject_exactly_one CHECK (num_nonnulls(user_id, group_id) = 1)
-);
-
-CREATE UNIQUE INDEX IF NOT EXISTS uk_share_member_user
- ON share_member (share_id, user_id)
- WHERE user_id IS NOT NULL;
-
-CREATE UNIQUE INDEX IF NOT EXISTS uk_share_member_group
- ON share_member (share_id, group_id)
- WHERE group_id IS NOT NULL;
-
-CREATE INDEX IF NOT EXISTS idx_share_member_share_status ON share_member (share_id, status);
-
-DROP TRIGGER IF EXISTS trg_share_member_updated_at ON share_member;
-CREATE TRIGGER trg_share_member_updated_at
-BEFORE UPDATE ON share_member
-FOR EACH ROW
-EXECUTE FUNCTION set_updated_at();
-
--- 星标:文件、文件夹都可加星
-CREATE TABLE IF NOT EXISTS favorite_item (
- favorite_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
- user_id BIGINT NOT NULL,
- item_type favorite_item_type_enum NOT NULL,
- file_id BIGINT NULL,
- folder_id BIGINT NULL,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- CONSTRAINT fk_favorite_item_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE,
- CONSTRAINT fk_favorite_item_file FOREIGN KEY (file_id) REFERENCES "file"(file_id) ON DELETE CASCADE,
- CONSTRAINT fk_favorite_item_folder FOREIGN KEY (folder_id) REFERENCES folder(folder_id) ON DELETE CASCADE,
- CONSTRAINT chk_favorite_item_target_exactly_one CHECK (num_nonnulls(file_id, folder_id) = 1),
- CONSTRAINT chk_favorite_item_type_match CHECK (
- (item_type = 'file' AND file_id IS NOT NULL AND folder_id IS NULL)
- OR
- (item_type = 'folder' AND folder_id IS NOT NULL AND file_id IS NULL)
- )
-);
-
-CREATE UNIQUE INDEX IF NOT EXISTS uk_favorite_item_file
- ON favorite_item (user_id, file_id)
- WHERE file_id IS NOT NULL;
-
-CREATE UNIQUE INDEX IF NOT EXISTS uk_favorite_item_folder
- ON favorite_item (user_id, folder_id)
- WHERE folder_id IS NOT NULL;
-
--- 目录视图偏好:全局默认 + 每个目录覆盖
-CREATE TABLE IF NOT EXISTS user_folder_preference (
- preference_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
- user_id BIGINT NOT NULL,
- folder_id BIGINT NULL,
- view_mode view_mode_enum NOT NULL DEFAULT 'list',
- sort_by sort_by_enum NOT NULL DEFAULT 'name',
- sort_direction sort_direction_enum NOT NULL DEFAULT 'asc',
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- CONSTRAINT fk_user_folder_preference_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE,
- CONSTRAINT fk_user_folder_preference_folder FOREIGN KEY (folder_id) REFERENCES folder(folder_id) ON DELETE CASCADE
-);
-
-CREATE UNIQUE INDEX IF NOT EXISTS uk_user_folder_preference_default
- ON user_folder_preference (user_id)
- WHERE folder_id IS NULL;
-
-CREATE UNIQUE INDEX IF NOT EXISTS uk_user_folder_preference_folder
- ON user_folder_preference (user_id, folder_id)
- WHERE folder_id IS NOT NULL;
-
-DROP TRIGGER IF EXISTS trg_user_folder_preference_updated_at ON user_folder_preference;
-CREATE TRIGGER trg_user_folder_preference_updated_at
-BEFORE UPDATE ON user_folder_preference
-FOR EACH ROW
-EXECUTE FUNCTION set_updated_at();
-
--- 预览产物:图片缩略图、PDF 页图、视频预览等
-CREATE TABLE IF NOT EXISTS file_preview_asset (
- preview_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
- source_object_id BIGINT NOT NULL,
- preview_object_id BIGINT NULL,
- preview_type VARCHAR(50) NOT NULL,
- page_no INTEGER NULL,
- mime_type VARCHAR(255) NULL,
- width INTEGER NULL,
- height INTEGER NULL,
- duration_ms BIGINT NULL,
- status preview_status_enum NOT NULL DEFAULT 'pending',
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- CONSTRAINT fk_file_preview_asset_source_object FOREIGN KEY (source_object_id) REFERENCES storage_object(object_id) ON DELETE CASCADE,
- CONSTRAINT fk_file_preview_asset_preview_object FOREIGN KEY (preview_object_id) REFERENCES storage_object(object_id) ON DELETE SET NULL
-);
-
-CREATE UNIQUE INDEX IF NOT EXISTS uk_file_preview_asset_type_page
- ON file_preview_asset (source_object_id, preview_type, (COALESCE(page_no, -1)));
-
-DROP TRIGGER IF EXISTS trg_file_preview_asset_updated_at ON file_preview_asset;
-CREATE TRIGGER trg_file_preview_asset_updated_at
-BEFORE UPDATE ON file_preview_asset
-FOR EACH ROW
-EXECUTE FUNCTION set_updated_at();
-
--- 媒体元数据:预览 / 转码 / 播放所需
-CREATE TABLE IF NOT EXISTS file_media_metadata (
- metadata_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
- source_object_id BIGINT NOT NULL UNIQUE,
- width INTEGER NULL,
- height INTEGER NULL,
- duration_ms BIGINT NULL,
- page_count INTEGER NULL,
- bitrate INTEGER NULL,
- sample_rate INTEGER NULL,
- video_codec VARCHAR(64) NULL,
- audio_codec VARCHAR(64) NULL,
- extra_metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
- extracted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- CONSTRAINT fk_file_media_metadata_source_object FOREIGN KEY (source_object_id) REFERENCES storage_object(object_id) ON DELETE CASCADE
-);
-
--- 扫描结果:病毒 / 色情 / 其他违规检测
-CREATE TABLE IF NOT EXISTS object_scan_result (
- scan_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
- object_id BIGINT NOT NULL,
- scan_type VARCHAR(50) NOT NULL,
- engine_name VARCHAR(100) NULL,
- engine_version VARCHAR(100) NULL,
- result scan_result_enum NOT NULL DEFAULT 'pending',
- details JSONB NOT NULL DEFAULT '{}'::jsonb,
- scanned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- CONSTRAINT fk_object_scan_result_object FOREIGN KEY (object_id) REFERENCES storage_object(object_id) ON DELETE CASCADE
-);
-
-CREATE INDEX IF NOT EXISTS idx_object_scan_result_object_scanned_at
- ON object_scan_result (object_id, scanned_at DESC);
-
--- 违规处理工单
-CREATE TABLE IF NOT EXISTS moderation_case (
- case_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
- object_id BIGINT NOT NULL,
- file_id BIGINT NULL,
- reason_type VARCHAR(50) NOT NULL,
- confidence NUMERIC(5,4) NULL,
- status VARCHAR(50) NOT NULL DEFAULT 'pending',
- resolution VARCHAR(50) NULL,
- detail JSONB NOT NULL DEFAULT '{}'::jsonb,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- handled_by BIGINT NULL,
- handled_at TIMESTAMP NULL,
- CONSTRAINT fk_moderation_case_object FOREIGN KEY (object_id) REFERENCES storage_object(object_id) ON DELETE CASCADE,
- CONSTRAINT fk_moderation_case_file FOREIGN KEY (file_id) REFERENCES "file"(file_id) ON DELETE SET NULL,
- CONSTRAINT fk_moderation_case_handled_by FOREIGN KEY (handled_by) REFERENCES "user"(user_id) ON DELETE SET NULL
-);
-
-CREATE INDEX IF NOT EXISTS idx_moderation_case_status_created_at
- ON moderation_case (status, created_at DESC);
-
-DROP TRIGGER IF EXISTS trg_moderation_case_updated_at ON moderation_case;
-CREATE TRIGGER trg_moderation_case_updated_at
-BEFORE UPDATE ON moderation_case
-FOR EACH ROW
-EXECUTE FUNCTION set_updated_at();
-
--- 安全事件:限制滥用、审计风险行为
-CREATE TABLE IF NOT EXISTS security_event (
- event_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
- user_id BIGINT NULL,
- session_id BIGINT NULL,
- event_type VARCHAR(50) NOT NULL,
- severity VARCHAR(20) NOT NULL DEFAULT 'info',
- target_type VARCHAR(50) NULL,
- target_id BIGINT NULL,
- ip_address VARCHAR(45) NULL,
- user_agent VARCHAR(255) NULL,
- detail JSONB NOT NULL DEFAULT '{}'::jsonb,
- occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- CONSTRAINT fk_security_event_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE SET NULL,
- CONSTRAINT fk_security_event_session FOREIGN KEY (session_id) REFERENCES user_session(session_id) ON DELETE SET NULL
-);
-
-CREATE INDEX IF NOT EXISTS idx_security_event_user_occurred_at
- ON security_event (user_id, occurred_at DESC);
-CREATE INDEX IF NOT EXISTS idx_security_event_type_occurred_at
- ON security_event (event_type, occurred_at DESC);
-
--- 批量下载任务:支持大目录异步打包下载
-CREATE TABLE IF NOT EXISTS batch_download_task (
- task_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
- user_id BIGINT NOT NULL,
- archive_name VARCHAR(255) NOT NULL,
- item_count INTEGER NOT NULL DEFAULT 0,
- items JSONB NOT NULL DEFAULT '[]'::jsonb,
- status VARCHAR(50) NOT NULL DEFAULT 'pending',
- storage_object_id BIGINT NULL,
- expire_at TIMESTAMP NULL,
- error_message TEXT NULL,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- completed_at TIMESTAMP NULL,
- CONSTRAINT fk_batch_download_task_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE,
- CONSTRAINT fk_batch_download_task_storage_object FOREIGN KEY (storage_object_id) REFERENCES storage_object(object_id) ON DELETE SET NULL
-);
-
-CREATE INDEX IF NOT EXISTS idx_batch_download_task_user_status
- ON batch_download_task (user_id, status);
-
-DROP TRIGGER IF EXISTS trg_batch_download_task_updated_at ON batch_download_task;
-CREATE TRIGGER trg_batch_download_task_updated_at
-BEFORE UPDATE ON batch_download_task
-FOR EACH ROW
-EXECUTE FUNCTION set_updated_at();
-
--- 分享访问日志:访问 / 下载统计明细
-CREATE TABLE IF NOT EXISTS share_access_log (
- id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
- share_id BIGINT NOT NULL,
- user_id BIGINT NULL,
- event_type VARCHAR(20) NOT NULL,
- ip_address VARCHAR(45) NULL,
- user_agent VARCHAR(255) NULL,
- result VARCHAR(20) NOT NULL DEFAULT 'success',
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- CONSTRAINT fk_share_access_log_share FOREIGN KEY (share_id) REFERENCES share(share_id) ON DELETE CASCADE,
- CONSTRAINT fk_share_access_log_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE SET NULL
-);
-
-CREATE INDEX IF NOT EXISTS idx_share_access_log_share_created_at
- ON share_access_log (share_id, created_at DESC);
-
--- =========================
--- 3) 触发器 / 维护函数
--- =========================
-
--- 通知表:同步 is_read / read_at
-CREATE OR REPLACE FUNCTION sync_notification_read_at()
-RETURNS TRIGGER AS $$
-BEGIN
- IF TG_OP = 'INSERT' THEN
- IF NEW.is_read AND NEW.read_at IS NULL THEN
- NEW.read_at = CURRENT_TIMESTAMP;
- ELSIF NOT NEW.is_read THEN
- NEW.read_at = NULL;
- END IF;
- RETURN NEW;
- END IF;
-
- IF NEW.is_read AND (OLD.is_read IS DISTINCT FROM NEW.is_read) AND NEW.read_at IS NULL THEN
- NEW.read_at = CURRENT_TIMESTAMP;
- ELSIF NOT NEW.is_read THEN
- NEW.read_at = NULL;
- ELSIF NEW.read_at IS NOT NULL THEN
- NEW.is_read = TRUE;
- END IF;
-
- RETURN NEW;
-END;
-$$ LANGUAGE plpgsql;
-
-DROP TRIGGER IF EXISTS trg_notification_sync_read_at ON notification;
-CREATE TRIGGER trg_notification_sync_read_at
-BEFORE INSERT OR UPDATE ON notification
-FOR EACH ROW
-EXECUTE FUNCTION sync_notification_read_at();
-
--- 重建 / 维护用户已用空间
-CREATE OR REPLACE FUNCTION recalc_user_storage_used(p_user_id BIGINT)
-RETURNS VOID AS $$
-BEGIN
- IF p_user_id IS NULL THEN
- RETURN;
- END IF;
-
- UPDATE "user" u
- SET storage_used = COALESCE((
- SELECT SUM(f.file_size)
- FROM "file" f
- WHERE f.owner_id = p_user_id
- AND f.status = 'active'
- ), 0)
- WHERE u.user_id = p_user_id;
-END;
-$$ LANGUAGE plpgsql;
-
--- 重建 / 维护物理对象引用数
-CREATE OR REPLACE FUNCTION recalc_storage_object_ref_count(p_object_id BIGINT)
-RETURNS VOID AS $$
-BEGIN
- IF p_object_id IS NULL THEN
- RETURN;
- END IF;
-
- UPDATE storage_object so
- SET ref_count = COALESCE((
- SELECT COUNT(*)
- FROM "file" f
- WHERE f.storage_object_id = p_object_id
- AND f.status = 'active'
- ), 0)
- WHERE so.object_id = p_object_id;
-END;
-$$ LANGUAGE plpgsql;
-
--- 单个目录及其祖先目录大小回算(cached_size = 直属 active 文件大小 + 直属 active 子目录 cached_size)
-CREATE OR REPLACE FUNCTION recalc_folder_cached_size(p_folder_id BIGINT)
-RETURNS VOID AS $$
-DECLARE
- v_folder_id BIGINT := p_folder_id;
- v_parent_id BIGINT;
-BEGIN
- WHILE v_folder_id IS NOT NULL LOOP
- UPDATE folder f
- SET cached_size =
- COALESCE((
- SELECT SUM(fi.file_size)
- FROM "file" fi
- WHERE fi.folder_id = f.folder_id
- AND fi.status = 'active'
- ), 0)
- +
- COALESCE((
- SELECT SUM(ch.cached_size)
- FROM folder ch
- WHERE ch.parent_folder_id = f.folder_id
- AND ch.status = 'active'
- ), 0)
- WHERE f.folder_id = v_folder_id;
-
- SELECT parent_folder_id INTO v_parent_id
- FROM folder
- WHERE folder_id = v_folder_id;
-
- v_folder_id := v_parent_id;
- END LOOP;
-END;
-$$ LANGUAGE plpgsql;
-
--- 全量重建目录缓存大小
-CREATE OR REPLACE FUNCTION rebuild_all_folder_cached_size()
-RETURNS VOID AS $$
-DECLARE
- r RECORD;
-BEGIN
- FOR r IN
- WITH RECURSIVE tree AS (
- SELECT folder_id, parent_folder_id, 0 AS depth
- FROM folder
- WHERE parent_folder_id IS NULL
-
- UNION ALL
-
- SELECT f.folder_id, f.parent_folder_id, t.depth + 1
- FROM folder f
- JOIN tree t ON f.parent_folder_id = t.folder_id
- )
- SELECT folder_id
- FROM tree
- ORDER BY depth DESC, folder_id DESC
- LOOP
- PERFORM recalc_folder_cached_size(r.folder_id);
- END LOOP;
-END;
-$$ LANGUAGE plpgsql;
-
--- 文件夹结构校验:同 owner、不能挂到自己的子孙目录下
-CREATE OR REPLACE FUNCTION check_folder_parent_valid()
-RETURNS TRIGGER AS $$
-DECLARE
- v_parent_owner BIGINT;
- v_parent_status folder_status_enum;
- v_has_cycle BOOLEAN := FALSE;
-BEGIN
- IF NEW.parent_folder_id IS NULL THEN
- RETURN NEW;
- END IF;
-
- SELECT owner_id, status
- INTO v_parent_owner, v_parent_status
- FROM folder
- WHERE folder_id = NEW.parent_folder_id;
-
- IF NOT FOUND THEN
- RAISE EXCEPTION 'Parent folder % does not exist', NEW.parent_folder_id;
- END IF;
-
- IF v_parent_owner <> NEW.owner_id THEN
- RAISE EXCEPTION 'Parent folder owner must match folder owner';
- END IF;
-
- IF NEW.status = 'active' AND v_parent_status <> 'active' THEN
- RAISE EXCEPTION 'Active folder cannot be placed under a non-active parent folder';
- END IF;
-
- IF NEW.folder_id IS NOT NULL THEN
- IF NEW.parent_folder_id = NEW.folder_id THEN
- RAISE EXCEPTION 'Folder cannot be its own parent';
- END IF;
-
- WITH RECURSIVE descendants AS (
- SELECT folder_id
- FROM folder
- WHERE parent_folder_id = NEW.folder_id
-
- UNION ALL
-
- SELECT f.folder_id
- FROM folder f
- JOIN descendants d ON f.parent_folder_id = d.folder_id
- )
- SELECT EXISTS(
- SELECT 1
- FROM descendants
- WHERE folder_id = NEW.parent_folder_id
- )
- INTO v_has_cycle;
-
- IF v_has_cycle THEN
- RAISE EXCEPTION 'Folder cannot be moved into its own descendant';
- END IF;
- END IF;
-
- RETURN NEW;
-END;
-$$ LANGUAGE plpgsql;
-
-DROP TRIGGER IF EXISTS trg_folder_validate ON folder;
-CREATE TRIGGER trg_folder_validate
-BEFORE INSERT OR UPDATE OF parent_folder_id, owner_id, status ON folder
-FOR EACH ROW
-EXECUTE FUNCTION check_folder_parent_valid();
-
--- 文件落目录校验:active 文件必须落在 active 且同 owner 的目录下
-CREATE OR REPLACE FUNCTION check_file_folder_valid()
-RETURNS TRIGGER AS $$
-DECLARE
- v_folder_owner BIGINT;
- v_folder_status folder_status_enum;
-BEGIN
- SELECT owner_id, status
- INTO v_folder_owner, v_folder_status
- FROM folder
- WHERE folder_id = NEW.folder_id;
-
- IF NOT FOUND THEN
- RAISE EXCEPTION 'Folder % does not exist', NEW.folder_id;
- END IF;
-
- IF NEW.status = 'active' THEN
- IF v_folder_owner <> NEW.owner_id THEN
- RAISE EXCEPTION 'File owner must match folder owner';
- END IF;
-
- IF v_folder_status <> 'active' THEN
- RAISE EXCEPTION 'Active file cannot be placed under a non-active folder';
- END IF;
- END IF;
-
- RETURN NEW;
-END;
-$$ LANGUAGE plpgsql;
-
-DROP TRIGGER IF EXISTS trg_file_validate ON "file";
-CREATE TRIGGER trg_file_validate
-BEFORE INSERT OR UPDATE OF folder_id, owner_id, status ON "file"
-FOR EACH ROW
-EXECUTE FUNCTION check_file_folder_valid();
-
--- 文件变更后:回算 storage_object.ref_count、user.storage_used、folder.cached_size
-CREATE OR REPLACE FUNCTION sync_file_derived_fields()
-RETURNS TRIGGER AS $$
-BEGIN
- IF TG_OP = 'INSERT' THEN
- PERFORM recalc_storage_object_ref_count(NEW.storage_object_id);
- PERFORM recalc_user_storage_used(NEW.owner_id);
- PERFORM recalc_folder_cached_size(NEW.folder_id);
- RETURN NULL;
- ELSIF TG_OP = 'UPDATE' THEN
- PERFORM recalc_storage_object_ref_count(OLD.storage_object_id);
- IF NEW.storage_object_id IS DISTINCT FROM OLD.storage_object_id THEN
- PERFORM recalc_storage_object_ref_count(NEW.storage_object_id);
- END IF;
-
- PERFORM recalc_user_storage_used(OLD.owner_id);
- IF NEW.owner_id IS DISTINCT FROM OLD.owner_id THEN
- PERFORM recalc_user_storage_used(NEW.owner_id);
- END IF;
-
- PERFORM recalc_folder_cached_size(OLD.folder_id);
- IF NEW.folder_id IS DISTINCT FROM OLD.folder_id THEN
- PERFORM recalc_folder_cached_size(NEW.folder_id);
- END IF;
- RETURN NULL;
- ELSE
- PERFORM recalc_storage_object_ref_count(OLD.storage_object_id);
- PERFORM recalc_user_storage_used(OLD.owner_id);
- PERFORM recalc_folder_cached_size(OLD.folder_id);
- RETURN NULL;
- END IF;
-END;
-$$ LANGUAGE plpgsql;
-
-DROP TRIGGER IF EXISTS trg_file_sync_derived_fields ON "file";
-CREATE TRIGGER trg_file_sync_derived_fields
-AFTER INSERT OR UPDATE OR DELETE ON "file"
-FOR EACH ROW
-EXECUTE FUNCTION sync_file_derived_fields();
-
--- 目录结构变化后:回算目录大小
-CREATE OR REPLACE FUNCTION sync_folder_cached_size()
-RETURNS TRIGGER AS $$
-BEGIN
- IF TG_OP = 'INSERT' THEN
- PERFORM recalc_folder_cached_size(NEW.folder_id);
- PERFORM recalc_folder_cached_size(NEW.parent_folder_id);
- RETURN NULL;
- ELSIF TG_OP = 'UPDATE' THEN
- PERFORM recalc_folder_cached_size(OLD.parent_folder_id);
- PERFORM recalc_folder_cached_size(NEW.folder_id);
- IF NEW.parent_folder_id IS DISTINCT FROM OLD.parent_folder_id THEN
- PERFORM recalc_folder_cached_size(NEW.parent_folder_id);
- END IF;
- RETURN NULL;
- ELSE
- PERFORM recalc_folder_cached_size(OLD.parent_folder_id);
- RETURN NULL;
- END IF;
-END;
-$$ LANGUAGE plpgsql;
-
-DROP TRIGGER IF EXISTS trg_folder_sync_cached_size ON folder;
-CREATE TRIGGER trg_folder_sync_cached_size
-AFTER INSERT OR DELETE OR UPDATE OF parent_folder_id, status ON folder
-FOR EACH ROW
-EXECUTE FUNCTION sync_folder_cached_size();
-
--- =========================
--- 4) 初始化缓存字段
--- =========================
-
-UPDATE storage_object so
-SET ref_count = x.ref_count
-FROM (
- SELECT storage_object_id, COUNT(*) AS ref_count
- FROM "file"
- WHERE status = 'active'
- GROUP BY storage_object_id
-) x
-WHERE so.object_id = x.storage_object_id;
-
-UPDATE storage_object so
-SET ref_count = 0
-WHERE NOT EXISTS (
- SELECT 1
- FROM "file" f
- WHERE f.storage_object_id = so.object_id
- AND f.status = 'active'
-);
-
-UPDATE "user" u
-SET storage_used = x.total_size
-FROM (
- SELECT owner_id AS user_id, COALESCE(SUM(file_size), 0) AS total_size
- FROM "file"
- WHERE status = 'active'
- GROUP BY owner_id
-) x
-WHERE u.user_id = x.user_id;
-
-UPDATE "user" u
-SET storage_used = 0
-WHERE NOT EXISTS (
- SELECT 1
- FROM "file" f
- WHERE f.owner_id = u.user_id
- AND f.status = 'active'
-);
-
-SELECT rebuild_all_folder_cached_size();
-
--- =========================
--- 5) 视图重建
--- =========================
-DROP VIEW IF EXISTS v_shared_with_me;
-DROP VIEW IF EXISTS v_user_permissions;
-DROP VIEW IF EXISTS v_user_recycle_bin;
-DROP VIEW IF EXISTS v_full_path;
-DROP VIEW IF EXISTS v_file_folder_details;
-DROP VIEW IF EXISTS v_user_storage_summary;
-DROP VIEW IF EXISTS v_admin_share_overview;
-DROP VIEW IF EXISTS v_user_dashboard;
-
--- 文件 / 文件夹统一明细视图
-CREATE VIEW v_file_folder_details AS
-SELECT
- 'file' AS item_type,
- f.file_id AS id,
- f.file_name AS name,
- f.file_size AS size,
- f.mime_type,
- f.folder_id AS parent_id,
- f.owner_id,
- u.username AS owner_name,
- f.created_at,
- f.updated_at
-FROM "file" f
-JOIN "user" u ON f.owner_id = u.user_id
-JOIN storage_object so ON f.storage_object_id = so.object_id
-WHERE f.status = 'active'
- AND so.upload_status = 'active'
-
-UNION ALL
-
-SELECT
- 'folder' AS item_type,
- fo.folder_id AS id,
- fo.folder_name AS name,
- fo.cached_size AS size,
- 'inode/directory' AS mime_type,
- fo.parent_folder_id AS parent_id,
- fo.owner_id,
- u.username AS owner_name,
- fo.created_at,
- fo.updated_at
-FROM folder fo
-JOIN "user" u ON fo.owner_id = u.user_id
-WHERE fo.status = 'active';
-
--- 用户权限视图:同时展开 direct user 授权和 group 授权
-CREATE VIEW v_user_permissions AS
-SELECT
- acl.id AS acl_id,
- u.user_id,
- u.username AS user_name,
- COALESCE(f.file_id, fo.folder_id) AS item_id,
- COALESCE(f.file_name, fo.folder_name) AS item_name,
- CASE WHEN acl.file_id IS NOT NULL THEN 'file' ELSE 'folder' END AS item_type,
- acl.permission_role,
- acl.can_preview,
- acl.can_download,
- acl.can_save,
- acl.can_share,
- acl.expire_at,
- 'direct'::TEXT AS grant_source
-FROM acl
-JOIN "user" u ON acl.user_id = u.user_id
-LEFT JOIN "file" f ON acl.file_id = f.file_id AND f.status = 'active'
-LEFT JOIN folder fo ON acl.folder_id = fo.folder_id AND fo.status = 'active'
-WHERE acl.user_id IS NOT NULL
- AND (
- (acl.file_id IS NOT NULL AND f.file_id IS NOT NULL)
- OR
- (acl.folder_id IS NOT NULL AND fo.folder_id IS NOT NULL)
- )
-
-UNION ALL
-
-SELECT
- acl.id AS acl_id,
- u.user_id,
- u.username AS user_name,
- COALESCE(f.file_id, fo.folder_id) AS item_id,
- COALESCE(f.file_name, fo.folder_name) AS item_name,
- CASE WHEN acl.file_id IS NOT NULL THEN 'file' ELSE 'folder' END AS item_type,
- acl.permission_role,
- acl.can_preview,
- acl.can_download,
- acl.can_save,
- acl.can_share,
- acl.expire_at,
- 'group'::TEXT AS grant_source
-FROM acl
-JOIN user_group_member ugm ON acl.group_id = ugm.group_id
-JOIN "user" u ON ugm.user_id = u.user_id
-LEFT JOIN "file" f ON acl.file_id = f.file_id AND f.status = 'active'
-LEFT JOIN folder fo ON acl.folder_id = fo.folder_id AND fo.status = 'active'
-WHERE acl.group_id IS NOT NULL
- AND (
- (acl.file_id IS NOT NULL AND f.file_id IS NOT NULL)
- OR
- (acl.folder_id IS NOT NULL AND fo.folder_id IS NOT NULL)
- );
-
--- 用户空间汇总
-CREATE VIEW v_user_storage_summary AS
-SELECT
- user_id,
- username,
- status,
- storage_limit,
- storage_used,
- CASE
- WHEN storage_limit <= 0 THEN 0::NUMERIC(10,2)
- ELSE ROUND((storage_used::NUMERIC / storage_limit::NUMERIC) * 100, 2)
- END AS storage_used_pct
-FROM "user";
-
--- “共享给我”:基于 share + share_member,包含待接受 / 已接受两种状态
-CREATE VIEW v_shared_with_me AS
-SELECT
- s.share_id,
- CASE WHEN s.file_id IS NOT NULL THEN 'file' ELSE 'folder' END AS item_type,
- COALESCE(f.file_id, fo.folder_id) AS item_id,
- COALESCE(f.file_name, fo.folder_name) AS item_name,
- owner.user_id AS shared_by_user_id,
- owner.username AS shared_by,
- sm.user_id AS shared_to_user_id,
- sm.status AS member_status,
- s.share_type,
- s.permission_role,
- s.allow_preview,
- s.allow_download,
- s.allow_save,
- s.allow_reshare,
- sm.target_folder_id,
- sm.accepted_at,
- s.expire_time,
- s.created_at AS shared_at
-FROM share s
-JOIN share_member sm ON s.share_id = sm.share_id
-JOIN "user" owner ON s.user_id = owner.user_id
-LEFT JOIN "file" f ON s.file_id = f.file_id AND f.status = 'active'
-LEFT JOIN folder fo ON s.folder_id = fo.folder_id AND fo.status = 'active'
-WHERE sm.user_id IS NOT NULL
- AND sm.status IN ('pending', 'accepted')
- AND s.status = 'active'
- AND (s.expire_time IS NULL OR s.expire_time > CURRENT_TIMESTAMP)
- AND (
- (s.file_id IS NOT NULL AND f.file_id IS NOT NULL)
- OR
- (s.folder_id IS NOT NULL AND fo.folder_id IS NOT NULL)
- )
-
-UNION ALL
-
-SELECT
- s.share_id,
- CASE WHEN s.file_id IS NOT NULL THEN 'file' ELSE 'folder' END AS item_type,
- COALESCE(f.file_id, fo.folder_id) AS item_id,
- COALESCE(f.file_name, fo.folder_name) AS item_name,
- owner.user_id AS shared_by_user_id,
- owner.username AS shared_by,
- ugm.user_id AS shared_to_user_id,
- sm.status AS member_status,
- s.share_type,
- s.permission_role,
- s.allow_preview,
- s.allow_download,
- s.allow_save,
- s.allow_reshare,
- sm.target_folder_id,
- sm.accepted_at,
- s.expire_time,
- s.created_at AS shared_at
-FROM share s
-JOIN share_member sm ON s.share_id = sm.share_id
-JOIN user_group_member ugm ON sm.group_id = ugm.group_id
-JOIN "user" owner ON s.user_id = owner.user_id
-LEFT JOIN "file" f ON s.file_id = f.file_id AND f.status = 'active'
-LEFT JOIN folder fo ON s.folder_id = fo.folder_id AND fo.status = 'active'
-WHERE sm.group_id IS NOT NULL
- AND sm.status IN ('pending', 'accepted')
- AND s.status = 'active'
- AND (s.expire_time IS NULL OR s.expire_time > CURRENT_TIMESTAMP)
- AND (
- (s.file_id IS NOT NULL AND f.file_id IS NOT NULL)
- OR
- (s.folder_id IS NOT NULL AND fo.folder_id IS NOT NULL)
- );
-
--- 目录全路径:按 owner 隔离,且只看 active 目录
-CREATE VIEW v_full_path AS
-WITH RECURSIVE folder_path AS (
- SELECT
- folder_id,
- owner_id,
- parent_folder_id,
- folder_name,
- CAST(folder_name AS VARCHAR(2048)) AS path
- FROM folder
- WHERE parent_folder_id IS NULL
- AND status = 'active'
-
- UNION ALL
-
- SELECT
- f.folder_id,
- f.owner_id,
- f.parent_folder_id,
- f.folder_name,
- fp.path || '/' || f.folder_name AS path
- FROM folder f
- JOIN folder_path fp
- ON f.parent_folder_id = fp.folder_id
- AND f.owner_id = fp.owner_id
- WHERE f.status = 'active'
-)
-SELECT
- folder_id AS id,
- owner_id,
- parent_folder_id,
- path
-FROM folder_path;
-
--- 回收站视图:修正 file.owner_id,并改用 cached_size
-CREATE VIEW v_user_recycle_bin AS
-SELECT
- 'file' AS item_type,
- f.file_id AS id,
- f.file_name AS name,
- f.file_size AS size,
- f.folder_id AS parent_id,
- f.owner_id,
- u.username AS owner_name,
- f.deleted_at
-FROM "file" f
-JOIN "user" u ON f.owner_id = u.user_id
-WHERE f.status = 'deleted'
- AND f.deleted_at IS NOT NULL
-
-UNION ALL
-
-SELECT
- 'folder' AS item_type,
- fo.folder_id AS id,
- fo.folder_name AS name,
- fo.cached_size AS size,
- fo.parent_folder_id AS parent_id,
- fo.owner_id,
- u.username AS owner_name,
- fo.deleted_at
-FROM folder fo
-JOIN "user" u ON fo.owner_id = u.user_id
-WHERE fo.status = 'deleted'
- AND fo.deleted_at IS NOT NULL;
-
--- 管理员查看分享链接总览
-CREATE VIEW v_admin_share_overview AS
-SELECT
- s.share_id,
- s.share_code,
- s.status,
- s.share_type,
- s.user_id AS created_by_user_id,
- u.username AS created_by,
- CASE WHEN s.file_id IS NOT NULL THEN 'file' ELSE 'folder' END AS item_type,
- COALESCE(f.file_id, fo.folder_id) AS item_id,
- COALESCE(f.file_name, fo.folder_name) AS item_name,
- s.permission_role,
- s.allow_preview,
- s.allow_download,
- s.allow_save,
- s.allow_reshare,
- s.require_login,
- s.password_hash IS NOT NULL AS has_password,
- s.expire_time,
- s.visit_count,
- s.download_count,
- s.created_at,
- s.updated_at
-FROM share s
-JOIN "user" u ON s.user_id = u.user_id
-LEFT JOIN "file" f ON s.file_id = f.file_id
-LEFT JOIN folder fo ON s.folder_id = fo.folder_id;
-
--- 管理员用户仪表盘
-CREATE VIEW v_user_dashboard AS
-SELECT
- u.user_id,
- u.username,
- u.email,
- u.status,
- u.storage_limit,
- u.storage_used,
- CASE
- WHEN u.storage_limit <= 0 THEN 0::NUMERIC(10,2)
- ELSE ROUND((u.storage_used::NUMERIC / u.storage_limit::NUMERIC) * 100, 2)
- END AS storage_used_pct,
- COALESCE(f.file_count, 0) AS active_file_count,
- COALESCE(fo.folder_count, 0) AS active_folder_count,
- COALESCE(s.active_share_count, 0) AS active_share_count,
- COALESCE(fv.favorite_count, 0) AS favorite_count,
- u.last_login_at,
- u.created_at
-FROM "user" u
-LEFT JOIN (
- SELECT owner_id, COUNT(*) AS file_count
- FROM "file"
- WHERE status = 'active'
- GROUP BY owner_id
-) f ON u.user_id = f.owner_id
-LEFT JOIN (
- SELECT owner_id, COUNT(*) AS folder_count
- FROM folder
- WHERE status = 'active'
- GROUP BY owner_id
-) fo ON u.user_id = fo.owner_id
-LEFT JOIN (
- SELECT user_id, COUNT(*) AS active_share_count
- FROM share
- WHERE status = 'active'
- AND (expire_time IS NULL OR expire_time > CURRENT_TIMESTAMP)
- GROUP BY user_id
-) s ON u.user_id = s.user_id
-LEFT JOIN (
- SELECT user_id, COUNT(*) AS favorite_count
- FROM favorite_item
- GROUP BY user_id
-) fv ON u.user_id = fv.user_id;
From 1ab6955a89d15d1c5508ad0b7d0dc53c742a1ad8 Mon Sep 17 00:00:00 2001
From: 0neStep <146049978+AperturePlus@users.noreply.github.com>
Date: Sat, 21 Mar 2026 15:46:43 +0800
Subject: [PATCH 2/8] feat(app): add SQLAlchemy engine session bootstrap
---
app/src/db/session.py | 12 ++++++++++++
1 file changed, 12 insertions(+)
create mode 100644 app/src/db/session.py
diff --git a/app/src/db/session.py b/app/src/db/session.py
new file mode 100644
index 0000000..478e12b
--- /dev/null
+++ b/app/src/db/session.py
@@ -0,0 +1,12 @@
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+import os
+
+DATABASE_URL= os.getenv("DATABASE_URL")
+
+if not DATABASE_URL:
+ raise ValueError("DATABASE_URL environment variable is not set")
+
+engine = create_engine(
+ url=DATABASE_URL,
+)
\ No newline at end of file
From c65a789b056b57cf961723811f39986e9e1fbd27 Mon Sep 17 00:00:00 2001
From: 0neStep <146049978+AperturePlus@users.noreply.github.com>
Date: Sat, 21 Mar 2026 15:47:03 +0800
Subject: [PATCH 3/8] feat(mock): build stateful mock backend for auth, files,
sharing and admin
---
web/src/mock/handlers/auth.ts | 212 +++++++--
web/src/mock/handlers/file.ts | 434 +++++++++++++----
web/src/mock/handlers/folder.ts | 306 +++++++++---
web/src/mock/handlers/log.ts | 39 ++
web/src/mock/handlers/notification.ts | 116 +++++
web/src/mock/handlers/permission.ts | 142 ++++++
web/src/mock/handlers/recycle.ts | 150 ++++--
web/src/mock/handlers/share.ts | 246 ++++++++--
web/src/mock/handlers/storage.ts | 127 +++++
web/src/mock/handlers/upload.ts | 232 ++++++---
web/src/mock/handlers/user.ts | 340 +++++++++-----
web/src/mock/handlers/usergroup.ts | 189 +++++++-
web/src/mock/index.ts | 10 +-
web/src/mock/state.ts | 272 +++++++++++
web/src/mock/vfs.ts | 647 +++++++++++++++++++-------
15 files changed, 2856 insertions(+), 606 deletions(-)
create mode 100644 web/src/mock/handlers/log.ts
create mode 100644 web/src/mock/handlers/notification.ts
create mode 100644 web/src/mock/handlers/permission.ts
create mode 100644 web/src/mock/handlers/storage.ts
create mode 100644 web/src/mock/state.ts
diff --git a/web/src/mock/handlers/auth.ts b/web/src/mock/handlers/auth.ts
index 468d5aa..3c6b29d 100644
--- a/web/src/mock/handlers/auth.ts
+++ b/web/src/mock/handlers/auth.ts
@@ -1,53 +1,177 @@
import Mock from 'mockjs';
+import { addLog, addNotification, createMockId, mockUsers } from '../state';
export const setupAuthMocks = () => {
- // 登录接口
- Mock.mock(/\/api\/v1\/auth\/login/, 'post', {
- success: true,
- code: 200,
- message: 'Login successful',
- data: {
- token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.mock-access-token-@datetime',
- tokenType: 'Bearer',
- expiresIn: 3600,
- refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.mock-refresh-token-@datetime',
- user: {
- userId: 'user1',
- username: 'Demo User',
- email: 'demo@example.com',
- storageLimit: 107374182400, // 100GB
- storageUsed: 21474836480, // 20GB
- createdAt: '@datetime'
- }
- },
+ Mock.mock(/\/api\/v1\/auth\/login/, 'post', (options) => {
+ const { username, password } = JSON.parse(options.body || '{}');
+ const targetUser = mockUsers.find((user) =>
+ user.username.toLowerCase() === String(username || '').toLowerCase() ||
+ user.email.toLowerCase() === String(username || '').toLowerCase(),
+ );
+
+ if (!targetUser || !password) {
+ return {
+ success: false,
+ code: 401,
+ message: 'Invalid username or password',
+ data: null,
+ };
+ }
+
+ if (targetUser.status === 'suspended') {
+ return {
+ success: false,
+ code: 403,
+ message: 'Account is suspended',
+ data: null,
+ };
+ }
+
+ addLog('user_login', { userId: targetUser.userId, username: targetUser.username });
+
+ return {
+ success: true,
+ code: 200,
+ message: 'Login successful',
+ data: {
+ token: `mock-access-${createMockId('token')}`,
+ tokenType: 'Bearer',
+ expiresIn: 3600,
+ refreshToken: `mock-refresh-${createMockId('token')}`,
+ user: {
+ userId: targetUser.userId,
+ username: targetUser.username,
+ email: targetUser.email,
+ storageLimit: targetUser.storageLimit,
+ storageUsed: targetUser.storageUsed,
+ createdAt: targetUser.createdAt,
+ },
+ },
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/auth\/register/, 'post', (options) => {
+ const { username, email } = JSON.parse(options.body || '{}');
+
+ if (!username || !email) {
+ return {
+ success: false,
+ code: 400,
+ message: 'Username and email are required',
+ data: null,
+ };
+ }
+
+ const exists = mockUsers.some((user) =>
+ user.username.toLowerCase() === String(username).toLowerCase() ||
+ user.email.toLowerCase() === String(email).toLowerCase(),
+ );
+
+ if (exists) {
+ return {
+ success: false,
+ code: 409,
+ message: 'User already exists',
+ data: null,
+ };
+ }
+
+ const createdUser = {
+ userId: createMockId('user'),
+ username,
+ email,
+ storageLimit: 50 * 1024 * 1024 * 1024,
+ storageUsed: 0,
+ createdAt: new Date().toISOString(),
+ status: 'active' as const,
+ role: 'user' as const,
+ };
+
+ mockUsers.push(createdUser);
+
+ addLog('user_register', { userId: createdUser.userId, email: createdUser.email });
+ addNotification(`Registration email sent to ${createdUser.email}`, true);
+
+ return {
+ success: true,
+ code: 201,
+ message: 'Registration successful',
+ data: {
+ ...createdUser,
+ groups: [],
+ updatedAt: createdUser.createdAt,
+ lastLogin: createdUser.createdAt,
+ },
+ };
});
- // 刷新令牌接口
- Mock.mock(/\/api\/v1\/auth\/refresh/, 'post', {
- success: true,
- code: 200,
- message: 'Token refreshed successfully',
- data: {
- token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.new-access-token-@datetime',
- tokenType: 'Bearer',
- expiresIn: 3600,
- refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.new-refresh-token-@datetime',
- user: {
- userId: 'user1',
- username: 'Demo User',
- email: 'demo@example.com',
- storageLimit: 107374182400,
- storageUsed: 21474836480,
- createdAt: '@datetime'
- }
+ Mock.mock(/\/api\/v1\/auth\/forgot-password/, 'post', (options) => {
+ const { email } = JSON.parse(options.body || '{}');
+
+ if (!email) {
+ return {
+ success: false,
+ code: 400,
+ message: 'Email is required',
+ data: null,
+ };
}
+
+ addLog('password_reset_request', { email });
+ addNotification(`Password reset email queued for ${email}`, true);
+
+ return {
+ success: true,
+ code: 200,
+ message: 'If this email exists, a reset link has been sent',
+ data: {
+ requestId: createMockId('pwd_reset'),
+ expiresInMinutes: 15,
+ },
+ };
});
- // 登出接口
- Mock.mock(/\/api\/v1\/auth\/logout/, 'post', {
- success: true,
- code: 200,
- message: 'Logout successful',
- data: null
+ Mock.mock(/\/api\/v1\/auth\/reset-password/, 'post', () => {
+ addLog('password_reset_complete', { status: 'ok' });
+
+ return {
+ success: true,
+ code: 200,
+ message: 'Password has been reset successfully',
+ data: null,
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/auth\/refresh/, 'post', () => {
+ return {
+ success: true,
+ code: 200,
+ message: 'Token refreshed successfully',
+ data: {
+ token: `mock-access-${createMockId('token')}`,
+ tokenType: 'Bearer',
+ expiresIn: 3600,
+ refreshToken: `mock-refresh-${createMockId('token')}`,
+ user: {
+ userId: mockUsers[0].userId,
+ username: mockUsers[0].username,
+ email: mockUsers[0].email,
+ storageLimit: mockUsers[0].storageLimit,
+ storageUsed: mockUsers[0].storageUsed,
+ createdAt: mockUsers[0].createdAt,
+ },
+ },
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/auth\/logout/, 'post', () => {
+ addLog('user_logout', { status: 'ok' });
+
+ return {
+ success: true,
+ code: 200,
+ message: 'Logout successful',
+ data: null,
+ };
});
-};
\ No newline at end of file
+};
diff --git a/web/src/mock/handlers/file.ts b/web/src/mock/handlers/file.ts
index 92cb971..6efe1b3 100644
--- a/web/src/mock/handlers/file.ts
+++ b/web/src/mock/handlers/file.ts
@@ -1,118 +1,392 @@
-import Mock from 'mockjs';
-import { vfsApi } from '../vfs';
import JSZip from 'jszip';
+import Mock from 'mockjs';
+import { addLog, addNotification } from '../state';
+import { vfsApi, type VfsNode } from '../vfs';
+
+function parseUrl(url: string) {
+ return new URL(url, 'http://localhost');
+}
-// Helper to convert base64 to blob
-function base64ToBlob(base64: string, type: string): Blob {
- const byteCharacters = atob(base64);
- const byteNumbers = new Array(byteCharacters.length);
- for (let i = 0; i < byteCharacters.length; i++) {
- byteNumbers[i] = byteCharacters.charCodeAt(i);
+function nodeToItem(node: VfsNode) {
+ if (node.type === 'folder') {
+ return {
+ itemType: 'folder' as const,
+ id: node.id,
+ name: node.name,
+ size: vfsApi.getFolderStats(node.id).totalSize,
+ ownerName: 'You',
+ updatedAt: node.updatedAt,
+ createdAt: node.createdAt,
+ parentFolderId: node.parent,
+ permission: node.permission || 'owner',
+ isStarred: node.isStarred || false,
+ };
}
- const byteArray = new Uint8Array(byteNumbers);
- return new Blob([byteArray], { type });
+
+ return {
+ itemType: 'file' as const,
+ id: node.id,
+ name: node.name,
+ size: node.size || 0,
+ mimeType: node.mimeType || 'application/octet-stream',
+ ownerName: 'You',
+ updatedAt: node.updatedAt,
+ createdAt: node.createdAt,
+ folderId: node.parent || 'root',
+ permission: node.permission || 'owner',
+ isStarred: node.isStarred || false,
+ };
}
+function buildMockFileBlob(file: VfsNode) {
+ if (file.content) {
+ const byteCharacters = atob(file.content);
+ const byteNumbers = new Array(byteCharacters.length);
+ for (let i = 0; i < byteCharacters.length; i += 1) {
+ byteNumbers[i] = byteCharacters.charCodeAt(i);
+ }
+ const byteArray = new Uint8Array(byteNumbers);
+ return new Blob([byteArray], { type: file.mimeType || 'application/octet-stream' });
+ }
+
+ if ((file.mimeType || '').startsWith('text/')) {
+ return new Blob([`Mock content for ${file.name}`], { type: file.mimeType || 'text/plain' });
+ }
+
+ if ((file.mimeType || '').startsWith('image/')) {
+ const svg = ``;
+ return new Blob([svg], { type: 'image/svg+xml' });
+ }
+
+ if ((file.mimeType || '').startsWith('audio/')) {
+ return new Blob([], { type: file.mimeType || 'audio/mpeg' });
+ }
+
+ if ((file.mimeType || '').startsWith('video/')) {
+ return new Blob([], { type: file.mimeType || 'video/mp4' });
+ }
+
+ if (file.mimeType === 'application/pdf') {
+ const text = `%PDF-1.4\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF`;
+ return new Blob([text], { type: 'application/pdf' });
+ }
+
+ return new Blob([`Binary file: ${file.name}`], { type: file.mimeType || 'application/octet-stream' });
+}
+
+function getSortedItems(items: VfsNode[], sort: string | null, order: string | null) {
+ const sortField = sort || 'name';
+ const sortOrder = order === 'desc' ? -1 : 1;
+
+ return [...items].sort((a, b) => {
+ if (a.type === 'folder' && b.type === 'file') return -1;
+ if (a.type === 'file' && b.type === 'folder') return 1;
+
+ let compareValue = 0;
+ if (sortField === 'size') {
+ compareValue = (a.size || 0) - (b.size || 0);
+ } else if (sortField === 'updatedAt') {
+ compareValue = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
+ } else if (sortField === 'createdAt') {
+ compareValue = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
+ } else {
+ compareValue = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
+ }
+
+ return compareValue * sortOrder;
+ });
+}
export const setupFileMocks = () => {
- // Download File
- Mock.mock(/\/api\/v1\/files\/(.+)\/download/, 'get', (options) => {
- const fileId = (options.url.match(/\/api\/v1\/files\/(.+)\/download/) || [])[1];
- const file = vfsApi.get(fileId);
+ Mock.mock(/\/api\/v1\/files\/?(\?.*)?$/, 'get', (options) => {
+ const url = parseUrl(options.url);
+ const folderId = url.searchParams.get('folderId') || 'root';
+ const search = url.searchParams.get('search');
+ const sort = url.searchParams.get('sort');
+ const order = url.searchParams.get('order');
+
+ const sourceItems = search
+ ? vfsApi.search(folderId, search)
+ : vfsApi.getChildren(folderId);
+
+ const sorted = getSortedItems(sourceItems, sort, order);
+ const mapped = sorted.map(nodeToItem);
+
+ return {
+ success: true,
+ code: 200,
+ data: {
+ items: mapped,
+ pagination: {
+ totalItems: mapped.length,
+ totalPages: 1,
+ perPage: mapped.length,
+ currentPage: 1,
+ hasPrev: false,
+ hasNext: false,
+ },
+ },
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/files\/starred$/, 'get', () => {
+ const starred = vfsApi.getStarred().map(nodeToItem);
+
+ return {
+ success: true,
+ code: 200,
+ data: {
+ items: starred,
+ pagination: {
+ totalItems: starred.length,
+ totalPages: 1,
+ perPage: starred.length,
+ currentPage: 1,
+ hasPrev: false,
+ hasNext: false,
+ },
+ },
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/files\/([^/]+)$/, 'get', (options) => {
+ const fileId = (options.url.match(/\/api\/v1\/files\/([^/?]+)/) || [])[1];
+ const node = vfsApi.get(fileId);
- if (!file || file.type !== 'file') {
+ if (!node || node.type !== 'file' || node.isTrashed) {
return {
success: false,
code: 404,
message: 'File not found',
+ data: null,
};
}
-
- // If the file has real content stored, decode and serve it
- if (file.content) {
- // We don't store mime type, so we use a generic one.
- // In a real backend, you'd store and retrieve the mime type.
- return base64ToBlob(file.content, file.mimeType || 'application/octet-stream');
- }
- // Fallback for files without stored content
- const content = `Mock content for ${file.name}`;
- const blob = new Blob([content], { type: 'application/octet-stream' });
-
- // Return the blob directly for download
- return blob;
+ return {
+ success: true,
+ code: 200,
+ data: {
+ ...nodeToItem(node),
+ status: true,
+ },
+ };
});
- // Preview File (similar to download for now)
- Mock.mock(/\/api\/v1\/files\/(.+)\/preview/, 'get', (options) => {
- const fileId = (options.url.match(/\/api\/v1\/files\/(.+)\/preview/) || [])[1];
- const file = vfsApi.get(fileId);
+ Mock.mock(/\/api\/v1\/files\/([^/]+)\/download$/, 'get', (options) => {
+ const fileId = (options.url.match(/\/api\/v1\/files\/([^/]+)\/download/) || [])[1];
+ const node = vfsApi.get(fileId);
- if (!file || file.type !== 'file') {
- return { success: false, code: 404, message: 'File not found' };
+ if (!node || node.type !== 'file') {
+ return {
+ success: false,
+ code: 404,
+ message: 'File not found',
+ data: null,
+ };
}
-
- if (file.content) {
- return base64ToBlob(file.content, file.mimeType || 'application/octet-stream');
+
+ addLog('file_download', { fileId: node.id, fileName: node.name });
+ return buildMockFileBlob(node);
+ });
+
+ Mock.mock(/\/api\/v1\/files\/([^/]+)\/preview$/, 'get', (options) => {
+ const fileId = (options.url.match(/\/api\/v1\/files\/([^/]+)\/preview/) || [])[1];
+ const node = vfsApi.get(fileId);
+
+ if (!node || node.type !== 'file') {
+ return {
+ success: false,
+ code: 404,
+ message: 'File not found',
+ data: null,
+ };
}
- const content = `Mock preview content for ${file.name}`;
- const blob = new Blob([content], { type: 'text/plain' });
- return blob;
+ return buildMockFileBlob(node);
});
- // Batch Download Files as Zip
- Mock.mock(/\/api\/v1\/files\/batch-download/, 'post', async (options) => {
- const { fileIds } = JSON.parse(options.body);
- const zip = new JSZip();
+ Mock.mock(/\/api\/v1\/files\/([^/]+)\/thumbnail$/, 'get', (options) => {
+ const fileId = (options.url.match(/\/api\/v1\/files\/([^/]+)\/thumbnail/) || [])[1];
+ const node = vfsApi.get(fileId);
- for (const fileId of fileIds) {
- const file = vfsApi.get(fileId);
- if (file && file.type === 'file') {
- // In a real scenario, you'd fetch file content. Here, we generate it.
- const fileContent = `Mock content for ${file.name}`;
- zip.file(file.name, fileContent);
- }
+ if (!node || node.type !== 'file') {
+ return {
+ success: false,
+ code: 404,
+ message: 'File not found',
+ data: null,
+ };
}
- const zipBlob = await zip.generateAsync({ type: 'blob' });
- return zipBlob;
+ const svg = ``;
+ return new Blob([svg], { type: 'image/svg+xml' });
});
- // Move File
- Mock.mock(/\/api\/v1\/files\/(.+)\/move/, 'patch', (options) => {
- const fileId = (options.url.match(/\/api\/v1\/files\/(.+)\/move/) || [])[1];
- const { targetFolderId } = JSON.parse(options.body);
- const movedFile = vfsApi.move(fileId, targetFolderId);
- return { success: true, code: 200, data: movedFile };
+ Mock.mock(/\/api\/v1\/files\/([^/]+)\/move$/, 'patch', (options) => {
+ const fileId = (options.url.match(/\/api\/v1\/files\/([^/]+)\/move/) || [])[1];
+ const { targetFolderId } = JSON.parse(options.body || '{}');
+
+ const moved = vfsApi.move(fileId, targetFolderId);
+ addLog('file_move', { fileId, targetFolderId });
+
+ return {
+ success: true,
+ code: 200,
+ data: {
+ fileId: moved.id,
+ targetFolderId,
+ movedAt: moved.updatedAt,
+ },
+ };
});
- // Rename File
- Mock.mock(/\/api\/v1\/files\/(?![^/]+\/(?:move|copy|download|preview|thumbnail)$)[^/]+$/, 'patch', (options) => {
- const fileId = options.url.match(/\/api\/v1\/files\/(.+)/)![1];
- const { fileName } = JSON.parse(options.body);
- const updatedFile = vfsApi.rename(fileId, fileName);
- return { success: true, code: 200, data: updatedFile };
+ Mock.mock(/\/api\/v1\/files\/([^/]+)\/copy$/, 'post', (options) => {
+ const fileId = (options.url.match(/\/api\/v1\/files\/([^/]+)\/copy/) || [])[1];
+ const { targetFolderId, newName } = JSON.parse(options.body || '{}');
+
+ const copied = vfsApi.copy(fileId, targetFolderId, newName);
+ addLog('file_copy', { fileId, targetFolderId, copiedFileId: copied.id });
+
+ return {
+ success: true,
+ code: 201,
+ data: {
+ fileId: copied.id,
+ originalFileId: fileId,
+ targetFolderId,
+ newName: copied.name,
+ copiedAt: copied.createdAt,
+ },
+ };
+ });
+
+ 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));
+
+ return {
+ success: true,
+ code: 200,
+ data: nodeToItem(node),
+ };
});
- // Delete File
- Mock.mock(/\/api\/v1\/files\/(.+)/, 'delete', (options) => {
- const fileId = (options.url.match(/\/api\/v1\/files\/(.+)/) || [])[1];
+ Mock.mock(/\/api\/v1\/files\/(?![^/]+\/(?:move|copy|download|preview|thumbnail|star)$)([^/?]+)$/, 'patch', (options) => {
+ const fileId = (options.url.match(/\/api\/v1\/files\/([^/?]+)/) || [])[1];
+ const { fileName } = JSON.parse(options.body || '{}');
+ const updated = vfsApi.rename(fileId, fileName);
+
+ return {
+ success: true,
+ code: 200,
+ data: {
+ ...nodeToItem(updated),
+ status: true,
+ },
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/files\/([^/]+)$/, 'delete', (options) => {
+ const fileId = (options.url.match(/\/api\/v1\/files\/([^/?]+)/) || [])[1];
+ const node = vfsApi.get(fileId);
+
+ if (!node || node.type !== 'file') {
+ return {
+ success: false,
+ code: 404,
+ message: 'File not found',
+ data: null,
+ };
+ }
+
vfsApi.delete(fileId);
- return { success: true, code: 200, data: { fileId, message: 'File moved to trash.' } };
+ addLog('file_delete', { fileId, fileName: node.name });
+
+ return {
+ success: true,
+ code: 200,
+ data: {
+ fileId,
+ fileName: node.name,
+ deletedAt: new Date().toISOString(),
+ },
+ };
});
- // Batch Delete Files
- Mock.mock(/\/api\/v1\/files\/batch/, 'post', (options) => {
- const { action, fileIds, targetFolderId } = JSON.parse(options.body);
- if (action === 'delete') {
- fileIds.forEach((id: string) => vfsApi.delete(id));
- return { success: true, code: 200, data: { successCount: fileIds.length } };
+ Mock.mock(/\/api\/v1\/files\/batch-download$/, 'post', async (options) => {
+ const { fileIds = [] } = JSON.parse(options.body || '{}');
+ const zip = new JSZip();
+
+ fileIds.forEach((fileId: string) => {
+ const node = vfsApi.get(fileId);
+ if (!node || node.type !== 'file' || node.isTrashed) return;
+ zip.file(node.name, `Mock content for ${node.name}`);
+ });
+
+ addLog('file_batch_download', { count: fileIds.length });
+ return zip.generateAsync({ type: 'blob' });
+ });
+
+ Mock.mock(/\/api\/v1\/files\/batch$/, 'post', (options) => {
+ const { action, fileIds = [], targetFolderId } = JSON.parse(options.body || '{}');
+
+ if (!Array.isArray(fileIds) || fileIds.length === 0) {
+ return {
+ success: false,
+ code: 400,
+ message: 'fileIds is required',
+ data: null,
+ };
}
- if (action === 'move') {
- fileIds.forEach((id: string) => vfsApi.move(id, targetFolderId));
- return { success: true, code: 200, data: { successCount: fileIds.length } };
+
+ let succeeded = 0;
+
+ if (action === 'delete') {
+ fileIds.forEach((id: string) => {
+ const node = vfsApi.get(id);
+ if (node) {
+ vfsApi.delete(id);
+ succeeded += 1;
+ }
+ });
+ addLog('file_batch_delete', { count: succeeded });
+ addNotification(`${succeeded} files moved to recycle bin`, true);
+ } else if (action === 'move') {
+ fileIds.forEach((id: string) => {
+ const node = vfsApi.get(id);
+ if (node) {
+ vfsApi.move(id, targetFolderId);
+ succeeded += 1;
+ }
+ });
+ addLog('file_batch_move', { count: succeeded, targetFolderId: targetFolderId || '' });
+ } else if (action === 'copy') {
+ fileIds.forEach((id: string) => {
+ const node = vfsApi.get(id);
+ if (node) {
+ vfsApi.copy(id, targetFolderId);
+ succeeded += 1;
+ }
+ });
+ addLog('file_batch_copy', { count: succeeded, targetFolderId: targetFolderId || '' });
+ } else {
+ return {
+ success: false,
+ code: 400,
+ message: 'Unsupported batch action',
+ data: null,
+ };
}
- return { success: false, code: 400, message: 'Action not mocked' };
+
+ return {
+ success: true,
+ code: 200,
+ data: {
+ processed: fileIds.length,
+ action,
+ succeeded,
+ },
+ };
});
-};
\ No newline at end of file
+};
diff --git a/web/src/mock/handlers/folder.ts b/web/src/mock/handlers/folder.ts
index 3a942ae..3109fa8 100644
--- a/web/src/mock/handlers/folder.ts
+++ b/web/src/mock/handlers/folder.ts
@@ -1,112 +1,280 @@
import Mock from 'mockjs';
-import { vfsApi } from '../vfs';
+import { addLog } from '../state';
+import { vfsApi, type VfsNode } from '../vfs';
+
+function parseUrl(url: string) {
+ return new URL(url, 'http://localhost');
+}
+
+function nodeToItem(node: VfsNode) {
+ if (node.type === 'folder') {
+ return {
+ itemType: 'folder' as const,
+ id: node.id,
+ name: node.name,
+ size: vfsApi.getFolderStats(node.id).totalSize,
+ ownerName: 'You',
+ updatedAt: node.updatedAt,
+ createdAt: node.createdAt,
+ parentFolderId: node.parent,
+ permission: node.permission || 'owner',
+ isStarred: node.isStarred || false,
+ };
+ }
+
+ return {
+ itemType: 'file' as const,
+ id: node.id,
+ name: node.name,
+ size: node.size || 0,
+ mimeType: node.mimeType || 'application/octet-stream',
+ ownerName: 'You',
+ updatedAt: node.updatedAt,
+ createdAt: node.createdAt,
+ folderId: node.parent || 'root',
+ permission: node.permission || 'owner',
+ isStarred: node.isStarred || false,
+ };
+}
+
+function sortItems(nodes: VfsNode[], sort: string | null, order: string | null) {
+ const sortField = sort || 'name';
+ const sortOrder = order === 'desc' ? -1 : 1;
+
+ return [...nodes].sort((a, b) => {
+ if (a.type === 'folder' && b.type === 'file') return -1;
+ if (a.type === 'file' && b.type === 'folder') return 1;
+
+ let value = 0;
+ if (sortField === 'size') {
+ value = (a.size || 0) - (b.size || 0);
+ } else if (sortField === 'updatedAt') {
+ value = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
+ } else if (sortField === 'createdAt') {
+ value = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
+ } else {
+ value = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
+ }
+
+ return value * sortOrder;
+ });
+}
+
+function buildPagination(count: number) {
+ return {
+ totalItems: count,
+ totalPages: 1,
+ perPage: count,
+ currentPage: 1,
+ hasPrev: false,
+ hasNext: false,
+ };
+}
export const setupFolderMocks = () => {
- // Get Folder Contents
- Mock.mock(/\/api\/v1\/folders\/([^\/]+)(?:\?.*)?$/, 'get', (options) => {
- const match = options.url.match(/\/api\/v1\/folders\/([^\/]+)(?:\?.*)?$/);
- const folderId = match ? match[1] : 'root';
- console.log('🔍 Mock API: Getting contents for folder', folderId);
-
- const children = vfsApi.getChildren(folderId);
- console.log('📁 Mock API: Found', children.length, 'children for folder', folderId);
-
- // 检查VFS节点是否存在
- const folderNode = vfsApi.get(folderId);
- if (!folderNode) {
- console.error('🚨 Mock API: Folder not found in VFS:', folderId);
+ Mock.mock(/\/api\/v1\/folders$/, 'get', (options) => {
+ const url = parseUrl(options.url);
+ const parentId = url.searchParams.get('parentId') || 'root';
+
+ const folders = vfsApi
+ .getChildren(parentId)
+ .filter((node) => node.type === 'folder')
+ .map(nodeToItem);
+
+ return {
+ success: true,
+ code: 200,
+ data: {
+ items: folders,
+ pagination: buildPagination(folders.length),
+ },
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/folders\/([^/]+)\/path$/, 'get', (options) => {
+ const folderId = (options.url.match(/\/api\/v1\/folders\/([^/]+)\/path/) || [])[1] || 'root';
+ const path = vfsApi.getPath(folderId);
+
+ if (!path.length) {
return {
success: false,
code: 404,
- message: `Folder ${folderId} not found`,
+ message: 'Folder not found',
data: null,
};
}
- // 简化API调试输出
- const childIds = children.map(c => c.id);
- const uniqueChildIds = new Set(childIds);
- if (childIds.length !== uniqueChildIds.size) {
- console.error('🚨 Mock API: VFS has duplicate children for folder', folderId);
- }
+ const pathItems = path.map((node) => ({
+ folderId: node.id,
+ name: node.id === 'root' ? 'My Files' : node.name,
+ }));
- const response = {
+ return {
success: true,
code: 200,
data: {
- items: children.map(c => ({...c, itemType: c.type, permission: c.permission || 'owner' })), // Ensure permission is passed
- pagination: { totalItems: children.length, totalPages: 1, perPage: children.length, currentPage: 1 },
+ fullPath: pathItems.map((item) => item.name).join('/'),
+ pathItems,
},
};
-
- console.log('📤 Mock API: Returning response:', {
- success: response.success,
- itemCount: response.data.items.length,
- firstItem: response.data.items[0]
- });
-
- return response;
});
- // Get Folder Path
- Mock.mock(/\/api\/v1\/folders\/([^\/]+)\/path$/, 'get', (options) => {
- const match = options.url.match(/\/api\/v1\/folders\/([^\/]+)\/path$/);
- const folderId = match ? match[1] : 'root';
- console.log('🔍 Mock API: Getting path for folder', folderId);
-
- const folderNode = vfsApi.get(folderId);
- if (!folderNode) {
- console.error('🚨 Mock API: Folder not found in VFS for path:', folderId);
+ Mock.mock(/\/api\/v1\/folders\/([^/]+)\/size$/, 'get', (options) => {
+ const folderId = (options.url.match(/\/api\/v1\/folders\/([^/]+)\/size/) || [])[1];
+
+ try {
+ const stats = vfsApi.getFolderStats(folderId);
+ return {
+ success: true,
+ code: 200,
+ data: stats,
+ };
+ } catch {
return {
success: false,
code: 404,
- message: `Folder ${folderId} not found`,
+ message: 'Folder not found',
data: null,
};
}
-
- const path = vfsApi.getPath(folderId);
- console.log('📤 Mock API: Returning path response:', {
+ });
+
+ Mock.mock(/\/api\/v1\/folders\/([^/]+)\/copy$/, 'post', (options) => {
+ const folderId = (options.url.match(/\/api\/v1\/folders\/([^/]+)\/copy/) || [])[1];
+ const { targetParentId, newName } = JSON.parse(options.body || '{}');
+
+ const copied = vfsApi.copy(folderId, targetParentId, newName);
+ addLog('folder_copy', { folderId, copiedFolderId: copied.id, targetParentId });
+
+ return {
success: true,
- pathCount: path.length,
- pathItems: path.map(p => ({ folderId: p.id, name: p.name }))
- });
-
+ code: 201,
+ data: nodeToItem(copied),
+ };
+ });
+
+ 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));
+
+ return {
+ success: true,
+ code: 200,
+ data: nodeToItem(updated),
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/folders\/([^/]+)(?:\?.*)?$/, 'get', (options) => {
+ const match = options.url.match(/\/api\/v1\/folders\/([^/?]+)/);
+ const folderId = match ? match[1] : 'root';
+ const url = parseUrl(options.url);
+
+ const search = url.searchParams.get('search');
+ const sort = url.searchParams.get('sort');
+ const order = url.searchParams.get('order');
+
+ const folder = vfsApi.get(folderId);
+ if (!folder || folder.type !== 'folder') {
+ return {
+ success: false,
+ code: 404,
+ message: 'Folder not found',
+ data: null,
+ };
+ }
+
+ const sourceItems = search ? vfsApi.search(folderId, search) : vfsApi.getChildren(folderId);
+ const sorted = sortItems(sourceItems, sort, order);
+ const items = sorted.map(nodeToItem);
+
return {
success: true,
code: 200,
data: {
- pathItems: path.map(p => ({ folderId: p.id, name: p.name })),
+ items,
+ pagination: buildPagination(items.length),
},
};
});
- // Create Folder
- Mock.mock(/\/api\/v1\/folders/, 'post', (options) => {
- const { folderName, parentFolderId } = JSON.parse(options.body);
+ Mock.mock(/\/api\/v1\/folders$/, 'post', (options) => {
+ const { folderName, parentFolderId } = JSON.parse(options.body || '{}');
+
+ if (!folderName || !parentFolderId) {
+ return {
+ success: false,
+ code: 400,
+ message: 'folderName and parentFolderId are required',
+ data: null,
+ };
+ }
+
const newFolder = vfsApi.createFolder(parentFolderId, folderName);
-
+ addLog('folder_create', { folderId: newFolder.id, folderName });
+
return {
success: true,
code: 201,
- data: newFolder,
+ data: nodeToItem(newFolder),
};
});
- // Delete Folder
- Mock.mock(/\/api\/v1\/folders\/([^\/]+)$/, 'delete', (options) => {
- const match = options.url.match(/\/api\/v1\/folders\/([^\/]+)$/);
- const folderId = match ? match[1] : '';
- vfsApi.delete(folderId);
- return { success: true, code: 200, data: { folderId, message: 'Folder moved to trash.' } };
- });
+ Mock.mock(/\/api\/v1\/folders\/([^/]+)\/move$/, 'patch', (options) => {
+ const folderId = (options.url.match(/\/api\/v1\/folders\/([^/]+)\/move/) || [])[1];
+ const { targetParentId } = JSON.parse(options.body || '{}');
- // Move Folder
- Mock.mock(/\/api\/v1\/folders\/([^\/]+)\/move$/, 'patch', (options) => {
- const match = options.url.match(/\/api\/v1\/folders\/([^\/]+)\/move$/);
- const folderId = match ? match[1] : '';
- const { targetParentId } = JSON.parse(options.body);
const movedFolder = vfsApi.move(folderId, targetParentId);
- return { success: true, code: 200, data: movedFolder };
+ addLog('folder_move', { folderId, targetParentId });
+
+ return {
+ success: true,
+ code: 200,
+ data: {
+ folderId: movedFolder.id,
+ targetParentId,
+ movedAt: movedFolder.updatedAt,
+ },
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/folders\/([^/]+)$/, 'patch', (options) => {
+ const folderId = (options.url.match(/\/api\/v1\/folders\/([^/?]+)/) || [])[1];
+ const { folderName } = JSON.parse(options.body || '{}');
+
+ const renamed = vfsApi.rename(folderId, folderName);
+
+ return {
+ success: true,
+ code: 200,
+ data: nodeToItem(renamed),
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/folders\/([^/]+)$/, 'delete', (options) => {
+ const folderId = (options.url.match(/\/api\/v1\/folders\/([^/?]+)/) || [])[1];
+ const folder = vfsApi.get(folderId);
+
+ if (!folder || folder.type !== 'folder') {
+ return {
+ success: false,
+ code: 404,
+ message: 'Folder not found',
+ data: null,
+ };
+ }
+
+ vfsApi.delete(folderId);
+ addLog('folder_delete', { folderId, folderName: folder.name });
+
+ return {
+ success: true,
+ code: 200,
+ data: {
+ folderId,
+ folderName: folder.name,
+ deletedAt: new Date().toISOString(),
+ },
+ };
});
-};
\ No newline at end of file
+};
diff --git a/web/src/mock/handlers/log.ts b/web/src/mock/handlers/log.ts
new file mode 100644
index 0000000..25744e4
--- /dev/null
+++ b/web/src/mock/handlers/log.ts
@@ -0,0 +1,39 @@
+import Mock from 'mockjs';
+import { mockLogs } from '../state';
+
+export const setupLogMocks = () => {
+ Mock.mock(/\/api\/v1\/logs(?:\?.*)?$/, '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 operation = url.searchParams.get('operation');
+ const startDate = url.searchParams.get('startDate');
+ const endDate = url.searchParams.get('endDate');
+
+ const filtered = mockLogs.filter((item) => {
+ if (operation && item.operation !== operation) return false;
+ if (startDate && new Date(item.performedAt).getTime() < new Date(startDate).getTime()) return false;
+ if (endDate && new Date(item.performedAt).getTime() > new Date(endDate).getTime()) return false;
+ return true;
+ });
+
+ const start = (Math.max(page, 1) - 1) * Math.max(perPage, 1);
+ const sliced = filtered.slice(start, start + Math.max(perPage, 1));
+
+ return {
+ success: true,
+ code: 200,
+ data: {
+ logs: sliced,
+ totalCount: filtered.length,
+ returnedCount: sliced.length,
+ hasMore: start + sliced.length < filtered.length,
+ filterSummary: {
+ operation: operation || undefined,
+ dateRange: startDate || endDate ? `${startDate || '-'} ~ ${endDate || '-'}` : undefined,
+ matchedRecords: filtered.length,
+ },
+ },
+ };
+ });
+};
diff --git a/web/src/mock/handlers/notification.ts b/web/src/mock/handlers/notification.ts
new file mode 100644
index 0000000..bbbba62
--- /dev/null
+++ b/web/src/mock/handlers/notification.ts
@@ -0,0 +1,116 @@
+import Mock from 'mockjs';
+import { addNotification, mockNotifications, paginate } from '../state';
+
+export const setupNotificationMocks = () => {
+ Mock.mock(/\/api\/v1\/notifications(?:\?.*)?$/, '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 isReadText = url.searchParams.get('isRead');
+
+ const filtered = mockNotifications.filter((item) => {
+ if (isReadText === null || isReadText === '') return true;
+ return item.isRead === (isReadText === 'true');
+ });
+
+ const paged = paginate(filtered, page, perPage);
+
+ return {
+ success: true,
+ code: 200,
+ data: {
+ ...paged,
+ unreadCount: mockNotifications.filter((item) => !item.isRead).length,
+ totalCount: mockNotifications.length,
+ },
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/notifications\/read-all$/, 'put', () => {
+ let updatedCount = 0;
+ mockNotifications.forEach((item) => {
+ if (!item.isRead) {
+ item.isRead = true;
+ updatedCount += 1;
+ }
+ });
+
+ return {
+ success: true,
+ code: 200,
+ data: {
+ updatedCount,
+ },
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/notifications\/broadcast$/, 'post', (options) => {
+ const { message } = JSON.parse(options.body || '{}');
+
+ if (!message) {
+ return {
+ success: false,
+ code: 400,
+ message: 'message is required',
+ data: null,
+ };
+ }
+
+ const created = addNotification(message, false);
+
+ return {
+ success: true,
+ code: 201,
+ data: created,
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/notifications\/([^/]+)\/read$/, 'put', (options) => {
+ const notificationId = Number((options.url.match(/\/api\/v1\/notifications\/([^/]+)\/read/) || [])[1]);
+ const target = mockNotifications.find((item) => item.id === notificationId);
+
+ if (!target) {
+ return {
+ success: false,
+ code: 404,
+ message: 'Notification not found',
+ data: null,
+ };
+ }
+
+ target.isRead = true;
+
+ return {
+ success: true,
+ code: 200,
+ data: {
+ notificationId: target.id,
+ updatedAt: new Date().toISOString(),
+ },
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/notifications\/([^/]+)$/, 'delete', (options) => {
+ const notificationId = Number((options.url.match(/\/api\/v1\/notifications\/([^/?]+)/) || [])[1]);
+ const index = mockNotifications.findIndex((item) => item.id === notificationId);
+
+ if (index === -1) {
+ return {
+ success: false,
+ code: 404,
+ message: 'Notification not found',
+ data: null,
+ };
+ }
+
+ mockNotifications.splice(index, 1);
+
+ return {
+ success: true,
+ code: 200,
+ data: {
+ notificationId,
+ },
+ };
+ });
+};
diff --git a/web/src/mock/handlers/permission.ts b/web/src/mock/handlers/permission.ts
new file mode 100644
index 0000000..baedcf7
--- /dev/null
+++ b/web/src/mock/handlers/permission.ts
@@ -0,0 +1,142 @@
+import Mock from 'mockjs';
+import { createMockId, mockPermissions, mockUsers, paginate } from '../state';
+
+const mockGroups = [
+ { id: 'group1', name: 'Developers' },
+ { id: 'group2', name: 'Designers' },
+ { id: 'group3', name: 'Operations' },
+];
+
+export const setupPermissionMocks = () => {
+ Mock.mock(/\/api\/v1\/permissions(?:\?.*)?$/, 'get', (options) => {
+ const url = new URL(options.url, 'http://localhost');
+ const fileId = url.searchParams.get('fileId');
+ const folderId = url.searchParams.get('folderId');
+ const page = Number(url.searchParams.get('page') || 1);
+ const perPage = Number(url.searchParams.get('perPage') || 20);
+
+ const filtered = mockPermissions.filter((permission) => {
+ if (fileId) return permission.itemType === 'file' && permission.itemId === fileId;
+ if (folderId) return permission.itemType === 'folder' && permission.itemId === folderId;
+ return true;
+ });
+
+ return {
+ success: true,
+ code: 200,
+ data: paginate(filtered, page, perPage),
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/permissions$/, 'post', (options) => {
+ const payload = JSON.parse(options.body || '{}');
+ const { fileId, folderId, userId, groupId, permission } = payload;
+
+ const itemType = fileId ? 'file' : folderId ? 'folder' : null;
+ const itemId = fileId || folderId;
+
+ if (!itemType || !itemId || !permission) {
+ return {
+ success: false,
+ code: 400,
+ message: 'Invalid permission payload',
+ data: null,
+ };
+ }
+
+ let grantedTo: { type: 'user' | 'group'; id: string; name: string } | null = null;
+
+ if (userId) {
+ const user = mockUsers.find((entry) => entry.userId === userId);
+ grantedTo = {
+ type: 'user',
+ id: userId,
+ name: user?.username || `User ${userId}`,
+ };
+ }
+
+ if (groupId) {
+ const group = mockGroups.find((entry) => entry.id === groupId);
+ grantedTo = {
+ type: 'group',
+ id: groupId,
+ name: group?.name || `Group ${groupId}`,
+ };
+ }
+
+ if (!grantedTo) {
+ return {
+ success: false,
+ code: 400,
+ message: 'Either userId or groupId is required',
+ data: null,
+ };
+ }
+
+ const created = {
+ permissionId: createMockId('perm'),
+ itemType,
+ itemId,
+ grantedTo,
+ permission,
+ createdAt: new Date().toISOString(),
+ };
+
+ mockPermissions.unshift(created as any);
+
+ return {
+ success: true,
+ code: 201,
+ data: created,
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/permissions\/([^/]+)$/, 'put', (options) => {
+ const permissionId = (options.url.match(/\/api\/v1\/permissions\/([^/?]+)/) || [])[1];
+ const { permission } = JSON.parse(options.body || '{}');
+
+ const target = mockPermissions.find((item) => item.permissionId === permissionId);
+ if (!target) {
+ return {
+ success: false,
+ code: 404,
+ message: 'Permission not found',
+ data: null,
+ };
+ }
+
+ target.permission = permission;
+
+ return {
+ success: true,
+ code: 200,
+ data: target,
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/permissions\/([^/]+)$/, 'delete', (options) => {
+ const permissionId = (options.url.match(/\/api\/v1\/permissions\/([^/?]+)/) || [])[1];
+ const index = mockPermissions.findIndex((item) => item.permissionId === permissionId);
+
+ if (index === -1) {
+ return {
+ success: false,
+ code: 404,
+ message: 'Permission not found',
+ data: null,
+ };
+ }
+
+ const removed = mockPermissions.splice(index, 1)[0];
+
+ return {
+ success: true,
+ code: 200,
+ data: {
+ permissionId: removed.permissionId,
+ revokedPermission: removed.permission,
+ deletedAt: new Date().toISOString(),
+ },
+ };
+ });
+};
diff --git a/web/src/mock/handlers/recycle.ts b/web/src/mock/handlers/recycle.ts
index 854c19b..440d932 100644
--- a/web/src/mock/handlers/recycle.ts
+++ b/web/src/mock/handlers/recycle.ts
@@ -1,81 +1,133 @@
import Mock from 'mockjs';
-import { vfsApi } from '../vfs';
import type { RecycleBinItem } from '../../types/file';
+import { addLog } from '../state';
+import { vfsApi } from '../vfs';
+
+function buildPagination(count: number) {
+ return {
+ totalItems: count,
+ totalPages: 1,
+ perPage: count,
+ currentPage: 1,
+ hasPrev: false,
+ hasNext: false,
+ };
+}
export const setupRecycleMocks = () => {
- // Get Recycle Bin Contents
- Mock.mock(/\/api\/v1\/recycle-bin/, 'get', () => {
- const allItems = vfsApi.getAll();
- const trashedItems: RecycleBinItem[] = Object.values(allItems)
- .filter(item => item.isTrashed)
- .map(item => {
- const path = vfsApi.getPath(item.id);
- const originalPath = path.slice(0, -1).map(p => p.name).join('/');
+ Mock.mock(/\/api\/v1\/recycle-bin$/, 'get', () => {
+ const all = Object.values(vfsApi.getAll());
- const daysUntilPermanentDelete = 30 - Math.floor((Date.now() - new Date(item.deletedAt!).getTime()) / (1000 * 60 * 60 * 24));
+ const trashedItems: RecycleBinItem[] = all
+ .filter((node) => node.isTrashed)
+ .map((node) => {
+ const path = vfsApi.getPath(node.id);
+ const originalPath = path.slice(0, -1).map((item) => item.name).join('/') || 'My Files';
+ const deletedAt = node.deletedAt || new Date().toISOString();
+ const expireAt = new Date(new Date(deletedAt).getTime() + 30 * 24 * 60 * 60 * 1000);
+ const daysUntilPermanentDelete = Math.max(
+ 0,
+ Math.ceil((expireAt.getTime() - Date.now()) / (24 * 60 * 60 * 1000)),
+ );
return {
- itemType: item.type,
- id: item.id,
- name: item.name,
- originalPath: originalPath || 'My Files',
- size: item.size || 0,
- mimeType: item.mimeType,
- deletedAt: item.deletedAt!,
- autoDeleteAt: new Date(new Date(item.deletedAt!).getTime() + 30 * 24 * 60 * 60 * 1000).toISOString(),
- daysUntilPermanentDelete: daysUntilPermanentDelete > 0 ? daysUntilPermanentDelete : 0,
- canRestore: true, // Mock logic, always true for now
- restoreConflicts: false, // Mock logic, always false for now
+ itemType: node.type,
+ id: node.id,
+ name: node.name,
+ originalPath,
+ size: node.size || 0,
+ mimeType: node.mimeType,
+ deletedAt,
+ autoDeleteAt: expireAt.toISOString(),
+ daysUntilPermanentDelete,
+ canRestore: true,
+ restoreConflicts: false,
};
- });
+ })
+ .sort((a, b) => new Date(b.deletedAt).getTime() - new Date(a.deletedAt).getTime());
return {
success: true,
code: 200,
data: {
- items: trashedItems.sort((a, b) => new Date(b.deletedAt).getTime() - new Date(a.deletedAt).getTime()),
- pagination: { totalItems: trashedItems.length, totalPages: 1, perPage: trashedItems.length, currentPage: 1 },
+ items: trashedItems,
+ pagination: buildPagination(trashedItems.length),
},
};
});
- // Restore Item
- Mock.mock(/\/api\/v1\/recycle-bin\/(.+)\/restore/, 'post', (options) => {
- const itemId = (options.url.match(/\/api\/v1\/recycle-bin\/(.+)\/restore/) || [])[1];
+ Mock.mock(/\/api\/v1\/recycle-bin\/([^/]+)\/restore$/, 'post', (options) => {
+ const itemId = (options.url.match(/\/api\/v1\/recycle-bin\/([^/]+)\/restore/) || [])[1];
+ const node = vfsApi.get(itemId);
+
+ if (!node) {
+ return {
+ success: false,
+ code: 404,
+ message: 'Item not found',
+ data: null,
+ };
+ }
+
vfsApi.restore(itemId);
- const restoredItem = vfsApi.get(itemId);
+ addLog('recycle_restore', { itemId, itemName: node.name });
+
return {
success: true,
code: 200,
- message: 'Item restored successfully.',
+ message: 'Item restored successfully',
data: {
- itemType: restoredItem?.type,
- id: restoredItem?.id,
- name: restoredItem?.name,
- restoredTo: restoredItem?.parent,
+ itemType: node.type,
+ id: node.id,
+ name: node.name,
+ restoredTo: node.parent,
restoredAt: new Date().toISOString(),
},
};
});
- // Permanent Delete
- Mock.mock(/\/api\/v1\/recycle-bin\/(.+)/, 'delete', (options) => {
- const itemId = (options.url.match(/\/api\/v1\/recycle-bin\/(.+)/) || [])[1];
- const item = vfsApi.get(itemId);
- if (!item) {
- return { success: false, code: 404, message: 'Item not found.' };
+ Mock.mock(/\/api\/v1\/recycle-bin\/([^/]+)$/, 'delete', (options) => {
+ const itemId = (options.url.match(/\/api\/v1\/recycle-bin\/([^/?]+)/) || [])[1];
+ const node = vfsApi.get(itemId);
+
+ if (!node) {
+ return {
+ success: false,
+ code: 404,
+ message: 'Item not found',
+ data: null,
+ };
}
+
vfsApi.permanentDelete(itemId);
+ addLog('recycle_permanent_delete', { itemId, itemName: node.name });
+
return {
- success: true,
- code: 200,
- message: 'Item permanently deleted.',
- data: {
- itemType: item.type,
- id: item.id,
- name: item.name,
- permanentlyDeletedAt: new Date().toISOString(),
- },
+ success: true,
+ code: 200,
+ message: 'Item permanently deleted',
+ data: {
+ itemType: node.type,
+ id: node.id,
+ name: node.name,
+ permanentlyDeletedAt: new Date().toISOString(),
+ },
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/recycle-bin$/, 'delete', () => {
+ const result = vfsApi.clearRecycleBin();
+ addLog('recycle_clear', { filesDeleted: result.filesDeleted, foldersDeleted: result.foldersDeleted });
+
+ return {
+ success: true,
+ code: 200,
+ data: {
+ filesDeleted: result.filesDeleted,
+ foldersDeleted: result.foldersDeleted,
+ totalStorageFreed: result.totalStorageFreed,
+ cleanupCompletedAt: new Date().toISOString(),
+ },
};
});
-};
\ No newline at end of file
+};
diff --git a/web/src/mock/handlers/share.ts b/web/src/mock/handlers/share.ts
index 515ccc2..74f18a2 100644
--- a/web/src/mock/handlers/share.ts
+++ b/web/src/mock/handlers/share.ts
@@ -1,48 +1,220 @@
import Mock from 'mockjs';
+import { addLog, createMockId, mockSharedItems, mockShares, paginate } from '../state';
+import { vfsApi } from '../vfs';
-const sharedItems = [
- {
- itemType: 'file',
- id: 'shared_file_1',
- name: 'Q3 Financial Report.xlsx',
- size: 1572864,
- mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
- sharedBy: 'Alice',
- permission: 'write',
- sharedAt: new Date(Date.now() - 86400000).toISOString(), // 1 day ago
- },
- {
- itemType: 'folder',
- id: 'shared_folder_1',
- name: 'Project Phoenix Assets',
- size: 268435456,
- sharedBy: 'Bob',
- permission: 'read',
- sharedAt: new Date(Date.now() - 172800000).toISOString(), // 2 days ago
- },
- {
- itemType: 'file',
- id: 'shared_file_2',
- name: 'Design Mockups.fig',
- size: 25165824,
- mimeType: 'application/figma',
- sharedBy: 'Charlie',
- permission: 'read',
- sharedAt: new Date(Date.now() - 259200000).toISOString(), // 3 days ago
- },
-];
+function sortSharedItems(items: T[], sort: string | null, order: string | null) {
+ const sortField = sort || 'sharedAt';
+ const direction = order === 'asc' ? 1 : -1;
+
+ return [...items].sort((a, b) => {
+ const av = a[sortField];
+ const bv = b[sortField];
+
+ if (typeof av === 'number' && typeof bv === 'number') {
+ return (av - bv) * direction;
+ }
+
+ return String(av || '').localeCompare(String(bv || ''), undefined, { sensitivity: 'base' }) * direction;
+ });
+}
export const setupShareMocks = () => {
- // Get Shared Items
- Mock.mock(/\/api\/v1\/shared-items/, 'get', (options) => {
- // Basic mock, no sorting/paging for now
+ Mock.mock(/\/api\/v1\/shares$/, 'post', (options) => {
+ const { resourceType, resourceId } = JSON.parse(options.body || '{}');
+ const resource = vfsApi.get(resourceId);
+
+ if (!resource || resource.type !== resourceType) {
+ return {
+ success: false,
+ code: 404,
+ message: 'Resource not found',
+ data: null,
+ };
+ }
+
+ const shareLink = Mock.Random.string('upper', 6);
+ const share = {
+ shareId: createMockId('share'),
+ shareLink,
+ ownerUserId: 'user1',
+ itemType: resourceType,
+ itemInfo: {
+ id: resource.id,
+ name: resource.name,
+ size: resource.type === 'folder' ? vfsApi.getFolderStats(resource.id).totalSize : resource.size || 0,
+ mimeType: resource.mimeType || (resource.type === 'folder' ? 'inode/directory' : 'application/octet-stream'),
+ folderPath: vfsApi
+ .getPath(resource.id)
+ .slice(0, -1)
+ .map((node) => node.name)
+ .join('/'),
+ },
+ settings: {
+ passwordProtected: false,
+ expireAt: null,
+ allowDownload: true,
+ allowPreview: true,
+ },
+ createdAt: new Date().toISOString(),
+ visitCount: 0,
+ downloadCount: 0,
+ };
+
+ mockShares.unshift(share);
+ addLog('file_share', { resourceId, shareId: share.shareId, shareLink });
+
+ return {
+ success: true,
+ code: 201,
+ data: share,
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/shares(?:\?.*)?$/, '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);
+
+ return {
+ success: true,
+ code: 200,
+ data: paginate(mockShares, page, perPage),
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/shares\/([^/]+)$/, 'get', (options) => {
+ const shareLink = (options.url.match(/\/api\/v1\/shares\/([^/?]+)/) || [])[1];
+ const share = mockShares.find((item) => item.shareLink === shareLink || item.shareId === shareLink);
+
+ if (!share) {
+ return {
+ success: false,
+ code: 404,
+ message: 'Share not found',
+ data: null,
+ };
+ }
+
+ return {
+ success: true,
+ code: 200,
+ data: share,
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/shares\/([^/]+)\/access$/, 'post', (options) => {
+ const shareLink = (options.url.match(/\/api\/v1\/shares\/([^/]+)\/access/) || [])[1];
+ const { password } = JSON.parse(options.body || '{}');
+ const share = mockShares.find((item) => item.shareLink === shareLink || item.shareId === shareLink);
+
+ if (!share) {
+ return {
+ success: false,
+ code: 404,
+ message: 'Share not found',
+ data: null,
+ };
+ }
+
+ if (share.settings.passwordProtected && password !== '123456') {
+ return {
+ success: false,
+ code: 403,
+ message: 'Invalid share password',
+ data: null,
+ };
+ }
+
+ share.visitCount = (share.visitCount || 0) + 1;
+
+ return {
+ success: true,
+ code: 200,
+ data: {
+ accessToken: createMockId('access'),
+ expiresIn: 1800,
+ itemType: share.itemType,
+ itemInfo: share.itemInfo,
+ accessUrls: {
+ download: `/api/v1/files/${share.itemInfo.id}/download`,
+ preview: `/api/v1/files/${share.itemInfo.id}/preview`,
+ },
+ },
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/shares\/([^/]+)$/, 'delete', (options) => {
+ const shareLink = (options.url.match(/\/api\/v1\/shares\/([^/?]+)/) || [])[1];
+ const index = mockShares.findIndex((item) => item.shareLink === shareLink || item.shareId === shareLink);
+
+ if (index === -1) {
+ return {
+ success: false,
+ code: 404,
+ message: 'Share not found',
+ data: null,
+ };
+ }
+
+ const removed = mockShares.splice(index, 1)[0];
+ addLog('share_delete', { shareId: removed.shareId, shareLink: removed.shareLink });
+
+ return {
+ success: true,
+ code: 200,
+ data: {
+ shareId: removed.shareId,
+ shareLink: removed.shareLink,
+ deletedAt: new Date().toISOString(),
+ },
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/shared-items(?:\?.*)?$/, '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 sort = url.searchParams.get('sort');
+ const order = url.searchParams.get('order');
+
+ const sorted = sortSharedItems(mockSharedItems, sort, order);
+
+ return {
+ success: true,
+ code: 200,
+ data: paginate(sorted, page, perPage),
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/shared-items\/([^/]+)\/accept$/, 'post', (options) => {
+ const itemId = (options.url.match(/\/api\/v1\/shared-items\/([^/]+)\/accept/) || [])[1];
+ const item = mockSharedItems.find((entry) => entry.id === itemId);
+
+ if (!item) {
+ return {
+ success: false,
+ code: 404,
+ message: 'Shared item not found',
+ data: null,
+ };
+ }
+
+ if (item.itemType === 'folder') {
+ vfsApi.createFolder('root', `${item.name} (Shared)`);
+ } else {
+ vfsApi.createFile('root', `${item.name}`, item.size, item.mimeType || 'application/octet-stream');
+ }
+
+ addLog('shared_item_accept', { itemId, itemName: item.name });
+
return {
success: true,
code: 200,
data: {
- items: sharedItems,
- pagination: { totalItems: sharedItems.length, totalPages: 1, perPage: sharedItems.length, currentPage: 1 },
+ accepted: true,
+ acceptedAt: new Date().toISOString(),
+ itemId,
},
};
});
-};
\ No newline at end of file
+};
diff --git a/web/src/mock/handlers/storage.ts b/web/src/mock/handlers/storage.ts
new file mode 100644
index 0000000..b8b9317
--- /dev/null
+++ b/web/src/mock/handlers/storage.ts
@@ -0,0 +1,127 @@
+import Mock from 'mockjs';
+import { mockUsers } from '../state';
+import { vfsApi } from '../vfs';
+
+function computeStorageStats() {
+ const nodes = Object.values(vfsApi.getAll()).filter((node) => !node.isTrashed);
+ const files = nodes.filter((node) => node.type === 'file');
+ const folders = nodes.filter((node) => node.type === 'folder' && node.id !== 'root');
+
+ const storageUsed = files.reduce((sum, node) => sum + (node.size || 0), 0);
+ const storageLimit = mockUsers[0]?.storageLimit || 100 * 1024 * 1024 * 1024;
+
+ const bucket = {
+ documents: { size: 0, count: 0 },
+ images: { size: 0, count: 0 },
+ videos: { size: 0, count: 0 },
+ audio: { size: 0, count: 0 },
+ archives: { size: 0, count: 0 },
+ others: { size: 0, count: 0 },
+ };
+
+ files.forEach((file) => {
+ const size = file.size || 0;
+ const mime = file.mimeType || '';
+
+ if (mime.startsWith('image/')) {
+ bucket.images.size += size;
+ bucket.images.count += 1;
+ } else if (mime.startsWith('video/')) {
+ bucket.videos.size += size;
+ bucket.videos.count += 1;
+ } else if (mime.startsWith('audio/')) {
+ bucket.audio.size += size;
+ bucket.audio.count += 1;
+ } else if (mime.includes('zip') || mime.includes('compressed')) {
+ bucket.archives.size += size;
+ bucket.archives.count += 1;
+ } else if (mime.includes('pdf') || mime.includes('sheet') || mime.includes('word') || mime.startsWith('text/')) {
+ bucket.documents.size += size;
+ bucket.documents.count += 1;
+ } else {
+ bucket.others.size += size;
+ bucket.others.count += 1;
+ }
+ });
+
+ return {
+ storage_limit: storageLimit,
+ storage_used: storageUsed,
+ storage_available: Math.max(storageLimit - storageUsed, 0),
+ storage_percentage: Number(((storageUsed / storageLimit) * 100).toFixed(2)),
+ file_count: files.length,
+ folder_count: folders.length,
+ breakdown: bucket,
+ };
+}
+
+export const setupStorageMocks = () => {
+ Mock.mock(/\/api\/v1\/storage\/statistics$/, 'get', () => {
+ return {
+ success: true,
+ code: 200,
+ data: computeStorageStats(),
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/storage\/summary$/, 'get', () => {
+ return {
+ success: true,
+ code: 200,
+ data: computeStorageStats(),
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/storage\/usage-trend(?:\?.*)?$/, 'get', (options) => {
+ const url = new URL(options.url, 'http://localhost');
+ const days = Math.min(Number(url.searchParams.get('days') || 7), 30);
+ const currentUsed = computeStorageStats().storage_used;
+
+ const trends = Array.from({ length: days }).map((_, index) => {
+ const dayIndex = days - index - 1;
+ const date = new Date(Date.now() - dayIndex * 24 * 60 * 60 * 1000);
+ const dailyUsed = Math.max(currentUsed - dayIndex * 12000000 + Mock.Random.integer(-4000000, 4000000), 0);
+
+ return {
+ date: date.toISOString().split('T')[0],
+ used: dailyUsed,
+ };
+ });
+
+ return {
+ success: true,
+ code: 200,
+ data: {
+ trends,
+ },
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/admin\/storage\/users$/, 'get', () => {
+ const items = mockUsers.map((user) => ({
+ userId: user.userId,
+ username: user.username,
+ email: user.email,
+ storageUsed: user.storageUsed,
+ storageLimit: user.storageLimit,
+ usagePercentage: Number(((user.storageUsed / user.storageLimit) * 100).toFixed(2)),
+ status: user.status,
+ }));
+
+ return {
+ success: true,
+ code: 200,
+ data: {
+ items,
+ pagination: {
+ totalItems: items.length,
+ totalPages: 1,
+ perPage: items.length,
+ currentPage: 1,
+ hasPrev: false,
+ hasNext: false,
+ },
+ },
+ };
+ });
+};
diff --git a/web/src/mock/handlers/upload.ts b/web/src/mock/handlers/upload.ts
index ebac779..1ca5a12 100644
--- a/web/src/mock/handlers/upload.ts
+++ b/web/src/mock/handlers/upload.ts
@@ -1,80 +1,206 @@
import Mock from 'mockjs';
+import { addLog, addNotification } from '../state';
import { vfsApi } from '../vfs';
-import { arrayBufferToBase64, fileToArrayBuffer } from '../../utils/hash';
+import { arrayBufferToBase64 } from '../../utils/hash';
-export const setupUploadMocks = () => {
- // Store chunks temporarily. In a real scenario, this would be on a server.
- const chunkStorage = new Map>();
+type UploadSession = {
+ uploadId: string;
+ fileHash: string;
+ fileName: string;
+ fileSize: number;
+ mimeType: string;
+ parentId: string;
+ chunkSize: number;
+ chunks: Map;
+ uploadedChunkIndexes: Set;
+ createdAt: string;
+};
+
+const sessions = new Map();
+const hashToSessionId = new Map();
- // Preflight Upload
+function findCompletedFileByHash(fileHash: string) {
+ return Object.values(vfsApi.getAll()).find(
+ (node) => node.type === 'file' && !node.isTrashed && node.hash === fileHash,
+ );
+}
+
+export const setupUploadMocks = () => {
Mock.mock(/\/api\/v1\/uploads\/preflight/, 'post', (options) => {
+ const payload = JSON.parse(options.body || '{}');
+ const { fileHash, fileName, fileSize, mimeType, parentId } = payload;
+
+ if (!fileHash || !fileName || !fileSize || !parentId) {
+ return {
+ success: false,
+ code: 400,
+ message: 'fileHash, fileName, fileSize and parentId are required',
+ data: null,
+ };
+ }
+
+ const existingFile = findCompletedFileByHash(fileHash);
+ if (existingFile) {
+ return {
+ success: true,
+ code: 200,
+ message: 'File already exists',
+ data: {
+ status: 'COMPLETE',
+ fileId: existingFile.id,
+ },
+ };
+ }
+
+ const existingSessionId = hashToSessionId.get(fileHash);
+ if (existingSessionId && sessions.has(existingSessionId)) {
+ const session = sessions.get(existingSessionId)!;
+ return {
+ success: true,
+ code: 200,
+ message: 'Resume upload session',
+ data: {
+ status: 'UPLOADING',
+ uploadId: session.uploadId,
+ chunkSize: session.chunkSize,
+ uploadedChunkIndexes: [...session.uploadedChunkIndexes].sort((a, b) => a - b),
+ },
+ };
+ }
+
const uploadId = Mock.Random.guid();
- chunkStorage.set(uploadId, new Map());
+ const chunkSize = 5 * 1024 * 1024;
+ const newSession: UploadSession = {
+ uploadId,
+ fileHash,
+ fileName,
+ fileSize,
+ mimeType: mimeType || 'application/octet-stream',
+ parentId,
+ chunkSize,
+ chunks: new Map(),
+ uploadedChunkIndexes: new Set(),
+ createdAt: new Date().toISOString(),
+ };
+
+ sessions.set(uploadId, newSession);
+ hashToSessionId.set(fileHash, uploadId);
+
return {
- success: true, code: 200, message: 'Ready for upload.',
+ success: true,
+ code: 200,
+ message: 'Ready for upload',
data: {
status: 'UPLOADING',
- uploadId: uploadId,
- chunkSize: 5 * 1024 * 1024, // 5MB chunks
+ uploadId,
+ chunkSize,
uploadedChunkIndexes: [],
- }
+ },
};
});
- // Upload Chunk
- Mock.mock(/\/api\/v1\/uploads\/(.+)\/chunk/, 'post', (options) => {
- const uploadId = (options.url.match(/\/api\/v1\/uploads\/(.+)\/chunk/) || [])[1];
- const formData = options.body as FormData; // Mockjs passes FormData directly
- const chunk = formData.get('chunk') as File;
- const chunkIndex = parseInt(formData.get('chunkIndex') as string, 10);
-
- if (chunkStorage.has(uploadId)) {
- chunkStorage.get(uploadId)!.set(chunkIndex, chunk);
- return { success: true, code: 200, message: `Chunk ${chunkIndex} uploaded for ${uploadId}.`};
- } else {
- return { success: false, code: 404, message: 'Upload ID not found.' };
+ Mock.mock(/\/api\/v1\/uploads\/([^/]+)\/chunk$/, 'post', (options) => {
+ const uploadId = (options.url.match(/\/api\/v1\/uploads\/([^/]+)\/chunk/) || [])[1];
+ const session = sessions.get(uploadId);
+
+ if (!session) {
+ return {
+ success: false,
+ code: 404,
+ message: 'Upload session not found',
+ data: null,
+ };
}
+
+ const formData = options.body as FormData;
+ const chunk = formData.get('chunk') as Blob | null;
+ const chunkIndexText = formData.get('chunkIndex') as string | null;
+ const chunkIndex = Number(chunkIndexText);
+
+ if (!chunk || Number.isNaN(chunkIndex)) {
+ return {
+ success: false,
+ code: 400,
+ message: 'Invalid chunk payload',
+ data: null,
+ };
+ }
+
+ session.chunks.set(chunkIndex, chunk);
+ session.uploadedChunkIndexes.add(chunkIndex);
+
+ return {
+ success: true,
+ code: 200,
+ message: `Chunk ${chunkIndex} uploaded`,
+ data: null,
+ };
});
- // Merge Chunks
- Mock.mock(/\/api\/v1\/uploads\/(.+)\/merge/, 'post', (options) => {
- const uploadId = (options.url.match(/\/api\/v1\/uploads\/(.+)\/merge/) || [])[1];
- const { fileName, parentId, mimeType } = JSON.parse(options.body);
+ 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 (!chunkStorage.has(uploadId)) {
- return { success: false, code: 404, message: 'Upload ID not found.' };
+ if (!session) {
+ return {
+ success: false,
+ code: 404,
+ message: 'Upload session not found',
+ data: null,
+ };
}
- const chunksMap = chunkStorage.get(uploadId)!;
- const sortedChunks = Array.from(chunksMap.keys()).sort((a, b) => a - b).map(key => chunksMap.get(key)!);
-
- if (sortedChunks.length === 0) {
- return { success: false, code: 400, message: 'No chunks found for this upload.' };
+ 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 completeFileBlob = new Blob(sortedChunks);
-
- const newFileVfsNode = vfsApi.createFile(parentId, fileName, completeFileBlob.size, mimeType || 'application/octet-stream');
-
- chunkStorage.delete(uploadId); // Clean up after merge
-
- // The mock must return a response that matches the `MergeChunksResponse` type definition
- const responseData = {
- fileId: newFileVfsNode.id,
- fileName: newFileVfsNode.name,
- fileSize: newFileVfsNode.size || 0,
- mimeType: newFileVfsNode.mimeType || 'application/octet-stream',
- folderId: newFileVfsNode.parent || 'root',
- objectHash: `mock-hash-${newFileVfsNode.id}`,
- createdAt: newFileVfsNode.createdAt,
- downloadUrl: `/api/v1/files/${newFileVfsNode.id}/download`,
- };
+ 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);
+
+ addLog('file_upload', { fileId: created.id, fileName: created.name, size: created.size || 0 });
+ addNotification(`Upload complete: ${created.name}`, true);
return {
success: true,
code: 201,
- message: 'File created successfully.',
- data: responseData,
+ 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`,
+ },
};
});
-};
\ No newline at end of file
+};
diff --git a/web/src/mock/handlers/user.ts b/web/src/mock/handlers/user.ts
index b7dcdcf..9ce7650 100644
--- a/web/src/mock/handlers/user.ts
+++ b/web/src/mock/handlers/user.ts
@@ -1,134 +1,266 @@
import Mock from 'mockjs';
-import { vfsApi } from '../vfs'; // We might not need vfs here, but good to have.
+import { addLog, addNotification, mockLogs, mockUsers, paginate } from '../state';
-const users = [
- { userId: 'user1', username: 'Alice', email: 'alice@example.com' },
- { userId: 'user2', username: 'Bob', email: 'bob@example.com' },
- { userId: 'user3', username: 'Charlie', email: 'charlie@example.com' },
- { userId: 'user4', username: 'David', email: 'david@example.com' },
+const profileGroups = [
+ {
+ groupId: 'group1',
+ groupName: 'Developers',
+ role: 'admin' as const,
+ },
+ {
+ groupId: 'group2',
+ groupName: 'Product Team',
+ role: 'member' as const,
+ },
];
+function toDateArray(isoString: string) {
+ const date = new Date(isoString);
+ return [
+ date.getFullYear(),
+ date.getMonth() + 1,
+ date.getDate(),
+ date.getHours(),
+ date.getMinutes(),
+ date.getSeconds(),
+ ];
+}
+
export const setupUserMocks = () => {
- // Get Users (with search)
- Mock.mock(/\/api\/v1\/users/, 'get', (options) => {
+ Mock.mock(/\/api\/v1\/users(?:\?.*)?$/, 'get', (options) => {
+ const url = new URL(options.url, 'http://localhost');
+ const search = (url.searchParams.get('search') || '').toLowerCase();
+ const page = Number(url.searchParams.get('page') || 1);
+ const perPage = Number(url.searchParams.get('perPage') || 20);
+
+ const filtered = mockUsers.filter((user) => {
+ if (!search) return true;
+ return user.username.toLowerCase().includes(search) || user.email.toLowerCase().includes(search);
+ });
+
+ return {
+ success: true,
+ code: 200,
+ data: paginate(filtered, page, perPage),
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/admin\/users(?:\?.*)?$/, 'get', (options) => {
const url = new URL(options.url, 'http://localhost');
- const search = url.searchParams.get('search') || '';
-
- const filteredUsers = users.filter(user =>
- user.username.toLowerCase().includes(search.toLowerCase()) ||
- user.email.toLowerCase().includes(search.toLowerCase())
- );
+ const page = Number(url.searchParams.get('page') || 1);
+ const perPage = Number(url.searchParams.get('perPage') || 20);
+
+ const users = mockUsers.map((user) => ({
+ ...user,
+ role: user.role,
+ status: user.status,
+ lastActiveAt: new Date(Date.now() - Mock.Random.integer(1, 72) * 3600000).toISOString(),
+ }));
+
+ return {
+ success: true,
+ code: 200,
+ data: paginate(users, page, perPage),
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/admin\/users\/([^/]+)\/status$/, 'patch', (options) => {
+ const userId = (options.url.match(/\/api\/v1\/admin\/users\/([^/]+)\/status/) || [])[1];
+ const { status } = JSON.parse(options.body || '{}');
+
+ const target = mockUsers.find((user) => user.userId === userId);
+ if (!target) {
+ return {
+ success: false,
+ code: 404,
+ message: 'User not found',
+ data: null,
+ };
+ }
+
+ target.status = status;
+ addLog('admin_user_status_update', { userId, status });
return {
success: true,
code: 200,
data: {
- items: filteredUsers,
- pagination: { totalItems: filteredUsers.length, totalPages: 1, perPage: filteredUsers.length, currentPage: 1 },
+ userId,
+ status,
+ updatedAt: new Date().toISOString(),
},
};
});
- // Get User Profile
- Mock.mock(/\/api\/v1\/me\/profile/, 'get', {
- success: true,
- code: 200,
- data: {
- userId: 'user1',
- username: 'Demo User',
- email: 'demo@example.com',
- storageLimit: 107374182400, // 100GB
- storageUsed: 21474836480, // 20GB
- createdAt: '@datetime("yyyy-MM-dd HH:mm:ss")',
- updatedAt: '@datetime("yyyy-MM-dd HH:mm:ss")',
- lastLogin: '@datetime("yyyy-MM-dd HH:mm:ss")',
- groups: [
- {
- groupId: 1,
- groupName: '开发团队',
- role: 'admin'
+ Mock.mock(/\/api\/v1\/admin\/violations(?:\?.*)?$/, 'get', () => {
+ const items = [
+ {
+ id: 'vio_1',
+ fileId: 'file8',
+ fileName: 'intro.mp3',
+ type: 'copyright',
+ level: 'medium',
+ reportedAt: new Date(Date.now() - 36 * 3600000).toISOString(),
+ status: 'pending',
+ },
+ {
+ id: 'vio_2',
+ fileId: 'file9',
+ fileName: 'walkthrough.mp4',
+ type: 'sensitive_content',
+ level: 'high',
+ reportedAt: new Date(Date.now() - 12 * 3600000).toISOString(),
+ status: 'under_review',
+ },
+ ];
+
+ return {
+ success: true,
+ code: 200,
+ data: {
+ items,
+ pagination: {
+ totalItems: items.length,
+ totalPages: 1,
+ perPage: items.length,
+ currentPage: 1,
+ hasPrev: false,
+ hasNext: false,
},
- {
- groupId: 2,
- groupName: '项目管理',
- role: 'member'
- }
- ]
- }
+ },
+ };
});
- // Get Storage Stats
- Mock.mock(/\/api\/v1\/me\/storage-stats/, 'get', {
- success: true,
- code: 200,
- data: {
- storageLimit: 107374182400, // 100GB
- storageUsed: 21474836480, // 20GB
- storageAvailable: 85899345920, // 80GB
- storagePercentage: 20,
- fileCount: 1247,
- folderCount: 86,
- breakdown: {
- documents: {
- size: 5368709120, // 5GB
- count: 234
- },
- images: {
- size: 10737418240, // 10GB
- count: 567
- },
- videos: {
- size: 4294967296, // 4GB
- count: 12
- },
- audio: {
- size: 1073741824, // 1GB
- count: 89
- },
- archives: {
- size: 268435456, // 256MB
- count: 15
+ Mock.mock(/\/api\/v1\/admin\/violations\/([^/]+)\/resolve$/, 'post', (options) => {
+ const violationId = (options.url.match(/\/api\/v1\/admin\/violations\/([^/]+)\/resolve/) || [])[1];
+ addLog('admin_violation_resolve', { violationId });
+
+ return {
+ success: true,
+ code: 200,
+ data: {
+ violationId,
+ resolvedAt: new Date().toISOString(),
+ },
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/me\/profile$/, 'get', () => {
+ const user = mockUsers[0];
+
+ return {
+ success: true,
+ code: 200,
+ data: {
+ userId: user.userId,
+ username: user.username,
+ email: user.email,
+ storageLimit: user.storageLimit,
+ storageUsed: user.storageUsed,
+ createdAt: user.createdAt,
+ updatedAt: new Date().toISOString(),
+ lastLogin: new Date(Date.now() - 2 * 3600000).toISOString(),
+ groups: profileGroups,
+ },
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/me\/update-profile$/, 'put', (options) => {
+ const { username, email } = JSON.parse(options.body || '{}');
+ const user = mockUsers[0];
+
+ if (username) user.username = username;
+ if (email) user.email = email;
+
+ return {
+ success: true,
+ code: 200,
+ data: {
+ userId: user.userId,
+ username: user.username,
+ email: user.email,
+ storageLimit: user.storageLimit,
+ storageUsed: user.storageUsed,
+ createdAt: user.createdAt,
+ updatedAt: new Date().toISOString(),
+ lastLogin: new Date(Date.now() - 2 * 3600000).toISOString(),
+ groups: profileGroups,
+ },
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/me\/password$/, 'put', () => {
+ addNotification('Password updated successfully', true);
+
+ return {
+ success: true,
+ code: 200,
+ data: null,
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/me\/storage-stats$/, 'get', () => {
+ const user = mockUsers[0];
+ const percentage = Number(((user.storageUsed / user.storageLimit) * 100).toFixed(2));
+
+ return {
+ success: true,
+ code: 200,
+ data: {
+ storageLimit: user.storageLimit,
+ storageUsed: user.storageUsed,
+ storageAvailable: user.storageLimit - user.storageUsed,
+ storagePercentage: percentage,
+ fileCount: 1247,
+ folderCount: 86,
+ breakdown: {
+ documents: { size: 5368709120, count: 234 },
+ images: { size: 10737418240, count: 567 },
+ videos: { size: 4294967296, count: 12 },
+ audio: { size: 1073741824, count: 89 },
+ archives: { size: 268435456, count: 15 },
+ others: { size: 268435456, count: 330 },
},
- others: {
- size: 268435456, // 256MB
- count: 330
- }
- }
- },
+ },
+ };
});
- // Get Activity Log
- Mock.mock(/\/api\/v1\/me\/activity-log/, 'get', (options) => {
+ Mock.mock(/\/api\/v1\/me\/activity-log(?:\?.*)?$/, 'get', (options) => {
const url = new URL(options.url, 'http://localhost');
- const page = parseInt(url.searchParams.get('page') || '1');
- const perPage = parseInt(url.searchParams.get('perPage') || '20');
-
- const activities = Mock.mock({
- [`items|${perPage}`]: [{
- 'id|+1': 1,
- 'operation|1': ['file_upload', 'file_download', 'file_delete', 'folder_create', 'file_share', 'login'],
- details: {
- fileName: '@word.txt',
- fileSize: '@integer(1024, 10485760)',
- 'action|1': ['创建', '上传', '下载', '删除', '分享']
- },
- ipAddress: '@ip',
- performedAt: '@datetime("yyyy-MM-dd HH:mm:ss")'
- }]
- });
+ const page = Number(url.searchParams.get('page') || 1);
+ const perPage = Number(url.searchParams.get('perPage') || 20);
+ const operation = url.searchParams.get('operation');
+
+ const filtered = mockLogs.filter((item) => (operation ? item.operation === operation : true));
+ const paged = paginate(filtered, page, perPage);
return {
success: true,
code: 200,
data: {
- items: activities.items,
+ items: paged.items.map((item) => ({
+ id: Number(String(item.id).replace('log_', '')),
+ operation: item.operation,
+ details: {
+ ...item.details,
+ user_agent: Mock.Random.pick([
+ 'Mozilla/5.0 Chrome/123.0 Safari/537.36',
+ 'Mozilla/5.0 Firefox/120.0',
+ 'Mozilla/5.0 Edg/122.0',
+ ]),
+ },
+ ip_address: item.ipAddress,
+ performed_at: toDateArray(item.performedAt),
+ })),
pagination: {
- currentPage: page,
- perPage: perPage,
- totalItems: 156,
- totalPages: Math.ceil(156 / perPage)
- }
- }
+ total_items: paged.pagination.totalItems,
+ total_pages: paged.pagination.totalPages,
+ per_page: paged.pagination.perPage,
+ current_page: paged.pagination.currentPage,
+ has_prev: paged.pagination.hasPrev,
+ has_next: paged.pagination.hasNext,
+ },
+ },
};
});
-};
\ No newline at end of file
+};
diff --git a/web/src/mock/handlers/usergroup.ts b/web/src/mock/handlers/usergroup.ts
index 1061e6f..cb5e961 100644
--- a/web/src/mock/handlers/usergroup.ts
+++ b/web/src/mock/handlers/usergroup.ts
@@ -1,28 +1,191 @@
import Mock from 'mockjs';
+import { createMockId, mockUsers, paginate } from '../state';
-const groups = [
- { groupId: 'group1', name: 'Developers', description: 'All software developers' },
- { groupId: 'group2', name: 'Designers', description: 'UI/UX design team' },
- { groupId: 'group3', name: 'Marketing', description: 'Marketing and sales' },
+const groups: Array<{
+ groupId: string;
+ name: string;
+ description?: string;
+ memberCount: number;
+ createdAt: string;
+ members: Array<{ userId: string; role: 'member' | 'admin' }>;
+}> = [
+ {
+ groupId: 'group1',
+ name: 'Developers',
+ description: 'All software developers',
+ memberCount: 2,
+ createdAt: new Date(Date.now() - 120 * 24 * 3600000).toISOString(),
+ members: [
+ { userId: 'user1', role: 'admin' },
+ { userId: 'user2', role: 'member' },
+ ],
+ },
+ {
+ groupId: 'group2',
+ name: 'Designers',
+ description: 'UI and visual designers',
+ memberCount: 1,
+ createdAt: new Date(Date.now() - 80 * 24 * 3600000).toISOString(),
+ members: [{ userId: 'user3', role: 'member' }],
+ },
+ {
+ groupId: 'group3',
+ name: 'Marketing',
+ description: 'Marketing and growth team',
+ memberCount: 1,
+ createdAt: new Date(Date.now() - 40 * 24 * 3600000).toISOString(),
+ members: [{ userId: 'user4', role: 'member' }],
+ },
];
export const setupUserGroupMocks = () => {
- // Get User Groups (with search)
- Mock.mock(/\/api\/v1\/user-groups/, 'get', (options) => {
+ Mock.mock(/\/api\/v1\/user-groups(?:\?.*)?$/, 'get', (options) => {
const url = new URL(options.url, 'http://localhost');
- const search = url.searchParams.get('search') || '';
+ const search = (url.searchParams.get('search') || '').toLowerCase();
+ const page = Number(url.searchParams.get('page') || 1);
+ const perPage = Number(url.searchParams.get('perPage') || 20);
- const filteredGroups = groups.filter(group =>
- group.name.toLowerCase().includes(search.toLowerCase())
- );
+ const filtered = groups.filter((group) => {
+ if (!search) return true;
+ return group.name.toLowerCase().includes(search) || (group.description || '').toLowerCase().includes(search);
+ });
+
+ const mapped = filtered.map(({ members, ...group }) => group);
+
+ return {
+ success: true,
+ code: 200,
+ data: paginate(mapped, page, perPage),
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/user-groups$/, 'post', (options) => {
+ const { name, description } = JSON.parse(options.body || '{}');
+
+ if (!name) {
+ return {
+ success: false,
+ code: 400,
+ message: 'name is required',
+ data: null,
+ };
+ }
+
+ const created = {
+ groupId: createMockId('group'),
+ name,
+ description,
+ memberCount: 0,
+ createdAt: new Date().toISOString(),
+ members: [] as Array<{ userId: string; role: 'member' | 'admin' }>,
+ };
+
+ groups.unshift(created);
+
+ return {
+ success: true,
+ code: 201,
+ data: {
+ groupId: created.groupId,
+ name: created.name,
+ description: created.description,
+ memberCount: created.memberCount,
+ createdAt: created.createdAt,
+ },
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/user-groups\/([^/]+)\/members$/, 'post', (options) => {
+ const groupId = (options.url.match(/\/api\/v1\/user-groups\/([^/]+)\/members/) || [])[1];
+ const { userId, role } = JSON.parse(options.body || '{}');
+
+ const group = groups.find((entry) => entry.groupId === groupId);
+ if (!group) {
+ return {
+ success: false,
+ code: 404,
+ message: 'Group not found',
+ data: null,
+ };
+ }
+
+ const user = mockUsers.find((entry) => entry.userId === userId);
+ if (!user) {
+ return {
+ success: false,
+ code: 404,
+ message: 'User not found',
+ data: null,
+ };
+ }
+
+ const existing = group.members.find((member) => member.userId === userId);
+ if (existing) {
+ existing.role = role;
+ } else {
+ group.members.push({ userId, role });
+ group.memberCount += 1;
+ }
+
+ return {
+ success: true,
+ code: 200,
+ data: {
+ groupId: group.groupId,
+ groupName: group.name,
+ addedUser: {
+ userId: user.userId,
+ username: user.username,
+ role,
+ },
+ totalMembers: group.memberCount,
+ },
+ };
+ });
+
+ Mock.mock(/\/api\/v1\/user-groups\/([^/]+)\/members\/([^/]+)$/, 'delete', (options) => {
+ const groupMatch = options.url.match(/\/api\/v1\/user-groups\/([^/]+)\/members\/([^/?]+)/);
+ const groupId = groupMatch ? groupMatch[1] : '';
+ const userId = groupMatch ? groupMatch[2] : '';
+
+ const group = groups.find((entry) => entry.groupId === groupId);
+ if (!group) {
+ return {
+ success: false,
+ code: 404,
+ message: 'Group not found',
+ data: null,
+ };
+ }
+
+ const memberIndex = group.members.findIndex((member) => member.userId === userId);
+ if (memberIndex === -1) {
+ return {
+ success: false,
+ code: 404,
+ message: 'Group member not found',
+ data: null,
+ };
+ }
+
+ const [removedMember] = group.members.splice(memberIndex, 1);
+ group.memberCount = Math.max(0, group.memberCount - 1);
+
+ const user = mockUsers.find((entry) => entry.userId === userId);
return {
success: true,
code: 200,
data: {
- items: filteredGroups,
- pagination: { totalItems: filteredGroups.length, totalPages: 1, perPage: filteredGroups.length, currentPage: 1 },
+ groupId: group.groupId,
+ groupName: group.name,
+ removedUser: {
+ userId,
+ username: user?.username || userId,
+ role: removedMember.role,
+ },
+ remainingMembers: group.memberCount,
},
};
});
-};
\ No newline at end of file
+};
diff --git a/web/src/mock/index.ts b/web/src/mock/index.ts
index d7df8f8..285f078 100644
--- a/web/src/mock/index.ts
+++ b/web/src/mock/index.ts
@@ -6,6 +6,10 @@ import { setupUserMocks } from './handlers/user';
import { setupUserGroupMocks } from './handlers/usergroup';
import { setupShareMocks } from './handlers/share';
import { setupRecycleMocks } from './handlers/recycle';
+import { setupPermissionMocks } from './handlers/permission';
+import { setupNotificationMocks } from './handlers/notification';
+import { setupLogMocks } from './handlers/log';
+import { setupStorageMocks } from './handlers/storage';
// Setup all mock handlers
export const setupMocks = () => {
@@ -17,7 +21,11 @@ export const setupMocks = () => {
setupUserGroupMocks();
setupShareMocks();
setupRecycleMocks();
+ setupPermissionMocks();
+ setupNotificationMocks();
+ setupLogMocks();
+ setupStorageMocks();
};
// Immediately setup mocks when this module is imported
-setupMocks();
\ No newline at end of file
+setupMocks();
diff --git a/web/src/mock/state.ts b/web/src/mock/state.ts
new file mode 100644
index 0000000..2d7faf1
--- /dev/null
+++ b/web/src/mock/state.ts
@@ -0,0 +1,272 @@
+import Mock from 'mockjs';
+import type { PermissionItem } from '../types/permission';
+import type { Share, SharedItem } from '../types/share';
+import type { NotificationItem } from '../types/notification';
+import type { LogItem } from '../types/log';
+import type { User } from '../types/user';
+
+export type MockUserRecord = User & {
+ status: 'active' | 'suspended';
+ role: 'user' | 'admin';
+};
+
+const now = () => new Date().toISOString();
+
+const randomRecentTime = (maxHours = 72) => {
+ const offset = Math.floor(Math.random() * maxHours * 60 * 60 * 1000);
+ return new Date(Date.now() - offset).toISOString();
+};
+
+let notificationId = 200;
+let logId = 1000;
+
+export const mockUsers: MockUserRecord[] = [
+ {
+ userId: 'user1',
+ username: 'Demo User',
+ email: 'demo@example.com',
+ storageLimit: 107374182400,
+ storageUsed: 21474836480,
+ createdAt: '2025-01-10T09:30:00.000Z',
+ status: 'active',
+ role: 'admin',
+ },
+ {
+ userId: 'user2',
+ username: 'Alice Chen',
+ email: 'alice@example.com',
+ storageLimit: 53687091200,
+ storageUsed: 10737418240,
+ createdAt: '2025-03-12T11:20:00.000Z',
+ status: 'active',
+ role: 'user',
+ },
+ {
+ userId: 'user3',
+ username: 'Bob Wang',
+ email: 'bob@example.com',
+ storageLimit: 53687091200,
+ storageUsed: 3435973836,
+ createdAt: '2025-06-02T08:15:00.000Z',
+ status: 'active',
+ role: 'user',
+ },
+ {
+ userId: 'user4',
+ username: 'Charlie Li',
+ email: 'charlie@example.com',
+ storageLimit: 53687091200,
+ storageUsed: 17448304640,
+ createdAt: '2025-08-01T06:05:00.000Z',
+ status: 'suspended',
+ role: 'user',
+ },
+];
+
+export const mockShares: Array = [
+ {
+ shareId: 'share_1001',
+ shareLink: 'S8M3J5',
+ ownerUserId: 'user1',
+ itemType: 'file',
+ itemInfo: {
+ id: 'file2',
+ name: 'project-plan.pdf',
+ size: 256000,
+ mimeType: 'application/pdf',
+ folderPath: '/My Files',
+ },
+ settings: {
+ passwordProtected: false,
+ expireAt: null,
+ allowDownload: true,
+ allowPreview: true,
+ },
+ createdAt: randomRecentTime(),
+ visitCount: 18,
+ downloadCount: 7,
+ },
+ {
+ shareId: 'share_1002',
+ shareLink: 'X4D9Q2',
+ ownerUserId: 'user1',
+ itemType: 'folder',
+ itemInfo: {
+ id: 'folder1',
+ name: 'Work Documents',
+ size: 48370,
+ mimeType: 'inode/directory',
+ folderPath: '/My Files',
+ },
+ settings: {
+ passwordProtected: true,
+ expireAt: new Date(Date.now() + 4 * 24 * 60 * 60 * 1000).toISOString(),
+ allowDownload: true,
+ allowPreview: false,
+ },
+ createdAt: randomRecentTime(),
+ visitCount: 6,
+ downloadCount: 2,
+ },
+];
+
+export const mockSharedItems: SharedItem[] = [
+ {
+ itemType: 'file',
+ id: 'shared_file_1',
+ name: 'Q3 Financial Report.xlsx',
+ size: 1572864,
+ mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ sharedBy: 'Alice Chen',
+ permission: 'write',
+ sharedAt: randomRecentTime(),
+ },
+ {
+ itemType: 'folder',
+ id: 'shared_folder_1',
+ name: 'Project Phoenix Assets',
+ size: 268435456,
+ sharedBy: 'Bob Wang',
+ permission: 'read',
+ sharedAt: randomRecentTime(),
+ },
+ {
+ itemType: 'file',
+ id: 'shared_file_2',
+ name: 'Design Mockups.fig',
+ size: 25165824,
+ mimeType: 'application/figma',
+ sharedBy: 'Charlie Li',
+ permission: 'read',
+ sharedAt: randomRecentTime(),
+ },
+];
+
+export const mockPermissions: PermissionItem[] = [
+ {
+ permissionId: 'perm_1',
+ itemType: 'file',
+ itemId: 'file2',
+ grantedTo: {
+ type: 'user',
+ id: 'user2',
+ name: 'Alice Chen',
+ },
+ permission: 'write',
+ createdAt: randomRecentTime(),
+ },
+ {
+ permissionId: 'perm_2',
+ itemType: 'folder',
+ itemId: 'folder1',
+ grantedTo: {
+ type: 'group',
+ id: 'group1',
+ name: 'Developers',
+ },
+ permission: 'read',
+ createdAt: randomRecentTime(),
+ },
+];
+
+export const mockNotifications: NotificationItem[] = [
+ {
+ id: 1,
+ message: 'Welcome to FileFlash. Your account is ready.',
+ isRead: false,
+ createdAt: randomRecentTime(),
+ },
+ {
+ id: 2,
+ message: 'Storage usage reached 80%. Consider cleanup.',
+ isRead: false,
+ createdAt: randomRecentTime(),
+ },
+ {
+ id: 3,
+ message: 'Security scan completed for uploaded files.',
+ isRead: true,
+ createdAt: randomRecentTime(),
+ },
+];
+
+export const mockLogs: LogItem[] = Array.from({ length: 40 }).map((_, index) => {
+ const operation = Mock.Random.pick([
+ 'file_upload',
+ 'file_download',
+ 'file_delete',
+ 'file_share',
+ 'folder_create',
+ 'user_login',
+ 'virus_scan',
+ 'rate_limit_trigger',
+ ]);
+
+ return {
+ id: `log_${900 + index}`,
+ operation,
+ operationName: operation.split('_').join(' '),
+ details: {
+ message: `Mock event for ${operation}`,
+ resource: Mock.Random.pick(['file2', 'file6', 'folder1', 'file8']),
+ status: Mock.Random.pick(['ok', 'ok', 'ok', 'warning']),
+ },
+ ipAddress: Mock.Random.ip(),
+ performedAt: randomRecentTime(240),
+ };
+});
+
+export function createMockId(prefix: string) {
+ return `${prefix}_${Mock.Random.string('number', 6)}`;
+}
+
+export function addNotification(message: string, isRead = false) {
+ notificationId += 1;
+ const item: NotificationItem = {
+ id: notificationId,
+ message,
+ isRead,
+ createdAt: now(),
+ };
+ mockNotifications.unshift(item);
+ return item;
+}
+
+export function addLog(operation: string, details: Record) {
+ logId += 1;
+ const item: LogItem = {
+ id: `log_${logId}`,
+ operation,
+ operationName: operation.split('_').join(' '),
+ details,
+ ipAddress: Mock.Random.ip(),
+ performedAt: now(),
+ };
+ mockLogs.unshift(item);
+ return item;
+}
+
+export function paginate(items: T[], page = 1, perPage = 20) {
+ const normalizedPage = Number.isFinite(page) && page > 0 ? page : 1;
+ const normalizedPerPage = Number.isFinite(perPage) && perPage > 0 ? perPage : 20;
+ const start = (normalizedPage - 1) * normalizedPerPage;
+ const sliced = items.slice(start, start + normalizedPerPage);
+ const totalItems = items.length;
+ const totalPages = Math.max(1, Math.ceil(totalItems / normalizedPerPage));
+
+ return {
+ items: sliced,
+ pagination: {
+ totalItems,
+ totalPages,
+ perPage: normalizedPerPage,
+ currentPage: normalizedPage,
+ hasPrev: normalizedPage > 1,
+ hasNext: normalizedPage < totalPages,
+ },
+ };
+}
+
+export function getCurrentUser() {
+ return mockUsers[0];
+}
diff --git a/web/src/mock/vfs.ts b/web/src/mock/vfs.ts
index 21c3412..d01ff2f 100644
--- a/web/src/mock/vfs.ts
+++ b/web/src/mock/vfs.ts
@@ -1,6 +1,5 @@
import Mock from 'mockjs';
-// --- Types ---
export interface VfsNode {
id: string;
name: string;
@@ -9,198 +8,496 @@ export interface VfsNode {
children?: string[];
size?: number;
mimeType?: string;
- content?: string; // Base64 encoded content for files
+ content?: string;
createdAt: string;
updatedAt: string;
permission?: 'read' | 'write' | 'owner';
isTrashed?: boolean;
deletedAt?: string;
+ isStarred?: boolean;
+ hash?: string;
+ virusStatus?: 'clean' | 'pending' | 'flagged';
+ thumbnailUrl?: string;
}
export interface Vfs {
[key: string]: VfsNode;
}
-// --- Constants ---
-//load from .env file
const VFS_STORAGE_KEY = import.meta.env.VFS_STORAGE_KEY || 'fileflash-vfs';
-// --- Initial Data ---
+function nowIso() {
+ return new Date().toISOString();
+}
+
+function newId() {
+ return Mock.Random.guid();
+}
+
const initialVfs: Vfs = {
- 'root': { id: 'root', name: 'My Files', type: 'folder', parent: null, children: ['folder1', 'file1', 'file3', 'file4', 'file5', 'file6'], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), permission: 'owner' },
- 'folder1': { id: 'folder1', name: 'Work Documents', type: 'folder', parent: 'root', children: ['file2'], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), permission: 'owner' },
- 'file1': { id: 'file1', name: 'notes.txt', type: 'file', parent: 'root', size: 1024, mimeType: 'text/plain', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), permission: 'owner' },
- 'file2': { id: 'file2', name: 'project-brief.docx', type: 'file', parent: 'folder1', size: 20480, mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), permission: 'owner' },
- 'file3': { id: 'file3', name: 'main.py', type: 'file', parent: 'root', size: 5120, mimeType: 'text/x-python', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), permission: 'owner' },
- 'file4': { id: 'file4', name: 'archive.zip', type: 'file', parent: 'root', size: 102400, mimeType: 'application/zip', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), permission: 'owner' },
- 'file5': { id: 'file5', name: 'logo.png', type: 'file', parent: 'root', size: 12288, mimeType: 'image/png', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), permission: 'owner' },
- 'file6': { id: 'file6', name: 'installer.exe', type: 'file', parent: 'root', size: 512000, mimeType: 'application/x-msdownload', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), permission: 'owner' },
+ root: {
+ id: 'root',
+ name: 'My Files',
+ type: 'folder',
+ parent: null,
+ children: ['folder1', 'folder2', 'file1', 'file2', 'file3', 'file4', 'file5'],
+ createdAt: nowIso(),
+ updatedAt: nowIso(),
+ permission: 'owner',
+ },
+ folder1: {
+ id: 'folder1',
+ name: 'Work Documents',
+ type: 'folder',
+ parent: 'root',
+ children: ['file6', 'file7'],
+ createdAt: nowIso(),
+ updatedAt: nowIso(),
+ permission: 'owner',
+ },
+ folder2: {
+ id: 'folder2',
+ name: 'Media',
+ type: 'folder',
+ parent: 'root',
+ children: ['file8', 'file9'],
+ createdAt: nowIso(),
+ updatedAt: nowIso(),
+ permission: 'owner',
+ },
+ file1: {
+ id: 'file1',
+ name: 'notes.txt',
+ type: 'file',
+ parent: 'root',
+ size: 1024,
+ mimeType: 'text/plain',
+ createdAt: nowIso(),
+ updatedAt: nowIso(),
+ permission: 'owner',
+ isStarred: true,
+ hash: 'mock-hash-file1',
+ virusStatus: 'clean',
+ },
+ file2: {
+ id: 'file2',
+ name: 'project-plan.pdf',
+ type: 'file',
+ parent: 'root',
+ size: 256000,
+ mimeType: 'application/pdf',
+ createdAt: nowIso(),
+ updatedAt: nowIso(),
+ permission: 'owner',
+ hash: 'mock-hash-file2',
+ virusStatus: 'clean',
+ },
+ file3: {
+ id: 'file3',
+ name: 'cover.jpg',
+ type: 'file',
+ parent: 'root',
+ size: 98000,
+ mimeType: 'image/jpeg',
+ createdAt: nowIso(),
+ updatedAt: nowIso(),
+ permission: 'owner',
+ thumbnailUrl: '/src/assets/image.svg',
+ virusStatus: 'clean',
+ },
+ file4: {
+ id: 'file4',
+ name: 'archive.zip',
+ type: 'file',
+ parent: 'root',
+ size: 102400,
+ mimeType: 'application/zip',
+ createdAt: nowIso(),
+ updatedAt: nowIso(),
+ permission: 'owner',
+ virusStatus: 'clean',
+ },
+ file5: {
+ id: 'file5',
+ name: 'README.md',
+ type: 'file',
+ parent: 'root',
+ size: 2048,
+ mimeType: 'text/markdown',
+ createdAt: nowIso(),
+ updatedAt: nowIso(),
+ permission: 'owner',
+ virusStatus: 'clean',
+ },
+ file6: {
+ id: 'file6',
+ name: 'release-notes.docx',
+ type: 'file',
+ parent: 'folder1',
+ size: 30480,
+ mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ createdAt: nowIso(),
+ updatedAt: nowIso(),
+ permission: 'owner',
+ virusStatus: 'clean',
+ },
+ file7: {
+ id: 'file7',
+ name: 'budget.xlsx',
+ type: 'file',
+ parent: 'folder1',
+ size: 17890,
+ mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ createdAt: nowIso(),
+ updatedAt: nowIso(),
+ permission: 'owner',
+ virusStatus: 'clean',
+ },
+ file8: {
+ id: 'file8',
+ name: 'intro.mp3',
+ type: 'file',
+ parent: 'folder2',
+ size: 4500030,
+ mimeType: 'audio/mpeg',
+ createdAt: nowIso(),
+ updatedAt: nowIso(),
+ permission: 'owner',
+ isStarred: true,
+ virusStatus: 'clean',
+ },
+ file9: {
+ id: 'file9',
+ name: 'walkthrough.mp4',
+ type: 'file',
+ parent: 'folder2',
+ size: 25000000,
+ mimeType: 'video/mp4',
+ createdAt: nowIso(),
+ updatedAt: nowIso(),
+ permission: 'owner',
+ virusStatus: 'clean',
+ },
};
-// --- VFS Singleton ---
let vfs: Vfs;
function saveVfs() {
localStorage.setItem(VFS_STORAGE_KEY, JSON.stringify(vfs));
}
+function sanitizeVfs(input: Vfs): Vfs {
+ const sanitized: Vfs = { ...input };
+
+ Object.values(sanitized).forEach((node) => {
+ if (node.type === 'folder') {
+ const children = Array.isArray(node.children) ? node.children : [];
+ const deduped = Array.from(new Set(children)).filter((childId) => Boolean(sanitized[childId]));
+ node.children = deduped;
+ }
+ });
+
+ return sanitized;
+}
+
function loadVfs(): Vfs {
const storedVfs = localStorage.getItem(VFS_STORAGE_KEY);
- if (storedVfs) {
- try {
- const parsed = JSON.parse(storedVfs);
-
- // 验证VFS数据完整性
- if (parsed.root && parsed.root.children) {
- const rootChildren = parsed.root.children;
- const childIds = new Set();
- let hasDuplicates = false;
-
- rootChildren.forEach((childId: string) => {
- if (childIds.has(childId)) {
- hasDuplicates = true;
- console.error('🚨 VFS: Duplicate child ID detected:', childId);
- }
- childIds.add(childId);
- });
-
- if (hasDuplicates) {
- console.log('🔧 VFS: Cleaning up duplicate children in root folder');
- parsed.root.children = [...childIds]; // Remove duplicates
- }
- }
-
- return parsed;
- } catch (e) {
- console.error("Failed to parse VFS from localStorage, resetting.", e);
+ if (!storedVfs) {
+ return JSON.parse(JSON.stringify(initialVfs));
+ }
+
+ try {
+ const parsed = JSON.parse(storedVfs) as Vfs;
+ if (!parsed.root || parsed.root.type !== 'folder') {
+ return JSON.parse(JSON.stringify(initialVfs));
}
+ return sanitizeVfs(parsed);
+ } catch {
+ return JSON.parse(JSON.stringify(initialVfs));
+ }
+}
+
+function ensureFolder(nodeId: string) {
+ const node = vfs[nodeId];
+ if (!node || node.type !== 'folder') {
+ throw new Error(`Folder ${nodeId} not found`);
+ }
+ if (!Array.isArray(node.children)) {
+ node.children = [];
}
- return initialVfs;
+ return node;
+}
+
+function removeChild(parentId: string | null, childId: string) {
+ if (!parentId) return;
+ const parent = vfs[parentId];
+ if (!parent || parent.type !== 'folder' || !parent.children) return;
+ parent.children = parent.children.filter((id) => id !== childId);
+}
+
+function appendChild(parentId: string, childId: string) {
+ const parent = ensureFolder(parentId);
+ if (!parent.children!.includes(childId)) {
+ parent.children!.push(childId);
+ }
+}
+
+function cloneNodeRecursively(sourceId: string, targetParentId: string, newName?: string): string {
+ const source = vfs[sourceId];
+ if (!source) throw new Error('Source node not found');
+
+ const nodeId = newId();
+ const timestamp = nowIso();
+ const copy: VfsNode = {
+ ...source,
+ id: nodeId,
+ parent: targetParentId,
+ name: newName ?? source.name,
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ isTrashed: false,
+ deletedAt: undefined,
+ isStarred: false,
+ };
+
+ if (copy.type === 'folder') {
+ copy.children = [];
+ }
+
+ vfs[nodeId] = copy;
+ appendChild(targetParentId, nodeId);
+
+ if (source.type === 'folder' && source.children) {
+ source.children.forEach((childId) => {
+ cloneNodeRecursively(childId, nodeId);
+ });
+ }
+
+ return nodeId;
+}
+
+function markSubtree(nodeId: string, updater: (node: VfsNode) => void) {
+ const node = vfs[nodeId];
+ if (!node) return;
+
+ updater(node);
+ if (node.type === 'folder' && node.children) {
+ node.children.forEach((childId) => markSubtree(childId, updater));
+ }
+}
+
+function collectSubtreeStats(nodeId: string): { totalSize: number; fileCount: number; folderCount: number } {
+ const node = vfs[nodeId];
+ if (!node || node.isTrashed) {
+ return { totalSize: 0, fileCount: 0, folderCount: 0 };
+ }
+
+ if (node.type === 'file') {
+ return { totalSize: node.size || 0, fileCount: 1, folderCount: 0 };
+ }
+
+ let totalSize = 0;
+ let fileCount = 0;
+ let folderCount = 1;
+
+ (node.children || []).forEach((childId) => {
+ const childStats = collectSubtreeStats(childId);
+ totalSize += childStats.totalSize;
+ fileCount += childStats.fileCount;
+ folderCount += childStats.folderCount;
+ });
+
+ return { totalSize, fileCount, folderCount };
}
-// Initialize VFS
vfs = loadVfs();
-saveVfs(); // Ensure it's saved on first load if it didn't exist
+saveVfs();
-// --- VFS API ---
export const vfsApi = {
get: (id: string): VfsNode | undefined => vfs[id],
+
getAll: (): Vfs => vfs,
-
+
getChildren: (folderId: string): VfsNode[] => {
const parent = vfs[folderId];
- if (parent && parent.type === 'folder' && parent.children) {
- return parent.children
- .map(id => vfs[id])
- .filter(Boolean)
- .filter(item => !item.isTrashed); // Filter out trashed items from normal view
+ if (!parent || parent.type !== 'folder' || !parent.children) {
+ return [];
}
- return [];
+
+ return parent.children
+ .map((id) => vfs[id])
+ .filter((node): node is VfsNode => Boolean(node))
+ .filter((node) => !node.isTrashed);
},
-
+
getPath: (id: string): VfsNode[] => {
const path: VfsNode[] = [];
let current: VfsNode | undefined = vfs[id];
+
while (current) {
path.unshift(current);
current = current.parent ? vfs[current.parent] : undefined;
}
+
return path;
},
+ search: (folderId: string, query: string): VfsNode[] => {
+ const lowerQuery = query.trim().toLowerCase();
+ if (!lowerQuery) return [];
+
+ const results: VfsNode[] = [];
+
+ const walk = (nodeId: string) => {
+ const node = vfs[nodeId];
+ if (!node || node.isTrashed) return;
+
+ if (node.id !== folderId && node.name.toLowerCase().includes(lowerQuery)) {
+ results.push(node);
+ }
+
+ if (node.type === 'folder' && node.children) {
+ node.children.forEach((childId) => walk(childId));
+ }
+ };
+
+ walk(folderId);
+ return results;
+ },
+
createFile: (parentId: string, fileName: string, size: number, mimeType: string, content?: string): VfsNode => {
- const newId = Mock.Random.guid();
- const now = new Date().toISOString();
- const newFile: VfsNode = {
- id: newId,
+ ensureFolder(parentId);
+
+ const timestamp = nowIso();
+ const file: VfsNode = {
+ id: newId(),
name: fileName,
type: 'file',
parent: parentId,
size,
mimeType,
content,
- createdAt: now,
- updatedAt: now,
- permission: 'owner', // New files created by the user are owned by them
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ permission: 'owner',
+ isStarred: false,
+ hash: `mock-hash-${Mock.Random.string('lower', 12)}`,
+ virusStatus: 'clean',
};
- vfs[newId] = newFile;
- // Ensure parent folder exists and has a children array
- if (vfs[parentId] && vfs[parentId].children) {
- vfs[parentId].children?.push(newId);
- }
+
+ vfs[file.id] = file;
+ appendChild(parentId, file.id);
saveVfs();
- return newFile;
+ return file;
},
-
+
createFolder: (parentId: string, folderName: string): VfsNode => {
- const newId = Mock.Random.guid();
- const now = new Date().toISOString();
- const newFolder: VfsNode = {
- id: newId,
+ ensureFolder(parentId);
+
+ const timestamp = nowIso();
+ const folder: VfsNode = {
+ id: newId(),
name: folderName,
type: 'folder',
parent: parentId,
children: [],
- createdAt: now,
- updatedAt: now,
+ createdAt: timestamp,
+ updatedAt: timestamp,
permission: 'owner',
+ isStarred: false,
};
- vfs[newId] = newFolder;
- // Ensure parent folder exists and has a children array
- if (vfs[parentId] && vfs[parentId].children) {
- vfs[parentId].children?.push(newId);
- }
+
+ vfs[folder.id] = folder;
+ appendChild(parentId, folder.id);
saveVfs();
- return newFolder;
+ return folder;
},
rename: (id: string, newName: string): VfsNode => {
- vfs[id].name = newName;
- vfs[id].updatedAt = new Date().toISOString();
+ const node = vfs[id];
+ if (!node) {
+ throw new Error('Node not found');
+ }
+
+ node.name = newName;
+ node.updatedAt = nowIso();
saveVfs();
- return vfs[id];
+ return node;
},
-
+
move: (id: string, targetParentId: string): VfsNode => {
+ if (id === 'root') {
+ throw new Error('Root folder cannot be moved');
+ }
+
const node = vfs[id];
- if (!node) throw new Error("Node to move not found");
-
- const oldParentId = node.parent;
- if (oldParentId && vfs[oldParentId]?.children) {
- const children = vfs[oldParentId].children!;
- const index = children.indexOf(id);
- if (index > -1) {
- children.splice(index, 1);
+ if (!node) {
+ throw new Error('Node not found');
+ }
+
+ ensureFolder(targetParentId);
+
+ if (node.parent === targetParentId) {
+ return node;
+ }
+
+ if (node.type === 'folder') {
+ let cursor = targetParentId;
+ while (cursor) {
+ if (cursor === id) {
+ throw new Error('Cannot move a folder into itself');
+ }
+ const cursorNode = vfs[cursor];
+ cursor = cursorNode?.parent || '';
}
}
-
+
+ removeChild(node.parent, id);
node.parent = targetParentId;
- vfs[targetParentId].children?.push(id);
- node.updatedAt = new Date().toISOString();
-
+ node.updatedAt = nowIso();
+ appendChild(targetParentId, id);
+ saveVfs();
+ return node;
+ },
+
+ copy: (id: string, targetParentId: string, newName?: string): VfsNode => {
+ ensureFolder(targetParentId);
+ const newIdValue = cloneNodeRecursively(id, targetParentId, newName);
+ const copied = vfs[newIdValue];
+ saveVfs();
+ return copied;
+ },
+
+ setStarred: (id: string, isStarred: boolean): VfsNode => {
+ const node = vfs[id];
+ if (!node) {
+ throw new Error('Node not found');
+ }
+
+ node.isStarred = isStarred;
+ node.updatedAt = nowIso();
saveVfs();
return node;
},
+ getStarred: (): VfsNode[] => {
+ return Object.values(vfs).filter((node) => !node.isTrashed && node.id !== 'root' && node.isStarred);
+ },
+
delete: (id: string) => {
+ if (id === 'root') return;
const node = vfs[id];
if (!node) return;
- // Remove from parent's children list
- if (node.parent && vfs[node.parent]?.children) {
- const children = vfs[node.parent].children!;
- const index = children.indexOf(id);
- if (index > -1) {
- children.splice(index, 1);
- }
- }
+ removeChild(node.parent, id);
- // Mark as trashed
- node.isTrashed = true;
- node.deletedAt = new Date().toISOString();
+ const deletedAt = nowIso();
+ markSubtree(id, (entry) => {
+ entry.isTrashed = true;
+ entry.deletedAt = deletedAt;
+ entry.updatedAt = deletedAt;
+ });
- // No recursive action. If a folder is deleted, its children are still in the VFS
- // but are effectively inaccessible until the parent folder is restored.
saveVfs();
},
@@ -208,75 +505,113 @@ export const vfsApi = {
const node = vfs[id];
if (!node) return;
- // Un-mark as trashed
- node.isTrashed = false;
- delete node.deletedAt;
+ const restoreAncestors = (nodeId: string) => {
+ const current = vfs[nodeId];
+ if (!current || !current.parent) return;
+ const parent = vfs[current.parent];
+ if (!parent) return;
- // Add back to parent's children list
- if (node.parent && vfs[node.parent]?.children) {
- if (!vfs[node.parent].children!.includes(id)) {
- vfs[node.parent].children!.push(id);
+ if (parent.isTrashed) {
+ restoreAncestors(parent.id);
+ parent.isTrashed = false;
+ parent.deletedAt = undefined;
}
- }
-
- // If a folder is restored, we need to recursively restore its children
- if (node.type === 'folder' && node.children) {
- // This is tricky. The simplest way is to not recursively trash.
- // Let's assume for now children are not marked as trashed when parent is.
- }
+
+ appendChild(parent.id, current.id);
+ };
+
+ restoreAncestors(id);
+
+ markSubtree(id, (entry) => {
+ entry.isTrashed = false;
+ entry.deletedAt = undefined;
+ entry.updatedAt = nowIso();
+ });
saveVfs();
},
-
+
permanentDelete: (id: string) => {
+ if (id === 'root') return;
const node = vfs[id];
if (!node) return;
-
- // Recursively delete children if it's a folder
- if (node.type === 'folder' && node.children) {
- // Make a copy of children array before iterating
- [...node.children].forEach(childId => vfsApi.permanentDelete(childId));
- }
-
- // Parent's children list is already updated when item was trashed.
-
- delete vfs[id];
+
+ removeChild(node.parent, id);
+
+ const erase = (nodeId: string) => {
+ const target = vfs[nodeId];
+ if (!target) return;
+ if (target.type === 'folder' && target.children) {
+ [...target.children].forEach((childId) => erase(childId));
+ }
+ delete vfs[nodeId];
+ };
+
+ erase(id);
saveVfs();
},
- // --- Development helpers ---
+ clearRecycleBin: () => {
+ const trashedNodes = Object.values(vfs)
+ .filter((node) => node.isTrashed)
+ .sort((a, b) => (b.type === 'folder' ? 1 : 0) - (a.type === 'folder' ? 1 : 0));
+
+ let fileCount = 0;
+ let folderCount = 0;
+ let totalSize = 0;
+
+ trashedNodes.forEach((node) => {
+ if (!vfs[node.id]) return;
+ if (node.type === 'file') {
+ fileCount += 1;
+ totalSize += node.size || 0;
+ } else {
+ folderCount += 1;
+ }
+ vfsApi.permanentDelete(node.id);
+ });
+
+ saveVfs();
+ return {
+ filesDeleted: fileCount,
+ foldersDeleted: folderCount,
+ totalStorageFreed: totalSize,
+ };
+ },
+
+ getFolderStats: (folderId: string) => {
+ const node = vfs[folderId];
+ if (!node || node.type !== 'folder') {
+ throw new Error('Folder not found');
+ }
+
+ const stats = collectSubtreeStats(folderId);
+ return {
+ totalSize: stats.totalSize,
+ fileCount: stats.fileCount,
+ folderCount: Math.max(stats.folderCount - 1, 0),
+ };
+ },
+
resetVfs: () => {
- console.log('🔄 Resetting VFS to initial state...');
- vfs = JSON.parse(JSON.stringify(initialVfs)); // Deep clone
+ vfs = JSON.parse(JSON.stringify(initialVfs));
saveVfs();
- console.log('✅ VFS reset complete');
return vfs;
},
-
+
debugVfs: () => {
- console.log('🔍 VFS Debug Info:');
- console.log('Root children:', vfs.root?.children);
- console.log('All nodes:', Object.keys(vfs));
-
- if (vfs.root?.children) {
- const duplicates = vfs.root.children.filter((id, index, arr) => arr.indexOf(id) !== index);
- if (duplicates.length > 0) {
- console.error('❌ Found duplicate children:', duplicates);
- } else {
- console.log('✅ No duplicate children found');
- }
- }
-
- return vfs;
+ return {
+ nodes: Object.keys(vfs).length,
+ rootChildren: vfs.root?.children || [],
+ trashed: Object.values(vfs).filter((node) => node.isTrashed).map((node) => node.id),
+ };
},
};
-// 在开发环境中暴露调试功能到全局
if (import.meta.env.DEV) {
(window as any).vfsDebug = {
reset: vfsApi.resetVfs,
debug: vfsApi.debugVfs,
- getVfs: vfsApi.getAll
+ getVfs: vfsApi.getAll,
};
- console.log('🛠️ VFS Debug tools available: vfsDebug.reset(), vfsDebug.debug(), vfsDebug.getVfs()');
-}
\ No newline at end of file
+}
From 2347aa2036bca0f136839561b44b1e276a65e950 Mon Sep 17 00:00:00 2001
From: 0neStep <146049978+AperturePlus@users.noreply.github.com>
Date: Sat, 21 Mar 2026 15:47:12 +0800
Subject: [PATCH 4/8] feat(web-core): extend api clients and routing for
sharing, admin and password reset
---
web/src/api/file.ts | 22 ++++++-
web/src/api/folder.ts | 12 +++-
web/src/api/notification.ts | 8 ++-
web/src/api/recycle.ts | 4 +-
web/src/api/share.ts | 10 +++-
web/src/api/storage.ts | 6 +-
web/src/api/user.ts | 35 +++++++++++-
web/src/router/gurad.ts | 2 +-
web/src/router/routes.ts | 5 ++
web/src/store/file.ts | 111 ++++++------------------------------
web/src/types/file.d.ts | 6 +-
web/src/utils/eventBus.ts | 3 +-
web/src/utils/http.ts | 7 +--
13 files changed, 116 insertions(+), 115 deletions(-)
diff --git a/web/src/api/file.ts b/web/src/api/file.ts
index 3ab9632..8e724a6 100644
--- a/web/src/api/file.ts
+++ b/web/src/api/file.ts
@@ -1,10 +1,10 @@
import http from '../utils/http';
import type {
- PaginatedData,
- ApiResponse
+ PaginatedData
} from '../types/base';
import type {
FileItem,
+ ContentItem,
FileDetails,
GetFilesRequest,
RenameFileRequest,
@@ -53,6 +53,13 @@ export const getFiles = (params: GetFilesRequest) => {
return http.get>('/files', params);
};
+/**
+ * 获取已星标文件与文件夹
+ */
+export const getStarredFiles = () => {
+ return http.get>('/files/starred');
+};
+
/**
* 获取文件详情
* @param fileId 文件ID
@@ -112,6 +119,15 @@ export const moveFile = (fileId: string, data: MoveFileRequest) => {
return http.patch<{ fileId: string; targetFolderId: string; movedAt: string }>(`/files/${fileId}/move`, data);
};
+/**
+ * 设置文件星标状态
+ * @param fileId 文件ID
+ * @param isStarred 是否星标
+ */
+export const toggleFileStar = (fileId: string, isStarred: boolean) => {
+ return http.patch(`/files/${fileId}/star`, { isStarred });
+};
+
/**
* 复制文件
* @param fileId 文件ID
@@ -153,4 +169,4 @@ interface ResponseData {
processed: number;
action: string;
succeeded: number;
-}
\ No newline at end of file
+}
diff --git a/web/src/api/folder.ts b/web/src/api/folder.ts
index 127a7c9..378db19 100644
--- a/web/src/api/folder.ts
+++ b/web/src/api/folder.ts
@@ -3,7 +3,6 @@ import type { PaginatedData } from '../types/base';
import type {
ContentItem,
GetFolderContentsRequest,
- FolderPathResponse,
FolderItem,
CreateFolderRequest,
RenameFolderRequest,
@@ -113,4 +112,13 @@ export const getFolderSize = (folderId: string) => {
*/
export const copyFolder = (folderId: string, data: { targetParentId: string; newName?: string }) => {
return http.post(`/folders/${folderId}/copy`, data);
-};
\ No newline at end of file
+};
+
+/**
+ * 设置文件夹星标状态
+ * @param folderId 文件夹ID
+ * @param isStarred 是否星标
+ */
+export const toggleFolderStar = (folderId: string, isStarred: boolean) => {
+ return http.patch(`/folders/${folderId}/star`, { isStarred });
+};
diff --git a/web/src/api/notification.ts b/web/src/api/notification.ts
index 8ee5f30..4298cdb 100644
--- a/web/src/api/notification.ts
+++ b/web/src/api/notification.ts
@@ -1,5 +1,5 @@
import http from '../utils/http';
-import type { NotificationsList, GetNotificationsRequest } from '../types/notification';
+import type { NotificationsList, GetNotificationsRequest, NotificationItem } from '../types/notification';
export const getNotifications = (params: GetNotificationsRequest) => {
return http.get('/notifications', params);
@@ -15,4 +15,8 @@ export const markAllAsRead = () => {
export const deleteNotification = (notificationId: string) => {
return http.delete<{ notificationId: string }>(`/notifications/${notificationId}`);
-};
\ No newline at end of file
+};
+
+export const broadcastNotification = (message: string) => {
+ return http.post('/notifications/broadcast', { message });
+};
diff --git a/web/src/api/recycle.ts b/web/src/api/recycle.ts
index b38cd27..e8309b3 100644
--- a/web/src/api/recycle.ts
+++ b/web/src/api/recycle.ts
@@ -27,7 +27,7 @@ export const restoreItem = (itemId: string, data: RestoreRecycleItemRequest) =>
* @param itemType 项目类型
* @returns 删除后的项目信息
*/
-export const permanentDelete = (itemId: string, itemType: string) => {
+export const permanentDelete = (itemId: string, _itemType: string) => {
return http.delete<{ }>(`/recycle-bin/${itemId}`);
};
@@ -37,4 +37,4 @@ export const permanentDelete = (itemId: string, itemType: string) => {
*/
export const clearRecycleBin = () => {
return http.delete<{ filesDeleted: number; foldersDeleted: number; totalStorageFreed: number; cleanupCompletedAt: string }>('/recycle-bin');
-};
\ No newline at end of file
+};
diff --git a/web/src/api/share.ts b/web/src/api/share.ts
index c9622e7..45f0de0 100644
--- a/web/src/api/share.ts
+++ b/web/src/api/share.ts
@@ -62,4 +62,12 @@ export const deleteShare = (shareLink: string) => {
*/
export const getSharedItems = (params: GetSharedItemsRequest) => {
return http.get>('/shared-items', params);
-};
\ No newline at end of file
+};
+
+/**
+ * 接受他人共享的文件/文件夹到我的文件
+ * @param itemId 共享项目ID
+ */
+export const acceptSharedItem = (itemId: string) => {
+ return http.post<{ accepted: boolean; acceptedAt: string; itemId: string }>(`/shared-items/${itemId}/accept`);
+};
diff --git a/web/src/api/storage.ts b/web/src/api/storage.ts
index 4426bb3..6430771 100644
--- a/web/src/api/storage.ts
+++ b/web/src/api/storage.ts
@@ -31,4 +31,8 @@ export const getStorageSummary = async () => {
*/
export const getUsageTrend = (params: GetUsageTrendRequest) => {
return http.get('/storage/usage-trend', params);
-};
\ No newline at end of file
+};
+
+export const getStorageUsers = () => {
+ return http.get('/admin/storage/users');
+};
diff --git a/web/src/api/user.ts b/web/src/api/user.ts
index 8090b59..af8b015 100644
--- a/web/src/api/user.ts
+++ b/web/src/api/user.ts
@@ -74,6 +74,23 @@ export const register = (data: RegisterRequest) => {
return http.post('/auth/register', data);
};
+/**
+ * 找回密码 - 发送重置邮件
+ * @param email 邮箱地址
+ */
+export const forgotPassword = (email: string) => {
+ return http.post<{ requestId: string; expiresInMinutes: number }>('/auth/forgot-password', { email });
+};
+
+/**
+ * 重置密码
+ * @param token 重置令牌
+ * @param newPassword 新密码
+ */
+export const resetPassword = (token: string, newPassword: string) => {
+ return http.post('/auth/reset-password', { token, newPassword });
+};
+
/**
* 用户登录
* @param data 登录凭据
@@ -151,4 +168,20 @@ export const getActivityLog = async (params: GetActivityLogRequest) => {
*/
export const getUsers = (params: { search?: string; page?: number; perPage?: number }) => {
return http.get>('/users', params);
-};
\ No newline at end of file
+};
+
+export const getAdminUsers = (params: { page?: number; perPage?: number }) => {
+ return http.get>('/admin/users', params);
+};
+
+export const updateUserStatus = (userId: string, status: 'active' | 'suspended') => {
+ return http.patch<{ userId: string; status: string; updatedAt: string }>(`/admin/users/${userId}/status`, { status });
+};
+
+export const getViolations = () => {
+ return http.get>('/admin/violations');
+};
+
+export const resolveViolation = (violationId: string) => {
+ return http.post<{ violationId: string; resolvedAt: string }>(`/admin/violations/${violationId}/resolve`);
+};
diff --git a/web/src/router/gurad.ts b/web/src/router/gurad.ts
index 9bfab95..cf2f769 100644
--- a/web/src/router/gurad.ts
+++ b/web/src/router/gurad.ts
@@ -5,7 +5,7 @@ const LOGIN_ROUTE_NAME = 'Login';
const HOME_ROUTE_NAME = 'Home';
export function createRouterGuard(router: Router) {
- router.beforeEach((to, from, next) => {
+ router.beforeEach((to, _from, next) => {
const userStore = useUserStore();
const isLoggedIn = !!userStore.token;
diff --git a/web/src/router/routes.ts b/web/src/router/routes.ts
index b650041..d5d8548 100644
--- a/web/src/router/routes.ts
+++ b/web/src/router/routes.ts
@@ -12,6 +12,11 @@ export const routes: Array = [
name: 'Register',
component: () => import('../pages/register/index.ts'),
},
+ {
+ path: '/forgot-password',
+ name: 'ForgotPassword',
+ component: () => import('../pages/forgot-password/index.ts'),
+ },
{
path: '/',
diff --git a/web/src/store/file.ts b/web/src/store/file.ts
index 81cdd1c..b452d47 100644
--- a/web/src/store/file.ts
+++ b/web/src/store/file.ts
@@ -12,76 +12,30 @@ export const useFileStore = defineStore('file', () => {
async function fetchFolderContents(folderId: string) {
isLoading.value = true;
- selectedFile.value = null; // Reset selection when navigating
+ selectedFile.value = null;
+
try {
- console.log(`正在获取文件夹内容: ${folderId}`);
-
- // Fetch folder contents and path concurrently
const [contentsResponse, pathResponse] = await Promise.all([
getFolderContents({ folderId }),
getFolderPath(folderId),
]);
-
- console.log('Contents response:', contentsResponse);
- console.log('Path response:', pathResponse);
-
- // Validate responses - 更宽松的验证
- if (!contentsResponse) {
- throw new Error(`No contents response for folder ${folderId}`);
- }
- if (!pathResponse) {
- throw new Error(`No path response for folder ${folderId}`);
- }
-
- // 检查数据结构并适配 - 更灵活的数据提取
- let responseItems = [];
- let pathItems = [];
-
- // 尝试多种可能的数据结构
- if (contentsResponse.items) {
- responseItems = contentsResponse.items;
- } else if (contentsResponse.data) {
- responseItems = contentsResponse.data;
- } else if (Array.isArray(contentsResponse)) {
- responseItems = contentsResponse;
- }
-
- // 处理路径数据
- if (Array.isArray(pathResponse)) {
- pathItems = pathResponse;
- } else if (pathResponse.pathItems) {
- pathItems = pathResponse.pathItems;
- } else if (pathResponse.data) {
- pathItems = pathResponse.data;
- }
-
- console.log('Processed items:', responseItems);
- console.log('Processed path:', pathItems);
-
- // 如果是根文件夹,确保路径显示为 "My Files"
+
+ items.value = contentsResponse.items;
+
if (folderId === 'root') {
- pathItems = [
- {
- folderId: 'root',
- name: 'My Files'
- }
- ];
+ path.value = [{ folderId: 'root', name: 'My Files' }];
} else {
- // 处理其他路径,替换任何 root 为 "My Files"
- pathItems = pathItems.map((pathItem: any) => ({
- ...pathItem,
- name: pathItem.name === 'root' ? 'My Files' : pathItem.name
+ path.value = pathResponse.pathItems.map((item) => ({
+ ...item,
+ name: item.folderId === 'root' ? 'My Files' : item.name,
}));
}
- // Set store state
- items.value = responseItems;
- path.value = pathItems;
currentFolderId.value = folderId;
-
} catch (error) {
console.error(`Failed to fetch contents for folder ${folderId}:`, error);
- // Handle error, maybe show a notification to the user
+ items.value = [];
+ path.value = [{ folderId: 'root', name: 'My Files' }];
} finally {
isLoading.value = false;
}
@@ -93,47 +47,16 @@ export const useFileStore = defineStore('file', () => {
async function searchInFolder(folderId: string, query: string): Promise {
try {
- const response = await getFolderContents({
- folderId,
- search: query
- });
-
- let actualItems = response.items;
-
- // 特殊处理 root 文件夹的情况 - 与其他地方保持一致
- if (folderId === 'root' && actualItems.length === 1 && actualItems[0].name === 'root' && actualItems[0].itemType === 'folder') {
- const rootFolderId = actualItems[0].id.toString();
- const rootSearchResponse = await getFolderContents({
- folderId: rootFolderId,
- search: query
- });
- actualItems = rootSearchResponse.items;
- }
-
- return actualItems;
+ const response = await getFolderContents({ folderId, search: query });
+ return response.items;
} catch (error) {
- console.error(`Failed to search in folder ${folderId} with query "${query}":`, error);
+ console.error(`Failed to search in folder ${folderId} with query \"${query}\":`, error);
throw error;
}
}
function removeItems(itemIds: string[]) {
- console.log('removeItems called with:', itemIds);
- console.log('Current items before filter:', items.value.map(item => ({ id: item.id, name: item.name })));
-
- // 从当前文件夹中移除指定的项目
- const itemsBefore = items.value.length;
- items.value = items.value.filter(item => {
- const shouldKeep = !itemIds.includes(item.id);
- if (!shouldKeep) {
- console.log(`Removing item: ${item.id} (${item.name})`);
- }
- return shouldKeep;
- });
-
- const itemsAfter = items.value.length;
- console.log(`Items count: ${itemsBefore} -> ${itemsAfter} (removed ${itemsBefore - itemsAfter})`);
- console.log('Remaining items:', items.value.map(item => ({ id: item.id, name: item.name })));
+ items.value = items.value.filter((item) => !itemIds.includes(item.id));
}
return {
@@ -145,6 +68,6 @@ export const useFileStore = defineStore('file', () => {
fetchFolderContents,
navigateToFolder,
searchInFolder,
- removeItems, // 添加新的删除方法
+ removeItems,
};
-});
\ No newline at end of file
+});
diff --git a/web/src/types/file.d.ts b/web/src/types/file.d.ts
index 3a0cf6f..45b8ed4 100644
--- a/web/src/types/file.d.ts
+++ b/web/src/types/file.d.ts
@@ -12,12 +12,13 @@ export interface FileItem {
createdAt: string;
folderId: string;
permission?: 'read' | 'write' | 'owner';
+ isStarred?: boolean;
}
/**
* 文件夹项的基础结构
*/
- export interface FolderItem {
+export interface FolderItem {
itemType: 'folder';
id: string;
name: string;
@@ -27,6 +28,7 @@ export interface FileItem {
createdAt: string;
parentFolderId: string | null;
permission?: 'read' | 'write' | 'owner';
+ isStarred?: boolean;
}
/**
@@ -330,4 +332,4 @@ export interface GetRecycleBinRequest {
page?: number;
perPage?: number;
itemType?: 'file' | 'folder';
-}
\ No newline at end of file
+}
diff --git a/web/src/utils/eventBus.ts b/web/src/utils/eventBus.ts
index bc5bc0d..4e07812 100644
--- a/web/src/utils/eventBus.ts
+++ b/web/src/utils/eventBus.ts
@@ -1,4 +1,3 @@
-import { ref } from 'vue';
import mitt from 'mitt';
type Events = {
@@ -11,4 +10,4 @@ type Events = {
'search-files': { query: string };
};
-export const eventBus = mitt();
\ No newline at end of file
+export const eventBus = mitt();
diff --git a/web/src/utils/http.ts b/web/src/utils/http.ts
index 08281e8..24f84fc 100644
--- a/web/src/utils/http.ts
+++ b/web/src/utils/http.ts
@@ -2,7 +2,6 @@ import axios from 'axios';
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import qs from 'qs';
import { useUserStore } from '../store/user';
-import type { ApiResponse } from '../types/base';
// 通过模块扩展为 AxiosRequestConfig 添加自定义属性
declare module 'axios' {
@@ -61,7 +60,7 @@ instance.interceptors.response.use(
return response.data;
}
// 如果HTTP状态码不是200, Blob中可能包含错误信息
- return new Promise((resolve, reject) => {
+ return new Promise((_resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
try {
@@ -109,7 +108,7 @@ instance.interceptors.response.use(
case 401:
console.error(`[401] 认证失败: ${errorMessage}`);
const userStore = useUserStore();
- userStore.removeToken();
+ userStore.logout();
// 此处可以添加重定向到登录页的逻辑
// import router from '../router';
// router.push('/login');
@@ -179,4 +178,4 @@ const http = {
instance.delete(url, config),
};
-export default http;
\ No newline at end of file
+export default http;
From 883017e6267defb02891a62cdfe717350940ae4a Mon Sep 17 00:00:00 2001
From: 0neStep <146049978+AperturePlus@users.noreply.github.com>
Date: Sat, 21 Mar 2026 15:47:24 +0800
Subject: [PATCH 5/8] feat(web-ui): revamp workspace layout and
auth/share/trash experiences
---
web/index.html | 2 +-
web/src/components/common/Breadcrumb.vue | 6 +-
web/src/components/common/FileTreeNode.vue | 7 +-
web/src/components/common/ShareDialog.vue | 710 ++++++----
web/src/components/layout/Header.vue | 356 +++--
web/src/components/layout/LeftSidebar.vue | 266 ++--
web/src/components/layout/MainLayout.vue | 68 +-
web/src/components/layout/RightSidebar.vue | 243 ++--
web/src/components/layout/UserMenu.vue | 5 +-
web/src/composables/useBatchActions.ts | 6 +-
web/src/composables/useFileActions.ts | 4 +-
web/src/composables/useUpload.ts | 6 +-
web/src/pages/dashboard/Dashboard.vue | 436 +++++-
web/src/pages/files/MyFiles.vue | 1206 ++++++++---------
.../pages/forgot-password/ForgotPassword.vue | 153 +++
web/src/pages/forgot-password/index.ts | 3 +
web/src/pages/login/Login.vue | 455 ++-----
web/src/pages/profile/Profile.vue | 27 +-
web/src/pages/register/Register.vue | 493 ++-----
web/src/pages/settings/Settings.vue | 14 +-
web/src/pages/shared/SharedWithMe.vue | 453 +++++--
web/src/pages/trash/Trash.vue | 252 ++--
web/src/style.css | 188 +--
23 files changed, 2919 insertions(+), 2440 deletions(-)
create mode 100644 web/src/pages/forgot-password/ForgotPassword.vue
create mode 100644 web/src/pages/forgot-password/index.ts
diff --git a/web/index.html b/web/index.html
index dde16aa..478dd2d 100644
--- a/web/index.html
+++ b/web/index.html
@@ -4,7 +4,7 @@
- Vite + Vue + TS
+ FileFlash Cloud Drive
diff --git a/web/src/components/common/Breadcrumb.vue b/web/src/components/common/Breadcrumb.vue
index 0e53d88..85f3780 100644
--- a/web/src/components/common/Breadcrumb.vue
+++ b/web/src/components/common/Breadcrumb.vue
@@ -35,7 +35,7 @@ const handleDrop = (e: DragEvent, folderId: string | null) => {
emit('drop-on-folder', { sourceItemIds, targetFolderId: folderId });
};
-const handleDragOver = (e: DragEvent, folderId: string | null) => {
+const handleDragOver = (e: DragEvent) => {
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'move';
@@ -63,7 +63,7 @@ const handleDragLeave = () => {
class="breadcrumb-link"
:class="{ 'drag-over': isBeingDraggedOver === item.folderId }"
@drop="handleDrop($event, item.folderId)"
- @dragover="handleDragOver($event, item.folderId)"
+ @dragover="handleDragOver($event)"
@dragenter="handleDragEnter(item.folderId)"
@dragleave="handleDragLeave"
>
@@ -123,4 +123,4 @@ const handleDragLeave = () => {
margin: 0 var(--spacing-sm);
color: var(--color-text-tertiary);
}
-
\ No newline at end of file
+
diff --git a/web/src/components/common/FileTreeNode.vue b/web/src/components/common/FileTreeNode.vue
index 7356938..62e0970 100644
--- a/web/src/components/common/FileTreeNode.vue
+++ b/web/src/components/common/FileTreeNode.vue
@@ -1,8 +1,9 @@
@@ -96,53 +192,82 @@ const copyLink = () => {