From 7b9d111513a7691c557c392f1273954afe39b257 Mon Sep 17 00:00:00 2001 From: yuzheng3 Date: Fri, 19 Sep 2025 10:08:47 +0000 Subject: [PATCH 1/7] SECRET_KEY --- docker-compose-middleware.yml | 56 +++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docker-compose-middleware.yml diff --git a/docker-compose-middleware.yml b/docker-compose-middleware.yml new file mode 100644 index 00000000000..eca151db99b --- /dev/null +++ b/docker-compose-middleware.yml @@ -0,0 +1,56 @@ +services: + plane-db: + container_name: plane-db + image: postgres:15.7-alpine + restart: always + command: postgres -c 'max_connections=1000' + volumes: + - ./docker_data/pgdata:/var/lib/postgresql/data + env_file: + - .env + environment: + POSTGRES_USER: plane + POSTGRES_DB: plane + POSTGRES_PASSWORD: plane + PGDATA: /var/lib/postgresql/data + ports: + - "5432:5432" + + plane-redis: + container_name: plane-redis + image: valkey/valkey:7.2.5-alpine + restart: always + volumes: + - ./docker_data/redisdata:/data + ports: + - "6379:6379" + + plane-mq: + container_name: plane-mq + image: rabbitmq:3.13.6-management-alpine + restart: always + env_file: + - .env + environment: + RABBITMQ_DEFAULT_USER: plane + RABBITMQ_DEFAULT_PASS: plane + RABBITMQ_DEFAULT_VHOST: plane + volumes: + - ./docker_data/rabbitmq_data:/var/lib/rabbitmq + ports: + - "5672:5672" + - "15672:15672" + + plane-minio: + container_name: plane-minio + image: minio/minio + restart: always + command: server /export --console-address ":9090" + volumes: + - ./docker_data/uploads:/export + environment: + MINIO_ROOT_USER: plane + MINIO_ROOT_PASSWORD: plane123456789 + ports: + - "9000:9000" + - "9090:9090" From 4c3c3cdf499fad32e79db58a5e6b373375ccffed Mon Sep 17 00:00:00 2001 From: yuzheng3 Date: Thu, 25 Sep 2025 09:58:32 +0000 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E5=BC=80?= =?UTF-8?q?=E5=8F=91=E7=8E=AF=E5=A2=83=E9=85=8D=E7=BD=AE=E5=92=8C=E4=BE=A7?= =?UTF-8?q?=E8=BE=B9=E6=A0=8F=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增本地开发环境配置,包括数据库、Redis和S3设置 - 优化Next.js开发服务器配置,支持0.0.0.0访问 - 改进侧边栏交互逻辑,修复折叠状态下的peek行为 - 添加模型注释和类型定义 - 移除GitHub星标按钮 - 增加并发构建数量 --- apps/admin/package.json | 2 +- apps/api/plane/api/models.py | 0 apps/api/plane/db/models/workspace.py | 151 +++++++++++------- apps/api/plane/settings/common.py | 46 +++--- apps/api/plane/settings/local.py | 1 + apps/api/plane/web/models.py | 0 apps/space/next.config.js | 10 ++ apps/space/package.json | 2 +- .../[workspaceSlug]/(projects)/_sidebar.tsx | 4 +- .../[workspaceSlug]/(projects)/header.tsx | 2 +- .../(all)/[workspaceSlug]/(projects)/page.tsx | 3 + apps/web/core/components/home/root.tsx | 8 +- .../home/widgets/manage/widget-item.tsx | 5 +- .../components/sidebar/resizable-sidebar.tsx | 56 ++++--- .../workspace/sidebar/sidebar-item.tsx | 22 ++- apps/web/next.config.js | 2 + apps/web/package.json | 2 +- package.json | 2 +- 18 files changed, 202 insertions(+), 116 deletions(-) create mode 100644 apps/api/plane/api/models.py create mode 100644 apps/api/plane/web/models.py diff --git a/apps/admin/package.json b/apps/admin/package.json index 18acfb50f3d..345e21cae03 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -5,7 +5,7 @@ "license": "AGPL-3.0", "private": true, "scripts": { - "dev": "next dev --port 3001", + "dev": "next dev --port 3001 -H 0.0.0.0", "build": "next build", "preview": "next build && next start", "start": "next start", diff --git a/apps/api/plane/api/models.py b/apps/api/plane/api/models.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/api/plane/db/models/workspace.py b/apps/api/plane/db/models/workspace.py index 75a45f72c08..d2f3d85188d 100644 --- a/apps/api/plane/db/models/workspace.py +++ b/apps/api/plane/db/models/workspace.py @@ -113,28 +113,33 @@ def slug_validator(value): class Workspace(BaseModel): + """ + 工作空间模型,项目的顶层容器。 + """ TIMEZONE_CHOICES = tuple(zip(pytz.common_timezones, pytz.common_timezones)) - name = models.CharField(max_length=80, verbose_name="Workspace Name") - logo = models.TextField(verbose_name="Logo", blank=True, null=True) + name = models.CharField(max_length=80, verbose_name="Workspace Name", help_text="工作空间名称") + logo = models.TextField(verbose_name="Logo", blank=True, null=True, help_text="工作空间 Logo 的 URL 或 Base64 数据") logo_asset = models.ForeignKey( "db.FileAsset", on_delete=models.SET_NULL, related_name="workspace_logo", blank=True, null=True, + help_text="关联到文件资源模型的 Logo", ) owner = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="owner_workspace", + help_text="工作空间的所有者", ) slug = models.SlugField( - max_length=48, db_index=True, unique=True, validators=[slug_validator] + max_length=48, db_index=True, unique=True, validators=[slug_validator], help_text="用于 URL 的唯一标识符" ) - organization_size = models.CharField(max_length=20, blank=True, null=True) - timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES) - background_color = models.CharField(max_length=255, default=get_random_color) + organization_size = models.CharField(max_length=20, blank=True, null=True, help_text="组织规模") + timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES, help_text="工作空间所在时区") + background_color = models.CharField(max_length=255, default=get_random_color, help_text="背景颜色") def __str__(self): """Return name of the Workspace""" @@ -155,20 +160,20 @@ def delete( self, using: Optional[str] = None, soft: bool = True, *args: Any, **kwargs: Any ): """ - Override the delete method to append epoch timestamp to the slug when soft deleting. + 重写 delete 方法,在软删除时为 slug 附加时间戳以保持唯一性。 Args: - using: The database alias to use for the deletion. - soft: Whether to perform a soft delete (True) or hard delete (False). - *args: Additional positional arguments. - **kwargs: Additional keyword arguments. + using: 用于删除的数据库别名。 + soft: 是否执行软删除 (True) 或硬删除 (False)。 + *args: 额外的 positional arguments。 + **kwargs: 额外的 keyword arguments。 """ - # Call the parent class's delete method first + # 首先调用父类的 delete 方法 result = super().delete(using=using, soft=soft, *args, **kwargs) - # If it's a soft delete and the model still exists (not hard deleted) + # 如果是软删除且模型仍然存在 (未被硬删除) if soft and hasattr(self, "deleted_at") and self.deleted_at: - # Use the deleted_at timestamp to update the slug + # 使用 deleted_at 时间戳来更新 slug deletion_timestamp: int = int(self.deleted_at.timestamp()) self.slug = f"{self.slug}__{deletion_timestamp}" self.save(update_fields=["slug"]) @@ -183,37 +188,47 @@ class Meta: class WorkspaceBaseModel(BaseModel): + """ + 一个抽象基类模型,为其他模型提供 'workspace' 和 'project' 字段。 + """ workspace = models.ForeignKey( - "db.Workspace", models.CASCADE, related_name="workspace_%(class)s" + "db.Workspace", models.CASCADE, related_name="workspace_%(class)s", help_text="关联的工作空间" ) project = models.ForeignKey( - "db.Project", models.CASCADE, related_name="project_%(class)s", null=True + "db.Project", models.CASCADE, related_name="project_%(class)s", null=True, help_text="关联的项目" ) class Meta: abstract = True def save(self, *args, **kwargs): + """ + 重写 save 方法,如果设置了 project,则自动关联到其 workspace。 + """ if self.project: self.workspace = self.project.workspace super(WorkspaceBaseModel, self).save(*args, **kwargs) class WorkspaceMember(BaseModel): + """ + 将用户和工作空间关联起来的模型,定义了用户在工作空间中的角色和属性。 + """ workspace = models.ForeignKey( - "db.Workspace", on_delete=models.CASCADE, related_name="workspace_member" + "db.Workspace", on_delete=models.CASCADE, related_name="workspace_member", help_text="关联的工作空间" ) member = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="member_workspace", + help_text="关联的用户成员", ) - role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=5) - company_role = models.TextField(null=True, blank=True) - view_props = models.JSONField(default=get_default_props) - default_props = models.JSONField(default=get_default_props) - issue_props = models.JSONField(default=get_issue_props) - is_active = models.BooleanField(default=True) + role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=5, help_text="用户角色 (20: Admin, 15: Member, 5: Guest)") + company_role = models.TextField(null=True, blank=True, help_text="成员在公司中的职位") + view_props = models.JSONField(default=get_default_props, help_text="视图相关的属性配置") + default_props = models.JSONField(default=get_default_props, help_text="默认的属性配置") + issue_props = models.JSONField(default=get_issue_props, help_text="Issue 相关的属性配置") + is_active = models.BooleanField(default=True, help_text="成员是否在工作空间中活跃") class Meta: unique_together = ["workspace", "member", "deleted_at"] @@ -230,20 +245,23 @@ class Meta: ordering = ("-created_at",) def __str__(self): - """Return members of the workspace""" + """返回工作空间的成员""" return f"{self.member.email} <{self.workspace.name}>" class WorkspaceMemberInvite(BaseModel): + """ + 存储发送给用户的待处理工作空间邀请。 + """ workspace = models.ForeignKey( - "db.Workspace", on_delete=models.CASCADE, related_name="workspace_member_invite" + "db.Workspace", on_delete=models.CASCADE, related_name="workspace_member_invite", help_text="邀请关联的工作空间" ) - email = models.CharField(max_length=255) - accepted = models.BooleanField(default=False) - token = models.CharField(max_length=255) - message = models.TextField(null=True) - responded_at = models.DateTimeField(null=True) - role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=5) + email = models.CharField(max_length=255, help_text="被邀请者的邮箱") + accepted = models.BooleanField(default=False, help_text="邀请是否被接受") + token = models.CharField(max_length=255, help_text="用于验证邀请的唯一令牌") + message = models.TextField(null=True, help_text="邀请信息") + responded_at = models.DateTimeField(null=True, help_text="邀请被回应的时间") + role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=5, help_text="被邀请者接受邀请后将拥有的角色") class Meta: unique_together = ["email", "workspace", "deleted_at"] @@ -264,15 +282,18 @@ def __str__(self): class Team(BaseModel): - name = models.CharField(max_length=255, verbose_name="Team Name") - description = models.TextField(verbose_name="Team Description", blank=True) + """ + 工作空间内的团队模型。 + """ + name = models.CharField(max_length=255, verbose_name="Team Name", help_text="团队名称") + description = models.TextField(verbose_name="Team Description", blank=True, help_text="团队描述") workspace = models.ForeignKey( - Workspace, on_delete=models.CASCADE, related_name="workspace_team" + Workspace, on_delete=models.CASCADE, related_name="workspace_team", help_text="团队所属的工作空间" ) - logo_props = models.JSONField(default=dict) + logo_props = models.JSONField(default=dict, help_text="团队 Logo 的属性") def __str__(self): - """Return name of the team""" + """返回团队的名称""" return f"{self.name} <{self.workspace.name}>" class Meta: @@ -291,14 +312,17 @@ class Meta: class WorkspaceTheme(BaseModel): + """ + 工作空间的主题设置。 + """ workspace = models.ForeignKey( - "db.Workspace", on_delete=models.CASCADE, related_name="themes" + "db.Workspace", on_delete=models.CASCADE, related_name="themes", help_text="主题所属的工作空间" ) - name = models.CharField(max_length=300) + name = models.CharField(max_length=300, help_text="主题名称") actor = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="themes" + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="themes", help_text="创建主题的用户" ) - colors = models.JSONField(default=dict) + colors = models.JSONField(default=dict, help_text="主题颜色配置") def __str__(self): return str(self.name) + str(self.actor.email) @@ -319,20 +343,25 @@ class Meta: class WorkspaceUserProperties(BaseModel): + """ + 存储用户在特定工作空间中的个性化属性,如过滤器和显示设置。 + """ workspace = models.ForeignKey( "db.Workspace", on_delete=models.CASCADE, related_name="workspace_user_properties", + help_text="关联的工作空间", ) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="workspace_user_properties", + help_text="关联的用户", ) - filters = models.JSONField(default=get_default_filters) - display_filters = models.JSONField(default=get_default_display_filters) - display_properties = models.JSONField(default=get_default_display_properties) - rich_filters = models.JSONField(default=dict) + filters = models.JSONField(default=get_default_filters, help_text="用户的过滤器设置") + display_filters = models.JSONField(default=get_default_display_filters, help_text="用户的显示过滤器设置") + display_properties = models.JSONField(default=get_default_display_properties, help_text="用户的显示属性设置") + rich_filters = models.JSONField(default=dict, help_text="用户的富文本过滤器设置") class Meta: unique_together = ["workspace", "user", "deleted_at"] @@ -353,13 +382,17 @@ def __str__(self): class WorkspaceUserLink(WorkspaceBaseModel): - title = models.CharField(max_length=255, null=True, blank=True) - url = models.TextField() - metadata = models.JSONField(default=dict) + """ + 用户在工作空间中保存的链接。 + """ + title = models.CharField(max_length=255, null=True, blank=True, help_text="链接标题") + url = models.TextField(help_text="链接 URL") + metadata = models.JSONField(default=dict, help_text="链接的元数据") owner = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="owner_workspace_user_link", + help_text="创建链接的所有者", ) class Meta: @@ -373,9 +406,10 @@ def __str__(self): class WorkspaceHomePreference(BaseModel): - """Preference for the home page of a workspace for a user""" + """用户对工作空间主页的偏好设置""" class HomeWidgetKeys(models.TextChoices): + """主页小组件的 Key 定义""" QUICK_LINKS = "quick_links", "Quick Links" RECENTS = "recents", "Recents" MY_STICKIES = "my_stickies", "My Stickies" @@ -386,16 +420,18 @@ class HomeWidgetKeys(models.TextChoices): "db.Workspace", on_delete=models.CASCADE, related_name="workspace_user_home_preferences", + help_text="关联的工作空间", ) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="workspace_user_home_preferences", + help_text="关联的用户", ) - key = models.CharField(max_length=255) - is_enabled = models.BooleanField(default=True) - config = models.JSONField(default=dict) - sort_order = models.FloatField(default=65535) + key = models.CharField(max_length=255, help_text="偏好设置的 Key") + is_enabled = models.BooleanField(default=True, help_text="是否启用该偏好") + config = models.JSONField(default=dict, help_text="偏好设置的具体配置") + sort_order = models.FloatField(default=65535, help_text="排序顺序") class Meta: unique_together = ["workspace", "user", "key", "deleted_at"] @@ -416,9 +452,10 @@ def __str__(self): class WorkspaceUserPreference(BaseModel): - """Preference for the workspace for a user""" + """用户在工作空间中的偏好设置,主要用于侧边栏等。""" class UserPreferenceKeys(models.TextChoices): + """用户偏好设置的 Key 定义""" VIEWS = "views", "Views" ACTIVE_CYCLES = "active_cycles", "Active Cycles" ANALYTICS = "analytics", "Analytics" @@ -430,15 +467,17 @@ class UserPreferenceKeys(models.TextChoices): "db.Workspace", on_delete=models.CASCADE, related_name="workspace_user_preferences", + help_text="关联的工作空间", ) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="workspace_user_preferences", + help_text="关联的用户", ) - key = models.CharField(max_length=255) - is_pinned = models.BooleanField(default=False) - sort_order = models.FloatField(default=65535) + key = models.CharField(max_length=255, help_text="偏好设置的 Key") + is_pinned = models.BooleanField(default=False, help_text="是否固定该偏好项") + sort_order = models.FloatField(default=65535, help_text="排序顺序") class Meta: unique_together = ["workspace", "user", "key", "deleted_at"] diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index 3c3410107d6..6d47be1e6b2 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -15,7 +15,8 @@ # Module imports from plane.utils.url import is_valid_url - +from dotenv import load_dotenv +load_dotenv() BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -134,21 +135,30 @@ AUTH_USER_MODEL = "db.User" # Database -if bool(os.environ.get("DATABASE_URL")): - # Parse database configuration from $DATABASE_URL - DATABASES = {"default": dj_database_url.config()} -else: - DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql", - "NAME": os.environ.get("POSTGRES_DB"), - "USER": os.environ.get("POSTGRES_USER"), - "PASSWORD": os.environ.get("POSTGRES_PASSWORD"), - "HOST": os.environ.get("POSTGRES_HOST"), - "PORT": os.environ.get("POSTGRES_PORT", "5432"), - } +# if bool(os.environ.get("DATABASE_URL")): +# # Parse database configuration from $DATABASE_URL +# DATABASES = {"default": dj_database_url.config()} +# else: +# DATABASES = { +# "default": { +# "ENGINE": "django.db.backends.postgresql", +# "NAME": os.environ.get("POSTGRES_DB"), +# "USER": os.environ.get("POSTGRES_USER"), +# "PASSWORD": os.environ.get("POSTGRES_PASSWORD"), +# "HOST": os.environ.get("POSTGRES_HOST"), +# "PORT": os.environ.get("POSTGRES_PORT", "5432"), +# } +# } +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": 'plane', + "USER": 'plane', + "PASSWORD": 'plane', + "HOST": '10.32.190.226', + "PORT": 5432, } - +} if os.environ.get("ENABLE_READ_REPLICA", "0") == "1": if bool(os.environ.get("DATABASE_READ_REPLICA_URL")): @@ -173,7 +183,7 @@ # Redis Config -REDIS_URL = os.environ.get("REDIS_URL") +REDIS_URL = 'redis://10.32.190.226:6379/0' REDIS_SSL = REDIS_URL and "rediss" in REDIS_URL if REDIS_SSL: @@ -243,8 +253,8 @@ } } STORAGES["default"] = {"BACKEND": "plane.settings.storage.S3Storage"} -AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key") -AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key") +AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "plane") +AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "plane123456789") AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads") AWS_REGION = os.environ.get("AWS_REGION", "") AWS_DEFAULT_ACL = "public-read" diff --git a/apps/api/plane/settings/local.py b/apps/api/plane/settings/local.py index 15af36a2da9..482ad4e89c4 100644 --- a/apps/api/plane/settings/local.py +++ b/apps/api/plane/settings/local.py @@ -5,6 +5,7 @@ from .common import * # noqa DEBUG = True +SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-key-do-not-use-in-prod") # Debug Toolbar settings INSTALLED_APPS += ("debug_toolbar",) # noqa diff --git a/apps/api/plane/web/models.py b/apps/api/plane/web/models.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/space/next.config.js b/apps/space/next.config.js index a736f4f6452..beee46b4c90 100644 --- a/apps/space/next.config.js +++ b/apps/space/next.config.js @@ -6,6 +6,16 @@ const nextConfig = { basePath: process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "", reactStrictMode: false, swcMinify: true, + // Enable file watching for Docker environments + webpack: (config, { dev }) => { + if (dev) { + config.watchOptions = { + poll: 1000, + aggregateTimeout: 300, + }; + } + return config; + }, async headers() { return [ { diff --git a/apps/space/package.json b/apps/space/package.json index 58caf798d3b..04e1a4df2ee 100644 --- a/apps/space/package.json +++ b/apps/space/package.json @@ -4,7 +4,7 @@ "private": true, "license": "AGPL-3.0", "scripts": { - "dev": "next dev -p 3002", + "dev": "next dev -p 3002 -H 0.0.0.0", "build": "next build", "start": "next start", "clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist", diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx index aaa7f6e51c1..a2a55c02008 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx @@ -12,7 +12,7 @@ import { useAppRail } from "@/hooks/use-app-rail"; // local imports import { ExtendedAppSidebar } from "./extended-sidebar"; import { AppSidebar } from "./sidebar"; - +// 这是工作区的视图、 分析、 归档、草稿 export const ProjectAppSidebar: FC = observer(() => { // store hooks const { @@ -55,7 +55,7 @@ export const ProjectAppSidebar: FC = observer(() => { } isAnyExtendedSidebarExpanded={isAnyExtendedSidebarOpen} isAnySidebarDropdownOpen={isAnySidebarDropdownOpen} - disablePeekTrigger={shouldRenderAppRail} + disablePeekTrigger={shouldRenderAppRail && !sidebarCollapsed} // 修改:折叠时仍允许“窥视” > diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/header.tsx index 27ef0ad4237..1f446923b2b 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/header.tsx @@ -42,7 +42,7 @@ export const WorkspaceDashboardHeader = observer(() => {
{t("home.manage_widgets")}
- + {/* */} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/page.tsx index 446a965aeca..1bc6e02ea3b 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/page.tsx @@ -14,14 +14,17 @@ import { WorkspaceDashboardHeader } from "./header"; const WorkspaceDashboardPage = observer(() => { const { currentWorkspace } = useWorkspace(); + const { t } = useTranslation(); // derived values const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - ${t("home.title")}` : undefined; return ( <> + {/* 这个是首页顶部的面包屑和右侧的按钮 */} } /> + {/* 这里是tab页上展示的名称 */} diff --git a/apps/web/core/components/home/root.tsx b/apps/web/core/components/home/root.tsx index 5a0e6188f82..84a204b8630 100644 --- a/apps/web/core/components/home/root.tsx +++ b/apps/web/core/components/home/root.tsx @@ -24,7 +24,8 @@ export const WorkspaceHomeView = observer(() => { const { data: currentUser } = useUser(); const { data: currentUserProfile, updateTourCompleted } = useUserProfile(); const { fetchWidgets } = useHome(); - + + useSWR( workspaceSlug ? `HOME_DASHBOARD_WIDGETS_${workspaceSlug}` : null, workspaceSlug ? () => fetchWidgets(workspaceSlug?.toString()) : null, @@ -55,14 +56,17 @@ export const WorkspaceHomeView = observer(() => { <> {currentUserProfile && !currentUserProfile.is_tour_completed && (
- + {/* 新手教程 */} +
)} <>
+ {/* 展示用户名和时间 */} {currentUser && } + {/* 小部件和首页小部件展示 */}
diff --git a/apps/web/core/components/home/widgets/manage/widget-item.tsx b/apps/web/core/components/home/widgets/manage/widget-item.tsx index 42cb7f5c0d4..81a66ed4bb5 100644 --- a/apps/web/core/components/home/widgets/manage/widget-item.tsx +++ b/apps/web/core/components/home/widgets/manage/widget-item.tsx @@ -47,6 +47,7 @@ export const WidgetItem: FC = observer((props) => { const { widgetsMap } = useHome(); const { t } = useTranslation(); // derived values + const widget = widgetsMap[widgetId] as TWidgetEntityData; const widgetTitle = HOME_WIDGETS_LIST[widget.key]?.title; @@ -80,8 +81,8 @@ export const WidgetItem: FC = observer((props) => { }, }), dropTargetForElements({ - element, - canDrop: ({ source }) => getCanDrop(source, widget), + element, // 当前行的元素ref + canDrop: ({ source }) => getCanDrop(source, widget), //控制能否在此投放 onDragStart: () => { setIsDragging(true); }, diff --git a/apps/web/core/components/sidebar/resizable-sidebar.tsx b/apps/web/core/components/sidebar/resizable-sidebar.tsx index ddfd2ef9f2a..a3fdfa7746f 100644 --- a/apps/web/core/components/sidebar/resizable-sidebar.tsx +++ b/apps/web/core/components/sidebar/resizable-sidebar.tsx @@ -25,33 +25,38 @@ interface ResizableSidebarProps { isAnySidebarDropdownOpen?: boolean; disablePeekTrigger?: boolean; } +// width , setWidth : 受控 的宽度状态。父组件必须提供当前宽度值和更新宽度的函数。 +// isCollapsed , toggleCollapsed : 受控 的折叠状态。父组件控制侧边栏是否折叠,并提供切换方法。 +// showPeek , togglePeek : 受控 的“窥视”状态。父组件控制“窥视”视图是否显示,并提供切换方法。 + +// minWidth , maxWidth : 限制侧边栏可拖动的最小和最大宽度。 export function ResizableSidebar({ - showPeek = false, - togglePeek, - peekDuration = 500, - isCollapsed = false, - toggleCollapsed: toggleCollapsedProp, - onCollapsedChange, - width, - setWidth, - onWidthChange, - minWidth = 236, - maxWidth = 350, - className = "", - children, - extendedSidebar, - isAnyExtendedSidebarExpanded = false, - isAnySidebarDropdownOpen = false, - disablePeekTrigger = false, + showPeek = false, // 受控 的“窥视”状态。父组件控制“窥视”视图是否显示,并提供切换方法。 + togglePeek, // 用于切换“窥视”状态的函数。父组件必须提供此函数,以便在点击“窥视”按钮时切换“窥视”状态。 + peekDuration = 500, // 控制“窥视”视图显示的持续时间。 + isCollapsed = false, // 受控 的折叠状态。父组件控制侧边栏是否折叠,并提供切换方法。 + toggleCollapsed: toggleCollapsedProp, // 用于切换折叠状态的函数。父组件必须提供此函数,以便在点击折叠按钮时切换折叠状态。 + onCollapsedChange, // + width, // 受控 的宽度状态。父组件必须提供当前宽度值和更新宽度的函数。 + setWidth, // 用于更新宽度的函数。父组件必须提供此函数,以便在拖动侧边栏时更新宽度。 + onWidthChange, // 用于在宽度变化时触发的回调函数。父组件可以提供此函数,以便在宽度变化时执行自定义逻辑。 + minWidth = 236, // 限制侧边栏可拖动的最小宽度 + maxWidth = 350, // 限制侧边栏可拖动的最大宽度 + className = "", // 自定义的 CSS 类名,用于添加自定义样式。 + children, // 侧边栏的子元素。父组件可以在侧边栏中添加自定义内容。 + extendedSidebar, // 侧边栏的扩展内容。父组件可以在侧边栏中添加自定义的扩展内容。 + isAnyExtendedSidebarExpanded = false, // 任何扩展侧边栏是否展开。如果设置为 true ,则侧边栏将显示扩展内容。 + isAnySidebarDropdownOpen = false, // 任何侧边栏下拉菜单是否打开。如果设置为 true ,则侧边栏将显示下拉菜单。 + disablePeekTrigger = false, // 禁用 “窥视” 触发。如果设置为 true ,则点击 “窥视” 按钮将不会触发 “窥视” 状态的切换。 }: ResizableSidebarProps) { // states - const [isResizing, setIsResizing] = useState(false); - const [isHoveringTrigger, setIsHoveringTrigger] = useState(false); + const [isResizing, setIsResizing] = useState(false); //是否正在拖拽调整宽度 + const [isHoveringTrigger, setIsHoveringTrigger] = useState(false); //鼠标是否在 Peek 触发区悬停 // refs - const peekTimeoutRef = useRef>(); - const initialWidthRef = useRef(0); - const initialMouseXRef = useRef(0); + const peekTimeoutRef = useRef>(); //保存控制 Peek 自动收起的定时器句柄 + const initialWidthRef = useRef(0); // 保存初始宽度,用于计算拖动 deltaX + const initialMouseXRef = useRef(0); // 保存初始鼠标 X 坐标,用于计算拖动 deltaX // handlers const setShowPeek = useCallback( @@ -60,7 +65,7 @@ export function ResizableSidebar({ }, [togglePeek] ); - + // 根据当前鼠标位置与初始位置的差值 deltaX,计算新的宽度 const handleResize = useCallback( (e: MouseEvent) => { if (!isResizing) return; @@ -86,6 +91,7 @@ export function ResizableSidebar({ }, []); const toggleCollapsed = useCallback(() => { + // 不给参数就直接取反 toggleCollapsedProp(); setShowPeek(false); setIsHoveringTrigger(false); @@ -94,8 +100,10 @@ export function ResizableSidebar({ } }, [toggleCollapsedProp, setShowPeek]); + // 悬浮左侧栏 const handleTriggerEnter = useCallback(() => { if (isCollapsed) { + setIsHoveringTrigger(true); setShowPeek(true); if (peekTimeoutRef.current) { @@ -114,6 +122,7 @@ export function ResizableSidebar({ }, [isCollapsed, peekDuration, setShowPeek, isAnyExtendedSidebarExpanded]); const handlePeekEnter = useCallback(() => { + if (isCollapsed && showPeek) { if (peekTimeoutRef.current) { clearTimeout(peekTimeoutRef.current); @@ -122,6 +131,7 @@ export function ResizableSidebar({ }, [isCollapsed, showPeek]); const handlePeekLeave = useCallback(() => { + if (isCollapsed && !isAnyExtendedSidebarExpanded && !isAnySidebarDropdownOpen) { peekTimeoutRef.current = setTimeout(() => { setShowPeek(false); diff --git a/apps/web/core/components/workspace/sidebar/sidebar-item.tsx b/apps/web/core/components/workspace/sidebar/sidebar-item.tsx index e2e15b4d16a..3f2058d86d9 100644 --- a/apps/web/core/components/workspace/sidebar/sidebar-item.tsx +++ b/apps/web/core/components/workspace/sidebar/sidebar-item.tsx @@ -29,22 +29,26 @@ export const SidebarItemBase: FC = observer(({ item, additionalRender, ad const pathname = usePathname(); const { workspaceSlug } = useParams(); const { allowPermissions } = useUserPermissions(); - const { getNavigationPreferences } = useWorkspace(); - const { data } = useUser(); + const { getNavigationPreferences } = useWorkspace(); // 获取当前工作空间的导航偏好设置 + const { data } = useUser(); // 获取当前用户的信息 + //toggleSidebar: 切换侧边栏的展开状态 + //isExtendedSidebarOpened: 检查当前是否有扩展侧边栏打开 + //toggleExtendedSidebar: 切换扩展侧边栏的展开状态 const { toggleSidebar, isExtendedSidebarOpened, toggleExtendedSidebar } = useAppTheme(); const handleLinkClick = () => { - if (window.innerWidth < 768) toggleSidebar(); - if (isExtendedSidebarOpened) toggleExtendedSidebar(false); + if (window.innerWidth < 768) toggleSidebar(); // 当窗口宽度小于768px时,切换侧边栏的展开状态 + if (isExtendedSidebarOpened) toggleExtendedSidebar(false); // 当有扩展侧边栏打开时,关闭它 }; - const staticItems = ["home", "inbox", "pi_chat", "projects", "your_work", ...(additionalStaticItems || [])]; - const slug = workspaceSlug?.toString() || ""; + const staticItems = ["home", "inbox", "pi_chat", "projects", "your_work", ...(additionalStaticItems || [])]; // 静态导航项的列表,包括"home", "inbox", "pi_chat", "projects", "your_work"等 + const slug = workspaceSlug?.toString() || ""; // 当前工作空间的slug,用于构建导航链接 - if (!allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, slug)) return null; + if (!allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, slug)) return null; // 检查当前用户是否有访问该导航项的权限 const sidebarPreference = getNavigationPreferences(slug); + const isPinned = sidebarPreference?.[item.key]?.is_pinned; if (!isPinned && !staticItems.includes(item.key)) return null; @@ -52,9 +56,11 @@ export const SidebarItemBase: FC = observer(({ item, additionalRender, ad item.key === "your_work" && data?.id ? joinUrlPath(slug, item.href, data?.id) : joinUrlPath(slug, item.href); const icon = getSidebarNavigationItemIcon(item.key); + return ( - + {/* item.highlight显示高亮 */} +
{icon}

{t(item.labelTranslationKey)}

diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 2067cf1f8ba..cf5dccb264e 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -7,6 +7,8 @@ const nextConfig = { trailingSlash: true, reactStrictMode: false, swcMinify: true, + concurrentFeatures: true, + fastRefresh: true, output: "standalone", async headers() { return [ diff --git a/apps/web/package.json b/apps/web/package.json index 4a7b96814f8..94fe8564fdb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,7 +4,7 @@ "private": true, "license": "AGPL-3.0", "scripts": { - "dev": "next dev --port 3000", + "dev": "next dev --port 3000 -H 0.0.0.0", "build": "next build", "start": "next start", "clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist", diff --git a/package.json b/package.json index 591eda56f36..880cbe8a733 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "private": true, "scripts": { "build": "turbo run build", - "dev": "turbo run dev --concurrency=18", + "dev": "turbo run dev --concurrency=36", "start": "turbo run start", "clean": "turbo run clean && rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist", "fix": "turbo run fix", From ecef24b197dc9e5811b0446b8c184abb7e71afe2 Mon Sep 17 00:00:00 2001 From: yuzheng3 Date: Fri, 26 Sep 2025 08:01:28 +0000 Subject: [PATCH 3/7] =?UTF-8?q?feat(projects):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E9=A1=B5=E9=9D=A2=E5=88=97=E8=A1=A8=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E5=92=8C=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增项目页面列表的布局组件和页面组件 - 在侧边栏导航中添加工作项入口 - 实现页面创建功能及相关权限控制 - 更新.gitignore文件排除开发环境文件 - 添加调试日志和注释说明 --- .gitignore | 5 + .../[projectId]/overview/(list)/header.tsx | 102 ++++++++++++++++++ .../[projectId]/overview/(list)/layout.tsx | 17 +++ .../[projectId]/overview/(list)/page.tsx | 81 ++++++++++++++ .../web/core/components/project/card-list.tsx | 4 +- .../workspace/sidebar/project-navigation.tsx | 13 ++- .../workspace/sidebar/projects-list.tsx | 2 + 7 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/overview/(list)/header.tsx create mode 100644 apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/overview/(list)/layout.tsx create mode 100644 apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/overview/(list)/page.tsx diff --git a/.gitignore b/.gitignore index 4baa3495a98..1e27d58c62c 100644 --- a/.gitignore +++ b/.gitignore @@ -99,3 +99,8 @@ dev-editor *.rdb.gz storybook-static +.venv +venv +.venv_bak +.gitignore +.trae/rules/project_rules.md diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/overview/(list)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/overview/(list)/header.tsx new file mode 100644 index 00000000000..ba3314eb878 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/overview/(list)/header.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +// constants +import { + EPageAccess, + EProjectFeatureKey, + PROJECT_PAGE_TRACKER_EVENTS, + PROJECT_TRACKER_ELEMENTS, +} from "@plane/constants"; +// plane types +import { TPage } from "@plane/types"; +// plane ui +import { Breadcrumbs, Button, Header, setToast, TOAST_TYPE } from "@plane/ui"; +// helpers +import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +// plane web +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; +// plane web hooks +import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store"; + +export const PagesListHeader = observer(() => { + // states + const [isCreatingPage, setIsCreatingPage] = useState(false); + // router + const router = useRouter(); + const { workspaceSlug } = useParams(); + const searchParams = useSearchParams(); + const pageType = searchParams.get("type"); + // store hooks + const { currentProjectDetails, loader } = useProject(); + const { canCurrentUserCreatePage, createPage } = usePageStore(EPageStoreType.PROJECT); + // handle page create + const handleCreatePage = async () => { + setIsCreatingPage(true); + + const payload: Partial = { + access: pageType === "private" ? EPageAccess.PRIVATE : EPageAccess.PUBLIC, + }; + + await createPage(payload) + .then((res) => { + captureSuccess({ + eventName: PROJECT_PAGE_TRACKER_EVENTS.create, + payload: { + id: res?.id, + state: "SUCCESS", + }, + }); + const pageId = `/${workspaceSlug}/projects/${currentProjectDetails?.id}/pages/${res?.id}`; + router.push(pageId); + }) + .catch((err) => { + captureError({ + eventName: PROJECT_PAGE_TRACKER_EVENTS.create, + payload: { + state: "ERROR", + }, + }); + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: err?.data?.error || "Page could not be created. Please try again.", + }); + }) + .finally(() => setIsCreatingPage(false)); + }; + + return ( +
+ + + + + + {canCurrentUserCreatePage ? ( + + + + ) : ( + <> + )} +
+ ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/overview/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/overview/(list)/layout.tsx new file mode 100644 index 00000000000..a74a9797b37 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/overview/(list)/layout.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { ReactNode } from "react"; +// components +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +// local components +import { PagesListHeader } from "./header"; + +export default function ProjectPagesListLayout({ children }: { children: ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/overview/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/overview/(list)/page.tsx new file mode 100644 index 00000000000..756a2dac646 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/overview/(list)/page.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams, useSearchParams } from "next/navigation"; +// plane imports +import { EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { EUserProjectRoles, TPageNavigationTabs } from "@plane/types"; +// components +import { PageHead } from "@/components/core/page-title"; +import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; +import { PagesListRoot } from "@/components/pages/list/root"; +import { PagesListView } from "@/components/pages/pages-list-view"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; +// plane web hooks +import { EPageStoreType } from "@/plane-web/hooks/store"; + +const ProjectPagesPage = observer(() => { + // router + const router = useAppRouter(); + const searchParams = useSearchParams(); + const type = searchParams.get("type"); + const { workspaceSlug, projectId } = useParams(); + // plane hooks + const { t } = useTranslation(); + // store hooks + const { getProjectById, currentProjectDetails } = useProject(); + const { allowPermissions } = useUserPermissions(); + // derived values + const project = projectId ? getProjectById(projectId.toString()) : undefined; + const pageTitle = project?.name ? `${project?.name} - Pages` : undefined; + const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT); + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/pages" }); + + const currentPageType = (): TPageNavigationTabs => { + const pageType = type?.toString(); + if (pageType === "private") return "private"; + if (pageType === "archived") return "archived"; + return "public"; + }; + + if (!workspaceSlug || !projectId) return <>; + + // No access to cycle + if (currentProjectDetails?.page_view === false) + return ( +
+ { + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); + }, + disabled: !canPerformEmptyStateActions, + }} + /> +
+ ); + return ( + <> + + + + + + ); +}); + +export default ProjectPagesPage; diff --git a/apps/web/core/components/project/card-list.tsx b/apps/web/core/components/project/card-list.tsx index ba8f7a6bb58..1d4ab9abb3f 100644 --- a/apps/web/core/components/project/card-list.tsx +++ b/apps/web/core/components/project/card-list.tsx @@ -28,7 +28,7 @@ export const ProjectCardList = observer((props: TProjectCardListProps) => { // plane hooks const { t } = useTranslation(); // store hooks - const { toggleCreateProjectModal } = useCommandPalette(); + const { toggleCreateProjectModal } = useCommandPalette(); // 控制是否打开添加项目的dialog const { loader, fetchStatus, @@ -92,6 +92,8 @@ export const ProjectCardList = observer((props: TProjectCardListProps) => { src={searchQuery.trim() === "" ? resolvedFiltersImage : resolvedNameFilterImage} className="mx-auto h-36 w-36 sm:h-48 sm:w-48" alt="No matching projects" + width={192} + height={192} />
{t("workspace_projects.empty_state.filter.title")}

diff --git a/apps/web/core/components/workspace/sidebar/project-navigation.tsx b/apps/web/core/components/workspace/sidebar/project-navigation.tsx index 59313763429..cafb0a84157 100644 --- a/apps/web/core/components/workspace/sidebar/project-navigation.tsx +++ b/apps/web/core/components/workspace/sidebar/project-navigation.tsx @@ -4,7 +4,7 @@ import React, { FC, useCallback, useMemo } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useParams, usePathname } from "next/navigation"; -import { FileText, Layers } from "lucide-react"; +import { FileText, Kanban, Layers } from "lucide-react"; import { EUserPermissionsLevel, EUserPermissions } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { DiceIcon, ContrastIcon, LayersIcon, Intake } from "@plane/propel/icons"; @@ -65,6 +65,16 @@ export const ProjectNavigation: FC = observer((props) => { const baseNavigation = useCallback( (workspaceSlug: string, projectId: string): TNavigationItem[] => [ + { + i18n_key: "sidebar.work_items", + key: "work_items", + name: "Work items", + href: `/${workspaceSlug}/projects/${projectId}/overview`, + icon: Kanban, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + shouldRender: true, + sortOrder: 0, + }, { i18n_key: "sidebar.work_items", key: "work_items", @@ -148,6 +158,7 @@ export const ProjectNavigation: FC = observer((props) => { return sortedNavigationItems; }, [workspaceSlug, projectId, baseNavigation, additionalNavigationItems]); + console.log("🚀 ~ navigationItemsMemo:", navigationItemsMemo); const isActive = useCallback( (item: TNavigationItem) => { diff --git a/apps/web/core/components/workspace/sidebar/projects-list.tsx b/apps/web/core/components/workspace/sidebar/projects-list.tsx index 8e5d4df281c..407e43bd08c 100644 --- a/apps/web/core/components/workspace/sidebar/projects-list.tsx +++ b/apps/web/core/components/workspace/sidebar/projects-list.tsx @@ -165,6 +165,7 @@ export const SidebarProjectsList: FC = observer(() => { {t("projects")}

+ {/* 创建项目的 + */} {isAuthorizedUser && ( + +
+ + {/* 内容区域 */} +
+ {activeTab === "properties" ? ( + + ) : ( + + )} +
+
+ + ); }); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/overview/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/overview/(list)/page.tsx index 2387d72fde4..be161c3e2fe 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/overview/(list)/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/overview/(list)/page.tsx @@ -32,7 +32,8 @@ const ProjectPagesPage = observer(() => { const { getProjectById, currentProjectDetails } = useProject(); const { allowPermissions } = useUserPermissions(); // derived values - const project = projectId ? getProjectById(projectId.toString()) : undefined; + const project = getProjectById(projectId.toString()); + if (!project) return; const pageTitle = project?.name ? `${project?.name} - Overview` : undefined; const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT); const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/pages" }); @@ -67,7 +68,8 @@ const ProjectPagesPage = observer(() => { return ( <> - + +

qqq

diff --git a/apps/web/core/components/dropdowns/date.tsx b/apps/web/core/components/dropdowns/date.tsx index b24713e84dc..2f410d18f51 100644 --- a/apps/web/core/components/dropdowns/date.tsx +++ b/apps/web/core/components/dropdowns/date.tsx @@ -31,7 +31,7 @@ type Props = TDropdownProps & { maxDate?: Date; onChange: (val: Date | null) => void; onClose?: () => void; - value: Date | string | null; + value: Date | string | null | undefined; closeOnSelect?: boolean; formatToken?: string; renderByDefault?: boolean; diff --git a/apps/web/core/components/project/project-activity.tsx b/apps/web/core/components/project/project-activity.tsx new file mode 100644 index 00000000000..539ba540c9b --- /dev/null +++ b/apps/web/core/components/project/project-activity.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { FC, useState, useEffect } from "react"; +import { observer } from "mobx-react"; +// plane package imports +import { E_SORT_ORDER } from "@plane/constants"; +import { useLocalStorage } from "@plane/hooks"; +// i18n +import { useTranslation } from "@plane/i18n"; +// components +import { ButtonAvatars } from "@/components/dropdowns/member/avatar"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useUser } from "@/hooks/store/user"; +// icons +import { Clock, User, Edit3, Settings, ChevronUp, ChevronDown } from "lucide-react"; + +type TProjectActivity = { + workspaceSlug: string; + projectId: string; + disabled?: boolean; +}; + +interface IProjectActivityItem { + id: string; + actor_detail: { + id: string; + display_name: string; + avatar?: string; + }; + verb: string; + field: string; + old_value: string | null; + new_value: string | null; + comment: string; + created_at: string; + project_detail: { + id: string; + name: string; + identifier: string; + }; +} + +export const ProjectActivity: FC = observer((props) => { + const { workspaceSlug, projectId, disabled = false } = props; + // i18n + const { t } = useTranslation(); + // hooks + const { setValue: setSortOrder, storedValue: sortOrder } = useLocalStorage( + "project_activity_sort_order", + E_SORT_ORDER.DESC + ); + // store hooks + const { getProjectById, fetchProjectHistory } = useProject(); + const { data: currentUser } = useUser(); + + // states + const [activities, setActivities] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + // derived values + const project = getProjectById(projectId); + + const toggleSortOrder = () => { + setSortOrder(sortOrder === E_SORT_ORDER.ASC ? E_SORT_ORDER.DESC : E_SORT_ORDER.ASC); + }; + + const fetchActivities = async () => { + if (!workspaceSlug || !projectId) return; + + setIsLoading(true); + try { + const response = await fetchProjectHistory(workspaceSlug, projectId); + console.log("🚀 ~ fetchActivities ~ responsess:", response); + // 由于后端返回的是模拟数据,我们需要处理单个对象或数组 + const activityData = Array.isArray(response) ? response : [response]; + setActivities(activityData); + } catch (error) { + console.error("Error fetching project activities:", error); + setActivities([]); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchActivities(); + }, [workspaceSlug, projectId]); + + const formatActivityMessage = (activity: IProjectActivityItem) => { + const { verb, field, old_value, new_value, comment } = activity; + + switch (field) { + case "description": + return comment || `更新了项目描述`; + case "name": + return `将项目名称从 "${old_value}" 更改为 "${new_value}"`; + case "project_lead": + return `更改了项目负责人`; + case "network": + return `更改了项目可见性`; + default: + return comment || `更新了 ${field}`; + } + }; + + const getActivityIcon = (field: string) => { + switch (field) { + case "description": + return ; + case "name": + return ; + case "project_lead": + return ; + default: + return ; + } + }; + + const sortedActivities = [...activities].sort((a, b) => { + const dateA = new Date(a.created_at).getTime(); + const dateB = new Date(b.created_at).getTime(); + return sortOrder === E_SORT_ORDER.ASC ? dateA - dateB : dateB - dateA; + }); + + if (!project) return <>; + + return ( +
+ {/* header */} +
+
项目活动
+
+ +
+
+ + {/* activity list */} +
+
+ {isLoading ? ( +
+
加载中...
+
+ ) : sortedActivities.length > 0 ? ( +
+ {sortedActivities.map((activity) => ( +
+ {/* Avatar */} +
+ +
+ + {/* Activity content */} +
+
+
{getActivityIcon(activity.field)}
+
+
+ {activity.actor_detail.display_name} + {formatActivityMessage(activity)} +
+
+ + + {new Date(activity.created_at).toLocaleString("zh-CN", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + +
+
+
+
+
+ ))} +
+ ) : ( +
+ +
暂无活动记录
+
项目的更改和活动将在这里显示
+
+ )} +
+
+
+ ); +}); diff --git a/apps/web/core/components/project/project-description-input.tsx b/apps/web/core/components/project/project-description-input.tsx new file mode 100644 index 00000000000..be823ee015f --- /dev/null +++ b/apps/web/core/components/project/project-description-input.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { FC, useCallback, useEffect, useRef, useState } from "react"; +import debounce from "lodash/debounce"; +import { observer } from "mobx-react"; +import { Controller, useForm } from "react-hook-form"; +// plane imports +import type { EditorRefApi } from "@plane/editor"; +import { useTranslation } from "@plane/i18n"; +import { EFileAssetType, TProject, TNameDescriptionLoader } from "@plane/types"; +import { Loader } from "@plane/ui"; +// components +import { getDescriptionPlaceholderI18n } from "@plane/utils"; +import { RichTextEditor } from "@/components/editor/rich-text"; +// hooks +import { useEditorAsset } from "@/hooks/store/use-editor-asset"; +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useProject } from "@/hooks/store/use-project"; +// plane web services +import { WorkspaceService } from "@/plane-web/services"; +const workspaceService = new WorkspaceService(); + +export type ProjectDescriptionInputProps = { + containerClassName?: string; + editorRef?: React.RefObject; + workspaceSlug: string; + projectId: string; + initialValue: string | undefined | null; + disabled?: boolean; + placeholder?: string | ((isFocused: boolean, value: string) => string); + setIsSubmitting: (initialValue: TNameDescriptionLoader) => void; + swrProjectDescription?: string | null | undefined; +}; + +export const ProjectDescriptionInput: FC = observer((props) => { + const { + containerClassName, + editorRef, + workspaceSlug, + projectId, + disabled, + swrProjectDescription, + initialValue, + setIsSubmitting, + placeholder, + } = props; + + // states + const [isInitialized, setIsInitialized] = useState(false); + const [localProjectDescription, setLocalProjectDescription] = useState({ + id: projectId, + description_html: initialValue || "

", + }); + + // ref to track if there are unsaved changes + const hasUnsavedChanges = useRef(false); + + // store hooks + const { uploadEditorAsset } = useEditorAsset(); + const { getWorkspaceBySlug } = useWorkspace(); + const { updateProject } = useProject(); + + // derived values + const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id?.toString(); + + // form info + const { handleSubmit, reset, control } = useForm({ + defaultValues: { + description_html: initialValue || "

", + }, + }); + + // i18n + const { t } = useTranslation(); + + const handleDescriptionFormSubmit = useCallback( + async (formData: Partial) => { + await updateProject(workspaceSlug, projectId, { + description_html: formData.description_html ?? "

", + }); + }, + [workspaceSlug, projectId, updateProject] + ); + + // Initialize component + useEffect(() => { + if (!projectId) return; + + const descriptionValue = initialValue || "

"; + + reset({ + id: projectId, + description_html: descriptionValue, + }); + + setLocalProjectDescription({ + id: projectId, + description_html: descriptionValue, + }); + + setIsInitialized(true); + hasUnsavedChanges.current = false; + }, [initialValue, projectId, reset]); + + // ADDING handleDescriptionFormSubmit TO DEPENDENCY ARRAY PRODUCES ADVERSE EFFECTS + // TODO: Verify the exhaustive-deps warning + // eslint-disable-next-line react-hooks/exhaustive-deps + const debouncedFormSave = useCallback( + debounce(async () => { + handleSubmit(handleDescriptionFormSubmit)().finally(() => { + setIsSubmitting("submitted"); + hasUnsavedChanges.current = false; + }); + }, 1500), + [handleSubmit, projectId] + ); + + // Save on unmount if there are unsaved changes + useEffect( + () => () => { + debouncedFormSave.cancel(); + + if (hasUnsavedChanges.current) { + handleSubmit(handleDescriptionFormSubmit)() + .catch((error) => { + console.error("Failed to save project description on unmount:", error); + }) + .finally(() => { + setIsSubmitting("submitted"); + hasUnsavedChanges.current = false; + }); + } + }, + // since we don't want to save on unmount if there are no unsaved changes, no deps are needed + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + // Show loader while initializing or if workspace is not available + if (!workspaceId || !isInitialized) { + return ( + + + + ); + } + + return ( + ( + { + setIsSubmitting("submitting"); + onChange(description_html); + hasUnsavedChanges.current = true; + debouncedFormSave(); + }} + placeholder={ + placeholder + ? placeholder + : (isFocused, value) => + isFocused + ? "添加项目描述..." + : value + ? "" + : "点击添加项目描述" + } + searchMentionCallback={async (payload) => + await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", { + ...payload, + project_id: projectId?.toString() ?? "", + }) + } + containerClassName={containerClassName} + uploadFile={async (blockId, file) => { + try { + const { asset_id } = await uploadEditorAsset({ + blockId, + data: { + entity_identifier: projectId, + entity_type: EFileAssetType.PROJECT_DESCRIPTION, + }, + file, + projectId, + workspaceSlug, + }); + return asset_id; + } catch (error) { + console.log("Error in uploading project asset:", error); + throw new Error("Asset upload failed. Please try again later."); + } + }} + ref={editorRef} + /> + )} + /> + ); +}); diff --git a/apps/web/core/components/project/project-properties.tsx b/apps/web/core/components/project/project-properties.tsx new file mode 100644 index 00000000000..339882a4541 --- /dev/null +++ b/apps/web/core/components/project/project-properties.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; +import { CalendarClock, CalendarCheck2, Users, UserCircle2, Globe, Lock, Signal } from "lucide-react"; +// i18n +import { useTranslation } from "@plane/i18n"; +// ui icons +import { DoubleCircleIcon } from "@plane/propel/icons"; +import { cn, getDate, renderFormattedPayloadDate } from "@plane/utils"; +// components +import { DateDropdown } from "@/components/dropdowns/date"; +import { PriorityDropdown } from "@/components/dropdowns/priority"; +import { StateDropdown } from "@/components/dropdowns/state/dropdown"; +import { ButtonAvatars } from "@/components/dropdowns/member/avatar"; +import { MemberDropdown } from "@/components/dropdowns/member/dropdown"; +// helpers +import { useMember } from "@/hooks/store/use-member"; +import { useProject } from "@/hooks/store/use-project"; +// types +import type { TProject } from "@plane/types"; + +interface IProjectProperties { + workspaceSlug: string; + projectId: string; + disabled?: boolean; +} + +export const ProjectProperties: FC = observer((props) => { + const { workspaceSlug, projectId, disabled = false } = props; + const { t } = useTranslation(); + + // store hooks + const { getProjectById, updateProject } = useProject(); + const { getUserDetails } = useMember(); + + // derived values + const project = getProjectById(projectId); + if (!project) return <>; + + const createdByDetails = project?.created_by ? getUserDetails(project.created_by) : null; + const projectLeadDetails = + typeof project.project_lead === "string" ? getUserDetails(project.project_lead) : project.project_lead; + + const handleProjectUpdate = async (data: Partial) => { + if (!disabled) { + await updateProject(workspaceSlug, projectId, data); + } + }; + + return ( +
+
{t("common.properties")}
+
+ {/* 项目状态 */} +
+
+ + 状态 +
+ handleProjectUpdate({ default_state: val })} + projectId={projectId} + disabled={disabled} + buttonVariant="transparent-with-text" + className="w-2/3 flex-grow group" + buttonContainerClassName="w-full text-left" + buttonClassName="text-sm" + dropdownArrow + dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline" + /> +
+ + {/* 项目优先级 */} + {/*
+
+ + 优先级 +
+ handleProjectUpdate({ priority: val })} + disabled={disabled} + buttonVariant="transparent-with-text" + className="w-2/3 flex-grow group" + buttonContainerClassName="w-full text-left" + buttonClassName="text-sm" + dropdownArrow + dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline" + /> +
*/} + + {/* 项目负责人 */} +
+
+ + 项目负责人 +
+ + handleProjectUpdate({ project_lead: Array.isArray(val) ? val[0] : val }) + } + disabled={disabled} + projectId={projectId} + placeholder="选择负责人" + buttonVariant="transparent-with-text" + className="w-2/3 flex-grow group" + buttonContainerClassName="w-full text-left" + buttonClassName={`text-sm ${project?.project_lead ? "" : "text-custom-text-400"}`} + hideIcon={!project.project_lead} + dropdownArrow + dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline" + multiple={false} + showUserDetails={true} + /> +
+ + {/* 项目成员 */} +
+
+ + 成员数量 +
+
+ {project.members?.length || 0} 名成员 +
+
+ + {/* 创建者 */} + {/* {createdByDetails && ( +
+
+ + 创建者 +
+
+ + {createdByDetails?.display_name} +
+
+ )} */} + + {/* 项目可见性 */} + {/*
+
+ {project.network === 0 ? ( + + ) : ( + + )} + 可见性 +
+
+ {project.network === 0 ? "私有" : "公开"} +
+
*/} + + {/* 创建时间 */} +
+
+ + 创建时间 +
+ + handleProjectUpdate({ + created_at: val ? renderFormattedPayloadDate(val) : null, + }) + } + placeholder="选择创建时间" + buttonVariant="transparent-with-text" + disabled={true} + className="w-2/3 flex-grow group" + buttonContainerClassName="w-full text-left" + buttonClassName={`text-sm ${project?.created_at ? "" : "text-custom-text-400"}`} + hideIcon + clearIconClassName="h-3 w-3 hidden group-hover:inline" + /> +
+ + {/* 更新时间 */} +
+
+ + 更新时间 +
+ + handleProjectUpdate({ + updated_at: val ? renderFormattedPayloadDate(val) : null, + }) + } + placeholder="选择更新时间" + buttonVariant="transparent-with-text" + disabled={true} + className="w-2/3 flex-grow group" + buttonContainerClassName="w-full text-left" + buttonClassName={`text-sm ${project?.updated_at ? "" : "text-custom-text-400"}`} + hideIcon + clearIconClassName="h-3 w-3 hidden group-hover:inline" + /> +
+
+
+ ); +}); diff --git a/apps/web/core/components/project/work-item-stats.tsx b/apps/web/core/components/project/work-item-stats.tsx new file mode 100644 index 00000000000..84740f85da2 --- /dev/null +++ b/apps/web/core/components/project/work-item-stats.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { FC, useState, useEffect } from "react"; +import { observer } from "mobx-react"; +// hooks +import { useProject } from "@/hooks/store/use-project"; + +type TWorkItemStats = { + workspaceSlug: string; + projectId: string; +}; + +interface IWorkItemData { + total_work_items: { count: number }; + started_work_items: { count: number }; + backlog_work_items: { count: number }; + un_started_work_items: { count: number }; + completed_work_items: { count: number }; + cancelled_work_items: { count: number }; +} + +interface IWorkItemType { + label: string; + key: keyof Omit; + color: string; + count: number; + percentage: number; +} + +export const WorkItemStats: FC = observer((props) => { + const { workspaceSlug, projectId } = props; + const [workItemData, setWorkItemData] = useState(null); + const [loading, setLoading] = useState(true); + + const { fetchProjectAnalyze } = useProject(); + + // 定义工作项类型配置 + const workItemTypes: Omit[] = [ + { label: "Backlog", key: "backlog_work_items", color: "#ebedf2" }, + { label: "Unstarted", key: "un_started_work_items", color: "#b6b6b6" }, + { label: "Started", key: "started_work_items", color: "#ffc099" }, + { label: "Completed", key: "completed_work_items", color: "#92eca7" }, + { label: "Cancelled", key: "cancelled_work_items", color: "#ffbfbf" }, + ]; + + // 获取工作项统计数据 + const fetchWorkItemStats = async () => { + try { + setLoading(true); + const response = await fetchProjectAnalyze(workspaceSlug, projectId); + setWorkItemData(response); + } catch (error) { + console.error("Error fetching work item stats:", error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (workspaceSlug && projectId) { + fetchWorkItemStats(); + } + }, [workspaceSlug, projectId]); + + // 计算工作项统计信息 + const getWorkItemStats = (): IWorkItemType[] => { + if (!workItemData) return []; + + const totalCount = workItemData.total_work_items.count; + if (totalCount === 0) return []; + + return workItemTypes.map((type) => ({ + ...type, + count: workItemData[type.key].count, + percentage: (workItemData[type.key].count / totalCount) * 100, + })); // 只显示有数据的类型 + }; + + const stats = getWorkItemStats(); + + if (loading) { + return ( +
+
加载中...
+
+ ); + } + + if (!workItemData || workItemData.total_work_items.count === 0) { + return ( +
+
暂无工作项数据
+
该项目还没有创建任何工作项
+
+ ); + } + + return ( +
+ {/* 标题 */} +
+

进度

+
+ + {/* 进度条 - 分离式设计 */} +
+
+ {stats.map((item, index) => ( +
0 ? "8px" : "0px", + }} + title={`${item.label}: ${item.count} (${item.percentage.toFixed(1)}%)`} + /> + ))} +
+
+ + {/* 图例 - 上下结构布局 */} +
+ {stats.map((item) => ( +
+ {/* 上行:颜色块和标签 */} +
+ + {item.label} +
+ {/* 下行:数量和百分比 */} +
+ {item.count} + {item.percentage.toFixed(0)}% +
+
+ ))} +
+
+ ); +}); diff --git a/apps/web/core/components/workspace/sidebar/project-navigation.tsx b/apps/web/core/components/workspace/sidebar/project-navigation.tsx index cafb0a84157..9fff20023f7 100644 --- a/apps/web/core/components/workspace/sidebar/project-navigation.tsx +++ b/apps/web/core/components/workspace/sidebar/project-navigation.tsx @@ -4,7 +4,7 @@ import React, { FC, useCallback, useMemo } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useParams, usePathname } from "next/navigation"; -import { FileText, Kanban, Layers } from "lucide-react"; +import { FileText, Rss, Layers } from "lucide-react"; import { EUserPermissionsLevel, EUserPermissions } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { DiceIcon, ContrastIcon, LayersIcon, Intake } from "@plane/propel/icons"; @@ -66,11 +66,11 @@ export const ProjectNavigation: FC = observer((props) => { const baseNavigation = useCallback( (workspaceSlug: string, projectId: string): TNavigationItem[] => [ { - i18n_key: "sidebar.work_items", - key: "work_items", - name: "Work items", + i18n_key: "sidebar.overview", + key: "overview", + name: "Overview", href: `/${workspaceSlug}/projects/${projectId}/overview`, - icon: Kanban, + icon: Rss, access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], shouldRender: true, sortOrder: 0, @@ -158,7 +158,6 @@ export const ProjectNavigation: FC = observer((props) => { return sortedNavigationItems; }, [workspaceSlug, projectId, baseNavigation, additionalNavigationItems]); - console.log("🚀 ~ navigationItemsMemo:", navigationItemsMemo); const isActive = useCallback( (item: TNavigationItem) => { diff --git a/apps/web/core/services/project/project.service.ts b/apps/web/core/services/project/project.service.ts index bf5a2ce2e53..570751dc672 100644 --- a/apps/web/core/services/project/project.service.ts +++ b/apps/web/core/services/project/project.service.ts @@ -61,6 +61,22 @@ export class ProjectService extends APIService { }); } + async getProjectHistory(workspaceSlug: string, projectId: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/history/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async getProjectAnalyze(workspaceSlug: string, projectId: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/advance-analytics/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + async getProjectAnalyticsCount( workspaceSlug: string, params?: TProjectAnalyticsCountParams diff --git a/apps/web/core/store/project/project.store.ts b/apps/web/core/store/project/project.store.ts index 20a0b307199..0919b679878 100644 --- a/apps/web/core/store/project/project.store.ts +++ b/apps/web/core/store/project/project.store.ts @@ -54,6 +54,8 @@ export interface IProjectStore { fetchPartialProjects: (workspaceSlug: string) => Promise; fetchProjects: (workspaceSlug: string) => Promise; fetchProjectDetails: (workspaceSlug: string, projectId: string) => Promise; + fetchProjectHistory: (workspaceSlug: string, projectId: string) => Promise; + fetchProjectAnalyze: (workspaceSlug: string, projectId: string) => Promise; fetchProjectAnalyticsCount: ( workspaceSlug: string, params?: TProjectAnalyticsCountParams @@ -116,6 +118,8 @@ export class ProjectStore implements IProjectStore { fetchPartialProjects: action, fetchProjects: action, fetchProjectDetails: action, + fetchProjectHistory: action, + fetchProjectAnalyze: action, fetchProjectAnalyticsCount: action, // favorites actions addProjectToFavorites: action, @@ -360,6 +364,30 @@ export class ProjectStore implements IProjectStore { } }; + fetchProjectAnalyze = async (workspaceSlug: string, projectId: string) => { + try { + const response = await this.projectService.getProjectAnalyze(workspaceSlug, projectId); + return response; + } catch (error) { + console.log("Error while fetching project details", error); + throw error; + } + }; + + fetchProjectHistory = async (workspaceSlug: string, projectId: string) => { + try { + // 目前后端尚未实现该接口,返回模拟数据 + // const response = await this.projectService.getProjectHistory(workspaceSlug, projectId); + // return response; + + const response = '[{"id":"83c4c56e-f267-4f18-81a3-d5776d856b8b","deleted_at":null,"actor_detail":{"id":"0b8a2dc5-2043-4a7e-8726-19ec159cf6a8","first_name":"YZ","last_name":"","avatar":"","avatar_url":null,"is_bot":false,"display_name":"xxxx2024"},"project_detail":{"id":"9b9fd37c-359f-4969-87ac-19377ba05c61","identifier":"KF789","name":"kf789","cover_image":"https://images.unsplash.com/photo-1691230995681-480d86cbc135?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80","cover_image_url":"https://images.unsplash.com/photo-1691230995681-480d86cbc135?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80","logo_props":{"emoji":{"url":"https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f447.png","value":"128071"},"in_use":"emoji"},"description":"Welcome to the Plane Demo Project! This project throws you into the driver’s seat of Plane, work management software. Through curated work items, you’ll uncover key features, pick up best practices, and see how Plane can streamline your team’s workflow. Whether you’re a startup hungry to scale or an enterprise sharpening efficiency, this demo is your launchpad to mastering Plane. Jump in and see what it can do!"},"workspace_detail":{"name":"kf789","slug":"kf789","id":"a29df89c-6208-4272-a18d-ec491b81d183","logo_url":null},"created_at":"2025-09-25T06:46:35.434624Z","updated_at":"2025-09-25T06:46:35.434638Z","verb":"updated","field":"description","old_value":null,"new_value":"

DSFSDFSD

","comment":"updated the description to","old_identifier":null,"new_identifier":null,"epoch":1758869195,"created_by":null,"updated_by":null,"project":"9b9fd37c-359f-4969-87ac-19377ba05c61","workspace":"a29df89c-6208-4272-a18d-ec491b81d183","issue":null,"issue_comment":null,"actor":"0b8a2dc5-2043-4a7e-8726-19ec159cf6a8"},{"id":"93c4c56e-f267-4f18-81a3-d5776d856b8b","deleted_at":null,"actor_detail":{"id":"0b8a2dc5-2043-4a7e-8726-19ec159cf6a8","first_name":"YZ","last_name":"","avatar":"","avatar_url":null,"is_bot":false,"display_name":"zhenggg2024"},"project_detail":{"id":"9b9fd37c-359f-4969-87ac-19377ba05c61","identifier":"KF789","name":"kf789","cover_image":"https://images.unsplash.com/photo-1691230995681-480d86cbc135?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80","cover_image_url":"https://images.unsplash.com/photo-1691230995681-480d86cbc135?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80","logo_props":{"emoji":{"url":"https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f447.png","value":"128071"},"in_use":"emoji"},"description":"Welcome to the Plane Demo Project! This project throws you into the driver’s seat of Plane, work management software. Through curated work items, you’ll uncover key features, pick up best practices, and see how Plane can streamline your team’s workflow. Whether you’re a startup hungry to scale or an enterprise sharpening efficiency, this demo is your launchpad to mastering Plane. Jump in and see what it can do!"},"workspace_detail":{"name":"kf789","slug":"kf789","id":"a29df89c-6208-4272-a18d-ec491b81d183","logo_url":null},"created_at":"2025-09-26T06:46:35.434624Z","updated_at":"2025-09-26T06:46:35.434638Z","verb":"updated","field":"description","old_value":null,"new_value":"

DSFSDFSD

","comment":"updated the description to","old_identifier":null,"new_identifier":null,"epoch":1758869195,"created_by":null,"updated_by":null,"project":"9b9fd37c-359f-4969-87ac-19377ba05c61","workspace":"a29df89c-6208-4272-a18d-ec491b81d183","issue":null,"issue_comment":null,"actor":"0b8a2dc5-2043-4a7e-8726-19ec159cf6a8"}]' + return JSON.parse(response); + } catch (error) { + console.log("Error while fetching project details", error); + throw error; + } + }; + /** * Fetches project analytics count using workspace slug and project id * @param workspaceSlug diff --git a/apps/web/next.config.js b/apps/web/next.config.js index cf5dccb264e..d82194824a5 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -7,9 +7,13 @@ const nextConfig = { trailingSlash: true, reactStrictMode: false, swcMinify: true, - concurrentFeatures: true, - fastRefresh: true, output: "standalone", + eslint: { + ignoreDuringBuilds: true, + }, + typescript: { + ignoreBuildErrors: true, + }, async headers() { return [ { diff --git a/apps/web/package.json b/apps/web/package.json index 94fe8564fdb..32499ce197b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,8 +5,8 @@ "license": "AGPL-3.0", "scripts": { "dev": "next dev --port 3000 -H 0.0.0.0", - "build": "next build", - "start": "next start", + "build": "NEXT_SKIP_TYPE_CHECK=true NEXT_SKIP_LINT=true next build --no-lint", + "start": "next start -H 0.0.0.0", "clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist", "check:lint": "eslint . --max-warnings 821", "check:types": "tsc --noEmit", diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json index 341a698a669..b33018db91a 100644 --- a/packages/i18n/src/locales/en/translations.json +++ b/packages/i18n/src/locales/en/translations.json @@ -317,6 +317,7 @@ "decline": "Decline", "unassigned": "Unassigned", "work_items": "Work items", + "overview": "Overview", "add_link": "Add link", "points": "Points", "no_assignee": "No assignee", diff --git a/packages/i18n/src/locales/zh-CN/translations.json b/packages/i18n/src/locales/zh-CN/translations.json index 03b35d9f691..ac2e6c8959e 100644 --- a/packages/i18n/src/locales/zh-CN/translations.json +++ b/packages/i18n/src/locales/zh-CN/translations.json @@ -16,7 +16,8 @@ "drafts": "草稿", "favorites": "收藏", "pro": "专业版", - "upgrade": "升级" + "upgrade": "升级", + "overview": "概览" }, "auth": { "common": { diff --git a/packages/types/src/project/projects.ts b/packages/types/src/project/projects.ts index 6927688752d..e1d99e2f68f 100644 --- a/packages/types/src/project/projects.ts +++ b/packages/types/src/project/projects.ts @@ -33,6 +33,7 @@ export interface IPartialProject { // actor created_by?: string; updated_by?: string; + description_html?: string | null; } export interface IProject extends IPartialProject { From 12c9feeef11aebbb2175cad8d1f5b59485775510 Mon Sep 17 00:00:00 2001 From: yuzheng3 Date: Tue, 30 Sep 2025 02:44:18 +0000 Subject: [PATCH 6/7] =?UTF-8?q?feat(overview):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E6=A6=82=E8=A7=88=E5=8A=9F=E8=83=BD=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在项目功能枚举中添加OVERVIEW选项 - 新增概览页面导航项和侧边栏控制 - 实现概览页面的布局和交互功能 - 更新项目类型定义和主题存储以支持概览功能 --- .../overview/(list)/OverviewList.tsx | 78 ++++++++++--------- .../[projectId]/overview/(list)/header.tsx | 57 +++----------- apps/web/ce/components/breadcrumbs/common.tsx | 2 + .../components/projects/navigation/helper.tsx | 12 ++- .../components/project/project-activity.tsx | 2 +- .../components/project/project-properties.tsx | 4 +- apps/web/core/store/theme.store.ts | 13 ++++ packages/constants/src/project.ts | 1 + packages/types/src/project/projects.ts | 5 +- 9 files changed, 86 insertions(+), 88 deletions(-) diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/overview/(list)/OverviewList.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/overview/(list)/OverviewList.tsx index c53cac85c15..0ae9669f785 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/overview/(list)/OverviewList.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/overview/(list)/OverviewList.tsx @@ -5,6 +5,7 @@ import { useState } from "react"; // plane web hooks import { cn, getFileURL } from "@plane/utils"; import { useProject } from "@/hooks/store/use-project"; +import { useAppTheme } from "@/hooks/store/use-app-theme"; import { Logo } from "@/components/common/logo"; import { ProjectDescriptionInput } from "@/components/project/project-description-input"; import { ProjectProperties } from "@/components/project/project-properties"; @@ -25,14 +26,15 @@ export const OverviewListView: React.FC = observer((props) => { const [activeTab, setActiveTab] = useState<"properties" | "activity">("properties"); // store hooks + const { overviewPeek } = useAppTheme(); // pages loader return (
{/* 主要布局:左右两栏 */}
- {/* 左侧区域 - 占2/3宽度 */} -
+ {/* 左侧区域 - 根据右侧是否显示调整宽度 */} +
{/* 背景图区域 - 固定高度 */}
@@ -95,43 +97,45 @@ export const OverviewListView: React.FC = observer((props) => {
- {/* 右侧功能区域 - 独占1/3宽度,使用粘性定位 */} -
- {/* 切换按钮 */} -
- - -
+ {/* 右侧功能区域 - 根据 overviewPeek 状态条件渲染 */} + {overviewPeek && ( +
+ {/* 切换按钮 */} +
+ + +
- {/* 内容区域 */} -
- {activeTab === "properties" ? ( - - ) : ( - - )} + {/* 内容区域 */} +
+ {activeTab === "properties" ? ( + + ) : ( + + )} +
-
+ )}
); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/overview/(list)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/overview/(list)/header.tsx index 7b2d9d0975b..83fc1299851 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/overview/(list)/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/overview/(list)/header.tsx @@ -22,6 +22,8 @@ import { useProject } from "@/hooks/store/use-project"; import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; // plane web hooks import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store"; +import { useAppTheme } from "@/hooks/store/use-app-theme"; +import { PanelLeft } from "lucide-react"; export const OverviewListHeader = observer(() => { // states @@ -33,42 +35,8 @@ export const OverviewListHeader = observer(() => { const pageType = searchParams.get("type"); // store hooks const { currentProjectDetails, loader } = useProject(); - const { canCurrentUserCreatePage, createPage } = usePageStore(EPageStoreType.PROJECT); - // handle page create - const handleCreatePage = async () => { - setIsCreatingPage(true); - - const payload: Partial = { - access: pageType === "private" ? EPageAccess.PRIVATE : EPageAccess.PUBLIC, - }; - - await createPage(payload) - .then((res) => { - captureSuccess({ - eventName: PROJECT_PAGE_TRACKER_EVENTS.create, - payload: { - id: res?.id, - state: "SUCCESS", - }, - }); - const pageId = `/${workspaceSlug}/projects/${currentProjectDetails?.id}/pages/${res?.id}`; - router.push(pageId); - }) - .catch((err) => { - captureError({ - eventName: PROJECT_PAGE_TRACKER_EVENTS.create, - payload: { - state: "ERROR", - }, - }); - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: err?.data?.error || "Page could not be created. Please try again.", - }); - }) - .finally(() => setIsCreatingPage(false)); - }; + const { canCurrentUserCreatePage } = usePageStore(EPageStoreType.PROJECT); + const { overviewPeek, overviewSidebarPeek } = useAppTheme(); return (
@@ -77,22 +45,21 @@ export const OverviewListHeader = observer(() => { {canCurrentUserCreatePage ? ( - + + ) : ( <> diff --git a/apps/web/ce/components/breadcrumbs/common.tsx b/apps/web/ce/components/breadcrumbs/common.tsx index abcb5cb3d1d..489b9959982 100644 --- a/apps/web/ce/components/breadcrumbs/common.tsx +++ b/apps/web/ce/components/breadcrumbs/common.tsx @@ -16,6 +16,8 @@ type TCommonProjectBreadcrumbProps = { export const CommonProjectBreadcrumbs: FC = (props) => { const { workspaceSlug, projectId, featureKey, isLast = false } = props; + console.log("🚀 ~ CommonProjectBreadcrumbs ~ featureKey:", featureKey); + return ( <> diff --git a/apps/web/ce/components/projects/navigation/helper.tsx b/apps/web/ce/components/projects/navigation/helper.tsx index b4866841536..3e86aec9d51 100644 --- a/apps/web/ce/components/projects/navigation/helper.tsx +++ b/apps/web/ce/components/projects/navigation/helper.tsx @@ -1,4 +1,4 @@ -import { FileText, Layers } from "lucide-react"; +import { FileText, Layers, Rss } from "lucide-react"; // plane imports import { EUserPermissions, EProjectFeatureKey } from "@plane/constants"; import { ContrastIcon, DiceIcon, LayersIcon, Intake } from "@plane/propel/icons"; @@ -16,6 +16,16 @@ export const getProjectFeatureNavigation = ( inbox_view: boolean; } ): TNavigationItem[] => [ + { + i18n_key: "sidebar.overview", + key: EProjectFeatureKey.OVERVIEW, + name: "Overview", + href: `/${workspaceSlug}/projects/${projectId}/overview`, + icon: Rss, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + shouldRender: true, + sortOrder: 0, + }, { i18n_key: "sidebar.work_items", key: EProjectFeatureKey.WORK_ITEMS, diff --git a/apps/web/core/components/project/project-activity.tsx b/apps/web/core/components/project/project-activity.tsx index 539ba540c9b..4edbfa68e62 100644 --- a/apps/web/core/components/project/project-activity.tsx +++ b/apps/web/core/components/project/project-activity.tsx @@ -129,7 +129,7 @@ export const ProjectActivity: FC = observer((props) => {
{/* header */}
-
项目活动
+
项目活动