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 = `${file.name}`; + 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 = `${node.name}`; + 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 @@ \ No newline at end of file + position: absolute; + inset: 0; + border-radius: 999px; + background-color: var(--color-border); + transition: 0.2s; +} + +.slider::before { + content: ''; + position: absolute; + width: 18px; + height: 18px; + left: 3px; + top: 3px; + border-radius: 50%; + background-color: #fff; + transition: 0.2s; +} + +.switch input:checked + .slider { + background-color: var(--color-primary); +} + +.switch input:checked + .slider::before { + transform: translateX(20px); +} + +.modal-fade-enter-active, +.modal-fade-leave-active { + transition: opacity 0.2s ease; +} + +.modal-fade-enter-from, +.modal-fade-leave-to { + opacity: 0; +} + diff --git a/web/src/components/layout/Header.vue b/web/src/components/layout/Header.vue index fa08c99..b12c991 100644 --- a/web/src/components/layout/Header.vue +++ b/web/src/components/layout/Header.vue @@ -1,133 +1,114 @@