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/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/Dockerfile.django b/apps/api/Dockerfile.django new file mode 100644 index 00000000000..15f66a7365e --- /dev/null +++ b/apps/api/Dockerfile.django @@ -0,0 +1,79 @@ +# 使用Python 3.12官方镜像作为基础镜像 +FROM python:3.12.10-alpine + +# 设置环境变量 +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 +ENV INSTANCE_CHANGELOG_URL=https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/ + +# 更新系统包以确保安全性 +RUN apk update && apk upgrade + +# 设置工作目录 +WORKDIR /code + +# 安装运行时依赖 +RUN apk add --no-cache --upgrade \ + "libpq" \ + "libxslt" \ + "xmlsec" \ + "ca-certificates" \ + "openssl" \ + "bash~=5.2" + +# 复制依赖文件 +COPY requirements.txt ./ +COPY requirements ./requirements + +# 安装Python依赖 +RUN apk add --no-cache libffi-dev && \ + apk add --no-cache --virtual .build-deps \ + "g++" \ + "gcc" \ + "cargo" \ + "git" \ + "make" \ + "postgresql-dev" \ + "libc-dev" \ + "linux-headers" && \ + pip install --upgrade pip && \ + pip install -r requirements.txt --compile --no-cache-dir && \ + apk del .build-deps && \ + rm -rf /var/cache/apk/* && \ + rm -rf /root/.cache/pip/* + +# 复制Django项目文件 +COPY manage.py manage.py +COPY plane plane/ +COPY templates templates/ +COPY package.json package.json + +# 复制启动脚本 +COPY ./bin ./bin/ + +# 创建必要的目录并设置权限 +RUN mkdir -p /code/plane/logs && \ + mkdir -p /code/uploads && \ + chmod +x ./bin/* && \ + chmod -R 777 /code + +# 创建非root用户(安全最佳实践) +RUN addgroup -g 1001 -S appgroup && \ + adduser -S appuser -u 1001 -G appgroup + +# 更改文件所有权 +RUN chown -R appuser:appgroup /code + +# 切换到非root用户 +USER appuser + +# 暴露端口 +EXPOSE 8000 + +# 设置健康检查 +HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ + CMD python manage.py check --deploy || exit 1 + +# 启动命令 - 使用本地开发启动脚本 +CMD ["./bin/docker-entrypoint-api-local.sh"] \ No newline at end of file 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/app/views/analytic/project_analytics.py b/apps/api/plane/app/views/analytic/project_analytics.py index 655f8e98984..2dfb2c7a8b0 100644 --- a/apps/api/plane/app/views/analytic/project_analytics.py +++ b/apps/api/plane/app/views/analytic/project_analytics.py @@ -56,7 +56,7 @@ def get_filtered_count() -> int: } def get_work_items_stats( - self, project_id, cycle_id=None, module_id=None + self, project_id, cycle_id=None, module_id=None ) -> Dict[str, Dict[str, int]]: """ Returns work item stats for the workspace, or filtered by cycle_id or module_id if provided. @@ -91,6 +91,9 @@ def get_work_items_stats( "completed_work_items": self.get_filtered_counts( base_queryset.filter(state__group="completed") ), + "cancelled_work_items": self.get_filtered_counts( + base_queryset.filter(state__group="cancelled") + ), } @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) @@ -133,7 +136,7 @@ def get_project_issues_stats(self) -> QuerySet: ) def get_work_items_stats( - self, project_id, cycle_id=None, module_id=None + self, project_id, cycle_id=None, module_id=None ) -> Dict[str, Dict[str, int]]: base_queryset = None if cycle_id is not None: @@ -215,7 +218,7 @@ def get(self, request: HttpRequest, slug: str, project_id: str) -> Response: class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView): def work_item_completion_chart( - self, project_id, cycle_id=None, module_id=None + self, project_id, cycle_id=None, module_id=None ) -> Dict[str, Any]: # Get the base queryset queryset = ( 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/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 new file mode 100644 index 00000000000..0ae9669f785 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/overview/(list)/OverviewList.tsx @@ -0,0 +1,142 @@ +import { observer } from "mobx-react"; +import { TNameDescriptionLoader } from "@plane/types"; +import type { IProject } from "@plane/types"; +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"; +import { ProjectActivity } from "@/components/project/project-activity"; +import { WorkItemStats } from "@/components/project/work-item-stats"; +import { BadgeInfo, Lock, Activity } from "lucide-react"; + +type TPageView = { + children: React.ReactNode; + project: IProject; + workspaceSlug: string; +}; + +export const OverviewListView: React.FC = observer((props) => { + const { children, project, workspaceSlug } = props; + // states + const [isSubmitting, setIsSubmitting] = useState("submitted"); + const [activeTab, setActiveTab] = useState<"properties" | "activity">("properties"); + + // store hooks + const { overviewPeek } = useAppTheme(); + + // pages loader + return ( +
+ {/* 主要布局:左右两栏 */} +
+ {/* 左侧区域 - 根据右侧是否显示调整宽度 */} +
+ {/* 背景图区域 - 固定高度 */} +
+
+ + {project?.name} + +
+
+
+ +
+ +
+

{project.name}

+ +

{project.identifier}

+ {project.network === 0 && } +
+
+
+
+
+ + {/* 左侧底部两个功能区域 - 无间隔,用虚线分隔 */} +
+ {/* 功能区域 1 - 项目描述编辑器 */} +
+
+
+

项目描述

+ {isSubmitting === "submitting" &&
保存中...
} +
+
+ +
+
+
+ + {/* 实线分隔线 */} +
+ + {/* 功能区域 2 */} +
+ +
+
+
+ + {/* 右侧功能区域 - 根据 overviewPeek 状态条件渲染 */} + {overviewPeek && ( +
+ {/* 切换按钮 */} +
+ + +
+ + {/* 内容区域 */} +
+ {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 new file mode 100644 index 00000000000..83fc1299851 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/overview/(list)/header.tsx @@ -0,0 +1,69 @@ +"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"; +import { useAppTheme } from "@/hooks/store/use-app-theme"; +import { PanelLeft } from "lucide-react"; + +export const OverviewListHeader = 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 } = usePageStore(EPageStoreType.PROJECT); + const { overviewPeek, overviewSidebarPeek } = useAppTheme(); + + 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..308fa4b759d --- /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 { OverviewListHeader } from "./header"; + +export default function ProjectOverviewListLayout({ 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..be161c3e2fe --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/overview/(list)/page.tsx @@ -0,0 +1,79 @@ +"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"; +import { OverviewListView } from "./OverviewList"; + +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 = 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" }); + + 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 ( + <> + + + +

qqq

+
+ + ); +}); + +export default ProjectPagesPage; 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/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/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/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/project/project-activity.tsx b/apps/web/core/components/project/project-activity.tsx new file mode 100644 index 00000000000..4edbfa68e62 --- /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..ce59d47c48f --- /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) : undefined, + }) + } + 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/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/project-navigation.tsx b/apps/web/core/components/workspace/sidebar/project-navigation.tsx index 59313763429..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, 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"; @@ -65,6 +65,16 @@ export const ProjectNavigation: FC = observer((props) => { const baseNavigation = useCallback( (workspaceSlug: string, projectId: string): TNavigationItem[] => [ + { + i18n_key: "sidebar.overview", + key: "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: "work_items", 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 && (