diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 5b8787d..08884ce 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Architecture-and-Code-Map.- -wiki_revision: 98adf48ab517493786cf27b241eecec500fce4c9 -synchronized_at: 2026-08-24T06:15:40Z +wiki_revision: 92ed37aff92d1f54fd18a8aac4f15835b8082251 +synchronized_at: 2026-08-24T06:51:38Z # 架构与代码地图 @@ -395,3 +395,13 @@ Provider 限流检查发生在真实上游调用前。放行后才执行 `BeginP - `internal/platform/apikey` 使用 `crypto/rand` 生成 256 bit secret,令牌格式为 `chorus__`;只计算并保存 secret 的 SHA-256,认证比较使用常量时间。完整令牌与哈希均被排除在默认 JSON 序列化之外。 - `internal/core/apikey` 提供按 `user_id` 隔离的创建、读取、列表、改名和撤销仓储。改名与撤销可安全重放;跨用户读取不会返回其他用户记录。 - `000006` 的 down 在 `api_keys` 或 `api_audit_events` 存在数据时主动失败,防止静默丢失安全数据。不得在生产用 `force` 绕过;只有完成备份、停服、风险确认和数据处置后才能回退。 + + +## #42 用户 API Key 生命周期与 Portal 页面 + +- `portal/handler` 提供会话认证的 `/api-keys` 页面,以及 `GET/POST /api/api-keys`、`PATCH/DELETE /api/api-keys/:id`。写操作沿用 Portal CSRF;这些页面与响应统一禁止缓存。 +- `portal/service` 在事务内锁定并确认终端用户仍为 active,再按 `user_id` 调用 `internal/core/apikey` 仓储;改名、撤销和查询均不能越过用户边界。 +- 创建时由 `internal/platform/apikey` 生成凭据,数据库只保存 public id、可识别前缀与 secret hash。完整 token 只存在于单次创建响应,列表、改名、撤销和刷新响应均不返回。 +- `portal/web/templates/api_keys.html` 与 `static/api-keys.js` 实现加载、空、错误、停用、限流、一次展示、改名和不可恢复撤销状态;移动端改为卡片式行布局,桌面端保持紧凑表格。 +- OpenAPI Bearer 认证和生成接口属于后续工单,不在 #42 中从 API Key 页面直接调用上游。 + diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 7fa5b30..c1dba57 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Business-Rules-and-Glossary.- -wiki_revision: 615749965eaf9a587a618f3698ea6dab52f02df7 -synchronized_at: 2026-08-24T06:15:45Z +wiki_revision: 1bdd0adffefbf1fbb33ddfdab777e2f27e6aef83 +synchronized_at: 2026-08-24T06:51:46Z # 业务规则与术语 @@ -146,3 +146,13 @@ synchronized_at: 2026-08-24T06:15:45Z - Key 的有效性同时取决于未撤销、未到期以及所属终端用户可用;管理员凭据与终端用户 API Key 继续分离。 - API Key 名称去除首尾空白后必须为 1 至 80 字符。改名和撤销按 Key 所属用户执行并支持幂等重放,不物理删除记录。 - `api_audit_events` 是追加写安全审计模型,摘要只能保存脱敏结构化信息;完整 Key、Authorization、Cookie、Prompt、文件内容和响应正文不得进入审计。 + + +## 用户 API Key 生命周期(#42) + +- 终端用户只能查看和管理自己的 API Key;停用用户不能列出、创建、改名或撤销 Key。 +- 名称去除首尾空白后必须为 1 至 80 个 Unicode 字符。创建时有效期只能选择 30、90、180 天或不设置到期时间。 +- 完整 Key 只在创建成功后显示一次并禁止缓存;数据库只保存不可逆 hash,刷新或以后查询不能再次取回。遗失时只能创建新 Key,并撤销旧 Key。 +- 撤销立即生效、不能恢复;重复撤销保持幂等。已过期或已撤销 Key 仍保留名称、前缀和状态,便于用户识别历史记录。 +- API Key 生命周期不包含注册、管理员代管、计费、点数、每日配额或 OpenAPI 调用;请求限流与审计分别由后续单元工单交付。 + diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index d93abf1..081b46f 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Local-Development-and-Verification wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Local-Development-and-Verification.- -wiki_revision: 9661a72756f4c6d53eabd454df03625d382f1ac0 -synchronized_at: 2026-08-24T04:20:17Z +wiki_revision: 71cf8fd56b2a18494d380464f59fc58d81283bd9 +synchronized_at: 2026-08-24T06:51:53Z # 本地开发与验证 @@ -424,3 +424,20 @@ go test ./internal/core/apikey -count=1 ``` `CHORUS_DSN` 与 `CHORUS_MIGRATE_URL` 必须指向同一个明确创建的可丢弃库。测试会重置目标库结构,严禁指向 `chorus`、当前开发库、共享库或生产库。#41 已在本机 MySQL 8.4.8 独立库完成迁移 up/down/up、非空数据 down 拒绝、仓储创建/读取/改名/撤销和事务回滚验证。 + + +## #42 Portal API Key 验证 + +普通回归和前端产物构建: + +```powershell +go test ./... +go vet ./... +go -C admin test ./... +pnpm --dir portal/web build +``` + +涉及真实仓储与 Handler 的验证必须使用显式命名、可丢弃的 MySQL 8 隔离库,不得指向当前开发库、共享库或生产库。#42 已在独立库覆盖 CSRF、创建后仅一次返回完整 token、数据库 hash、刷新不回显、非法有效期、跨用户 404、停用用户 403、改名和幂等撤销。 + +浏览器 E2E 使用 `portal/web/e2e/fixture` 的合成用户和独立库,在 375、768、1024、1440 四种视口执行创建、一次展示、关闭后清除、刷新不可恢复、改名和撤销,并检查无外部请求、无页面横向溢出和至少 44px 的操作目标。测试截图只能在完整 token 已从 DOM 清除后生成。 + diff --git a/internal/core/apikey/repository.go b/internal/core/apikey/repository.go index d5aa950..c2e6607 100644 --- a/internal/core/apikey/repository.go +++ b/internal/core/apikey/repository.go @@ -6,6 +6,7 @@ import ( "fmt" "strings" "time" + "unicode/utf8" "git.ilapage.cn/OPC/chorus/internal/core/model" "gorm.io/gorm" @@ -41,7 +42,7 @@ func (r *GORMRepository) Create(ctx context.Context, key *model.APIKey) error { key.Name = strings.TrimSpace(key.Name) key.PublicID = strings.TrimSpace(key.PublicID) key.KeyPrefix = strings.TrimSpace(key.KeyPrefix) - if key.UserID == 0 || key.Name == "" || len(key.Name) > 80 || len(key.PublicID) != 24 || len(key.KeyPrefix) != 32 || len(key.SecretHash) != 32 { + if key.UserID == 0 || key.Name == "" || !utf8.ValidString(key.Name) || utf8.RuneCountInString(key.Name) > 80 || len(key.PublicID) != 24 || len(key.KeyPrefix) != 32 || len(key.SecretHash) != 32 { return ErrInvalidAPIKey } if err := r.db.WithContext(ctx).Create(key).Error; err != nil { @@ -86,7 +87,7 @@ func (r *GORMRepository) ListForUser(ctx context.Context, userID uint64) ([]mode func (r *GORMRepository) Rename(ctx context.Context, id, userID uint64, name string) (bool, error) { name = strings.TrimSpace(name) - if id == 0 || userID == 0 || name == "" || len(name) > 80 { + if id == 0 || userID == 0 || name == "" || !utf8.ValidString(name) || utf8.RuneCountInString(name) > 80 { return false, ErrInvalidAPIKey } result := r.db.WithContext(ctx).Model(&model.APIKey{}). diff --git a/portal/handler/api_keys.go b/portal/handler/api_keys.go new file mode 100644 index 0000000..29b5aeb --- /dev/null +++ b/portal/handler/api_keys.go @@ -0,0 +1,126 @@ +package handler + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "time" + + "git.ilapage.cn/OPC/chorus/portal/service" + "git.ilapage.cn/OPC/chorus/portal/web" + "github.com/gin-gonic/gin" +) + +const apiKeyBodyLimit = 16 << 10 + +func (h *Handler) apiKeysPage(c *gin.Context) { + state := currentSession(c) + if state.UserID == 0 { + c.Redirect(http.StatusSeeOther, "/login?return_to=%2Fapi-keys") + return + } + user, err := h.service.User(c.Request.Context(), state.UserID) + if err != nil { + c.Status(http.StatusUnauthorized) + return + } + c.Header("Cache-Control", "no-store") + c.Header("Content-Type", "text/html; charset=utf-8") + if err := h.renderer.Render(c.Writer, "api-keys", web.Page{Title: "API Key", DisplayName: user.DisplayName, CSRFToken: state.CSRFToken}); err != nil { + c.Status(http.StatusInternalServerError) + } +} + +func (h *Handler) listAPIKeys(c *gin.Context) { + keys, err := h.service.ListAPIKeys(c.Request.Context(), currentSession(c).UserID, time.Now().UTC()) + if err != nil { + h.apiKeyServiceError(c, err) + return + } + noStore(c) + c.JSON(http.StatusOK, gin.H{"items": keys}) +} + +func (h *Handler) createAPIKey(c *gin.Context) { + var input struct { + Name string `json:"name"` + ExpiresInDays *int `json:"expires_in_days"` + } + if !decodeAPIKeyJSON(c, &input) { + return + } + created, err := h.service.CreateAPIKey(c.Request.Context(), currentSession(c).UserID, input.Name, input.ExpiresInDays, time.Now().UTC()) + if err != nil { + h.apiKeyServiceError(c, err) + return + } + noStore(c) + c.JSON(http.StatusCreated, created) +} + +func (h *Handler) renameAPIKey(c *gin.Context) { + id, ok := uintParam(c, "id") + if !ok { + return + } + var input struct { + Name string `json:"name"` + } + if !decodeAPIKeyJSON(c, &input) { + return + } + key, err := h.service.RenameAPIKey(c.Request.Context(), currentSession(c).UserID, id, input.Name, time.Now().UTC()) + if err != nil { + h.apiKeyServiceError(c, err) + return + } + noStore(c) + c.JSON(http.StatusOK, gin.H{"api_key": key}) +} + +func (h *Handler) revokeAPIKey(c *gin.Context) { + id, ok := uintParam(c, "id") + if !ok { + return + } + key, err := h.service.RevokeAPIKey(c.Request.Context(), currentSession(c).UserID, id, time.Now().UTC()) + if err != nil { + h.apiKeyServiceError(c, err) + return + } + noStore(c) + c.JSON(http.StatusOK, gin.H{"api_key": key}) +} + +func decodeAPIKeyJSON(c *gin.Context, target any) bool { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, apiKeyBodyLimit) + decoder := json.NewDecoder(c.Request.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil || decoder.Decode(&struct{}{}) != io.EOF { + writeError(c, http.StatusBadRequest, "invalid_request", "request body is invalid") + return false + } + return true +} + +func (h *Handler) apiKeyServiceError(c *gin.Context, err error) { + switch { + case errors.Is(err, service.ErrAccountDisabled): + noStore(c) + writeError(c, http.StatusForbidden, "account_disabled", "account is not allowed to manage API keys") + case errors.Is(err, service.ErrInvalidAPIKeyName): + writeError(c, http.StatusBadRequest, "invalid_api_key_name", "API key name is invalid") + case errors.Is(err, service.ErrInvalidAPIKeyExpiry): + writeError(c, http.StatusBadRequest, "invalid_api_key_expiry", "API key expiry is invalid") + case errors.Is(err, service.ErrNotFound): + writeError(c, http.StatusNotFound, "not_found", "resource was not found") + default: + writeError(c, http.StatusInternalServerError, "internal_error", "request could not be completed") + } +} + +func noStore(c *gin.Context) { + c.Header("Cache-Control", "no-store") + c.Header("Pragma", "no-cache") +} diff --git a/portal/handler/mysql_integration_test.go b/portal/handler/mysql_integration_test.go index 0c34663..979cb07 100644 --- a/portal/handler/mysql_integration_test.go +++ b/portal/handler/mysql_integration_test.go @@ -22,6 +22,7 @@ import ( "git.ilapage.cn/OPC/chorus/internal/core/queue" corerouter "git.ilapage.cn/OPC/chorus/internal/core/router" corestorage "git.ilapage.cn/OPC/chorus/internal/core/storage" + platformapikey "git.ilapage.cn/OPC/chorus/internal/platform/apikey" passwordpkg "git.ilapage.cn/OPC/chorus/internal/platform/password" platformstorage "git.ilapage.cn/OPC/chorus/internal/platform/storage" "git.ilapage.cn/OPC/chorus/portal/auth" @@ -97,8 +98,12 @@ func TestPortalAuthenticationSubmissionAndAuthorization(t *testing.T) { t.Fatal(err) } var databaseName string - if err := db.Raw("SELECT DATABASE()").Scan(&databaseName).Error; err != nil || databaseName != "chorus_test" { - t.Fatalf("portal integration requires chorus_test database, got %q: %v", databaseName, err) + expectedDatabase := strings.TrimSpace(os.Getenv("CHORUS_MIGRATION_TEST_DATABASE")) + if expectedDatabase == "" { + expectedDatabase = "chorus_test" + } + if err := db.Raw("SELECT DATABASE()").Scan(&databaseName).Error; err != nil || databaseName != expectedDatabase { + t.Fatalf("portal integration requires %s database, got %q: %v", expectedDatabase, databaseName, err) } sqlDB, _ := db.DB() defer sqlDB.Close() @@ -122,6 +127,7 @@ func TestPortalAuthenticationSubmissionAndAuthorization(t *testing.T) { } routeIDs, providerID := createPortalRoutes(t, db, suffix, templates) defer func() { + db.Where("user_id IN ?", []uint64{users[0].ID, users[1].ID}).Delete(&model.APIKey{}) db.Where("user_id IN ?", []uint64{users[0].ID, users[1].ID}).Delete(&model.Generation{}) db.Exec("DELETE FROM active_routes WHERE route_pool_id IN ?", routeIDs) db.Exec("DELETE FROM route_pools WHERE id IN ?", routeIDs) @@ -208,6 +214,47 @@ func TestPortalAuthenticationSubmissionAndAuthorization(t *testing.T) { if workspace.Code != http.StatusOK || !strings.Contains(workspace.Body.String(), "User A") || !strings.Contains(workspace.Body.String(), `content="1000"`) || !strings.Contains(workspace.Body.String(), `content="3"`) || strings.Contains(workspace.Body.String(), "cdn.") { t.Fatalf("workspace=%d %s", workspace.Code, workspace.Body.String()) } + apiKeyPage := clientA.do(http.MethodGet, "/api-keys", nil, "") + if apiKeyPage.Code != http.StatusOK || !strings.Contains(apiKeyPage.Body.String(), "完整 Key 只在创建成功后显示一次") || !strings.Contains(apiKeyPage.Body.String(), `/static/api-keys.js`) { + t.Fatalf("API key page=%d %s", apiKeyPage.Code, apiKeyPage.Body.String()) + } + createKeyBody, _ := json.Marshal(map[string]any{"name": "自动化脚本", "expires_in_days": 90}) + validCSRF := clientA.csrf + clientA.csrf = "" + if response := clientA.do(http.MethodPost, "/api/api-keys", createKeyBody, "application/json"); response.Code != http.StatusForbidden || response.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("API key CSRF response=%d cache=%q %s", response.Code, response.Header().Get("Cache-Control"), response.Body.String()) + } + clientA.csrf = validCSRF + createKeyResponse := clientA.do(http.MethodPost, "/api/api-keys", createKeyBody, "application/json") + var createdKey struct { + ID uint64 `json:"id"` + Name string `json:"name"` + KeyPrefix string `json:"key_prefix"` + Token string `json:"token"` + ExpiresAt *time.Time `json:"expires_at"` + } + if createKeyResponse.Code != http.StatusCreated || createKeyResponse.Header().Get("Cache-Control") != "no-store" || createKeyResponse.Header().Get("Pragma") != "no-cache" || json.Unmarshal(createKeyResponse.Body.Bytes(), &createdKey) != nil || createdKey.ID == 0 || createdKey.Token == "" || !strings.HasPrefix(createdKey.Token, createdKey.KeyPrefix) || createdKey.ExpiresAt == nil { + t.Fatalf("create API key=%d cache=%q body=%s", createKeyResponse.Code, createKeyResponse.Header().Get("Cache-Control"), createKeyResponse.Body.String()) + } + var storedKey model.APIKey + if err := db.First(&storedKey, createdKey.ID).Error; err != nil { + t.Fatal(err) + } + parsedKey, err := platformapikey.Parse(createdKey.Token) + if err != nil || parsedKey.PublicID != storedKey.PublicID || !bytes.Equal(parsedKey.SecretHash, storedKey.SecretHash) { + t.Fatalf("stored API key does not match one-time token: %v", err) + } + listKeys := clientA.do(http.MethodGet, "/api/api-keys", nil, "") + if listKeys.Code != http.StatusOK || !strings.Contains(listKeys.Body.String(), "自动化脚本") || strings.Contains(listKeys.Body.String(), createdKey.Token) || strings.Contains(listKeys.Body.String(), `secret_hash`) { + t.Fatalf("list API keys=%d %s", listKeys.Code, listKeys.Body.String()) + } + if refreshedPage := clientA.do(http.MethodGet, "/api-keys", nil, ""); strings.Contains(refreshedPage.Body.String(), createdKey.Token) { + t.Fatal("API key page recovered the complete token") + } + invalidExpiry, _ := json.Marshal(map[string]any{"name": "Invalid expiry", "expires_in_days": 365}) + if response := clientA.do(http.MethodPost, "/api/api-keys", invalidExpiry, "application/json"); response.Code != http.StatusBadRequest || strings.Contains(response.Body.String(), createdKey.Token) { + t.Fatalf("invalid API key expiry=%d %s", response.Code, response.Body.String()) + } pendingPage := clientA.do(http.MethodGet, fmt.Sprintf("/generations/%d", textID), nil, "") if pendingPage.Code != http.StatusOK || !strings.Contains(pendingPage.Body.String(), "任务正在排队") || !strings.Contains(pendingPage.Body.String(), "hx-get=") { t.Fatalf("pending page=%d %s", pendingPage.Code, pendingPage.Body.String()) @@ -338,6 +385,31 @@ func TestPortalAuthenticationSubmissionAndAuthorization(t *testing.T) { if response := clientB.do(http.MethodGet, fmt.Sprintf("/api/generations/%d", imageID), nil, ""); response.Code != 404 { t.Fatalf("cross-user detail=%d", response.Code) } + renameKeyBody, _ := json.Marshal(map[string]string{"name": "其他用户不能改名"}) + if response := clientB.do(http.MethodPatch, fmt.Sprintf("/api/api-keys/%d", createdKey.ID), renameKeyBody, "application/json"); response.Code != http.StatusNotFound { + t.Fatalf("cross-user API key rename=%d %s", response.Code, response.Body.String()) + } + if response := clientB.do(http.MethodDelete, fmt.Sprintf("/api/api-keys/%d", createdKey.ID), nil, ""); response.Code != http.StatusNotFound { + t.Fatalf("cross-user API key revoke=%d %s", response.Code, response.Body.String()) + } + renameKeyBody, _ = json.Marshal(map[string]string{"name": "已改名脚本"}) + if response := clientA.do(http.MethodPatch, fmt.Sprintf("/api/api-keys/%d", createdKey.ID), renameKeyBody, "application/json"); response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "已改名脚本") || strings.Contains(response.Body.String(), createdKey.Token) { + t.Fatalf("rename own API key=%d %s", response.Code, response.Body.String()) + } + if err := db.Model(&model.User{}).Where("id = ?", users[0].ID).Update("status", "disabled").Error; err != nil { + t.Fatal(err) + } + if response := clientA.do(http.MethodGet, "/api/api-keys", nil, ""); response.Code != http.StatusForbidden || !strings.Contains(response.Body.String(), `"code":"account_disabled"`) { + t.Fatalf("disabled user API keys=%d %s", response.Code, response.Body.String()) + } + if err := db.Model(&model.User{}).Where("id = ?", users[0].ID).Update("status", "active").Error; err != nil { + t.Fatal(err) + } + firstRevoke := clientA.do(http.MethodDelete, fmt.Sprintf("/api/api-keys/%d", createdKey.ID), nil, "") + secondRevoke := clientA.do(http.MethodDelete, fmt.Sprintf("/api/api-keys/%d", createdKey.ID), nil, "") + if firstRevoke.Code != http.StatusOK || secondRevoke.Code != http.StatusOK || firstRevoke.Body.String() != secondRevoke.Body.String() || !strings.Contains(firstRevoke.Body.String(), `"status":"revoked"`) || strings.Contains(firstRevoke.Body.String(), createdKey.Token) { + t.Fatalf("idempotent API key revoke=%d/%d %s / %s", firstRevoke.Code, secondRevoke.Code, firstRevoke.Body.String(), secondRevoke.Body.String()) + } if response := clientB.do(http.MethodGet, "/api/generations?limit=2&cursor="+firstHistory.NextCursor, nil, ""); response.Code != 400 || !strings.Contains(response.Body.String(), `"code":"invalid_cursor"`) { t.Fatalf("cross-user cursor=%d %s", response.Code, response.Body.String()) } diff --git a/portal/handler/router.go b/portal/handler/router.go index 56c6f37..b0e4bf5 100644 --- a/portal/handler/router.go +++ b/portal/handler/router.go @@ -58,12 +58,21 @@ func NewRouter(sessions *session.Manager, authService *auth.Service, generationS router.Use(handler.sessionMiddleware) router.GET("/login", handler.loginPage) router.GET("/", handler.appPage) + router.GET("/api-keys", handler.apiKeysPage) router.GET("/generations/:id", handler.appPage) router.GET("/ui/generations/:id/result", handler.resultFragment) api := router.Group("/api") api.GET("/session", handler.sessionState) api.POST("/session/login", handler.csrf, handler.login) api.POST("/session/logout", handler.requireAuth, handler.csrf, handler.logout) + apiKeys := api.Group("/api-keys", func(c *gin.Context) { + noStore(c) + c.Next() + }, handler.requireAuth) + apiKeys.GET("", handler.listAPIKeys) + apiKeys.POST("", handler.csrf, handler.createAPIKey) + apiKeys.PATCH("/:id", handler.csrf, handler.renameAPIKey) + apiKeys.DELETE("/:id", handler.csrf, handler.revokeAPIKey) generations := api.Group("/generations", handler.requireAuth) generations.GET("", handler.history) generations.POST("/text", handler.csrf, handler.submitText) diff --git a/portal/service/api_keys.go b/portal/service/api_keys.go new file mode 100644 index 0000000..ae92e5e --- /dev/null +++ b/portal/service/api_keys.go @@ -0,0 +1,178 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + "unicode/utf8" + + coreapikey "git.ilapage.cn/OPC/chorus/internal/core/apikey" + "git.ilapage.cn/OPC/chorus/internal/core/model" + platformapikey "git.ilapage.cn/OPC/chorus/internal/platform/apikey" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var ( + ErrAccountDisabled = errors.New("account_disabled") + ErrInvalidAPIKeyName = errors.New("invalid_api_key_name") + ErrInvalidAPIKeyExpiry = errors.New("invalid_api_key_expiry") +) + +type APIKeySummary struct { + ID uint64 `json:"id"` + Name string `json:"name"` + KeyPrefix string `json:"key_prefix"` + Status string `json:"status"` + ExpiresAt *time.Time `json:"expires_at"` + LastUsedAt *time.Time `json:"last_used_at"` + RevokedAt *time.Time `json:"revoked_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type CreatedAPIKey struct { + APIKeySummary + Token string `json:"token"` +} + +func (s *Service) ListAPIKeys(ctx context.Context, userID uint64, now time.Time) ([]APIKeySummary, error) { + var rows []model.APIKey + err := s.withActiveUser(ctx, userID, func(repository *coreapikey.GORMRepository) error { + var err error + rows, err = repository.ListForUser(ctx, userID) + return err + }) + if err != nil { + return nil, err + } + result := make([]APIKeySummary, 0, len(rows)) + for _, row := range rows { + result = append(result, apiKeySummary(row, now)) + } + return result, nil +} + +func (s *Service) CreateAPIKey(ctx context.Context, userID uint64, name string, expiresInDays *int, now time.Time) (CreatedAPIKey, error) { + name, err := validateAPIKeyName(name) + if err != nil { + return CreatedAPIKey{}, err + } + var expiresAt *time.Time + if expiresInDays != nil { + if *expiresInDays != 30 && *expiresInDays != 90 && *expiresInDays != 180 { + return CreatedAPIKey{}, ErrInvalidAPIKeyExpiry + } + value := now.UTC().Add(time.Duration(*expiresInDays) * 24 * time.Hour) + expiresAt = &value + } + credential, err := platformapikey.Generate() + if err != nil { + return CreatedAPIKey{}, err + } + row := model.APIKey{UserID: userID, Name: name, PublicID: credential.PublicID, KeyPrefix: credential.KeyPrefix, SecretHash: credential.SecretHash, ExpiresAt: expiresAt} + if err := s.withActiveUser(ctx, userID, func(repository *coreapikey.GORMRepository) error { + return repository.Create(ctx, &row) + }); err != nil { + return CreatedAPIKey{}, err + } + return CreatedAPIKey{APIKeySummary: apiKeySummary(row, now), Token: credential.Token}, nil +} + +func (s *Service) RenameAPIKey(ctx context.Context, userID, keyID uint64, name string, now time.Time) (APIKeySummary, error) { + name, err := validateAPIKeyName(name) + if err != nil { + return APIKeySummary{}, err + } + var row model.APIKey + err = s.withActiveUser(ctx, userID, func(repository *coreapikey.GORMRepository) error { + updated, err := repository.Rename(ctx, keyID, userID, name) + if err != nil { + return err + } + if !updated { + return ErrNotFound + } + row, err = repository.ByIDForUser(ctx, keyID, userID) + return err + }) + if err != nil { + return APIKeySummary{}, mapAPIKeyRepositoryError(err) + } + return apiKeySummary(row, now), nil +} + +func (s *Service) RevokeAPIKey(ctx context.Context, userID, keyID uint64, now time.Time) (APIKeySummary, error) { + var row model.APIKey + err := s.withActiveUser(ctx, userID, func(repository *coreapikey.GORMRepository) error { + revoked, err := repository.Revoke(ctx, keyID, userID, now.UTC()) + if err != nil { + return err + } + if !revoked { + return ErrNotFound + } + row, err = repository.ByIDForUser(ctx, keyID, userID) + return err + }) + if err != nil { + return APIKeySummary{}, mapAPIKeyRepositoryError(err) + } + return apiKeySummary(row, now), nil +} + +func (s *Service) withActiveUser(ctx context.Context, userID uint64, operation func(*coreapikey.GORMRepository) error) error { + if userID == 0 { + return ErrAccountDisabled + } + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var user model.User + err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Select("id").Where("id = ? AND status = 'active'", userID).First(&user).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrAccountDisabled + } + if err != nil { + return fmt.Errorf("verify API key user: %w", err) + } + repository, err := coreapikey.NewGORMRepository(tx) + if err != nil { + return err + } + return operation(repository) + }) +} + +func validateAPIKeyName(name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" || !utf8.ValidString(name) || utf8.RuneCountInString(name) > 80 { + return "", ErrInvalidAPIKeyName + } + return name, nil +} + +func mapAPIKeyRepositoryError(err error) error { + if errors.Is(err, coreapikey.ErrAPIKeyNotFound) { + return ErrNotFound + } + return err +} + +func apiKeySummary(key model.APIKey, now time.Time) APIKeySummary { + status := "active" + if key.RevokedAt != nil { + status = "revoked" + } else if key.ExpiresAt != nil && !key.ExpiresAt.After(now) { + status = "expired" + } + return APIKeySummary{ID: key.ID, Name: key.Name, KeyPrefix: key.KeyPrefix, Status: status, ExpiresAt: utcTime(key.ExpiresAt), LastUsedAt: utcTime(key.LastUsedAt), RevokedAt: utcTime(key.RevokedAt), CreatedAt: key.CreatedAt.UTC(), UpdatedAt: key.UpdatedAt.UTC()} +} + +func utcTime(value *time.Time) *time.Time { + if value == nil { + return nil + } + result := value.UTC() + return &result +} diff --git a/portal/service/api_keys_test.go b/portal/service/api_keys_test.go new file mode 100644 index 0000000..e62c107 --- /dev/null +++ b/portal/service/api_keys_test.go @@ -0,0 +1,41 @@ +package service + +import ( + "strings" + "testing" + "time" + + "git.ilapage.cn/OPC/chorus/internal/core/model" +) + +func TestValidateAPIKeyNameUsesCharactersAndTrims(t *testing.T) { + value := strings.Repeat("密", 80) + if got, err := validateAPIKeyName(" " + value + " "); err != nil || got != value { + t.Fatalf("validateAPIKeyName() = %q, %v", got, err) + } + if _, err := validateAPIKeyName(strings.Repeat("密", 81)); err != ErrInvalidAPIKeyName { + t.Fatalf("81-character name error = %v", err) + } + if _, err := validateAPIKeyName(" "); err != ErrInvalidAPIKeyName { + t.Fatalf("blank name error = %v", err) + } +} + +func TestAPIKeySummaryStatus(t *testing.T) { + now := time.Now().UTC() + expiredAt := now.Add(-time.Minute) + revokedAt := now.Add(-time.Hour) + tests := []struct { + key model.APIKey + want string + }{ + {model.APIKey{}, "active"}, + {model.APIKey{ExpiresAt: &expiredAt}, "expired"}, + {model.APIKey{ExpiresAt: &expiredAt, RevokedAt: &revokedAt}, "revoked"}, + } + for _, test := range tests { + if got := apiKeySummary(test.key, now).Status; got != test.want { + t.Errorf("status = %q, want %q", got, test.want) + } + } +} diff --git a/portal/web/e2e/fixture/main.go b/portal/web/e2e/fixture/main.go index a56347f..53a695f 100644 --- a/portal/web/e2e/fixture/main.go +++ b/portal/web/e2e/fixture/main.go @@ -138,6 +138,9 @@ func cleanup(db *gorm.DB, email string) error { var userID uint64 _ = tx.Raw("SELECT id FROM users WHERE email=?", strings.ToLower(strings.TrimSpace(email))).Scan(&userID).Error if userID != 0 { + if err := tx.Exec("DELETE FROM api_keys WHERE user_id=?", userID).Error; err != nil { + return err + } if err := tx.Exec("DELETE FROM generations WHERE user_id=?", userID).Error; err != nil { return err } diff --git a/portal/web/e2e/portal.spec.js b/portal/web/e2e/portal.spec.js index 887fd30..6b4025f 100644 --- a/portal/web/e2e/portal.spec.js +++ b/portal/web/e2e/portal.spec.js @@ -115,6 +115,66 @@ test.describe("portal responsive workflow", () => { }); }); +test.describe("API key responsive workflow", () => { + test.skip(!email || !password, "CHORUS_E2E_EMAIL and CHORUS_E2E_PASSWORD are required"); + + test("create once, rename, refresh without secret, and revoke", async ({ page }, testInfo) => { + const browserErrors = []; + const externalRequests = []; + page.on("console", message => { if (message.type() === "error") browserErrors.push(message.text()); }); + page.on("pageerror", error => browserErrors.push(error.message)); + page.on("request", request => { + const url = new URL(request.url()); + if (url.protocol !== "blob:" && url.origin !== new URL(baseURL).origin) externalRequests.push(request.url()); + }); + + await page.emulateMedia({ reducedMotion: "reduce" }); + await login(page); + await page.getByRole("link", { name: "API Key" }).click(); + await expect(page).toHaveURL(/\/api-keys$/); + await expect(page.getByRole("heading", { name: "API Key", level: 1 })).toBeVisible(); + await expectNoHorizontalOverflow(page); + await expectMinimumTargetSize(page, ".button, .icon-button, .api-key-action, .nav-item"); + + const name = `浏览器测试 ${testInfo.project.name}`; + await page.locator("#create-key-button").click(); + await expect(page.getByRole("dialog", { name: "创建 API Key" })).toBeVisible(); + await page.locator("#api-key-name").fill(name); + await page.locator("#api-key-expiry").selectOption("30"); + await page.locator("#submit-create-key").click(); + await expect(page.getByRole("dialog", { name: "API Key 已创建" })).toBeVisible(); + const token = await page.locator("#secret-key-value").textContent(); + expect(token).toMatch(/^chorus_[A-Za-z0-9_-]{24}_[A-Za-z0-9_-]{43}$/); + await page.locator("#confirm-secret-key").click(); + await expect(page.locator("#secret-key-modal")).toBeHidden(); + await expect(page.locator("#secret-key-value")).toHaveText(""); + await expect(page.getByText(token, { exact: true })).toHaveCount(0); + + let row = page.getByRole("row").filter({ hasText: name }); + await expect(row).toBeVisible(); + await page.reload(); + await expect(page.getByText(token, { exact: true })).toHaveCount(0); + row = page.getByRole("row").filter({ hasText: name }); + await expect(row).toBeVisible(); + await row.getByRole("button", { name: "改名" }).click(); + const renamed = `${name} 已改名`; + await page.locator("#rename-key-name").fill(renamed); + await page.locator("#submit-rename-key").click(); + row = page.getByRole("row").filter({ hasText: renamed }); + await expect(row).toBeVisible(); + await row.getByRole("button", { name: "撤销" }).click(); + await expect(page.getByRole("dialog", { name: "撤销 API Key" })).toContainText(renamed); + await page.locator("#confirm-revoke-key").click(); + row = page.getByRole("row").filter({ hasText: renamed }); + await expect(row).toContainText("已撤销"); + await expect(row.getByRole("button", { name: "撤销" })).toHaveCount(0); + await expectNoHorizontalOverflow(page); + await page.screenshot({ path: testInfo.outputPath("api-keys-revoked.png"), fullPage: true }); + expect(externalRequests).toEqual([]); + expect(browserErrors).toEqual([]); + }); +}); + test("browser submits image generation and ordered image editing", async ({ page }, testInfo) => { test.skip(testInfo.project.name !== "desktop-small", "terminal state flow runs once"); test.skip(!email || !password || !process.env.CHORUS_E2E_DSN || !process.env.CHORUS_E2E_STORAGE_ROOT, "E2E fixture environment is required"); diff --git a/portal/web/src/icons.js b/portal/web/src/icons.js index d7e8e76..041dc1f 100644 --- a/portal/web/src/icons.js +++ b/portal/web/src/icons.js @@ -1,12 +1,12 @@ import { - AlertCircle, ArrowDown, ArrowUp, Check, Clock3, Copy, Download, Eye, EyeOff, - FileText, Image, LogOut, Plus, RefreshCw, Search, ShieldCheck, X, + AlertCircle, ArrowDown, ArrowUp, Ban, Check, CircleCheck, Clock3, Copy, Download, Eye, EyeOff, + FileText, Image, KeyRound, LogOut, Pencil, Plus, RefreshCw, Search, ShieldCheck, TriangleAlert, X, createIcons } from "lucide"; const icons = { - AlertCircle, ArrowDown, ArrowUp, Check, Clock3, Copy, Download, Eye, EyeOff, - FileText, Image, LogOut, Plus, RefreshCw, Search, ShieldCheck, X + AlertCircle, ArrowDown, ArrowUp, Ban, Check, CircleCheck, Clock3, Copy, Download, Eye, EyeOff, + FileText, Image, KeyRound, LogOut, Pencil, Plus, RefreshCw, Search, ShieldCheck, TriangleAlert, X }; window.renderChorusIcons = (root = document) => createIcons({ icons, attrs: { "aria-hidden": "true" }, root }); diff --git a/portal/web/src/input.css b/portal/web/src/input.css index d6476ad..e0c9403 100644 --- a/portal/web/src/input.css +++ b/portal/web/src/input.css @@ -48,7 +48,9 @@ button:disabled { cursor: not-allowed; opacity: .48; } .brand { display: inline-flex; align-items: center; gap: 10px; min-width: 0; color: var(--text); font-size: 20px; font-weight: 750; text-decoration: none; } .brand-mark { display: grid; width: 34px; height: 34px; place-items: center; color: #fff; background: var(--primary); border-radius: 6px; font-size: 17px; font-weight: 800; } .primary-nav { display: flex; align-items: center; align-self: stretch; gap: 8px; } -.nav-item { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; color: var(--primary); text-decoration: none; border-bottom: 3px solid var(--primary); font-weight: 650; } +.nav-item { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; color: var(--text-muted); text-decoration: none; border-bottom: 3px solid transparent; font-weight: 650; } +.nav-item:hover { color: var(--text); background: var(--surface-subtle); } +.nav-item[aria-current="page"] { color: var(--primary); border-bottom-color: var(--primary); } .account-actions { display: flex; align-items: center; gap: 8px; } .user-name { max-width: 130px; overflow: hidden; color: var(--text-muted); text-overflow: ellipsis; white-space: nowrap; } @@ -58,6 +60,8 @@ button:disabled { cursor: not-allowed; opacity: .48; } .button-primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); } .button-secondary { color: var(--text); background: var(--surface); border-color: var(--border-strong); } .button-secondary:hover { background: var(--surface-strong); border-color: #8fa1a5; } +.button-danger { color: #fff; background: var(--danger); border-color: var(--danger); } +.button-danger:hover { background: #862b27; border-color: #862b27; } .button-block { width: 100%; } .icon-button { display: inline-grid; width: 44px; height: 44px; padding: 0; place-items: center; color: var(--text-muted); background: transparent; border: 1px solid transparent; } .icon-button:hover { color: var(--text); background: var(--surface-strong); border-color: var(--border); } @@ -191,6 +195,45 @@ h3 { margin: 0; font-size: 16px; line-height: 1.4; letter-spacing: 0; } .modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; } .toast { position: fixed; z-index: 120; right: 20px; bottom: 20px; display: flex; align-items: center; gap: 10px; max-width: min(420px,calc(100vw - 32px)); padding: 12px 14px; color: #fff; background: #203038; border-radius: 6px; box-shadow: var(--shadow); } +.api-key-main { min-height: calc(100dvh - var(--header-height)); padding: 28px clamp(16px,4vw,48px) 56px; } +.api-key-inner { width: 100%; max-width: 1240px; margin: 0 auto; } +.api-key-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin-bottom: 20px; } +.api-key-heading > div { min-width: 0; } +.api-key-heading p:last-child { max-width: 72ch; margin: 8px 0 0; color: var(--text-muted); } +.api-key-panel { overflow: hidden; } +.api-key-count { flex: 0 0 auto; color: var(--text-muted); font-size: 13px; font-variant-numeric: tabular-nums; } +.api-key-table-wrap { width: 100%; overflow-x: auto; } +.api-key-table { width: 100%; min-width: 940px; border-collapse: collapse; } +.api-key-table th,.api-key-table td { padding: 13px 14px; text-align: left; vertical-align: middle; border-bottom: 1px solid var(--border); } +.api-key-table th { color: var(--text-muted); background: var(--surface-subtle); font-size: 13px; font-weight: 700; white-space: nowrap; } +.api-key-table tbody tr:last-child td { border-bottom: 0; } +.api-key-table tbody tr:hover { background: #fbfdfc; } +.api-key-name { max-width: 220px; font-weight: 700; } +.api-key-prefix { color: #41565a; font-family: ui-monospace,SFMono-Regular,Consolas,monospace; font-size: 13px; } +.api-key-badge { display: inline-flex; align-items: center; min-height: 28px; padding: 3px 9px; border: 1px solid var(--border); border-radius: 999px; font-size: 12px; font-weight: 700; white-space: nowrap; } +.api-key-badge.active { color: var(--success); background: var(--success-soft); border-color: #b6d6c0; } +.api-key-badge.expired { color: var(--warning); background: var(--warning-soft); border-color: #e5c59e; } +.api-key-badge.revoked { color: var(--text-soft); background: var(--surface-strong); } +.api-key-actions { display: flex; align-items: center; gap: 4px; } +.api-key-action { display: inline-flex; align-items: center; gap: 5px; min-height: 44px; padding: 6px 8px; color: var(--primary); background: transparent; border: 0; border-radius: 4px; font-weight: 650; white-space: nowrap; } +.api-key-action:hover { background: var(--primary-soft); } +.api-key-action.danger { color: var(--danger); } +.api-key-action.danger:hover { background: var(--danger-soft); } +.api-key-action .icon { width: 17px; height: 17px; } +.api-key-loading { display: grid; gap: 14px; min-height: 260px; padding: 24px; } +.api-key-loading span { height: 48px; background: linear-gradient(90deg,var(--surface-strong),#f7f9f9,var(--surface-strong)); background-size: 220% 100%; border-radius: 5px; animation: api-key-loading 1.4s ease-in-out infinite; } +@keyframes api-key-loading { from { background-position: 100% 0; } to { background-position: -100% 0; } } +.api-key-state { display: grid; place-items: center; align-content: center; min-height: 330px; padding: 28px; text-align: center; } +.api-key-state p { max-width: 52ch; margin: 8px 0 18px; color: var(--text-muted); } +.modal-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: -8px -8px 18px 0; } +.modal-body { display: grid; gap: 18px; } +.api-key-modal { width: min(100%,520px); } +.one-time-warning { display: flex; align-items: flex-start; gap: 12px; padding: 13px 14px; color: var(--warning); background: var(--warning-soft); border: 1px solid #e5c59e; border-radius: 6px; } +.one-time-warning .icon { flex: 0 0 22px; width: 22px; height: 22px; } +.one-time-warning p { margin: 3px 0 0; color: #694522; font-size: 13px; } +.secret-value { display: block; max-width: 100%; padding: 14px; color: var(--text); background: var(--surface-subtle); border: 1px solid var(--border-strong); border-radius: 6px; overflow-wrap: anywhere; user-select: all; } +.confirm-key { padding: 12px; color: #41565a; background: var(--surface-subtle); border: 1px solid var(--border); border-radius: 6px; font-family: ui-monospace,SFMono-Regular,Consolas,monospace; overflow-wrap: anywhere; } + .login-view { display: grid; min-height: 100dvh; grid-template-columns: minmax(280px,.9fr) minmax(360px,1.1fr); background: var(--surface); } .login-context { display: flex; min-height: 100dvh; flex-direction: column; justify-content: space-between; padding: clamp(28px,6vw,72px); color: #f4fbfa; background: #173a3a; } .login-context .brand { color: #fff; } @@ -219,7 +262,8 @@ h3 { margin: 0; font-size: 16px; line-height: 1.4; letter-spacing: 0; } @media (max-width: 767px) { :root { --header-height: 56px; } .topbar { grid-template-columns: minmax(0,1fr) auto; height: auto; min-height: var(--header-height); padding: 0 12px; } - .primary-nav { display: none; } + .primary-nav { grid-column: 1 / -1; order: 3; width: 100%; overflow-x: auto; } + .nav-item { flex: 1 0 auto; justify-content: center; padding: 0 10px; } .brand { font-size: 18px; } .user-name { display: none; } .workspace { grid-template-columns: minmax(0,1fr); grid-template-areas: "main" "history"; } @@ -238,6 +282,21 @@ h3 { margin: 0; font-size: 16px; line-height: 1.4; letter-spacing: 0; } .order-button { width: 100%; } .drag-hint { display: none; } .toast { right: 16px; bottom: 16px; left: 16px; max-width: none; } + .api-key-main { padding: 20px 12px 32px; } + .api-key-heading { align-items: stretch; flex-direction: column; gap: 14px; } + .api-key-heading .button { width: 100%; } + .api-key-panel .panel-header { align-items: flex-start; } + .api-key-table,.api-key-table tbody,.api-key-table tr,.api-key-table td { display: block; min-width: 0; width: 100%; } + .api-key-table thead { display: none; } + .api-key-table tr { padding: 10px 14px; border-bottom: 1px solid var(--border); } + .api-key-table tr:last-child { border-bottom: 0; } + .api-key-table td { display: grid; grid-template-columns: 108px minmax(0,1fr); gap: 10px; padding: 7px 0; border: 0; } + .api-key-table td::before { content: attr(data-label); color: var(--text-soft); font-size: 13px; font-weight: 650; } + .api-key-name { max-width: none; } + .api-key-actions { flex-wrap: wrap; margin-top: -6px; } + .api-key-action { min-height: 44px; } + .modal-backdrop { padding: 12px; } + .modal { max-height: calc(100dvh - 24px); padding: 20px; overflow-y: auto; } .login-view { grid-template-columns: 1fr; } .login-context { min-height: auto; padding: 24px 20px; } .login-copy h1 { margin-top: 56px; font-size: 32px; } diff --git a/portal/web/static/api-keys.js b/portal/web/static/api-keys.js new file mode 100644 index 0000000..59018e4 --- /dev/null +++ b/portal/web/static/api-keys.js @@ -0,0 +1,289 @@ +(() => { + "use strict"; + + let keys = []; + let selectedKey = null; + let lastTrigger = null; + let toastTimer = null; + + const $ = selector => document.querySelector(selector); + const csrfToken = () => $("meta[name='csrf-token']")?.content || ""; + const show = element => element?.classList.remove("hidden"); + const hide = element => element?.classList.add("hidden"); + const renderIcons = root => window.renderChorusIcons?.(root || document); + + function toast(message) { + $("#toast-text").textContent = message; + show($("#toast")); + window.clearTimeout(toastTimer); + toastTimer = window.setTimeout(() => hide($("#toast")), 4000); + } + + function openModal(id, focusSelector) { + lastTrigger = document.activeElement; + const modal = document.getElementById(id); + show(modal); + window.setTimeout(() => modal?.querySelector(focusSelector)?.focus(), 0); + } + + function closeModal(id) { + hide(document.getElementById(id)); + lastTrigger?.focus?.(); + lastTrigger = null; + } + + function formatDate(value) { + if (!value) return "-"; + return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(value)); + } + + function statusLabel(status) { + return { active: "有效", expired: "已过期", revoked: "已撤销" }[status] || "未知"; + } + + function stateMarkup(type, retryAfter) { + const states = { + empty: ["key-round", "还没有 API Key", "创建后可用于程序调用;完整 Key 只显示一次。"], + error: ["alert-circle", "API Key 加载失败", "已有数据不会被修改。请检查网络后重试。"], + forbidden: ["shield-check", "当前账户无法管理 API Key", "账户已停用或当前会话不再具备访问条件。"], + limited: ["clock-3", "请求过于频繁", `请在 ${retryAfter || 1} 秒后重试。已有 Key 不会受到影响。`] + }; + const [icon, title, message] = states[type]; + const action = type === "empty" ? '' : (type === "error" || type === "limited" ? '' : ""); + return `

${title}

${message}

${action}
`; + } + + function tableCell(label, value, className) { + const cell = document.createElement("td"); + cell.dataset.label = label; + if (className) cell.className = className; + cell.textContent = value; + return cell; + } + + function renderList() { + const content = $("#api-key-content"); + content.setAttribute("aria-busy", "false"); + $("#api-key-count").textContent = `共 ${keys.length} 个`; + if (!keys.length) { + content.innerHTML = stateMarkup("empty"); + renderIcons(content); + return; + } + const wrap = document.createElement("div"); + wrap.className = "api-key-table-wrap"; + const table = document.createElement("table"); + table.className = "api-key-table"; + table.innerHTML = "名称Key 前缀状态最近使用到期时间创建时间操作"; + const tbody = document.createElement("tbody"); + keys.forEach(key => { + const row = document.createElement("tr"); + row.append(tableCell("名称", key.name, "api-key-name")); + row.append(tableCell("Key 前缀", key.key_prefix, "api-key-prefix")); + const status = document.createElement("td"); + status.dataset.label = "状态"; + const badge = document.createElement("span"); + badge.className = `api-key-badge ${key.status}`; + badge.textContent = statusLabel(key.status); + status.append(badge); + row.append(status); + row.append(tableCell("最近使用", key.last_used_at ? formatDate(key.last_used_at) : "从未使用")); + row.append(tableCell("到期时间", key.expires_at ? formatDate(key.expires_at) : "永不过期")); + row.append(tableCell("创建时间", formatDate(key.created_at))); + const actions = document.createElement("td"); + actions.dataset.label = "操作"; + const group = document.createElement("div"); + group.className = "api-key-actions"; + const rename = document.createElement("button"); + rename.className = "api-key-action"; + rename.type = "button"; + rename.dataset.action = "rename"; + rename.dataset.keyId = String(key.id); + rename.innerHTML = '改名'; + group.append(rename); + if (key.status === "active") { + const revoke = document.createElement("button"); + revoke.className = "api-key-action danger"; + revoke.type = "button"; + revoke.dataset.action = "revoke"; + revoke.dataset.keyId = String(key.id); + revoke.innerHTML = '撤销'; + group.append(revoke); + } + actions.append(group); + row.append(actions); + tbody.append(row); + }); + table.append(tbody); + wrap.append(table); + content.replaceChildren(wrap); + renderIcons(content); + } + + function renderRequestState(type, retryAfter) { + const content = $("#api-key-content"); + content.setAttribute("aria-busy", "false"); + $("#api-key-count").textContent = ""; + content.innerHTML = stateMarkup(type, retryAfter); + renderIcons(content); + } + + function handleAccessResponse(response, modalID) { + if (response.status === 401) { + if (modalID) closeModal(modalID); + openModal("auth-modal", "a"); + return true; + } + if (response.status === 403 || response.status === 429) { + if (modalID) closeModal(modalID); + renderRequestState(response.status === 403 ? "forbidden" : "limited", response.headers.get("Retry-After")); + return true; + } + return false; + } + + function apiErrorMessage(payload, fallback) { + return { + invalid_api_key_name: "名称不能为空且不能超过 80 个字符。", + invalid_api_key_expiry: "请选择有效的到期时间。", + not_found: "API Key 不存在或已无法访问。" + }[payload.error?.code] || fallback; + } + + async function loadKeys() { + const content = $("#api-key-content"); + content.setAttribute("aria-busy", "true"); + content.innerHTML = '
'; + try { + const response = await fetch("/api/api-keys", { headers: { Accept: "application/json" }, cache: "no-store" }); + if (response.status === 401) { openModal("auth-modal", "a"); return; } + if (response.status === 403) { renderRequestState("forbidden"); return; } + if (response.status === 429) { renderRequestState("limited", response.headers.get("Retry-After")); return; } + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error?.code || "load_failed"); + keys = Array.isArray(payload.items) ? payload.items : []; + renderList(); + } catch (_) { + renderRequestState("error"); + } + } + + async function createKey(event) { + event.preventDefault(); + const name = $("#api-key-name"); + const error = $("#api-key-name-error"); + hide(error); + if (!name.value.trim()) { error.textContent = "请输入 API Key 名称。"; show(error); name.focus(); return; } + const button = $("#submit-create-key"); + button.disabled = true; + button.querySelector("span").textContent = "正在创建"; + const expiry = $("#api-key-expiry").value; + try { + const response = await fetch("/api/api-keys", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken() }, body: JSON.stringify({ name: name.value, expires_in_days: expiry ? Number(expiry) : null }), cache: "no-store" }); + if (handleAccessResponse(response, "create-key-modal")) return; + const payload = await response.json().catch(() => ({})); + if (!response.ok) { error.textContent = apiErrorMessage(payload, "创建失败,请稍后重试。"); show(error); return; } + keys.unshift({ id: payload.id, name: payload.name, key_prefix: payload.key_prefix, status: payload.status, expires_at: payload.expires_at, last_used_at: payload.last_used_at, revoked_at: payload.revoked_at, created_at: payload.created_at, updated_at: payload.updated_at }); + closeModal("create-key-modal"); + renderList(); + $("#secret-key-value").textContent = payload.token; + hide($("#copy-secret-error")); + openModal("secret-key-modal", "#copy-secret-key"); + name.value = ""; + } catch (_) { + error.textContent = "创建请求失败,请检查网络后重试。"; + show(error); + } finally { + button.disabled = false; + button.querySelector("span").textContent = "创建"; + } + } + + async function renameKey(event) { + event.preventDefault(); + const input = $("#rename-key-name"); + const error = $("#rename-key-error"); + hide(error); + if (!selectedKey || !input.value.trim()) { error.textContent = "请输入 API Key 名称。"; show(error); input.focus(); return; } + const button = $("#submit-rename-key"); + button.disabled = true; + button.querySelector("span").textContent = "正在保存"; + try { + const response = await fetch(`/api/api-keys/${encodeURIComponent(selectedKey.id)}`, { method: "PATCH", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken() }, body: JSON.stringify({ name: input.value }), cache: "no-store" }); + if (handleAccessResponse(response, "rename-key-modal")) return; + const payload = await response.json().catch(() => ({})); + if (!response.ok) { error.textContent = apiErrorMessage(payload, "保存失败,请稍后重试。"); show(error); return; } + const index = keys.findIndex(key => key.id === payload.api_key.id); + if (index >= 0) keys[index] = payload.api_key; + closeModal("rename-key-modal"); + renderList(); + toast("名称已更新"); + } catch (_) { + error.textContent = "保存请求失败,请检查网络后重试。"; + show(error); + } finally { + button.disabled = false; + button.querySelector("span").textContent = "保存"; + } + } + + async function revokeKey() { + if (!selectedKey) return; + const error = $("#revoke-key-error"); + const button = $("#confirm-revoke-key"); + hide(error); + button.disabled = true; + button.querySelector("span").textContent = "正在撤销"; + try { + const response = await fetch(`/api/api-keys/${encodeURIComponent(selectedKey.id)}`, { method: "DELETE", headers: { "X-CSRF-Token": csrfToken() }, cache: "no-store" }); + if (handleAccessResponse(response, "revoke-key-modal")) return; + const payload = await response.json().catch(() => ({})); + if (!response.ok) { error.textContent = apiErrorMessage(payload, "撤销失败,请稍后重试。"); show(error); return; } + const index = keys.findIndex(key => key.id === payload.api_key.id); + if (index >= 0) keys[index] = payload.api_key; + closeModal("revoke-key-modal"); + renderList(); + toast("API Key 已撤销"); + } catch (_) { + error.textContent = "撤销请求失败,请检查网络后重试。"; + show(error); + } finally { + button.disabled = false; + button.querySelector("span").textContent = "确认撤销"; + } + } + + function initialize() { + $("#create-key-button").addEventListener("click", () => openModal("create-key-modal", "#api-key-name")); + $("#create-key-form").addEventListener("submit", createKey); + $("#rename-key-form").addEventListener("submit", renameKey); + $("#confirm-revoke-key").addEventListener("click", revokeKey); + $("#confirm-secret-key").addEventListener("click", () => { $("#secret-key-value").textContent = ""; closeModal("secret-key-modal"); toast("API Key 已创建"); }); + $("#copy-secret-key").addEventListener("click", async () => { + try { await navigator.clipboard.writeText($("#secret-key-value").textContent); hide($("#copy-secret-error")); toast("API Key 已复制"); } + catch (_) { show($("#copy-secret-error")); } + }); + document.addEventListener("click", event => { + const close = event.target.closest("[data-close-modal]"); + if (close) { closeModal(close.dataset.closeModal); return; } + const action = event.target.closest("[data-action]"); + if (!action) return; + if (action.dataset.action === "create") { openModal("create-key-modal", "#api-key-name"); return; } + if (action.dataset.action === "retry") { loadKeys(); return; } + selectedKey = keys.find(key => String(key.id) === action.dataset.keyId); + if (!selectedKey) return; + if (action.dataset.action === "rename") { $("#rename-key-name").value = selectedKey.name; hide($("#rename-key-error")); openModal("rename-key-modal", "#rename-key-name"); } + if (action.dataset.action === "revoke") { $("#revoke-key-summary").textContent = `${selectedKey.name} · ${selectedKey.key_prefix}`; hide($("#revoke-key-error")); openModal("revoke-key-modal", "#confirm-revoke-key"); } + }); + document.addEventListener("keydown", event => { + if (event.key !== "Escape" || !$("#secret-key-modal").classList.contains("hidden")) return; + const open = document.querySelector(".modal-backdrop:not(.hidden)"); + if (open && open.id !== "auth-modal") closeModal(open.id); + }); + $("#logout-button").addEventListener("click", async () => { await fetch("/api/session/logout", { method: "POST", headers: { "X-CSRF-Token": csrfToken() } }).catch(() => {}); window.location.assign("/login"); }); + renderIcons(); + loadKeys(); + } + + document.addEventListener("DOMContentLoaded", initialize); +})(); diff --git a/portal/web/static/app.css b/portal/web/static/app.css index 0b23d5f..32de066 100644 --- a/portal/web/static/app.css +++ b/portal/web/static/app.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.collapse{visibility:collapse}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.static{position:static}.block{display:block}.hidden{display:none}.inline{display:inline}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.resize{resize:both}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}}:root{--bg:#f3f6f6;--surface:#fff;--surface-subtle:#f8faf9;--surface-strong:#edf3f2;--text:#172126;--text-muted:#526169;--text-soft:#6a777e;--border:#d6dfe1;--border-strong:#b8c6c9;--primary:#006b66;--primary-hover:#005853;--primary-soft:#e4f2f0;--info:#245f87;--info-soft:#e8f1f7;--warning:#81501f;--warning-soft:#fff0df;--danger:#a63630;--danger-soft:#fcebea;--success:#2d6b42;--success-soft:#e7f3eb;--focus:#147bba;--shadow:0 6px 20px #17212614;--radius:6px;--header-height:64px}*{box-sizing:border-box}html{background:var(--bg);min-width:320px}body{min-width:320px;min-height:100dvh;color:var(--text);background:var(--bg);letter-spacing:0;margin:0;font-family:Inter,Segoe UI,Microsoft YaHei,sans-serif;font-size:16px;line-height:1.5;overflow-x:hidden}button,input,textarea{font:inherit;letter-spacing:0}button,input[type=file]{touch-action:manipulation}button,a{-webkit-tap-highlight-color:transparent}button{cursor:pointer}button:disabled{cursor:not-allowed;opacity:.48}[x-cloak]{display:none!important}:focus-visible{outline:3px solid var(--focus);outline-offset:2px}.skip-link{z-index:1000;color:#fff;background:var(--primary);border-radius:var(--radius);padding:10px 14px;position:fixed;top:8px;left:8px;transform:translateY(-140%)}.skip-link:focus{transform:translateY(0)}.sr-only{clip:rect(0, 0, 0, 0);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.hidden{display:none!important}.icon{stroke-width:2px;pointer-events:none;flex:0 0 20px;width:20px;height:20px}.topbar{z-index:40;height:var(--header-height);border-bottom:1px solid var(--border);background:#fffffffa;grid-template-columns:296px minmax(0,1fr) auto;align-items:center;padding:0 20px;display:grid;position:sticky;top:0}.brand{min-width:0;color:var(--text);align-items:center;gap:10px;font-size:20px;font-weight:750;text-decoration:none;display:inline-flex}.brand-mark{color:#fff;background:var(--primary);border-radius:6px;place-items:center;width:34px;height:34px;font-size:17px;font-weight:800;display:grid}.primary-nav{align-self:stretch;align-items:center;gap:8px;display:flex}.nav-item{min-height:44px;color:var(--primary);border-bottom:3px solid var(--primary);align-items:center;gap:8px;padding:0 14px;font-weight:650;text-decoration:none;display:inline-flex}.account-actions{align-items:center;gap:8px;display:flex}.user-name{max-width:130px;color:var(--text-muted);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.button,.icon-button,.mode-button,.history-item,.order-button,.role-button{border-radius:var(--radius);min-height:44px;transition:background-color .18s,border-color .18s,color .18s,box-shadow .18s}.button{border:1px solid #0000;justify-content:center;align-items:center;gap:8px;min-width:44px;padding:10px 16px;font-weight:650;text-decoration:none;display:inline-flex}.button-primary{color:#fff;background:var(--primary);border-color:var(--primary)}.button-primary:hover{background:var(--primary-hover);border-color:var(--primary-hover)}.button-secondary{color:var(--text);background:var(--surface);border-color:var(--border-strong)}.button-secondary:hover{background:var(--surface-strong);border-color:#8fa1a5}.button-block{width:100%}.icon-button{width:44px;height:44px;color:var(--text-muted);background:0 0;border:1px solid #0000;place-items:center;padding:0;display:inline-grid}.icon-button:hover{color:var(--text);background:var(--surface-strong);border-color:var(--border)}.workspace{min-height:calc(100dvh - var(--header-height));grid-template-columns:296px minmax(0,1fr);display:grid}.history-panel{border-right:1px solid var(--border);background:#f8faf9;grid-area:history;min-width:0;padding:20px 16px 28px}.history-sticky{top:calc(var(--header-height) + 16px);position:sticky}.field-label{color:var(--text);margin-bottom:6px;font-size:14px;font-weight:650;display:block}.history-search-label{margin-top:14px}.search-field{margin:6px 0 16px;position:relative}.search-field .icon{width:18px;height:18px;color:var(--text-soft);position:absolute;top:50%;left:12px;transform:translateY(-50%)}.input,.textarea{width:100%;color:var(--text);background:var(--surface);border:1px solid var(--border-strong);border-radius:var(--radius)}.input{min-height:44px;padding:9px 12px}.textarea{resize:vertical;min-height:128px;padding:12px}.search-field .input{padding-left:38px}.input::placeholder,.textarea::placeholder{color:#6f7d83}.input:hover,.textarea:hover{border-color:#879a9e}.history-list{gap:6px;max-height:calc(100dvh - 284px);margin:0;padding:0;list-style:none;display:grid;overflow:auto}.history-item{text-align:left;width:100%;color:var(--text);background:0 0;border:1px solid #0000;grid-template-columns:36px minmax(0,1fr);gap:10px;padding:10px;text-decoration:none;display:grid}.history-item:hover{background:var(--surface);border-color:var(--border)}.history-item[aria-current=page]{background:var(--primary-soft);border-color:#9bc7c3}.history-icon{width:36px;height:36px;color:var(--primary);background:var(--surface);border:1px solid var(--border);border-radius:6px;place-items:center;display:grid}.history-copy{min-width:0}.history-title{text-overflow:ellipsis;white-space:nowrap;font-size:14px;font-weight:650;display:block;overflow:hidden}.history-meta{color:var(--text-soft);align-items:center;gap:7px;margin-top:3px;font-size:12px;display:flex}.status-dot{background:var(--success);border-radius:50%;flex:0 0 7px;width:7px;height:7px}.status-dot.pending{background:var(--warning)}.status-dot.running{background:var(--info)}.status-dot.failed{background:var(--danger)}.history-end,.history-empty,.history-feedback{color:var(--text-soft);text-align:center;margin:14px 0 0;font-size:13px}.history-controls{display:grid}.history-controls .load-more{margin-top:10px;font-size:13px}.history-sentinel{width:100%;height:1px}.main{grid-area:main;min-width:0;padding:24px clamp(16px,3vw,40px) 48px}.main-inner{width:100%;max-width:1120px;margin:0 auto}.page-heading{margin-bottom:18px}.eyebrow{color:var(--primary);margin:0 0 4px;font-size:13px;font-weight:700}h1,h2,h3,p{overflow-wrap:anywhere}h1{letter-spacing:0;margin:0;font-size:clamp(24px,3vw,32px);line-height:1.2}h2{letter-spacing:0;margin:0;font-size:18px;line-height:1.35}h3{letter-spacing:0;margin:0;font-size:16px;line-height:1.4}.content-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.75fr);align-items:start;gap:20px;display:grid}.current-result{margin-bottom:20px}.panel{background:var(--surface);border:1px solid var(--border);border-radius:8px;min-width:0;box-shadow:0 1px 2px #17212608}.panel-header{border-bottom:1px solid var(--border);justify-content:space-between;align-items:center;gap:12px;min-height:60px;padding:12px 16px;display:flex}.panel-heading-group{min-width:0}.panel-heading-group p{color:var(--text-muted);margin:2px 0 0;font-size:13px}.status-badge{min-height:28px;color:var(--success);background:var(--success-soft);white-space:nowrap;border:1px solid #b6d6c0;border-radius:999px;align-items:center;gap:7px;padding:3px 9px;font-size:13px;font-weight:700;display:inline-flex}.status-badge[data-tone=pending]{color:var(--warning);background:var(--warning-soft);border-color:#e5c59e}.status-badge[data-tone=running]{color:var(--info);background:var(--info-soft);border-color:#b6cedd}.status-badge[data-tone=failed]{color:var(--danger);background:var(--danger-soft);border-color:#e1b6b3}.result-body{place-items:stretch stretch;min-height:470px;padding:18px;display:grid;position:relative}.result-frame{border:1px solid var(--border);background:#e9eeee;border-radius:6px;place-items:center;min-height:360px;display:grid;position:relative;overflow:hidden}.result-frame img{object-fit:contain;background:#e9eeee;width:100%;height:100%;min-height:360px;display:block}.result-gallery{gap:14px;display:grid}.result-actions{justify-content:space-between;align-items:center;gap:12px;margin-top:14px;display:flex}.result-meta{color:var(--text-muted);font-size:13px}.action-group{flex-wrap:wrap;gap:8px;display:flex}.state-message{text-align:center;align-content:center;place-items:center;max-width:520px;min-height:340px;margin:auto;padding:28px;display:grid}.state-icon{width:56px;height:56px;color:var(--primary);background:var(--primary-soft);border:1px solid #a6d0cc;border-radius:8px;place-items:center;margin-bottom:16px;display:grid}.state-icon .icon{width:28px;height:28px}.state-icon.danger{color:var(--danger);background:var(--danger-soft);border-color:#e1b6b3}.state-message p{max-width:440px;color:var(--text-muted);margin:8px 0 18px}.progress-track{background:#dce6e5;border-radius:999px;width:min(280px,80vw);height:6px;margin-top:10px;overflow:hidden}.progress-bar{background:var(--primary);transform-origin:0;width:44%;height:100%;animation:1.6s ease-in-out infinite alternate progress}@keyframes progress{0%{transform:scaleX(.35)}to{transform:scaleX(1)}}.text-result{border:1px solid var(--border);background:#fbfcfc;border-radius:6px;flex-direction:column;gap:14px;min-height:360px;padding:28px;display:flex}.text-result p{white-space:pre-wrap;max-width:70ch;margin:0;line-height:1.75}.notice{color:var(--warning);background:var(--warning-soft);border:1px solid #e5c59e;border-radius:6px;align-items:flex-start;gap:10px;margin-bottom:14px;padding:12px 14px;display:flex}.composer-body{gap:18px;padding:18px;display:grid}.mode-switch{background:var(--surface-strong);border:1px solid var(--border);border-radius:8px;grid-template-columns:repeat(2,minmax(0,1fr));gap:4px;padding:4px;display:grid}.mode-button{color:var(--text-muted);background:0 0;border:1px solid #0000;justify-content:center;align-items:center;gap:8px;padding:8px 10px;font-weight:650;display:inline-flex}.mode-button[aria-pressed=true]{color:var(--primary);background:var(--surface);border-color:var(--border-strong);box-shadow:0 1px 2px #17212614}.form-field{min-width:0}.field-help,.field-error{margin:6px 0 0;font-size:13px}.field-help{color:var(--text-soft)}.field-error{color:var(--danger);font-weight:600}.upload-zone{text-align:center;min-height:108px;color:var(--primary);background:var(--surface-subtle);cursor:pointer;border:1px dashed #8fa2a5;border-radius:6px;place-items:center;padding:16px;display:grid;position:relative}.upload-zone:hover{color:var(--primary);background:var(--primary-soft);border-color:var(--primary)}.upload-zone input{clip:rect(0,0,0,0);width:1px;height:1px;position:absolute;overflow:hidden}.upload-zone:focus-within{outline:3px solid var(--focus);outline-offset:2px}.upload-zone span{flex-direction:column;align-items:center;gap:5px;display:flex}.upload-zone strong{color:var(--text);font-size:14px}.upload-zone small{color:var(--text-soft)}.role-rule-box{background:var(--surface-subtle);border:1px solid var(--border);border-radius:6px;padding:12px}.role-rule-box summary{cursor:pointer;font-weight:650}.role-rule-box summary+div{gap:10px;margin-top:12px;display:grid}.section-divider{background:var(--border);height:1px;margin:2px 0}.subheading{justify-content:space-between;align-items:center;gap:8px;margin-bottom:10px;display:flex}.subheading h3{font-size:15px}.subheading p,.thumb-note{color:var(--text-soft);margin:0;font-size:12px}.image-list{gap:12px;margin-top:12px;display:grid}.image-editor{background:var(--surface);border:1px solid var(--border);border-radius:6px;grid-template-columns:96px minmax(0,1fr) 44px;align-items:start;gap:12px;padding:12px;display:grid}.image-editor.dragging{opacity:.62}.image-editor.drag-over{border-color:var(--primary);box-shadow:0 0 0 3px #006b661a}.image-thumb{aspect-ratio:1;background:#dfe7e6;border-radius:5px;position:relative;overflow:hidden}.image-thumb img{object-fit:cover;width:100%;height:100%;display:block}.image-index{color:#fff;background:#172126db;border-radius:50%;place-items:center;width:24px;height:24px;font-size:12px;font-weight:700;display:grid;position:absolute;top:6px;left:6px}.image-editor-main{gap:8px;min-width:0;display:grid}.image-title-row{justify-content:space-between;align-items:center;gap:8px;display:flex}.image-title{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-weight:700;overflow:hidden}.drag-hint{color:var(--text-soft);font-size:12px}.role-group{grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;display:grid}.role-button{color:var(--text-muted);background:var(--surface);border:1px solid var(--border-strong);justify-content:center;align-items:center;gap:6px;padding:7px;font-size:13px;font-weight:650;display:inline-flex}.role-button[aria-pressed=true]{color:var(--primary);background:var(--primary-soft);border-color:var(--primary)}.order-controls{gap:6px;display:grid}.order-button{width:44px;height:44px;color:var(--text-muted);background:var(--surface);border:1px solid var(--border);place-items:center;padding:0;display:grid}.order-button:hover{color:var(--primary);background:var(--primary-soft);border-color:#9bc7c3}.remove-image{color:var(--danger)}.composer-footer{gap:10px;margin-top:18px;display:grid}.privacy-note{color:var(--text-soft);align-items:flex-start;gap:8px;margin:0;font-size:12px;display:flex}.privacy-note .icon{flex-basis:16px;width:16px;height:16px;margin-top:1px}.prompt-panel{top:calc(var(--header-height) + 20px);position:sticky}.prompt-flow{gap:0;padding:16px 16px 16px 30px;display:grid}.flow-step{border-left:1px solid var(--border);grid-template-columns:28px minmax(0,1fr);gap:10px;padding:0 0 16px;display:grid}.flow-step:last-child{border-left-color:#0000;padding-bottom:0}.flow-number{z-index:1;width:28px;height:28px;color:var(--primary);background:var(--primary-soft);border:1px solid #9bc7c3;border-radius:50%;place-items:center;margin-left:-14px;font-size:13px;font-weight:750;display:grid}.flow-copy{padding-top:2px}.flow-copy strong{font-size:14px;display:block}.flow-copy p{color:var(--text-muted);margin:3px 0 0;font-size:13px}.read-only{color:#41565a;background:var(--surface-subtle);border:1px solid var(--border);border-radius:4px;margin-top:8px;padding:8px 10px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px;line-height:1.55;display:block}.prompt-preview{background:var(--surface-subtle);border:1px solid var(--border);border-radius:6px;margin:0 16px 16px;padding:12px}.prompt-preview summary{cursor:pointer;font-size:14px;font-weight:650}.prompt-preview p{color:#41565a;margin:10px 0 0;font-size:13px;line-height:1.65}.modal-backdrop{z-index:100;background:#17212694;place-items:center;padding:20px;display:grid;position:fixed;inset:0}.modal{background:var(--surface);border:1px solid var(--border);width:min(100%,440px);box-shadow:var(--shadow);border-radius:8px;padding:24px}.modal p{color:var(--text-muted)}.modal-actions{justify-content:flex-end;gap:8px;margin-top:20px;display:flex}.toast{z-index:120;color:#fff;max-width:min(420px,100vw - 32px);box-shadow:var(--shadow);background:#203038;border-radius:6px;align-items:center;gap:10px;padding:12px 14px;display:flex;position:fixed;bottom:20px;right:20px}.login-view{background:var(--surface);grid-template-columns:minmax(280px,.9fr) minmax(360px,1.1fr);min-height:100dvh;display:grid}.login-context{color:#f4fbfa;background:#173a3a;flex-direction:column;justify-content:space-between;min-height:100dvh;padding:clamp(28px,6vw,72px);display:flex}.login-context .brand{color:#fff}.login-copy{max-width:560px}.login-copy h1{font-size:clamp(32px,5vw,56px)}.login-copy p{color:#c5d9d7;max-width:52ch;margin:18px 0 0;font-size:18px}.login-status-list{gap:10px;margin:30px 0 0;padding:0;list-style:none;display:grid}.login-status-list li{align-items:center;gap:10px;display:flex}.login-status-list .icon{color:#8ed1c8}.login-context small{color:#a8c2c0}.login-form-wrap{place-items:center;min-width:0;padding:28px;display:grid}.login-form{width:min(100%,420px)}.login-form h2{margin-bottom:6px;font-size:28px}.login-form>p{color:var(--text-muted);margin:0 0 24px}.password-wrap{position:relative}.password-wrap .input{padding-right:52px}.password-wrap .icon-button{position:absolute;top:0;right:0}@media (max-width:1023px){.content-grid{grid-template-columns:1fr}.prompt-panel{position:static}}@media (min-width:768px){.workspace{grid-template-areas:"history main"}}@media (max-width:767px){:root{--header-height:56px}.topbar{height:auto;min-height:var(--header-height);grid-template-columns:minmax(0,1fr) auto;padding:0 12px}.primary-nav{display:none}.brand{font-size:18px}.user-name{display:none}.workspace{grid-template-columns:minmax(0,1fr);grid-template-areas:"main""history"}.main{padding:18px 12px 24px}.history-panel{border-top:1px solid var(--border);border-right:0;padding:20px 12px 32px}.history-sticky{position:static}.history-list{max-height:none}.result-body{min-height:390px;padding:12px}.result-frame,.result-frame img{min-height:270px}.result-actions{flex-direction:column;align-items:stretch}.action-group{grid-template-columns:repeat(2,minmax(0,1fr));display:grid}.state-message{min-height:300px;padding:20px 12px}.text-result{min-height:300px;padding:20px}.image-editor{grid-template-columns:76px minmax(0,1fr);gap:10px}.order-controls{grid-column:1/-1;grid-template-columns:repeat(3,minmax(0,1fr))}.order-button{width:100%}.drag-hint{display:none}.toast{max-width:none;bottom:16px;left:16px;right:16px}.login-view{grid-template-columns:1fr}.login-context{min-height:auto;padding:24px 20px}.login-copy h1{margin-top:56px;font-size:32px}.login-copy p{font-size:16px}.login-status-list,.login-context small{display:none}.login-form-wrap{padding:32px 20px 48px}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.collapse{visibility:collapse}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.static{position:static}.block{display:block}.hidden{display:none}.inline{display:inline}.table{display:table}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.resize{resize:both}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}}:root{--bg:#f3f6f6;--surface:#fff;--surface-subtle:#f8faf9;--surface-strong:#edf3f2;--text:#172126;--text-muted:#526169;--text-soft:#6a777e;--border:#d6dfe1;--border-strong:#b8c6c9;--primary:#006b66;--primary-hover:#005853;--primary-soft:#e4f2f0;--info:#245f87;--info-soft:#e8f1f7;--warning:#81501f;--warning-soft:#fff0df;--danger:#a63630;--danger-soft:#fcebea;--success:#2d6b42;--success-soft:#e7f3eb;--focus:#147bba;--shadow:0 6px 20px #17212614;--radius:6px;--header-height:64px}*{box-sizing:border-box}html{background:var(--bg);min-width:320px}body{min-width:320px;min-height:100dvh;color:var(--text);background:var(--bg);letter-spacing:0;margin:0;font-family:Inter,Segoe UI,Microsoft YaHei,sans-serif;font-size:16px;line-height:1.5;overflow-x:hidden}button,input,textarea{font:inherit;letter-spacing:0}button,input[type=file]{touch-action:manipulation}button,a{-webkit-tap-highlight-color:transparent}button{cursor:pointer}button:disabled{cursor:not-allowed;opacity:.48}[x-cloak]{display:none!important}:focus-visible{outline:3px solid var(--focus);outline-offset:2px}.skip-link{z-index:1000;color:#fff;background:var(--primary);border-radius:var(--radius);padding:10px 14px;position:fixed;top:8px;left:8px;transform:translateY(-140%)}.skip-link:focus{transform:translateY(0)}.sr-only{clip:rect(0, 0, 0, 0);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.hidden{display:none!important}.icon{stroke-width:2px;pointer-events:none;flex:0 0 20px;width:20px;height:20px}.topbar{z-index:40;height:var(--header-height);border-bottom:1px solid var(--border);background:#fffffffa;grid-template-columns:296px minmax(0,1fr) auto;align-items:center;padding:0 20px;display:grid;position:sticky;top:0}.brand{min-width:0;color:var(--text);align-items:center;gap:10px;font-size:20px;font-weight:750;text-decoration:none;display:inline-flex}.brand-mark{color:#fff;background:var(--primary);border-radius:6px;place-items:center;width:34px;height:34px;font-size:17px;font-weight:800;display:grid}.primary-nav{align-self:stretch;align-items:center;gap:8px;display:flex}.nav-item{min-height:44px;color:var(--text-muted);border-bottom:3px solid #0000;align-items:center;gap:8px;padding:0 14px;font-weight:650;text-decoration:none;display:inline-flex}.nav-item:hover{color:var(--text);background:var(--surface-subtle)}.nav-item[aria-current=page]{color:var(--primary);border-bottom-color:var(--primary)}.account-actions{align-items:center;gap:8px;display:flex}.user-name{max-width:130px;color:var(--text-muted);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.button,.icon-button,.mode-button,.history-item,.order-button,.role-button{border-radius:var(--radius);min-height:44px;transition:background-color .18s,border-color .18s,color .18s,box-shadow .18s}.button{border:1px solid #0000;justify-content:center;align-items:center;gap:8px;min-width:44px;padding:10px 16px;font-weight:650;text-decoration:none;display:inline-flex}.button-primary{color:#fff;background:var(--primary);border-color:var(--primary)}.button-primary:hover{background:var(--primary-hover);border-color:var(--primary-hover)}.button-secondary{color:var(--text);background:var(--surface);border-color:var(--border-strong)}.button-secondary:hover{background:var(--surface-strong);border-color:#8fa1a5}.button-danger{color:#fff;background:var(--danger);border-color:var(--danger)}.button-danger:hover{background:#862b27;border-color:#862b27}.button-block{width:100%}.icon-button{width:44px;height:44px;color:var(--text-muted);background:0 0;border:1px solid #0000;place-items:center;padding:0;display:inline-grid}.icon-button:hover{color:var(--text);background:var(--surface-strong);border-color:var(--border)}.workspace{min-height:calc(100dvh - var(--header-height));grid-template-columns:296px minmax(0,1fr);display:grid}.history-panel{border-right:1px solid var(--border);background:#f8faf9;grid-area:history;min-width:0;padding:20px 16px 28px}.history-sticky{top:calc(var(--header-height) + 16px);position:sticky}.field-label{color:var(--text);margin-bottom:6px;font-size:14px;font-weight:650;display:block}.history-search-label{margin-top:14px}.search-field{margin:6px 0 16px;position:relative}.search-field .icon{width:18px;height:18px;color:var(--text-soft);position:absolute;top:50%;left:12px;transform:translateY(-50%)}.input,.textarea{width:100%;color:var(--text);background:var(--surface);border:1px solid var(--border-strong);border-radius:var(--radius)}.input{min-height:44px;padding:9px 12px}.textarea{resize:vertical;min-height:128px;padding:12px}.search-field .input{padding-left:38px}.input::placeholder,.textarea::placeholder{color:#6f7d83}.input:hover,.textarea:hover{border-color:#879a9e}.history-list{gap:6px;max-height:calc(100dvh - 284px);margin:0;padding:0;list-style:none;display:grid;overflow:auto}.history-item{text-align:left;width:100%;color:var(--text);background:0 0;border:1px solid #0000;grid-template-columns:36px minmax(0,1fr);gap:10px;padding:10px;text-decoration:none;display:grid}.history-item:hover{background:var(--surface);border-color:var(--border)}.history-item[aria-current=page]{background:var(--primary-soft);border-color:#9bc7c3}.history-icon{width:36px;height:36px;color:var(--primary);background:var(--surface);border:1px solid var(--border);border-radius:6px;place-items:center;display:grid}.history-copy{min-width:0}.history-title{text-overflow:ellipsis;white-space:nowrap;font-size:14px;font-weight:650;display:block;overflow:hidden}.history-meta{color:var(--text-soft);align-items:center;gap:7px;margin-top:3px;font-size:12px;display:flex}.status-dot{background:var(--success);border-radius:50%;flex:0 0 7px;width:7px;height:7px}.status-dot.pending{background:var(--warning)}.status-dot.running{background:var(--info)}.status-dot.failed{background:var(--danger)}.history-end,.history-empty,.history-feedback{color:var(--text-soft);text-align:center;margin:14px 0 0;font-size:13px}.history-controls{display:grid}.history-controls .load-more{margin-top:10px;font-size:13px}.history-sentinel{width:100%;height:1px}.main{grid-area:main;min-width:0;padding:24px clamp(16px,3vw,40px) 48px}.main-inner{width:100%;max-width:1120px;margin:0 auto}.page-heading{margin-bottom:18px}.eyebrow{color:var(--primary);margin:0 0 4px;font-size:13px;font-weight:700}h1,h2,h3,p{overflow-wrap:anywhere}h1{letter-spacing:0;margin:0;font-size:clamp(24px,3vw,32px);line-height:1.2}h2{letter-spacing:0;margin:0;font-size:18px;line-height:1.35}h3{letter-spacing:0;margin:0;font-size:16px;line-height:1.4}.content-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.75fr);align-items:start;gap:20px;display:grid}.current-result{margin-bottom:20px}.panel{background:var(--surface);border:1px solid var(--border);border-radius:8px;min-width:0;box-shadow:0 1px 2px #17212608}.panel-header{border-bottom:1px solid var(--border);justify-content:space-between;align-items:center;gap:12px;min-height:60px;padding:12px 16px;display:flex}.panel-heading-group{min-width:0}.panel-heading-group p{color:var(--text-muted);margin:2px 0 0;font-size:13px}.status-badge{min-height:28px;color:var(--success);background:var(--success-soft);white-space:nowrap;border:1px solid #b6d6c0;border-radius:999px;align-items:center;gap:7px;padding:3px 9px;font-size:13px;font-weight:700;display:inline-flex}.status-badge[data-tone=pending]{color:var(--warning);background:var(--warning-soft);border-color:#e5c59e}.status-badge[data-tone=running]{color:var(--info);background:var(--info-soft);border-color:#b6cedd}.status-badge[data-tone=failed]{color:var(--danger);background:var(--danger-soft);border-color:#e1b6b3}.result-body{place-items:stretch stretch;min-height:470px;padding:18px;display:grid;position:relative}.result-frame{border:1px solid var(--border);background:#e9eeee;border-radius:6px;place-items:center;min-height:360px;display:grid;position:relative;overflow:hidden}.result-frame img{object-fit:contain;background:#e9eeee;width:100%;height:100%;min-height:360px;display:block}.result-gallery{gap:14px;display:grid}.result-actions{justify-content:space-between;align-items:center;gap:12px;margin-top:14px;display:flex}.result-meta{color:var(--text-muted);font-size:13px}.action-group{flex-wrap:wrap;gap:8px;display:flex}.state-message{text-align:center;align-content:center;place-items:center;max-width:520px;min-height:340px;margin:auto;padding:28px;display:grid}.state-icon{width:56px;height:56px;color:var(--primary);background:var(--primary-soft);border:1px solid #a6d0cc;border-radius:8px;place-items:center;margin-bottom:16px;display:grid}.state-icon .icon{width:28px;height:28px}.state-icon.danger{color:var(--danger);background:var(--danger-soft);border-color:#e1b6b3}.state-message p{max-width:440px;color:var(--text-muted);margin:8px 0 18px}.progress-track{background:#dce6e5;border-radius:999px;width:min(280px,80vw);height:6px;margin-top:10px;overflow:hidden}.progress-bar{background:var(--primary);transform-origin:0;width:44%;height:100%;animation:1.6s ease-in-out infinite alternate progress}@keyframes progress{0%{transform:scaleX(.35)}to{transform:scaleX(1)}}.text-result{border:1px solid var(--border);background:#fbfcfc;border-radius:6px;flex-direction:column;gap:14px;min-height:360px;padding:28px;display:flex}.text-result p{white-space:pre-wrap;max-width:70ch;margin:0;line-height:1.75}.notice{color:var(--warning);background:var(--warning-soft);border:1px solid #e5c59e;border-radius:6px;align-items:flex-start;gap:10px;margin-bottom:14px;padding:12px 14px;display:flex}.composer-body{gap:18px;padding:18px;display:grid}.mode-switch{background:var(--surface-strong);border:1px solid var(--border);border-radius:8px;grid-template-columns:repeat(2,minmax(0,1fr));gap:4px;padding:4px;display:grid}.mode-button{color:var(--text-muted);background:0 0;border:1px solid #0000;justify-content:center;align-items:center;gap:8px;padding:8px 10px;font-weight:650;display:inline-flex}.mode-button[aria-pressed=true]{color:var(--primary);background:var(--surface);border-color:var(--border-strong);box-shadow:0 1px 2px #17212614}.form-field{min-width:0}.field-help,.field-error{margin:6px 0 0;font-size:13px}.field-help{color:var(--text-soft)}.field-error{color:var(--danger);font-weight:600}.upload-zone{text-align:center;min-height:108px;color:var(--primary);background:var(--surface-subtle);cursor:pointer;border:1px dashed #8fa2a5;border-radius:6px;place-items:center;padding:16px;display:grid;position:relative}.upload-zone:hover{color:var(--primary);background:var(--primary-soft);border-color:var(--primary)}.upload-zone input{clip:rect(0,0,0,0);width:1px;height:1px;position:absolute;overflow:hidden}.upload-zone:focus-within{outline:3px solid var(--focus);outline-offset:2px}.upload-zone span{flex-direction:column;align-items:center;gap:5px;display:flex}.upload-zone strong{color:var(--text);font-size:14px}.upload-zone small{color:var(--text-soft)}.role-rule-box{background:var(--surface-subtle);border:1px solid var(--border);border-radius:6px;padding:12px}.role-rule-box summary{cursor:pointer;font-weight:650}.role-rule-box summary+div{gap:10px;margin-top:12px;display:grid}.section-divider{background:var(--border);height:1px;margin:2px 0}.subheading{justify-content:space-between;align-items:center;gap:8px;margin-bottom:10px;display:flex}.subheading h3{font-size:15px}.subheading p,.thumb-note{color:var(--text-soft);margin:0;font-size:12px}.image-list{gap:12px;margin-top:12px;display:grid}.image-editor{background:var(--surface);border:1px solid var(--border);border-radius:6px;grid-template-columns:96px minmax(0,1fr) 44px;align-items:start;gap:12px;padding:12px;display:grid}.image-editor.dragging{opacity:.62}.image-editor.drag-over{border-color:var(--primary);box-shadow:0 0 0 3px #006b661a}.image-thumb{aspect-ratio:1;background:#dfe7e6;border-radius:5px;position:relative;overflow:hidden}.image-thumb img{object-fit:cover;width:100%;height:100%;display:block}.image-index{color:#fff;background:#172126db;border-radius:50%;place-items:center;width:24px;height:24px;font-size:12px;font-weight:700;display:grid;position:absolute;top:6px;left:6px}.image-editor-main{gap:8px;min-width:0;display:grid}.image-title-row{justify-content:space-between;align-items:center;gap:8px;display:flex}.image-title{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-weight:700;overflow:hidden}.drag-hint{color:var(--text-soft);font-size:12px}.role-group{grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;display:grid}.role-button{color:var(--text-muted);background:var(--surface);border:1px solid var(--border-strong);justify-content:center;align-items:center;gap:6px;padding:7px;font-size:13px;font-weight:650;display:inline-flex}.role-button[aria-pressed=true]{color:var(--primary);background:var(--primary-soft);border-color:var(--primary)}.order-controls{gap:6px;display:grid}.order-button{width:44px;height:44px;color:var(--text-muted);background:var(--surface);border:1px solid var(--border);place-items:center;padding:0;display:grid}.order-button:hover{color:var(--primary);background:var(--primary-soft);border-color:#9bc7c3}.remove-image{color:var(--danger)}.composer-footer{gap:10px;margin-top:18px;display:grid}.privacy-note{color:var(--text-soft);align-items:flex-start;gap:8px;margin:0;font-size:12px;display:flex}.privacy-note .icon{flex-basis:16px;width:16px;height:16px;margin-top:1px}.prompt-panel{top:calc(var(--header-height) + 20px);position:sticky}.prompt-flow{gap:0;padding:16px 16px 16px 30px;display:grid}.flow-step{border-left:1px solid var(--border);grid-template-columns:28px minmax(0,1fr);gap:10px;padding:0 0 16px;display:grid}.flow-step:last-child{border-left-color:#0000;padding-bottom:0}.flow-number{z-index:1;width:28px;height:28px;color:var(--primary);background:var(--primary-soft);border:1px solid #9bc7c3;border-radius:50%;place-items:center;margin-left:-14px;font-size:13px;font-weight:750;display:grid}.flow-copy{padding-top:2px}.flow-copy strong{font-size:14px;display:block}.flow-copy p{color:var(--text-muted);margin:3px 0 0;font-size:13px}.read-only{color:#41565a;background:var(--surface-subtle);border:1px solid var(--border);border-radius:4px;margin-top:8px;padding:8px 10px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px;line-height:1.55;display:block}.prompt-preview{background:var(--surface-subtle);border:1px solid var(--border);border-radius:6px;margin:0 16px 16px;padding:12px}.prompt-preview summary{cursor:pointer;font-size:14px;font-weight:650}.prompt-preview p{color:#41565a;margin:10px 0 0;font-size:13px;line-height:1.65}.modal-backdrop{z-index:100;background:#17212694;place-items:center;padding:20px;display:grid;position:fixed;inset:0}.modal{background:var(--surface);border:1px solid var(--border);width:min(100%,440px);box-shadow:var(--shadow);border-radius:8px;padding:24px}.modal p{color:var(--text-muted)}.modal-actions{justify-content:flex-end;gap:8px;margin-top:20px;display:flex}.toast{z-index:120;color:#fff;max-width:min(420px,100vw - 32px);box-shadow:var(--shadow);background:#203038;border-radius:6px;align-items:center;gap:10px;padding:12px 14px;display:flex;position:fixed;bottom:20px;right:20px}.api-key-main{min-height:calc(100dvh - var(--header-height));padding:28px clamp(16px,4vw,48px) 56px}.api-key-inner{width:100%;max-width:1240px;margin:0 auto}.api-key-heading{justify-content:space-between;align-items:flex-end;gap:20px;margin-bottom:20px;display:flex}.api-key-heading>div{min-width:0}.api-key-heading p:last-child{max-width:72ch;color:var(--text-muted);margin:8px 0 0}.api-key-panel{overflow:hidden}.api-key-count{color:var(--text-muted);font-variant-numeric:tabular-nums;flex:none;font-size:13px}.api-key-table-wrap{width:100%;overflow-x:auto}.api-key-table{border-collapse:collapse;width:100%;min-width:940px}.api-key-table th,.api-key-table td{text-align:left;vertical-align:middle;border-bottom:1px solid var(--border);padding:13px 14px}.api-key-table th{color:var(--text-muted);background:var(--surface-subtle);white-space:nowrap;font-size:13px;font-weight:700}.api-key-table tbody tr:last-child td{border-bottom:0}.api-key-table tbody tr:hover{background:#fbfdfc}.api-key-name{max-width:220px;font-weight:700}.api-key-prefix{color:#41565a;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:13px}.api-key-badge{border:1px solid var(--border);white-space:nowrap;border-radius:999px;align-items:center;min-height:28px;padding:3px 9px;font-size:12px;font-weight:700;display:inline-flex}.api-key-badge.active{color:var(--success);background:var(--success-soft);border-color:#b6d6c0}.api-key-badge.expired{color:var(--warning);background:var(--warning-soft);border-color:#e5c59e}.api-key-badge.revoked{color:var(--text-soft);background:var(--surface-strong)}.api-key-actions{align-items:center;gap:4px;display:flex}.api-key-action{min-height:44px;color:var(--primary);white-space:nowrap;background:0 0;border:0;border-radius:4px;align-items:center;gap:5px;padding:6px 8px;font-weight:650;display:inline-flex}.api-key-action:hover{background:var(--primary-soft)}.api-key-action.danger{color:var(--danger)}.api-key-action.danger:hover{background:var(--danger-soft)}.api-key-action .icon{width:17px;height:17px}.api-key-loading{gap:14px;min-height:260px;padding:24px;display:grid}.api-key-loading span{background:linear-gradient(90deg,var(--surface-strong),#f7f9f9,var(--surface-strong));background-size:220% 100%;border-radius:5px;height:48px;animation:1.4s ease-in-out infinite api-key-loading}@keyframes api-key-loading{0%{background-position:100% 0}to{background-position:-100% 0}}.api-key-state{text-align:center;align-content:center;place-items:center;min-height:330px;padding:28px;display:grid}.api-key-state p{max-width:52ch;color:var(--text-muted);margin:8px 0 18px}.modal-header{justify-content:space-between;align-items:center;gap:12px;margin:-8px -8px 18px 0;display:flex}.modal-body{gap:18px;display:grid}.api-key-modal{width:min(100%,520px)}.one-time-warning{color:var(--warning);background:var(--warning-soft);border:1px solid #e5c59e;border-radius:6px;align-items:flex-start;gap:12px;padding:13px 14px;display:flex}.one-time-warning .icon{flex:0 0 22px;width:22px;height:22px}.one-time-warning p{color:#694522;margin:3px 0 0;font-size:13px}.secret-value{max-width:100%;color:var(--text);background:var(--surface-subtle);border:1px solid var(--border-strong);overflow-wrap:anywhere;-webkit-user-select:all;user-select:all;border-radius:6px;padding:14px;display:block}.confirm-key{color:#41565a;background:var(--surface-subtle);border:1px solid var(--border);overflow-wrap:anywhere;border-radius:6px;padding:12px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.login-view{background:var(--surface);grid-template-columns:minmax(280px,.9fr) minmax(360px,1.1fr);min-height:100dvh;display:grid}.login-context{color:#f4fbfa;background:#173a3a;flex-direction:column;justify-content:space-between;min-height:100dvh;padding:clamp(28px,6vw,72px);display:flex}.login-context .brand{color:#fff}.login-copy{max-width:560px}.login-copy h1{font-size:clamp(32px,5vw,56px)}.login-copy p{color:#c5d9d7;max-width:52ch;margin:18px 0 0;font-size:18px}.login-status-list{gap:10px;margin:30px 0 0;padding:0;list-style:none;display:grid}.login-status-list li{align-items:center;gap:10px;display:flex}.login-status-list .icon{color:#8ed1c8}.login-context small{color:#a8c2c0}.login-form-wrap{place-items:center;min-width:0;padding:28px;display:grid}.login-form{width:min(100%,420px)}.login-form h2{margin-bottom:6px;font-size:28px}.login-form>p{color:var(--text-muted);margin:0 0 24px}.password-wrap{position:relative}.password-wrap .input{padding-right:52px}.password-wrap .icon-button{position:absolute;top:0;right:0}@media (max-width:1023px){.content-grid{grid-template-columns:1fr}.prompt-panel{position:static}}@media (min-width:768px){.workspace{grid-template-areas:"history main"}}@media (max-width:767px){:root{--header-height:56px}.topbar{height:auto;min-height:var(--header-height);grid-template-columns:minmax(0,1fr) auto;padding:0 12px}.primary-nav{order:3;grid-column:1/-1;width:100%;overflow-x:auto}.nav-item{flex:1 0 auto;justify-content:center;padding:0 10px}.brand{font-size:18px}.user-name{display:none}.workspace{grid-template-columns:minmax(0,1fr);grid-template-areas:"main""history"}.main{padding:18px 12px 24px}.history-panel{border-top:1px solid var(--border);border-right:0;padding:20px 12px 32px}.history-sticky{position:static}.history-list{max-height:none}.result-body{min-height:390px;padding:12px}.result-frame,.result-frame img{min-height:270px}.result-actions{flex-direction:column;align-items:stretch}.action-group{grid-template-columns:repeat(2,minmax(0,1fr));display:grid}.state-message{min-height:300px;padding:20px 12px}.text-result{min-height:300px;padding:20px}.image-editor{grid-template-columns:76px minmax(0,1fr);gap:10px}.order-controls{grid-column:1/-1;grid-template-columns:repeat(3,minmax(0,1fr))}.order-button{width:100%}.drag-hint{display:none}.toast{max-width:none;bottom:16px;left:16px;right:16px}.api-key-main{padding:20px 12px 32px}.api-key-heading{flex-direction:column;align-items:stretch;gap:14px}.api-key-heading .button{width:100%}.api-key-panel .panel-header{align-items:flex-start}.api-key-table,.api-key-table tbody,.api-key-table tr,.api-key-table td{width:100%;min-width:0;display:block}.api-key-table thead{display:none}.api-key-table tr{border-bottom:1px solid var(--border);padding:10px 14px}.api-key-table tr:last-child{border-bottom:0}.api-key-table td{border:0;grid-template-columns:108px minmax(0,1fr);gap:10px;padding:7px 0;display:grid}.api-key-table td:before{content:attr(data-label);color:var(--text-soft);font-size:13px;font-weight:650}.api-key-name{max-width:none}.api-key-actions{flex-wrap:wrap;margin-top:-6px}.api-key-action{min-height:44px}.modal-backdrop{padding:12px}.modal{max-height:calc(100dvh - 24px);padding:20px;overflow-y:auto}.login-view{grid-template-columns:1fr}.login-context{min-height:auto;padding:24px 20px}.login-copy h1{margin-top:56px;font-size:32px}.login-copy p{font-size:16px}.login-status-list,.login-context small{display:none}.login-form-wrap{padding:32px 20px 48px}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false} \ No newline at end of file diff --git a/portal/web/static/vendor/icons.min.js b/portal/web/static/vendor/icons.min.js index f00f14a..45013ad 100644 --- a/portal/web/static/vendor/icons.min.js +++ b/portal/web/static/vendor/icons.min.js @@ -1,7 +1,7 @@ -(()=>{var u={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":2,"stroke-linecap":"round","stroke-linejoin":"round"};var R=([e,a,t])=>{let r=document.createElementNS("http://www.w3.org/2000/svg",e);return Object.keys(a).forEach(o=>{r.setAttribute(o,String(a[o]))}),t?.length&&t.forEach(o=>{let s=R(o);r.appendChild(s)}),r},T=(e,a={})=>{let r={...u,...a};return R(["svg",r,e])};var q=e=>{for(let a in e)if(a.startsWith("aria-")||a==="role"||a==="title")return!0;return!1};var b=(...e)=>e.filter((a,t,r)=>!!a&&a.trim()!==""&&r.indexOf(a)===t).join(" ").trim();var U=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(a,t,r)=>r?r.toUpperCase():t.toLowerCase());var O=e=>{let a=U(e);return a.charAt(0).toUpperCase()+a.slice(1)};var I=e=>Array.from(e.attributes).reduce((a,t)=>(a[t.name]=t.value,a),{}),v=e=>typeof e=="string"?e:!e||!e.class?"":e.class&&typeof e.class=="string"?e.class.split(" "):e.class&&Array.isArray(e.class)?e.class:"",p=(e,{nameAttr:a,icons:t,attrs:r})=>{let o=e.getAttribute(a);if(o==null)return;let s=O(o),f=t[s];if(!f)return console.warn(`${e.outerHTML} icon name was not found in the provided icons object.`);let l=I(e),H=q(l)?{}:{"aria-hidden":"true"},L={...u,"data-lucide":o,...H,...r,...l},E=v(l),G=v(r),y=b("lucide",`lucide-${o}`,...E,...G);y&&Object.assign(L,{class:y});let V=T(f,L);return e.parentNode?.replaceChild(V,e)};var m=[["path",{d:"M12 5v14"}],["path",{d:"m19 12-7 7-7-7"}]];var x=[["path",{d:"m5 12 7-7 7 7"}],["path",{d:"M12 19V5"}]];var i=[["path",{d:"M20 6 9 17l-5-5"}]];var d=[["circle",{cx:"12",cy:"12",r:"10"}],["line",{x1:"12",x2:"12",y1:"8",y2:"12"}],["line",{x1:"12",x2:"12.01",y1:"16",y2:"16"}]];var n=[["circle",{cx:"12",cy:"12",r:"10"}],["path",{d:"M12 6v6h4"}]];var c=[["rect",{width:"14",height:"14",x:"8",y:"8",rx:"2",ry:"2"}],["path",{d:"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"}]];var C=[["path",{d:"M12 15V3"}],["path",{d:"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"}],["path",{d:"m7 10 5 5 5-5"}]];var h=[["path",{d:"M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"}],["path",{d:"M14.084 14.158a3 3 0 0 1-4.242-4.242"}],["path",{d:"M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"}],["path",{d:"m2 2 20 20"}]];var S=[["path",{d:"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"}],["circle",{cx:"12",cy:"12",r:"3"}]];var g=[["path",{d:"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"}],["path",{d:"M14 2v5a1 1 0 0 0 1 1h5"}],["path",{d:"M10 9H8"}],["path",{d:"M16 13H8"}],["path",{d:"M16 17H8"}]];var w=[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",ry:"2"}],["circle",{cx:"9",cy:"9",r:"2"}],["path",{d:"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"}]];var k=[["path",{d:"m16 17 5-5-5-5"}],["path",{d:"M21 12H9"}],["path",{d:"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"}]];var P=[["path",{d:"M5 12h14"}],["path",{d:"M12 5v14"}]];var A=[["path",{d:"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"}],["path",{d:"M21 3v5h-5"}],["path",{d:"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"}],["path",{d:"M8 16H3v5"}]];var M=[["path",{d:"m21 21-4.34-4.34"}],["circle",{cx:"11",cy:"11",r:"8"}]];var B=[["path",{d:"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"}],["path",{d:"m9 12 2 2 4-4"}]];var F=[["path",{d:"M18 6 6 18"}],["path",{d:"m6 6 12 12"}]];var D=({icons:e={},nameAttr:a="data-lucide",attrs:t={},root:r=document,inTemplates:o}={})=>{if(!Object.values(e).length)throw new Error(`Please provide an icons object. +(()=>{var u={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":2,"stroke-linecap":"round","stroke-linejoin":"round"};var O=([e,a,t])=>{let r=document.createElementNS("http://www.w3.org/2000/svg",e);return Object.keys(a).forEach(o=>{r.setAttribute(o,String(a[o]))}),t?.length&&t.forEach(o=>{let s=O(o);r.appendChild(s)}),r},v=(e,a={})=>{let r={...u,...a};return O(["svg",r,e])};var H=e=>{for(let a in e)if(a.startsWith("aria-")||a==="role"||a==="title")return!0;return!1};var E=(...e)=>e.filter((a,t,r)=>!!a&&a.trim()!==""&&r.indexOf(a)===t).join(" ").trim();var G=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(a,t,r)=>r?r.toUpperCase():t.toLowerCase());var V=e=>{let a=G(e);return a.charAt(0).toUpperCase()+a.slice(1)};var K=e=>Array.from(e.attributes).reduce((a,t)=>(a[t.name]=t.value,a),{}),I=e=>typeof e=="string"?e:!e||!e.class?"":e.class&&typeof e.class=="string"?e.class.split(" "):e.class&&Array.isArray(e.class)?e.class:"",x=(e,{nameAttr:a,icons:t,attrs:r})=>{let o=e.getAttribute(a);if(o==null)return;let s=V(o),f=t[s];if(!f)return console.warn(`${e.outerHTML} icon name was not found in the provided icons object.`);let l=K(e),W=H(l)?{}:{"aria-hidden":"true"},b={...u,"data-lucide":o,...W,...r,...l},z=I(l),X=I(r),U=E("lucide",`lucide-${o}`,...z,...X);U&&Object.assign(b,{class:U});let N=v(f,b);return e.parentNode?.replaceChild(N,e)};var i=[["path",{d:"M12 5v14"}],["path",{d:"m19 12-7 7-7-7"}]];var n=[["path",{d:"m5 12 7-7 7 7"}],["path",{d:"M12 19V5"}]];var c=[["circle",{cx:"12",cy:"12",r:"10"}],["path",{d:"M4.929 4.929 19.07 19.071"}]];var C=[["path",{d:"M20 6 9 17l-5-5"}]];var d=[["circle",{cx:"12",cy:"12",r:"10"}],["line",{x1:"12",x2:"12",y1:"8",y2:"12"}],["line",{x1:"12",x2:"12.01",y1:"16",y2:"16"}]];var p=[["circle",{cx:"12",cy:"12",r:"10"}],["path",{d:"m9 12 2 2 4-4"}]];var h=[["circle",{cx:"12",cy:"12",r:"10"}],["path",{d:"M12 6v6h4"}]];var S=[["rect",{width:"14",height:"14",x:"8",y:"8",rx:"2",ry:"2"}],["path",{d:"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"}]];var g=[["path",{d:"M12 15V3"}],["path",{d:"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"}],["path",{d:"m7 10 5 5 5-5"}]];var w=[["path",{d:"M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"}],["path",{d:"M14.084 14.158a3 3 0 0 1-4.242-4.242"}],["path",{d:"M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"}],["path",{d:"m2 2 20 20"}]];var k=[["path",{d:"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"}],["circle",{cx:"12",cy:"12",r:"3"}]];var P=[["path",{d:"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"}],["path",{d:"M14 2v5a1 1 0 0 0 1 1h5"}],["path",{d:"M10 9H8"}],["path",{d:"M16 13H8"}],["path",{d:"M16 17H8"}]];var A=[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",ry:"2"}],["circle",{cx:"9",cy:"9",r:"2"}],["path",{d:"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"}]];var M=[["path",{d:"M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"}],["circle",{cx:"16.5",cy:"7.5",r:".5",fill:"currentColor"}]];var B=[["path",{d:"m16 17 5-5-5-5"}],["path",{d:"M21 12H9"}],["path",{d:"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"}]];var F=[["path",{d:"M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"}],["path",{d:"m15 5 4 4"}]];var D=[["path",{d:"M5 12h14"}],["path",{d:"M12 5v14"}]];var L=[["path",{d:"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"}],["path",{d:"M21 3v5h-5"}],["path",{d:"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"}],["path",{d:"M8 16H3v5"}]];var y=[["path",{d:"m21 21-4.34-4.34"}],["circle",{cx:"11",cy:"11",r:"8"}]];var R=[["path",{d:"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"}],["path",{d:"m9 12 2 2 4-4"}]];var m=[["path",{d:"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"}],["path",{d:"M12 9v4"}],["path",{d:"M12 17h.01"}]];var T=[["path",{d:"M18 6 6 18"}],["path",{d:"m6 6 12 12"}]];var q=({icons:e={},nameAttr:a="data-lucide",attrs:t={},root:r=document,inTemplates:o}={})=>{if(!Object.values(e).length)throw new Error(`Please provide an icons object. If you want to use all the icons you can import it like: \`import { createIcons, icons } from 'lucide'; -lucide.createIcons({icons});\``);if(typeof r>"u")throw new Error("`createIcons()` only works in a browser environment.");if(Array.from(r.querySelectorAll(`[${a}]`)).forEach(f=>p(f,{nameAttr:a,icons:e,attrs:t})),o&&Array.from(r.querySelectorAll("template")).forEach(l=>D({icons:e,nameAttr:a,attrs:t,root:l.content,inTemplates:o})),a==="data-lucide"){let f=r.querySelectorAll("[icon-name]");f.length>0&&(console.warn("[Lucide] Some icons were found with the now deprecated icon-name attribute. These will still be replaced for backwards compatibility, but will no longer be supported in v1.0 and you should switch to data-lucide"),Array.from(f).forEach(l=>p(l,{nameAttr:"icon-name",icons:e,attrs:t})))}};var W={AlertCircle:d,ArrowDown:m,ArrowUp:x,Check:i,Clock3:n,Copy:c,Download:C,Eye:S,EyeOff:h,FileText:g,Image:w,LogOut:k,Plus:P,RefreshCw:A,Search:M,ShieldCheck:B,X:F};window.renderChorusIcons=(e=document)=>D({icons:W,attrs:{"aria-hidden":"true"},root:e});document.addEventListener("DOMContentLoaded",()=>window.renderChorusIcons());})(); +lucide.createIcons({icons});\``);if(typeof r>"u")throw new Error("`createIcons()` only works in a browser environment.");if(Array.from(r.querySelectorAll(`[${a}]`)).forEach(f=>x(f,{nameAttr:a,icons:e,attrs:t})),o&&Array.from(r.querySelectorAll("template")).forEach(l=>q({icons:e,nameAttr:a,attrs:t,root:l.content,inTemplates:o})),a==="data-lucide"){let f=r.querySelectorAll("[icon-name]");f.length>0&&(console.warn("[Lucide] Some icons were found with the now deprecated icon-name attribute. These will still be replaced for backwards compatibility, but will no longer be supported in v1.0 and you should switch to data-lucide"),Array.from(f).forEach(l=>x(l,{nameAttr:"icon-name",icons:e,attrs:t})))}};var Z={AlertCircle:d,ArrowDown:i,ArrowUp:n,Ban:c,Check:C,CircleCheck:p,Clock3:h,Copy:S,Download:g,Eye:k,EyeOff:w,FileText:P,Image:A,KeyRound:M,LogOut:B,Pencil:F,Plus:D,RefreshCw:L,Search:y,ShieldCheck:R,TriangleAlert:m,X:T};window.renderChorusIcons=(e=document)=>q({icons:Z,attrs:{"aria-hidden":"true"},root:e});document.addEventListener("DOMContentLoaded",()=>window.renderChorusIcons());})(); /*! Bundled license information: lucide/dist/esm/defaultAttributes.mjs: @@ -13,8 +13,10 @@ lucide/dist/esm/shared/src/utils/toPascalCase.mjs: lucide/dist/esm/replaceElement.mjs: lucide/dist/esm/icons/arrow-down.mjs: lucide/dist/esm/icons/arrow-up.mjs: +lucide/dist/esm/icons/ban.mjs: lucide/dist/esm/icons/check.mjs: lucide/dist/esm/icons/circle-alert.mjs: +lucide/dist/esm/icons/circle-check.mjs: lucide/dist/esm/icons/clock-3.mjs: lucide/dist/esm/icons/copy.mjs: lucide/dist/esm/icons/download.mjs: @@ -22,11 +24,14 @@ lucide/dist/esm/icons/eye-off.mjs: lucide/dist/esm/icons/eye.mjs: lucide/dist/esm/icons/file-text.mjs: lucide/dist/esm/icons/image.mjs: +lucide/dist/esm/icons/key-round.mjs: lucide/dist/esm/icons/log-out.mjs: +lucide/dist/esm/icons/pencil.mjs: lucide/dist/esm/icons/plus.mjs: lucide/dist/esm/icons/refresh-cw.mjs: lucide/dist/esm/icons/search.mjs: lucide/dist/esm/icons/shield-check.mjs: +lucide/dist/esm/icons/triangle-alert.mjs: lucide/dist/esm/icons/x.mjs: lucide/dist/esm/lucide.mjs: (** diff --git a/portal/web/templates/api_keys.html b/portal/web/templates/api_keys.html new file mode 100644 index 0000000..ec4fad7 --- /dev/null +++ b/portal/web/templates/api_keys.html @@ -0,0 +1,43 @@ +{{define "api-keys"}} + + + + + + + {{.Title}} - Chorus + + + + + + +
+ CHORUS + + +
+
+
+

开发者访问

API Key

完整 Key 只在创建成功后显示一次,之后只能查看名称、前缀和状态。

+
+

我的 API Key

撤销后立即失效且不能恢复;轮换时请先创建新 Key。

+
+
+
+
+
+
+ + + + + + + + + + + + +{{end}} diff --git a/portal/web/templates/app.html b/portal/web/templates/app.html index 7195dc9..f4b2f44 100644 --- a/portal/web/templates/app.html +++ b/portal/web/templates/app.html @@ -19,7 +19,7 @@
CHORUS - +