Compare commits

..
Author SHA1 Message Date
lan-yonghui c1a3827f18 feat: add delete and reveal endpoints, support deleted_at field 2026-10-28 20:16:50 +08:00
48 changed files with 2772 additions and 587 deletions
+144 -2
View File
@@ -15,7 +15,7 @@ import (
)
func bindAPIKeyRequest(c *gin.Context, value interface{}) bool {
if _, err := auth.RequireAPIKeySession(c); err != nil {
if _, err := auth.RequireAPIKeyManagement(c); err != nil {
helper.BadAuth(c, "ErrNotLogin", nil)
return false
}
@@ -41,6 +41,24 @@ func apiKeyError(c *gin.Context, err error) {
helper.InternalServer(c, err)
}
// Swag's legacy || syntax combines schemes in one requirement (AND);
// separate @Security lines represent alternative authentication methods.
// @Tags API Key
// @Summary List the current account's API keys
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. The authenticated account determines ownership; another account cannot be selected in the request.
// @Description Returns key metadata and masked hints, never plaintext secrets. The legacy key is included when present; deleted keys are excluded.
// @Description Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500; check code rather than HTTP status alone.
// @Accept json
// @Produce json
// @Param request body dto.APIKeySearch true "Pagination and revoked-key filter"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyPage} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Header 200 {string} Cache-Control "no-store"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/search [post]
func (b *BaseApi) SearchAPIKeys(c *gin.Context) {
var req dto.APIKeySearch
if !bindAPIKeyRequest(c, &req) {
@@ -55,6 +73,25 @@ func (b *BaseApi) SearchAPIKeys(c *gin.Context) {
helper.SuccessWithData(c, result)
}
// @Tags API Key
// @Summary Create an API key for the current account
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. The new key belongs to the authenticated account.
// @Description requestID is optional. Omitted or empty values are generated by the server. For idempotent retries, supply a UUID and reuse it with the same payload.
// @Description A successful first request returns the plaintext secret. Repeating the same requestID and payload returns alreadyCreated=true and an empty apiKey; changing the payload for that requestID is rejected.
// @Description Omitted expiresAt defaults to 90 days; explicit null means no expiration. Omitted apiKeyValidityTime defaults to 120 minutes; zero disables timestamp validation.
// @Description API-created keys may have a longer lifetime or broader IP policy than the calling key. Revoking the calling key does not revoke keys it created.
// @Description Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500; check code rather than HTTP status alone. Unknown fields and bodies larger than 32 KiB are rejected.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyCreate true "Key policy and an optional UUID requestID for idempotency"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyCreated} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Header 200 {string} Cache-Control "no-store"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/create [post]
func (b *BaseApi) CreateAPIKey(c *gin.Context) {
req := dto.APIKeyCreate{APIKeyFields: dto.APIKeyFields{APIKeyValidityTime: 120}}
if !bindAPIKeyRequest(c, &req) {
@@ -69,6 +106,22 @@ func (b *BaseApi) CreateAPIKey(c *gin.Context) {
helper.SuccessWithData(c, result)
}
// @Tags API Key
// @Summary Update an API key's policy
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be changed.
// @Description Submit the current revision returned by search; stale revisions are rejected. This replaces editable policy fields without changing the secret. expiresAt must be supplied as an RFC 3339 timestamp or null.
// @Description Use id=legacy for the legacy key; its name is fixed and expiresAt must be null. Revoked or deleted independent keys cannot be updated.
// @Description Success increments the revision; search again before another mutation. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyUpdate true "Key ID, current revision and complete editable policy"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/update [post]
func (b *BaseApi) UpdateAPIKey(c *gin.Context) {
var req dto.APIKeyUpdate
if !bindAPIKeyRequest(c, &req) {
@@ -81,6 +134,21 @@ func (b *BaseApi) UpdateAPIKey(c *gin.Context) {
apiKeyMutationSuccess(c)
}
// @Tags API Key
// @Summary Enable or disable an API key
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be changed; id=legacy is supported.
// @Description Submit the current revision returned by search. Revoked or deleted keys cannot be enabled; expired keys must first receive a future expiration time. Disabling a key also closes its active terminal sessions.
// @Description Success increments the revision; search again before another mutation. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyStatus true "Key ID, current revision and Enable or Disable status"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/status [post]
func (b *BaseApi) SetAPIKeyStatus(c *gin.Context) {
var req dto.APIKeyStatus
if !bindAPIKeyRequest(c, &req) {
@@ -93,6 +161,22 @@ func (b *BaseApi) SetAPIKeyStatus(c *gin.Context) {
apiKeyMutationSuccess(c)
}
// @Tags API Key
// @Summary Revoke an API key
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be revoked.
// @Description Submit the current revision returned by search. Revocation destroys the independent key's stored secret and cannot be undone; its metadata remains listed. For id=legacy, the legacy secret is cleared and API access is disabled.
// @Description A key can revoke itself; subsequent requests signed with it fail. Keys previously created by the revoked key remain independent. Success increments the revision and closes associated terminal sessions.
// @Description Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyMutation true "Key ID and current revision"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/revoke [post]
func (b *BaseApi) RevokeAPIKey(c *gin.Context) {
var req dto.APIKeyMutation
if !bindAPIKeyRequest(c, &req) {
@@ -106,5 +190,63 @@ func (b *BaseApi) RevokeAPIKey(c *gin.Context) {
}
func apiKeyMutationSuccess(c *gin.Context) {
helper.SuccessWithData(c, gin.H{"terminalClosePending": c.GetBool("API_KEY_TERMINAL_CLOSE_PENDING")})
helper.SuccessWithData(c, dto.APIKeyMutationResult{TerminalClosePending: c.GetBool("API_KEY_TERMINAL_CLOSE_PENDING")})
}
// @Tags API Key
// @Summary Delete an independent API key
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be deleted; id=legacy is not supported.
// @Description Submit the current revision returned by search. Deletion immediately revokes the key, destroys its stored secret and removes it from lists. Audit and idempotency records are retained. Already-revoked keys can be deleted; deletion cannot be undone.
// @Description A key can delete itself; subsequent requests signed with it fail. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyMutation true "Independent key ID and current revision"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/delete [post]
func (b *BaseApi) DeleteAPIKey(c *gin.Context) {
var req dto.APIKeyMutation
if !bindAPIKeyRequest(c, &req) {
return
}
if err := service.NewAPIKeyService().Delete(c, req); err != nil {
apiKeyError(c, err)
return
}
apiKeyMutationSuccess(c)
}
// @Tags API Key
// @Summary Reveal an independent API key's plaintext secret
// @Description Requires a panel login session and CSRF token. API Key authentication is not supported, including requests that also contain a valid session cookie; omit all 1Panel authentication headers.
// @Description Only the current account's independent keys can be revealed. Submit the current revision returned by search. Legacy, revoked and deleted keys are rejected; disabled or expired keys can still be revealed.
// @Description Secret disclosure is audited. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500. The response must not be cached or logged.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyMutation true "Independent key ID and current revision"
// @Success 200 {object} dto.Response{data=dto.APIKeyRevealed} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token"
// @Header 200 {string} Cache-Control "no-store"
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/reveal [post]
func (b *BaseApi) RevealAPIKey(c *gin.Context) {
c.Header("Cache-Control", "no-store")
if _, err := auth.RequireAPIKeySession(c); err != nil {
helper.BadAuth(c, "ErrNotLogin", nil)
return
}
var req dto.APIKeyMutation
if !bindAPIKeyRequest(c, &req) {
return
}
result, err := service.NewAPIKeyService().Reveal(c, req)
if err != nil {
apiKeyError(c, err)
return
}
helper.SuccessWithData(c, result)
}
+11 -8
View File
@@ -417,11 +417,13 @@ func (b *BaseApi) MFAClose(c *gin.Context) {
}
// @Tags Auth
// @Summary generate api key
// @Summary Generate or reset the legacy API key
// @Description Requires a panel login session and CSRF token. API Key authentication is not supported. This replaces the current account's legacy secret; existing clients using the previous secret must be updated. Independent API keys are unaffected.
// @Accept json
// @Success 200 {string} key
// @Security ApiKeyAuth
// @Security Timestamp
// @Produce json
// @Success 200 {object} dto.Response{data=string} "code=200 with the new secret; authentication and business errors use envelope code 400, 401 or 500"
// @Header 200 {string} Cache-Control "no-store"
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/generate [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"生成 API 接口密钥","formatEN":"generate api key"}
func (b *BaseApi) GenerateApiKey(c *gin.Context) {
@@ -440,12 +442,13 @@ func (b *BaseApi) GenerateApiKey(c *gin.Context) {
}
// @Tags Auth
// @Summary Update api config
// @Summary Update the legacy API key configuration
// @Description Requires a panel login session and CSRF token. API Key authentication is not supported. This updates only the current account's legacy API configuration.
// @Accept json
// @Produce json
// @Param request body dto.ApiInterfaceConfig true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Success 200 {object} dto.Response "code=200 on success; authentication and business errors use envelope code 400, 401 or 500"
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/update [post]
// @x-panel-log {"bodyKeys":["ipWhiteList","apiTrustedProxies"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新 API 接口配置 => IP 白名单: [ipWhiteList], API 可信代理: [apiTrustedProxies]","formatEN":"update api config => IP Allowlist: [ipWhiteList], API Trusted Proxies: [apiTrustedProxies]"}
func (b *BaseApi) UpdateApiConfig(c *gin.Context) {
+16 -1
View File
@@ -7,6 +7,7 @@ import (
"encoding/hex"
"errors"
"net"
"net/http"
"strconv"
"strings"
"time"
@@ -42,7 +43,7 @@ func APIAuthMiddleware(loadConfig APIAuthConfigLoader, onSuccess APIAuthSuccessH
ownerProvider = ownerProviders[0]
}
return func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api/v2/core/auth") {
if strings.HasPrefix(c.Request.URL.Path, "/api/v2/core/auth") && !isAPIKeyManagementRequest(c) {
c.Next()
return
}
@@ -97,6 +98,20 @@ func APIAuthMiddleware(loadConfig APIAuthConfigLoader, onSuccess APIAuthSuccessH
}
}
func isAPIKeyManagementRequest(c *gin.Context) bool {
if c.Request.Method != http.MethodPost {
return false
}
switch c.Request.URL.Path {
case "/api/v2/core/auth/api/keys/search", "/api/v2/core/auth/api/keys/create",
"/api/v2/core/auth/api/keys/update", "/api/v2/core/auth/api/keys/status",
"/api/v2/core/auth/api/keys/revoke", "/api/v2/core/auth/api/keys/delete":
return true
default:
return false
}
}
func LoadAPIAuthConfig(_ *gin.Context) (APIAuthConfig, error) {
settingRepo := repo.NewISettingRepo()
config := APIAuthConfig{}
+23 -3
View File
@@ -66,6 +66,26 @@ func RequireAPIKeySession(c *gin.Context) (psession.SessionUser, error) {
return u, nil
}
func RequireAPIKeyManagement(c *gin.Context) (psession.SessionUser, error) {
if c == nil || !c.GetBool("API_AUTH") {
return RequireAPIKeySession(c)
}
if c.GetBool("LOCAL_REQUEST") {
return psession.SessionUser{}, buserr.New("ErrNotLogin")
}
kind, keyID := c.GetString("API_AUTH_KEY_KIND"), c.GetString("API_AUTH_KEY_ID")
if (kind != "apiKey" && kind != "legacy") || keyID == "" || (kind == "legacy") != (keyID == "legacy") {
return psession.SessionUser{}, buserr.New("ErrNotLogin")
}
ownerType, ownerID := c.GetString("API_AUTH_OWNER_TYPE"), c.GetString("API_AUTH_OWNER_ID")
value, _ := c.Get(psession.GinContextSessionUserKey)
u, ok := value.(psession.SessionUser)
if (ownerType != APIKeyOwnerPanelAdmin && ownerType != APIKeyOwnerEnterpriseUser) || ownerID == "" || !ok || u.ID != ownerID || u.Name == "" {
return psession.SessionUser{}, buserr.New("ErrNotLogin")
}
return u, nil
}
func HasAPICredentials(c *gin.Context) bool {
return c.GetHeader("1Panel-Token") != "" || c.GetHeader("1Panel-Timestamp") != "" || c.GetHeader("1Panel-Key-ID") != "" || c.GetHeader("1Panel-Signature-Version") != ""
}
@@ -87,7 +107,7 @@ func LegacyAPIKeyFingerprint(owner APIKeyOwner, config APIAuthConfig, policy mod
}
func APIKeySecret(key model.APIKey) (string, error) {
if key.SecretVersion != 1 || key.SecretCiphertext == "" {
if key.DeletedAt != nil || key.Status == "Revoked" || key.SecretVersion != 1 || key.SecretCiphertext == "" {
return "", buserr.New("ErrApiConfigKeyInvalid")
}
master, err := encrypt.APIKeyEncryptionKey()
@@ -98,7 +118,7 @@ func APIKeySecret(key model.APIKey) (string, error) {
}
func ValidateAPIKeyState(key model.APIKey, now time.Time) error {
if key.Status != constant.StatusEnable {
if key.DeletedAt != nil || key.Status != constant.StatusEnable {
return buserr.New("ErrApiConfigStatusInvalid")
}
if key.ExpiresAt != nil && !now.Before(*key.ExpiresAt) {
@@ -113,7 +133,7 @@ func configForAPIKey(key model.APIKey, secret string, owner APIKeyOwner) APIAuth
func loadMultiAPIKeyConfig(c *gin.Context, legacy APIAuthConfigLoader, provider APIKeyOwnerProvider) (APIAuthConfig, error) {
keyID := c.GetHeader("1Panel-Key-ID")
q := global.DB.Model(&model.APIKey{}).Select("id", "secret_ciphertext", "secret_version", "revision")
q := global.DB.Model(&model.APIKey{}).Where("deleted_at IS NULL").Select("id", "secret_ciphertext", "secret_version", "revision")
if keyID != "" {
q = q.Where("id = ?", keyID)
} else {
+35 -27
View File
@@ -6,41 +6,42 @@ import (
)
type APIKeyItem struct {
ID string `json:"id"`
Kind string `json:"kind"`
Name string `json:"name"`
Description string `json:"description"`
KeyHint string `json:"keyHint"`
Status string `json:"status"`
IPWhiteList string `json:"ipWhiteList"`
APITrustedProxies string `json:"apiTrustedProxies"`
APIKeyValidityTime int `json:"apiKeyValidityTime"`
ExpiresAt *time.Time `json:"expiresAt"`
AllowAppBinding bool `json:"allowAppBinding"`
Revision uint64 `json:"revision"`
CreatedAt *time.Time `json:"createdAt"`
ID string `json:"id" example:"8a8f0f77-1ec8-4d46-8f5a-0ea8807e2d8a"`
Kind string `json:"kind" enums:"apiKey,legacy" example:"apiKey"`
Name string `json:"name" example:"Deployment automation"`
Description string `json:"description" example:"Used by the deployment service"`
KeyHint string `json:"keyHint" example:"abcd••••1234"`
Status string `json:"status" enums:"Enable,Disable,Revoked,Expired" example:"Enable"`
IPWhiteList string `json:"ipWhiteList" example:"192.0.2.10"`
APITrustedProxies string `json:"apiTrustedProxies" example:"192.0.2.1"`
APIKeyValidityTime int `json:"apiKeyValidityTime" example:"120"`
ExpiresAt *time.Time `json:"expiresAt" format:"date-time" extensions:"x-nullable"`
AllowAppBinding bool `json:"allowAppBinding" example:"false"`
Revision uint64 `json:"revision" minimum:"1" example:"1"`
CreatedAt *time.Time `json:"createdAt" format:"date-time" extensions:"x-nullable"`
}
type APIKeySearch struct {
Page int `json:"page"`
PageSize int `json:"pageSize"`
ExcludeRevoked bool `json:"excludeRevoked"`
Page int `json:"page" minimum:"1" example:"1"`
PageSize int `json:"pageSize" minimum:"1" maximum:"100" example:"20"`
ExcludeRevoked bool `json:"excludeRevoked" default:"false"`
}
type APIKeyPage struct {
Items []APIKeyItem `json:"items"`
Total int `json:"total"`
}
type APIKeyFields struct {
Name string `json:"name"`
Description string `json:"description"`
IPWhiteList string `json:"ipWhiteList"`
APITrustedProxies string `json:"apiTrustedProxies"`
APIKeyValidityTime int `json:"apiKeyValidityTime"`
ExpiresAt json.RawMessage `json:"expiresAt"`
AllowAppBinding bool `json:"allowAppBinding"`
Name string `json:"name" minLength:"1" maxLength:"64" example:"Deployment automation"`
Description string `json:"description" maxLength:"256" example:"Used by the deployment service"`
IPWhiteList string `json:"ipWhiteList" maxLength:"4096" example:"192.0.2.10"`
APITrustedProxies string `json:"apiTrustedProxies" maxLength:"4096" example:"192.0.2.1"`
APIKeyValidityTime int `json:"apiKeyValidityTime" minimum:"0" maximum:"1440" example:"120"`
ExpiresAt json.RawMessage `json:"expiresAt" swaggertype:"string" format:"date-time" extensions:"x-nullable" example:"2030-01-01T00:00:00Z"`
AllowAppBinding bool `json:"allowAppBinding" default:"false"`
}
type APIKeyCreate struct {
RequestID string `json:"requestID"`
// Optional client-generated UUID for idempotent retries. Omitted or empty values are generated by the server.
RequestID string `json:"requestID" format:"uuid" example:"df1a75a3-5f56-4fa3-9e7a-a573a853d894"`
APIKeyFields
}
type APIKeyCreated struct {
@@ -49,8 +50,15 @@ type APIKeyCreated struct {
AlreadyCreated bool `json:"alreadyCreated,omitempty"`
}
type APIKeyMutation struct {
ID string `json:"id"`
Revision uint64 `json:"revision"`
ID string `json:"id" example:"8a8f0f77-1ec8-4d46-8f5a-0ea8807e2d8a"`
Revision uint64 `json:"revision" minimum:"1" example:"1"`
}
type APIKeyMutationResult struct {
TerminalClosePending bool `json:"terminalClosePending"`
}
type APIKeyRevealed struct {
APIKeyMutation
APIKey string `json:"apiKey"`
}
type APIKeyUpdate struct {
APIKeyMutation
@@ -58,5 +66,5 @@ type APIKeyUpdate struct {
}
type APIKeyStatus struct {
APIKeyMutation
Status string `json:"status"`
Status string `json:"status" enums:"Enable,Disable" example:"Disable"`
}
+1
View File
@@ -24,6 +24,7 @@ type APIKey struct {
CreatedAt time.Time `json:"-"`
UpdatedAt time.Time `json:"-"`
RevokedAt *time.Time `json:"-"`
DeletedAt *time.Time `json:"-"`
}
type LegacyAPIKeyPolicy struct {
+2 -2
View File
@@ -14,12 +14,12 @@ func APIKeyOwnerQuery(db *gorm.DB, ownerType, ownerID string) *gorm.DB {
}
func (r *APIKeyRepo) Get(id string) (model.APIKey, error) {
var key model.APIKey
err := global.DB.Where("id = ?", id).First(&key).Error
err := global.DB.Where("id = ? AND deleted_at IS NULL", id).First(&key).Error
return key, err
}
func (r *APIKeyRepo) List(ownerType, ownerID string, excludeRevoked bool) ([]model.APIKey, error) {
items := make([]model.APIKey, 0)
q := APIKeyOwnerQuery(global.DB, ownerType, ownerID)
q := APIKeyOwnerQuery(global.DB, ownerType, ownerID).Where("deleted_at IS NULL")
if excludeRevoked {
q = q.Where("status <> ?", "Revoked")
}
+89 -9
View File
@@ -52,6 +52,27 @@ func (s *APIKeyService) owner(c *gin.Context) (auth.APIKeyOwner, error) {
return s.Provider.CurrentAPIKeyOwner(c)
}
func (s *APIKeyService) managementOwner(c *gin.Context) (auth.APIKeyOwner, error) {
if _, err := auth.RequireAPIKeyManagement(c); err != nil {
return auth.APIKeyOwner{}, err
}
if s.Provider == nil {
return auth.APIKeyOwner{}, buserr.New("ErrApiConfigStatusInvalid")
}
if !c.GetBool("API_AUTH") {
return s.Provider.CurrentAPIKeyOwner(c)
}
ownerType, ownerID := c.GetString("API_AUTH_OWNER_TYPE"), c.GetString("API_AUTH_OWNER_ID")
owner, err := s.Provider.ResolveAPIKeyOwner(c, ownerType, ownerID)
if err != nil {
return auth.APIKeyOwner{}, err
}
if owner.Type != ownerType || owner.ID != ownerID {
return auth.APIKeyOwner{}, buserr.New("ErrNotLogin")
}
return owner, nil
}
func apiKeyItem(key model.APIKey) dto.APIKeyItem {
status := key.Status
if status != "Revoked" && key.ExpiresAt != nil && !time.Now().Before(*key.ExpiresAt) {
@@ -75,7 +96,7 @@ func (s *APIKeyService) legacy(owner auth.APIKeyOwner) (dto.APIKeyItem, auth.API
}
func (s *APIKeyService) Search(c *gin.Context, req dto.APIKeySearch) (*dto.APIKeyPage, error) {
owner, err := s.owner(c)
owner, err := s.managementOwner(c)
if err != nil {
return nil, err
}
@@ -146,11 +167,13 @@ func keyHint(secret string) string {
}
func (s *APIKeyService) Create(c *gin.Context, req dto.APIKeyCreate) (*dto.APIKeyCreated, error) {
owner, err := s.owner(c)
owner, err := s.managementOwner(c)
if err != nil {
return nil, err
}
if _, err = uuid.Parse(req.RequestID); err != nil {
if req.RequestID == "" {
req.RequestID = uuid.NewString()
} else if _, err = uuid.Parse(req.RequestID); err != nil {
return nil, buserr.New("ErrInvalidParams")
}
expires, err := validateAPIKeyFields(&req.APIKeyFields, true)
@@ -172,6 +195,9 @@ func (s *APIKeyService) Create(c *gin.Context, req dto.APIKeyCreate) (*dto.APIKe
q := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID)
err := q.Where("request_id = ?", req.RequestID).First(&key).Error
if err == nil {
if key.DeletedAt != nil {
return buserr.New("ErrAPIKeyNotFound")
}
if key.RequestHash != requestHash {
return buserr.New("ErrAPIKeyConflict")
}
@@ -219,7 +245,7 @@ func ensureAPIKeyName(tx *gorm.DB, owner auth.APIKeyOwner, name, id string) erro
}
func (s *APIKeyService) Update(c *gin.Context, req dto.APIKeyUpdate) error {
owner, err := s.owner(c)
owner, err := s.managementOwner(c)
if err != nil {
return err
}
@@ -247,7 +273,7 @@ func (s *APIKeyService) Update(c *gin.Context, req dto.APIKeyUpdate) error {
}
func (s *APIKeyService) Status(c *gin.Context, req dto.APIKeyStatus) error {
owner, err := s.owner(c)
owner, err := s.managementOwner(c)
if err != nil {
return err
}
@@ -271,7 +297,7 @@ func (s *APIKeyService) Status(c *gin.Context, req dto.APIKeyStatus) error {
}
func (s *APIKeyService) Revoke(c *gin.Context, req dto.APIKeyMutation) error {
owner, err := s.owner(c)
owner, err := s.managementOwner(c)
if err != nil {
return err
}
@@ -289,21 +315,75 @@ func (s *APIKeyService) Revoke(c *gin.Context, req dto.APIKeyMutation) error {
})
}
func (s *APIKeyService) Delete(c *gin.Context, req dto.APIKeyMutation) error {
owner, err := s.managementOwner(c)
if err != nil {
return err
}
if req.ID == "legacy" {
return buserr.New("ErrInvalidParams")
}
return s.mutate(c, owner, req, "delete", func(_ *gorm.DB, key *model.APIKey) error {
now := time.Now()
key.Status = "Revoked"
if key.RevokedAt == nil {
key.RevokedAt = &now
}
key.DeletedAt = &now
key.ActiveName = nil
key.SecretCiphertext = ""
key.KeyHint = ""
key.AllowAppBinding = false
return nil
})
}
func (s *APIKeyService) Reveal(c *gin.Context, req dto.APIKeyMutation) (*dto.APIKeyRevealed, error) {
owner, err := s.owner(c)
if err != nil {
return nil, err
}
if req.ID == "" || req.ID == "legacy" || req.Revision == 0 {
return nil, buserr.New("ErrInvalidParams")
}
// Serialize the snapshot and disclosure audit with revocation/deletion.
apiKeyMutationMu.Lock()
defer apiKeyMutationMu.Unlock()
var key model.APIKey
if err = repo.APIKeyOwnerQuery(global.DB, owner.Type, owner.ID).Where("id = ? AND deleted_at IS NULL", req.ID).First(&key).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, buserr.New("ErrAPIKeyNotFound")
}
return nil, err
}
if key.Revision != req.Revision || key.Status == "Revoked" {
return nil, buserr.New("ErrAPIKeyConflict")
}
secret, err := auth.APIKeySecret(key)
if err != nil {
return nil, buserr.New("ErrApiConfigKeyInvalid")
}
if err = apikey_audit.Record(c, apikey_audit.Event{OwnerType: owner.Type, OwnerID: owner.ID, KeyID: key.ID, KeyName: key.Name, Action: "reveal", Status: constant.StatusSuccess}); err != nil {
return nil, err
}
return &dto.APIKeyRevealed{APIKeyMutation: req, APIKey: secret}, nil
}
func (s *APIKeyService) mutate(c *gin.Context, owner auth.APIKeyOwner, req dto.APIKeyMutation, action string, change func(*gorm.DB, *model.APIKey) error) error {
if req.ID == "" || req.Revision == 0 {
return buserr.New("ErrInvalidParams")
}
apiKeyMutationMu.Lock()
var key model.APIKey
closeTerminals := action == "disable" || action == "revoke"
closeTerminals := action == "disable" || action == "revoke" || action == "delete"
err := global.DB.Transaction(func(tx *gorm.DB) error {
if err := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID).Where("id = ?", req.ID).First(&key).Error; err != nil {
if err := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID).Where("id = ? AND deleted_at IS NULL", req.ID).First(&key).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return buserr.New("ErrAPIKeyNotFound")
}
return err
}
if key.Revision != req.Revision || key.Status == "Revoked" {
if key.Revision != req.Revision || (key.Status == "Revoked" && action != "delete") {
return buserr.New("ErrAPIKeyConflict")
}
before := key
+2140 -264
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -18,7 +18,10 @@ import (
// @schemes http https
// @securityDefinitions.apikey ApiKeyAuth
// @description Custom Token Format, Format: md5('1panel' + API-Key + UnixTimestamp).
// @description Signed API Key authentication. Send the signature, never the plaintext API Key, in 1Panel-Token.
// @description Supported formats: hex(HMAC-SHA256(API-Key, '1panel:' + UnixTimestamp)) or md5('1panel' + API-Key + UnixTimestamp).
// @description Send the matching Unix timestamp in 1Panel-Timestamp. Optionally set 1Panel-Signature-Version to hmac-sha256, v1 or md5; omission accepts either algorithm.
// @description Independent keys may include their ID in 1Panel-Key-ID to select the credential; omit that header for a legacy key.
// @description ```
// @description eg:
// @description curl -X GET "http://{host}:{port}/api/v2/toolbox/device/base" \
@@ -27,7 +30,7 @@ import (
// @description ```
// @description - `1Panel-Token` is the key for the panel API Key.
// @type apiKey
// @in Header
// @in header
// @name 1Panel-Token
// @securityDefinitions.apikey Timestamp
// @type apiKey
+1
View File
@@ -72,5 +72,6 @@ func coreMigrations() []*gormigrate.Migration {
migrations.UpdateAiMenuOrder,
migrations.UpdateAiMenuOrderV2,
migrations.AddPanelMCP,
migrations.AddAPIKeyDeletedAt,
}
}
@@ -15,3 +15,8 @@ var AddOperationLogAPIKey = &gormigrate.Migration{
ID: "20260915-add-operation-log-api-key",
Migrate: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.OperationLog{}) },
}
var AddAPIKeyDeletedAt = &gormigrate.Migration{
ID: "20261028-api-key-deleted-at",
Migrate: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.APIKey{}) },
}
+4
View File
@@ -42,6 +42,10 @@ func OperationLog() gin.HandlerFunc {
return
}
switch c.Request.URL.Path {
case "/api/v2/core/auth/api/keys/reveal":
// Disclosure has its own audit and must not enter response-body capture.
c.Next()
return
case "/api/v2/core/mcp/credentials/create", "/api/v2/core/mcp/credentials/reveal", "/api/v2/core/mcp/credentials/rotate", "/api/v2/core/mcp/credentials/export":
c.Next()
return
+2
View File
@@ -40,6 +40,8 @@ func (s *BaseRouter) InitRouter(Router *gin.RouterGroup) {
authRouter.POST("/api/keys/update", baseApi.UpdateAPIKey)
authRouter.POST("/api/keys/status", baseApi.SetAPIKeyStatus)
authRouter.POST("/api/keys/revoke", baseApi.RevokeAPIKey)
authRouter.POST("/api/keys/delete", baseApi.DeleteAPIKey)
authRouter.POST("/api/keys/reveal", baseApi.RevealAPIKey)
authRouter.GET("/current", baseApi.GetCurrentUser)
authRouter.POST("/current/update", baseApi.UpdateCurrentUser)
+13
View File
@@ -45,6 +45,8 @@ func Build(c *gin.Context, event Event) (*model.OperationLog, error) {
"enable": {"启用 API Key", "Enable API key"},
"disable": {"停用 API Key", "Disable API key"},
"revoke": {"撤销 API Key", "Revoke API key"},
"delete": {"删除 API Key", "Delete API key"},
"reveal": {"查看 API Key", "Reveal API key"},
"app_binding": {"向 APP 交付 API Key", "Deliver API key to APP"},
}
label, ok := labels[event.Action]
@@ -80,6 +82,17 @@ func Build(c *gin.Context, event Event) (*model.OperationLog, error) {
record.UserAgent = c.Request.UserAgent()
record.IP = c.ClientIP()
}
if c.GetBool("API_AUTH") && event.Action != "app_binding" {
record.AuthMethod = "api_key"
record.APIKeyID = c.GetString("API_AUTH_KEY_ID")
record.APIKeyName = c.GetString("API_AUTH_KEY_NAME")
if c.GetString("API_AUTH_KEY_KIND") == "legacy" {
record.APIKeyID = "legacy:" + c.GetString("API_AUTH_OWNER_TYPE") + ":" + c.GetString("API_AUTH_OWNER_ID")
}
if clientIP := c.GetString("API_AUTH_CLIENT_IP"); clientIP != "" {
record.IP = clientIP
}
}
}
if event.ClientIP != "" {
record.IP = event.ClientIP
+3
View File
@@ -30,4 +30,7 @@ export namespace APIKey {
id: string;
revision: number;
}
export interface Revealed extends Reference {
apiKey: string;
}
}
+7 -1
View File
@@ -6,7 +6,7 @@ const prefix = '/core/auth/api/keys';
export const searchAPIKeys = (params: { page: number; pageSize: number; excludeRevoked?: boolean }) => {
return http.post<APIKey.Search>(`${prefix}/search`, params);
};
export const createAPIKey = (params: APIKey.Editable & { requestID: string }) => {
export const createAPIKey = (params: APIKey.Editable & { requestID?: string }) => {
return http.post<APIKey.Created>(`${prefix}/create`, params);
};
export const updateAPIKey = (params: APIKey.Editable & APIKey.Reference) => {
@@ -18,3 +18,9 @@ export const setAPIKeyStatus = (params: APIKey.Reference & { status: 'Enable' |
export const revokeAPIKey = (params: APIKey.Reference) => {
return http.post<{ terminalClosePending: boolean }>(`${prefix}/revoke`, params);
};
export const deleteAPIKey = (params: APIKey.Reference) => {
return http.post<{ terminalClosePending: boolean }>(`${prefix}/delete`, params);
};
export const revealAPIKey = (params: APIKey.Reference) => {
return http.post<APIKey.Revealed>(`${prefix}/reveal`, params);
};
+4 -12
View File
@@ -1,9 +1,9 @@
@font-face {
font-family: "iconfont"; /* Project id 4776196 */
src: url('iconfont.woff2?t=1793239167475') format('woff2'),
url('iconfont.woff?t=1793239167475') format('woff'),
url('iconfont.ttf?t=1793239167475') format('truetype'),
url('iconfont.svg?t=1793239167475#iconfont') format('svg');
src: url('iconfont.woff2?t=1786526991803') format('woff2'),
url('iconfont.woff?t=1786526991803') format('woff'),
url('iconfont.ttf?t=1786526991803') format('truetype'),
url('iconfont.svg?t=1786526991803#iconfont') format('svg');
}
.iconfont {
@@ -14,14 +14,6 @@
-moz-osx-font-smoothing: grayscale;
}
.p-huggingface1:before {
content: "\e656";
}
.p-modelscope:before {
content: "\e6be";
}
.p-shuju:before {
content: "\e634";
}
File diff suppressed because one or more lines are too long
@@ -5,20 +5,6 @@
"css_prefix_text": "p-",
"description": "",
"glyphs": [
{
"icon_id": "38704691",
"name": "huggingface",
"font_class": "huggingface1",
"unicode": "e656",
"unicode_decimal": 58966
},
{
"icon_id": "45461892",
"name": "modelscope",
"font_class": "modelscope",
"unicode": "e6be",
"unicode_decimal": 59070
},
{
"icon_id": "12903469",
"name": "本地数据",
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 255 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -6,27 +6,14 @@
:confirm-before-close="true"
@before-close="beforeClose"
>
<template v-if="created">
<el-alert
:title="$t(secret ? 'apiKeyManagement.saveSecret' : 'apiKeyManagement.alreadyCreated')"
:type="secret ? 'warning' : 'info'"
:closable="false"
show-icon
class="mb-4"
/>
<el-form label-position="top">
<el-form-item :label="$t('commons.table.name')">{{ created.name }}</el-form-item>
<el-form-item v-if="secret" :label="$t('setting.apiKey')">
<el-input :model-value="secret" readonly autocomplete="off">
<template #append>
<CopyButton :content="secret" :is-icon="false" />
</template>
</el-input>
</el-form-item>
</el-form>
<el-checkbox v-if="secret" v-model="saved">{{ $t('apiKeyManagement.saved') }}</el-checkbox>
</template>
<el-form v-else ref="formRef" :model="form" :rules="rules" label-position="top" @submit.prevent="submit">
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="text-[var(--el-text-color-regular)]"
@submit.prevent="submit"
>
<el-alert v-if="!item" type="warning" :closable="false" class="!mb-4">
<ul class="m-0 list-disc space-y-1 pl-4 break-words leading-6">
<li>
@@ -139,13 +126,10 @@
</el-form-item>
</el-form>
<template #footer>
<el-button v-if="!created" :disabled="busy" @click="beforeClose(() => (visible = false))">
<el-button :disabled="busy" @click="beforeClose(() => (visible = false))">
{{ $t('commons.button.cancel') }}
</el-button>
<el-button v-if="created" type="primary" :disabled="!!secret && !saved" @click="finish">
{{ $t('commons.button.confirm') }}
</el-button>
<el-button v-else type="primary" :loading="busy" @click="submit">
<el-button type="primary" :loading="busy" @click="submit">
{{ $t(item ? 'commons.button.save' : 'apiKeyManagement.create') }}
</el-button>
</template>
@@ -154,7 +138,7 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue';
import { ElMessageBox, type FormInstance } from 'element-plus';
import type { FormInstance } from 'element-plus';
import { isAxiosError } from 'axios';
import { v4 as uuidv4 } from 'uuid';
import { onBeforeRouteLeave } from 'vue-router';
@@ -174,9 +158,6 @@ const visible = ref(false);
const busy = ref(false);
const uncertain = ref(false);
const item = ref<APIKey.Item>();
const created = ref<APIKey.Item>();
const secret = ref('');
const saved = ref(false);
const formRef = ref<FormInstance>();
const ipMode = ref('restricted');
const neverExpires = ref(false);
@@ -196,8 +177,6 @@ let editorVersion = 0;
const discardEditor = () => {
editorVersion++;
secret.value = '';
created.value = undefined;
visible.value = false;
busy.value = false;
};
@@ -240,9 +219,6 @@ const rules = {
const open = (existing?: APIKey.Item, fromApp = false) => {
editorVersion++;
item.value = existing;
created.value = undefined;
secret.value = '';
saved.value = false;
uncertain.value = false;
pendingCreate = undefined;
requestID = uuidv4();
@@ -269,19 +245,9 @@ const open = (existing?: APIKey.Item, fromApp = false) => {
nextTick(() => formRef.value?.clearValidate());
};
const finish = () => {
secret.value = '';
visible.value = false;
if (created.value) emit('created', created.value);
};
const beforeClose = async (done: () => void) => {
const beforeClose = (done: () => void) => {
if (busy.value) return;
if (secret.value && !saved.value) {
await ElMessageBox.alert(i18n.global.t('apiKeyManagement.saveSecret'), i18n.global.t('apiKeyManagement.title'));
return;
}
if (created.value) finish();
else if (uncertain.value) emit('changed');
if (uncertain.value) emit('changed');
done();
};
@@ -310,9 +276,13 @@ const submit = async () => {
pendingCreate ??= { ...payload, requestID };
const response = await createAPIKey(pendingCreate);
if (ticket !== editorVersion) return;
created.value = response.data.item;
secret.value = response.data.apiKey;
uncertain.value = false;
pendingCreate = undefined;
MsgSuccess(i18n.global.t('commons.msg.createSuccess'));
visible.value = false;
emit('changed');
emit('created', response.data.item);
return;
}
emit('changed');
} catch (error) {
@@ -341,16 +311,12 @@ watch(
{ flush: 'sync' },
);
onBeforeUnmount(discardEditor);
onBeforeRouteLeave(async (to) => {
onBeforeRouteLeave((to) => {
if (!globalStore.isLogin || ['entrance', 'Expired', 'EnterpriseLicenseRequired'].includes(String(to.name))) {
discardEditor();
return true;
}
if (busy.value) return false;
if (secret.value && !saved.value) {
await ElMessageBox.alert(i18n.global.t('apiKeyManagement.saveSecret'), i18n.global.t('apiKeyManagement.title'));
return false;
}
discardEditor();
return true;
});
+2 -2
View File
@@ -11,14 +11,14 @@
<el-col :span="22">
<el-alert class="mt-2" :show-icon="true" type="warning" :closable="false">
<div v-for="(item, index) in form.msgs" :key="index">
<div class="dialog-object-name" style="line-height: 20px">
<div style="line-height: 20px; word-wrap: break-word">
<span>{{ item }}</span>
</div>
</div>
</el-alert>
<slot name="content"></slot>
<ul v-for="(item, index) in form.names" :key="index">
<div class="dialog-object-name">
<div style="word-wrap: break-word">
<li>{{ item }}</li>
</div>
</ul>
+1 -1
View File
@@ -59,7 +59,7 @@
class="mr-2 flex-shrink-0"
></svg-icon>
<el-tooltip :content="item.path" placement="top">
<span class="dialog-object-name text-sm">{{ item.name }}</span>
<span class="truncate text-sm">{{ item.name }}</span>
</el-tooltip>
</div>
</div>
+7 -6
View File
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
const message = {
apiKeyManagement: {
reveal: 'Show secret',
hideSecret: 'Hide secret',
revealHelp: 'Keep this secret secure. Disabled or expired keys remain unusable even when displayed.',
deleteConfirm:
'Delete “{0}”? Its secret will be destroyed and the key removed from the list. New requests will be rejected and terminal connections closed. Audit records are retained; running jobs are not cancelled. This cannot be undone.',
title: 'API Key',
manage: 'Manage API Keys',
personalEntry: 'Manage your keys, access conditions, and APP binding in Settings.',
@@ -23,10 +28,6 @@ const message = {
allowAppBinding: 'Allow APP QR binding',
bindingDisableHelp:
'Disabling QR binding prevents new device bindings. Devices already bound can continue to use the key until it is disabled, revoked, or expires.',
saveSecret: 'Copy and save this secret now. After closing, ordinary key management cannot display it again.',
saved: 'I have saved the secret',
alreadyCreated:
'This key was already created. Its secret will not be shown again. If the original response was lost, revoke and recreate it, or use QR binding if allowed.',
uncertain:
'The request result is uncertain. Retry with the same details to avoid duplicate keys, or close and check the list before creating another.',
legacyHelp:
@@ -1086,7 +1087,7 @@ const message = {
'Container address is for agent containers; 127.0.0.1, system IP, or custom address is for AI Gateway or external services.',
containerAddress: 'Container address',
customAddress: 'Custom address',
downloader: 'Downloads',
downloader: 'Downloader',
modelDir: 'Model directory',
modelSource: 'Model source',
repoID: 'Repository ID',
@@ -6992,7 +6993,7 @@ const message = {
'Search HuggingFace model repositories directly and view downloads, likes, model details, and file lists.',
modelDownloaderTitle2: 'Accelerated Downloads',
modelDownloaderContent2:
'Configure a HuggingFace acceleration endpoint and HuggingFace Token for the official site, mirrors, and private models.',
'Configure a HuggingFace acceleration endpoint and HF Token for the official site, mirrors, and private models.',
modelDownloaderTitle3: 'Task Tracking',
modelDownloaderContent3:
'Review download queues, task status, progress, and errors, with support for canceling, retrying, and clearing records.',
+7 -6
View File
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
const message = {
apiKeyManagement: {
reveal: 'Mostrar secreto',
hideSecret: 'Ocultar secreto',
revealHelp: 'Guarde el secreto de forma segura. Mostrarlo no reactiva claves deshabilitadas o caducadas.',
deleteConfirm:
'¿Eliminar «{0}»? Se destruirá el secreto y se quitará la clave de la lista. Se rechazarán nuevas solicitudes y se cerrarán sus terminales. Se conservará la auditoría; las tareas en ejecución no se cancelarán. Esta acción es irreversible.',
title: 'API Key',
manage: 'Gestionar claves API',
personalEntry: 'Gestiona tus claves, condiciones de acceso y vinculación de la APP en Ajustes.',
@@ -23,10 +28,6 @@ const message = {
allowAppBinding: 'Permitir vinculación de APP por QR',
bindingDisableHelp:
'Desactivar la vinculación por QR impide nuevas vinculaciones. Los dispositivos ya vinculados pueden seguir usando la clave hasta que se desactive, se revoque o caduque.',
saveSecret: 'Copia y guarda la clave ahora. Tras cerrar, la gestión normal no podrá mostrarla de nuevo.',
saved: 'He guardado la clave',
alreadyCreated:
'La clave ya se creó y no se mostrará de nuevo. Si se perdió la respuesta original, revócala y crea otra, o usa QR si está permitido.',
uncertain:
'El resultado es incierto. Reintenta con los mismos datos para evitar duplicados, o cierra y revisa la lista antes de crear otra.',
legacyHelp:
@@ -1088,7 +1089,7 @@ const message = {
'La dirección del contenedor es para contenedores de agentes; 127.0.0.1, IP del sistema o dirección personalizada es para AI Gateway o servicios externos.',
containerAddress: 'Dirección del contenedor',
customAddress: 'Dirección personalizada',
downloader: 'Gestión de descargas',
downloader: 'Descargador',
modelDir: 'Directorio del modelo',
modelSource: 'Fuente del modelo',
repoID: 'ID del repositorio',
@@ -7093,7 +7094,7 @@ const message = {
'Busca repositorios de modelos de HuggingFace directamente y consulta descargas, me gusta, detalles y archivos.',
modelDownloaderTitle2: 'Descargas aceleradas',
modelDownloaderContent2:
'Configura un endpoint de aceleración de HuggingFace y HuggingFace Token para el sitio oficial, espejos y modelos privados.',
'Configura un endpoint de aceleración de HuggingFace y HF Token para el sitio oficial, espejos y modelos privados.',
modelDownloaderTitle3: 'Seguimiento de tareas',
modelDownloaderContent3:
'Consulta la cola, el estado, el progreso y los errores de descarga, con soporte para cancelar, reintentar y limpiar registros.',
+7 -6
View File
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
const message = {
apiKeyManagement: {
reveal: 'نمایش کلید محرمانه',
hideSecret: 'پنهان کردن کلید محرمانه',
revealHelp: 'کلید را در جای امن نگه دارید. نمایش آن، کلید غیرفعال یا منقضی‌شده را قابل استفاده نمی‌کند.',
deleteConfirm:
'«{0}» حذف شود؟ کلید محرمانه نابود و از فهرست حذف می‌شود. درخواست‌های جدید رد و اتصال‌های ترمینال مرتبط بسته می‌شوند. سوابق حسابرسی حفظ و کارهای در حال اجرا لغو نمی‌شوند. این عملیات برگشت‌پذیر نیست.',
title: 'API Key',
manage: 'مدیریت کلیدهای API',
personalEntry: 'کلیدها، شرایط دسترسی و اتصال برنامه را در تنظیمات مدیریت کنید.',
@@ -23,10 +28,6 @@ const message = {
allowAppBinding: 'اجازه اتصال برنامه با QR',
bindingDisableHelp:
'با غیرفعال کردن اتصال از طریق QR، اتصال دستگاه‌های جدید متوقف می‌شود. دستگاه‌های متصل می‌توانند تا زمان غیرفعال‌سازی، لغو یا انقضای کلید به استفاده از آن ادامه دهند.',
saveSecret: 'اکنون کلید را کپی و ذخیره کنید. پس از بستن، مدیریت عادی نمی‌تواند کلید کامل را دوباره نشان دهد.',
saved: 'کلید را ذخیره کردم',
alreadyCreated:
'این کلید قبلاً ایجاد شده و دوباره نمایش داده نمی‌شود. اگر پاسخ اصلی گم شده است، کلید را لغو و دوباره ایجاد کنید یا در صورت اجازه از QR استفاده کنید.',
uncertain:
'نتیجه درخواست نامشخص است. برای جلوگیری از تکرار، با همان اطلاعات دوباره تلاش کنید یا ببندید و پیش از ایجاد کلید دیگر فهرست را بررسی کنید.',
legacyHelp:
@@ -1071,7 +1072,7 @@ const message = {
'آدرس کانتینر برای کانتینرهای عامل است؛ 127.0.0.1، IP سیستم یا آدرس سفارشی برای دروازه AI یا خدمات خارجی است.',
containerAddress: 'آدرس کانتینر',
customAddress: 'آدرس سفارشی',
downloader: 'مدیریت دانلودها',
downloader: 'دانلودکننده',
modelDir: 'دایرکتوری مدل',
modelSource: 'منبع مدل',
repoID: 'شناسه مخزن',
@@ -6945,7 +6946,7 @@ const message = {
'مستقیماً مخازن مدل HuggingFace را جستجو کنید و دانلودها، لایک‌ها، جزئیات مدل و لیست فایل‌ها را مشاهده کنید.',
modelDownloaderTitle2: 'دانلود شتاب‌دهنده',
modelDownloaderContent2:
'یک نقطه پایانی شتاب HuggingFace و توکن HuggingFace را برای سایت رسمی، آینه‌ها و مدل‌های خصوصی پیکربندی کنید.',
'یک نقطه پایانی شتاب HuggingFace و توکن HF را برای سایت رسمی، آینه‌ها و مدل‌های خصوصی پیکربندی کنید.',
modelDownloaderTitle3: 'ردیابی وظایف',
modelDownloaderContent3:
'صف‌های دانلود، وضعیت وظایف، پیشرفت و خطاها را مرور کنید و از لغو، تلاش مجدد و پاک‌سازی رکوردها پشتیبانی کنید.',
+7 -6
View File
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
const message = {
apiKeyManagement: {
reveal: 'シークレットを表示',
hideSecret: 'シークレットを隠す',
revealHelp: 'シークレットを安全に保管してください。表示しても無効または期限切れのキーは使用できません。',
deleteConfirm:
'「{0}」を削除しますか?シークレットは破棄され、キーは一覧から削除されます。新しいリクエストは拒否され、関連ターミナルは切断されます。監査記録は保持され、実行中のタスクは中止されません。この操作は元に戻せません。',
title: 'API Key',
manage: 'API Key を管理',
personalEntry: '設定で自分のキー、アクセス条件、APP の QR 連携を管理します。',
@@ -23,10 +28,6 @@ const message = {
allowAppBinding: 'APP の QR 連携を許可',
bindingDisableHelp:
'QR 連携を無効にすると、新規の連携を受け付けなくなります。連携済みの端末は、キーが無効化、失効、または期限切れになるまで引き続き利用できます。',
saveSecret: '今すぐキーをコピーして保存してください。閉じると通常のキー管理から完全なキーを再表示できません。',
saved: 'キーを保存しました',
alreadyCreated:
'このキーは作成済みで、再表示できません。最初の応答を受け取れなかった場合は失効させて再作成するか、許可されていれば QR を使用してください。',
uncertain: 'リクエスト結果が不明です。重複を避けるため同じ設定で再試行するか、閉じて一覧を確認してください。',
legacyHelp:
'旧版キーは従来の API 接続との互換性を維持します。有効化、無効化、リセットはこのキーにのみ適用されます。',
@@ -1075,7 +1076,7 @@ const message = {
'コンテナアドレスはエージェントコンテナ向けです。127.0.0.1、システム IP、カスタムアドレスは AI Gateway または外部サービス向けです。',
containerAddress: 'コンテナアドレス',
customAddress: 'カスタムアドレス',
downloader: 'ダウンロード管理',
downloader: 'ダウンローダー',
modelDir: 'モデルディレクトリ',
modelSource: 'モデルソース',
repoID: 'リポジトリ ID',
@@ -6967,7 +6968,7 @@ const message = {
'HuggingFace のモデルリポジトリを直接検索し、ダウンロード数、いいね、モデル詳細、ファイル一覧を確認できます。',
modelDownloaderTitle2: '高速ダウンロード',
modelDownloaderContent2:
'HuggingFace アクセラレーションエンドポイントと HuggingFace Token を設定し、公式サイト、ミラー、プライベートモデルに対応します。',
'HuggingFace アクセラレーションエンドポイントと HF Token を設定し、公式サイト、ミラー、プライベートモデルに対応します。',
modelDownloaderTitle3: 'タスク追跡',
modelDownloaderContent3:
'ダウンロードキュー、タスク状態、進捗、エラーを一元確認し、キャンセル、再試行、記録の削除を行えます。',
+7 -6
View File
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
const message = {
apiKeyManagement: {
reveal: '비밀 키 표시',
hideSecret: '비밀 키 숨기기',
revealHelp: '비밀 키를 안전하게 보관하세요. 표시해도 비활성화되거나 만료된 키는 사용할 수 없습니다.',
deleteConfirm:
'“{0}”을(를) 삭제하시겠습니까? 비밀 키가 파기되고 목록에서 제거됩니다. 새 요청은 거부되고 연결된 터미널은 종료됩니다. 감사 기록은 유지되며 실행 중인 작업은 취소되지 않습니다. 이 작업은 되돌릴 수 없습니다.',
title: 'API Key',
manage: 'API Key 관리',
personalEntry: '설정에서 내 키, 접근 조건 및 APP QR 연결을 관리합니다.',
@@ -23,10 +28,6 @@ const message = {
allowAppBinding: 'APP QR 연결 허용',
bindingDisableHelp:
'QR 연결을 비활성화하면 새로운 기기를 연결할 수 없습니다. 이미 연결된 기기는 키가 비활성화, 폐기 또는 만료될 때까지 계속 사용할 수 있습니다.',
saveSecret: '지금 키를 복사하여 저장하세요. 닫으면 일반 키 관리에서 전체 키를 다시 표시할 수 없습니다.',
saved: '키를 저장했습니다',
alreadyCreated:
'이 키는 이미 생성되어 다시 표시할 수 없습니다. 원래 응답을 잃었다면 폐기 후 다시 만들거나 허용된 QR 연결을 사용하세요.',
uncertain:
'요청 결과를 확인할 수 없습니다. 중복 생성을 피하려면 같은 설정으로 재시도하거나 닫고 목록을 확인하세요.',
legacyHelp:
@@ -1064,7 +1065,7 @@ const message = {
'컨테이너 주소는 에이전트 컨테이너 호출에 적합합니다. 127.0.0.1, 시스템 IP 또는 사용자 지정 주소는 AI Gateway 또는 외부 서비스 호출에 적합합니다.',
containerAddress: '컨테이너 주소',
customAddress: '사용자 지정 주소',
downloader: '다운로드 관리',
downloader: '다운로더',
modelDir: '모델 디렉토리',
modelSource: '모델 소스',
repoID: '저장소 ID',
@@ -6824,7 +6825,7 @@ const message = {
'HuggingFace 모델 저장소를 직접 검색하고 다운로드 수, 좋아요, 모델 상세 정보, 파일 목록을 확인합니다.',
modelDownloaderTitle2: '가속 다운로드',
modelDownloaderContent2:
'HuggingFace 가속 엔드포인트와 HuggingFace Token을 설정하여 공식 사이트, 미러, 비공개 모델에 대응합니다.',
'HuggingFace 가속 엔드포인트와 HF Token을 설정하여 공식 사이트, 미러, 비공개 모델에 대응합니다.',
modelDownloaderTitle3: '작업 추적',
modelDownloaderContent3:
'다운로드 대기열, 작업 상태, 진행률, 오류를 한곳에서 확인하고 취소, 재시도, 기록 정리를 지원합니다.',
+7 -6
View File
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
const message = {
apiKeyManagement: {
reveal: 'ສະແດງຄີລັບ',
hideSecret: 'ເຊື່ອງຄີລັບ',
revealHelp: 'ກະລຸນາເກັບຄີລັບໃຫ້ປອດໄພ. ການສະແດງບໍ່ເຮັດໃຫ້ຄີທີ່ປິດໃຊ້ງານ ຫຼືໝົດອາຍຸກັບມາໃຊ້ໄດ້.',
deleteConfirm:
'ລຶບ “{0}” ຫຼືບໍ່? ຄີລັບຈະຖືກທຳລາຍ ແລະລຶບອອກຈາກລາຍການ. ຄຳຮ້ອງຂໍໃໝ່ຈະຖືກປະຕິເສດ ແລະປິດເທີມິນັລທີ່ກ່ຽວຂ້ອງ. ບັນທຶກການກວດສອບຈະຍັງຄົງຢູ່; ວຽກທີ່ກຳລັງເຮັດຈະບໍ່ຖືກຍົກເລີກ. ບໍ່ສາມາດກູ້ຄືນໄດ້.',
title: 'API Key',
manage: 'ຈັດການ API Key',
personalEntry: 'ຈັດການກະແຈ, ເງື່ອນໄຂເຂົ້າໃຊ້ ແລະ ການຜູກ APP ໃນການຕັ້ງຄ່າ.',
@@ -23,9 +28,6 @@ const message = {
allowAppBinding: 'ອະນຸຍາດຜູກ APP ຜ່ານ QR',
bindingDisableHelp:
'ເມື່ອປິດການຜູກມັດຜ່ານ QR, ລະບົບຈະບໍ່ຮັບການຜູກມັດໃໝ່. ອຸປະກອນທີ່ຜູກມັດແລ້ວຍັງສາມາດໃຊ້ງານໄດ້ ຈົນກວ່າກະແຈຈະຖືກປິດໃຊ້, ຖອນ ຫຼື ໝົດອາຍຸ.',
saveSecret: 'ຄັດລອກ ແລະ ບັນທຶກກະແຈດຽວນີ້. ຫຼັງປິດແລ້ວ ການຈັດການປົກກະຕິຈະບໍ່ສະແດງກະແຈເຕັມອີກ.',
saved: 'ຂ້ອຍບັນທຶກກະແຈແລ້ວ',
alreadyCreated: 'ກະແຈນີ້ສ້າງແລ້ວ ແລະ ບໍ່ສະແດງອີກ. ຖ້າຄຳຕອບເກົ່າຫາຍ ໃຫ້ຖອນແລ້ວສ້າງໃໝ່ ຫຼື ໃຊ້ QR ຖ້າອະນຸຍາດ.',
uncertain: 'ຍັງບໍ່ແນ່ໃຈຜົນຄຳຮ້ອງ. ລອງໃໝ່ດ້ວຍຂໍ້ມູນເດີມເພື່ອບໍ່ສ້າງຊ້ຳ ຫຼື ປິດແລ້ວກວດລາຍການກ່ອນສ້າງອີກ.',
legacyHelp:
'ກະແຈເກົ່າໃຊ້ເພື່ອຮອງຮັບການເຊື່ອມຕໍ່ API ເກົ່າ. ການເປີດໃຊ້, ປິດໃຊ້ ແລະ ຣີເຊັດ ມີຜົນສະເພາະກະແຈນີ້ເທົ່ານັ້ນ.',
@@ -1060,7 +1062,7 @@ const message = {
'ທີ່ຢູ່ຄອນເທນເນີແມ່ນສຳລັບຄອນເທນເນີຕົວແທນ; 127.0.0.1, IP ລະບົບ, ຫຼື ທີ່ຢູ່ກຳນົດເອງແມ່ນສຳລັບ AI Gateway ຫຼື ບໍລິການພາຍນອກ.',
containerAddress: 'ທີ່ຢູ່ຄອນເທນເນີ',
customAddress: 'ທີ່ຢູ່ກຳນົດເອງ',
downloader: 'ຈັດການການດາວໂຫຼດ',
downloader: 'ຕົວດາວໂຫຼດ',
modelDir: 'ໄດເຣັກທໍຣີໂມເດວ',
modelSource: 'ແຫຼ່ງທີ່ມາໂມເດວ',
repoID: 'ID ຄັງເກັບ',
@@ -6767,8 +6769,7 @@ const message = {
modelDownloaderTitle1: 'ຄົ້ນຫາມໍເດລ',
modelDownloaderContent1: 'ຄົ້ນຫາຄັງມໍເດລ HuggingFace ໂດຍກົງ ແລະ ເບິ່ງຍອດດາວໂຫຼດ, ໄລ້ ແລະ ລາຍລະອຽດໄຟລ໌.',
modelDownloaderTitle2: 'ດາວໂຫຼດເລັ່ງດ່ວນ',
modelDownloaderContent2:
'ຕັ້ງຄ່າ ຈຸດເຊື່ອມຕໍ່ເລັ່ງຄວາມໄວ HuggingFace ແລະ HuggingFace Token ສຳລັບມໍເດລສ່ວນຕົວ.',
modelDownloaderContent2: 'ຕັ້ງຄ່າ HuggingFace acceleration endpoint ແລະ Token ສຳລັບມໍເດລສ່ວນຕົວ.',
modelDownloaderTitle3: 'ຕິດຕາມງານ',
modelDownloaderContent3: 'ກວດສອບຄິວດາວໂຫຼດ, ສະຖານະ, ຄວາມຄືບໜ້າ ແລະ ຂໍ້ຜິດພາດ.',
modelDownloaderTitle4: 'ຈັດການໃນເຄື່ອງ',
+8 -7
View File
@@ -2,6 +2,12 @@ import { getFuLocaleMessage } from '@/lang/fu';
const message = {
apiKeyManagement: {
reveal: 'Papar rahsia',
hideSecret: 'Sembunyikan rahsia',
revealHelp:
'Simpan rahsia ini dengan selamat. Memaparkannya tidak mengaktifkan semula kunci yang dinyahdayakan atau tamat tempoh.',
deleteConfirm:
'Padam “{0}”? Rahsianya akan dimusnahkan dan kunci dibuang daripada senarai. Permintaan baharu akan ditolak dan terminal berkaitan ditutup. Rekod audit dikekalkan; tugas yang sedang berjalan tidak dibatalkan. Tindakan ini tidak boleh dibuat asal.',
title: 'API Key',
manage: 'Urus kunci API',
personalEntry: 'Urus kunci, syarat akses dan pautan APP anda dalam Tetapan.',
@@ -23,11 +29,6 @@ const message = {
allowAppBinding: 'Benarkan pautan APP melalui QR',
bindingDisableHelp:
'Menyahdayakan pautan QR menghalang pautan peranti baharu. Peranti yang telah dipautkan boleh terus digunakan sehingga kunci dinyahdayakan, dibatalkan atau tamat tempoh.',
saveSecret:
'Salin dan simpan kunci sekarang. Selepas ditutup, pengurusan biasa tidak dapat memaparkan kunci penuh lagi.',
saved: 'Saya telah menyimpan kunci',
alreadyCreated:
'Kunci ini telah dicipta dan tidak akan dipaparkan semula. Jika respons asal hilang, batalkan dan cipta semula atau gunakan QR jika dibenarkan.',
uncertain:
'Hasil permintaan belum pasti. Cuba semula dengan butiran sama untuk mengelakkan pendua, atau tutup dan semak senarai sebelum mencipta lagi.',
legacyHelp:
@@ -1097,7 +1098,7 @@ const message = {
'Alamat kontena sesuai untuk kontena agen; 127.0.0.1, IP sistem, atau alamat tersuai sesuai untuk AI Gateway atau perkhidmatan luaran.',
containerAddress: 'Alamat kontena',
customAddress: 'Alamat tersuai',
downloader: 'Pengurusan muat turun',
downloader: 'Pemuat Turun',
modelDir: 'Direktori Model',
modelSource: 'Sumber model',
repoID: 'ID Repositori',
@@ -7074,7 +7075,7 @@ const message = {
'Cari repositori model HuggingFace secara langsung dan lihat muat turun, suka, butiran model dan senarai fail.',
modelDownloaderTitle2: 'Muat Turun Dipercepat',
modelDownloaderContent2:
'Konfigurasikan titik akhir pecutan HuggingFace dan HuggingFace Token untuk laman rasmi, cermin dan model persendirian.',
'Konfigurasikan titik akhir pecutan HuggingFace dan HF Token untuk laman rasmi, cermin dan model persendirian.',
modelDownloaderTitle3: 'Penjejakan Tugas',
modelDownloaderContent3:
'Semak baris gilir muat turun, status tugas, kemajuan dan ralat, dengan sokongan batal, cuba semula dan bersihkan rekod.',
+7 -6
View File
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
const message = {
apiKeyManagement: {
reveal: 'Mostrar segredo',
hideSecret: 'Ocultar segredo',
revealHelp: 'Guarde o segredo com segurança. Exibi-lo não reativa chaves desabilitadas ou expiradas.',
deleteConfirm:
'Excluir “{0}”? O segredo será destruído e a chave removida da lista. Novas solicitações serão rejeitadas e os terminais associados serão fechados. Os registros de auditoria serão mantidos; tarefas em execução não serão canceladas. Esta ação é irreversível.',
title: 'API Key',
manage: 'Gerenciar chaves API',
personalEntry: 'Gerencie suas chaves, condições de acesso e vínculo do APP nas Configurações.',
@@ -23,10 +28,6 @@ const message = {
allowAppBinding: 'Permitir vínculo do APP por QR',
bindingDisableHelp:
'Desativar a vinculação por QR impede novas vinculações. Os dispositivos já vinculados podem continuar usando a chave até que ela seja desativada, revogada ou expire.',
saveSecret: 'Copie e salve a chave agora. Após fechar, o gerenciamento normal não poderá exibi-la novamente.',
saved: 'Salvei a chave',
alreadyCreated:
'Esta chave já foi criada e não será exibida novamente. Se a resposta original foi perdida, revogue e recrie a chave ou use o QR, se permitido.',
uncertain:
'O resultado é incerto. Tente novamente com os mesmos dados para evitar duplicações ou feche e verifique a lista antes de criar outra.',
legacyHelp:
@@ -1092,7 +1093,7 @@ const message = {
'Use o endereço do contêiner para agentes; 127.0.0.1, IP do sistema ou endereço personalizado para AI Gateway ou serviços externos.',
containerAddress: 'Endereço do contêiner',
customAddress: 'Endereço personalizado',
downloader: 'Gerenciamento de downloads',
downloader: 'Baixador',
modelDir: 'Diretório do modelo',
modelSource: 'Fonte do modelo',
repoID: 'ID do repositório',
@@ -7117,7 +7118,7 @@ const message = {
'Pesquise repositórios de modelos do HuggingFace diretamente e veja downloads, curtidas, detalhes do modelo e lista de arquivos.',
modelDownloaderTitle2: 'Downloads acelerados',
modelDownloaderContent2:
'Configure um endpoint de aceleração do HuggingFace e HuggingFace Token para o site oficial, espelhos e modelos privados.',
'Configure um endpoint de aceleração do HuggingFace e HF Token para o site oficial, espelhos e modelos privados.',
modelDownloaderTitle3: 'Acompanhamento de tarefas',
modelDownloaderContent3:
'Veja filas de download, status, progresso e erros, com suporte para cancelar, tentar novamente e limpar registros.',
+7 -7
View File
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
const message = {
apiKeyManagement: {
reveal: 'Показать секрет',
hideSecret: 'Скрыть секрет',
revealHelp: 'Храните секрет в безопасности. Просмотр не активирует отключённые или просроченные ключи.',
deleteConfirm:
'Удалить «{0}»? Секрет будет уничтожен, а ключ удалён из списка. Новые запросы будут отклонены, связанные терминалы закрыты. Журнал аудита сохранится; выполняющиеся задачи не отменяются. Действие необратимо.',
title: 'API Key',
manage: 'Управление API-ключами',
personalEntry: 'Управляйте своими ключами, условиями доступа и привязкой приложения в настройках.',
@@ -23,11 +28,6 @@ const message = {
allowAppBinding: 'Разрешить привязку приложения по QR',
bindingDisableHelp:
'Отключение привязки по QR-коду запрещает новые привязки. Уже привязанные устройства могут продолжать работу, пока ключ не будет отключён, отозван или не истечёт срок его действия.',
saveSecret:
'Скопируйте и сохраните ключ сейчас. После закрытия обычное управление не сможет показать полный ключ снова.',
saved: 'Ключ сохранён',
alreadyCreated:
'Ключ уже создан и не будет показан повторно. Если исходный ответ потерян, отзовите и создайте ключ заново либо используйте разрешённую привязку по QR.',
uncertain:
'Результат запроса неизвестен. Повторите с теми же данными, чтобы избежать дублей, или закройте и проверьте список.',
legacyHelp:
@@ -1085,7 +1085,7 @@ const message = {
'Адрес контейнера подходит для контейнеров агентов; 127.0.0.1, системный IP или пользовательский адрес подходит для AI Gateway или внешних сервисов.',
containerAddress: 'Адрес контейнера',
customAddress: 'Пользовательский адрес',
downloader: 'Управление загрузками',
downloader: 'Загрузчик',
modelDir: 'Каталог модели',
modelSource: 'Источник модели',
repoID: 'ID репозитория',
@@ -7086,7 +7086,7 @@ const message = {
'Ищите репозитории моделей HuggingFace напрямую и просматривайте загрузки, лайки, сведения о модели и список файлов.',
modelDownloaderTitle2: 'Ускоренные загрузки',
modelDownloaderContent2:
'Настройте endpoint ускорения HuggingFace и HuggingFace Token для официального сайта, зеркал и приватных моделей.',
'Настройте endpoint ускорения HuggingFace и HF Token для официального сайта, зеркал и приватных моделей.',
modelDownloaderTitle3: 'Отслеживание задач',
modelDownloaderContent3:
'Просматривайте очередь загрузок, статус, прогресс и ошибки, отменяйте, повторяйте и очищайте записи.',
+8 -7
View File
@@ -2,6 +2,12 @@ import { getFuLocaleMessage } from '@/lang/fu';
const message = {
apiKeyManagement: {
reveal: 'Gizli anahtarı göster',
hideSecret: 'Gizli anahtarı gizle',
revealHelp:
'Gizli anahtarı güvenli tutun. Görüntülemek, devre dışı veya süresi dolmuş anahtarı kullanılabilir yapmaz.',
deleteConfirm:
'“{0}” silinsin mi? Gizli anahtar yok edilecek ve listeden kaldırılacaktır. Yeni istekler reddedilecek ve ilgili terminaller kapatılacaktır. Denetim kayıtları korunur; çalışan işler iptal edilmez. Bu işlem geri alınamaz.',
title: 'API Key',
manage: 'API anahtarlarını yönet',
personalEntry: 'Ayarlar bölümünde anahtarlarınızı, erişim koşullarını ve uygulama QR bağlantısını yönetin.',
@@ -23,11 +29,6 @@ const message = {
allowAppBinding: 'Uygulama QR bağlantısına izin ver',
bindingDisableHelp:
'QR eşleştirmesini devre dışı bırakmak yeni eşleştirmeleri engeller. Eşleştirilmiş cihazlar, anahtar devre dışı bırakılana, iptal edilene veya süresi dolana kadar kullanılmaya devam edebilir.',
saveSecret:
'Anahtarı şimdi kopyalayıp kaydedin. Kapattıktan sonra normal anahtar yönetimi tam anahtarı yeniden gösteremez.',
saved: 'Anahtarı kaydettim',
alreadyCreated:
'Bu anahtar zaten oluşturuldu ve yeniden gösterilmez. İlk yanıt kaybolduysa iptal edip yeniden oluşturun veya izin veriliyorsa QR bağlantısını kullanın.',
uncertain:
'İstek sonucu belirsiz. Tekrarları önlemek için aynı bilgilerle yeniden deneyin veya kapatıp yeni anahtar oluşturmadan listeyi kontrol edin.',
legacyHelp:
@@ -1096,7 +1097,7 @@ const message = {
'Konteyner adresi ajan konteynerleri içindir; 127.0.0.1, sistem IP veya özel adres AI Gateway ya da harici servisler içindir.',
containerAddress: 'Konteyner adresi',
customAddress: 'Özel adres',
downloader: 'İndirme yönetimi',
downloader: 'İndirici',
modelDir: 'Model dizini',
modelSource: 'Model kaynağı',
repoID: 'Depo ID',
@@ -7094,7 +7095,7 @@ const message = {
'HuggingFace model depolarını doğrudan arayın; indirme sayısı, beğeni, model ayrıntıları ve dosya listesini görüntüleyin.',
modelDownloaderTitle2: 'Hızlandırılmış İndirme',
modelDownloaderContent2:
'Resmi site, aynalar ve özel modeller için HuggingFace hızlandırma endpointi ve HuggingFace Token yapılandırın.',
'Resmi site, aynalar ve özel modeller için HuggingFace hızlandırma endpointi ve HF Token yapılandırın.',
modelDownloaderTitle3: 'Görev Takibi',
modelDownloaderContent3:
'İndirme kuyruğunu, görev durumunu, ilerlemeyi ve hataları görüntüleyin; iptal, yeniden deneme ve kayıt temizlemeyi destekler.',
+7 -7
View File
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
const message = {
apiKeyManagement: {
reveal: '檢視密鑰',
hideSecret: '隱藏密鑰',
revealHelp: '請妥善保管密鑰。檢視不會恢復已停用或已過期密鑰的使用權限。',
deleteConfirm:
'確定刪除「{0}」嗎?密鑰將被銷毀並從列表移除,拒絕後續請求並關閉關聯終端。稽核記錄仍保留,執行中的工作不會取消,此操作無法復原。',
title: 'API Key',
manage: '管理 API Key',
personalEntry: '前往設定管理自己的金鑰、存取條件與 APP 掃碼綁定。',
@@ -22,10 +27,6 @@ const message = {
futureExpiry: '請選擇晚於目前時間的到期時間。',
allowAppBinding: '允許 APP 掃碼綁定',
bindingDisableHelp: '關閉掃碼綁定後,將停止接受新的綁定;已綁定裝置可繼續使用,直至金鑰被停用、撤銷或到期。',
saveSecret: '請立即複製並儲存金鑰。關閉後,一般金鑰管理無法再次顯示完整金鑰。',
saved: '我已儲存金鑰',
alreadyCreated:
'這把金鑰已建立,無法再次顯示明文。若原回應遺失,請撤銷後重新建立;允許掃碼綁定時,也可透過掃碼交付。',
uncertain: '請求結果尚不確定。請保持原設定重試,避免重複建立;或關閉並檢查清單後再建立其他金鑰。',
legacyHelp: '舊版 Key 用於相容歷史 API 連線,啟用、停用及重設操作僅對該 Key 生效。',
showLegacy: '檢視舊版金鑰',
@@ -1025,7 +1026,7 @@ const message = {
'容器地址適合智能體容器呼叫;127.0.0.1、本機 IP 或自訂地址適合 AI 閘道或外部服務呼叫。',
containerAddress: '容器地址',
customAddress: '自訂地址',
downloader: '下載管理',
downloader: '下載器',
modelDir: '模型目錄',
modelSource: '模型來源',
repoID: '倉庫 ID',
@@ -6484,8 +6485,7 @@ const message = {
modelDownloaderTitle1: '模型發現',
modelDownloaderContent1: '直接搜尋 HuggingFace 模型倉庫,檢視下載量、點讚數、模型詳情與檔案列表。',
modelDownloaderTitle2: '加速下載',
modelDownloaderContent2:
'支援設定 HuggingFace 加速地址與 HuggingFace Token,適配官方站、鏡像站和私有模型存取。',
modelDownloaderContent2: '支援設定 HuggingFace 加速地址與 HF Token,適配官方站、鏡像站和私有模型存取。',
modelDownloaderTitle3: '任務追蹤',
modelDownloaderContent3: '統一檢視下載佇列、任務狀態、進度、錯誤資訊,並支援取消、重試和記錄清理。',
modelDownloaderTitle4: '本地管理',
+7 -7
View File
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
const message = {
apiKeyManagement: {
reveal: '查看密钥',
hideSecret: '隐藏密钥',
revealHelp: '请妥善保管密钥。查看不会恢复已停用或已过期密钥的使用权限。',
deleteConfirm:
'确定删除“{0}”吗?密钥将被销毁并从列表移除,拒绝后续请求并关闭关联终端。审计记录仍保留,运行中的任务不会取消,此操作不可恢复。',
title: 'API Key',
manage: '管理 API Key',
personalEntry: '前往设置管理自己的密钥、访问条件和 APP 扫码绑定。',
@@ -22,10 +27,6 @@ const message = {
futureExpiry: '请选择晚于当前时间的到期时间。',
allowAppBinding: '允许 APP 扫码绑定',
bindingDisableHelp: '关闭扫码绑定后,将停止接受新的绑定;已绑定设备可继续使用,直至密钥被停用、撤销或到期。',
saveSecret: '请立即复制并保存密钥。关闭后,普通密钥管理无法再次显示完整密钥。',
saved: '我已保存密钥',
alreadyCreated:
'这把密钥已创建,无法再次展示明文。若原响应丢失,请撤销后重新创建;允许扫码绑定时,也可通过扫码交付。',
uncertain: '请求结果尚不确定。请保持原配置重试,避免重复创建;或关闭并检查列表后再创建其他密钥。',
legacyHelp: '旧版 Key 用于兼容历史 API 连接,启用、停用和重置操作仅对该 Key 生效。',
showLegacy: '查看旧版密钥',
@@ -1038,7 +1039,7 @@ const message = {
'容器地址适合智能体容器调用;127.0.0.1、本机 IP 或自定义地址适合 AI 网关或外部服务调用。',
containerAddress: '容器地址',
customAddress: '自定义地址',
downloader: '下载管理',
downloader: '下载器',
modelDir: '模型目录',
modelSource: '模型来源',
repoID: '仓库 ID',
@@ -5442,8 +5443,7 @@ const message = {
modelDownloaderTitle1: '模型发现',
modelDownloaderContent1: '直接搜索 HuggingFace 模型仓库,查看下载量、点赞数、模型详情与文件列表。',
modelDownloaderTitle2: '加速下载',
modelDownloaderContent2:
'支持配置 HuggingFace 加速地址与 HuggingFace Token,适配官方站、镜像站和私有模型访问。',
modelDownloaderContent2: '支持配置 HuggingFace 加速地址与 HF Token,适配官方站、镜像站和私有模型访问。',
modelDownloaderTitle3: '任务追踪',
modelDownloaderContent3: '统一查看下载队列、任务状态、进度、错误信息,并支持取消、重试和记录清理。',
modelDownloaderTitle4: '本地管理',
+3 -2
View File
@@ -358,8 +358,9 @@ html {
background: var(--panel-main-bg-color-10);
pre {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
width: 350px;
overflow: hidden;
text-overflow: ellipsis;
}
}
-24
View File
@@ -286,27 +286,3 @@ html {
border: 1px solid var(--panel-main-bg-color-10);
background-color: var(--panel-main-bg-color-10) !important;
}
// Allow long object names in dialog titles and explanatory text to wrap.
.el-dialog__header,
.el-dialog__body,
.el-message-box__title,
.el-message-box__message {
overflow-wrap: anywhere;
}
.el-dialog__title,
.el-message-box__title {
white-space: normal;
}
.el-message-box__message,
.el-dialog .el-alert__content {
min-width: 0;
}
.dialog-object-name {
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
}
@@ -10,7 +10,7 @@
<template #header>
<div ref="dialogHeader" class="flex items-center justify-between code-header px-4 rounded-t">
<div class="code-title">
<span class="dialog-object-name">{{ $t('home.dir') + ' - ' + currentEditorPath }}</span>
<span class="truncate-text">{{ $t('home.dir') + ' - ' + currentEditorPath }}</span>
<el-tooltip v-if="currentEditorPath" :content="$t('file.copyDir')" placement="top">
<CopyButton class="code-title-copy" :content="currentEditorPath" />
</el-tooltip>
@@ -2175,6 +2175,13 @@ defineExpose({ acceptParams });
flex-shrink: 0;
}
.truncate-text {
display: block;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.code-title {
display: inline-flex;
align-items: center;
@@ -1,5 +1,5 @@
<template>
<DialogPro v-model="open" :title="$t('commons.button.delete')" size="w-60" class="file-delete-dialog">
<DialogPro v-model="open" :title="$t('commons.button.delete')" size="small">
<div v-loading="loading">
<el-row>
<el-col :span="22" :offset="1">
@@ -34,7 +34,7 @@
:iconName="getIconName(row.extension)"
></svg-icon>
</div>
<span class="dialog-object-name">{{ row.name }}</span>
<span class="sle">{{ row.name }}</span>
</div>
</div>
</el-col>
@@ -150,11 +150,6 @@ defineExpose({
</script>
<style scoped>
:global(.el-dialog.file-delete-dialog:not(.is-fullscreen)) {
width: 60vw !important;
min-width: 0;
}
.resource {
margin-top: 10px;
max-height: 400px;
@@ -37,7 +37,7 @@
<template v-for="(item, index) in imageFiles" :key="index">
<el-tooltip :content="item.path" placement="right">
<div
class="text-sm dialog-object-name mb-1 rounded p-1 left-item"
class="text-sm truncate mb-1 rounded p-1 left-item"
@click="changeImg(item.path)"
:class="item.path === filePath ? 'left-item-default' : ''"
>
@@ -11,7 +11,7 @@
<div class="flex items-center gap-3">
<div class="flex-1 min-w-0">
<el-tooltip :content="value.name" placement="top">
<div class="dialog-object-name text-gray-700">{{ value.name }}</div>
<div class="truncate text-gray-700">{{ value.name }}</div>
</el-tooltip>
<div class="text-gray-500">
{{ getStatusText(value) }}
@@ -1,5 +1,5 @@
<template>
<DialogPro v-model="open" :title="$t('commons.button.delete')" size="w-60" class="recycle-file-dialog">
<DialogPro v-model="open" :title="$t('commons.button.delete')" size="small">
<el-row>
<el-col :span="20" :offset="2">
<el-alert :title="$t('file.deleteRecycleHelper')" show-icon type="error" :closable="false"></el-alert>
@@ -8,7 +8,7 @@
<svg-icon v-if="row.isDir" className="table-icon mr-1 " iconName="p-file-folder"></svg-icon>
<svg-icon v-else className="table-icon mr-1" :iconName="getIconName(row.extension)"></svg-icon>
</div>
<span class="dialog-object-name">{{ row.name }}</span>
<span class="sle">{{ row.name }}</span>
</div>
</el-col>
</el-row>
@@ -72,11 +72,6 @@ defineExpose({
</script>
<style scoped>
:global(.el-dialog.recycle-file-dialog:not(.is-fullscreen)) {
width: 60vw !important;
min-width: 0;
}
.resource {
margin-top: 10px;
max-height: 400px;
@@ -1,5 +1,5 @@
<template>
<DialogPro v-model="open" :title="$t('file.reduce')" size="w-60" class="recycle-file-dialog">
<DialogPro v-model="open" :title="$t('file.reduce')" size="small">
<el-row>
<el-col :span="20" :offset="2">
<el-alert :title="$t('file.confirmReduce')" show-icon type="error" :closable="false"></el-alert>
@@ -8,7 +8,7 @@
<svg-icon v-if="row.isDir" className="table-icon mr-1 " iconName="p-file-folder"></svg-icon>
<svg-icon v-else className="table-icon mr-1" :iconName="getIconName(row.extension)"></svg-icon>
</div>
<span class="dialog-object-name">{{ row.name }}</span>
<span class="sle">{{ row.name }}</span>
</div>
</el-col>
</el-row>
@@ -71,10 +71,3 @@ defineExpose({
acceptParams,
});
</script>
<style scoped>
:global(.el-dialog.recycle-file-dialog:not(.is-fullscreen)) {
width: 60vw !important;
min-width: 0;
}
</style>
+129 -42
View File
@@ -42,7 +42,7 @@
/>
</el-tooltip>
</div>
<div class="text-xs text-gray-500 break-all">{{ row.keyHint }}</div>
<div class="text-xs text-[var(--el-text-color-secondary)] break-all">{{ row.keyHint }}</div>
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.status')" min-width="100" prop="status">
@@ -137,26 +137,50 @@
v-model="detailVisible"
:header="$t('apiKeyManagement.details')"
size="min(640px, 100vw)"
@close="clearLegacySecret"
@close="clearSecret"
>
<APIKeySummary v-if="detail" :item="detail" />
<div v-if="detail?.kind === 'legacy' && detail.status !== 'Revoked'" class="mt-5 flex flex-col gap-3">
<el-alert :title="$t('apiKeyManagement.legacyHelp')" type="info" :closable="false" show-icon />
<el-space wrap>
<el-button type="primary" plain :loading="legacyBusy" @click="revealLegacy">
{{ $t('apiKeyManagement.showLegacy') }}
<div v-if="detail && detail.status !== 'Revoked'" class="mt-5 flex flex-col gap-3">
<el-alert
:title="
$t(detail.kind === 'legacy' ? 'apiKeyManagement.legacyHelp' : 'apiKeyManagement.revealHelp')
"
type="info"
:closable="false"
show-icon
/>
<el-space v-if="detail.kind === 'legacy'" wrap>
<el-button :disabled="secretBusy" @click="resetLegacy">
{{ $t('setting.apiKeyReset') }}
</el-button>
<el-button :disabled="legacyBusy" @click="resetLegacy">{{ $t('setting.apiKeyReset') }}</el-button>
</el-space>
<el-input
v-if="legacySecret"
:model-value="legacySecret"
:model-value="secret || '********'"
:type="secret ? 'text' : 'password'"
:aria-label="$t('setting.apiKey')"
readonly
autocomplete="off"
>
<template #suffix>
<el-button
link
:icon="secret ? Hide : View"
:loading="secretBusy && secretAction === 'reveal'"
:disabled="loading || secretBusy"
:aria-label="$t(secret ? 'apiKeyManagement.hideSecret' : 'apiKeyManagement.reveal')"
:title="$t(secret ? 'apiKeyManagement.hideSecret' : 'apiKeyManagement.reveal')"
:aria-pressed="!!secret"
@click="toggleSecret"
/>
</template>
<template #append>
<CopyButton :content="legacySecret" :is-icon="false" />
<el-button
:loading="secretBusy && secretAction === 'copy'"
:disabled="loading || secretBusy"
@click="copySecret"
>
{{ $t('commons.button.copy') }}
</el-button>
</template>
</el-input>
</div>
@@ -165,10 +189,11 @@
</template>
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, reactive, ref } from 'vue';
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, reactive, ref, watch } from 'vue';
import { ElMessageBox } from 'element-plus';
import { Hide, View } from '@element-plus/icons-vue';
import type { APIKey } from '@/api/interface/api-key';
import { revokeAPIKey, searchAPIKeys, setAPIKeyStatus } from '@/api/modules/api-key';
import { deleteAPIKey, revealAPIKey, revokeAPIKey, searchAPIKeys, setAPIKeyStatus } from '@/api/modules/api-key';
import { generateApiKey, getUserInfo } from '@/api/modules/auth';
import APIKeyEditor from '@/components/api-key-management/editor.vue';
import APIKeySummary from '@/components/api-key-management/summary.vue';
@@ -176,35 +201,38 @@ import DrawerPro from '@/components/drawer-pro/index.vue';
import type { FuTableOperationButton } from '@/components/table/shared';
import { isAnyAPIKeyIP, normalizeAPIKeyIPs } from '@/utils/api-key';
import { MsgSuccess, MsgWarning } from '@/utils/message';
import { copyText } from '@/utils/clipboard';
import i18n from '@/lang';
import { useGlobalStore } from '@/composables/useGlobalStore';
const { isMobile } = useGlobalStore();
const { isMobile, globalStore } = useGlobalStore();
const items = ref<APIKey.Item[]>([]);
const loading = ref(false);
const editor = ref<InstanceType<typeof APIKeyEditor>>();
const detail = ref<APIKey.Item>();
const detailVisible = ref(false);
const legacySecret = ref('');
const legacyBusy = ref(false);
const secret = ref('');
const secretBusy = ref(false);
const secretAction = ref<'reveal' | 'copy'>('reveal');
const pagination = reactive({ currentPage: 1, pageSize: 20, total: 0 });
let searchVersion = 0;
let secretRequestVersion = 0;
let active = false;
let operationPending = false;
const whitelistEntries = (value: string) =>
normalizeAPIKeyIPs(value || '')
.split('\n')
.filter(Boolean);
const clearLegacySecret = () => {
const clearSecret = () => {
secretRequestVersion++;
legacySecret.value = '';
legacyBusy.value = false;
secret.value = '';
secretBusy.value = false;
};
const search = async (preserveLegacyRequest?: number) => {
const search = async (preserveSecretRequest?: number) => {
const ticket = ++searchVersion;
loading.value = true;
try {
@@ -213,7 +241,7 @@ const search = async (preserveLegacyRequest?: number) => {
const lastPage = Math.max(1, Math.ceil(response.data.total / pagination.pageSize));
if (pagination.currentPage > lastPage) {
pagination.currentPage = lastPage;
await search(preserveLegacyRequest);
await search(preserveSecretRequest);
return;
}
items.value = response.data.items || [];
@@ -221,8 +249,8 @@ const search = async (preserveLegacyRequest?: number) => {
if (detail.value) {
const previousRevision = detail.value.revision;
detail.value = items.value.find((entry) => entry.id === detail.value?.id);
if (detail.value?.revision !== previousRevision && preserveLegacyRequest !== secretRequestVersion)
clearLegacySecret();
if (detail.value?.revision !== previousRevision && preserveSecretRequest !== secretRequestVersion)
clearSecret();
if (!detail.value) detailVisible.value = false;
}
} finally {
@@ -230,7 +258,7 @@ const search = async (preserveLegacyRequest?: number) => {
}
};
const showDetail = (entry: APIKey.Item) => {
clearLegacySecret();
clearSecret();
detail.value = entry;
detailVisible.value = true;
};
@@ -239,11 +267,15 @@ const operate = async (entry: APIKey.Item, action: string) => {
editor.value?.open(entry);
return;
}
if (action !== 'status' && action !== 'revoke') return;
if (!active || operationPending || !['status', 'revoke', 'delete'].includes(action)) return;
operationPending = true;
const requestVersion = searchVersion;
try {
await ElMessageBox.confirm(
i18n.global.t(
action === 'revoke'
action === 'delete'
? 'apiKeyManagement.deleteConfirm'
: action === 'revoke'
? 'apiKeyManagement.revokeConfirm'
: entry.status === 'Enable'
? 'apiKeyManagement.disableConfirm'
@@ -258,20 +290,29 @@ const operate = async (entry: APIKey.Item, action: string) => {
},
);
} catch {
operationPending = false;
return;
}
if (!active || requestVersion !== searchVersion) {
operationPending = false;
return;
}
clearSecret();
loading.value = true;
try {
const reference = { id: entry.id, revision: entry.revision };
const response =
action === 'revoke'
action === 'delete'
? await deleteAPIKey(reference)
: action === 'revoke'
? await revokeAPIKey(reference)
: await setAPIKeyStatus({ ...reference, status: entry.status === 'Enable' ? 'Disable' : 'Enable' });
clearLegacySecret();
clearSecret();
if (response.data?.terminalClosePending) MsgWarning(i18n.global.t('apiKeyManagement.closePending'));
else MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
} finally {
await search();
operationPending = false;
if (active) await search();
}
};
const buttons = computed<FuTableOperationButton<APIKey.Item>[]>(() => [
@@ -300,21 +341,59 @@ const buttons = computed<FuTableOperationButton<APIKey.Item>[]>(() => [
show: (row) => row.status !== 'Revoked',
click: (row) => operate(row, 'revoke'),
},
{
label: i18n.global.t('commons.button.delete'),
show: (row) => row.kind === 'apiKey',
click: (row) => operate(row, 'delete'),
},
]);
const revealLegacy = async () => {
if (legacyBusy.value || !detailVisible.value || detail.value?.kind !== 'legacy') return;
const toggleSecret = async () => {
if (secretBusy.value || loading.value) return;
if (secret.value) clearSecret();
else await revealSecret();
};
const accessSecret = async (action: 'reveal' | 'copy') => {
if (secretBusy.value || loading.value || !active || !detailVisible.value || !detail.value) return;
if (detail.value.status === 'Revoked') return;
const reference = { id: detail.value.id, revision: detail.value.revision };
const kind = detail.value.kind;
const ticket = ++secretRequestVersion;
legacyBusy.value = true;
secretAction.value = action;
secretBusy.value = true;
try {
const response = await getUserInfo();
if (ticket === secretRequestVersion && detailVisible.value && active && detail.value?.kind === 'legacy')
legacySecret.value = response.data.apiKey;
if (action === 'copy' && secret.value) {
await copyText(secret.value);
return;
}
secret.value = '';
const data =
kind === 'legacy'
? { ...reference, apiKey: (await getUserInfo()).data.apiKey }
: (await revealAPIKey(reference)).data;
if (
ticket === secretRequestVersion &&
active &&
detailVisible.value &&
detail.value?.id === reference.id &&
detail.value?.kind === kind &&
detail.value?.revision === reference.revision &&
data.id === reference.id &&
data.revision === reference.revision &&
data.apiKey
) {
if (action === 'copy') await copyText(data.apiKey);
else secret.value = data.apiKey;
}
} catch {
if (ticket === secretRequestVersion && active) await search();
} finally {
if (ticket === secretRequestVersion) legacyBusy.value = false;
if (ticket === secretRequestVersion) secretBusy.value = false;
}
};
const revealSecret = () => accessSecret('reveal');
const copySecret = () => accessSecret('copy');
const resetLegacy = async () => {
if (legacyBusy.value || !detailVisible.value || detail.value?.kind !== 'legacy') return;
if (secretBusy.value || !detailVisible.value || detail.value?.kind !== 'legacy') return;
const ticket = ++secretRequestVersion;
try {
await ElMessageBox.confirm(i18n.global.t('setting.apiKeyResetHelper'), i18n.global.t('setting.apiKeyReset'));
@@ -322,17 +401,18 @@ const resetLegacy = async () => {
return;
}
if (ticket !== secretRequestVersion || !detailVisible.value || !active) return;
legacyBusy.value = true;
legacySecret.value = '';
secretAction.value = 'reveal';
secretBusy.value = true;
secret.value = '';
try {
const response = await generateApiKey();
await search(ticket);
// A reset changes the revision and deliberately clears any older reveal.
// Never restore a secret after the original details drawer was closed.
if (ticket === secretRequestVersion && detailVisible.value && active && detail.value?.kind === 'legacy')
legacySecret.value = response.data;
secret.value = response.data;
} finally {
if (ticket === secretRequestVersion) legacyBusy.value = false;
if (ticket === secretRequestVersion) secretBusy.value = false;
}
};
const activate = () => {
@@ -343,9 +423,16 @@ const activate = () => {
const deactivate = () => {
active = false;
searchVersion++;
clearLegacySecret();
clearSecret();
detailVisible.value = false;
};
watch(
() => globalStore.isLogin,
(loggedIn) => {
if (!loggedIn) deactivate();
},
{ flush: 'sync' },
);
onMounted(activate);
onActivated(activate);
onDeactivated(deactivate);