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} // 修改:折叠时仍允许“窥视”
>
{project.identifier}
+ {project.network === 0 &&
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项目活动
+ {t("common.properties")}
+ 进度
+