diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 499292e..ad3ab86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: run: task frontend:install - name: Run formatting checks - run: task fmt + run: task fmt:check - name: Run lint checks run: task lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0bbee86..ad198e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,7 @@ jobs: run: task frontend:install - name: Run frontend format checks - run: task frontend:fmt + run: task frontend:fmt:check - name: Run frontend lint checks run: task frontend:lint diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..03ba32c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ +# 文档目录 + +[API](docs/api/contracts.md) + +## 规范 + +[目录规范](docs/standards/directory.md) +[后端规范](docs/standards/backend.md) +[前端规范](docs/standards/frontend.md) +[运维规范](docs/standards/ops.md) + +## 设计 + +[容器实例设计](docs/design-docs/instance_lifecycle.md) + +## 执行计划 + +[执行计划目录说明](docs/exec-plans/README.md) diff --git a/Readme.md b/Readme.md index ab4c7a3..8c5805a 100644 --- a/Readme.md +++ b/Readme.md @@ -4,41 +4,34 @@ ## 运行指南 -### Task 命令 ```bash task dev # 一键启动前后端开发服务 -task fmt # 执行全局格式检查(当前依赖 backend:fmt) -task vet # 执行全局静态检查(当前依赖 backend:vet) -task test # 执行全局测试(当前依赖 backend:test) -task build # 统一编译前后端 -task clean # 清理构建产物 -task frontend:install # 安装前端依赖 ``` ## 待办清单 ### 实例生命周期与资源调度 + - [x] 开服与停服 - [x] 实例的创建与删除 - [ ] 实例重启与强制结束进程 - [ ] 基于 Cgroups 的 CPU 与内存资源配额可视化配置 ### 实时交互与性能监控 + - [ ] Web 控制台标准输出日志实时推流 - [ ] 网页端在线交互指令无缝下发 - [ ] 容器 CPU、内存、网络 I/O 运行态数据实时图表展示 ### 数据灾备与持久化 + - [ ] 游戏容器 Volume 持久化目录挂载映射 - [ ] 游戏存档/数据一键手动快照备份 - [ ] 基于 Cron 表达式的自动化定时备份任务 - [ ] 一键回档功能 ### 在线文件管理与差异化配置 + - [ ] 可视化 Web 文件浏览器 - [ ] 游戏基础配置文件的在线文本编辑器 - [ ] 大文件 (Mod/插件) 上传及在线解压缩 - -## 注意事项 -默认 SQLite 数据库路径为 `backend/data/minedock.db`(在 `backend` 目录启动时对应 `data/minedock.db`)。 -可通过环境变量 `MINEDOCK_DB_PATH` 覆盖。 \ No newline at end of file diff --git a/Taskfile.yml b/Taskfile.yml index fdc0a24..9981ef3 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -9,7 +9,7 @@ tasks: cmds: - go run main.go - backend:fmt: + backend:fmt:check: desc: Verify backend Go formatting dir: backend cmds: @@ -21,12 +21,24 @@ tasks: exit 1 fi + backend:fmt:fix: + desc: Format backend Go files with gofmt + dir: backend + cmds: + - gofmt -w . + backend:vet: desc: Run backend go vet checks dir: backend cmds: - go vet ./... + backend:lint: + desc: Run backend golangci-lint checks + dir: backend + cmds: + - go run github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8 run --config .golangci.yml ./... + backend:test: desc: Run backend tests dir: backend @@ -64,12 +76,59 @@ tasks: cmds: - npm run lint - frontend:fmt: + frontend:fmt:check: desc: Run frontend Prettier format checks dir: frontend cmds: - npm run format:check + frontend:fmt:fix: + desc: Format frontend files with Prettier + dir: frontend + cmds: + - npm run format + + docs:todo: + desc: Generate docs/exec-plans/TODO.md from TODO comments + cmds: + - go run ./scripts/todo-gen/main.go -root . -out ./docs/exec-plans/TODO.md + + docs:lint:rules: + desc: Run Markdown rule checks for the knowledge base + cmds: + - npx --yes markdownlint-cli2 "AGENTS.md" "Readme.md" "docs/**/*.md" + + docs:lint:rules:fix: + desc: Auto-fix Markdown rule violations where possible + cmds: + - npx --yes markdownlint-cli2 --fix "AGENTS.md" "Readme.md" "docs/**/*.md" + + docs:links:check: + desc: Check Markdown links for the knowledge base + cmds: + - npx --yes markdown-link-check -q "AGENTS.md" "Readme.md" "docs" + + docs:lint: + desc: Run documentation lint checks + deps: + - docs:lint:rules + - docs:links:check + + docs:lint:fix: + desc: Auto-fix documentation lint issues where possible + deps: + - docs:lint:rules:fix + + docs:fmt:check: + desc: Run Markdown formatting checks + cmds: + - npx --yes prettier --check "AGENTS.md" "Readme.md" "docs/**/*.md" + + docs:fmt:fix: + desc: Format Markdown documents with Prettier + cmds: + - npx --yes prettier --write "AGENTS.md" "Readme.md" "docs/**/*.md" + dev: desc: Run backend and frontend dev servers in parallel deps: @@ -87,16 +146,26 @@ tasks: cmds: - rm -rf backend/bin frontend/dist - fmt: + fmt:check: desc: Run global formatting checks deps: - - backend:fmt - - frontend:fmt + - backend:fmt:check + - frontend:fmt:check + - docs:fmt:check + + fmt:fix: + desc: Run global formatting fixes + deps: + - backend:fmt:fix + - frontend:fmt:fix + - docs:fmt:fix lint: desc: Run global lint checks deps: + - backend:lint - frontend:lint + - docs:lint vet: desc: Run global vet checks diff --git a/backend/.golangci.yml b/backend/.golangci.yml new file mode 100644 index 0000000..5bb27df --- /dev/null +++ b/backend/.golangci.yml @@ -0,0 +1,17 @@ +run: + timeout: 5m + tests: true + modules-download-mode: readonly + +linters: + disable-all: true + enable: + - govet + - staticcheck + - ineffassign + - unused + - gosimple + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index 18f7c36..a479ea5 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -1,37 +1,52 @@ package api import ( + "context" "encoding/json" + "errors" "net/http" "strings" - "minedock/backend/internal/service" - "minedock/backend/internal/store" + "minedock/backend/internal/model" ) -// Handler exposes HTTP handlers. +// InstanceService 定义 API Handler 依赖的业务操作。 +type InstanceService interface { + ListInstances(ctx context.Context) ([]model.Instance, error) + CreateInstance(ctx context.Context, name string) (string, error) + StartInstance(ctx context.Context, containerID string) error + StopInstance(ctx context.Context, containerID string) error + DeleteInstance(ctx context.Context, containerID string) error +} + +// Handler 暴露 HTTP 处理器。 type Handler struct { - svc *service.DockerService + svc InstanceService } -func NewHandler(svc *service.DockerService) *Handler { +// NewHandler 使用给定的实例服务创建 Handler。 +func NewHandler(svc InstanceService) *Handler { return &Handler{svc: svc} } +// createRequest 定义创建实例请求体。 type createRequest struct { Name string `json:"name"` } +// statusResponse 定义通用状态响应体。 type statusResponse struct { Status string `json:"status"` Error string `json:"error,omitempty"` } +// createResponse 定义创建成功时的响应体。 type createResponse struct { Status string `json:"status"` ContainerID string `json:"container_id"` } +// GetInstances 处理 GET /api/instances 并返回所有托管实例。 func (h *Handler) GetInstances(w http.ResponseWriter, r *http.Request) { instances, err := h.svc.ListInstances(r.Context()) if err != nil { @@ -41,6 +56,7 @@ func (h *Handler) GetInstances(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, instances) } +// CreateInstance 处理 POST /api/instances 并创建新实例。 func (h *Handler) CreateInstance(w http.ResponseWriter, r *http.Request) { var req createRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -56,17 +72,16 @@ func (h *Handler) CreateInstance(w http.ResponseWriter, r *http.Request) { id, err := h.svc.CreateInstance(r.Context(), req.Name) if err != nil { - code := http.StatusInternalServerError - if err == store.ErrNameExists { - code = http.StatusConflict - } - writeJSON(w, code, statusResponse{Status: "error", Error: err.Error()}) + writeJSON(w, mapErrorCode(err), statusResponse{Status: "error", Error: err.Error()}) return } writeJSON(w, http.StatusOK, createResponse{Status: "success", ContainerID: id}) } +// TODO: 抽取 start/stop/delete 的公共处理流程。 +// 这三个处理器都重复了:解析容器 ID、调用 Service、写回状态 JSON。 +// StartInstance 处理 POST /api/instances/{id}/start 并启动实例。 func (h *Handler) StartInstance(w http.ResponseWriter, r *http.Request) { id, ok := pathContainerID(r) if !ok { @@ -82,6 +97,7 @@ func (h *Handler) StartInstance(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, statusResponse{Status: "success"}) } +// StopInstance 处理 POST /api/instances/{id}/stop 并停止实例。 func (h *Handler) StopInstance(w http.ResponseWriter, r *http.Request) { id, ok := pathContainerID(r) if !ok { @@ -97,6 +113,7 @@ func (h *Handler) StopInstance(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, statusResponse{Status: "success"}) } +// DeleteInstance 处理 DELETE /api/instances/{id} 并删除实例。 func (h *Handler) DeleteInstance(w http.ResponseWriter, r *http.Request) { id, ok := pathContainerID(r) if !ok { @@ -105,17 +122,14 @@ func (h *Handler) DeleteInstance(w http.ResponseWriter, r *http.Request) { } if err := h.svc.DeleteInstance(r.Context(), id); err != nil { - code := http.StatusInternalServerError - if err == service.ErrInstanceRunning { - code = http.StatusConflict - } - writeJSON(w, code, statusResponse{Status: "error", Error: err.Error()}) + writeJSON(w, mapErrorCode(err), statusResponse{Status: "error", Error: err.Error()}) return } writeJSON(w, http.StatusOK, statusResponse{Status: "success"}) } +// pathContainerID 解析并校验 URL 路径中的容器 ID。 func pathContainerID(r *http.Request) (string, bool) { id := strings.TrimSpace(r.PathValue("id")) if id == "" || strings.Contains(id, "/") { @@ -124,7 +138,22 @@ func pathContainerID(r *http.Request) (string, bool) { return id, true } +// mapErrorCode 将领域错误统一映射为 HTTP 状态码。 +func mapErrorCode(err error) int { + switch { + case errors.Is(err, model.ErrNameExists): + return http.StatusConflict + case errors.Is(err, model.ErrInstanceRunning): + return http.StatusConflict + default: + return http.StatusInternalServerError + } +} + +// writeJSON 按指定状态码写入 JSON 响应。 func writeJSON(w http.ResponseWriter, code int, v any) { + // 说明:当前会忽略编码错误,因为状态码可能已写入。 + // TODO: 增加统一的编码错误日志,提升可观测性。 w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(v) diff --git a/backend/internal/api/handlers_test.go b/backend/internal/api/handlers_test.go new file mode 100644 index 0000000..86799d6 --- /dev/null +++ b/backend/internal/api/handlers_test.go @@ -0,0 +1,258 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "minedock/backend/internal/model" +) + +// mockService 为 Handler 测试实现 InstanceService。 +type mockService struct { + listFn func(ctx context.Context) ([]model.Instance, error) + createFn func(ctx context.Context, name string) (string, error) + startFn func(ctx context.Context, id string) error + stopFn func(ctx context.Context, id string) error + deleteFn func(ctx context.Context, id string) error +} + +func (m *mockService) ListInstances(ctx context.Context) ([]model.Instance, error) { + return m.listFn(ctx) +} +func (m *mockService) CreateInstance(ctx context.Context, name string) (string, error) { + return m.createFn(ctx, name) +} +func (m *mockService) StartInstance(ctx context.Context, id string) error { + return m.startFn(ctx, id) +} +func (m *mockService) StopInstance(ctx context.Context, id string) error { + return m.stopFn(ctx, id) +} +func (m *mockService) DeleteInstance(ctx context.Context, id string) error { + return m.deleteFn(ctx, id) +} + +func newTestRouter(m *mockService) http.Handler { + h := NewHandler(m) + return NewRouter(h) +} + +// --- GET /api/instances 场景 --- + +func TestGetInstances_Success(t *testing.T) { + router := newTestRouter(&mockService{ + listFn: func(_ context.Context) ([]model.Instance, error) { + return []model.Instance{ + {ContainerID: "c1", Name: "server-1", Status: "Running"}, + }, nil + }, + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/instances", nil) + router.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var got []model.Instance + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != 1 || got[0].ContainerID != "c1" { + t.Fatalf("unexpected response: %+v", got) + } +} + +func TestGetInstances_Error(t *testing.T) { + router := newTestRouter(&mockService{ + listFn: func(_ context.Context) ([]model.Instance, error) { + return nil, errTest + }, + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/instances", nil) + router.ServeHTTP(w, r) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", w.Code) + } +} + +// --- POST /api/instances 场景 --- + +func TestCreateInstance_Success(t *testing.T) { + router := newTestRouter(&mockService{ + createFn: func(_ context.Context, name string) (string, error) { + if name != "test-server" { + t.Fatalf("unexpected name: %s", name) + } + return "abc123", nil + }, + }) + + body := `{"name":"test-server"}` + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/instances", strings.NewReader(body)) + router.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp createResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Status != "success" || resp.ContainerID != "abc123" { + t.Fatalf("unexpected response: %+v", resp) + } +} + +func TestCreateInstance_InvalidJSON(t *testing.T) { + router := newTestRouter(&mockService{}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/instances", strings.NewReader("{bad")) + router.ServeHTTP(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestCreateInstance_EmptyName(t *testing.T) { + router := newTestRouter(&mockService{}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/instances", strings.NewReader(`{"name":" "}`)) + router.ServeHTTP(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestCreateInstance_NameConflict(t *testing.T) { + router := newTestRouter(&mockService{ + createFn: func(_ context.Context, _ string) (string, error) { + return "", model.ErrNameExists + }, + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/instances", strings.NewReader(`{"name":"dup"}`)) + router.ServeHTTP(w, r) + + if w.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d", w.Code) + } +} + +// --- POST /api/instances/{id}/start 场景 --- + +func TestStartInstance_Success(t *testing.T) { + router := newTestRouter(&mockService{ + startFn: func(_ context.Context, id string) error { + if id != "abc123" { + t.Fatalf("unexpected id: %s", id) + } + return nil + }, + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/instances/abc123/start", nil) + router.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestStartInstance_Error(t *testing.T) { + router := newTestRouter(&mockService{ + startFn: func(_ context.Context, _ string) error { + return errTest + }, + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/instances/abc123/start", nil) + router.ServeHTTP(w, r) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", w.Code) + } +} + +// --- POST /api/instances/{id}/stop 场景 --- + +func TestStopInstance_Success(t *testing.T) { + router := newTestRouter(&mockService{ + stopFn: func(_ context.Context, id string) error { + if id != "abc123" { + t.Fatalf("unexpected id: %s", id) + } + return nil + }, + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/instances/abc123/stop", nil) + router.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +// --- DELETE /api/instances/{id} 场景 --- + +func TestDeleteInstance_Success(t *testing.T) { + router := newTestRouter(&mockService{ + deleteFn: func(_ context.Context, id string) error { + if id != "abc123" { + t.Fatalf("unexpected id: %s", id) + } + return nil + }, + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodDelete, "/api/instances/abc123", nil) + router.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestDeleteInstance_Running(t *testing.T) { + router := newTestRouter(&mockService{ + deleteFn: func(_ context.Context, _ string) error { + return model.ErrInstanceRunning + }, + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodDelete, "/api/instances/abc123", nil) + router.ServeHTTP(w, r) + + if w.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d", w.Code) + } +} + +// errTest 是测试桩使用的通用错误。 +var errTest = errorString("test error") + +type errorString string + +func (e errorString) Error() string { return string(e) } diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index e63422e..62ae44a 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -2,6 +2,7 @@ package api import "net/http" +// NewRouter 注册 API 路由并包装中间件。 func NewRouter(h *Handler) http.Handler { mux := http.NewServeMux() @@ -14,6 +15,7 @@ func NewRouter(h *Handler) http.Handler { return withCORS(mux) } +// withCORS 添加宽松的 CORS 响应头并处理 OPTIONS 预检请求。 func withCORS(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") diff --git a/backend/internal/model/errors.go b/backend/internal/model/errors.go new file mode 100644 index 0000000..3385945 --- /dev/null +++ b/backend/internal/model/errors.go @@ -0,0 +1,9 @@ +package model + +import "errors" + +// ErrNameExists 表示实例名称已被占用。 +var ErrNameExists = errors.New("instance name already exists") + +// ErrInstanceRunning 表示实例正在运行,删除前必须先停止。 +var ErrInstanceRunning = errors.New("instance is running, stop it before delete") diff --git a/backend/internal/service/docker_service.go b/backend/internal/service/docker_service.go index 3b16939..56e10d0 100644 --- a/backend/internal/service/docker_service.go +++ b/backend/internal/service/docker_service.go @@ -2,7 +2,6 @@ package service import ( "context" - "errors" "fmt" "io" "strings" @@ -13,11 +12,8 @@ import ( "github.com/docker/docker/client" "minedock/backend/internal/model" - "minedock/backend/internal/store" ) -var ErrInstanceRunning = errors.New("instance is running, stop it before delete") - const ( managedLabelKey = "minedock.managed" managedLabelValue = "true" @@ -25,20 +21,30 @@ const ( defaultImage = "alpine:latest" ) -// DockerService contains business logic for container management. +// InstanceStore 定义 DockerService 依赖的持久化操作。 +type InstanceStore interface { + Save(ctx context.Context, inst model.Instance) error + Get(ctx context.Context, containerID string) (model.Instance, bool, error) + Delete(ctx context.Context, containerID string) error +} + +// DockerService 封装容器管理相关业务逻辑。 type DockerService struct { cli *client.Client - store *store.SQLiteStore + store InstanceStore image string } -func NewDockerService(cli *client.Client, sqliteStore *store.SQLiteStore, imageName string) *DockerService { +// NewDockerService 使用依赖项和运行镜像创建 DockerService。 +func NewDockerService(cli *client.Client, s InstanceStore, imageName string) *DockerService { if strings.TrimSpace(imageName) == "" { imageName = defaultImage } - return &DockerService{cli: cli, store: sqliteStore, image: imageName} + return &DockerService{cli: cli, store: s, image: imageName} } +// CreateInstance 创建托管容器并持久化实例元数据。 +// TODO: 让 Docker 创建与 SQLite 保存具备原子性。 func (s *DockerService) CreateInstance(ctx context.Context, name string) (string, error) { if err := s.ensureImage(ctx, s.image); err != nil { return "", err @@ -58,13 +64,15 @@ func (s *DockerService) CreateInstance(ctx context.Context, name string) (string inst := model.Instance{ContainerID: resp.ID, Name: name, Status: "Stopped"} if err := s.store.Save(ctx, inst); err != nil { + // 说明:请求上下文取消时,清理逻辑会使用独立上下文做尽力回收。 _ = s.cli.ContainerRemove(context.Background(), resp.ID, container.RemoveOptions{Force: true}) - return "", err + return "", fmt.Errorf("save instance record: %w", err) } return resp.ID, nil } +// StartInstance 启动托管容器并更新持久化状态。 func (s *DockerService) StartInstance(ctx context.Context, containerID string) error { if err := s.cli.ContainerStart(ctx, containerID, container.StartOptions{}); err != nil { return fmt.Errorf("start container: %w", err) @@ -75,12 +83,13 @@ func (s *DockerService) StartInstance(ctx context.Context, containerID string) e return err } if err := s.store.Save(ctx, inst); err != nil { - return err + return fmt.Errorf("save instance state: %w", err) } return nil } +// StopInstance 停止托管容器并更新持久化状态。 func (s *DockerService) StopInstance(ctx context.Context, containerID string) error { timeout := 10 if err := s.cli.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout}); err != nil { @@ -92,12 +101,15 @@ func (s *DockerService) StopInstance(ctx context.Context, containerID string) er return err } if err := s.store.Save(ctx, inst); err != nil { - return err + return fmt.Errorf("save instance state: %w", err) } return nil } +// ListInstances 列出托管容器并将快照状态同步到存储层。 +// TODO: 将逐条 Save 改为批量或事务化同步路径。 +// TODO: 增加并发写保护,避免最后写入覆盖前写入。 func (s *DockerService) ListInstances(ctx context.Context) ([]model.Instance, error) { args := filters.NewArgs() args.Add("label", managedLabelKey+"="+managedLabelValue) @@ -109,6 +121,7 @@ func (s *DockerService) ListInstances(ctx context.Context) ([]model.Instance, er instances := make([]model.Instance, 0, len(containers)) for _, c := range containers { + // 说明:名称回退顺序是先 label,再 Docker 容器名。 name := c.Labels[nameLabelKey] if strings.TrimSpace(name) == "" && len(c.Names) > 0 { name = strings.TrimPrefix(c.Names[0], "/") @@ -122,6 +135,8 @@ func (s *DockerService) ListInstances(ctx context.Context) ([]model.Instance, er Name: name, Status: status, } + // 说明:当前 Save 为尽力而为,避免影响列表返回。 + // TODO: 在不影响列表返回的前提下上报同步失败。 _ = s.store.Save(ctx, inst) instances = append(instances, inst) } @@ -129,6 +144,7 @@ func (s *DockerService) ListInstances(ctx context.Context) ([]model.Instance, er return instances, nil } +// DeleteInstance 删除已停止的托管容器及其持久化记录。 func (s *DockerService) DeleteInstance(ctx context.Context, containerID string) error { inspect, err := s.cli.ContainerInspect(ctx, containerID) if err != nil { @@ -136,24 +152,27 @@ func (s *DockerService) DeleteInstance(ctx context.Context, containerID string) } if inspect.State != nil && inspect.State.Running { - return ErrInstanceRunning + return model.ErrInstanceRunning } if err := s.cli.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: false}); err != nil { return fmt.Errorf("remove container: %w", err) } if err := s.store.Delete(ctx, containerID); err != nil { - return err + return fmt.Errorf("delete instance record: %w", err) } return nil } +// readInstance 从存储层读取实例信息并与运行态进行对账。 +// TODO: 将该函数拆分为存储读取与 Docker 对账两个辅助函数。 func (s *DockerService) readInstance(ctx context.Context, containerID string, fallbackStatus string) (model.Instance, error) { inst, ok, err := s.store.Get(ctx, containerID) if err != nil { - return model.Instance{}, err + return model.Instance{}, fmt.Errorf("read instance: %w", err) } if ok { + // 说明:命中存储后仍会应用调用方传入的兜底状态。 inst.Status = fallbackStatus return inst, nil } @@ -173,6 +192,7 @@ func (s *DockerService) readInstance(ctx context.Context, containerID string, fa status := fallbackStatus if inspect.State != nil { + // 说明:当 inspect 有运行态数据时,以 Docker 真实状态覆盖兜底状态。 if inspect.State.Running { status = "Running" } else { @@ -183,6 +203,7 @@ func (s *DockerService) readInstance(ctx context.Context, containerID string, fa return model.Instance{ContainerID: containerID, Name: name, Status: status}, nil } +// ensureImage 确保本地存在目标镜像,缺失时自动拉取。 func (s *DockerService) ensureImage(ctx context.Context, imageName string) error { list, err := s.cli.ImageList(ctx, image.ListOptions{}) if err != nil { diff --git a/backend/internal/store/memory.go b/backend/internal/store/sqlite.go similarity index 79% rename from backend/internal/store/memory.go rename to backend/internal/store/sqlite.go index af0e566..db09f23 100644 --- a/backend/internal/store/memory.go +++ b/backend/internal/store/sqlite.go @@ -11,16 +11,15 @@ import ( "minedock/backend/internal/model" - _ "modernc.org/sqlite" + _ "modernc.org/sqlite" // 注册 SQLite 驱动 ) -var ErrNameExists = errors.New("instance name already exists") - -// SQLiteStore persists instance state in a local SQLite database. +// SQLiteStore 在本地 SQLite 数据库中持久化实例状态。 type SQLiteStore struct { db *sql.DB } +// NewSQLiteStore 打开 dbPath 指向的 SQLite 并初始化所需表结构。 func NewSQLiteStore(dbPath string) (*SQLiteStore, error) { cleanPath := strings.TrimSpace(dbPath) if cleanPath == "" { @@ -36,7 +35,8 @@ func NewSQLiteStore(dbPath string) (*SQLiteStore, error) { return nil, fmt.Errorf("open sqlite db: %w", err) } - // SQLite works best with a single writer connection in this MVP. + // 说明:当前 MVP 阶段使用单写连接更贴合 SQLite 的并发模型。 + // TODO: 当写入吞吐成为瓶颈时,重新评估连接池策略。 db.SetMaxOpenConns(1) if err := db.Ping(); err != nil { @@ -53,6 +53,7 @@ func NewSQLiteStore(dbPath string) (*SQLiteStore, error) { return s, nil } +// Close 释放底层 SQLite 数据库连接。 func (s *SQLiteStore) Close() error { if s == nil || s.db == nil { return nil @@ -60,6 +61,7 @@ func (s *SQLiteStore) Close() error { return s.db.Close() } +// InitSchema 在表不存在时创建存储表结构。 func (s *SQLiteStore) InitSchema(ctx context.Context) error { const schema = ` CREATE TABLE IF NOT EXISTS instances ( @@ -76,6 +78,7 @@ CREATE TABLE IF NOT EXISTS instances ( return nil } +// Save 按容器 ID 执行实例记录的 upsert。 func (s *SQLiteStore) Save(ctx context.Context, instance model.Instance) error { const upsert = ` INSERT INTO instances(container_id, name, status) @@ -89,7 +92,7 @@ DO UPDATE SET _, err := s.db.ExecContext(ctx, upsert, instance.ContainerID, instance.Name, instance.Status) if err != nil { if isUniqueNameErr(err) { - return ErrNameExists + return model.ErrNameExists } return fmt.Errorf("save instance: %w", err) } @@ -97,6 +100,7 @@ DO UPDATE SET return nil } +// Delete 按容器 ID 删除一条实例记录。 func (s *SQLiteStore) Delete(ctx context.Context, containerID string) error { if _, err := s.db.ExecContext(ctx, "DELETE FROM instances WHERE container_id = ?", containerID); err != nil { return fmt.Errorf("delete instance: %w", err) @@ -104,6 +108,7 @@ func (s *SQLiteStore) Delete(ctx context.Context, containerID string) error { return nil } +// Get 按容器 ID 获取一条实例记录。 func (s *SQLiteStore) Get(ctx context.Context, containerID string) (model.Instance, bool, error) { const q = ` SELECT container_id, name, status @@ -122,6 +127,7 @@ WHERE container_id = ?; return inst, true, nil } +// List 返回所有实例记录,并按创建时间倒序排列。 func (s *SQLiteStore) List(ctx context.Context) ([]model.Instance, error) { const q = ` SELECT container_id, name, status @@ -151,6 +157,9 @@ ORDER BY created_at DESC; return out, nil } +// isUniqueNameErr 判断是否为 instances.name 的唯一约束冲突。 +// 说明:当前实现依赖 SQLite 错误文本匹配。 +// TODO: 用 SQLite 错误码替代文本匹配判断。 func isUniqueNameErr(err error) bool { if err == nil { return false diff --git a/backend/internal/store/sqlite_test.go b/backend/internal/store/sqlite_test.go new file mode 100644 index 0000000..6826bf0 --- /dev/null +++ b/backend/internal/store/sqlite_test.go @@ -0,0 +1,168 @@ +package store + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "minedock/backend/internal/model" +) + +func newTestStore(t *testing.T) *SQLiteStore { + t.Helper() + path := filepath.Join(t.TempDir(), "test.db") + s, err := NewSQLiteStore(path) + if err != nil { + t.Fatalf("new test store: %v", err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +func TestNewSQLiteStore_EmptyPath(t *testing.T) { + _, err := NewSQLiteStore("") + if err == nil { + t.Fatal("expected error for empty path") + } +} + +func TestSaveAndGet(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + inst := model.Instance{ContainerID: "c1", Name: "server-1", Status: "Stopped"} + if err := s.Save(ctx, inst); err != nil { + t.Fatalf("save: %v", err) + } + + got, ok, err := s.Get(ctx, "c1") + if err != nil { + t.Fatalf("get: %v", err) + } + if !ok { + t.Fatal("expected instance to exist") + } + if got.ContainerID != "c1" || got.Name != "server-1" || got.Status != "Stopped" { + t.Fatalf("unexpected instance: %+v", got) + } +} + +func TestSave_Upsert(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + inst := model.Instance{ContainerID: "c1", Name: "server-1", Status: "Stopped"} + if err := s.Save(ctx, inst); err != nil { + t.Fatalf("first save: %v", err) + } + + inst.Status = "Running" + if err := s.Save(ctx, inst); err != nil { + t.Fatalf("upsert save: %v", err) + } + + got, _, err := s.Get(ctx, "c1") + if err != nil { + t.Fatalf("get: %v", err) + } + if got.Status != "Running" { + t.Fatalf("expected Running, got %s", got.Status) + } +} + +func TestSave_DuplicateName(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + if err := s.Save(ctx, model.Instance{ContainerID: "c1", Name: "dup", Status: "Stopped"}); err != nil { + t.Fatalf("first save: %v", err) + } + + err := s.Save(ctx, model.Instance{ContainerID: "c2", Name: "dup", Status: "Stopped"}) + if !errors.Is(err, model.ErrNameExists) { + t.Fatalf("expected ErrNameExists, got %v", err) + } +} + +func TestGet_NotFound(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + _, ok, err := s.Get(ctx, "nonexistent") + if err != nil { + t.Fatalf("get: %v", err) + } + if ok { + t.Fatal("expected not found") + } +} + +func TestDelete(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + inst := model.Instance{ContainerID: "c1", Name: "server-1", Status: "Stopped"} + if err := s.Save(ctx, inst); err != nil { + t.Fatalf("save: %v", err) + } + + if err := s.Delete(ctx, "c1"); err != nil { + t.Fatalf("delete: %v", err) + } + + _, ok, err := s.Get(ctx, "c1") + if err != nil { + t.Fatalf("get after delete: %v", err) + } + if ok { + t.Fatal("expected instance to be deleted") + } +} + +func TestDelete_NonExistent(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + // 删除不存在的记录不应返回错误。 + if err := s.Delete(ctx, "nonexistent"); err != nil { + t.Fatalf("delete non-existent: %v", err) + } +} + +func TestList(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + instances := []model.Instance{ + {ContainerID: "c1", Name: "alpha", Status: "Running"}, + {ContainerID: "c2", Name: "beta", Status: "Stopped"}, + {ContainerID: "c3", Name: "gamma", Status: "Running"}, + } + for _, inst := range instances { + if err := s.Save(ctx, inst); err != nil { + t.Fatalf("save %s: %v", inst.Name, err) + } + } + + got, err := s.List(ctx) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(got) != 3 { + t.Fatalf("expected 3 instances, got %d", len(got)) + } +} + +func TestList_Empty(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + got, err := s.List(ctx) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(got) != 0 { + t.Fatalf("expected 0 instances, got %d", len(got)) + } +} diff --git a/docs/00_Root_Context.md b/docs/00_Root_Context.md deleted file mode 100644 index 8f98311..0000000 --- a/docs/00_Root_Context.md +++ /dev/null @@ -1,67 +0,0 @@ -# MineDock Root Context - -## 1. 说明 -MineDock 是一个用于管理 Docker 容器实例的系统 - -## 2. 目标 -实现一个支持易配置,功能强的游戏服务器容器化管理平台 - -## 3. 当前系统边界 -### 所有权边界 -#### 边界内 -- 本系统创建的容器 -- 本系统的数据库及其数据 -- 本系统的工作目录及容器挂载配置的数据卷目录 -#### 边界外 -- 非本系统创建的容器 -- 宿主机的其他进程 -- 除上述工作与挂载目录外的其他宿主机文件系统 -### 功能边界 -#### 边界内 -- 容器的生命周期管理与资源配额调度 -- 容器内游戏服务器的快速安装与运行 -- 在线终端交互与控制指令下发 -- 游戏数据的持久化与灾灾备 (快照、备份与回档) -#### 边界外 -- 游戏服务端程序本身的更新与热修复 -### 数据边界 -#### 边界内 -- Docker SDK返回的运行时信息 -- 容器的标准输出日志流 -- 容器的端口暴露配置 -#### 边界外 -- Docker Daemon的集群状态 -- 主机底层的网络状态(如防火墙配置) -### 用户边界 -#### 边界内 -- 单管理员环境结构下的全局容器管控 -#### 边界外 -- 复杂的多租户资源隔离与细粒度角色控制 (RBAC) - -## 4. 目录规范 -```text -MineDock/ -├── backend/ # 后端 Go 服务 -│ ├── data/ # 数据存储目录(如 SQLite 数据库文件) -│ ├── internal/ # 内部私有代码 -│ │ ├── api/ # 路由与 HTTP 处理层 (Handlers, Routers) -│ │ ├── model/ # 领域数据模型定义实体 (如 Instance) -│ │ ├── service/ # 核心业务逻辑服务 (如调用 Docker CLI/SDK) -│ │ └── store/ # 数据持久化/存储交互层 -│ ├── main.go # 后端程序入口 -│ └── go.mod # Go 依赖配置 -├── frontend/ # 前端 Vue 项目 -│ ├── src/ -│ │ ├── api/ # 统一管理后端接口定义与请求封装 -│ │ ├── components/ # 全局复用组件(非全局使用的应当就近存放业务内) -│ │ ├── composables/ # 复用的组合式 API (如 useInstances) -│ │ ├── locales/ # 多语言 i18n 配置文件 -│ │ ├── router/ # 路由配置与全局路由守卫 -│ │ ├── stores/ # 全局跨组件状态管理 (Pinia) -│ │ └── views/ # 路由级别的业务页面组件 -│ ├── package.json # Npm 依赖配置 -│ └── vite.config.js # Vite 构建配置 -├── docs/ # 相关架构规范与领域文档 -├── Taskfile.yml # 自动化任务构建配置 -└── Readme.md # 项目文档与入口 -``` diff --git a/docs/01_Backend_Standards.md b/docs/01_Backend_Standards.md deleted file mode 100644 index fca6c3a..0000000 --- a/docs/01_Backend_Standards.md +++ /dev/null @@ -1,21 +0,0 @@ -# 后端规范 - -后端基于以下核心选型构建: -- **开发语言**:Go -- **持久化存储**:SQLite -- **核心依赖**:Docker SDK - -## 架构依赖规则 -为保证代码的可测试性与可维护性,`internal` 目录下的四层架构必须遵循单向依赖规则,严禁循环引用: -- **`api` 层**:作为程序的入口与防腐层,负责依赖注入 `service`,并处理 HTTP 相关的解析与返回。 -- **`service` 层**:作为纯粹的业务中枢,内部调用 `store` 进行持久化获取或通过外部组件交互。 -- **`store` 层**:负责底层数据落地设计(如 SQLite 语句),对外暴露 Interface 或直接的数据操作方法。 -- **`model` 层**:最为底层,被上述三层共同引用,不应依赖 `api`/`service`/`store` 的任何代码。 - -## 编码质量与规范 -- **代码格式化与校验**:`go fmt` 与 `go vet` -- **错误处理**: - - 核心逻辑层要求显式返回 `error`,并在合适的网络层封装为统一规范的 JSON HTTP 响应体(如 `{"status": "error", "message": "..."}`)。 - - 透传错误时,应使用 `%w` (如 `fmt.Errorf("操作容器失败: %w", err)`)保留错误堆栈及上下文。 -- **Panic 处理**:业务处理流程中严禁主动触发 `panic`。仅系统引导阶段(如 SQLite 数据源加载失败、配置文件解析失败等无法提供服务的场景)被允许 `panic` -- **无状态设计**:作为管理平台,除了 `store` 与外部环境,`api` 层与 `service` 层应保持无状态,以防发生状态数据不一致的异常 diff --git a/docs/01_Frontend_Standards.md b/docs/01_Frontend_Standards.md deleted file mode 100644 index d963cbc..0000000 --- a/docs/01_Frontend_Standards.md +++ /dev/null @@ -1,25 +0,0 @@ -# 前端规范 - -前端基于以下核心选型构建: -- **构建工具**:Vite -- **核心框架**:Vue 3 (Composition API) -- **开发语言**: TypeScript -- **状态管理**:Pinia -- **路由控制**:Vue Router - -## 代码质量 -- **代码格式化与校验**: 使用 ESLint + Prettier - -## 模块化与主题化 -前端 UI 必须具备高度的模块化,以支持动态主题(如浅色/暗色模式切换)和未来可能的皮肤更换 -- **禁止硬编码样式**:所有颜色、间距、字体大小必须使用 CSS 变量引入 -- **变量分层**: - - 基础变量:如 `--blue-500`, `--gray-900` - - 语义变量:如 `--bg-primary`, `--text-danger`, `--border-color` -- **主题切换实现**:通过动态修改 `` 或 `` 的 `data-theme` 属性(如 `data-theme="dark"`),结合 CSS 变量覆盖实现低成本主题切换 - -## 国际化规范 -提供多语言支持 -- **文案分离**:所有的界面静态文本禁止在 `.vue` 或 `.js` 文件中硬编码,必须通过 Vue I18n 等插件引入 -- **文件组织**:语言包统一存放于 `src/locales/`,按语言划分(如 `zh-CN.json`, `en-US.json`) -- **动态变量插值**:涉及动态数据的文案使用插值符,例如:"成功启动容器 {containerName}" diff --git a/docs/02_API_Contracts.md b/docs/02_API_Contracts.md deleted file mode 100644 index e9c73e9..0000000 --- a/docs/02_API_Contracts.md +++ /dev/null @@ -1,13 +0,0 @@ -# MineDock API Contracts - -前后端接口定义,后端与Docker Daemon的交互流等 - -## 1. 容器实例生命周期接口 (Instance Lifecycle) - -| 方法 | 路径 | 说明 | 请求参数 | 返回结果 | -| --- | --- | --- | --- | --- | -| GET | `/api/instances` | 获取当前所有容器的列表 | 无 | `[{"container_id":"xxx", "name":"xxx", "status":"xxx"}]` | -| POST | `/api/instances` | 创建一个新容器(初始为 Stopped) | `{"name": "测试服1号"}` | `{"status": "success", "container_id": "xxx"}` | -| POST | `/api/instances/:id/start` | 启动指定容器实例 | 无(ID 在路径中) | `{"status": "success"}` | -| POST | `/api/instances/:id/stop` | 停止指定容器实例 | 无(ID 在路径中) | `{"status": "success"}` | -| DELETE | `/api/instances/:id` | 彻底删除指定容器实例 | 无(ID 在路径中) | `{"status": "success"}` | diff --git a/docs/api/contracts.md b/docs/api/contracts.md new file mode 100644 index 0000000..4231525 --- /dev/null +++ b/docs/api/contracts.md @@ -0,0 +1,80 @@ +# MineDock API Contracts + +## 命名规则 + +## 约定 + +- Base URL: `/api` +- CORS: 允许任意来源,允许方法 `GET,POST,DELETE,OPTIONS` + +## HTTP接口 + +### GET /api/instances + +- 说明:获取当前所有容器的列表 +- 状态码: + - 成功:`200` + - 失败:`500` +- 请求参数:无 +- 返回结果: + +```json +[{ "container_id": "xxx", "name": "xxx", "status": "xxx" }] +``` + +### POST /api/instances + +- 说明:创建一个新容器(初始为 Stopped) +- 状态码: + - 成功:`200` + - 失败:`400`(JSON非法/空名称)、`409`(名称冲突)、`500` +- 请求参数: + +```json +{ "name": "容器1号" } +``` + +- 返回结果: + +```json +{ "status": "success", "container_id": "xxx" } +``` + +### POST /api/instances/:id/start + +- 说明:启动指定容器实例 +- 状态码: + - 成功:`200` + - 失败:`400`(ID非法)、`500` +- 请求参数:无(ID 在路径中) +- 返回结果: + +```json +{ "status": "success" } +``` + +### POST /api/instances/:id/stop + +- 说明:停止指定容器实例 +- 状态码: + - 成功:`200` + - 失败:`400`(ID非法)、`500` +- 请求参数:无(ID 在路径中) +- 返回结果: + +```json +{ "status": "success" } +``` + +### DELETE /api/instances/:id + +- 说明:彻底删除指定容器实例 +- 状态码: + - 成功:`200` + - 失败:`400`(ID非法)、`409`(实例运行中)、`500` +- 请求参数:无(ID 在路径中) +- 返回结果: + +```json +{ "status": "success" } +``` diff --git a/docs/design-docs/instance_lifecycle.md b/docs/design-docs/instance_lifecycle.md new file mode 100644 index 0000000..2f26bae --- /dev/null +++ b/docs/design-docs/instance_lifecycle.md @@ -0,0 +1,50 @@ +# 容器实例生命周期 + +## 数据结构 + +### 后端 + +```go +type Instance struct { + // 映射的 Docker 容器唯一标识 + ContainerID string `json:"container_id"` + // 服务端名称 + Name string `json:"name"` + // 当前运行态 + Status string `json:"status"` +} +``` + +### 数据库 + +- `instances` 表字段: +- `container_id`(主键) +- `name`(唯一) +- `status` +- `created_at` + +## 接口 + +[../api/contracts.md](../api/contracts.md) + +## 状态流转 + +```mermaid +stateDiagram-v2 + [*] --> Stopped: Create + Stopped --> Running: Start + Running --> Stopped: Stop + Stopped --> [*]: Delete +``` + +## 一致性策略 + +- 事实来源:Docker Daemon。 +- 缓存来源:SQLite(用于实例名、状态缓存和恢复)。 +- 收敛机制:`ListInstances` 周期性/调用时同步 Docker -> SQLite。 + +## 失败与回滚策略 + +- 创建流程:若数据库保存失败,立即强制删除刚创建容器。 +- 启停流程:Docker 操作成功但 DB 更新失败时,接口返回错误;后续列表刷新会将状态重新收敛。 +- 删除流程:Docker 删除成功后若 DB 删除失败,后续列表会因 Docker 不存在而清理状态。 diff --git a/docs/domain_instance_lifecycle.md b/docs/domain_instance_lifecycle.md deleted file mode 100644 index e8de601..0000000 --- a/docs/domain_instance_lifecycle.md +++ /dev/null @@ -1,35 +0,0 @@ -# 容器实例生命周期 - -## 1. 说明 -该领域负责容器实例的完整生命周期管理: -- 查询当前实例列表 -- 创建新实例(不自动启动) -- 启动指定实例 -- 停止指定实例 -- 彻底删除指定实例(要求先停止) - -该领域是当前系统的核心业务能力。 - -## 2. 数据结构 - -### Instance(后端) -```go -type Instance struct { - // 映射的 Docker 容器唯一标识 - ContainerID string `json:"container_id"` - // 服务端名称 (比如 "测试服1号") - Name string `json:"name"` - // 当前运行态 (Running, Stopped) - Status string `json:"status"` -} -``` - -## 3. HTTP 接口 -具体的接口定义请参考 [02_API_Contracts.md](02_API_Contracts.md#1-容器实例生命周期接口-instance-lifecycle)。 - -## 4. 状态流转(当前实现语义) -- 创建成功后:实例进入 `Stopped`,并写入 SQLite 持久化存储。 -- 启动成功后:实例状态更新为 `Running`。 -- 停止成功后:实例状态更新为 `Stopped`。 -- 删除成功后:销毁 Docker 容器,同时从 SQLite 中删除该实例记录;若实例仍为 `Running`,删除请求会被拒绝(需先停止)。 -- 列表查询:以后端从 Docker 引擎读取的受管容器为准,并同步更新 SQLite 中的实例状态信息。 diff --git a/docs/exec-plans/README.md b/docs/exec-plans/README.md new file mode 100644 index 0000000..4419b2f --- /dev/null +++ b/docs/exec-plans/README.md @@ -0,0 +1,82 @@ +# 执行计划目录说明 + +本目录用于存放计划文档 + +- `completed` 存放已经完成的计划 +- `active` 存放当前正在进行的计划 + +## 命名规范 + +`YYYYMMDD-主题关键字.md` + +示例: + +- `20260329-instance-restart.md` +- `20260330-backup-snapshot.md` + +## 内容格式 + +执行计划内容建议遵循以下标准结构模块: + +```markdown +# [目标描述 / Goal Description] + +提供问题的简要描述,相关背景上下文,以及此更改要实现的目标。 + +## 需要评审的内容 (User Review Required) + +记录任何需要用户审查或反馈的内容,例如破坏性更改或重大且重要的设计决策。可以使用 GitHub 提示块(如 `> [!IMPORTANT]` 或 `> [!WARNING]`)高亮强调。 + +## 拟定更改 (Proposed Changes) + +按组件名称(如具体的 package、功能区或依赖层)将要修改的文件分组归类,并按逻辑顺序(例如先依赖后实现)展开描述。建议使用水平分割线来区分不同组件。 + +### [组件名称 / Component Name] + +简述该组件的变化,并按具体文件拆分说明。 +对于具体的变更文件,使用 `[NEW]` 和 `[DELETE]` 标签标明文件的新旧状态,例如: + +#### [MODIFY] 修改的文件名.go (及文件相对路径) + +#### [NEW] 新增的文件名.go + +#### [DELETE] 删除的文件名.go + +## 执行步骤 (Execution Steps) + +将完整的计划拆分为可执行的、细化的步骤列表,使用复选框来追踪进度状态: + +- `[ ]` 待办 (Uncompleted) +- `[/]` 进行中 (In Progress) +- `[x]` 已完成 (Completed) + +推荐使用嵌套列表来拆分复杂的步骤,例如: + +- [ ] 核心模型层设计 + - [ ] 定义 `User` 结构体 + - [ ] 编写对应的单元测试 +- [ ] API 路由注册 +- [/] 当前正在开发的功能 +- [x] 已经处理完毕的事项 + +## 待确认的疑问 (Open Questions) + +记录任何需要澄清的业务逻辑或者架构设计疑问,这些疑问通常会直接影响执行计划的方案。可以使用 GitHub 提示块强调。 + +## 验证计划 (Verification Plan) + +简述你将如何验证上述更改达到了预期效果。 + +### 自动化测试 (Automated Tests) + +- 将要执行的确切测试命令 (如 `go test ./...`)。 + +### 手动验证 (Manual Verification) + +- 如果涉及无法自动化测试的部分(如 UI 调整),提供手动的步骤和期望结果的说明。 +``` + +## TODO 索引生成 + +- 运行 `task docs:todo` 可从仓库注释中的 `TODO: 描述` 自动生成 `docs/exec-plans/TODO.md`。 +- 生成文件用于集中追踪技术债与改进事项,建议在提交前执行一次。 diff --git a/docs/exec-plans/TODO.md b/docs/exec-plans/TODO.md new file mode 100644 index 0000000..a763c60 --- /dev/null +++ b/docs/exec-plans/TODO.md @@ -0,0 +1,20 @@ +# TODO Index + +Generated date: 2026-03-30 +Source pattern: TODO: description + +## Summary + +- Total TODO items: 9 + +## Items + +- [ ] 抽取 start/stop/delete 的公共处理流程。 (backend/internal/api/handlers.go:82) +- [ ] 增加统一的编码错误日志,提升可观测性。 (backend/internal/api/handlers.go:156) +- [ ] 让 Docker 创建与 SQLite 保存具备原子性。 (backend/internal/service/docker_service.go:47) +- [ ] 将逐条 Save 改为批量或事务化同步路径。 (backend/internal/service/docker_service.go:111) +- [ ] 增加并发写保护,避免最后写入覆盖前写入。 (backend/internal/service/docker_service.go:112) +- [ ] 在不影响列表返回的前提下上报同步失败。 (backend/internal/service/docker_service.go:139) +- [ ] 将该函数拆分为存储读取与 Docker 对账两个辅助函数。 (backend/internal/service/docker_service.go:168) +- [ ] 当写入吞吐成为瓶颈时,重新评估连接池策略。 (backend/internal/store/sqlite.go:39) +- [ ] 用 SQLite 错误码替代文本匹配判断。 (backend/internal/store/sqlite.go:162) diff --git a/docs/ops_engineering_standards.md b/docs/ops_engineering_standards.md deleted file mode 100644 index e513a30..0000000 --- a/docs/ops_engineering_standards.md +++ /dev/null @@ -1,69 +0,0 @@ -# Ops & Infrastructure - -本文档定义了项目的本地开发工作流、自动化构建与 CI/CD 规范 - -## 1. 构建命令 -| 指令 | 作用 | 说明 | -| --- | --- | --- | -| `task --list-all` | 查看可用任务 | 输出当前支持的任务列表 | -| `task dev` | 一键启动前后端开发服务 | 并行执行 `backend:dev` 与 `frontend:dev` | -| `task backend:fmt` | 检查后端 Go 代码格式 | 若存在未格式化文件则失败 | -| `task backend:vet` | 执行后端静态检查 | 在 `backend` 目录执行 `go vet ./...` | -| `task backend:test` | 执行后端测试 | 在 `backend` 目录执行 `go test ./...` | -| `task build` | 统一编译前后端 | 依赖 `backend:build` 与 `frontend:build` | -| `task clean` | 清理构建产物 | 清理 `backend/bin` 与 `frontend/dist` | -| `task frontend:install` | 安装前端依赖 | 在 `frontend` 目录执行 `npm install` | -| `task fmt` | 执行全局格式检查 | 当前依赖 `backend:fmt` | -| `task vet` | 执行全局静态检查 | 当前依赖 `backend:vet` | -| `task test` | 执行全局测试 | 当前依赖 `backend:test` | - -## 2. 环境依赖 -| 组件 | 用途 | -| --- | --- | -| Go | 后端编译与运行 | -| Node.js + npm | 前端依赖安装、开发与构建 | -| Docker Engine | 后端容器生命周期能力依赖 | -| task | 统一任务入口 | - -## 3. CI/CD 对接规范 -GitHub Actions 工作流: - -- `.github/workflows/ci.yml` -- `.github/workflows/release.yml` - -### 3.1 CI(代码自动化检查) - -触发条件:`push`、`pull_request` - -执行顺序: - -1. 准备 Go 与 Node.js 环境 -2. 安装 `task` -3. 执行 `task frontend:install` -4. 执行 `task fmt` -5. 执行 `task test` -6. 执行 `task vet` -7. 执行 `task build` - -### 3.2 Release(跨平台编译发布) - -触发条件:推送标签 `v*`(例如 `v0.1.0`) - -执行顺序: - -1. 准备 Go 与 Node.js 环境 -2. 安装 `task` -3. 执行 `task frontend:install` -4. 执行 `task frontend:build`(仅构建一次) -5. 交叉编译后端二进制(`linux/amd64`、`windows/amd64`、`darwin/amd64`、`darwin/arm64`) -6. 打包发布产物(包含后端二进制、前端 `dist`、`Readme.md`) -7. 上传到 GitHub Release - -## 4. 本地开发工作流 - -推荐执行顺序如下: - -1. 安装前端依赖:`task frontend:install` -2. 启动开发环境:`task dev` -3. 发布前构建验证:`task build` -4. 收尾清理:`task clean` diff --git a/docs/standards/backend.md b/docs/standards/backend.md new file mode 100644 index 0000000..434a9a1 --- /dev/null +++ b/docs/standards/backend.md @@ -0,0 +1,78 @@ +# 后端规范 + +## 1. 基础规范 + +### 技术栈 + +- 开发语言:Go +- 持久化存储:SQLite +- 核心依赖:Docker SDK +- HTTP 框架:标准库 `net/http` + `http.ServeMux` +- 数据库驱动:`modernc.org/sqlite` + +### 格式化 + +- 所有 Go 文件必须通过 `gofmt`。 +- 本地执行:`task backend:fmt:check`,仓库级执行:`task fmt:check`。 + +### 注释(Go Doc) + +- 导出符号(大写开头)**必须**以符号名开头的完整句子注释。 +- TODO 规范:`// TODO: 描述内容`。 + +## 2. 核心机制与控制流 + +### 上下文与并发 + +- Context 传递: + - `ctx` 必须作为函数第一个参数。 + - Handler 层通过 `r.Context()` 获取生命周期。 + - 禁止将 `context.Context` 存储在结构体中。 +- Goroutine 管理: + - 启动 Goroutine 时必须明确其**退出机制**(通过 ctx 或 channel)。 + - 禁止在 `init()` 函数中启动后台任务。 + +### 错误处理 + +- 底层错误统一使用 `fmt.Errorf("...: %w", err)` 包装。 +- 对于可预见的业务逻辑错误,在包级别定义 `ErrNotFound` 等变量,便于 `errors.Is()` 判断。 +- 优先处理错误流,减少 `else` 嵌套。 +- Handler 层错误映射:业务错误到 HTTP 状态码的映射应集中管理(如统一查表函数),避免在各 Handler 中重复硬编码。 + +## 3. 架构与设计模式 + +### 依赖注入 + +- 构造函数:组件通过 `New...` 函数初始化,并在此处注入依赖。 +- 接口定义:**消费方**在自己的包中定义所需接口(Go 惯例), + 接口应尽量小,仅包含实际调用的方法。 +- 解耦底线:Handler 禁止直接操作 Docker SDK 或 SQL, + 仅通过 Service 层暴露的接口交互。 + +## 4. 数据与存储 + +### 数据库 + +- 并发控制:SQLite 必须限制 `SetMaxOpenConns(1)` 以避免 `database is locked` 错误。 +- 事务处理:涉及多表更新的操作必须在 Store 层封装事务,并确保 `defer tx.Rollback()`。 +- Upsert 模式:优先使用 `ON CONFLICT` 处理幂等写入。 + +### API + +[../api/contracts.md](../api/contracts.md) + +## 5. 可观测性 + +### 日志规范 + +- 启动与致命错误使用标准库日志输出。 +- 禁止记录无意义的 `err != nil`。日志应包含:**动作+对象+关键ID+原始错误**。 + +## 6. 质量保障 + +### 测试 + +- 后端测试入口:`task backend:test`(`go test ./...`)。 +- Lint 检查:`task backend:lint`(`golangci-lint run ./...`)。 +- 静态检查:`task backend:vet`(`go vet ./...`)。 +- 提交前最低门禁建议:`task fmt:check && task lint && task vet && task test && task build`。 diff --git a/docs/standards/directory.md b/docs/standards/directory.md new file mode 100644 index 0000000..9c61602 --- /dev/null +++ b/docs/standards/directory.md @@ -0,0 +1,30 @@ +# 目录规范 + +```text +MineDock/ +├── .github/ # 存放 CI/CD +├── backend/ # 后端 Go 服务 +│ ├── data/ # 数据存储目录 +│ ├── internal/ # 内部私有代码 +│ │ ├── api/ # 路由与 HTTP 处理层 +│ │ ├── model/ # 领域数据模型定义 +│ │ ├── service/ # 核心业务逻辑层 +│ │ └── store/ # 数据持久化/存储交互层 +│ ├── main.go # 后端程序入口 +│ └── go.mod # Go 依赖配置 +├── frontend/ # 前端 Vue 项目 +│ ├── src/ +│ │ ├── api/ # 统一管理后端接口定义与请求封装 +│ │ ├── components/ # 全局复用组件 +│ │ ├── composables/ # 复用的组合式API +│ │ ├── locales/ # 多语言 i18n 配置文件 +│ │ ├── router/ # 路由配置与全局路由守卫 +│ │ ├── stores/ # 全局状态管理 +│ │ └── views/ # 路由级别的业务页面组件 +│ ├── package.json # Npm 依赖配置 +│ └── vite.config.js # Vite 构建配置 +├── docs/ # 文档 +├── Taskfile.yml # 构建配置 +├── AGENTS.md # 文档导航 +└── Readme.md # 项目介绍 +``` diff --git a/docs/standards/frontend.md b/docs/standards/frontend.md new file mode 100644 index 0000000..d2904af --- /dev/null +++ b/docs/standards/frontend.md @@ -0,0 +1,82 @@ +# 前端规范 + +## 1. 基础与类型安全 + +### 技术栈 + +- 构建工具:Vite +- 核心框架:Vue 3 (Composition API) +- 开发语言: TypeScript +- 状态管理:Pinia +- 路由控制:Vue Router + +### 格式化 + +- 使用 ESLint + Prettier。 +- 使用 TypeScript 严格模式。 + +### 注释规范 + +- 注释整体以说明“做什么”为主,避免逐行复述代码。 +- 关键决策点允许补充“为什么”,例如性能权衡、兼容性约束、并发/时序约束、回退策略。 +- 以下场景必须补充注释: + - `api/` 中不直观的数据映射与错误转换。 + - `stores/` 中包含副作用或跨模块状态同步的 action。 + - `composables/` 导出函数的输入、输出与边界条件。 + - 组件中不直观的交互状态机或复杂条件分支。 +- 注释必须与代码同步维护:逻辑变更后,失效注释必须同步更新或删除。 +- TODO 规范:`// TODO: 描述内容`。 + +## 2. 架构与组件设计 + +- `views/` 放路由级页面。 +- `components/` 放可复用 UI 组件。 +- `stores/` 放 Pinia 业务状态和副作用聚合。 +- `api/` 统一封装网络调用,禁止在页面组件里散落 `fetch`。 +- 页面组件使用 `script setup` + Composition API 组织逻辑。 + +## 3. 状态管理 + +- 全局状态统一使用 Pinia。 +- 业务动作在 store 中实现并返回执行结果。 +- 视图层只负责触发动作和渲染,不直接维护后端数据副本。 + +## 4. 数据交互与网络 + +### API + +[../api/contracts.md](../api/contracts.md) + +网络约定: + +- 基础地址读取 `VITE_API_BASE_URL`,默认回退 `/api`。 +- 通用请求函数负责: + - JSON 序列化与 `Content-Type` 注入 + - 默认 `Accept: application/json` + - 非 2xx 响应统一抛错(优先读取后端 `error` 字段) +- 所有业务 API 通过统一封装函数导出。 + +## 5. 样式与 UI 框架 + +- 禁止硬编码样式:所有颜色、间距、字体大小必须使用 CSS 变量引入 +- 变量分层: + - 基础变量:如 `--blue-500`, `--gray-900` + - 语义变量:如 `--bg-primary`, `--text-danger`, `--border-color` +- 主题切换实现:通过动态修改 `` 或 `` 的 `data-theme` 属性 + (如 `data-theme="dark"`),结合 CSS 变量覆盖实现低成本主题切换 + +## 6. 路由与鉴权 + +- 当前仅包含基础路由 `/`,映射容器列表页。 +- 当前版本无登录鉴权。 +- 新增页面时要求: + - 在 `router/index.ts` 显式注册路由 + - 路由名称与页面职责一致 + - 若后续引入鉴权,统一通过全局前置守卫实现 + +## 7. 异常处理 + +- API 异常统一在 store 层收敛并转换为可读错误信息。 +- 视图层对关键破坏性操作进行二次确认(如删除实例)。 +- 统一将成功/失败反馈写入 `output` 区域,避免静默失败。 +- 国际化文案通过 `vue-i18n` 管理,错误提示使用 i18n key 输出。 diff --git a/docs/standards/ops.md b/docs/standards/ops.md new file mode 100644 index 0000000..5ff17d4 --- /dev/null +++ b/docs/standards/ops.md @@ -0,0 +1,63 @@ +# 运维规范 + +## 构建 + +- `task frontend:install`:安装前端依赖 +- `task dev`:并行启动前后端开发服务 +- `task build`:构建前后端产物 +- `task clean`:清理构建产物 +- `task fmt:check` / `task lint` / `task test` / `task vet`:质量检查 + +构建产物约定: + +- 后端:`backend/bin/minedock-backend(.exe)` +- 前端:`frontend/dist` + +环境变量约定: + +- 后端: + - `MINEDOCK_DB_PATH`(默认 `data/minedock.db`) + - `MINEDOCK_IMAGE`(默认 `alpine:latest`) +- 前端: + - `VITE_API_BASE_URL`(默认 `/api`) + +## CI/CD + +CI(`.github/workflows/ci.yml`)规则: + +- 触发条件:`push`、`pull_request` +- 运行环境:`ubuntu-latest` +- 校验顺序: + 1. 安装前端依赖 + 2. `task fmt:check` + 3. `task lint` + 4. `task test` + 5. `task vet` + 6. `task build` + +Release(`.github/workflows/release.yml`)规则: + +- 触发条件:tag push(`v*`) +- 发布前检查:前端格式检查、前端 lint、前端构建 +- 打包目标: + - `linux/amd64` (`tar.gz`) + - `windows/amd64` (`zip`) + - `darwin/amd64` (`tar.gz`) + - `darwin/arm64` (`tar.gz`) +- 归档内容: + - 后端可执行文件 + - 前端 `dist` + - `Readme.md` + +### 命名规范 + +- 正式版本标签:`vX.Y.Z` +- 预发布标签:`vX.Y.Z-alpha.N` / `vX.Y.Z-beta.N` / `vX.Y.Z-rc.N` +- 不符合上述规则的 tag 禁止进入发布流程 + +### 必要检查 + +- 合并到主线前必须通过:`fmt:check`、`lint`、`test`、`vet`、`build` +- 发布前必须确保: + - 前端已完成一次独立构建 + - Release 产物可在目标平台解压并运行 diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 2284d1f..c75f568 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -8,11 +8,43 @@ interface RequestOptions extends Omit { body?: BodyInit | JsonObject | null; } +export interface ApiRequestErrorInfo { + key: string; + status?: number; + backendMessage?: string; +} + +export class ApiRequestError extends Error { + readonly key: string; + readonly status?: number; + readonly backendMessage?: string; + + constructor(info: ApiRequestErrorInfo) { + super(info.key); + this.name = "ApiRequestError"; + this.key = info.key; + this.status = info.status; + this.backendMessage = info.backendMessage; + } +} + function isPlainObject(value: unknown): value is JsonObject { return Object.prototype.toString.call(value) === "[object Object]"; } +function getResponseBackendError(data: unknown): string | undefined { + // 后端契约约定优先返回 error 字段,前端统一在这里提取后再映射到 i18n key。 + if (data && typeof data === "object" && "error" in data) { + const error = (data as { error?: unknown }).error; + if (typeof error === "string" && error.trim().length > 0) { + return error.trim(); + } + } + return undefined; +} + async function request(path: string, options: RequestOptions = {}): Promise { + // 统一处理请求体序列化、默认请求头和非 2xx 错误转换。 const { headers: customHeaders, body, ...rest } = options; const headers = new Headers(customHeaders); @@ -32,16 +64,24 @@ async function request(path: string, options: RequestOptions = {}): headers.set("Accept", "application/json"); } - const resp = await fetch(`${BASE_URL}${path}`, { - ...rest, - headers, - body: finalBody, - }); + let resp: Response; + try { + resp = await fetch(`${BASE_URL}${path}`, { + ...rest, + headers, + body: finalBody, + }); + } catch { + throw new ApiRequestError({ key: "errors.network" }); + } - const data = await resp.json().catch(() => ({})); + const data: unknown = await resp.json().catch(() => ({})); if (!resp.ok) { - const message = data?.error || `request failed: ${resp.status}`; - throw new Error(message); + throw new ApiRequestError({ + key: "errors.httpStatus", + status: resp.status, + backendMessage: getResponseBackendError(data), + }); } return data as T; } diff --git a/frontend/src/components/Sidebar.vue b/frontend/src/components/Sidebar.vue index 7c9cfcc..f379614 100644 --- a/frontend/src/components/Sidebar.vue +++ b/frontend/src/components/Sidebar.vue @@ -2,6 +2,7 @@ import { ref } from "vue"; import { RouterLink } from "vue-router"; +// 同一开关状态同时驱动桌面折叠和移动端抽屉,保持两端导航行为一致。 const isOpen = ref(false); diff --git a/frontend/src/components/TopBar.vue b/frontend/src/components/TopBar.vue index 2585e67..db625d4 100644 --- a/frontend/src/components/TopBar.vue +++ b/frontend/src/components/TopBar.vue @@ -15,7 +15,7 @@ const setLocale = (lang: typeof locale.value) => { isOpen.value = false; }; -// Close dropdown when clicking outside +// 语言菜单只在组件内维护,点击外部区域时统一收起。 const handleClickOutside = (event: MouseEvent) => { const target = event.target as HTMLElement; if (!target.closest(".lang-selector")) { @@ -24,6 +24,7 @@ const handleClickOutside = (event: MouseEvent) => { }; onMounted(() => { + // 使用 document 级监听做外部点击检测,卸载时必须成对移除。 document.addEventListener("click", handleClickOutside); }); diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index b685029..a0cd763 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -25,6 +25,19 @@ "starting": "Starting container: {name}...", "startSuccess": "Container {name} start request sent" }, + "errors": { + "network": "Network request failed. Please check your connection and try again.", + "badRequest": "The request is invalid. Please verify your input and retry.", + "notFound": "The requested resource was not found.", + "conflict": "The operation conflicts with the current resource state. Please refresh and retry.", + "internal": "The service is temporarily unavailable. Please try again later.", + "requestFailedWithStatus": "Request failed (status: {status}).", + "invalidJsonBody": "The request body format is invalid.", + "invalidContainerId": "The container ID is invalid.", + "instanceNameExists": "Container name already exists. Please choose another one.", + "instanceRunning": "The container is running. Stop it before deleting.", + "unknown": "An unknown error occurred. Please try again later." + }, "sidebar": { "containerList": "Containers" }, diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index f9f80cd..26bd819 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -25,6 +25,19 @@ "starting": "正在开启容器: {name}...", "startSuccess": "容器 {name} 请求开启成功" }, + "errors": { + "network": "网络请求失败,请检查连接后重试。", + "badRequest": "请求参数无效,请检查输入后重试。", + "notFound": "请求的资源不存在或已被移除。", + "conflict": "当前操作与资源状态冲突,请刷新后重试。", + "internal": "服务暂时不可用,请稍后重试。", + "requestFailedWithStatus": "请求失败(状态码:{status})。", + "invalidJsonBody": "请求体格式不正确。", + "invalidContainerId": "容器 ID 无效。", + "instanceNameExists": "容器名称已存在,请更换后重试。", + "instanceRunning": "容器运行中,请先停止后再删除。", + "unknown": "发生未知错误,请稍后重试。" + }, "sidebar": { "containerList": "容器列表" }, diff --git a/frontend/src/stores/containers.ts b/frontend/src/stores/containers.ts index 657be56..28ef056 100644 --- a/frontend/src/stores/containers.ts +++ b/frontend/src/stores/containers.ts @@ -2,6 +2,7 @@ import { ref } from "vue"; import { defineStore } from "pinia"; import type { Instance } from "../api/index"; import { + ApiRequestError, listInstances, createInstance as apiCreate, deleteInstance as apiDelete, @@ -9,35 +10,101 @@ import { stopInstance as apiStop, } from "../api/index"; +type OutputI18nPayload = { + key: string; + values?: Record; +}; + +const backendMessageKeyMap: Record = { + "name is required": "status.emptyName", + "invalid json body": "errors.invalidJsonBody", + "invalid container id": "errors.invalidContainerId", + "instance name already exists": "errors.instanceNameExists", + "instance is running, stop it before delete": "errors.instanceRunning", +}; + +function isLikelyI18nKey(value: string): boolean { + return /^[a-z][a-z0-9_-]*(?:\.[a-zA-Z0-9_-]+)+$/.test(value); +} + export const useContainerStore = defineStore("containers", () => { const instances = ref([]); + // 统一输出区支持纯文本和 i18n key 两种模式,视图层只负责渲染。 const output = ref(""); + const outputI18n = ref(null); + + function print(data: unknown): void { + outputI18n.value = null; + output.value = typeof data === "string" ? data : JSON.stringify(data, null, 2); + } + + function printI18n(key: string, values?: Record): void { + outputI18n.value = { key, values }; + output.value = ""; + } + + function mapStatusToError(status?: number): OutputI18nPayload { + switch (status) { + case 400: + return { key: "errors.badRequest" }; + case 404: + return { key: "errors.notFound" }; + case 409: + return { key: "errors.conflict" }; + case 500: + return { key: "errors.internal" }; + default: + if (typeof status === "number") { + return { key: "errors.requestFailedWithStatus", values: { status } }; + } + return { key: "errors.unknown" }; + } + } + + // 统一将底层异常映射为稳定的 i18n key,避免泄露网络/后端原始文案。 + function mapErrorToI18n(error: unknown): OutputI18nPayload { + if (error instanceof ApiRequestError) { + if (error.backendMessage) { + const mappedKey = backendMessageKeyMap[error.backendMessage.trim().toLowerCase()]; + if (mappedKey) { + return { key: mappedKey }; + } + } + + if (error.key === "errors.network") { + return { key: "errors.network" }; + } - function getErrorMessage(error: unknown): string { - if (error instanceof Error) return error.message; - if (typeof error === "string") return error; - if (error && typeof error === "object") { - const maybeMessage = (error as { message?: unknown }).message; - if (typeof maybeMessage === "string" && maybeMessage.trim().length > 0) { - return maybeMessage; + if (error.key === "errors.httpStatus") { + return mapStatusToError(error.status); } - try { - return JSON.stringify(error); - } catch { - return String(error); + + if (isLikelyI18nKey(error.key)) { + return { key: error.key }; } } - return String(error); - } - function print(data: unknown): void { - output.value = typeof data === "string" ? data : JSON.stringify(data, null, 2); + if (typeof error === "string" && isLikelyI18nKey(error)) { + return { key: error }; + } + + if (error instanceof Error && isLikelyI18nKey(error.message)) { + return { key: error.message }; + } + + return { key: "errors.unknown" }; } function printError(error: unknown): void { - output.value = `ERROR: ${getErrorMessage(error)}`; + const mapped = mapErrorToI18n(error); + printI18n(mapped.key, mapped.values); + } + + function printErrorKey(key: string, values?: Record): void { + printI18n(key, values); } + // 同步后端实例列表到全局状态,返回值供视图层决定后续提示文案。 async function fetchInstances(): Promise { try { const data = await listInstances(); @@ -49,6 +116,7 @@ export const useContainerStore = defineStore("containers", () => { } } + // 创建实例后立即刷新列表,避免视图层维护后端数据副本。 async function create(name: string): Promise { try { const data = await apiCreate(name); @@ -61,6 +129,7 @@ export const useContainerStore = defineStore("containers", () => { } } + // 删除结果与刷新都在 store 内完成,破坏性确认由视图层负责。 async function remove(containerId: string): Promise { try { const data = await apiDelete(containerId); @@ -71,6 +140,7 @@ export const useContainerStore = defineStore("containers", () => { } } + // start/stop 统一走一个 action,并在 finally 刷新以消除状态漂移。 async function toggle(instance: Instance): Promise { const running = isRunning(instance.status); let success = true; @@ -97,8 +167,10 @@ export const useContainerStore = defineStore("containers", () => { return { instances, output, + outputI18n, print, printError, + printErrorKey, fetchInstances, create, remove, diff --git a/frontend/src/views/ContainerList.vue b/frontend/src/views/ContainerList.vue index 268b959..a598a38 100644 --- a/frontend/src/views/ContainerList.vue +++ b/frontend/src/views/ContainerList.vue @@ -1,5 +1,5 @@