完善项目入口与成员管理页面并同步真实整改进度
This commit is contained in:
parent
af779818fb
commit
a11315ddff
89
.claude/docs/ai/project-members/api-handoff-v2.md
Normal file
89
.claude/docs/ai/project-members/api-handoff-v2.md
Normal file
@ -0,0 +1,89 @@
|
||||
# Scert 项目入口与成员页面接口交接
|
||||
|
||||
## 本版范围
|
||||
|
||||
本版接续 `api-handoff.md` 的成员列表、授予和撤销接口,新增当前账户可见项目入口以及成员页面。管理员通过“项目与成员”导航进入,普通用户通过用户中心的同名板块进入;普通用户担任项目管理员时无需进入全局管理员控制台。
|
||||
|
||||
项目创建、编辑、删除,以及域名、证书、服务器、DNS 和排队任务的完整权限链不属于已完成内容。前端测试和构建不代表浏览器验收,更不代表生产验收。
|
||||
|
||||
## GET /api/v1/projects
|
||||
|
||||
用途:从服务端读取当前真实账户有权访问的项目,前端不得先读取全量项目再自行过滤,也不得固定使用默认项目。
|
||||
|
||||
认证:正式会话 Cookie;同源发送。身份仅来自会话,账户状态、全局角色与当前项目成员关系由同一条 SQL 实时查询。只有有效超级管理员可见所有项目;普通管理员和普通用户只可见自己有合法项目角色的项目。分组关系不隐式授予权限。
|
||||
|
||||
请求示例:
|
||||
|
||||
```http
|
||||
GET /api/v1/projects?page_size=5&after=project-a
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 约束 |
|
||||
|---|---|---|
|
||||
| `page_size` | 整数 | 默认 10,允许 5、10、15、30;页面项目列表默认选 5 |
|
||||
| `after` | 字符串 | 默认空;使用上页 `next_cursor`,只允许 1~128 位 ASCII 字母、数字、连字符或下划线 |
|
||||
|
||||
重复参数、未知参数、非法转义或无效页大小返回 400,不接受操作者 ID 或角色参数。按项目 ID 升序做游标分页,无总数和任意页码跳转;同一账户在项目内具有多个角色不会重复返回项目。
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "project-b",
|
||||
"name": "测试项目",
|
||||
"description": "项目说明",
|
||||
"can_manage_members": true
|
||||
}
|
||||
],
|
||||
"page_size": 5,
|
||||
"has_more": false,
|
||||
"next_cursor": ""
|
||||
}
|
||||
```
|
||||
|
||||
- `items` 始终为数组;有效账户没有可见项目或请求末尾之后的游标,返回空数组。
|
||||
- `can_manage_members` 仅表示本次查询时的页面能力提示,不是后续调用的授权凭据;成员接口继续独立判权。
|
||||
- 不返回项目成员目录、邮箱、密码摘要或账户分组。
|
||||
- 项目列表与成员接口的所有响应均设置 `Cache-Control: private, no-store` 和 `Vary: Cookie`,包括未认证和权限拒绝响应;前端请求同时使用 `cache: no-store`。
|
||||
- 只有存在更多数据时 `has_more` 为真,`next_cursor` 才包含本页最后项目 ID。
|
||||
|
||||
### 错误边界
|
||||
|
||||
错误格式沿用 `{"error":"中文提示","request_id":"请求编号"}`。
|
||||
|
||||
| 状态码 | 含义与前端行为 |
|
||||
|---|---|
|
||||
| 400 | 参数无效;不重复提交原参数 |
|
||||
| 401 | 会话无效;清空旧数据并跳转登录 |
|
||||
| 403 | 会话验证后账户状态或角色已不允许读取;清空旧项目,不伪装空列表 |
|
||||
| 409 | 系统尚未初始化;不解释为合法空项目 |
|
||||
| 500 | 查询失败;仅显示稳定中文信息和安全的请求编号,不显示数据库原始错误 |
|
||||
| 503 | 项目仓储未接入;不能当作没有项目 |
|
||||
|
||||
## 成员页面交互
|
||||
|
||||
1. 先获取可见项目,再选择实际返回的项目;仅在 `can_manage_members` 为真时请求成员和账户建议。
|
||||
2. 成员使用既有账户游标分页,默认每页 10,允许 5、10、15、30;一个账户的多个角色合并展示。
|
||||
3. 账户建议复用现有 `GET /api/v1/users` 权限边界,并合并本页有效成员的 ID、用户名;停用账户不加入授予建议。普通项目管理员不会因此读取全站账户列表。
|
||||
4. 不在既有可见账户范围内的目标,填写对方提供的明确账户 ID;用户管理页面显示已有可见账户的 ID,普通用户可取得自己的 ID,不要求查看数据库。
|
||||
5. 授予角色只提交 `user_id`、`role`;不提交操作者或全局角色。成功后重新读取第一页;409 保留输入、刷新成员并显示冲突,不乐观添加成员。
|
||||
6. 撤销前显示账户与中文角色的确认栏;DELETE 不发送正文。撤销自己管理角色后,如果成员刷新返回 403,清空成员和账户建议,关闭继续修改能力;通过刷新项目重新获取能力提示。
|
||||
7. 项目切换、分页更新和组件卸载均终止旧读取并使用请求代次防止迟到响应覆盖新状态。已提交的变更不能承诺被浏览器取消;旧项目变更响应不得刷新新项目或清空新表单。
|
||||
8. 账户建议失败时允许手工填写账户 ID;成员读取失败显示错误而非伪造成功。网络故障显示中文;401 跳转登录;安全格式的 `request_id` 附在错误信息后。
|
||||
|
||||
## 实现与验证位置
|
||||
|
||||
- 仓储:`backend/internal/repository/project_directory.go`,与基础全量 `ProjectRepository.ListProjects` 分离。
|
||||
- HTTP:`backend/internal/httpapi/project_directory_api.go`;应用通过独立 `ProjectDirectory` 依赖接线。
|
||||
- 页面:`frontend/src/views/ProjectManagement/`,视图、样式、目录逻辑、成员逻辑与请求封装分开。
|
||||
- 普通工作台:`frontend/src/views/UserManagement/UserWorkspace.vue`/`.js`/`.css`,账户与项目单板块显示。
|
||||
- 正式测试:项目目录仓储、HTTP 边界、实际应用登录接线及 `ProjectManagement.test.js`;最终命令、条件跳过与验收缺口以支持矩阵第二十四节为准。
|
||||
|
||||
## 尚未完成
|
||||
|
||||
- [ ] 项目创建、编辑、删除及相关事务审计。
|
||||
- [ ] 本批页面的真实浏览器布局与交互验收。
|
||||
- [ ] 全部资源入口与排队任务的执行、取消、重试权限重判。
|
||||
- [ ] PostgreSQL 当前实库、Linux 原生与完整生产部署验收。
|
||||
@ -1,5 +1,7 @@
|
||||
# Scert 项目成员接口交接
|
||||
|
||||
> 本文件保留成员接口批次契约。新增可见项目入口和成员页面的当前交接见同目录 `api-handoff-v2.md`;下方“不提供项目列表或页面”描述仅对应原批次。
|
||||
|
||||
## 业务范围与交付边界
|
||||
|
||||
项目成员关系用于管理同一项目内的六类角色,与用户分组独立。普通用户可以担任项目管理员,普通全局管理员不因登录角色或同组身份自动取得项目权限。接口允许当前有效超级管理员或目标项目的项目管理员管理成员。
|
||||
|
||||
@ -155,8 +155,9 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
|
||||
}, management)
|
||||
server = httpapi.New(httpapi.Dependencies{
|
||||
Config: cfg, ConfigPath: configPath, Logger: logger, Repository: repo, Servers: serverRepo, Users: userRepo, SSHKeys: sshKeys, DeploymentFactory: deployment.SSHFactory{Secrets: secretStore},
|
||||
ProjectMembers: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver),
|
||||
SetupDNS: dnsVerification, Secrets: secretStore, Firewall: firewall.NewManager(), AliyunChecker: aliyunAuthChecker,
|
||||
ProjectMembers: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver),
|
||||
ProjectDirectory: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver),
|
||||
SetupDNS: dnsVerification, Secrets: secretStore, Firewall: firewall.NewManager(), AliyunChecker: aliyunAuthChecker,
|
||||
IssueCertificate: workflow.IssueForHTTP,
|
||||
IssueSetupCertificate: func(ctx context.Context, request certificate.SetupCertificateRequest) (certificate.SavedResource, error) {
|
||||
return certificates.IssueForSetup(ctx, request)
|
||||
|
||||
@ -32,4 +32,13 @@ func TestApplicationWiresProjectMemberAPI(t *testing.T) {
|
||||
if listed.Code != http.StatusOK || !strings.Contains(listed.Body.String(), `"items":[]`) {
|
||||
t.Fatalf("实际应用未接入成员仓储或默认项目隐式增加成员:%d,%s", listed.Code, listed.Body.String())
|
||||
}
|
||||
directoryRequest := httptest.NewRequest(http.MethodGet, "/api/v1/projects?page_size=5", nil)
|
||||
for _, cookie := range response.Result().Cookies() {
|
||||
directoryRequest.AddCookie(cookie)
|
||||
}
|
||||
directoryResponse := httptest.NewRecorder()
|
||||
application.HTTP.Handler().ServeHTTP(directoryResponse, directoryRequest)
|
||||
if directoryResponse.Code != http.StatusOK || !strings.Contains(directoryResponse.Body.String(), `"id":"default"`) || !strings.Contains(directoryResponse.Body.String(), `"can_manage_members":true`) {
|
||||
t.Fatalf("实际应用未接入项目入口:%d,%s", directoryResponse.Code, directoryResponse.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
39
backend/internal/httpapi/project_directory_api.go
Normal file
39
backend/internal/httpapi/project_directory_api.go
Normal file
@ -0,0 +1,39 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
||||
)
|
||||
|
||||
// 项目与成员数据属于当前身份,即使认证失败也不能让共享缓存复用响应。
|
||||
func privateProjectResponse(c *gin.Context) {
|
||||
c.Header("Cache-Control", "private, no-store")
|
||||
c.Header("Vary", "Cookie")
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func (s *Server) listVisibleProjects(c *gin.Context) {
|
||||
if s.projectDirectory == nil {
|
||||
s.writeError(c, http.StatusServiceUnavailable, "项目服务尚未就绪", nil)
|
||||
return
|
||||
}
|
||||
query, err := projectMemberPageQuery(c)
|
||||
if err != nil {
|
||||
s.writeError(c, http.StatusBadRequest, "分页参数无效,每页仅支持 5、10、15 或 30 个项目", nil)
|
||||
return
|
||||
}
|
||||
page, err := s.projectDirectory.ListVisibleProjects(c.Request.Context(), c.GetString(authenticatedActorKey), query.After, query.Size)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrProjectAccessDenied) {
|
||||
s.writeError(c, http.StatusForbidden, "无权读取项目列表", nil)
|
||||
} else {
|
||||
s.writeError(c, http.StatusInternalServerError, "读取项目列表失败,请稍后重试", nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, page)
|
||||
}
|
||||
148
backend/internal/httpapi/project_directory_api_test.go
Normal file
148
backend/internal/httpapi/project_directory_api_test.go
Normal file
@ -0,0 +1,148 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
||||
)
|
||||
|
||||
type projectDirectoryBoundary struct {
|
||||
repository.ProjectDirectoryRepository
|
||||
before func()
|
||||
err error
|
||||
}
|
||||
|
||||
func (r projectDirectoryBoundary) ListVisibleProjects(ctx context.Context, actor, after string, size int) (repository.VisibleProjectPage, error) {
|
||||
if r.before != nil {
|
||||
r.before()
|
||||
}
|
||||
if r.err != nil {
|
||||
return repository.VisibleProjectPage{}, r.err
|
||||
}
|
||||
return r.ProjectDirectoryRepository.ListVisibleProjects(ctx, actor, after, size)
|
||||
}
|
||||
|
||||
func TestProjectDirectoryAPI(t *testing.T) {
|
||||
f := newProjectMemberAPIFixture(t)
|
||||
for _, tc := range []struct {
|
||||
actor string
|
||||
status, count int
|
||||
manage bool
|
||||
}{
|
||||
{"", 401, 0, false}, {"primary-super-admin", 200, 3, true},
|
||||
{"manager", 200, 1, true}, {"viewer", 200, 1, false}, {"global-admin", 200, 0, false},
|
||||
} {
|
||||
t.Run("visibility_"+tc.actor, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/projects?page_size=5", nil)
|
||||
if tc.actor != "" {
|
||||
req.AddCookie(f.cookies[tc.actor])
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
f.server.Handler().ServeHTTP(response, req)
|
||||
if response.Header().Get("Cache-Control") != "private, no-store" || response.Header().Get("Vary") != "Cookie" {
|
||||
t.Fatal("项目响应没有禁止共享缓存")
|
||||
}
|
||||
if response.Code != tc.status {
|
||||
t.Fatalf("项目入口响应错误:%d,%s", response.Code, response.Body.String())
|
||||
}
|
||||
if tc.status != 200 {
|
||||
return
|
||||
}
|
||||
var page repository.VisibleProjectPage
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &page); err != nil || page.Items == nil || len(page.Items) != tc.count {
|
||||
t.Fatalf("项目响应不正确:%s,%v", response.Body.String(), err)
|
||||
}
|
||||
for _, item := range page.Items {
|
||||
if item.CanManageMembers != tc.manage {
|
||||
t.Fatalf("前端能力提示错误:%+v", item)
|
||||
}
|
||||
}
|
||||
for _, field := range []string{"password", "email", "username", "project_members"} {
|
||||
if strings.Contains(response.Body.String(), field) {
|
||||
t.Fatalf("项目入口包含无关隐私:%s", field)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, query := range []string{"page_size=7", "page_size=5&page_size=10", "after=x&after=y", "actor=primary-super-admin", "after=%00", "page_size=", "page_size=5;after=x"} {
|
||||
t.Run("invalid_"+query, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/projects?"+query, nil)
|
||||
req.AddCookie(f.cookies["manager"])
|
||||
response := httptest.NewRecorder()
|
||||
f.server.Handler().ServeHTTP(response, req)
|
||||
if response.Code != 400 {
|
||||
t.Fatalf("项目入口接受未知或无效输入:%d", response.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, size := range []string{"5", "10", "15", "30"} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/projects?page_size="+size+"&after=zzzz", nil)
|
||||
req.AddCookie(f.cookies["manager"])
|
||||
response := httptest.NewRecorder()
|
||||
f.server.Handler().ServeHTTP(response, req)
|
||||
if response.Code != 200 || !strings.Contains(response.Body.String(), `"items":[]`) {
|
||||
t.Fatalf("支持页长或空页失败:%s", response.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectDirectoryAPIBoundaries(t *testing.T) {
|
||||
for _, mode := range []string{"missing_repository", "storage_error", "disabled_after_session", "revoked_after_session"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
f := newProjectMemberAPIFixture(t)
|
||||
status := 200
|
||||
switch mode {
|
||||
case "missing_repository":
|
||||
f.server.projectDirectory = nil
|
||||
status = 503
|
||||
case "storage_error":
|
||||
f.server.projectDirectory = projectDirectoryBoundary{err: errors.New("secret database detail")}
|
||||
status = 500
|
||||
case "disabled_after_session", "revoked_after_session":
|
||||
if mode == "disabled_after_session" {
|
||||
status = 403
|
||||
}
|
||||
f.server.projectDirectory = projectDirectoryBoundary{ProjectDirectoryRepository: f.members, before: func() {
|
||||
query := "DELETE FROM project_members WHERE user_id='manager'"
|
||||
if mode == "disabled_after_session" {
|
||||
query = "UPDATE users SET status='disabled' WHERE id='manager'"
|
||||
}
|
||||
if _, err := f.db.ExecContext(t.Context(), query); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}}
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/projects", nil)
|
||||
req.AddCookie(f.cookies["manager"])
|
||||
response := httptest.NewRecorder()
|
||||
f.server.Handler().ServeHTTP(response, req)
|
||||
if response.Code != status || strings.Contains(response.Body.String(), "secret database detail") {
|
||||
t.Fatalf("项目入口错误边界失效:%d,%s", response.Code, response.Body.String())
|
||||
}
|
||||
if mode == "revoked_after_session" && !strings.Contains(response.Body.String(), `"items":[]`) {
|
||||
t.Fatal("会话验证后撤权仍然可见项目")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectMemberAPIPrivateCache(t *testing.T) {
|
||||
f := newProjectMemberAPIFixture(t)
|
||||
for _, actor := range []string{"", "manager", "viewer"} {
|
||||
req := httptest.NewRequest(http.MethodGet, memberTestPath, nil)
|
||||
if actor != "" {
|
||||
req.AddCookie(f.cookies[actor])
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
f.server.Handler().ServeHTTP(response, req)
|
||||
if response.Header().Get("Cache-Control") != "private, no-store" || response.Header().Get("Vary") != "Cookie" {
|
||||
t.Fatalf("成员响应允许共享缓存:%s", actor)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -16,7 +16,7 @@ import (
|
||||
const projectMemberTargetKey = "project_member_target_id"
|
||||
|
||||
func (s *Server) registerProjectMemberRoutes() {
|
||||
api := s.router.Group("/api/v1/projects/:projectID/members")
|
||||
api := s.router.Group("/api/v1/projects/:projectID/members", privateProjectResponse)
|
||||
api.GET("", s.projectMemberFailureAudit("project_member.list"), s.requireAuthenticated, s.listProjectMembers)
|
||||
api.POST("", s.projectMemberFailureAudit("project_member.grant"), s.requireAuthenticated, s.grantProjectMember)
|
||||
api.DELETE("/:userID/roles/:role", s.projectMemberFailureAudit("project_member.revoke"), s.requireAuthenticated, s.revokeProjectMember)
|
||||
|
||||
@ -48,7 +48,7 @@ func newProjectMemberAPIFixture(t *testing.T) projectMemberAPIFixture {
|
||||
db: db.DB, members: repository.NewSQLProjectMemberRepository(db.DB, cfg.Database.Driver),
|
||||
audit: repository.NewSQLAuditRepository(db.DB, cfg.Database.Driver), cookies: make(map[string]*http.Cookie),
|
||||
}
|
||||
f.server = New(Dependencies{Config: cfg, Users: users, ProjectMembers: f.members, Audit: f.audit})
|
||||
f.server = New(Dependencies{Config: cfg, Users: users, ProjectMembers: f.members, ProjectDirectory: f.members, Audit: f.audit})
|
||||
for _, account := range []struct{ id, role string }{
|
||||
{"primary-super-admin", repository.UserRoleSuperAdmin}, {"manager", repository.UserRoleUser},
|
||||
{"global-admin", repository.UserRoleAdmin}, {"viewer", repository.UserRoleUser}, {"target", repository.UserRoleUser},
|
||||
|
||||
@ -49,6 +49,7 @@ type Dependencies struct {
|
||||
Servers repository.ServerRepository
|
||||
Users repository.UserRepository
|
||||
ProjectMembers repository.ProjectMemberRepository
|
||||
ProjectDirectory repository.ProjectDirectoryRepository
|
||||
SSHKeys *sshkey.Service
|
||||
DeploymentFactory deployment.ClientFactory
|
||||
SetupDNS setupDNSVerifier
|
||||
@ -81,6 +82,7 @@ type Server struct {
|
||||
servers repository.ServerRepository
|
||||
users repository.UserRepository
|
||||
projectMembers repository.ProjectMemberRepository
|
||||
projectDirectory repository.ProjectDirectoryRepository
|
||||
sshKeys *sshkey.Service
|
||||
deploymentFactory deployment.ClientFactory
|
||||
setupDNS setupDNSVerifier
|
||||
@ -142,6 +144,7 @@ func New(deps Dependencies) *Server {
|
||||
servers: deps.Servers,
|
||||
users: deps.Users,
|
||||
projectMembers: deps.ProjectMembers,
|
||||
projectDirectory: deps.ProjectDirectory,
|
||||
sshKeys: deps.SSHKeys,
|
||||
deploymentFactory: deps.DeploymentFactory,
|
||||
issueCertificate: deps.IssueCertificate,
|
||||
@ -190,6 +193,7 @@ func (s *Server) registerRoutes() {
|
||||
userAPI.PUT("/:id", s.requireSuperAdmin, s.updateUser)
|
||||
userAPI.DELETE("/:id", s.requireSuperAdmin, s.deleteUser)
|
||||
s.registerProjectMemberRoutes()
|
||||
s.router.GET("/api/v1/projects", privateProjectResponse, s.requireAuthenticated, s.listVisibleProjects)
|
||||
adminAPI.GET("/domains", s.listDomains)
|
||||
adminAPI.POST("/domains", s.dnsOperation, s.createDomain)
|
||||
adminAPI.PUT("/domains/:id", s.dnsOperation, s.updateDomain)
|
||||
|
||||
85
backend/internal/repository/project_directory.go
Normal file
85
backend/internal/repository/project_directory.go
Normal file
@ -0,0 +1,85 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ProjectDirectoryRepository 只返回当前有效账户有权读取的项目,不暴露基础仓储的全量列表。
|
||||
type ProjectDirectoryRepository interface {
|
||||
ListVisibleProjects(context.Context, string, string, int) (VisibleProjectPage, error)
|
||||
}
|
||||
|
||||
type VisibleProject struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CanManageMembers bool `json:"can_manage_members"`
|
||||
}
|
||||
|
||||
type VisibleProjectPage struct {
|
||||
Items []VisibleProject `json:"items"`
|
||||
PageSize int `json:"page_size"`
|
||||
HasMore bool `json:"has_more"`
|
||||
NextCursor string `json:"next_cursor"`
|
||||
}
|
||||
|
||||
var _ ProjectDirectoryRepository = (*SQLProjectMemberRepository)(nil)
|
||||
|
||||
func (r *SQLProjectMemberRepository) ListVisibleProjects(ctx context.Context, actorID, after string, limit int) (VisibleProjectPage, error) {
|
||||
page := VisibleProjectPage{Items: make([]VisibleProject, 0), PageSize: limit}
|
||||
if err := r.ensureDB(); err != nil {
|
||||
return page, err
|
||||
}
|
||||
if limit < 1 || limit > 100 || len(after) > 128 {
|
||||
return page, errors.New("项目分页参数无效")
|
||||
}
|
||||
// 授权、可见性过滤与读取共用一条 SQL;有效账户没有项目时仍返回授权门行。
|
||||
// 使用 EXISTS 避免一个账户的多个角色造成重复项目和错误分页。
|
||||
rows, err := r.db.QueryContext(ctx, Rebind(r.driver, `SELECT a.id, p.id, p.name, p.description,
|
||||
CASE WHEN a.role = 'super_admin' OR EXISTS (
|
||||
SELECT 1 FROM project_members manager WHERE manager.project_id = p.id
|
||||
AND manager.user_id = a.id AND manager.role = 'project_admin'
|
||||
) THEN 1 ELSE 0 END
|
||||
FROM users a LEFT JOIN projects p ON p.id > ? AND (
|
||||
a.role = 'super_admin' OR EXISTS (
|
||||
SELECT 1 FROM project_members member WHERE member.project_id = p.id AND member.user_id = a.id
|
||||
AND member.role IN ('project_admin', 'certificate_admin', 'deployment_admin', 'dns_admin', 'audit_viewer', 'read_only')
|
||||
)
|
||||
)
|
||||
WHERE a.id = ? AND a.status = 'active' AND a.role IN ('super_admin', 'admin', 'user')
|
||||
ORDER BY p.id LIMIT ?`), after, actorID, limit+1)
|
||||
if err != nil {
|
||||
return page, fmt.Errorf("查询可见项目失败:%w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
allowed := false
|
||||
for rows.Next() {
|
||||
var actor string
|
||||
var id, name, description sql.NullString
|
||||
var manager int
|
||||
if err := rows.Scan(&actor, &id, &name, &description, &manager); err != nil {
|
||||
return page, fmt.Errorf("读取可见项目失败:%w", err)
|
||||
}
|
||||
allowed = true
|
||||
if id.Valid {
|
||||
page.Items = append(page.Items, VisibleProject{
|
||||
ID: id.String, Name: name.String, Description: description.String, CanManageMembers: manager == 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return VisibleProjectPage{}, fmt.Errorf("读取可见项目结果失败:%w", err)
|
||||
}
|
||||
if !allowed {
|
||||
return page, ErrProjectAccessDenied
|
||||
}
|
||||
if len(page.Items) > limit {
|
||||
page.Items = page.Items[:limit]
|
||||
page.HasMore = true
|
||||
page.NextCursor = page.Items[limit-1].ID
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
150
backend/internal/repository/project_directory_test.go
Normal file
150
backend/internal/repository/project_directory_test.go
Normal file
@ -0,0 +1,150 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/database"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest"
|
||||
)
|
||||
|
||||
func TestProjectDirectorySQLite(t *testing.T) {
|
||||
checkProjectDirectory(t, config.Default(t.TempDir()).Database)
|
||||
}
|
||||
|
||||
func TestProjectDirectoryMySQLReal(t *testing.T) {
|
||||
server := mysqltest.New(t)
|
||||
checkProjectDirectory(t, server.Database(t))
|
||||
}
|
||||
|
||||
func checkProjectDirectory(t *testing.T, cfg config.DatabaseConfig) {
|
||||
t.Helper()
|
||||
db, err := database.Open(t.Context(), cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := database.Migrate(t.Context(), db.DB, cfg.Driver); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, user := range []struct{ id, role string }{
|
||||
{"root", "super_admin"}, {"manager", "user"}, {"global-admin", "admin"}, {"viewer", "user"},
|
||||
} {
|
||||
if _, err := db.DB.ExecContext(t.Context(), Rebind(cfg.Driver, `INSERT INTO users (id, username, password_hash, role, status)
|
||||
VALUES (?, ?, 'test-only-no-login', ?, 'active')`), user.id, user.id, user.role); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
projects := NewSQLProjectRepository(db.DB, cfg.Driver)
|
||||
repo := NewSQLProjectMemberRepository(db.DB, cfg.Driver)
|
||||
for i := 0; i < 12; i++ {
|
||||
id := fmt.Sprintf("visible-%02d", i)
|
||||
if err := projects.CreateProject(t.Context(), domain.Project{ID: id, Name: "测试项目", Description: "项目说明"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, role := range []domain.ProjectRole{domain.ProjectRoleAdmin, domain.ProjectRoleReadOnly} {
|
||||
if err := repo.GrantProjectRole(t.Context(), "root", id, "manager", role); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := repo.GrantProjectRole(t.Context(), "root", "visible-00", "viewer", domain.ProjectRoleReadOnly); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Run("visible_roles_and_empty_directory", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
actor string
|
||||
count int
|
||||
manage bool
|
||||
}{
|
||||
{"root", 13, true}, {"manager", 12, true}, {"viewer", 1, false}, {"global-admin", 0, false},
|
||||
} {
|
||||
page, err := repo.ListVisibleProjects(t.Context(), tc.actor, "", 30)
|
||||
if err != nil || page.Items == nil || len(page.Items) != tc.count || page.HasMore || page.NextCursor != "" {
|
||||
t.Fatalf("项目可见性错误:%s,%+v,%v", tc.actor, page, err)
|
||||
}
|
||||
for _, item := range page.Items {
|
||||
if item.CanManageMembers != tc.manage || (tc.actor != "root" && item.ID == domain.DefaultProjectID) {
|
||||
t.Fatalf("全局身份被错误映射为项目权限:%s,%+v", tc.actor, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("pagination_deduplicates_roles", func(t *testing.T) {
|
||||
for _, size := range []int{1, 5, 10, 15, 30} {
|
||||
after := ""
|
||||
seen := make(map[string]bool)
|
||||
for attempts := 0; ; attempts++ {
|
||||
if attempts > 12 {
|
||||
t.Fatal("项目分页游标没有前进")
|
||||
}
|
||||
page, err := repo.ListVisibleProjects(t.Context(), "manager", after, size)
|
||||
if err != nil || len(page.Items) > size || page.PageSize != size {
|
||||
t.Fatalf("项目分页无效:%+v,%v", page, err)
|
||||
}
|
||||
for _, item := range page.Items {
|
||||
if seen[item.ID] || item.ID <= after {
|
||||
t.Fatalf("项目重复或排序错误:%+v", item)
|
||||
}
|
||||
seen[item.ID] = true
|
||||
}
|
||||
if !page.HasMore {
|
||||
break
|
||||
}
|
||||
if page.NextCursor != page.Items[len(page.Items)-1].ID {
|
||||
t.Fatal("下一页游标不是最后项目")
|
||||
}
|
||||
after = page.NextCursor
|
||||
}
|
||||
if len(seen) != 12 {
|
||||
t.Fatalf("项目被截断或泄露:%d", len(seen))
|
||||
}
|
||||
}
|
||||
page, err := repo.ListVisibleProjects(t.Context(), "manager", "zzzz", 5)
|
||||
if err != nil || page.Items == nil || len(page.Items) != 0 {
|
||||
t.Fatalf("有效空页被拒绝:%+v,%v", page, err)
|
||||
}
|
||||
})
|
||||
t.Run("realtime_revocation_and_disabled_accounts", func(t *testing.T) {
|
||||
if err := repo.RevokeProjectRole(t.Context(), "root", "visible-00", "manager", domain.ProjectRoleAdmin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, err := repo.ListVisibleProjects(t.Context(), "manager", "", 5)
|
||||
if err != nil || len(page.Items) == 0 || page.Items[0].CanManageMembers {
|
||||
t.Fatalf("撤销管理角色未生效:%+v,%v", page, err)
|
||||
}
|
||||
if err := repo.RevokeProjectRole(t.Context(), "root", "visible-00", "manager", domain.ProjectRoleReadOnly); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, err = repo.ListVisibleProjects(t.Context(), "manager", "", 5)
|
||||
if err != nil || len(page.Items) == 0 || page.Items[0].ID == "visible-00" {
|
||||
t.Fatalf("已离开项目仍可见:%+v,%v", page, err)
|
||||
}
|
||||
for _, actor := range []string{"manager", "root"} {
|
||||
if _, err := db.DB.ExecContext(t.Context(), Rebind(cfg.Driver, "UPDATE users SET status='disabled' WHERE id=?"), actor); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := repo.ListVisibleProjects(t.Context(), actor, "", 5); !errors.Is(err, ErrProjectAccessDenied) {
|
||||
t.Fatalf("停用账户仍有权限:%v", err)
|
||||
}
|
||||
}
|
||||
for _, actor := range []string{"", "missing"} {
|
||||
if _, err := repo.ListVisibleProjects(t.Context(), actor, "", 5); !errors.Is(err, ErrProjectAccessDenied) {
|
||||
t.Fatalf("不存在身份没有被拒绝:%v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("invalid_limits_and_storage", func(t *testing.T) {
|
||||
for _, size := range []int{0, -1, 101} {
|
||||
if _, err := repo.ListVisibleProjects(t.Context(), "viewer", "", size); err == nil {
|
||||
t.Fatal("接受无效分页")
|
||||
}
|
||||
}
|
||||
if _, err := NewSQLProjectMemberRepository(nil).ListVisibleProjects(t.Context(), "viewer", "", 5); err == nil {
|
||||
t.Fatal("空数据库被视为成功")
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -1,5 +1,14 @@
|
||||
# Scert 开发任务清单
|
||||
|
||||
## 当前项目入口批次(2026-09-28 19:54)
|
||||
|
||||
- [x] 可见项目分页接口、管理员和普通项目管理员入口、独立成员页面、账户 ID 与禁止缓存保护已实现。
|
||||
- [x] 前端 198 项通过;最后源码后端专项 83 个叶子通过、4 条条件跳过,完整后端 1700 个叶子通过、54 条条件跳过;双版本 MySQL 三轮组合各 195 个叶子通过,静态检查、模块校验及双平台构建通过。
|
||||
- [x] 三轮专项竞态 249 个叶子通过、12 条外部 MySQL 条件跳过,退出码 0;已同步对应任务和验证文档。完整命令、证据和边界见支持矩阵第二十四节,当前任务以执行清单首节为准,提交状态以 Git 记录为准。
|
||||
- [ ] 真实浏览器、项目增删改、所有资源和排队任务权限、PostgreSQL 当前实库、Linux 原生及生产验收。
|
||||
|
||||
原始 44 项范围不缩减、总状态不升级。下方均为按当时源码记录的历史批次,其中成员页面和项目入口未交付的描述已被本节取代。
|
||||
|
||||
> 最新批次(2026-09-28 19:10):成员列表账户分页、授予/撤销接口、应用接线与失败审计已实现;修复审计错误误报 409,并补充验证会话认证后撤权/停用、请求取消、审计存储故障和 DELETE 输入拒绝。当前专项、双版本 MySQL 三轮、完整后端 1679 个叶子与双平台构建通过;成员三轮竞态 186 个叶子通过、9 条外部 MySQL 条件跳过,退出码 0。详细证据见支持矩阵第二十三节。成员页面、项目入口及完整资源/任务授权仍待完成,原始 44 项总状态不升级。下方各批次均为历史记录。
|
||||
|
||||
> 当前批次(2026-09-28):成功成员变更与审计同事务写入已实现,审计失败回滚;源码检查点不是正式发布。SQLite 候选为 v1.59.0,最后源码取消查询/WAL 专项、完整后端、双版本 MySQL 三轮组合及双平台构建通过;原 MySQL 索引异常根因未关闭。最新任务状态、最终竞态和证据见执行清单首节与支持矩阵第二十二节,不复用旧批次数字。下一开发重点为成员 Web/API、分页、失败审计、业务资源入口与排队任务重新判权。
|
||||
|
||||
@ -1,16 +1,20 @@
|
||||
# Scert 整改任务执行清单
|
||||
|
||||
## 2026 年 9 月 28 日:项目入口与成员页面(19:17 开始,实施中)
|
||||
## 2026 年 9 月 28 日:项目入口与成员页面(19:17 开始,19:54 回归完成,等待复审)
|
||||
|
||||
需求确认:承接现有成员接口,提供当前账户可见项目的入口和成员管理页面;普通用户担任项目管理员时也必须能够进入,不放开整个管理员控制台,不暴露无权限的项目或全站账户目录。保留原始 44 项范围,本批子项不等于 P0-07 整项完成。
|
||||
|
||||
- [x] 核对真实工作区、成员接口、账户可见性和普通用户工作台;上一批结果只作为基线。
|
||||
- [~] 保存本批任务清单,提交推送修改前检查点。
|
||||
- [ ] 新增服务端过滤的可见项目分页接口,实时检查账户状态与项目角色,不写死默认项目。
|
||||
- [ ] 新增独立拆分的项目成员页面,接入管理员导航和普通用户工作台;账户建议沿用已有可见性边界,未知账户使用明确的账户 ID,不开放全站搜索。
|
||||
- [ ] 支持每页 5、10、15、30 位成员、多角色授予/撤销和撤权处理,切换项目、分页及卸载后旧请求不得污染新页面。
|
||||
- [ ] 补充仓储、HTTP 与前端正式测试,执行可用的构建与验证;浏览器、实库及生产验收未执行的部分单独标记。
|
||||
- [ ] 同步总清单、模型和接口交接,按文件精确提交推送;项目创建编辑、完整资源隔离、排队任务重判和生产验收仍保留待办。
|
||||
- [x] 保存本批任务清单;修改前检查点 `af77981` 已提交推送,命令行认证失败后经 Git 专用工具成功推送。
|
||||
- [x] 新增服务端过滤的可见项目分页接口,实时检查账户状态与项目角色,不写死默认项目;项目和成员响应禁止缓存,最后修改后的专项与完整回归已通过。
|
||||
- [x] 新增独立拆分的项目成员页面,接入管理员导航和普通用户工作台;账户建议沿用已有可见性边界,未知账户使用明确的账户 ID,不开放全站搜索。账户页补充可分享的账户 ID。
|
||||
- [x] 支持每页 5、10、15、30 位成员、多角色授予/撤销、冲突刷新和撤权清空;切换项目、分页及卸载后的旧响应隔离已覆盖正式前端测试。
|
||||
- [x] 前端完整回归 198 项通过、0 失败、0 跳过;包含真实 Vue 模板渲染而非浏览器测试,前端生产构建通过。首轮导航测试三处失败保留,不覆盖历史日志。
|
||||
- [x] 最后源码专项 83 个叶子通过、4 条外部 MySQL 条件跳过;完整后端 1700 个叶子通过、54 条条件跳过,均无失败。静态检查、模块校验及双平台构建退出码均为 0;证据使用新建的最终日志,不以缓存保护修改前的记录代替。
|
||||
- [x] MySQL 5.7/8.0 三轮成员/目录组合各 195 个叶子通过、0 失败、0 跳过,退出码均为 0;含 SQLite 用例,不称纯 MySQL 测试。
|
||||
- [x] 最后源码三轮专项竞态 249 个叶子通过、0 失败、12 条外部 MySQL 条件跳过,退出码 0;不称全仓库或实库竞态通过,不用上一批记录替代。
|
||||
- [x] 同步总清单 P0-07 对应说明、执行清单、模型、开发清单、审查记录、基础规格与第二版接口交接,补齐支持矩阵第二十四节及最终候选哈希;旧批次明确标为历史,不再用顶部新增记录掩盖对应条目的过时说明。提交和推送状态以 Git 记录为准,产物及隔离数据库不纳入提交。
|
||||
- [ ] 本批真实浏览器验收、项目创建编辑、完整资源隔离、排队任务重判、PostgreSQL 当前实库、Linux 原生和生产验收仍保留待办。
|
||||
|
||||
## 2026 年 9 月 28 日:项目成员接口与分页(19:10 同步,本批回归通过,等待复审)
|
||||
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
# Scert 整改、合并与优化清单
|
||||
|
||||
## 正在实施(2026 年 9 月 28 日 19:17,项目入口与成员页面)
|
||||
## 当前进度(2026 年 9 月 28 日 19:54,项目入口与成员页面本批回归完成,等待复审)
|
||||
|
||||
- [~] 接续成员接口,新增实时授权的可见项目入口,以及管理员和普通项目管理员均可使用的成员页面。
|
||||
- [ ] 本批源码、回归及页面验证尚未完成;具体任务见《Scert整改任务执行清单》首节。
|
||||
- [x] 已新增实时授权的可见项目分页接口,以及管理员和普通项目管理员均可使用的成员页面;不暴露全站账户目录,不写死默认项目。
|
||||
- [x] 前端完整回归 198 项通过、0 失败,包含模板渲染和旧请求隔离;前端构建通过,不等于浏览器或生产验收。
|
||||
- [x] 最后缓存保护修改后的专项 83 个叶子通过、4 条实库条件跳过;完整后端 1700 个叶子通过、54 条条件跳过,均无失败。静态检查、模块校验、Windows 构建和 Linux 交叉构建均退出 0。
|
||||
- [x] MySQL 5.7/8.0 三轮目录/成员组合各 195 个叶子通过,0 失败、0 跳过;包含 SQLite 用例,不称纯 MySQL 测试。
|
||||
- [x] 本批三轮专项竞态 249 个叶子通过、0 失败、12 条外部 MySQL 条件跳过,退出码 0;总清单对应条目及相关文档已同步。精确范围见《构建版本与数据库支持矩阵》第二十四节,不称全仓库或实库竞态通过。
|
||||
- [ ] 真实浏览器、项目创建编辑、全资源与排队任务权限、PostgreSQL 当前实库、Linux 原生及生产验收仍未完成。
|
||||
|
||||
下方 19:10 是最近已完成回归的批次记录,不代表本批新实现通过;原始 44 项状态不升级,生产验收仍未通过。
|
||||
原始 44 项仍为:复审通过 0、完成待复审 3、进行中 27、阻塞 3、未开始 11。页面实现与本地回归属于子项进展,不升级 P0-07 整项状态,生产验收仍未通过。当前待办以本节、P0-07 对应条目及执行清单首节为准;下方各批次保留历史证据,不覆盖本节。
|
||||
|
||||
## 当前进度(2026 年 9 月 28 日 19:10,成员接口本批回归通过,等待复审)
|
||||
## 历史进度快照(2026 年 9 月 28 日 19:10,成员接口回归通过,等待复审)
|
||||
|
||||
本批继续推进成员接口、分页及失败审计;实现与错误分类修复已保存到检查点 `3857dac`,随后增加正式边界测试。已重新执行当前源码专项、双版本 MySQL、完整后端及构建,不用上一批数据库候选结果替代本批证据。本批不清理现有业务库、不进行浏览器或远端部署测试。
|
||||
|
||||
@ -33,7 +37,7 @@
|
||||
| `[!]` 暂缓或存在阻塞 | 3 | P0-11、P2-08、P2-09 |
|
||||
| `[ ]` 未开始 | 11 | P1-10 及 P3 的 10 项 |
|
||||
|
||||
当前整体验收仍未通过。下方章节按各自时间保留为历史快照,当前待办以本节与《Scert整改任务执行清单.md》首节为准。
|
||||
本节是 19:10 的历史批次记录,表内总项数量仍有效,但本节尚未交付的成员页面与项目入口已在后续批次实现。当前子项状态以本文顶部、P0-07 对应条目及《Scert整改任务执行清单.md》首节为准,整体验收仍未通过。
|
||||
|
||||
## 历史进度快照(2026 年 9 月 28 日 18:36,数据库候选回归完成,整体验收未通过)
|
||||
|
||||
@ -370,7 +374,7 @@ backup_records 备份记录
|
||||
|
||||
**状态:** `[~] 进行中`
|
||||
|
||||
**最新进度(2026-09-28 19:10):** 成员列表分页、授予/撤销 API、实际应用接线和失败审计已实现,审计失败误报冲突问题修复后专项、双版本 MySQL 三轮、完整后端、成员专项三轮竞态与双平台构建通过,等待复审。完整证据及跳过项见支持矩阵第二十三节。成员管理页面、项目入口、全部资源入口与排队任务执行/取消/重试重判仍未完成,P0-07 不升级为完成;成员接口通过不等于完整项目隔离或生产验收通过。
|
||||
**最新进度(2026-09-28 19:54):** 成员列表、授予/撤销 API、事务及失败审计已实现;新增可见项目分页入口、管理员和普通项目管理员的成员页面、账户 ID 展示及禁止缓存保护。前端 198 项通过,最后源码后端专项 83 个叶子通过、4 条条件跳过,完整后端 1700 个叶子通过、54 条条件跳过;双版本 MySQL 三轮组合各 195 个叶子通过,静态检查、模块校验及双平台构建通过。本批三轮专项竞态 249 个叶子通过、12 条外部 MySQL 条件跳过,退出码 0;证据见支持矩阵第二十四节。真实浏览器、项目创建编辑删除、全部资源入口和排队任务执行/取消/重试重判仍未完成,P0-07 保持进行中;页面实现与本地回归不等于完整项目隔离或生产验收。
|
||||
|
||||
建议角色:
|
||||
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
# 全面审查、测试与优化记录
|
||||
|
||||
## 当前项目入口与成员页面复核(2026-09-28 19:54)
|
||||
|
||||
可见项目和成员页面已实现。前端最终 198 项通过、0 失败、0 跳过,生产构建成功;首轮导航数量与组件注册的三处测试失败已修复并保留日志。最后新增缓存保护后重新构建产品程序并重跑,后端专项 83 个叶子通过、4 条外部 MySQL 条件跳过,完整后端 1700 个叶子通过、54 条条件跳过;静态检查、模块校验及双平台构建退出码均为 0。MySQL 5.7/8.0 三轮成员和目录组合各 195 个叶子通过、0 失败、0 跳过,含 SQLite 用例。
|
||||
|
||||
本批三轮专项竞态 249 个叶子通过、0 失败、12 条外部 MySQL 条件跳过,退出码 0,不称全仓库或实库竞态通过;最终证据、命令和候选哈希集中在支持矩阵第二十四节。未进行真实浏览器、远端或生产部署验收,不以模板渲染、条件跳过或 Linux 交叉构建替代实机。下方历史批次只证明各自当时源码,不覆盖当前结果。
|
||||
|
||||
> 最新成员接口批次(2026-09-28 19:10):首轮审计触发器失败被误映射为 409,原失败日志保留;修复后增加 DELETE 输入拒绝、认证后撤权/停用、取消请求审计、审计存储故障和三驱动错误分类正式测试。专项 62 个叶子通过、3 条实库条件跳过;MySQL 5.7/8.0 三轮各 171 个叶子通过且无失败跳过,包含 SQLite 用例;完整后端 1679 个叶子通过、53 条条件跳过,静态检查、模块校验和双平台构建通过。成员三轮竞态 186 个叶子通过、9 条外部 MySQL 条件跳过,退出码 0;完整证据见支持矩阵第二十三节,不复用下方历史竞态数字。本批没有浏览器或远端部署验收。
|
||||
|
||||
> 最终候选结果同步(2026-09-28 18:36):完整后端 1635 个叶子通过、52 条条件跳过;MySQL 5.7/8.0 三轮组合各 531 个叶子通过且无失败跳过(包含 SQLite 用例);最后源码取消查询/WAL 专项共 30 个叶子通过,服务并发关闭另 30 次通过;最终三轮竞态 831 个叶子通过、15 条条件跳过。上述进程退出码均为 0。Windows 独立程序历史备份/升级/成员恢复、静态检查、模块校验及双平台构建通过,完整证据见支持矩阵第二十二节。旧索引异常根因仍未关闭,跳过与交叉构建不作为实机验收通过。
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
# 基础整改任务规格
|
||||
|
||||
> 当前进度(2026-09-28 19:54):P0-07 的成员 API、成功事务审计、失败请求审计、可见项目分页入口和成员 Web 页面均已实现;普通项目管理员可从自己的工作台进入,项目和成员响应禁止缓存,账户建议不扩大原有用户可见性。最后源码专项、完整后端、前端、双版本 MySQL 组合及三轮专项竞态已通过,条件跳过和构建证据见支持矩阵第二十四节。真实浏览器、项目增删改、全业务资源及排队任务授权仍未交付或验收;P0-07 保持进行中,原始六阶段与生产门禁不变。下方未接入成员 Web/API 的表述仅对应历史批次。
|
||||
|
||||
> 当前补充(2026-09-28):P0-07/P1-09 的成功成员变更与审计同事务实现已在检查点保存,审计失败必须回滚;拒绝请求审计、成员 Web/API 和业务授权仍未交付。P0-04/P0-05 最终驱动候选的完整后端、SQLite 取消查询与 WAL 专项、双版本 MySQL 组合及 Windows 独立程序恢复已通过。精确结果、竞态与未关闭项见支持矩阵第二十二节,不沿用旧批次放行;各总项状态不升级。
|
||||
|
||||
> 本批进度(模式 14):P0-07 已实现项目成员与六类角色、按实时账户状态和关系判权、单语句授权变更;P0-06 增加成员引用约束。用户分组不隐式授予权限,普通用户不能读取完整私钥。成员 Web/API、审计和全部资源/任务授权链仍未接入;P0-05/P0-06/P0-07 保持 `[~]`。证据与边界见执行清单首节、模型第九节和支持矩阵第二十一节;下方为历史批次。
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
# 构建版本与数据库支持矩阵
|
||||
|
||||
首次核查日期:2026 年 9 月 27 日;成员接口批次的最新结果见第二十三节。本文区分代码支持、隔离测试和正式生产准入,不把驱动存在视为兼容性验收。
|
||||
首次核查日期:2026 年 9 月 27 日;项目入口与成员页面的最新结果见第二十四节。本文区分代码支持、隔离测试和正式生产准入,不把驱动存在视为兼容性验收。
|
||||
|
||||
运行时候选选型依据见第二十二节,新增成员接口后的验证另列第二十三节;此前各节保留各自批次的源码、依赖和环境证据,不据此推断新代码已完成相同范围的验证。
|
||||
运行时候选选型依据见第二十二节,成员接口验证见第二十三节,项目入口与页面验证见第二十四节;此前各节保留各自批次的源码、依赖和环境证据,不据此推断新代码已完成相同范围的验证。
|
||||
|
||||
## 一、工具链与依赖
|
||||
|
||||
@ -1104,3 +1104,85 @@ go test -race -p 1 -json -count=3 -timeout=12m -run 'ProjectMember|RequireAuthen
|
||||
- [ ] PostgreSQL 当前实库、Linux 原生、三库完整恢复、完整发行包和十二层生产门禁。
|
||||
|
||||
P0-07 与其他未满足原始验收标准的总项仍为 `[~]`,不按新增测试数量折算整体完成率。
|
||||
|
||||
## 二十四、项目入口与成员页面验证(2026-09-28)
|
||||
|
||||
### 24.1 源码与验证边界
|
||||
|
||||
修改前检查点为 `af77981`,本批新增当前身份可见项目的分页接口、成员页面及普通用户工作台入口。全量基础项目仓储不直接暴露给 HTTP;账户状态、合法全局角色、项目角色和能力提示在同一条 SQL 中判定。成员调用继续实时判权,用户分组不隐式赋权。项目和成员成功、认证失败及拒绝响应禁止缓存。
|
||||
|
||||
本批最后的缓存保护晚于首轮完整后端启动,因此 `backend.jsonl` 和早期构建记录仅为中间证据;最后源码重新构建产品程序,再运行 `focused-final.jsonl`、`backend-final.jsonl`、静态检查、模块校验和双平台构建,已取回各步退出码 0。MySQL 两轮版本测试之后仓储源码未变化,后续修改仅涉及 HTTP 缓存保护。
|
||||
|
||||
证据目录:`artifacts/project-members-ui-20260928/`。本批不清理业务库、不操作 DNS 或远端、不创建辅助验收脚本;正式测试使用隔离库。日志、数据库和二进制均在既有忽略目录,不提交。
|
||||
|
||||
### 24.2 已核对结果
|
||||
|
||||
| 证据 | 结果 | 边界 |
|
||||
|---|---|---|
|
||||
| `frontend-first.log` | 首轮 175 项,172 通过、3 失败 | 新导航导致旧数量断言及组件注册失败,失败记录保留 |
|
||||
| `frontend-final.log` | 198 通过,0 失败、0 跳过 | 含真实 Vue 模板渲染、角色操作、错误分支和迟到响应隔离,不等于浏览器验收 |
|
||||
| `frontend-build-final.log` | 生产构建通过,顺序命令退出 0 | 构建成功不代表运行布局通过 |
|
||||
| `focused-final.jsonl` | 83 个叶子通过、0 失败、4 条条件跳过,退出 0 | 仓储、HTTP、实际应用接线与认证专项 |
|
||||
| `mysql57.jsonl` | 三轮共 195 个叶子通过,0 失败、0 跳过,退出 0 | 目录与成员组合,包含 SQLite 用例 |
|
||||
| `mysql80.jsonl` | 三轮共 195 个叶子通过,0 失败、0 跳过,退出 0 | 与 5.7 同范围,两个版本顺序执行 |
|
||||
| `backend-final.jsonl` | 1700 个叶子通过,0 失败、54 条条件跳过,退出 0 | 使用最后源码构建的 Windows 独立产品程序执行历史升级与备份恢复测试 |
|
||||
| `vet-final.log`、`modules-final.log` | 静态检查及模块校验均退出 0 | 模块校验明确返回全部模块验证成功 |
|
||||
| `race-final.jsonl` | 三轮共 249 个叶子通过、0 失败、12 条条件跳过,退出 0 | 仓储、HTTP、实际应用接线与认证专项,不是全仓库或实库竞态 |
|
||||
|
||||
专项的四条条件跳过分别为目录、成员变更、成员列表和成员角色的外部 MySQL 测试;由上述实库组合单独提供证据。三轮竞态中同样四条各跳过三次,合计 12 条,不将普通实库测试当作实库竞态。完整回归中的平台、外部数据库与远端前置条件跳过不计通过,不能推断 Linux、PostgreSQL 或生产验收完成。
|
||||
|
||||
### 24.3 可复核命令
|
||||
|
||||
前端在仓库根目录执行 `npm.cmd --prefix frontend test` 和 `npm.cmd --prefix frontend run build`。以下后端命令在 `backend/` 执行,逐项核对退出码,失败即停止后续步骤;PowerShell 重定向日志按 Unicode 读取。
|
||||
|
||||
```powershell
|
||||
$env:GOMAXPROCS = '2'
|
||||
$env:CGO_ENABLED = '0'
|
||||
$env:GOOS = 'windows'
|
||||
$env:GOARCH = 'amd64'
|
||||
go build -p 1 -o ../build/review-scert-project-ui-windows-amd64.exe ./cmd/scert
|
||||
$env:SCERT_SQLITE_TEST_CLI = 'D:\Desktop\Scert\build\review-scert-project-ui-windows-amd64.exe'
|
||||
go test -p 1 -json -count=1 -timeout=6m -run 'ProjectDirectory|ProjectMember|VisibleProject|RequireAuthenticated' ./internal/repository ./internal/httpapi ./internal/app
|
||||
go test -p 1 -json -count=1 -timeout=12m ./... > ../artifacts/project-members-ui-20260928/backend-final.jsonl
|
||||
go vet -p 1 ./...
|
||||
go mod verify
|
||||
go build -p 1 -o ../build/review-scert-project-ui-windows-amd64.exe ./cmd/scert
|
||||
$env:GOOS = 'linux'
|
||||
go build -p 1 -trimpath -o ../build/review-scert-project-ui-linux-amd64 ./cmd/scert
|
||||
```
|
||||
|
||||
MySQL 组合分别在独立命令环境设置 `SCERT_MYSQL_TEST_BIN` 为 `E:\DB\5.7\bin`、`E:\DB\8.0\bin`,`SCERT_MYSQL_TEST_ROOT` 为本批证据目录下的 `mysql57`、`mysql80`,顺序执行:
|
||||
|
||||
```powershell
|
||||
go test -p 1 -json ./internal/repository -run 'TestProjectDirectory|TestProjectMember' -count=3
|
||||
```
|
||||
|
||||
完整后端结束后,另一个未配置外部 MySQL 的命令进程执行本批三轮竞态:
|
||||
|
||||
```powershell
|
||||
$env:GOMAXPROCS = '2'
|
||||
$env:CGO_ENABLED = '1'
|
||||
$env:GOOS = 'windows'
|
||||
$env:GOARCH = 'amd64'
|
||||
go test -race -p 1 -json -count=3 -timeout=12m -run 'ProjectDirectory|ProjectMember|VisibleProject|RequireAuthenticated' ./internal/repository ./internal/httpapi ./internal/app > ../artifacts/project-members-ui-20260928/race-final.jsonl
|
||||
```
|
||||
|
||||
### 24.4 最后候选构建
|
||||
|
||||
| 目标 | SHA256 |
|
||||
|---|---|
|
||||
| Windows amd64 | `3E1E29DB5887D1BF4A1E0C99FD9ADF9C45A5F9CCE4720CC0FEAA2FD3825AB92B` |
|
||||
| Linux amd64 | `B9871674ECC41E4DF8A18F71A3B61644A5BCAC9731E808500E6CEC021F89DC98` |
|
||||
|
||||
两次最终构建均退出 0,Linux 为交叉构建,不是 Linux 原生运行或完整发行包验收。候选哈希只定位本批产物,不替代源码提交号与生产证据。
|
||||
|
||||
### 24.5 未关闭事项
|
||||
|
||||
- [x] 本批三轮专项竞态及对应进度、证据文档同步完成,等待复审;提交和推送状态以 Git 记录为准。
|
||||
- [ ] 真实浏览器布局和完整交互验收;接口交接见 `.claude/docs/ai/project-members/api-handoff-v2.md`。
|
||||
- [ ] 项目创建、编辑、删除及其事务审计。
|
||||
- [ ] 全资源入口及排队任务执行、取消、重试的权限重判和跨项目拒绝测试。
|
||||
- [!] 旧 MySQL Error 1280 的根因仍未关闭,本批实库成功不替代历史异常根因分析。
|
||||
- [ ] PostgreSQL 当前实库、Linux 原生、三库完整恢复、完整发行包和生产门禁。
|
||||
|
||||
原始 44 项范围及验收标准不缩减;P0-07 保持 `[~]`,生产部署验收未通过。
|
||||
|
||||
@ -1,5 +1,18 @@
|
||||
# 项目资源关联模型
|
||||
|
||||
> 当前入口批次(2026-09-28 19:54):已新增服务端实时过滤的可见项目分页接口和独立成员页面,管理员导航与普通用户工作台均有入口;账户建议沿用既有用户可见性,不开放全站搜索。见本文件第十节。最后源码后端专项、完整回归、双版本 MySQL 组合、前端测试、三轮专项竞态及双平台构建已通过;条件跳过和验证范围见任务执行清单首节和支持矩阵第二十四节。真实浏览器、项目增删改和全资源权限链仍待验收,下方早期批次未接入界面的描述仅为历史。
|
||||
|
||||
## 十、可见项目入口与成员页面
|
||||
|
||||
- `GET /api/v1/projects` 通过独立 `ProjectDirectoryRepository` 读取,不直接暴露基础仓储的全量 `ListProjects`。账户状态、合法全局角色、项目成员角色与能力提示在同一 SQL 中判定,用户分组不隐式授予项目权限。
|
||||
- 有效超级管理员可见全部真实项目;普通管理员、普通用户仅可见自己持有合法项目角色的项目。普通用户担任项目管理员时也可管理成员;其他项目角色只能看到项目基本资料,不能取得成员目录。
|
||||
- 项目和成员均使用游标分页,支持 5、10、15、30;多个角色不产生重复项目,也不将一个成员拆到不同页。合法空项目列表与认证、权限、存储错误分开。
|
||||
- `can_manage_members` 仅为界面提示,不是后续请求的授权凭证;所有成员操作继续服务端实时判权。项目和成员响应明确禁止缓存,防止旧身份或旧权限的响应被复用。
|
||||
- 前端分拆为视图、样式、目录逻辑、成员逻辑与请求封装;普通用户工作台保持独立,不放开全局管理员资源接口。
|
||||
- 账户建议只读取已有可见用户和本页有效成员;未知目标由本人提供账户 ID。用户管理页显示已有可见账户 ID,不新增账户隐私范围。
|
||||
- 切换项目、分页和卸载时终止旧读取并递增请求代次;旧成员、账户建议及变更响应不能污染新项目。撤销自己的管理角色后清空成员与账户建议,后续刷新项目重新查询能力。
|
||||
- 项目创建、编辑、删除,所有业务资源的权限接入,以及排队任务执行、取消、重试重判仍未完成;不能据成员页面上线宣称 P0-07 完成。
|
||||
|
||||
> 当前补充(2026-09-28):模式仍为 14。项目成员成功授予/撤销已与审计在同一事务实现,审计失败或未实际写入时回滚成员变化;MySQL 要求成员表和审计表均为 InnoDB。不是失败请求审计,也不等于 Web/API 与任务授权闭环。最终驱动候选的完整后端、双版本 MySQL 组合及 Windows 独立程序恢复已通过;最终证据、竞态结果与未关闭项以执行清单首节及支持矩阵第二十二节为准,下方旧批次数字不替代当前验证。
|
||||
|
||||
> 当前模式为 14:已新增项目成员多角色关系、实时判权及原子授予/撤销,见第九节。成员不是用户分组,不向历史账户自动授予默认项目权限。该模块尚未接入完整 Web/API 与任务执行链,不能据此宣称资源隔离完成。第七、八节及下方模式 13 提示是上一批历史记录;本批证据见支持矩阵第二十一节。
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test ../build/toolchain.test.mjs src/App.test.js src/views/PasswordReset/PasswordReset.test.js src/views/SetupWizard/SetupWizard.test.js src/views/SetupWizard/SetupWizardBootstrap.test.js src/views/SetupWizard/DatabaseSetup.test.js src/views/SetupWizard/SetupWizardLayout.test.js src/views/SetupWizard/BootstrapPayload.test.js src/views/Dashboard/Dashboard.test.js src/views/Dashboard/SettingsModal.test.js src/views/UserManagement/UserManagement.test.js src/views/components/HelpTip/HelpTip.test.js",
|
||||
"test": "node --test ../build/toolchain.test.mjs src/App.test.js src/views/PasswordReset/PasswordReset.test.js src/views/SetupWizard/SetupWizard.test.js src/views/SetupWizard/SetupWizardBootstrap.test.js src/views/SetupWizard/DatabaseSetup.test.js src/views/SetupWizard/SetupWizardLayout.test.js src/views/SetupWizard/BootstrapPayload.test.js src/views/Dashboard/Dashboard.test.js src/views/Dashboard/SettingsModal.test.js src/views/UserManagement/UserManagement.test.js src/views/ProjectManagement/ProjectManagement.test.js src/views/components/HelpTip/HelpTip.test.js",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
|
||||
@ -11,7 +11,7 @@ import { aliyunProvider, deferred, mountComposable, verifiedResponse } from "../
|
||||
test("板块导航默认只选中基本信息,超级管理员可切换全部板块", () => {
|
||||
const navigation = useDashboardNavigation(ref({ role: "super_admin" }));
|
||||
assert.equal(navigation.activeSection.value, "overview");
|
||||
assert.equal(navigation.sections.value.length, 7);
|
||||
assert.equal(navigation.sections.value.length, 8);
|
||||
for (const section of navigation.sections.value) {
|
||||
navigation.selectSection(section.id);
|
||||
assert.equal(navigation.activeSection.value, section.id);
|
||||
@ -32,17 +32,17 @@ test("用户管理导航按权限显示,权限变化后回到基本信息", ()
|
||||
assert.equal(navigation.activeSection.value, "users");
|
||||
user.value = { role: "admin" };
|
||||
assert.equal(navigation.activeSection.value, "users");
|
||||
assert.equal(navigation.sections.value.length, 7);
|
||||
assert.equal(navigation.sections.value.length, 8);
|
||||
user.value = null;
|
||||
assert.equal(navigation.activeSection.value, "overview");
|
||||
assert.equal(navigation.sections.value.length, 5);
|
||||
assert.equal(navigation.sections.value.length, 6);
|
||||
});
|
||||
|
||||
test("单板块布局使用互斥条件,切换重置滚动且保留弹窗入口", async () => {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const template = await readFile(new URL("./Dashboard.vue", import.meta.url), "utf8");
|
||||
const stylesheet = await readFile(new URL("./DashboardLayout.css", import.meta.url), "utf8");
|
||||
for (const id of ["overview", "activity", "domains", "servers", "keys", "dns-accounts", "users"]) {
|
||||
for (const id of ["overview", "activity", "projects", "domains", "servers", "keys", "dns-accounts", "users"]) {
|
||||
assert.ok(template.includes(`v-if="activeSection === '${id}'"`));
|
||||
}
|
||||
assert.match(template, /:key="activeSection"/);
|
||||
@ -95,6 +95,7 @@ test("完整页面逐个渲染六个板块,每次只有一个内容标题", as
|
||||
const headings = {
|
||||
overview: "基本信息",
|
||||
activity: "运行概览",
|
||||
projects: "项目与成员",
|
||||
domains: "托管域名",
|
||||
servers: "部署服务器",
|
||||
keys: "服务器 SSH 密钥",
|
||||
@ -111,6 +112,7 @@ test("完整页面逐个渲染六个板块,每次只有一个内容标题", as
|
||||
app.component(name, { render: () => null });
|
||||
}
|
||||
app.component("UserManagement", { render: () => Vue.h("h2", headings.users) });
|
||||
app.component("ProjectManagement", { render: () => Vue.h("h2", headings.projects) });
|
||||
app.component("DNSAccounts", { render: () => Vue.h("h2", headings["dns-accounts"]) });
|
||||
app.component("DeploymentResult", { render: () => null });
|
||||
const warnings = [];
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import UserManagement from "../UserManagement/UserManagement.vue";
|
||||
import ProjectManagement from "../ProjectManagement/ProjectManagement.vue";
|
||||
import DNSAccounts from "../DNSAccounts/DNSAccounts.vue";
|
||||
import SettingsModal from "./SettingsModal.vue";
|
||||
import DomainModal from "./DomainModal.vue";
|
||||
@ -328,6 +329,7 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
|
||||
</div>
|
||||
|
||||
<UserManagement v-if="activeSection === 'users'" />
|
||||
<ProjectManagement v-if="activeSection === 'projects'" />
|
||||
<DNSAccounts v-if="activeSection === 'dns-accounts'" :refresh-key="settings" />
|
||||
|
||||
<section v-if="activeSection === 'activity'" class="panel operations-panel">
|
||||
|
||||
@ -3,6 +3,7 @@ import { computed, ref } from "vue";
|
||||
export const dashboardSections = [
|
||||
{ id: "overview", label: "基本信息", group: "总览", hint: "查看用户、域名状态、证书到期时间和部署服务器" },
|
||||
{ id: "activity", label: "运行概览", group: "总览", hint: "查看证书有效期、续期覆盖和任务执行情况" },
|
||||
{ id: "projects", label: "项目与成员", group: "证书与部署", hint: "查看有权访问的项目并管理项目成员角色" },
|
||||
{ id: "domains", label: "域名配置", group: "证书与部署", hint: "管理域名、申请证书和执行部署" },
|
||||
{ id: "servers", label: "部署服务器", group: "证书与部署", hint: "配置部署目标及 SSH 认证方式" },
|
||||
{ id: "keys", label: "SSH 私钥", group: "证书与部署", hint: "管理加密保存的 SSH 私钥" },
|
||||
|
||||
230
frontend/src/views/ProjectManagement/ProjectManagement.css
Normal file
230
frontend/src/views/ProjectManagement/ProjectManagement.css
Normal file
@ -0,0 +1,230 @@
|
||||
.project-management {
|
||||
padding: 22px;
|
||||
color: #25344d;
|
||||
border: 1px solid #dfe6f0;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.project-heading,
|
||||
.project-toolbar,
|
||||
.project-pagination,
|
||||
.project-confirm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.project-management h2,
|
||||
.project-management h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.project-heading {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.project-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 260px) minmax(0, 1fr);
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.project-directory,
|
||||
.project-members,
|
||||
.project-title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-directory {
|
||||
padding-right: 20px;
|
||||
border-right: 1px solid #e5eaf2;
|
||||
}
|
||||
|
||||
.project-management button,
|
||||
.project-management input,
|
||||
.project-management select {
|
||||
min-height: 36px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #d5ddea;
|
||||
border-radius: 8px;
|
||||
color: inherit;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.project-management button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.project-management button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.project-management :focus-visible {
|
||||
outline: 2px solid #3664c3;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.project-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 16px 0;
|
||||
max-height: 48vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.project-list button {
|
||||
text-align: left;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.project-list button[aria-current="true"] {
|
||||
border-color: #4c74cc;
|
||||
background: #edf3ff;
|
||||
}
|
||||
|
||||
.project-management small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: #68788f;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.project-title h3 {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.project-grant-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(160px, 1fr) minmax(145px, 200px) auto;
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
margin: 18px 0;
|
||||
}
|
||||
|
||||
.project-grant-form label {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.project-grant-form input {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-management .project-primary {
|
||||
color: #fff;
|
||||
border-color: #3664c3;
|
||||
background: #3664c3;
|
||||
}
|
||||
|
||||
.project-help {
|
||||
color: #68788f;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.project-member-table {
|
||||
max-height: 46vh;
|
||||
overflow: auto;
|
||||
border: 1px solid #e5eaf2;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.project-member-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.project-member-table th,
|
||||
.project-member-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #edf0f6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.project-member-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #f5f7fb;
|
||||
}
|
||||
|
||||
.project-role-list {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.project-role {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 6px;
|
||||
background: #f0f4fb;
|
||||
}
|
||||
|
||||
.project-role button {
|
||||
min-height: 28px;
|
||||
padding: 2px 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.project-pagination {
|
||||
margin-top: 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.project-member-pagination {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.project-member-pagination label {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.project-empty {
|
||||
padding: 24px 10px;
|
||||
color: #68788f;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.project-error {
|
||||
color: #a12c3b;
|
||||
}
|
||||
|
||||
.project-notice {
|
||||
color: #247049;
|
||||
}
|
||||
|
||||
.project-confirm {
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 14px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: #fff4df;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.project-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.project-directory {
|
||||
padding-right: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.project-list {
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
max-height: 24vh;
|
||||
}
|
||||
|
||||
.project-grant-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
85
frontend/src/views/ProjectManagement/ProjectManagement.js
Normal file
85
frontend/src/views/ProjectManagement/ProjectManagement.js
Normal file
@ -0,0 +1,85 @@
|
||||
import { onMounted, onUnmounted, ref } from "vue";
|
||||
import { handleProjectSession, pageSizes, requestProjectAPI } from "./ProjectRequest.js";
|
||||
import { useProjectMembers } from "./ProjectMembers.js";
|
||||
|
||||
export function useProjectManagement() {
|
||||
const projects = ref([]);
|
||||
const selectedProject = ref(null);
|
||||
const loadingProjects = ref(false);
|
||||
const projectError = ref("");
|
||||
const projectPageSize = ref(5);
|
||||
const projectCursors = ref([""]);
|
||||
const projectPage = ref(0);
|
||||
const projectHasMore = ref(false);
|
||||
const projectNextCursor = ref("");
|
||||
const members = useProjectMembers(selectedProject);
|
||||
let generation = 0;
|
||||
let controller;
|
||||
|
||||
async function loadProjects() {
|
||||
const current = ++generation;
|
||||
controller?.abort();
|
||||
controller = new AbortController();
|
||||
loadingProjects.value = true;
|
||||
projectError.value = "";
|
||||
const selectedID = selectedProject.value?.id;
|
||||
projects.value = [];
|
||||
selectedProject.value = null;
|
||||
projectHasMore.value = false;
|
||||
projectNextCursor.value = "";
|
||||
const query = new URLSearchParams({
|
||||
page_size: String(projectPageSize.value),
|
||||
after: projectCursors.value[projectPage.value],
|
||||
});
|
||||
try {
|
||||
const result = await requestProjectAPI(`/api/v1/projects?${query}`, { signal: controller.signal });
|
||||
if (current !== generation) return;
|
||||
projects.value = result.items || [];
|
||||
projectHasMore.value = Boolean(result.has_more && result.next_cursor);
|
||||
projectNextCursor.value = result.next_cursor || "";
|
||||
selectedProject.value = projects.value.find((item) => item.id === selectedID) || projects.value[0] || null;
|
||||
} catch (error) {
|
||||
if (current === generation && error.name !== "AbortError") projectError.value = handleProjectSession(error);
|
||||
} finally {
|
||||
if (current === generation) loadingProjects.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectProject(id) {
|
||||
if (loadingProjects.value || selectedProject.value?.id === id) return;
|
||||
selectedProject.value = projects.value.find((item) => item.id === id) || null;
|
||||
}
|
||||
|
||||
async function changeProjectPageSize(value) {
|
||||
if (!pageSizes.includes(Number(value))) return;
|
||||
projectPageSize.value = Number(value);
|
||||
projectPage.value = 0;
|
||||
projectCursors.value = [""];
|
||||
await loadProjects();
|
||||
}
|
||||
|
||||
async function nextProjectPage() {
|
||||
if (loadingProjects.value || !projectHasMore.value) return;
|
||||
projectCursors.value = [...projectCursors.value.slice(0, projectPage.value + 1), projectNextCursor.value];
|
||||
projectPage.value += 1;
|
||||
await loadProjects();
|
||||
}
|
||||
|
||||
async function previousProjectPage() {
|
||||
if (loadingProjects.value || projectPage.value === 0) return;
|
||||
projectPage.value -= 1;
|
||||
await loadProjects();
|
||||
}
|
||||
|
||||
onMounted(loadProjects);
|
||||
onUnmounted(() => {
|
||||
generation += 1;
|
||||
controller?.abort();
|
||||
});
|
||||
|
||||
return {
|
||||
...members, projects, selectedProject, loadingProjects, projectError,
|
||||
projectPageSize, projectPage, projectHasMore, pageSizes,
|
||||
loadProjects, selectProject, changeProjectPageSize, nextProjectPage, previousProjectPage,
|
||||
};
|
||||
}
|
||||
361
frontend/src/views/ProjectManagement/ProjectManagement.test.js
Normal file
361
frontend/src/views/ProjectManagement/ProjectManagement.test.js
Normal file
@ -0,0 +1,361 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { nextTick, ref } from "vue";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { mountComposable, deferred } from "../../test-support/mount.js";
|
||||
import { useProjectManagement } from "./ProjectManagement.js";
|
||||
import { useProjectMembers } from "./ProjectMembers.js";
|
||||
import { useUserWorkspace } from "../UserManagement/UserWorkspace.js";
|
||||
|
||||
test("完整成员页面可渲染中文、多个角色、权限拒绝和确认状态且转义账户文字", async (t) => {
|
||||
const Vue = await import("vue");
|
||||
const { parse, compileTemplate } = await import("@vue/compiler-sfc");
|
||||
const { renderToString } = await import("@vue/server-renderer");
|
||||
const source = await readFile(new URL("./ProjectManagement.vue", import.meta.url), "utf8");
|
||||
const { descriptor } = parse(source);
|
||||
const compiled = compileTemplate({ source: descriptor.template.content, filename: "ProjectManagement.vue", id: "project-members", compilerOptions: { mode: "function" } });
|
||||
assert.deepEqual(compiled.errors, []);
|
||||
const render = new Function("Vue", compiled.code)(Vue);
|
||||
const { state } = await mountComposable(t, useProjectManagement, async (path) => {
|
||||
if (path.startsWith("/api/v1/projects?")) return page([projectA]);
|
||||
if (path === "/api/v1/users") return page([]);
|
||||
return page([{ ...memberA, username: "<script>unexpected</script>" }]);
|
||||
});
|
||||
const renderPage = async () => {
|
||||
const warnings = [];
|
||||
const app = Vue.createSSRApp({ render, setup: () => state });
|
||||
app.config.warnHandler = (message) => warnings.push(message);
|
||||
const html = await renderToString(app);
|
||||
assert.deepEqual(warnings, []);
|
||||
return html;
|
||||
};
|
||||
let html = await renderPage();
|
||||
assert.match(html, /项目与成员/);
|
||||
assert.match(html, /DNS 管理员/);
|
||||
assert.match(html, /<script>unexpected<\/script>/);
|
||||
assert.doesNotMatch(html, /<script>unexpected/);
|
||||
state.requestRevoke(state.members.value[0], "dns_admin");
|
||||
html = await renderPage();
|
||||
assert.match(html, /确认撤销/);
|
||||
state.selectedProject.value = { ...projectA, can_manage_members: false };
|
||||
html = await renderPage();
|
||||
assert.match(html, /当前没有成员管理权限/);
|
||||
assert.doesNotMatch(html, /project-grant-form/);
|
||||
assert.doesNotMatch(html, /unexpected/);
|
||||
});
|
||||
|
||||
test("账户页提供本人可分享的账户 ID,不要求用户查看数据库", async () => {
|
||||
const source = await readFile(new URL("../UserManagement/UserTable.vue", import.meta.url), "utf8");
|
||||
assert.ok(source.includes("账户 ID:{{ item.id }}"));
|
||||
});
|
||||
|
||||
test("目录卸载后响应不再修改页面状态", async (t) => {
|
||||
const pending = deferred();
|
||||
const { state, unmount } = await mountComposable(t, useProjectManagement, () => pending.promise);
|
||||
unmount();
|
||||
pending.resolve(page([projectA]));
|
||||
await flush();
|
||||
assert.deepEqual(state.projects.value, []);
|
||||
assert.equal(state.selectedProject.value, null);
|
||||
});
|
||||
|
||||
test("网络错误显示中文,服务端错误保留安全的请求编号", async (t) => {
|
||||
let mode = "network";
|
||||
const { state } = await setupMembers(t, (path) => {
|
||||
if (!path.includes("/members?")) return;
|
||||
if (mode === "network") throw new TypeError("Failed to fetch");
|
||||
return Response.json({ error: "读取失败", request_id: "scert-test-123" }, { status: 500 });
|
||||
});
|
||||
assert.equal(state.memberError.value, "项目请求失败,请检查网络后重试");
|
||||
mode = "server";
|
||||
await state.loadMembers();
|
||||
assert.match(state.memberError.value, /请求编号:scert-test-123/);
|
||||
});
|
||||
|
||||
test("变更请求在卸载后成功,不显示成功提示也不再读取成员", async (t) => {
|
||||
const pending = deferred();
|
||||
const { state, calls, unmount } = await setupMembers(t, (_path, options) => options.method === "POST" ? pending.promise : undefined);
|
||||
state.memberForm.value = { userID: "target", role: "read_only" };
|
||||
const mutation = state.grantMember();
|
||||
const count = calls.length;
|
||||
unmount();
|
||||
pending.resolve(Response.json({}, { status: 201 }));
|
||||
await mutation;
|
||||
assert.equal(calls.length, count);
|
||||
assert.equal(state.memberNotice.value, "");
|
||||
});
|
||||
|
||||
test("停用成员仍可撤销角色,但不在授予角色的建议列表中", async (t) => {
|
||||
const { state } = await setupMembers(t, (path) => path.includes("/members?") ? page([{ ...memberA, status: "disabled" }]) : undefined);
|
||||
assert.equal(state.members.value.length, 1);
|
||||
assert.equal(state.accountChoices.value.some((item) => item.id === memberA.user_id), false);
|
||||
state.requestRevoke(state.members.value[0], "dns_admin");
|
||||
assert.equal(state.pendingRevoke.value.userID, memberA.user_id);
|
||||
});
|
||||
|
||||
test("撤销必须确认且 DELETE 不带正文,撤销自身管理权限后清空敏感页面", async (t) => {
|
||||
let revoked = false;
|
||||
const { state, calls } = await setupMembers(t, (path, options) => {
|
||||
if (options.method === "DELETE") { revoked = true; return new Response(null, { status: 204 }); }
|
||||
if (revoked && path.includes("/members?")) return Response.json({ error: "无权管理成员" }, { status: 403 });
|
||||
});
|
||||
state.requestRevoke(memberA, "project_admin");
|
||||
assert.equal(calls.some(({ options }) => options.method === "DELETE"), false);
|
||||
assert.equal(state.pendingRevoke.value.userID, "user-a");
|
||||
await state.confirmRevoke();
|
||||
const request = calls.find(({ options }) => options.method === "DELETE");
|
||||
assert.equal(request.path, "/api/v1/projects/project-a/members/user-a/roles/project_admin");
|
||||
assert.equal(Object.hasOwn(request.options, "body"), false);
|
||||
assert.equal(state.canManageMembers.value, false);
|
||||
assert.deepEqual(state.members.value, []);
|
||||
assert.deepEqual(state.accountChoices.value, []);
|
||||
assert.equal(state.pendingRevoke.value, null);
|
||||
});
|
||||
|
||||
test("切换项目后旧成员响应和账户建议不得污染新项目", async (t) => {
|
||||
const oldMembers = deferred();
|
||||
const oldAccounts = deferred();
|
||||
let accounts = 0;
|
||||
const { state, project } = await setupMembers(t, (path) => {
|
||||
if (path.includes("project-a/members")) return oldMembers.promise;
|
||||
if (path === "/api/v1/users" && ++accounts === 1) return oldAccounts.promise;
|
||||
if (path.includes("project-b/members")) return page([{ ...memberA, user_id: "new-user" }]);
|
||||
});
|
||||
project.value = projectB;
|
||||
await flush();
|
||||
oldMembers.resolve(page([{ ...memberA, user_id: "old-user" }]));
|
||||
oldAccounts.resolve(page([{ id: "old-account", status: "active" }]));
|
||||
await flush();
|
||||
assert.deepEqual(state.members.value.map((item) => item.user_id), ["new-user"]);
|
||||
assert.equal(state.accountChoices.value.some((item) => item.id === "old-account"), false);
|
||||
});
|
||||
|
||||
test("旧分页请求后返回时不能覆盖新的页长和内容", async (t) => {
|
||||
const old = deferred();
|
||||
const { state } = await setupMembers(t, (path) => {
|
||||
if (path.includes("/members?page_size=10")) return old.promise;
|
||||
if (path.includes("/members?page_size=5")) return page([{ ...memberA, user_id: "five" }]);
|
||||
});
|
||||
await state.changeMemberPageSize(5);
|
||||
old.resolve(page([memberA], "user-a"));
|
||||
await flush();
|
||||
assert.equal(state.members.value[0].user_id, "five");
|
||||
assert.equal(state.memberHasMore.value, false);
|
||||
});
|
||||
|
||||
test("切换项目后旧变更成功不能清空新表单或显示错误成功消息", async (t) => {
|
||||
const pending = deferred();
|
||||
const { state, project } = await setupMembers(t, (_path, options) => options.method === "POST" ? pending.promise : undefined);
|
||||
state.memberForm.value = { userID: "target-a", role: "dns_admin" };
|
||||
const mutation = state.grantMember();
|
||||
project.value = projectB;
|
||||
await flush();
|
||||
state.memberForm.value = { userID: "target-b", role: "read_only" };
|
||||
pending.resolve(Response.json({}, { status: 201 }));
|
||||
await mutation;
|
||||
assert.equal(state.memberForm.value.userID, "target-b");
|
||||
assert.equal(state.memberNotice.value, "");
|
||||
});
|
||||
|
||||
test("卸载后成员请求失败不得回写或触发登录跳转", async (t) => {
|
||||
const pending = deferred();
|
||||
const { state, unmount } = await setupMembers(t, (path) => path.includes("/members?") ? pending.promise : undefined);
|
||||
let redirects = 0;
|
||||
window.location.assign = () => { redirects += 1; };
|
||||
unmount();
|
||||
pending.resolve(Response.json({ error: "会话过期" }, { status: 401 }));
|
||||
await flush();
|
||||
assert.deepEqual(state.members.value, []);
|
||||
assert.equal(redirects, 0);
|
||||
});
|
||||
|
||||
test("有效页面收到 401 跳转登录并清空成员与账户建议", async (t) => {
|
||||
const pending = deferred();
|
||||
const { state } = await setupMembers(t, (path) => path.includes("/members?") ? pending.promise : undefined);
|
||||
const redirects = [];
|
||||
window.location.assign = (path) => redirects.push(path);
|
||||
pending.resolve(Response.json({ error: "请登录" }, { status: 401 }));
|
||||
await flush();
|
||||
assert.deepEqual(redirects, ["/login"]);
|
||||
assert.equal(state.canManageMembers.value, false);
|
||||
assert.deepEqual(state.accountChoices.value, []);
|
||||
});
|
||||
|
||||
test("账户建议失败不妨碍通过明确账户 ID 授予角色", async (t) => {
|
||||
const { state, calls } = await setupMembers(t, (path) => path === "/api/v1/users" ? Response.json({}, { status: 500 }) : undefined);
|
||||
assert.match(state.accountError.value, /账户 ID/);
|
||||
state.memberForm.value = { userID: "known-user", role: "read_only" };
|
||||
await state.grantMember();
|
||||
assert.equal(calls.filter(({ options }) => options.method === "POST").length, 1);
|
||||
});
|
||||
|
||||
test("普通工作台采用单板块导航,不挂载管理员控制台", async (t) => {
|
||||
const { state } = await mountComposable(t, useUserWorkspace, async () => page([]));
|
||||
assert.equal(state.section.value, "account");
|
||||
state.section.value = "projects";
|
||||
assert.equal(state.section.value, "projects");
|
||||
const template = await readFile(new URL("../UserManagement/UserWorkspace.vue", import.meta.url), "utf8");
|
||||
assert.ok(template.includes(`v-if="section === 'account'"`));
|
||||
assert.ok(template.includes(`v-else-if="section === 'projects'"`));
|
||||
assert.equal(template.includes("<Dashboard"), false);
|
||||
});
|
||||
|
||||
const projectA = { id: "project-a", name: "项目甲", can_manage_members: true };
|
||||
const projectB = { id: "project-b", name: "项目乙", can_manage_members: true };
|
||||
const memberA = { user_id: "user-a", username: "成员甲", status: "active", roles: ["project_admin", "dns_admin"] };
|
||||
const page = (items, next = "") => Response.json({ items, has_more: Boolean(next), next_cursor: next, page_size: 10 });
|
||||
const flush = async () => { await new Promise((resolve) => setImmediate(resolve)); await nextTick(); };
|
||||
|
||||
async function setupMembers(t, handler = () => undefined, project = ref(projectA)) {
|
||||
const calls = [];
|
||||
const mounted = await mountComposable(t, () => useProjectMembers(project), async (path, options) => {
|
||||
calls.push({ path, options });
|
||||
const override = handler(path, options);
|
||||
if (override !== undefined) return override;
|
||||
if (path === "/api/v1/users") return page([{ id: "self", username: "本人", status: "active" }, { id: "disabled", status: "disabled" }]);
|
||||
return page([memberA]);
|
||||
});
|
||||
return { ...mounted, calls, project };
|
||||
}
|
||||
|
||||
test("项目入口使用服务端项目且默认选择实际返回项,不写死默认项目", async (t) => {
|
||||
const calls = [];
|
||||
const { state } = await mountComposable(t, useProjectManagement, async (path, options) => {
|
||||
calls.push({ path, options });
|
||||
if (path.startsWith("/api/v1/projects?")) return page([projectA, projectB]);
|
||||
if (path === "/api/v1/users") return page([]);
|
||||
return page([memberA]);
|
||||
});
|
||||
assert.equal(state.selectedProject.value.id, "project-a");
|
||||
assert.equal(state.members.value.length, 1);
|
||||
assert.ok(calls.every(({ options }) => options.credentials === "include" && options.cache === "no-store"));
|
||||
assert.equal(calls.some(({ path }) => path.includes("default")), false);
|
||||
state.selectProject("project-b");
|
||||
await flush();
|
||||
assert.equal(state.selectedProject.value.id, "project-b");
|
||||
assert.ok(calls.some(({ path }) => path.includes("project-b/members")));
|
||||
});
|
||||
|
||||
test("项目分页保留游标并支持四种页长,未知项目不能被选中", async (t) => {
|
||||
const paths = [];
|
||||
const { state } = await mountComposable(t, useProjectManagement, async (path) => {
|
||||
paths.push(path);
|
||||
if (path.startsWith("/api/v1/projects?")) {
|
||||
const query = new URL(path, "http://localhost").searchParams;
|
||||
return query.get("after") ? page([projectB]) : page([projectA], "project-a");
|
||||
}
|
||||
return page([]);
|
||||
});
|
||||
await state.nextProjectPage();
|
||||
assert.equal(state.projectPage.value, 1);
|
||||
assert.equal(state.selectedProject.value.id, "project-b");
|
||||
assert.ok(paths.includes("/api/v1/projects?page_size=5&after=project-a"));
|
||||
await state.previousProjectPage();
|
||||
assert.equal(state.projectPage.value, 0);
|
||||
for (const size of [5, 10, 15, 30]) {
|
||||
await state.changeProjectPageSize(size);
|
||||
assert.equal(state.projectPageSize.value, size);
|
||||
assert.equal(state.projectPage.value, 0);
|
||||
}
|
||||
const count = paths.length;
|
||||
await state.changeProjectPageSize(100);
|
||||
assert.equal(paths.length, count);
|
||||
state.selectProject("hidden-project");
|
||||
assert.equal(state.selectedProject.value, null);
|
||||
});
|
||||
|
||||
test("没有项目时显示空列表,不尝试请求默认项目或全站用户", async (t) => {
|
||||
const paths = [];
|
||||
const { state } = await mountComposable(t, useProjectManagement, async (path) => { paths.push(path); return page([]); });
|
||||
assert.deepEqual(state.projects.value, []);
|
||||
assert.equal(state.selectedProject.value, null);
|
||||
assert.equal(paths.length, 1);
|
||||
});
|
||||
|
||||
test("项目查询刷新失败清空旧项目和成员,旧响应不能覆盖新分页", async (t) => {
|
||||
const old = deferred();
|
||||
let attempt = 0;
|
||||
const { state } = await mountComposable(t, useProjectManagement, (path) => {
|
||||
if (!path.startsWith("/api/v1/projects?")) return page([]);
|
||||
attempt += 1;
|
||||
if (attempt === 1) return old.promise;
|
||||
if (attempt === 2) return page([projectB]);
|
||||
return Response.json({ error: "无法读取项目" }, { status: 500 });
|
||||
});
|
||||
await state.changeProjectPageSize(15);
|
||||
old.resolve(page([projectA]));
|
||||
await flush();
|
||||
assert.equal(state.selectedProject.value.id, "project-b");
|
||||
await state.loadProjects();
|
||||
assert.equal(state.selectedProject.value, null);
|
||||
assert.deepEqual(state.projects.value, []);
|
||||
assert.deepEqual(state.members.value, []);
|
||||
assert.equal(state.projectError.value, "无法读取项目");
|
||||
});
|
||||
|
||||
test("只读成员不请求成员目录或账户建议,也不能伪造授予操作", async (t) => {
|
||||
const { state, calls } = await setupMembers(t, undefined, ref({ ...projectA, can_manage_members: false }));
|
||||
assert.equal(state.canManageMembers.value, false);
|
||||
state.memberForm.value = { userID: "target", role: "project_admin" };
|
||||
await state.grantMember();
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test("账户建议只使用已有可见账户接口和本页成员,不使用邮箱或全站查询", async (t) => {
|
||||
const { state, calls } = await setupMembers(t);
|
||||
assert.deepEqual(state.accountChoices.value, [{ id: "self", username: "本人" }, { id: "user-a", username: "成员甲" }]);
|
||||
assert.equal(calls.filter(({ path }) => path === "/api/v1/users").length, 1);
|
||||
});
|
||||
|
||||
test("成员分页使用账户游标且多个角色不拆分,切换页长重置页码", async (t) => {
|
||||
const { state, calls } = await setupMembers(t, (path) => {
|
||||
if (path.includes("/members?")) return page([memberA], "user-a");
|
||||
});
|
||||
assert.equal(state.members.value[0].roles.length, 2);
|
||||
await state.nextMemberPage();
|
||||
assert.equal(state.memberPage.value, 1);
|
||||
assert.ok(calls.at(-1).path.endsWith("page_size=10&after=user-a"));
|
||||
await state.previousMemberPage();
|
||||
assert.equal(state.memberPage.value, 0);
|
||||
for (const size of [5, 10, 15, 30]) {
|
||||
await state.changeMemberPageSize(size);
|
||||
assert.equal(state.memberPageSize.value, size);
|
||||
assert.equal(state.memberPage.value, 0);
|
||||
assert.ok(calls.at(-1).path.endsWith(`page_size=${size}&after=`));
|
||||
}
|
||||
const count = calls.length;
|
||||
await state.changeMemberPageSize(100);
|
||||
assert.equal(calls.length, count);
|
||||
});
|
||||
|
||||
test("授予角色只提交账户 ID 与角色,去重点击并在成功后刷新", async (t) => {
|
||||
const pending = deferred();
|
||||
const { state, calls } = await setupMembers(t, (_path, options) => options.method === "POST" ? pending.promise : undefined);
|
||||
state.memberForm.value = { userID: " target ", role: "dns_admin" };
|
||||
const first = state.grantMember();
|
||||
await state.grantMember();
|
||||
const posts = calls.filter(({ options }) => options.method === "POST");
|
||||
assert.equal(posts.length, 1);
|
||||
assert.deepEqual(JSON.parse(posts[0].options.body), { user_id: "target", role: "dns_admin" });
|
||||
assert.equal(posts[0].options.headers["Content-Type"], "application/json");
|
||||
pending.resolve(Response.json({ message: "成功" }, { status: 201 }));
|
||||
await first;
|
||||
assert.equal(state.memberNotice.value, "项目角色已授予");
|
||||
assert.equal(state.memberForm.value.userID, "");
|
||||
assert.equal(state.savingMember.value, false);
|
||||
});
|
||||
|
||||
test("非法账户和角色不提交请求,重复授权冲突保留表单供修正", async (t) => {
|
||||
const { state, calls } = await setupMembers(t, (_path, options) => options.method === "POST" ? Response.json({ error: "角色已存在" }, { status: 409 }) : undefined);
|
||||
const initial = calls.length;
|
||||
for (const form of [{ userID: "../other", role: "dns_admin" }, { userID: "target", role: "super_admin" }]) {
|
||||
state.memberForm.value = form;
|
||||
await state.grantMember();
|
||||
}
|
||||
assert.equal(calls.length, initial);
|
||||
state.memberForm.value = { userID: "target", role: "dns_admin" };
|
||||
await state.grantMember();
|
||||
assert.equal(state.memberError.value, "角色已存在");
|
||||
assert.equal(state.memberForm.value.userID, "target");
|
||||
assert.equal(state.canManageMembers.value, true);
|
||||
});
|
||||
124
frontend/src/views/ProjectManagement/ProjectManagement.vue
Normal file
124
frontend/src/views/ProjectManagement/ProjectManagement.vue
Normal file
@ -0,0 +1,124 @@
|
||||
<script setup>
|
||||
import { useProjectManagement } from "./ProjectManagement.js";
|
||||
|
||||
const {
|
||||
projects, selectedProject, loadingProjects, projectError, projectPageSize,
|
||||
projectPage, projectHasMore, pageSizes, loadProjects, selectProject,
|
||||
changeProjectPageSize, nextProjectPage, previousProjectPage,
|
||||
members, accountChoices, loadingMembers, savingMember, memberError, accountError,
|
||||
memberNotice, canManageMembers, memberPageSize, memberPage, memberHasMore,
|
||||
memberForm, projectRoles, pendingRevoke, loadMembers, changeMemberPageSize,
|
||||
nextMemberPage, previousMemberPage, grantMember, requestRevoke, confirmRevoke, roleLabel,
|
||||
} = useProjectManagement();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="project-management" aria-label="项目与成员">
|
||||
<header class="project-heading">
|
||||
<h2>项目与成员</h2>
|
||||
<button type="button" :disabled="loadingProjects || savingMember" @click="loadProjects">刷新项目</button>
|
||||
</header>
|
||||
<p v-if="projectError" class="project-error" role="alert">{{ projectError }}</p>
|
||||
<div class="project-layout">
|
||||
<aside class="project-directory" aria-label="可见项目">
|
||||
<div class="project-toolbar">
|
||||
<h3>我的项目</h3>
|
||||
<label>每页
|
||||
<select :value="projectPageSize" :disabled="loadingProjects || savingMember" @change="changeProjectPageSize($event.target.value)">
|
||||
<option v-for="size in pageSizes" :key="size" :value="size">{{ size }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="loadingProjects" role="status">正在读取项目……</p>
|
||||
<p v-else-if="!projects.length && !projectError" class="project-empty">暂无可见项目</p>
|
||||
<nav class="project-list" aria-label="选择项目">
|
||||
<button
|
||||
v-for="project in projects" :key="project.id" type="button"
|
||||
:aria-current="selectedProject?.id === project.id ? 'true' : undefined"
|
||||
:title="project.description || project.name"
|
||||
@click="selectProject(project.id)"
|
||||
>
|
||||
<strong>{{ project.name }}</strong>
|
||||
<small>{{ project.can_manage_members ? "可管理成员" : "项目成员" }}</small>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="project-pagination">
|
||||
<button type="button" :disabled="loadingProjects || savingMember || projectPage === 0" @click="previousProjectPage">上一页</button>
|
||||
<span>第 {{ projectPage + 1 }} 页</span>
|
||||
<button type="button" :disabled="loadingProjects || savingMember || !projectHasMore" @click="nextProjectPage">下一页</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="project-members" aria-label="项目成员管理">
|
||||
<template v-if="selectedProject">
|
||||
<div class="project-toolbar">
|
||||
<div class="project-title">
|
||||
<h3 :title="selectedProject.description">{{ selectedProject.name }}</h3>
|
||||
<small :title="selectedProject.id">项目 ID:{{ selectedProject.id }}</small>
|
||||
</div>
|
||||
<button v-if="canManageMembers" type="button" :disabled="loadingMembers || savingMember" @click="loadMembers">刷新成员</button>
|
||||
</div>
|
||||
<p v-if="memberError" class="project-error" role="alert">{{ memberError }}</p>
|
||||
<p v-if="memberNotice" class="project-notice" role="status">{{ memberNotice }}</p>
|
||||
<p v-if="!canManageMembers" class="project-empty">当前没有成员管理权限。权限调整后请刷新项目。</p>
|
||||
<template v-else>
|
||||
<form class="project-grant-form" @submit.prevent="grantMember">
|
||||
<label class="project-account-field">
|
||||
<span>成员账户 <span class="project-help" tabindex="0" title="仅建议当前可见账户和本页已有成员;其他账户请填写对方提供的账户 ID。项目角色不改变账户的全局角色。">ⓘ</span></span>
|
||||
<input v-model="memberForm.userID" list="project-account-options" autocomplete="off" required maxlength="128" placeholder="选择或填写账户 ID" :disabled="savingMember || loadingMembers" />
|
||||
<datalist id="project-account-options">
|
||||
<option v-for="account in accountChoices" :key="account.id" :value="account.id">{{ account.username }}</option>
|
||||
</datalist>
|
||||
</label>
|
||||
<label>项目角色
|
||||
<select v-model="memberForm.role" :disabled="savingMember || loadingMembers">
|
||||
<option v-for="role in projectRoles" :key="role.value" :value="role.value">{{ role.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="project-primary" type="submit" :disabled="savingMember || loadingMembers">{{ savingMember ? "正在保存……" : "授予角色" }}</button>
|
||||
</form>
|
||||
<p v-if="accountError" class="project-error" role="status">{{ accountError }}</p>
|
||||
<div v-if="pendingRevoke" class="project-confirm" role="alert">
|
||||
<span>确认撤销“{{ pendingRevoke.username }}”的“{{ roleLabel(pendingRevoke.role) }}”角色?</span>
|
||||
<span class="project-help" tabindex="0" title="撤销自己的项目管理员角色后会立即失去成员管理权限;其他已授予的角色不受影响。">ⓘ</span>
|
||||
<button type="button" :disabled="savingMember" @click="confirmRevoke">确认撤销</button>
|
||||
<button type="button" :disabled="savingMember" @click="pendingRevoke = null">取消</button>
|
||||
</div>
|
||||
<div class="project-member-table" :aria-busy="loadingMembers">
|
||||
<table>
|
||||
<thead><tr><th scope="col">成员</th><th scope="col">状态</th><th scope="col">项目角色</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="member in members" :key="member.user_id">
|
||||
<td><strong>{{ member.username }}</strong><small>{{ member.user_id }}</small></td>
|
||||
<td>{{ member.status === "active" ? "正常" : "已停用" }}</td>
|
||||
<td><div class="project-role-list">
|
||||
<span v-for="role in member.roles" :key="role" class="project-role">
|
||||
{{ roleLabel(role) }}
|
||||
<button type="button" :disabled="savingMember || loadingMembers" :aria-label="`撤销 ${member.username} 的${roleLabel(role)}角色`" @click="requestRevoke(member, role)">撤销</button>
|
||||
</span>
|
||||
</div></td>
|
||||
</tr>
|
||||
<tr v-if="!members.length"><td colspan="3" class="project-empty">{{ loadingMembers ? "正在读取成员……" : "本页暂无成员" }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="project-pagination project-member-pagination">
|
||||
<label>每页
|
||||
<select :value="memberPageSize" :disabled="savingMember || loadingMembers" @change="changeMemberPageSize($event.target.value)">
|
||||
<option v-for="size in pageSizes" :key="size" :value="size">{{ size }}</option>
|
||||
</select>
|
||||
位成员
|
||||
</label>
|
||||
<button type="button" :disabled="savingMember || loadingMembers || memberPage === 0" @click="previousMemberPage">上一页</button>
|
||||
<span>第 {{ memberPage + 1 }} 页</span>
|
||||
<button type="button" :disabled="savingMember || loadingMembers || !memberHasMore" @click="nextMemberPage">下一页</button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<p v-else class="project-empty">请从左侧选择有权访问的项目</p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="./ProjectManagement.css"></style>
|
||||
208
frontend/src/views/ProjectManagement/ProjectMembers.js
Normal file
208
frontend/src/views/ProjectManagement/ProjectMembers.js
Normal file
@ -0,0 +1,208 @@
|
||||
import { computed, onUnmounted, ref, watch } from "vue";
|
||||
import { handleProjectSession, pageSizes, requestProjectAPI } from "./ProjectRequest.js";
|
||||
|
||||
export const projectRoles = [
|
||||
{ value: "project_admin", label: "项目管理员" },
|
||||
{ value: "certificate_admin", label: "证书管理员" },
|
||||
{ value: "deployment_admin", label: "部署管理员" },
|
||||
{ value: "dns_admin", label: "DNS 管理员" },
|
||||
{ value: "audit_viewer", label: "审计查看者" },
|
||||
{ value: "read_only", label: "只读用户" },
|
||||
];
|
||||
|
||||
export function useProjectMembers(project) {
|
||||
const members = ref([]);
|
||||
const accounts = ref([]);
|
||||
const loadingMembers = ref(false);
|
||||
const savingMember = ref(false);
|
||||
const memberError = ref("");
|
||||
const accountError = ref("");
|
||||
const memberNotice = ref("");
|
||||
const accessDenied = ref(false);
|
||||
const memberPageSize = ref(10);
|
||||
const memberPage = ref(0);
|
||||
const cursors = ref([""]);
|
||||
const memberHasMore = ref(false);
|
||||
const nextCursor = ref("");
|
||||
const memberForm = ref({ userID: "", role: "read_only" });
|
||||
const pendingRevoke = ref(null);
|
||||
const canManageMembers = computed(() => Boolean(project.value?.can_manage_members && !accessDenied.value));
|
||||
const accountChoices = computed(() => {
|
||||
const choices = new Map(accounts.value.map((item) => [item.id, { id: item.id, username: item.username }]));
|
||||
for (const item of members.value) {
|
||||
if (item.status === "active") choices.set(item.user_id, { id: item.user_id, username: item.username });
|
||||
}
|
||||
return [...choices.values()];
|
||||
});
|
||||
let scope = 0;
|
||||
let readGeneration = 0;
|
||||
let readController;
|
||||
let accountController;
|
||||
|
||||
function clearPage() {
|
||||
members.value = [];
|
||||
memberHasMore.value = false;
|
||||
nextCursor.value = "";
|
||||
}
|
||||
|
||||
function fail(error) {
|
||||
memberError.value = handleProjectSession(error);
|
||||
if (error.status === 401 || error.status === 403) {
|
||||
accessDenied.value = true;
|
||||
clearPage();
|
||||
accounts.value = [];
|
||||
pendingRevoke.value = null;
|
||||
accountController?.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMembers() {
|
||||
if (!canManageMembers.value) return;
|
||||
const current = scope;
|
||||
const read = ++readGeneration;
|
||||
readController?.abort();
|
||||
readController = new AbortController();
|
||||
loadingMembers.value = true;
|
||||
memberError.value = "";
|
||||
clearPage();
|
||||
const query = new URLSearchParams({ page_size: String(memberPageSize.value), after: cursors.value[memberPage.value] });
|
||||
try {
|
||||
const result = await requestProjectAPI(`/api/v1/projects/${encodeURIComponent(project.value.id)}/members?${query}`, { signal: readController.signal });
|
||||
if (current !== scope || read !== readGeneration || !canManageMembers.value) return;
|
||||
members.value = result.items || [];
|
||||
memberHasMore.value = Boolean(result.has_more && result.next_cursor);
|
||||
nextCursor.value = result.next_cursor || "";
|
||||
} catch (error) {
|
||||
if (current === scope && read === readGeneration && error.name !== "AbortError") fail(error);
|
||||
} finally {
|
||||
if (current === scope && read === readGeneration) loadingMembers.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAccounts() {
|
||||
const current = scope;
|
||||
accountController = new AbortController();
|
||||
try {
|
||||
// 复用现有账户可见性接口,不为项目管理员扩大用户目录权限。
|
||||
const result = await requestProjectAPI("/api/v1/users", { signal: accountController.signal });
|
||||
if (current !== scope || !canManageMembers.value) return;
|
||||
accounts.value = (result.items || []).filter((item) => item.status === "active");
|
||||
} catch (error) {
|
||||
if (current !== scope || !canManageMembers.value || error.name === "AbortError") return;
|
||||
if (error.status === 401) fail(error);
|
||||
else accountError.value = "账户建议暂不可用,仍可填写对方提供的账户 ID";
|
||||
}
|
||||
}
|
||||
|
||||
async function changeMemberPageSize(value) {
|
||||
if (savingMember.value || !pageSizes.includes(Number(value))) return;
|
||||
memberPageSize.value = Number(value);
|
||||
memberPage.value = 0;
|
||||
cursors.value = [""];
|
||||
pendingRevoke.value = null;
|
||||
await loadMembers();
|
||||
}
|
||||
|
||||
async function nextMemberPage() {
|
||||
if (loadingMembers.value || savingMember.value || !memberHasMore.value) return;
|
||||
cursors.value = [...cursors.value.slice(0, memberPage.value + 1), nextCursor.value];
|
||||
memberPage.value += 1;
|
||||
pendingRevoke.value = null;
|
||||
await loadMembers();
|
||||
}
|
||||
|
||||
async function previousMemberPage() {
|
||||
if (loadingMembers.value || savingMember.value || memberPage.value === 0) return;
|
||||
memberPage.value -= 1;
|
||||
pendingRevoke.value = null;
|
||||
await loadMembers();
|
||||
}
|
||||
|
||||
async function mutateMember(userID, role, revoke) {
|
||||
if (!canManageMembers.value || savingMember.value || loadingMembers.value) return;
|
||||
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(userID) || !projectRoles.some((item) => item.value === role)) {
|
||||
memberError.value = "请选择有效的账户 ID 和项目角色";
|
||||
return;
|
||||
}
|
||||
const current = scope;
|
||||
const base = `/api/v1/projects/${encodeURIComponent(project.value.id)}/members`;
|
||||
savingMember.value = true;
|
||||
memberError.value = "";
|
||||
memberNotice.value = "";
|
||||
try {
|
||||
await requestProjectAPI(revoke ? `${base}/${encodeURIComponent(userID)}/roles/${encodeURIComponent(role)}` : base, {
|
||||
method: revoke ? "DELETE" : "POST",
|
||||
...(revoke ? {} : { body: JSON.stringify({ user_id: userID, role }) }),
|
||||
});
|
||||
if (current !== scope) return;
|
||||
memberNotice.value = revoke ? "项目角色已撤销" : "项目角色已授予";
|
||||
memberForm.value = { userID: "", role: "read_only" };
|
||||
pendingRevoke.value = null;
|
||||
memberPage.value = 0;
|
||||
cursors.value = [""];
|
||||
await loadMembers();
|
||||
} catch (error) {
|
||||
if (current !== scope) return;
|
||||
fail(error);
|
||||
if (error.status === 409) {
|
||||
const conflict = memberError.value;
|
||||
await loadMembers();
|
||||
if (current === scope && canManageMembers.value && !memberError.value) memberError.value = conflict;
|
||||
}
|
||||
} finally {
|
||||
if (current === scope) savingMember.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function grantMember() {
|
||||
return mutateMember(memberForm.value.userID.trim(), memberForm.value.role, false);
|
||||
}
|
||||
|
||||
function requestRevoke(member, role) {
|
||||
if (!canManageMembers.value || savingMember.value || loadingMembers.value || !member.roles.includes(role)) return;
|
||||
pendingRevoke.value = { userID: member.user_id, username: member.username, role };
|
||||
}
|
||||
|
||||
function confirmRevoke() {
|
||||
const target = pendingRevoke.value;
|
||||
if (target) return mutateMember(target.userID, target.role, true);
|
||||
}
|
||||
|
||||
watch(project, () => {
|
||||
scope += 1;
|
||||
readGeneration += 1;
|
||||
readController?.abort();
|
||||
accountController?.abort();
|
||||
clearPage();
|
||||
accounts.value = [];
|
||||
memberError.value = "";
|
||||
memberNotice.value = "";
|
||||
accountError.value = "";
|
||||
accessDenied.value = false;
|
||||
savingMember.value = false;
|
||||
loadingMembers.value = false;
|
||||
memberPage.value = 0;
|
||||
cursors.value = [""];
|
||||
memberForm.value = { userID: "", role: "read_only" };
|
||||
pendingRevoke.value = null;
|
||||
if (canManageMembers.value) {
|
||||
void loadMembers();
|
||||
void loadAccounts();
|
||||
}
|
||||
}, { immediate: true, flush: "sync" });
|
||||
|
||||
onUnmounted(() => {
|
||||
scope += 1;
|
||||
readGeneration += 1;
|
||||
readController?.abort();
|
||||
accountController?.abort();
|
||||
});
|
||||
|
||||
return {
|
||||
members, accountChoices, loadingMembers, savingMember, memberError, accountError,
|
||||
memberNotice, canManageMembers, memberPageSize, memberPage, memberHasMore, memberForm,
|
||||
projectRoles, pendingRevoke, loadMembers, changeMemberPageSize, nextMemberPage,
|
||||
previousMemberPage, grantMember, requestRevoke, confirmRevoke,
|
||||
roleLabel: (role) => projectRoles.find((item) => item.value === role)?.label || "未知角色",
|
||||
};
|
||||
}
|
||||
31
frontend/src/views/ProjectManagement/ProjectRequest.js
Normal file
31
frontend/src/views/ProjectManagement/ProjectRequest.js
Normal file
@ -0,0 +1,31 @@
|
||||
export async function requestProjectAPI(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
...options,
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
...(options.body ? { "Content-Type": "application/json" } : {}),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
const result = response.status === 204 ? {} : await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const error = new Error(result.error || "项目请求失败,请稍后重试");
|
||||
error.status = response.status;
|
||||
error.requestID = /^[a-zA-Z0-9_-]{1,128}$/.test(result.request_id || "") ? result.request_id : "";
|
||||
throw error;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function handleProjectSession(error) {
|
||||
if (error.status === 401) {
|
||||
window.location.assign("/login");
|
||||
return "登录会话已过期,请重新登录";
|
||||
}
|
||||
if (!error.status) return "项目请求失败,请检查网络后重试";
|
||||
const message = error.message || "项目请求失败,请稍后重试";
|
||||
return error.requestID ? `${message}(请求编号:${error.requestID})` : message;
|
||||
}
|
||||
|
||||
export const pageSizes = [5, 10, 15, 30];
|
||||
@ -324,3 +324,12 @@ button:focus-visible {
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
.user-account-id {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
max-width: 300px;
|
||||
color: #68788f;
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
@ -24,7 +24,10 @@ defineEmits(["edit", "toggle", "delete"]);
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in users" :key="item.id">
|
||||
<td><strong>{{ item.username }}</strong></td>
|
||||
<td>
|
||||
<strong>{{ item.username }}</strong>
|
||||
<small class="user-account-id" title="可将此账户 ID 提供给项目管理员,用于加入项目">账户 ID:{{ item.id }}</small>
|
||||
</td>
|
||||
<td>{{ item.email || "未绑定" }}</td>
|
||||
<td><span class="user-badge">{{ roleLabels[item.role] || item.role }}</span></td>
|
||||
<td><span class="user-status" :class="item.status">{{ statusLabels[item.status] || item.status }}</span></td>
|
||||
|
||||
@ -24,3 +24,15 @@
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-workspace-navigation {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin: 18px 0;
|
||||
}
|
||||
|
||||
.user-workspace-navigation button[aria-current="page"] {
|
||||
color: #234d9d;
|
||||
border-color: #4c74cc;
|
||||
background: #edf3ff;
|
||||
}
|
||||
|
||||
7
frontend/src/views/UserManagement/UserWorkspace.js
Normal file
7
frontend/src/views/UserManagement/UserWorkspace.js
Normal file
@ -0,0 +1,7 @@
|
||||
import { ref } from "vue";
|
||||
import { useAccessDenied } from "../AccessDenied/AccessDenied.js";
|
||||
|
||||
export function useUserWorkspace() {
|
||||
const section = ref("account");
|
||||
return { ...useAccessDenied(), section };
|
||||
}
|
||||
@ -1,8 +1,9 @@
|
||||
<script setup>
|
||||
import UserManagement from "./UserManagement.vue";
|
||||
import { useAccessDenied } from "../AccessDenied/AccessDenied.js";
|
||||
import ProjectManagement from "../ProjectManagement/ProjectManagement.vue";
|
||||
import { useUserWorkspace } from "./UserWorkspace.js";
|
||||
|
||||
const { loggingOut, error, logout } = useAccessDenied();
|
||||
const { loggingOut, error, logout, section } = useUserWorkspace();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -14,7 +15,12 @@ const { loggingOut, error, logout } = useAccessDenied();
|
||||
</button>
|
||||
</header>
|
||||
<p v-if="error" role="alert">{{ error }}</p>
|
||||
<UserManagement />
|
||||
<nav class="user-workspace-navigation" aria-label="用户中心导航">
|
||||
<button type="button" :aria-current="section === 'account' ? 'page' : undefined" @click="section = 'account'">账户与密码</button>
|
||||
<button type="button" :aria-current="section === 'projects' ? 'page' : undefined" @click="section = 'projects'">项目与成员</button>
|
||||
</nav>
|
||||
<UserManagement v-if="section === 'account'" />
|
||||
<ProjectManagement v-else-if="section === 'projects'" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user