Merge pull request #1081 from fudiwei:main

This commit is contained in:
RHQYZ 2025-12-04 17:16:51 +08:00 committed by GitHub
commit a038ee0367
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
68 changed files with 1939 additions and 1039 deletions

15
go.mod
View File

@ -40,7 +40,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/iam v1.52.2
github.com/baidubce/bce-sdk-go v0.9.252
github.com/byteplus-sdk/byteplus-sdk-golang v1.0.59
github.com/go-acme/lego/v4 v4.28.1
github.com/go-acme/lego/v4 v4.29.0
github.com/go-cmd/cmd v1.4.3
github.com/go-resty/resty/v2 v2.17.0
github.com/go-viper/mapstructure/v2 v2.4.0
@ -99,7 +99,7 @@ require (
github.com/alibabacloud-go/alibabacloud-gateway-fc-util v0.0.7 // indirect
github.com/avast/retry-go v3.0.0+incompatible // indirect
github.com/aws/aws-sdk-go v1.40.45 // indirect
github.com/aws/aws-sdk-go-v2/service/route53 v1.59.1 // indirect
github.com/aws/aws-sdk-go-v2/service/route53 v1.61.0 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/buger/goterm v1.0.4 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
@ -107,8 +107,8 @@ require (
github.com/djherbis/times v1.6.0 // indirect
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-acme/alidns-20150109/v4 v4.6.1 // indirect
github.com/go-acme/tencentclouddnspod v1.1.10 // indirect
github.com/go-acme/alidns-20150109/v4 v4.7.0 // indirect
github.com/go-acme/tencentclouddnspod v1.1.25 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
@ -131,7 +131,7 @@ require (
github.com/kong/semver/v4 v4.0.1 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/linode/linodego v1.60.0 // indirect
github.com/linode/linodego v1.61.0 // indirect
github.com/magefile/mage v1.14.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
@ -155,7 +155,7 @@ require (
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/vultr/govultr/v3 v3.24.0 // indirect
github.com/vultr/govultr/v3 v3.25.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.mongodb.org/mongo-driver v1.17.2 // indirect
go.uber.org/ratelimit v0.3.1 // indirect
@ -165,7 +165,7 @@ require (
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/ns1/ns1-go.v2 v2.15.1 // indirect
gopkg.in/ns1/ns1-go.v2 v2.15.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
@ -203,6 +203,7 @@ require (
github.com/fatih/color v1.18.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
github.com/go-acme/esa-20240910/v2 v2.40.1 // indirect
github.com/go-acme/tencentedgdeone v1.1.48 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect

32
go.sum
View File

@ -227,8 +227,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/A
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 h1:FIouAnCE46kyYqyhs0XEBDFFSREtdnr8HQuLPQPLCrY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E=
github.com/aws/aws-sdk-go-v2/service/route53 v1.59.1 h1:KuoA/cmy/yK8n9v/d6WH36cZwGxFOrn0TmZ4lNN3MKQ=
github.com/aws/aws-sdk-go-v2/service/route53 v1.59.1/go.mod h1:BymbICXBfXQHO6i+yTBhocA9a6DM0uMDQqYelqa9wzs=
github.com/aws/aws-sdk-go-v2/service/route53 v1.61.0 h1:W3+0Cbc9awFBr9Yt7nFUkvB4N4e7vVIGtKD1qDttXn4=
github.com/aws/aws-sdk-go-v2/service/route53 v1.61.0/go.mod h1:Wa3q5R2uwIfIL3HZH+vG1/P9y7CjjfzTgcz5IWXlsZs=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 h1:MxMBdKTYBjPQChlJhi4qlEueqB1p1KcbTEa7tD5aqPs=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2/go.mod h1:iS6EPmNeqCsGo+xQmXv0jIMjyYtQfnwg36zl2FwEouk=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 h1:ksUT5KtgpZd3SAiFJNJ0AFEJVva3gjBmN7eXUZjzUwQ=
@ -337,12 +337,14 @@ github.com/gammazero/toposort v0.1.1/go.mod h1:H2cozTnNpMw0hg2VHAYsAxmkHXBYroNan
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-acme/alidns-20150109/v4 v4.6.1 h1:Dch3aWRcw4U62+jKPjPQN3iW3TPvgIywATbvHzojXeo=
github.com/go-acme/alidns-20150109/v4 v4.6.1/go.mod h1:RBcqBA5IvUWtlpjx6dC6EkPVyBNLQ+mR18XoaP38BFY=
github.com/go-acme/lego/v4 v4.28.1 h1:zt301JYF51UIEkpSXsdeGq9hRePeFzQCq070OdAmP0Q=
github.com/go-acme/lego/v4 v4.28.1/go.mod h1:bzjilr03IgbaOwlH396hq5W56Bi0/uoRwW/JM8hP7m4=
github.com/go-acme/tencentclouddnspod v1.1.10 h1:ERVJ4mc3cT4Nb3+n6H/c1AwZnChGBqLoymE0NVYscKI=
github.com/go-acme/tencentclouddnspod v1.1.10/go.mod h1:Bo/0YQJ/99FM+44HmCQkByuptX1tJsJ9V14MGV/2Qco=
github.com/go-acme/alidns-20150109/v4 v4.7.0 h1:PqJ/wR0JTpL4v0Owu1uM7bPQ1Yww0eQLAuuSdLjjQaQ=
github.com/go-acme/alidns-20150109/v4 v4.7.0/go.mod h1:btQvB6xZoN6ykKB74cPhiR+uvhrEE2AFVXm6RDmCHm0=
github.com/go-acme/esa-20240910/v2 v2.40.1 h1:pog3UlF5d+3LPoo1L8u8PqB189recIXX8T7pGoEz18A=
github.com/go-acme/esa-20240910/v2 v2.40.1/go.mod h1:ZYdN9EN9ikn26SNapxCVjZ65pHT/1qm4fzuJ7QGVX6g=
github.com/go-acme/lego/v4 v4.29.0 h1:vKMEtvoKb0gOO9rWO9zMBwE4CgI5A5CWDsK4QEeBqzo=
github.com/go-acme/lego/v4 v4.29.0/go.mod h1:rnYyDj1NdDd9y1dHkVuUS97j7bfe9I61+oY9odKaHM8=
github.com/go-acme/tencentclouddnspod v1.1.25 h1:7H3ZKshkaHzCXfRpAHVB5nvxeDDl2XLeNZfrNHiZj/s=
github.com/go-acme/tencentclouddnspod v1.1.25/go.mod h1:XXfzp0AYV7UAUsHKT6R0KAUJFhqAUXmWGF07Elpa5cE=
github.com/go-acme/tencentedgdeone v1.1.48 h1:WLyLBsRVhSLFmtbEFXk0naLODSQn7X6J0Fc/qR8xVUk=
github.com/go-acme/tencentedgdeone v1.1.48/go.mod h1:mu6tA+bPhlSd+CKUfzRikE0mfxmTlBI6dVTn9LY9dRI=
github.com/go-cmd/cmd v1.4.3 h1:6y3G+3UqPerXvPcXvj+5QNPHT02BUw7p6PsqRxLNA7Y=
@ -618,8 +620,8 @@ github.com/libdns/dynv6 v1.1.1 h1:X+P/5tNeBo+MLf080kyy1aJG2FPdon0/fVOd58AlfZU=
github.com/libdns/dynv6 v1.1.1/go.mod h1:XhpjX1oZ0TdzH0nZaP5hpW3x/HCMB0T7loZw65on5Iw=
github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
github.com/linode/linodego v1.60.0 h1:SgsebJFRCi+lSmYy+C40wmKZeJllGGm+W12Qw4+yVdI=
github.com/linode/linodego v1.60.0/go.mod h1:1+Bt0oTz5rBnDOJbGhccxn7LYVytXTIIfAy7QYmijDs=
github.com/linode/linodego v1.61.0 h1:9g20NWl+/SbhDFj6X5EOZXtM2hBm1Mx8I9h8+F3l1LM=
github.com/linode/linodego v1.61.0/go.mod h1:64o30geLNwR0NeYh5HM/WrVCBXcSqkKnRK3x9xoRuJI=
github.com/luthermonson/go-proxmox v0.2.3 h1:NAjUJ5Jd1ynIK6UHMGd/VLGgNZWpGXhfL+DBmAVSEaA=
github.com/luthermonson/go-proxmox v0.2.3/go.mod h1:oyFgg2WwTEIF0rP6ppjiixOHa5ebK1p8OaRiFhvICBQ=
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
@ -838,7 +840,7 @@ github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn v1.2.3 h1:48Vaw27yh
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn v1.2.3/go.mod h1:20RYaBaXM5Qo/JzYX91x2SsyvVrP/+dVCK7V5IwLJd8=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/clb v1.2.2 h1:mDg1SYAFF3tiDRFnFDGguCR2Kq35udG07nHa52uTYX0=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/clb v1.2.2/go.mod h1:59a8Y11O2Hqa9cS3mMpsLbTgQsu6+PpOu+ZYWTEtVDE=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.1.10/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.1.25/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.1.45/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.1.48/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.1.49/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
@ -883,8 +885,8 @@ github.com/volcengine/volc-sdk-golang v1.0.229 h1:gOkDltTS6Fta8OyfYrbeY9bqCHHyiJ
github.com/volcengine/volc-sdk-golang v1.0.229/go.mod h1:zHJlaqiMbIB+0mcrsZPTwOb3FB7S/0MCfqlnO8R7hlM=
github.com/volcengine/volcengine-go-sdk v1.1.50 h1:hWJvHKcpcye3tqA1rqQrRLxiyrVB5s9gwhJNs0DjWk8=
github.com/volcengine/volcengine-go-sdk v1.1.50/go.mod h1:oxoVo+A17kvkwPkIeIHPVLjSw7EQAm+l/Vau1YGHN+A=
github.com/vultr/govultr/v3 v3.24.0 h1:fTTTj0VBve+Miy+wGhlb90M2NMDfpGFi6Frlj3HVy6M=
github.com/vultr/govultr/v3 v3.24.0/go.mod h1:9WwnWGCKnwDlNjHjtt+j+nP+0QWq6hQXzaHgddqrLWY=
github.com/vultr/govultr/v3 v3.25.0 h1:rS8/Vdy8HlHArwmD4MtLY+hbbpYAbcnZueZrE6b0oUg=
github.com/vultr/govultr/v3 v3.25.0/go.mod h1:9WwnWGCKnwDlNjHjtt+j+nP+0QWq6hQXzaHgddqrLWY=
github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8=
github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@ -1389,8 +1391,8 @@ gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
gopkg.in/ns1/ns1-go.v2 v2.15.1 h1:8rri2TzAPYcVbBGXn48+dz1Xg30PzHfZ4k8A9JOS0Z0=
gopkg.in/ns1/ns1-go.v2 v2.15.1/go.mod h1:pfaU0vECVP7DIOr453z03HXS6dFJpXdNRwOyRzwmPSc=
gopkg.in/ns1/ns1-go.v2 v2.15.2 h1:aBVyKeEH3rBFWwX72xPPjEuRL4+Lp5P9GlAcrzu0Y5M=
gopkg.in/ns1/ns1-go.v2 v2.15.2/go.mod h1:pfaU0vECVP7DIOr453z03HXS6dFJpXdNRwOyRzwmPSc=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

View File

@ -13,8 +13,11 @@ type Settings struct {
}
const (
SettingsNameSSLProvider = "sslProvider"
SettingsNamePersistence = "persistence"
SettingsNameEmails = "emails"
SettingsNameNotificationTemplate = "notifyTemplate"
SettingsNameScriptTemplate = "scriptTemplate"
SettingsNameSSLProvider = "sslProvider"
SettingsNamePersistence = "persistence"
)
type SettingsContent map[string]any

View File

@ -4,8 +4,9 @@ import (
"errors"
"time"
"github.com/go-acme/lego/v4/providers/dns/aliesa"
"github.com/certimate-go/certimate/pkg/core/certifier"
"github.com/certimate-go/certimate/pkg/core/certifier/challengers/dns01/aliyun-esa/internal"
)
type ChallengerConfig struct {
@ -21,8 +22,8 @@ func NewChallenger(config *ChallengerConfig) (certifier.ACMEChallenger, error) {
return nil, errors.New("the configuration of the acme challenge provider is nil")
}
providerConfig := internal.NewDefaultConfig()
providerConfig.SecretID = config.AccessKeyId
providerConfig := aliesa.NewDefaultConfig()
providerConfig.APIKey = config.AccessKeyId
providerConfig.SecretKey = config.AccessKeySecret
providerConfig.RegionID = config.Region
if config.DnsPropagationTimeout != 0 {
@ -32,7 +33,7 @@ func NewChallenger(config *ChallengerConfig) (certifier.ACMEChallenger, error) {
providerConfig.TTL = config.DnsTTL
}
provider, err := internal.NewDNSProviderConfig(providerConfig)
provider, err := aliesa.NewDNSProviderConfig(providerConfig)
if err != nil {
return nil, err
}

View File

@ -1,225 +0,0 @@
package internal
import (
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
openapiutil "github.com/alibabacloud-go/darabonba-openapi/v2/utils"
aliesa "github.com/alibabacloud-go/esa-20240910/v2/client"
"github.com/alibabacloud-go/tea/dara"
)
// This is a partial copy of https://github.com/alibabacloud-go/esa-20240910/blob/master/client/client.go
// to lightweight the vendor packages in the built binary.
type EsaClient struct {
openapi.Client
DisableSDKError *bool
}
func NewEsaClient(config *openapiutil.Config) (*EsaClient, error) {
client := new(EsaClient)
err := client.Init(config)
return client, err
}
func (client *EsaClient) Init(config *openapiutil.Config) (_err error) {
_err = client.Client.Init(config)
if _err != nil {
return _err
}
_err = client.CheckConfig(config)
if _err != nil {
return _err
}
return nil
}
func (client *EsaClient) CreateRecordWithOptions(tmpReq *aliesa.CreateRecordRequest, runtime *dara.RuntimeOptions) (_result *aliesa.CreateRecordResponse, _err error) {
_err = tmpReq.Validate()
if _err != nil {
return _result, _err
}
request := &aliesa.CreateRecordShrinkRequest{}
openapiutil.Convert(tmpReq, request)
if !dara.IsNil(tmpReq.AuthConf) {
request.AuthConfShrink = openapiutil.ArrayToStringWithSpecifiedStyle(tmpReq.AuthConf, dara.String("AuthConf"), dara.String("json"))
}
if !dara.IsNil(tmpReq.Data) {
request.DataShrink = openapiutil.ArrayToStringWithSpecifiedStyle(tmpReq.Data, dara.String("Data"), dara.String("json"))
}
query := map[string]interface{}{}
if !dara.IsNil(request.AuthConfShrink) {
query["AuthConf"] = request.AuthConfShrink
}
if !dara.IsNil(request.BizName) {
query["BizName"] = request.BizName
}
if !dara.IsNil(request.Comment) {
query["Comment"] = request.Comment
}
if !dara.IsNil(request.DataShrink) {
query["Data"] = request.DataShrink
}
if !dara.IsNil(request.HostPolicy) {
query["HostPolicy"] = request.HostPolicy
}
if !dara.IsNil(request.Proxied) {
query["Proxied"] = request.Proxied
}
if !dara.IsNil(request.RecordName) {
query["RecordName"] = request.RecordName
}
if !dara.IsNil(request.SiteId) {
query["SiteId"] = request.SiteId
}
if !dara.IsNil(request.SourceType) {
query["SourceType"] = request.SourceType
}
if !dara.IsNil(request.Ttl) {
query["Ttl"] = request.Ttl
}
if !dara.IsNil(request.Type) {
query["Type"] = request.Type
}
req := &openapiutil.OpenApiRequest{
Query: openapiutil.Query(query),
}
params := &openapiutil.Params{
Action: dara.String("CreateRecord"),
Version: dara.String("2024-09-10"),
Protocol: dara.String("HTTPS"),
Pathname: dara.String("/"),
Method: dara.String("POST"),
AuthType: dara.String("AK"),
Style: dara.String("RPC"),
ReqBodyType: dara.String("formData"),
BodyType: dara.String("json"),
}
_result = &aliesa.CreateRecordResponse{}
_body, _err := client.CallApi(params, req, runtime)
if _err != nil {
return _result, _err
}
_err = dara.Convert(_body, &_result)
return _result, _err
}
func (client *EsaClient) CreateRecord(request *aliesa.CreateRecordRequest) (_result *aliesa.CreateRecordResponse, _err error) {
runtime := &dara.RuntimeOptions{}
_result = &aliesa.CreateRecordResponse{}
_body, _err := client.CreateRecordWithOptions(request, runtime)
if _err != nil {
return _result, _err
}
_result = _body
return _result, _err
}
func (client *EsaClient) DeleteRecordWithOptions(request *aliesa.DeleteRecordRequest, runtime *dara.RuntimeOptions) (_result *aliesa.DeleteRecordResponse, _err error) {
_err = request.Validate()
if _err != nil {
return _result, _err
}
query := map[string]interface{}{}
if !dara.IsNil(request.RecordId) {
query["RecordId"] = request.RecordId
}
if !dara.IsNil(request.SecurityToken) {
query["SecurityToken"] = request.SecurityToken
}
req := &openapiutil.OpenApiRequest{
Query: openapiutil.Query(query),
}
params := &openapiutil.Params{
Action: dara.String("DeleteRecord"),
Version: dara.String("2024-09-10"),
Protocol: dara.String("HTTPS"),
Pathname: dara.String("/"),
Method: dara.String("POST"),
AuthType: dara.String("AK"),
Style: dara.String("RPC"),
ReqBodyType: dara.String("formData"),
BodyType: dara.String("json"),
}
_result = &aliesa.DeleteRecordResponse{}
_body, _err := client.CallApi(params, req, runtime)
if _err != nil {
return _result, _err
}
_err = dara.Convert(_body, &_result)
return _result, _err
}
func (client *EsaClient) DeleteRecord(request *aliesa.DeleteRecordRequest) (_result *aliesa.DeleteRecordResponse, _err error) {
runtime := &dara.RuntimeOptions{}
_result = &aliesa.DeleteRecordResponse{}
_body, _err := client.DeleteRecordWithOptions(request, runtime)
if _err != nil {
return _result, _err
}
_result = _body
return _result, _err
}
func (client *EsaClient) ListSitesWithOptions(tmpReq *aliesa.ListSitesRequest, runtime *dara.RuntimeOptions) (_result *aliesa.ListSitesResponse, _err error) {
_err = tmpReq.Validate()
if _err != nil {
return _result, _err
}
request := &aliesa.ListSitesShrinkRequest{}
openapiutil.Convert(tmpReq, request)
if !dara.IsNil(tmpReq.TagFilter) {
request.TagFilterShrink = openapiutil.ArrayToStringWithSpecifiedStyle(tmpReq.TagFilter, dara.String("TagFilter"), dara.String("json"))
}
query := openapiutil.Query(dara.ToMap(request))
req := &openapiutil.OpenApiRequest{
Query: openapiutil.Query(query),
}
params := &openapiutil.Params{
Action: dara.String("ListSites"),
Version: dara.String("2024-09-10"),
Protocol: dara.String("HTTPS"),
Pathname: dara.String("/"),
Method: dara.String("GET"),
AuthType: dara.String("AK"),
Style: dara.String("RPC"),
ReqBodyType: dara.String("formData"),
BodyType: dara.String("json"),
}
_result = &aliesa.ListSitesResponse{}
_body, _err := client.CallApi(params, req, runtime)
if _err != nil {
return _result, _err
}
_err = dara.Convert(_body, &_result)
return _result, _err
}
func (client *EsaClient) ListSites(request *aliesa.ListSitesRequest) (_result *aliesa.ListSitesResponse, _err error) {
runtime := &dara.RuntimeOptions{}
_result = &aliesa.ListSitesResponse{}
_body, _err := client.ListSitesWithOptions(request, runtime)
if _err != nil {
return _result, _err
}
_result = _body
return _result, _err
}

View File

@ -1,196 +0,0 @@
package internal
import (
"errors"
"fmt"
"sync"
"time"
aliopen "github.com/alibabacloud-go/darabonba-openapi/v2/client"
aliesa "github.com/alibabacloud-go/esa-20240910/v2/client"
"github.com/alibabacloud-go/tea/tea"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
)
const (
envNamespace = "ALICLOUDESA_"
EnvAccessKey = envNamespace + "ACCESS_KEY"
EnvSecretKey = envNamespace + "SECRET_KEY"
EnvRegionID = envNamespace + "REGION_ID"
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
)
var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
type Config struct {
SecretID string
SecretKey string
RegionID string
PropagationTimeout time.Duration
PollingInterval time.Duration
TTL int
HTTPTimeout time.Duration
}
type DNSProvider struct {
client *EsaClient
config *Config
recordIDs map[string]int64
recordIDsMu sync.Mutex
}
func NewDefaultConfig() *Config {
return &Config{
TTL: env.GetOrDefaultInt(EnvTTL, 300),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
}
}
func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get(EnvAccessKey, EnvSecretKey, EnvRegionID)
if err != nil {
return nil, fmt.Errorf("alicloud-esa: %w", err)
}
config := NewDefaultConfig()
config.SecretID = values[EnvAccessKey]
config.SecretKey = values[EnvSecretKey]
config.RegionID = values[EnvRegionID]
return NewDNSProviderConfig(config)
}
func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
if config == nil {
return nil, errors.New("alicloud-esa: the configuration of the DNS provider is nil")
}
if config.RegionID == "" {
config.RegionID = "cn-hangzhou"
}
client, err := NewEsaClient(&aliopen.Config{
AccessKeyId: tea.String(config.SecretID),
AccessKeySecret: tea.String(config.SecretKey),
Endpoint: tea.String(fmt.Sprintf("esa.%s.aliyuncs.com", config.RegionID)),
})
if err != nil {
return nil, fmt.Errorf("alicloud-esa: %w", err)
}
return &DNSProvider{
client: client,
config: config,
recordIDs: make(map[string]int64),
recordIDsMu: sync.Mutex{},
}, nil
}
func (d *DNSProvider) Present(domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
if err != nil {
return fmt.Errorf("alicloud-esa: could not find zone for domain %q: %w", domain, err)
}
siteName := dns01.UnFqdn(authZone)
siteID, err := d.findSiteIdByName(siteName)
if err != nil {
return fmt.Errorf("alicloud-esa: could not find site for zone %q: %w", siteName, err)
}
// REF: https://www.alibabacloud.com/help/en/edge-security-acceleration/esa/api-esa-2024-09-10-createrecord
aliCreateRecordReq := &aliesa.CreateRecordRequest{
SiteId: tea.Int64(siteID),
Type: tea.String("TXT"),
RecordName: tea.String(dns01.UnFqdn(info.EffectiveFQDN)),
Data: &aliesa.CreateRecordRequestData{
Value: tea.String(info.Value),
},
Ttl: tea.Int32(int32(d.config.TTL)),
}
aliCreateRecordResp, err := d.client.CreateRecord(aliCreateRecordReq)
if err != nil {
return fmt.Errorf("alicloud-esa: error when create record: %w", err)
}
d.recordIDsMu.Lock()
d.recordIDs[token] = *aliCreateRecordResp.Body.GetRecordId()
d.recordIDsMu.Unlock()
return nil
}
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
d.recordIDsMu.Lock()
recordID, ok := d.recordIDs[token]
d.recordIDsMu.Unlock()
if !ok {
return fmt.Errorf("alicloud-esa: unknown record ID for '%s'", info.EffectiveFQDN)
}
// REF: https://www.alibabacloud.com/help/en/edge-security-acceleration/esa/api-esa-2024-09-10-deleterecord
aliDeleteRecordReq := &aliesa.DeleteRecordRequest{
RecordId: &recordID,
}
if _, err := d.client.DeleteRecord(aliDeleteRecordReq); err != nil {
return fmt.Errorf("alicloud-esa: error when delete record %w", err)
}
return nil
}
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
return d.config.PropagationTimeout, d.config.PollingInterval
}
func (d *DNSProvider) findSiteIdByName(siteName string) (int64, error) {
aliListSitesPageNumber := 1
aliListSitesPageSize := 500
for {
// REF: https://www.alibabacloud.com/help/en/edge-security-acceleration/esa/api-esa-2024-09-10-listsites
aliListSitesReq := &aliesa.ListSitesRequest{
SiteName: tea.String(siteName),
SiteSearchType: tea.String("exact"),
AccessType: tea.String("NS"),
PageNumber: tea.Int32(int32(aliListSitesPageNumber)),
PageSize: tea.Int32(int32(aliListSitesPageSize)),
}
aliListSitesResp, err := d.client.ListSites(aliListSitesReq)
if err != nil {
return 0, err
}
if aliListSitesResp.Body == nil {
break
}
for _, siteItem := range aliListSitesResp.Body.Sites {
if *siteItem.GetSiteName() == siteName {
return *siteItem.GetSiteId(), nil
}
}
if len(aliListSitesResp.Body.Sites) < aliListSitesPageSize {
break
}
aliListSitesPageNumber++
}
return 0, fmt.Errorf("could not find site '%s'", siteName)
}

View File

@ -4,8 +4,9 @@ import (
"errors"
"time"
"github.com/go-acme/lego/v4/providers/dns/baiducloud"
"github.com/certimate-go/certimate/pkg/core/certifier"
"github.com/certimate-go/certimate/pkg/core/certifier/challengers/dns01/baiducloud/internal"
)
type ChallengerConfig struct {
@ -22,7 +23,7 @@ func NewChallenger(config *ChallengerConfig) (certifier.ACMEChallenger, error) {
// 没有使用 github.com/go-acme/lego/v4/providers/dns/baiducloud
// 因为该实现存在一些问题
providerConfig := internal.NewDefaultConfig()
providerConfig := baiducloud.NewDefaultConfig()
providerConfig.AccessKeyID = config.AccessKeyId
providerConfig.SecretAccessKey = config.SecretAccessKey
if config.DnsPropagationTimeout != 0 {
@ -32,7 +33,7 @@ func NewChallenger(config *ChallengerConfig) (certifier.ACMEChallenger, error) {
providerConfig.TTL = config.DnsTTL
}
provider, err := internal.NewDNSProviderConfig(providerConfig)
provider, err := baiducloud.NewDNSProviderConfig(providerConfig)
if err != nil {
return nil, err
}

View File

@ -1,175 +0,0 @@
package internal
import (
"errors"
"fmt"
"time"
bce "github.com/baidubce/bce-sdk-go/bce"
bcedns "github.com/baidubce/bce-sdk-go/services/dns"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/samber/lo"
)
const (
envNamespace = "BAIDUCLOUD_"
EnvAccessKeyID = envNamespace + "ACCESS_KEY_ID"
EnvSecretAccessKey = envNamespace + "SECRET_ACCESS_KEY"
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
)
var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
type Config struct {
AccessKeyID string
SecretAccessKey string
PropagationTimeout time.Duration
PollingInterval time.Duration
TTL int
HTTPTimeout time.Duration
}
type DNSProvider struct {
client *bcedns.Client
config *Config
}
func NewDefaultConfig() *Config {
return &Config{
TTL: env.GetOrDefaultInt(EnvTTL, 300),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
}
}
func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get(EnvAccessKeyID, EnvSecretAccessKey)
if err != nil {
return nil, fmt.Errorf("baiducloud: %w", err)
}
config := NewDefaultConfig()
config.AccessKeyID = values[EnvAccessKeyID]
config.SecretAccessKey = values[EnvSecretAccessKey]
return NewDNSProviderConfig(config)
}
func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
if config == nil {
return nil, errors.New("baiducloud: the configuration of the DNS provider is nil")
}
client, err := bcedns.NewClient(config.AccessKeyID, config.SecretAccessKey, "")
if err != nil {
return nil, err
} else {
if client.Config == nil {
client.Config = &bce.BceClientConfiguration{}
}
client.Config.HTTPClientTimeout = &config.HTTPTimeout
}
return &DNSProvider{
client: client,
config: config,
}, nil
}
func (d *DNSProvider) Present(domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
if err != nil {
return fmt.Errorf("baiducloud: could not find zone for domain %q: %w", domain, err)
}
subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
if err != nil {
return fmt.Errorf("baiducloud: %w", err)
}
// REF: https://cloud.baidu.com/doc/DNS/s/El4s7lssr#%E6%B7%BB%E5%8A%A0%E8%A7%A3%E6%9E%90%E8%AE%B0%E5%BD%95
bceCreateRecordReq := &bcedns.CreateRecordRequest{
Type: "TXT",
Rr: subDomain,
Value: info.Value,
Description: lo.ToPtr("certimate acme"),
Ttl: lo.ToPtr(int32(d.config.TTL)),
}
if err := d.client.CreateRecord(dns01.UnFqdn(authZone), bceCreateRecordReq, security.RandomString(32)); err != nil {
return fmt.Errorf("baiducloud: error when create record: %w", err)
}
return nil
}
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
if err != nil {
return fmt.Errorf("baiducloud: could not find zone for domain %q: %w", domain, err)
}
subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
if err != nil {
return fmt.Errorf("baiducloud: %w", err)
}
record, err := d.findDNSRecord(dns01.UnFqdn(authZone), subDomain, info.Value)
if err != nil {
return fmt.Errorf("baiducloud: error when find record: %q: %w", domain, err)
}
// REF: https://cloud.baidu.com/doc/DNS/s/El4s7lssr#%E5%88%A0%E9%99%A4%E8%A7%A3%E6%9E%90%E8%AE%B0%E5%BD%95
if err := d.client.DeleteRecord(dns01.UnFqdn(authZone), record.Id, security.RandomString(32)); err != nil {
return fmt.Errorf("baiducloud: %w", err)
}
return nil
}
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
return d.config.PropagationTimeout, d.config.PollingInterval
}
func (d *DNSProvider) findDNSRecord(zoneName, subDomain, tokenValue string) (*bcedns.Record, error) {
bceListRecordPageMarker := ""
for {
// REF: https://cloud.baidu.com/doc/DNS/s/El4s7lssr#%E6%9F%A5%E8%AF%A2%E8%A7%A3%E6%9E%90%E8%AE%B0%E5%BD%95%E5%88%97%E8%A1%A8
bceListRecordReq := &bcedns.ListRecordRequest{}
bceListRecordReq.Rr = subDomain
bceListRecordReq.Marker = bceListRecordPageMarker
bceListRecordReq.MaxKeys = 1000
bceListRecordResp, err := d.client.ListRecord(zoneName, bceListRecordReq)
if err != nil {
return nil, err
}
for _, record := range bceListRecordResp.Records {
if record.Type == "TXT" && record.Rr == subDomain && record.Value == tokenValue {
return &record, nil
}
}
if bceListRecordResp.NextMarker == "" {
break
}
bceListRecordPageMarker = bceListRecordResp.NextMarker
}
return nil, errors.New("could not find record")
}

View File

@ -8,6 +8,7 @@ import (
"log/slog"
"os/exec"
"runtime"
"strings"
"github.com/certimate-go/certimate/pkg/core/deployer"
xcert "github.com/certimate-go/certimate/pkg/utils/cert"
@ -83,7 +84,17 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*dep
// 执行前置命令
if d.config.PreCommand != "" {
stdout, stderr, err := execCommand(d.config.ShellEnv, d.config.PreCommand)
command := d.config.PreCommand
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_PATH}", d.config.OutputCertPath)
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_SERVER_PATH}", d.config.OutputServerCertPath)
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_INTERMEDIA_PATH}", d.config.OutputIntermediaCertPath)
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_PRIVATEKEY_PATH}", d.config.OutputKeyPath)
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_PFX_PASSWORD}", d.config.PfxPassword)
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_JKS_ALIAS}", d.config.JksAlias)
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_JKS_KEYPASS}", d.config.JksKeypass)
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_JKS_STOREPASS}", d.config.JksStorepass)
stdout, stderr, err := execCommand(d.config.ShellEnv, command)
d.logger.Debug("run pre-command", slog.String("stdout", stdout), slog.String("stderr", stderr))
if err != nil {
return nil, fmt.Errorf("failed to execute pre-command (stdout: %s, stderr: %s): %w ", stdout, stderr, err)
@ -147,7 +158,17 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*dep
// 执行后置命令
if d.config.PostCommand != "" {
stdout, stderr, err := execCommand(d.config.ShellEnv, d.config.PostCommand)
command := d.config.PostCommand
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_PATH}", d.config.OutputCertPath)
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_SERVER_PATH}", d.config.OutputServerCertPath)
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_INTERMEDIA_PATH}", d.config.OutputIntermediaCertPath)
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_PRIVATEKEY_PATH}", d.config.OutputKeyPath)
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_PFX_PASSWORD}", d.config.PfxPassword)
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_JKS_ALIAS}", d.config.JksAlias)
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_JKS_KEYPASS}", d.config.JksKeypass)
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_JKS_STOREPASS}", d.config.JksStorepass)
stdout, stderr, err := execCommand(d.config.ShellEnv, command)
d.logger.Debug("run post-command", slog.String("stdout", stdout), slog.String("stderr", stderr))
if err != nil {
return nil, fmt.Errorf("failed to execute post-command (stdout: %s, stderr: %s): %w ", stdout, stderr, err)

View File

@ -8,6 +8,7 @@ import (
"log/slog"
"net"
"strconv"
"strings"
"golang.org/x/crypto/ssh"
@ -181,7 +182,17 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*dep
// 执行前置命令
if d.config.PreCommand != "" {
stdout, stderr, err := execSshCommand(client, d.config.PreCommand)
command := d.config.PreCommand
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_PATH}", d.config.OutputCertPath)
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_SERVER_PATH}", d.config.OutputServerCertPath)
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_INTERMEDIA_PATH}", d.config.OutputIntermediaCertPath)
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_PRIVATEKEY_PATH}", d.config.OutputKeyPath)
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_PFX_PASSWORD}", d.config.PfxPassword)
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_JKS_ALIAS}", d.config.JksAlias)
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_JKS_KEYPASS}", d.config.JksKeypass)
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_JKS_STOREPASS}", d.config.JksStorepass)
stdout, stderr, err := execSshCommand(client, command)
d.logger.Debug("run pre-command", slog.String("stdout", stdout), slog.String("stderr", stderr))
if err != nil {
return nil, fmt.Errorf("failed to execute pre-command (stdout: %s, stderr: %s): %w ", stdout, stderr, err)
@ -245,7 +256,17 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*dep
// 执行后置命令
if d.config.PostCommand != "" {
stdout, stderr, err := execSshCommand(client, d.config.PostCommand)
command := d.config.PostCommand
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_PATH}", d.config.OutputCertPath)
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_SERVER_PATH}", d.config.OutputServerCertPath)
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_INTERMEDIA_PATH}", d.config.OutputIntermediaCertPath)
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_PRIVATEKEY_PATH}", d.config.OutputKeyPath)
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_PFX_PASSWORD}", d.config.PfxPassword)
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_JKS_ALIAS}", d.config.JksAlias)
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_JKS_KEYPASS}", d.config.JksKeypass)
command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_JKS_STOREPASS}", d.config.JksStorepass)
stdout, stderr, err := execSshCommand(client, command)
d.logger.Debug("run post-command", slog.String("stdout", stdout), slog.String("stderr", stderr))
if err != nil {
return nil, fmt.Errorf("failed to execute post-command (stdout: %s, stderr: %s): %w ", stdout, stderr, err)

View File

@ -93,8 +93,6 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*dep
return nil, fmt.Errorf("failed to parse webhook url: %w", err)
} else if webhookUrl.Scheme != "http" && webhookUrl.Scheme != "https" {
return nil, fmt.Errorf("unsupported webhook url scheme '%s'", webhookUrl.Scheme)
} else {
webhookUrl.Path = strings.ReplaceAll(webhookUrl.Path, "${DOMAIN}", url.PathEscape(certX509.Subject.CommonName))
}
// 处理 Webhook 请求谓词
@ -143,13 +141,6 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*dep
return nil, fmt.Errorf("failed to unmarshal webhook data: %w", err)
}
replaceJsonValueRecursively(webhookData, "${DOMAIN}", certX509.Subject.CommonName)
replaceJsonValueRecursively(webhookData, "${DOMAINS}", strings.Join(certX509.DNSNames, ";"))
replaceJsonValueRecursively(webhookData, "${CERTIFICATE}", certPEM)
replaceJsonValueRecursively(webhookData, "${SERVER_CERTIFICATE}", serverCertPEM)
replaceJsonValueRecursively(webhookData, "${INTERMEDIA_CERTIFICATE}", intermediaCertPEM)
replaceJsonValueRecursively(webhookData, "${PRIVATE_KEY}", privkeyPEM)
if webhookMethod == http.MethodGet || webhookContentType == CONTENT_TYPE_FORM || webhookContentType == CONTENT_TYPE_MULTIPART {
temp := make(map[string]string)
jsonb, err := json.Marshal(webhookData)
@ -163,6 +154,24 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*dep
}
}
// 替换变量值
webhookUrl.Path = strings.ReplaceAll(webhookUrl.Path, "${CERTIMATE_DEPLOYER_COMMONNAME}", url.PathEscape(certX509.Subject.CommonName))
replaceJsonValueRecursively(webhookData, "${CERTIMATE_DEPLOYER_COMMONNAME}", certX509.Subject.CommonName)
replaceJsonValueRecursively(webhookData, "${CERTIMATE_DEPLOYER_SUBJECTALTNAMES}", strings.Join(certX509.DNSNames, ";"))
replaceJsonValueRecursively(webhookData, "${CERTIMATE_DEPLOYER_CERTIFICATE}", certPEM)
replaceJsonValueRecursively(webhookData, "${CERTIMATE_DEPLOYER_CERTIFICATE_SERVER}", serverCertPEM)
replaceJsonValueRecursively(webhookData, "${CERTIMATE_DEPLOYER_CERTIFICATE_INTERMEDIA}", intermediaCertPEM)
replaceJsonValueRecursively(webhookData, "${CERTIMATE_DEPLOYER_PRIVATEKEY}", privkeyPEM)
// 兼容旧版变量
webhookUrl.Path = strings.ReplaceAll(webhookUrl.Path, "${DOMAIN}", url.PathEscape(certX509.Subject.CommonName))
replaceJsonValueRecursively(webhookData, "${DOMAIN}", certX509.Subject.CommonName)
replaceJsonValueRecursively(webhookData, "${DOMAINS}", strings.Join(certX509.DNSNames, ";"))
replaceJsonValueRecursively(webhookData, "${CERTIFICATE}", certPEM)
replaceJsonValueRecursively(webhookData, "${SERVER_CERTIFICATE}", serverCertPEM)
replaceJsonValueRecursively(webhookData, "${INTERMEDIA_CERTIFICATE}", intermediaCertPEM)
replaceJsonValueRecursively(webhookData, "${PRIVATE_KEY}", privkeyPEM)
// 生成请求
// 其中 GET 请求需转换为查询参数
req := d.httpClient.R().SetHeaderMultiValues(webhookHeaders)

View File

@ -127,9 +127,6 @@ func (n *Notifier) Notify(ctx context.Context, subject string, message string) (
return nil, fmt.Errorf("failed to unmarshal webhook data: %w", err)
}
replaceJsonValueRecursively(webhookData, "${SUBJECT}", subject)
replaceJsonValueRecursively(webhookData, "${MESSAGE}", message)
if webhookMethod == http.MethodGet || webhookContentType == CONTENT_TYPE_FORM || webhookContentType == CONTENT_TYPE_MULTIPART {
temp := make(map[string]string)
jsonb, err := json.Marshal(webhookData)
@ -143,6 +140,14 @@ func (n *Notifier) Notify(ctx context.Context, subject string, message string) (
}
}
// 替换变量值
replaceJsonValueRecursively(webhookData, "${CERTIMATE_NOTIFIER_SUBJECT}", subject)
replaceJsonValueRecursively(webhookData, "${CERTIMATE_NOTIFIER_MESSAGE}", message)
// 兼容旧版变量
replaceJsonValueRecursively(webhookData, "${SUBJECT}", subject)
replaceJsonValueRecursively(webhookData, "${MESSAGE}", message)
// 生成请求
// 其中 GET 请求需转换为查询参数
req := n.httpClient.R().SetContext(ctx).SetHeaderMultiValues(webhookHeaders)

View File

@ -125,7 +125,7 @@ export default defineConfig(
"react-hooks/exhaustive-deps": ["warn"],
"react-hooks/immutability": ["warn"],
"react-hooks/refs": ["warn"],
"react-hooks/preserve-manual-memoization": ["warn"],
"react-hooks/preserve-manual-memoization": ["off"],
"react-hooks/set-state-in-effect": ["warn"],
"react-hooks/set-state-in-render": ["warn"],
"react-refresh/only-export-components": [

View File

@ -18,7 +18,6 @@ export interface DrawerFormProps<T extends NonNullable<unknown> = any> extends O
open?: boolean;
title?: React.ReactNode;
trigger?: React.ReactNode;
onClose?: (e: React.MouseEvent | React.KeyboardEvent) => void | Promise<unknown>;
onFinish?: (values: T) => unknown | Promise<unknown>;
onOpenChange?: (open: boolean) => void;
}

View File

@ -33,7 +33,6 @@ export interface ModalFormProps<T extends NonNullable<unknown> = any> extends Om
title?: ModalProps["title"];
trigger?: React.ReactNode;
width?: ModalProps["width"];
onClose?: (e: React.MouseEvent | React.KeyboardEvent) => void | Promise<unknown>;
onFinish?: (values: T) => unknown | Promise<unknown>;
onOpenChange?: (open: boolean) => void;
}

View File

@ -17,8 +17,8 @@ export interface MultipleSplitValueInputProps extends Omit<InputProps, "count" |
defaultValue?: string;
maxCount?: number;
minCount?: number;
modalTitle?: string;
modalWidth?: number;
modalTitle?: React.ReactNode;
modalWidth?: number | string;
placeholderInModal?: string;
showSortButton?: boolean;
separator?: string;
@ -38,7 +38,7 @@ const MultipleSplitValueInput = ({
maxCount,
minCount,
modalTitle,
modalWidth = 480,
modalWidth = "480px",
placeholder,
placeholderInModal,
showSortButton = true,

View File

@ -82,20 +82,29 @@ const AccessEditDrawer = ({ afterSubmit, mode, data, loading, trigger, usage, ..
}
try {
if (mode === "create") {
if (data?.id) {
throw "Invalid props: `data`";
}
switch (mode) {
case "create":
{
if (data?.id) {
throw "Invalid props: `data`";
}
formValues = await createAccess(formValues);
} else if (mode === "modify") {
if (!data?.id) {
throw "Invalid props: `data`";
}
formValues = await createAccess(formValues);
}
break;
formValues = await updateAccess({ ...data, ...formValues });
} else {
throw "Invalid props: `action`";
case "modify":
{
if (!data?.id) {
throw "Invalid props: `data`";
}
formValues = await updateAccess({ ...data, ...formValues });
}
break;
default:
throw "Invalid props: `mode`";
}
afterSubmit?.(formValues);
@ -174,11 +183,7 @@ const AccessEditDrawer = ({ afterSubmit, mode, data, loading, trigger, usage, ..
title={
<Flex align="center" justify="space-between" gap="small">
<div className="flex-1 truncate">
{mode === "modify"
? data?.id
? t("access.action.edit.modal.title") + ` #${data.id}`
: t("access.action.edit.modal.title")
: t(`access.action.${mode}.modal.title`)}
{mode === "modify" && !!data?.id ? t("access.action.modify.modal.title") + ` #${data.id}` : t(`access.action.${mode}.modal.title`)}
</div>
<Button
className="ant-drawer-close"

View File

@ -16,7 +16,9 @@ const AccessSelect = ({ onFilter, ...props }: AccessTypeSelectProps) => {
const { token: themeToken } = theme.useToken();
const { accesses, loadedAtOnce, fetchAccesses } = useAccessesStore(useZustandShallowSelector(["accesses", "loadedAtOnce", "fetchAccesses"]));
useMount(() => fetchAccesses(false));
useMount(() => {
fetchAccesses(false);
});
const [options, setOptions] = useState<Array<{ key: string; value: string; label: string; data: AccessModel }>>([]);
useEffect(() => {

View File

@ -68,8 +68,8 @@ const AccessConfigFormFieldsProviderWebhook = ({ usage = "none" }: AccessConfigF
[parentNamePath, "data"],
JSON.stringify(
{
title: "${SUBJECT}",
body: "${MESSAGE}",
title: "${CERTIMATE_NOTIFIER_SUBJECT}",
body: "${CERTIMATE_NOTIFIER_MESSAGE}",
device_key: "<your-bark-device-key>",
},
null,
@ -86,8 +86,8 @@ const AccessConfigFormFieldsProviderWebhook = ({ usage = "none" }: AccessConfigF
[parentNamePath, "data"],
JSON.stringify(
{
title: "${SUBJECT}",
message: "${MESSAGE}",
title: "${CERTIMATE_NOTIFIER_SUBJECT}",
message: "${CERTIMATE_NOTIFIER_MESSAGE}",
priority: 1,
},
null,
@ -105,8 +105,8 @@ const AccessConfigFormFieldsProviderWebhook = ({ usage = "none" }: AccessConfigF
JSON.stringify(
{
topic: "<your-ntfy-topic>",
title: "${SUBJECT}",
message: "${MESSAGE}",
title: "${CERTIMATE_NOTIFIER_SUBJECT}",
message: "${CERTIMATE_NOTIFIER_MESSAGE}",
priority: 1,
},
null,
@ -125,8 +125,8 @@ const AccessConfigFormFieldsProviderWebhook = ({ usage = "none" }: AccessConfigF
{
token: "<your-pushover-token>",
user: "<your-pushover-user>",
title: "${SUBJECT}",
message: "${MESSAGE}",
title: "${CERTIMATE_NOTIFIER_SUBJECT}",
message: "${CERTIMATE_NOTIFIER_MESSAGE}",
},
null,
2
@ -143,8 +143,8 @@ const AccessConfigFormFieldsProviderWebhook = ({ usage = "none" }: AccessConfigF
JSON.stringify(
{
token: "<your-pushplus-token>",
title: "${SUBJECT}",
content: "${MESSAGE}",
title: "${CERTIMATE_NOTIFIER_SUBJECT}",
content: "${CERTIMATE_NOTIFIER_MESSAGE}",
},
null,
2
@ -160,8 +160,8 @@ const AccessConfigFormFieldsProviderWebhook = ({ usage = "none" }: AccessConfigF
[parentNamePath, "data"],
JSON.stringify(
{
title: "${SUBJECT}",
desp: "${MESSAGE}",
title: "${CERTIMATE_NOTIFIER_SUBJECT}",
desp: "${CERTIMATE_NOTIFIER_MESSAGE}",
},
null,
2
@ -177,8 +177,8 @@ const AccessConfigFormFieldsProviderWebhook = ({ usage = "none" }: AccessConfigF
[parentNamePath, "data"],
JSON.stringify(
{
title: "${SUBJECT}",
desp: "${MESSAGE}",
title: "${CERTIMATE_NOTIFIER_SUBJECT}",
desp: "${CERTIMATE_NOTIFIER_MESSAGE}",
},
null,
2
@ -231,7 +231,7 @@ const AccessConfigFormFieldsProviderWebhook = ({ usage = "none" }: AccessConfigF
items: [
{
key: "certimate",
label: "Certimate",
label: t("access.form.webhook_preset_data.common"),
onClick: handlePresetDataForDeploymentClick,
},
],
@ -239,7 +239,7 @@ const AccessConfigFormFieldsProviderWebhook = ({ usage = "none" }: AccessConfigF
trigger={["click"]}
>
<Button size="small" type="link">
{t("access.form.webhook_preset_data.button")}
{t("access.form.webhook_preset_data")}
<IconChevronDown size="1.25em" />
</Button>
</Dropdown>
@ -268,14 +268,14 @@ const AccessConfigFormFieldsProviderWebhook = ({ usage = "none" }: AccessConfigF
menu={{
items: ["bark", "ntfy", "gotify", "pushover", "pushplus", "serverchan3", "serverchanturbo", "common"].map((key) => ({
key,
label: <span dangerouslySetInnerHTML={{ __html: t(`access.form.webhook_preset_data.option.${key}.label`) }}></span>,
label: <span dangerouslySetInnerHTML={{ __html: t(`access.form.webhook_preset_data.${key}`) }}></span>,
onClick: () => handlePresetDataForNotificationClick(key),
})),
}}
trigger={["click"]}
>
<Button size="small" type="link">
{t("access.form.webhook_preset_data.button")}
{t("access.form.webhook_preset_data")}
<IconChevronDown size="1.25em" />
</Button>
</Dropdown>
@ -318,14 +318,14 @@ const getInitialValues = ({ usage = "none" }: { usage?: "deployment" | "notifica
data: JSON.stringify(
usage === "deployment"
? {
name: "${DOMAINS}",
cert: "${CERTIFICATE}",
privkey: "${PRIVATE_KEY}",
name: "${CERTIMATE_DEPLOYER_COMMONNAME}",
cert: "${CERTIMATE_DEPLOYER_CERTIFICATE}",
privkey: "${CERTIMATE_DEPLOYER_PRIVATEKEY}",
}
: usage === "notification"
? {
subject: "${SUBJECT}",
message: "${MESSAGE}",
subject: "${CERTIMATE_NOTIFIER_SUBJECT}",
message: "${CERTIMATE_NOTIFIER_MESSAGE}",
}
: {},
null,

View File

@ -0,0 +1,83 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { IconMoodEmpty } from "@tabler/icons-react";
import { useMount } from "ahooks";
import { Dropdown, type DropdownProps } from "antd";
import { useZustandShallowSelector } from "@/hooks";
import { useNotifyTemplatesStore } from "@/stores/settings";
type PresetTemplate = {
subject: string;
message: string;
};
export interface PresetNotifyTemplatesPopselectProps extends Omit<DropdownProps, "menu"> {
options?: NonNullable<DropdownProps["menu"]>["items"];
onSelect?: (value: string, template?: PresetTemplate | undefined) => void;
}
const PresetNotifyTemplatesPopselect = ({ className, options, onSelect, ...props }: PresetNotifyTemplatesPopselectProps) => {
const { t } = useTranslation();
const { templates, fetchTemplates } = useNotifyTemplatesStore(useZustandShallowSelector(["templates", "fetchTemplates"]));
useMount(() => {
fetchTemplates(false);
});
const menuItems = useMemo(() => {
type MenuItem = NonNullable<NonNullable<DropdownProps["menu"]>["items"]>[number];
const temp: MenuItem[] = [];
if (!options?.length && !templates?.length) {
temp.push({
key: "nodata",
label: t("common.text.nodata"),
icon: (
<span className="anticon scale-125">
<IconMoodEmpty size="1em" />
</span>
),
disabled: true,
});
return temp;
}
if (options?.length) {
temp.push(
...options.map((option) => {
return {
...option!,
onClick: (e: any) => {
if ("onClick" in option!) {
option.onClick?.(e);
}
onSelect?.(String(option!.key!));
},
};
})
);
}
if (templates?.length) {
temp.push({
key: "custom",
label: t("preset.dropdown.option_group.custom"),
children: templates.map((template) => ({
key: `custom/${template.name}`,
label: template.name,
onClick: () => {
onSelect?.(template.name, template);
},
})),
});
}
return temp;
}, [options, templates, onSelect]);
return <Dropdown className={className} menu={{ items: menuItems }} {...props} />;
};
export default PresetNotifyTemplatesPopselect;

View File

@ -0,0 +1,82 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { IconMoodEmpty } from "@tabler/icons-react";
import { useMount } from "ahooks";
import { Dropdown, type DropdownProps } from "antd";
import { useZustandShallowSelector } from "@/hooks";
import { useScriptTemplatesStore } from "@/stores/settings";
type PresetTemplate = {
command: string;
};
export interface PresetScriptTemplatesPopselectProps extends Omit<DropdownProps, "menu"> {
options?: NonNullable<DropdownProps["menu"]>["items"];
onSelect?: (value: string, template?: PresetTemplate | undefined) => void;
}
const PresetScriptTemplatesPopselect = ({ className, options, onSelect, ...props }: PresetScriptTemplatesPopselectProps) => {
const { t } = useTranslation();
const { templates, fetchTemplates } = useScriptTemplatesStore(useZustandShallowSelector(["templates", "fetchTemplates"]));
useMount(() => {
fetchTemplates(false);
});
const menuItems = useMemo(() => {
type MenuItem = NonNullable<NonNullable<DropdownProps["menu"]>["items"]>[number];
const temp: MenuItem[] = [];
if (!options?.length && !templates?.length) {
temp.push({
key: "nodata",
label: t("common.text.nodata"),
icon: (
<span className="anticon scale-125">
<IconMoodEmpty size="1em" />
</span>
),
disabled: true,
});
return temp;
}
if (options?.length) {
temp.push(
...options.map((option) => {
return {
...option!,
onClick: (e: any) => {
if ("onClick" in option!) {
option.onClick?.(e);
}
onSelect?.(String(option!.key!));
},
};
})
);
}
if (templates?.length) {
temp.push({
key: "custom",
label: t("preset.dropdown.option_group.custom"),
children: templates.map((template) => ({
key: `custom/${template.name}`,
label: template.name,
onClick: () => {
onSelect?.(template.name, template);
},
})),
});
}
return temp;
}, [options, templates, onSelect]);
return <Dropdown className={className} menu={{ items: menuItems }} {...props} />;
};
export default PresetScriptTemplatesPopselect;

View File

@ -1,7 +1,7 @@
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMount } from "ahooks";
import { Avatar, Card, Empty, Input, type InputRef, Tag, Tooltip, Typography } from "antd";
import { Avatar, Card, Empty, Input, type InputRef, Tag, Typography } from "antd";
import Show from "@/components/Show";
import { ACCESS_USAGES, type AccessProvider, type AccessUsageType, accessProvidersMap } from "@/domain/provider";
@ -63,10 +63,15 @@ const AccessProviderPicker = ({
const renderOption = (provider: AccessProvider) => {
return (
<div key={provider.type}>
<div className="group/provider size-full" key={provider.type}>
<Card
className={mergeCls("w-full overflow-hidden shadow", provider.builtin ? " cursor-not-allowed" : "", showOptionTagAnyhow ? "h-32" : "h-28")}
styles={{ body: { height: "100%", padding: "0.5rem 1rem" } }}
className={mergeCls("size-full overflow-hidden shadow", provider.builtin ? "cursor-not-allowed" : void 0)}
styles={{
body: {
height: "100%",
padding: "1.25rem 1rem",
},
}}
hoverable
onClick={() => {
if (provider.builtin) {
@ -76,36 +81,44 @@ const AccessProviderPicker = ({
handleProviderTypeSelect(provider.type);
}}
>
<div className="flex size-full flex-col items-center justify-center gap-3 overflow-hidden p-2">
<div className="flex items-center justify-center">
<Avatar className="bg-stone-100" icon={<img src={provider.icon} />} shape="square" size={32} />
</div>
<div className="w-full overflow-hidden text-center">
<div className={mergeCls("w-full truncate", { "mb-1": showOptionTagAnyhow })}>
<Tooltip title={t(provider.name)} mouseEnterDelay={1}>
<Typography.Text type={provider.builtin ? "secondary" : void 0}>{t(provider.name) || "\u00A0"}</Typography.Text>
</Tooltip>
<div className="flex size-full flex-col">
<div className="flex flex-1 justify-between gap-3">
<div className="flex-1">
<Typography.Text type={provider.builtin ? "secondary" : void 0}>{t(provider.name) || "\u00A0"}</Typography.Text>
</div>
<div className="transition-all group-hover/provider:scale-110">
<Avatar className="bg-stone-50" icon={<img src={provider.icon} />} shape="square" size={28} />
</div>
<Show when={showOptionTagAnyhow}>
<div className="origin-top scale-80 whitespace-nowrap">
<Show when={showOptionTagForBuiltin && provider.builtin}>
<Tag>{t("access.props.provider.builtin")}</Tag>
</Show>
<Show when={showOptionTagForDNS && provider.usages.includes(ACCESS_USAGES.DNS)}>
<Tag color="#d93f0b99">{t("access.props.provider.usage.dns")}</Tag>
</Show>
<Show when={showOptionTagForHosting && provider.usages.includes(ACCESS_USAGES.HOSTING)}>
<Tag color="#0052cc99">{t("access.props.provider.usage.hosting")}</Tag>
</Show>
<Show when={showOptionTagForCA && provider.usages.includes(ACCESS_USAGES.CA)}>
<Tag color="#0e8a1699">{t("access.props.provider.usage.ca")}</Tag>
</Show>
<Show when={showOptionTagForNotification && provider.usages.includes(ACCESS_USAGES.NOTIFICATION)}>
<Tag color="#1d76db99">{t("access.props.provider.usage.notification")}</Tag>
</Show>
</div>
</Show>
</div>
<Show when={showOptionTagAnyhow}>
<div className="flex origin-left scale-80 items-center gap-1 whitespace-nowrap">
<Show when={showOptionTagForBuiltin && provider.builtin}>
<Tag className="mt-4 -mb-2" color="default">
{t("access.props.provider.builtin")}
</Tag>
</Show>
<Show when={showOptionTagForDNS && provider.usages.includes(ACCESS_USAGES.DNS)}>
<Tag className="mt-4 -mb-2" color="#d93f0b">
{t("access.props.provider.usage.dns")}
</Tag>
</Show>
<Show when={showOptionTagForHosting && provider.usages.includes(ACCESS_USAGES.HOSTING)}>
<Tag className="mt-4 -mb-2" color="#0052cc">
{t("access.props.provider.usage.hosting")}
</Tag>
</Show>
<Show when={showOptionTagForCA && provider.usages.includes(ACCESS_USAGES.CA)}>
<Tag className="mt-4 -mb-2" color="#0e8a16">
{t("access.props.provider.usage.ca")}
</Tag>
</Show>
<Show when={showOptionTagForNotification && provider.usages.includes(ACCESS_USAGES.NOTIFICATION)}>
<Tag className="mt-4 -mb-2" color="#1d76db">
{t("access.props.provider.usage.notification")}
</Tag>
</Show>
</div>
</Show>
</div>
</Card>
</div>

View File

@ -60,21 +60,21 @@ const AccessProviderSelect = ({ showOptionTags, onFilter, ...props }: AccessProv
{t(provider.name)}
</Typography.Text>
</div>
<div className="origin-right scale-75 whitespace-nowrap">
<div className="flex origin-right scale-80 items-center justify-center gap-1 whitespace-nowrap">
<Show when={showOptionTagForBuiltin && provider.builtin}>
<Tag>{t("access.props.provider.builtin")}</Tag>
<Tag color="default">{t("access.props.provider.builtin")}</Tag>
</Show>
<Show when={showOptionTagForDNS && provider.usages.includes(ACCESS_USAGES.DNS)}>
<Tag color="orange">{t("access.props.provider.usage.dns")}</Tag>
<Tag color="#d93f0b">{t("access.props.provider.usage.dns")}</Tag>
</Show>
<Show when={showOptionTagForHosting && provider.usages.includes(ACCESS_USAGES.HOSTING)}>
<Tag color="geekblue">{t("access.props.provider.usage.hosting")}</Tag>
<Tag color="#0052cc">{t("access.props.provider.usage.hosting")}</Tag>
</Show>
<Show when={showOptionTagForCA && provider.usages.includes(ACCESS_USAGES.CA)}>
<Tag color="magenta">{t("access.props.provider.usage.ca")}</Tag>
<Tag color="#0e8a16">{t("access.props.provider.usage.ca")}</Tag>
</Show>
<Show when={showOptionTagForNotification && provider.usages.includes(ACCESS_USAGES.NOTIFICATION)}>
<Tag color="cyan">{t("access.props.provider.usage.notification")}</Tag>
<Tag color="#1d76db">{t("access.props.provider.usage.notification")}</Tag>
</Show>
</div>
</div>

View File

@ -67,7 +67,9 @@ const DeploymentProviderPicker = ({
>
<div className={mergeCls("size-full", transparent ? "transition-opacity opacity-75 group-hover/provider:opacity-100" : void 0)}>
<div className="flex size-full items-center gap-4 overflow-hidden">
<Avatar className="bg-stone-100" icon={<img src={provider.icon} />} shape="square" size={28} />
<div>
<Avatar className="bg-stone-50" icon={<img src={provider.icon} />} shape="square" size={28} />
</div>
<div className="flex-1 overflow-hidden">
<div className="line-clamp-2 max-w-full">
<Tooltip title={t(provider.name)} mouseEnterDelay={1}>

View File

@ -55,7 +55,9 @@ const NotificationProviderPicker = ({
>
<div className={mergeCls("size-full", transparent ? "transition-opacity opacity-75 group-hover/provider:opacity-100" : void 0)}>
<div className="flex size-full items-center gap-4 overflow-hidden">
<Avatar className="bg-stone-100" icon={<img src={provider.icon} />} shape="square" size={28} />
<div>
<Avatar className="bg-stone-50" icon={<img src={provider.icon} />} shape="square" size={28} />
</div>
<div className="flex-1 overflow-hidden">
<div className="line-clamp-2 max-w-full">
<Tooltip title={t(provider.name)} mouseEnterDelay={1}>

View File

@ -26,7 +26,9 @@ export const useSelectDataSource = <T extends Provider>({
deps?: React.DependencyList;
}) => {
const { accesses, fetchAccesses } = useAccessesStore(useZustandShallowSelector(["accesses", "fetchAccesses"]));
useMount(() => fetchAccesses(false));
useMount(() => {
fetchAccesses(false);
});
const filteredDataSource = useMemo(() => {
return dataSource.filter((provider) => {
@ -82,7 +84,7 @@ export const usePickerWrapperCols = (width: number) => {
const wWidth = wrapperSize?.width ?? document.body.clientWidth - 256;
const wCols = Math.floor(wWidth / width);
return Math.min(9, Math.max(1, wCols));
}, [wrapperSize, width]);
}, [wrapperSize?.width, width]);
return {
wrapperElRef,
@ -104,7 +106,9 @@ export const usePickerDataSource = <T extends Provider>({
const { t } = useTranslation();
const { accesses, fetchAccesses } = useAccessesStore(useZustandShallowSelector(["accesses", "fetchAccesses"]));
useMount(() => fetchAccesses(false));
useMount(() => {
fetchAccesses(false);
});
const filteredDataSource = useMemo(() => {
return dataSource

View File

@ -22,7 +22,7 @@ import { acmeDns01ProvidersMap, acmeHttp01ProvidersMap, caProvidersMap } from "@
import { type WorkflowNodeConfigForBizApply, defaultNodeConfigForBizApply } from "@/domain/workflow";
import { useAntdForm, useZustandShallowSelector } from "@/hooks";
import { useAccessesStore } from "@/stores/access";
import { useContactEmailsStore } from "@/stores/contact";
import { useContactEmailsStore } from "@/stores/settings";
import { getErrMsg } from "@/utils/error";
import { validDomainName, validIPv4Address, validIPv6Address } from "@/utils/validators";
@ -653,7 +653,9 @@ const BizApplyNodeConfigForm = ({ node, ...props }: BizApplyNodeConfigFormProps)
const InternalEmailInput = memo(
({ disabled, placeholder, ...props }: { disabled?: boolean; placeholder?: string; value?: string; onChange?: (value: string) => void }) => {
const { emails, fetchEmails, removeEmail } = useContactEmailsStore();
useMount(() => fetchEmails(false));
useMount(() => {
fetchEmails(false);
});
const [value, setValue] = useControllableValue<string>(props, {
valuePropName: "value",

View File

@ -1,10 +1,11 @@
import { getI18n, useTranslation } from "react-i18next";
import { IconChevronDown } from "@tabler/icons-react";
import { Button, Dropdown, Form, Input, Select } from "antd";
import { IconBulb, IconChevronDown } from "@tabler/icons-react";
import { Button, Divider, Form, Input, Popover, Select, Space } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod";
import CodeInput from "@/components/CodeInput";
import PresetScriptTemplatesPopselect from "@/components/preset/PresetScriptTemplatesPopselect";
import Show from "@/components/Show";
import Tips from "@/components/Tips";
import { CERTIFICATE_FORMATS } from "@/domain/certificate";
@ -59,8 +60,8 @@ sudo service nginx reload
return `# *** 需要管理员权限 ***
#
$pfxPath = "${params?.certPath || "<your-cert-path>"}" # PFX
$pfxPassword = "${params?.pfxPassword || "<your-pfx-password>"}" # PFX
$pfxPath = "\${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_PATH}" # PFX
$pfxPassword = "\${CERTIMATE_DEPLOYER_CMDVAR_PFX_PASSWORD}" # PFX
$siteName = "<your-site-name>" # IIS
$domain = "<your-domain-name>" #
$ipaddr = "<your-binding-ip>" # IP* IP
@ -90,8 +91,8 @@ Remove-Item -Path "$pfxPath" -Force
return `# *** 需要管理员权限 ***
#
$pfxPath = "${params?.certPath || "<your-cert-path>"}" # PFX
$pfxPassword = "${params?.pfxPassword || "<your-pfx-password>"}" # PFX
$pfxPath = "\${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_PATH}" # PFX
$pfxPassword = "\${CERTIMATE_DEPLOYER_CMDVAR_PFX_PASSWORD}" # PFX
$ipaddr = "<your-binding-ip>" # IP0.0.0.0 IP
$port = "<your-binding-port>" #
@ -113,8 +114,8 @@ Remove-Item -Path "$pfxPath" -Force
return `# *** 需要管理员权限 ***
#
$pfxPath = "${params?.certPath || "<your-cert-path>"}" # PFX
$pfxPassword = "${params?.pfxPassword || "<your-pfx-password>"}" # PFX
$pfxPath = "\${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_PATH}" # PFX
$pfxPassword = "\${CERTIMATE_DEPLOYER_CMDVAR_PFX_PASSWORD}" # PFX
#
$cert = Import-PfxCertificate -FilePath "$pfxPath" -CertStoreLocation Cert:\\LocalMachine\\My -Password (ConvertTo-SecureString -String "$pfxPassword" -AsPlainText -Force) -Exportable
@ -339,21 +340,25 @@ const BizDeployNodeConfigFieldsProviderLocal = () => {
<Form.Item label={t("workflow_node.deploy.form.local_pre_command.label")}>
<div className="absolute -top-1.5 right-0 -translate-y-full">
<Dropdown
menu={{
items: ["sh_backup_files", "ps_backup_files"].map((key) => ({
key,
label: t(`workflow_node.deploy.form.local_preset_scripts.option.${key}.label`),
onClick: () => handlePresetPreScriptClick(key),
})),
}}
<PresetScriptTemplatesPopselect
options={["sh_backup_files", "ps_backup_files"].map((key) => ({
key,
label: t(`workflow_node.deploy.form.local_preset_scripts.${key}`),
}))}
trigger={["click"]}
onSelect={(key, template) => {
if (template) {
formInst.setFieldValue([parentNamePath, "preCommand"], template.command);
} else {
handlePresetPreScriptClick(key);
}
}}
>
<Button size="small" type="link">
{t("workflow_node.deploy.form.local_preset_scripts.button")}
{t("preset.dropdown.script.button")}
<IconChevronDown size="1.25em" />
</Button>
</Dropdown>
</PresetScriptTemplatesPopselect>
</div>
<Form.Item name={[parentNamePath, "preCommand"]} initialValue={initialValues.preCommand} noStyle rules={[formRule]}>
<CodeInput
@ -368,21 +373,33 @@ const BizDeployNodeConfigFieldsProviderLocal = () => {
<Form.Item label={t("workflow_node.deploy.form.local_post_command.label")}>
<div className="absolute -top-1.5 right-0 -translate-y-full">
<Dropdown
menu={{
items: ["sh_reload_nginx", "ps_binding_iis", "ps_binding_netsh", "ps_binding_rdp"].map((key) => ({
<Space align="center" separator={<Divider orientation="vertical" />} size={0}>
<Popover content={<div dangerouslySetInnerHTML={{ __html: t("workflow_node.deploy.form.shared_script_command.vartips") }} />} mouseEnterDelay={1}>
<Button color="default" size="small" variant="link">
<IconBulb size="1.25em" />
</Button>
</Popover>
<PresetScriptTemplatesPopselect
options={["sh_reload_nginx", "ps_binding_iis", "ps_binding_netsh", "ps_binding_rdp"].map((key) => ({
key,
label: t(`workflow_node.deploy.form.local_preset_scripts.option.${key}.label`),
label: t(`workflow_node.deploy.form.local_preset_scripts.${key}`),
onClick: () => handlePresetPostScriptClick(key),
})),
}}
trigger={["click"]}
>
<Button size="small" type="link">
{t("workflow_node.deploy.form.local_preset_scripts.button")}
<IconChevronDown size="1.25em" />
</Button>
</Dropdown>
}))}
trigger={["click"]}
onSelect={(key, template) => {
if (template) {
formInst.setFieldValue([parentNamePath, "postCommand"], template.command);
} else {
handlePresetPostScriptClick(key);
}
}}
>
<Button size="small" type="link">
{t("preset.dropdown.script.button")}
<IconChevronDown size="1.25em" />
</Button>
</PresetScriptTemplatesPopselect>
</Space>
</div>
<Form.Item name={[parentNamePath, "postCommand"]} initialValue={initialValues.postCommand} noStyle rules={[formRule]}>
<CodeInput

View File

@ -1,10 +1,11 @@
import { getI18n, useTranslation } from "react-i18next";
import { IconChevronDown } from "@tabler/icons-react";
import { Button, Dropdown, Form, Input, Select, Switch } from "antd";
import { IconBulb, IconChevronDown } from "@tabler/icons-react";
import { Button, Divider, Form, Input, Popover, Select, Space, Switch } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod";
import CodeInput from "@/components/CodeInput";
import PresetScriptTemplatesPopselect from "@/components/preset/PresetScriptTemplatesPopselect";
import Show from "@/components/Show";
import { CERTIFICATE_FORMATS } from "@/domain/certificate";
@ -26,9 +27,9 @@ const initPresetScript = (
# https://github.com/catchdave/ssl-certs/blob/main/replace_synology_ssl_certs.sh
#
$tmpFullchainPath = "${params?.certPath || "<your-fullchain-cert-path>"}" #
$tmpCertPath = "${params?.certPathForServerOnly || "<your-server-cert-path>"}" #
$tmpKeyPath = "${params?.keyPath || "<your-key-path>"}" #
$tmpFullchainPath = "\${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_PATH}" #
$tmpCertPath = "\${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_SERVER_PATH}" #
$tmpKeyPath = "\${CERTIMATE_DEPLOYER_CMDVAR_PRIVATEKEY_PATH}" #
DEBUG=1
error_exit() { echo "[ERROR] $1"; exit 1; }
@ -105,9 +106,9 @@ info "Completed"
#
# \`/usr/trim/etc/network_cert_all.conf\` 中查看,注意不要修改文件名
$tmpFullchainPath = "${params?.certPath || "<your-fullchain-cert-path>"}" #
$tmpCertPath = "${params?.certPathForServerOnly || "<your-server-cert-path>"}" #
$tmpKeyPath = "${params?.keyPath || "<your-key-path>"}" #
$tmpFullchainPath = "\${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_PATH}" #
$tmpCertPath = "\${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_SERVER_PATH}" #
$tmpKeyPath = "\${CERTIMATE_DEPLOYER_CMDVAR_PRIVATEKEY_PATH}" #
$fnFullchainPath = "/usr/trim/var/trim_connect/ssls/example.com/1234567890/fullchain.crt" #
$fnCertPath = "/usr/trim/var/trim_connect/ssls/example.com/1234567890/example.com.crt" #
$fnKeyPath = "/usr/trim/var/trim_connect/ssls/example.com/1234567890/example.com.key" #
@ -139,8 +140,8 @@ systemctl restart trim_nginx.service
# HTTPS
#
$tmpFullchainPath = "${params?.certPath || "<your-fullchain-cert-path>"}" #
$tmpKeyPath = "${params?.keyPath || "<your-key-path>"}" #
$tmpFullchainPath = "\${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_PATH}"}" #
$tmpKeyPath = "\${CERTIMATE_DEPLOYER_CMDVAR_PRIVATEKEY_PATH}" #
#
cp -rf "$tmpFullchainPath" /etc/stunnel/backup.cert
@ -257,6 +258,16 @@ const BizDeployNodeConfigFieldsProviderSSH = () => {
return (
<>
<Form.Item
name={[parentNamePath, "useSCP"]}
initialValue={initialValues.useSCP}
label={t("workflow_node.deploy.form.ssh_use_scp.label")}
rules={[formRule]}
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.deploy.form.ssh_use_scp.tooltip") }}></span>}
>
<Switch />
</Form.Item>
<Form.Item
name={[parentNamePath, "format"]}
initialValue={initialValues.format}
@ -364,21 +375,25 @@ const BizDeployNodeConfigFieldsProviderSSH = () => {
<Form.Item label={t("workflow_node.deploy.form.ssh_pre_command.label")}>
<div className="absolute -top-1.5 right-0 -translate-y-full">
<Dropdown
menu={{
items: ["sh_backup_files", "ps_backup_files"].map((key) => ({
key,
label: t(`workflow_node.deploy.form.ssh_preset_scripts.option.${key}.label`),
onClick: () => handlePresetPreScriptClick(key),
})),
}}
<PresetScriptTemplatesPopselect
options={["sh_backup_files", "ps_backup_files"].map((key) => ({
key,
label: t(`workflow_node.deploy.form.ssh_preset_scripts.${key}`),
}))}
trigger={["click"]}
onSelect={(key, template) => {
if (template) {
formInst.setFieldValue([parentNamePath, "preCommand"], template.command);
} else {
handlePresetPreScriptClick(key);
}
}}
>
<Button size="small" type="link">
{t("workflow_node.deploy.form.ssh_preset_scripts.button")}
{t("preset.dropdown.script.button")}
<IconChevronDown size="1.25em" />
</Button>
</Dropdown>
</PresetScriptTemplatesPopselect>
</div>
<Form.Item name={[parentNamePath, "preCommand"]} initialValue={initialValues.preCommand} noStyle rules={[formRule]}>
<CodeInput
@ -393,9 +408,14 @@ const BizDeployNodeConfigFieldsProviderSSH = () => {
<Form.Item label={t("workflow_node.deploy.form.ssh_post_command.label")}>
<div className="absolute -top-1.5 right-0 -translate-y-full">
<Dropdown
menu={{
items: [
<Space align="center" separator={<Divider orientation="vertical" />} size={0}>
<Popover content={<div dangerouslySetInnerHTML={{ __html: t("workflow_node.deploy.form.shared_script_command.vartips") }} />} mouseEnterDelay={1}>
<Button color="default" size="small" variant="link">
<IconBulb size="1.25em" />
</Button>
</Popover>
<PresetScriptTemplatesPopselect
options={[
"sh_reload_nginx",
"sh_replace_synologydsm_ssl",
"sh_replace_fnos_ssl",
@ -405,17 +425,23 @@ const BizDeployNodeConfigFieldsProviderSSH = () => {
"ps_binding_rdp",
].map((key) => ({
key,
label: t(`workflow_node.deploy.form.ssh_preset_scripts.option.${key}.label`),
onClick: () => handlePresetPostScriptClick(key),
})),
}}
trigger={["click"]}
>
<Button size="small" type="link">
{t("workflow_node.deploy.form.ssh_preset_scripts.button")}
<IconChevronDown size="1.25em" />
</Button>
</Dropdown>
label: t(`workflow_node.deploy.form.ssh_preset_scripts.${key}`),
}))}
trigger={["click"]}
onSelect={(key, template) => {
if (template) {
formInst.setFieldValue([parentNamePath, "postCommand"], template.command);
} else {
handlePresetPostScriptClick(key);
}
}}
>
<Button size="small" type="link">
{t("preset.dropdown.script.button")}
<IconChevronDown size="1.25em" />
</Button>
</PresetScriptTemplatesPopselect>
</Space>
</div>
<Form.Item name={[parentNamePath, "postCommand"]} initialValue={initialValues.postCommand} noStyle rules={[formRule]}>
<CodeInput
@ -427,16 +453,6 @@ const BizDeployNodeConfigFieldsProviderSSH = () => {
/>
</Form.Item>
</Form.Item>
<Form.Item
name={[parentNamePath, "useSCP"]}
initialValue={initialValues.useSCP}
label={t("workflow_node.deploy.form.ssh_use_scp.label")}
rules={[formRule]}
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.deploy.form.ssh_use_scp.tooltip") }}></span>}
>
<Switch />
</Form.Item>
</>
);
};
@ -454,6 +470,7 @@ const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType<typeof getI18n> })
return z
.object({
useSCP: z.boolean().nullish(),
format: z.literal([FORMAT_PEM, FORMAT_PFX, FORMAT_JKS], t("workflow_node.deploy.form.ssh_format.placeholder")),
keyPath: z
.string()
@ -483,7 +500,6 @@ const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType<typeof getI18n> })
.string()
.max(20480, t("common.errmsg.string_max", { max: 20480 }))
.nullish(),
useSCP: z.boolean().nullish(),
})
.superRefine((values, ctx) => {
switch (values.format) {

View File

@ -1,5 +1,6 @@
import { getI18n, useTranslation } from "react-i18next";
import { Form, Input } from "antd";
import { IconBulb } from "@tabler/icons-react";
import { Button, Form, Input, Popover } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod";
@ -30,21 +31,24 @@ const BizDeployNodeConfigFieldsProviderWebhook = () => {
return (
<>
<Form.Item
name={[parentNamePath, "webhookData"]}
initialValue={initialValues.webhookData}
label={t("workflow_node.deploy.form.webhook_data.label")}
extra={t("workflow_node.deploy.form.webhook_data.help")}
rules={[formRule]}
>
<CodeInput
height="auto"
minHeight="64px"
maxHeight="256px"
language="json"
placeholder={t("workflow_node.deploy.form.webhook_data.placeholder")}
onBlur={handleWebhookDataBlur}
/>
<Form.Item label={t("workflow_node.deploy.form.webhook_data.label")} extra={t("workflow_node.deploy.form.webhook_data.help")}>
<div className="absolute -top-1.5 right-0 -translate-y-full">
<Popover content={<div dangerouslySetInnerHTML={{ __html: t("workflow_node.deploy.form.webhook_data.vartips") }} />} mouseEnterDelay={1}>
<Button color="default" size="small" variant="link">
<IconBulb size="1.25em" />
</Button>
</Popover>
</div>
<Form.Item name={[parentNamePath, "webhookData"]} initialValue={initialValues.webhookData} noStyle rules={[formRule]}>
<CodeInput
height="auto"
minHeight="64px"
maxHeight="256px"
language="json"
placeholder={t("workflow_node.deploy.form.webhook_data.placeholder")}
onBlur={handleWebhookDataBlur}
/>
</Form.Item>
</Form.Item>
<Form.Item name={[parentNamePath, "timeout"]} label={t("workflow_node.deploy.form.webhook_timeout.label")} rules={[formRule]}>

View File

@ -1,5 +1,6 @@
import { getI18n, useTranslation } from "react-i18next";
import { Form, Input } from "antd";
import { IconBulb } from "@tabler/icons-react";
import { Button, Form, Input, Popover } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod";
@ -30,21 +31,24 @@ const BizNotifyNodeConfigFieldsProviderWebhook = () => {
return (
<>
<Form.Item
name={[parentNamePath, "webhookData"]}
initialValue={initialValues.webhookData}
label={t("workflow_node.notify.form.webhook_data.label")}
extra={t("workflow_node.notify.form.webhook_data.help")}
rules={[formRule]}
>
<CodeInput
height="auto"
minHeight="64px"
maxHeight="256px"
language="json"
placeholder={t("workflow_node.notify.form.webhook_data.placeholder")}
onBlur={handleWebhookDataBlur}
/>
<Form.Item label={t("workflow_node.notify.form.webhook_data.label")} extra={t("workflow_node.notify.form.webhook_data.help")}>
<div className="absolute -top-1.5 right-0 -translate-y-full">
<Popover content={<div dangerouslySetInnerHTML={{ __html: t("workflow_node.notify.form.webhook_data.vartips") }} />} mouseEnterDelay={1}>
<Button color="default" size="small" variant="link">
<IconBulb size="1.25em" />
</Button>
</Popover>
</div>
<Form.Item name={[parentNamePath, "webhookData"]} initialValue={initialValues.webhookData} noStyle rules={[formRule]}>
<CodeInput
height="auto"
minHeight="64px"
maxHeight="256px"
language="json"
placeholder={t("workflow_node.notify.form.webhook_data.placeholder")}
onBlur={handleWebhookDataBlur}
/>
</Form.Item>
</Form.Item>
<Form.Item name={[parentNamePath, "timeout"]} label={t("workflow_node.notify.form.webhook_timeout.label")} rules={[formRule]}>

View File

@ -1,13 +1,14 @@
import { useEffect, useMemo } from "react";
import { getI18n, useTranslation } from "react-i18next";
import { type FlowNodeEntity } from "@flowgram.ai/fixed-layout-editor";
import { IconPlus } from "@tabler/icons-react";
import { IconChevronDown, IconPlus } from "@tabler/icons-react";
import { type AnchorProps, Button, Divider, Form, type FormInstance, Input, Switch, Typography } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod";
import AccessEditDrawer from "@/components/access/AccessEditDrawer";
import AccessSelect from "@/components/access/AccessSelect";
import PresetNotifyTemplatesPopselect from "@/components/preset/PresetNotifyTemplatesPopselect";
import NotificationProviderPicker from "@/components/provider/NotificationProviderPicker";
import NotificationProviderSelect from "@/components/provider/NotificationProviderSelect";
import Show from "@/components/Show";
@ -111,8 +112,26 @@ const BizNotifyNodeConfigForm = ({ node, ...props }: BizNotifyNodeConfigFormProp
<Input placeholder={t("workflow_node.notify.form.subject.placeholder")} />
</Form.Item>
<Form.Item name="message" label={t("workflow_node.notify.form.message.label")} rules={[formRule]}>
<Input.TextArea autoSize={{ minRows: 3, maxRows: 10 }} placeholder={t("workflow_node.notify.form.message.placeholder")} />
<Form.Item label={t("workflow_node.notify.form.message.label")}>
<div className="absolute -top-1.5 right-0 -translate-y-full">
<PresetNotifyTemplatesPopselect
trigger={["click"]}
onSelect={(_, template) => {
if (template) {
formInst.setFieldValue("subject", template.subject);
formInst.setFieldValue("message", template.message);
}
}}
>
<Button size="small" type="link">
{t("preset.dropdown.notification.button")}
<IconChevronDown size="1.25em" />
</Button>
</PresetNotifyTemplatesPopselect>
</div>
<Form.Item name="message" noStyle rules={[formRule]}>
<Input.TextArea autoSize={{ minRows: 3, maxRows: 10 }} placeholder={t("workflow_node.notify.form.message.placeholder")} />
</Form.Item>
</Form.Item>
<Form.Item>

View File

@ -2,6 +2,8 @@ import { type CAProviderType } from "./provider";
export const SETTINGS_NAMES = Object.freeze({
EMAILS: "emails",
NOTIFY_TEMPLATE: "notifyTemplate",
SCRIPT_TEMPLATE: "scriptTemplate",
SSL_PROVIDER: "sslProvider",
PERSISTENCE: "persistence",
} as const);
@ -19,6 +21,25 @@ export type EmailsSettingsContent = {
};
// #endregion
// #region Settings: NotifyTemplate
export type NotifyTemplateContent = {
templates: Array<{
name: string;
subject: string;
message: string;
}>;
};
// #endregion
// #region Settings: ScriptTemplate
export type ScriptTemplateContent = {
templates: Array<{
name: string;
command: string;
}>;
};
// #endregion
// #region Settings: SSLProvider
export type SSLProviderSettingsContent = {
provider: CAProviderType;

View File

@ -3,6 +3,7 @@ import nlsCertificate from "./nls.certificate.json";
import nlsCommon from "./nls.common.json";
import nlsDashboard from "./nls.dashboard.json";
import nlsLogin from "./nls.login.json";
import nlsPreset from "./nls.preset.json";
import nlsProvider from "./nls.provider.json";
import nlsSettings from "./nls.settings.json";
import nlsWorkflow from "./nls.workflow.json";
@ -17,6 +18,7 @@ export default Object.freeze({
...nlsSettings,
...nlsProvider,
...nlsAccess,
...nlsPreset,
...nlsCertificate,
...nlsWorkflow,
...nlsWorkflowNodes,

View File

@ -2,7 +2,6 @@
"access.page.title": "Credentials",
"access.page.subtitle": "Credentials store authentication information (username and password, API key, tokens, etc.) to connect with specific third-party apps and services.",
"access.nodata": "No credentials",
"access.nodata.title": "No credentials",
"access.nodata.description": "It looks like you don't have any credentials. Get started by adding one.",
"access.nodata.button": "Create credential",
@ -11,8 +10,8 @@
"access.action.create.button": "Create credential",
"access.action.create.modal.title": "Create credential",
"access.action.edit.menu": "Edit",
"access.action.edit.modal.title": "Edit credential",
"access.action.modify.menu": "Edit",
"access.action.modify.modal.title": "Edit credential",
"access.action.duplicate.menu": "Duplicate",
"access.action.duplicate.modal.title": "Duplicate credential",
"access.action.delete.menu": "Delete",
@ -627,17 +626,17 @@
"access.form.webhook_data.label": "Webhook data (Optional)",
"access.form.webhook_data.placeholder": "Please enter the default Webhook data",
"access.form.webhook_data.help": "Notes: It can be overrided in the workflows.",
"access.form.webhook_data.guide_for_deployment": "The Webhook data should be in JSON format. <br><br>The values in JSON support template variables, which will be replaced by actual values when sent to the Webhook URL. Supported variables: <br><ol style=\"list-style: disc;\"><li><strong>${DOMAIN}</strong>: The primary domain of the certificate (a.k.a. <i>CommonName</i>).</li><li><strong>${DOMAINS}</strong>: The domains list of the certificate (a.k.a. <i>SubjectAltNames</i>).</li><li><strong>${CERTIFICATE}</strong>: The PEM format content of the certificate file.</li><li><strong>${SERVER_CERTIFICATE}</strong>: The PEM format content of the server certificate file.</li><li><strong>${INTERMEDIA_CERTIFICATE}</strong>: The PEM format content of the intermediate CA certificate file.</li><li><strong>${PRIVATE_KEY}</strong>: The PEM format content of the private key file.</li></ol><br>When the request method is GET, the data will be passed as query string. Otherwise, the data will be encoded in the format indicated by the Content-Type in the request headers. Supported formats: <br><ol style=\"list-style: disc;\"><li>application/json (default).</li><li>application/x-www-form-urlencoded: Nested data is not supported.</li><li>multipart/form-data: Nested data is not supported.</li>",
"access.form.webhook_data.guide_for_notification": "The Webhook data should be in JSON format. <br><br>The values in JSON support template variables, which will be replaced by actual values when sent to the Webhook URL. Supported variables: <br><ol style=\"list-style: disc;\"><li><strong>${SUBJECT}</strong>: The subject of notification.</li><li><strong>${MESSAGE}</strong>: The message of notification.</li></ol><br>When the request method is GET, the data will be passed as query string. Otherwise, the data will be encoded in the format indicated by the Content-Type in the request headers. Supported formats: <br><ol style=\"list-style: disc;\"><li>application/json (default).</li><li>application/x-www-form-urlencoded: Nested data is not supported.</li><li>multipart/form-data: Nested data is not supported.</li>",
"access.form.webhook_preset_data.button": "Use preset template",
"access.form.webhook_preset_data.option.bark.label": "Bark",
"access.form.webhook_preset_data.option.gotify.label": "Gotify",
"access.form.webhook_preset_data.option.ntfy.label": "ntfy",
"access.form.webhook_preset_data.option.pushover.label": "Pushover",
"access.form.webhook_preset_data.option.pushplus.label": "PushPlus",
"access.form.webhook_preset_data.option.serverchan3.label": "ServerChan<sup>3</sup>",
"access.form.webhook_preset_data.option.serverchanturbo.label": "ServerChan<sup>Turbo</sup>",
"access.form.webhook_preset_data.option.common.label": "General template",
"access.form.webhook_data.guide_for_deployment": "The Webhook data should be in JSON format. <br><br>The values in JSON support template variables, which will be replaced by actual values when sent to the Webhook URL. Supported variables: <br><ol style=\"list-style: disc;\"><li><strong>${CERTIMATE_DEPLOYER_COMMONNAME}</strong>: The primary domain of the certificate (<i>CommonName</i>).</li><li><strong>${CERTIMATE_DEPLOYER_SUBJECTALTNAMES}</strong>: The domains of the certificate, separated by semicolons (<i>SubjectAltNames</i>).</li><li><strong>${CERTIMATE_DEPLOYER_CERTIFICATE}</strong>: The PEM format content of the certificate file.</li><li><strong>${CERTIMATE_DEPLOYER_CERTIFICATE_SERVER}</strong>: The PEM format content of the server certificate file.</li><li><strong>${CERTIMATE_DEPLOYER_CERTIFICATE_INTERMEDIA}</strong>: The PEM format content of the intermediate CA certificate file.</li><li><strong>${CERTIMATE_DEPLOYER_PRIVATEKEY}</strong>: The PEM format content of the private key file.</li></ol><br>When the request method is GET, the data will be passed as query string. Otherwise, the data will be encoded in the format indicated by the Content-Type in the request headers. Supported formats: <br><ol style=\"list-style: disc;\"><li>application/json (default).</li><li>application/x-www-form-urlencoded: Nested data is not supported.</li><li>multipart/form-data: Nested data is not supported.</li>",
"access.form.webhook_data.guide_for_notification": "The Webhook data should be in JSON format. <br><br>The values in JSON support template variables, which will be replaced by actual values when sent to the Webhook URL. Supported variables: <br><ol style=\"list-style: disc;\"><li><strong>${CERTIMATE_NOTIFIER_SUBJECT}</strong>: The subject of notification.</li><li><strong>${CERTIMATE_NOTIFIER_MESSAGE}</strong>: The message of notification.</li></ol><br>When the request method is GET, the data will be passed as query string. Otherwise, the data will be encoded in the format indicated by the Content-Type in the request headers. Supported formats: <br><ol style=\"list-style: disc;\"><li>application/json (default).</li><li>application/x-www-form-urlencoded: Nested data is not supported.</li><li>multipart/form-data: Nested data is not supported.</li>",
"access.form.webhook_preset_data": "Use preset Webhook",
"access.form.webhook_preset_data.bark": "Bark",
"access.form.webhook_preset_data.gotify": "Gotify",
"access.form.webhook_preset_data.ntfy": "ntfy",
"access.form.webhook_preset_data.pushover": "Pushover",
"access.form.webhook_preset_data.pushplus": "PushPlus",
"access.form.webhook_preset_data.serverchan3": "ServerChan<sup>3</sup>",
"access.form.webhook_preset_data.serverchanturbo": "ServerChan<sup>Turbo</sup>",
"access.form.webhook_preset_data.common": "General data",
"access.form.wecombot_webhook_url.label": "WeCom bot Webhook URL",
"access.form.wecombot_webhook_url.placeholder": "Please enter WeCom bot Webhook URL",
"access.form.wecombot_webhook_url.tooltip": "For more information, see <a href=\"https://www.west.cn/CustomerCenter/doc/apiv2.html#12u3001u8eabu4efdu9a8cu8bc10a3ca20id3d12u3001u8eabu4efdu9a8cu8bc13e203ca3e\" target=\"_blank\">https://www.west.cn/CustomerCenter/doc/apiv2.html</a>",

View File

@ -0,0 +1,34 @@
{
"preset.page.title": "Presets",
"preset.page.subtitle": "Presets are a set of reusable data snippets, for filling forms conveniently.",
"preset.action.create.button": "Create preset",
"preset.action.create.modal.title": "Create preset",
"preset.action.modify.menu": "Edit",
"preset.action.modify.modal.title": "Edit preset",
"preset.action.delete.menu": "Delete",
"preset.action.delete.modal.title": "Delete \"{{name}}\"",
"preset.action.delete.modal.content": "Are you sure want to delete this preset? <br>This action cannot be undone.",
"preset.props.name": "Name",
"preset.props.usage.notification": "Notification template",
"preset.props.usage.notification.tips": "You can use these preset notification subjects and messages in workflow notification nodes.",
"preset.props.usage.script": "Script template",
"preset.props.usage.script.tips": "You can use these preset script commands in workflow deployment nodes (for <i>Local host</i>, <i>Remote host</i>, etc.).",
"preset.warning.excceeded": "The maximum number of presets has been reached",
"preset.form.name.label": "Preset name",
"preset.form.name.placeholder": "Please enter preset name",
"preset.form.name.errmsg.duplicated": "The name already exists, please use a different one.",
"preset.form.notification_subject.label": "Notification subject",
"preset.form.notification_subject.placeholder": "Please enter notification subject",
"preset.form.notification_message.label": "Notification message",
"preset.form.notification_message.placeholder": "Please enter notification message",
"preset.form.script_command.label": "Script command",
"preset.form.script_command.placeholder": "Please enter script command",
"preset.dropdown.notification.button": "Use preset notification",
"preset.dropdown.script.button": "Use preset script",
"preset.dropdown.option_group.builtin": "Built-in templates",
"preset.dropdown.option_group.custom": "Customized templates"
}

View File

@ -9,7 +9,7 @@
"workflow.search.placeholder": "Search by workflow name ...",
"workflow.action.create.button": "Create workflow",
"workflow.action.edit.menu": "Edit",
"workflow.action.modify.menu": "Edit",
"workflow.action.duplicate.menu": "Duplicate",
"workflow.action.duplicate.modal.title": "Duplicate \"{{name}}\"",
"workflow.action.duplicate.modal.content": "Are you sure to duplicate this workflow?",

View File

@ -198,6 +198,7 @@
"workflow_node.deploy.form.shared_domain_match_pattern.option.wildcard.label": "Wildcard matches",
"workflow_node.deploy.form.shared_domain_match_pattern.option.certsan.label": "via Certificate",
"workflow_node.deploy.form.shared_domain_match_pattern.help_wildcard": "Notes: For the sites which support wildcard resolution, an <strong>exact match</strong> of a wildcard domain only includes the site itself, does not include its subdomains.",
"workflow_node.deploy.form.shared_script_command.vartips": "Supported variables: <br><ol style=\"list-style: disc;\"><li><strong>${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_PATH}</strong>: <br>The path of the certificate file, same as the value of the form related field.</li><li><strong>${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_SERVER_PATH}</strong>: <br>The path of the server certificate file, same as the value of the form related field.</li><li><strong>${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_INTERMEDIA_PATH}</strong>: <br>The path of the intermediate CA certificate file, same as the value of the form related field.</li><li><strong>${CERTIMATE_DEPLOYER_CMDVAR_PRIVATEKEY_PATH}</strong>: <br>The path of the private key file, same as the value of the form related field.</li><li><strong>${CERTIMATE_DEPLOYER_CMDVAR_PFX_PASSWORD}</strong>: <br>The PFX password, same as the value of the form related field.</li><li><strong>${CERTIMATE_DEPLOYER_CMDVAR_JKS_ALIAS}</strong>: <br>The JKS alias, same as the value of the form related field.</li><li><strong>${CERTIMATE_DEPLOYER_CMDVAR_JKS_KEYPASS}</strong>: <br>The JKS key password, same as the value of the form related field.</li><li><strong>${CERTIMATE_DEPLOYER_CMDVAR_JKS_STOREPASS}</strong>: <br>The JKS store password, same as the value of the form related field.</li></ol>",
"workflow_node.deploy.form.1panel_console_auto_restart.label": "Auto restart 1Panel after deployment",
"workflow_node.deploy.form.1panel_site_node_name.label": "1Panel node name (Optional)",
"workflow_node.deploy.form.1panel_site_node_name.placeholder": "Please enter 1Panel node name",
@ -675,13 +676,12 @@
"workflow_node.deploy.form.local_pre_command.placeholder": "Please enter command to be executed before saving files",
"workflow_node.deploy.form.local_post_command.label": "Post-command (Optional)",
"workflow_node.deploy.form.local_post_command.placeholder": "Please enter command to be executed after saving files",
"workflow_node.deploy.form.local_preset_scripts.button": "Use preset scripts",
"workflow_node.deploy.form.local_preset_scripts.option.sh_backup_files.label": "POSIX Bash - Backup certificate files",
"workflow_node.deploy.form.local_preset_scripts.option.ps_backup_files.label": "PowerShell - Backup certificate files",
"workflow_node.deploy.form.local_preset_scripts.option.sh_reload_nginx.label": "POSIX Bash - Reload nginx",
"workflow_node.deploy.form.local_preset_scripts.option.ps_binding_iis.label": "PowerShell - Binding IIS",
"workflow_node.deploy.form.local_preset_scripts.option.ps_binding_netsh.label": "PowerShell - Binding netsh",
"workflow_node.deploy.form.local_preset_scripts.option.ps_.label": "PowerShell - Binding RDP",
"workflow_node.deploy.form.local_preset_scripts.sh_backup_files": "POSIX Bash - Backup certificate files",
"workflow_node.deploy.form.local_preset_scripts.ps_backup_files": "PowerShell - Backup certificate files",
"workflow_node.deploy.form.local_preset_scripts.sh_reload_nginx": "POSIX Bash - Reload nginx",
"workflow_node.deploy.form.local_preset_scripts.ps_binding_iis": "PowerShell - Binding IIS",
"workflow_node.deploy.form.local_preset_scripts.ps_binding_netsh": "PowerShell - Binding netsh",
"workflow_node.deploy.form.local_preset_scripts.ps_binding_rdp": "PowerShell - Binding RDP",
"workflow_node.deploy.form.netlify_site_id.label": "Netlify site ID",
"workflow_node.deploy.form.netlify_site_id.placeholder": "Please enter Netlify site ID",
"workflow_node.deploy.form.netlify_site_id.tooltip": "For more information, see <a href=\"https://docs.netlify.com/api/get-started/#get-site\" target=\"_blank\">https://docs.netlify.com/api/get-started/#get-site</a>",
@ -755,16 +755,15 @@
"workflow_node.deploy.form.ssh_pre_command.placeholder": "Please enter command to be executed before uploading files",
"workflow_node.deploy.form.ssh_post_command.label": "Post-command (Optional)",
"workflow_node.deploy.form.ssh_post_command.placeholder": "Please enter command to be executed after uploading files",
"workflow_node.deploy.form.ssh_preset_scripts.button": "Use preset scripts",
"workflow_node.deploy.form.ssh_preset_scripts.option.sh_backup_files.label": "POSIX Bash - Backup certificate files",
"workflow_node.deploy.form.ssh_preset_scripts.option.ps_backup_files.label": "PowerShell - Backup certificate files",
"workflow_node.deploy.form.ssh_preset_scripts.option.sh_reload_nginx.label": "POSIX Bash - Reload nginx",
"workflow_node.deploy.form.ssh_preset_scripts.option.sh_replace_synologydsm_ssl.label": "POSIX Bash - Replace SynologyDSM SSL certificate",
"workflow_node.deploy.form.ssh_preset_scripts.option.sh_replace_fnos_ssl.label": "POSIX Bash - Replace fnOS SSL certificate",
"workflow_node.deploy.form.ssh_preset_scripts.option.sh_replace_qnap_ssl.label": "POSIX Bash - Replace QNAP SSL certificate",
"workflow_node.deploy.form.ssh_preset_scripts.option.ps_binding_iis.label": "PowerShell - Binding IIS",
"workflow_node.deploy.form.ssh_preset_scripts.option.ps_binding_netsh.label": "PowerShell - Binding netsh",
"workflow_node.deploy.form.ssh_preset_scripts.option.ps_binding_rdp.label": "PowerShell - Binding RDP",
"workflow_node.deploy.form.ssh_preset_scripts.sh_backup_files": "POSIX Bash - Backup certificate files",
"workflow_node.deploy.form.ssh_preset_scripts.ps_backup_files": "PowerShell - Backup certificate files",
"workflow_node.deploy.form.ssh_preset_scripts.sh_reload_nginx": "POSIX Bash - Reload nginx",
"workflow_node.deploy.form.ssh_preset_scripts.sh_replace_synologydsm_ssl": "POSIX Bash - Replace SynologyDSM SSL certificate",
"workflow_node.deploy.form.ssh_preset_scripts.sh_replace_fnos_ssl": "POSIX Bash - Replace fnOS SSL certificate",
"workflow_node.deploy.form.ssh_preset_scripts.sh_replace_qnap_ssl": "POSIX Bash - Replace QNAP SSL certificate",
"workflow_node.deploy.form.ssh_preset_scripts.ps_binding_iis": "PowerShell - Binding IIS",
"workflow_node.deploy.form.ssh_preset_scripts.ps_binding_netsh": "PowerShell - Binding netsh",
"workflow_node.deploy.form.ssh_preset_scripts.ps_binding_rdp": "PowerShell - Binding RDP",
"workflow_node.deploy.form.ssh_use_scp.label": "Fallback to use SCP",
"workflow_node.deploy.form.ssh_use_scp.tooltip": "If the remote server does not support SFTP, please check this option to fallback to SCP.",
"workflow_node.deploy.form.tencentcloud_cdn_endpoint.label": "Tencent Cloud API endpoint (Optional)",
@ -1005,6 +1004,7 @@
"workflow_node.deploy.form.webhook_data.placeholder": "Please enter Webhook data",
"workflow_node.deploy.form.webhook_data.help": "Notes: Leave it blank to use the default Webhook data provided by the credential.",
"workflow_node.deploy.form.webhook_data.errmsg.json_invalid": "Please enter a valid JSON string",
"workflow_node.deploy.form.webhook_data.vartips": "Supported variables: <br><ol style=\"list-style: disc;\"><li><strong>${CERTIMATE_DEPLOYER_COMMONNAME}</strong>: <br>The primary domain of the certificate (<i>CommonName</i>).</li><li><strong>${CERTIMATE_DEPLOYER_SUBJECTALTNAMES}</strong>: <br>The domains of the certificate, separated by semicolons (<i>SubjectAltNames</i>).</li><li><strong>${CERTIMATE_DEPLOYER_CERTIFICATE}</strong>: <br>The PEM format content of the certificate file.</li><li><strong>${CERTIMATE_DEPLOYER_CERTIFICATE_SERVER}</strong>: <br>The PEM format content of the server certificate file.</li><li><strong>${CERTIMATE_DEPLOYER_CERTIFICATE_INTERMEDIA}</strong>: <br>The PEM format content of the intermediate CA certificate file.</li><li><strong>${CERTIMATE_DEPLOYER_PRIVATEKEY}</strong>: <br>The PEM format content of the private key file.</li></ol>",
"workflow_node.deploy.form.webhook_timeout.label": "Webhook timeout (Optional)",
"workflow_node.deploy.form.webhook_timeout.placeholder": "Please enter Webhook timeout",
"workflow_node.deploy.form.webhook_timeout.unit": "seconds",
@ -1025,7 +1025,7 @@
"workflow_node.notify.form.subject.placeholder": "Please enter subject",
"workflow_node.notify.form.message.label": "Message",
"workflow_node.notify.form.message.placeholder": "Please enter message",
"workflow_node.notify.form.template.guide": "<details><summary>The content using the \"Mustache\" syntax (double curly braces) and preceded by \"$\" in the subject or message are text interpolations. They will be replaced by the actual values. (Expand to see more) </summary><br>Supported text interpolations: <ol style=\"list-style: disc;\"><li><em>workflow.id</em>: The ID of the workflow.</li><li><em>workflow.name</em>: The name of the workflow.</li><li><em>run.id</em>: The ID of the workflow run.</li><li><em>error.nodeId</em>: The node ID that execution failed. If there are multiple nodes that have failed before this, it always indicate the nearest one.</li><li><em>error.nodeName</em>: The node name that execution failed. If there are multiple nodes that have failed before this, it always indicate the nearest one.</li><li><em>error.message</em>: The error message that execution failed. If there are multiple nodes that have failed before this, it always indicate the nearest one.</li><li><em>certificate.domain</em>: The primary domain of the certificate (a.k.a. <i>CommonName</i>). If there are multiple nodes outputting a certificate before this, it always indicate the nearest one.</li><li><em>certificate.domains</em>: The domains list of the certificate (a.k.a. <i>SubjectAltNames</i>). If there are multiple nodes outputting a certificate before this, it always indicate the nearest one.</li><li><em>certificate.notBefore</em>: The effect time of the certificate, formatted in RFC3339. If there are multiple nodes outputting a certificate before this, it always indicate the nearest one.</li><li><em>certificate.notAfter</em>: The effect time of the certificate, formatted in RFC3339. If there are multiple nodes outputting a certificate before this, it always indicate the nearest one.</li><li><em>certificate.hoursLeft</em>: The left hours of the certificate. If there are multiple nodes outputting a certificate before this, it always indicate the nearest one.</li><li><em>certificate.daysLeft</em>: The left days of the certificate. If there are multiple nodes outputting a certificate before this, it always indicate the nearest one.</li><li><em>certificate.validity</em>: The validity of the certificate. If there are multiple nodes outputting a certificate before this, it always indicate the nearest one.</li><li><em>now</em>: The current time on the server, formatted in RFC3339. </li></ol><br>Example: <br><em>Your workflow {{ $workflow.name }} has failed on node {{ $error.nodeName }} at {{ $now }}.</em><br><br>Please visit the documentation for more details.</details>",
"workflow_node.notify.form.template.guide": "<details><summary>The content using the \"Mustache\" syntax (double curly braces) and preceded by \"$\" in the subject or message are text interpolations. They will be replaced by the actual values. </summary><br>Supported text interpolations: <ol style=\"list-style: disc;\"><li><em>workflow.id</em>: The ID of the workflow.</li><li><em>workflow.name</em>: The name of the workflow.</li><li><em>run.id</em>: The ID of the workflow run.</li><li><em>error.nodeId</em>: The node ID that execution failed. If there are multiple nodes that have failed before this, it always indicate the nearest one.</li><li><em>error.nodeName</em>: The node name that execution failed. If there are multiple nodes that have failed before this, it always indicate the nearest one.</li><li><em>error.message</em>: The error message that execution failed. If there are multiple nodes that have failed before this, it always indicate the nearest one.</li><li><em>certificate.domain</em>: The primary domain of the certificate (a.k.a. <i>CommonName</i>). If there are multiple nodes outputting a certificate before this, it always indicate the nearest one.</li><li><em>certificate.domains</em>: The domains list of the certificate (a.k.a. <i>SubjectAltNames</i>). If there are multiple nodes outputting a certificate before this, it always indicate the nearest one.</li><li><em>certificate.notBefore</em>: The effect time of the certificate, formatted in RFC3339. If there are multiple nodes outputting a certificate before this, it always indicate the nearest one.</li><li><em>certificate.notAfter</em>: The effect time of the certificate, formatted in RFC3339. If there are multiple nodes outputting a certificate before this, it always indicate the nearest one.</li><li><em>certificate.hoursLeft</em>: The left hours of the certificate. If there are multiple nodes outputting a certificate before this, it always indicate the nearest one.</li><li><em>certificate.daysLeft</em>: The left days of the certificate. If there are multiple nodes outputting a certificate before this, it always indicate the nearest one.</li><li><em>certificate.validity</em>: The validity of the certificate. If there are multiple nodes outputting a certificate before this, it always indicate the nearest one.</li><li><em>now</em>: The current time on the server, formatted in RFC3339. </li></ol><br>Example: <br><em>Your workflow {{ $workflow.name }} has failed on node {{ $error.nodeName }} at {{ $now }}.</em><br><br>Please visit the documentation for more details.</details>",
"workflow_node.notify.form.provider.label": "Notification channel",
"workflow_node.notify.form.provider.placeholder": "Please select notification channel",
"workflow_node.notify.form.provider.search.placeholder": "Search notification channel ...",
@ -1052,6 +1052,7 @@
"workflow_node.notify.form.webhook_data.placeholder": "Please enter Webhook data",
"workflow_node.notify.form.webhook_data.help": "Notes: Leave it blank to use the default Webhook data provided by the credential.",
"workflow_node.notify.form.webhook_data.errmsg.json_invalid": "Please enter a valid JSON string",
"workflow_node.notify.form.webhook_data.vartips": "Supported variables: <br><ol style=\"list-style: disc;\"><li><strong>${CERTIMATE_NOTIFIER_SUBJECT}</strong>: <br>The subject of notification.</li><li><strong>${CERTIMATE_NOTIFIER_MESSAGE}</strong>: <br>The message of notification.</li></ol>",
"workflow_node.notify.form.webhook_timeout.label": "Webhook timeout (Optional)",
"workflow_node.notify.form.webhook_timeout.placeholder": "Please enter Webhook timeout",
"workflow_node.notify.form.webhook_timeout.unit": "seconds",

View File

@ -3,6 +3,7 @@ import nlsCertificate from "./nls.certificate.json";
import nlsCommon from "./nls.common.json";
import nlsDashboard from "./nls.dashboard.json";
import nlsLogin from "./nls.login.json";
import nlsPreset from "./nls.preset.json";
import nlsProvider from "./nls.provider.json";
import nlsSettings from "./nls.settings.json";
import nlsWorkflow from "./nls.workflow.json";
@ -17,6 +18,7 @@ export default Object.freeze({
...nlsSettings,
...nlsProvider,
...nlsAccess,
...nlsPreset,
...nlsCertificate,
...nlsWorkflow,
...nlsWorkflowNodes,

View File

@ -10,8 +10,8 @@
"access.action.create.button": "新建授权",
"access.action.create.modal.title": "新建授权",
"access.action.edit.menu": "编辑授权",
"access.action.edit.modal.title": "编辑授权",
"access.action.modify.menu": "编辑授权",
"access.action.modify.modal.title": "编辑授权",
"access.action.duplicate.menu": "复制授权",
"access.action.duplicate.modal.title": "复制授权",
"access.action.delete.menu": "删除授权",
@ -626,17 +626,17 @@
"access.form.webhook_data.label": "Webhook 回调数据(可选)",
"access.form.webhook_data.placeholder": "请输入默认的 Webhook 回调数据",
"access.form.webhook_data.help": "提示:可在工作流中覆盖此设置。",
"access.form.webhook_data.guide_for_deployment": "回调数据是一个 JSON 格式的数据。<br><br>其中值支持模板变量,将在被发送到指定的 Webhook URL 时被替换为实际值;其他内容将保持原样。支持的变量:<br><ol style=\"list-style: disc;\"><li><strong>${DOMAIN}</strong>:证书的主域名(即 <i>CommonName</i>)。</li><li><strong>${DOMAINS}</strong>:证书的多域名列表(即 <i>SubjectAltNames</i>)。</li><li><strong>${CERTIFICATE}</strong>:证书文件 PEM 格式内容。</li><li><strong>${SERVER_CERTIFICATE}</strong>证书文件仅含服务器证书PEM 格式内容。</li><li><strong>${INTERMEDIA_CERTIFICATE}</strong>证书文件仅含中间证书PEM 格式内容。</li><li><strong>${PRIVATE_KEY}</strong>:私钥文件 PEM 格式内容。</li></ol><br>当请求谓词为 GET 时,回调数据将作为查询参数;否则,回调数据将按照请求标头中 Content-Type 所指示的格式进行编码。支持的格式:<br><ol style=\"list-style: disc;\"><li>application/json默认。</li><li>application/x-www-form-urlencoded不支持嵌套数据。</li><li>multipart/form-data不支持嵌套数据。</li>",
"access.form.webhook_data.guide_for_notification": "回调数据是一个 JSON 格式的数据。<br><br>其中值支持模板变量,将在被发送到指定的 Webhook URL 时被替换为实际值;其他内容将保持原样。支持的变量:<br><ol style=\"list-style: disc;\"><li><strong>${SUBJECT}</strong>:通知主题。</li><li><strong>${MESSAGE}</strong>:通知内容。</ol><br>当请求谓词为 GET 时,回调数据将作为查询参数;否则,回调数据将按照请求标头中 Content-Type 所指示的格式进行编码。支持的格式:<br><ol style=\"list-style: disc;\"><li>application/json默认。</li><li>application/x-www-form-urlencoded不支持嵌套数据。</li><li>multipart/form-data不支持嵌套数据。</li>",
"access.form.webhook_preset_data.button": "使用预设模板",
"access.form.webhook_preset_data.option.bark.label": "Bark",
"access.form.webhook_preset_data.option.gotify.label": "Gotify",
"access.form.webhook_preset_data.option.ntfy.label": "ntfy",
"access.form.webhook_preset_data.option.pushover.label": "Pushover",
"access.form.webhook_preset_data.option.pushplus.label": "PushPlus 推送加",
"access.form.webhook_preset_data.option.serverchan3.label": "Server 酱 <sup>3</sup>",
"access.form.webhook_preset_data.option.serverchanturbo.label": "Server酱 <sup>Turbo</sup>",
"access.form.webhook_preset_data.option.common.label": "通用模板",
"access.form.webhook_data.guide_for_deployment": "回调数据是一个 JSON 格式的数据。<br><br>其中值支持模板变量,将在被发送到指定的 Webhook URL 时被替换为实际值;其他内容将保持原样。支持的变量:<br><ol style=\"list-style: disc;\"><li><strong>${CERTIMATE_DEPLOYER_COMMONNAME}</strong>:证书的主域名(即 <i>CommonName</i>)。</li><li><strong>${CERTIMATE_DEPLOYER_SUBJECTALTNAMES}</strong>:证书的多域名,以半角分号隔开(即 <i>SubjectAltNames</i>)。</li><li><strong>${CERTIMATE_DEPLOYER_CERTIFICATE}</strong>:证书文件 PEM 格式内容。</li><li><strong>${CERTIMATE_DEPLOYER_CERTIFICATE_SERVER}</strong>证书文件仅含服务器证书PEM 格式内容。</li><li><strong>${CERTIMATE_DEPLOYER_CERTIFICATE_INTERMEDIA}</strong>证书文件仅含中间证书PEM 格式内容。</li><li><strong>${CERTIMATE_DEPLOYER_PRIVATEKEY}</strong>:私钥文件 PEM 格式内容。</li></ol><br>当请求谓词为 GET 时,回调数据将作为查询参数;否则,回调数据将按照请求标头中 Content-Type 所指示的格式进行编码。支持的格式:<br><ol style=\"list-style: disc;\"><li>application/json默认。</li><li>application/x-www-form-urlencoded不支持嵌套数据。</li><li>multipart/form-data不支持嵌套数据。</li>",
"access.form.webhook_data.guide_for_notification": "回调数据是一个 JSON 格式的数据。<br><br>其中值支持模板变量,将在被发送到指定的 Webhook URL 时被替换为实际值;其他内容将保持原样。支持的变量:<br><ol style=\"list-style: disc;\"><li><strong>${CERTIMATE_NOTIFIER_SUBJECT}</strong>:通知主题。</li><li><strong>${CERTIMATE_NOTIFIER_MESSAGE}</strong>:通知内容。</ol><br>当请求谓词为 GET 时,回调数据将作为查询参数;否则,回调数据将按照请求标头中 Content-Type 所指示的格式进行编码。支持的格式:<br><ol style=\"list-style: disc;\"><li>application/json默认。</li><li>application/x-www-form-urlencoded不支持嵌套数据。</li><li>multipart/form-data不支持嵌套数据。</li>",
"access.form.webhook_preset_data": "使用预设回调",
"access.form.webhook_preset_data.bark": "Bark",
"access.form.webhook_preset_data.gotify": "Gotify",
"access.form.webhook_preset_data.ntfy": "ntfy",
"access.form.webhook_preset_data.pushover": "Pushover",
"access.form.webhook_preset_data.pushplus": "PushPlus 推送加",
"access.form.webhook_preset_data.serverchan3": "Server 酱 <sup>3</sup>",
"access.form.webhook_preset_data.serverchanturbo": "Server酱 <sup>Turbo</sup>",
"access.form.webhook_preset_data.common": "通用内容",
"access.form.wecombot_webhook_url.label": "企业微信群机器人 Webhook 地址",
"access.form.wecombot_webhook_url.placeholder": "请输入企业微信群机器人 Webhook 地址",
"access.form.wecombot_webhook_url.tooltip": "这是什么?请参阅 <a href=\"https://open.work.weixin.qq.com/help2/pc/18401#%E5%85%AD%E3%80%81%E7%BE%A4%E6%9C%BA%E5%99%A8%E4%BA%BAWebhook%E5%9C%B0%E5%9D%80\" target=\"_blank\">https://open.work.weixin.qq.com/help2/pc/18401</a>",

View File

@ -0,0 +1,34 @@
{
"preset.page.title": "预设模板",
"preset.page.subtitle": "预设模板是一组可复用的数据片段,可以快速填充特定表单。",
"preset.action.create.button": "新建模板",
"preset.action.create.modal.title": "新建模板",
"preset.action.modify.menu": "编辑模板",
"preset.action.modify.modal.title": "编辑模板",
"preset.action.delete.menu": "删除模板",
"preset.action.delete.modal.title": "删除「{{name}}」",
"preset.action.delete.modal.content": "确定要删除该模板吗?<br>注意此操作不可撤销,请谨慎操作。",
"preset.props.name": "名称",
"preset.props.usage.notification": "通知模板",
"preset.props.usage.notification.tips": "你可以在工作流的通知节点中使用这些预设的通知主题和内容。",
"preset.props.usage.script": "脚本模板",
"preset.props.usage.script.tips": "你可以在工作流的部署节点如本地主机、SSH 远程主机等)中使用这些预设的脚本命令。",
"preset.warning.excceeded": "可创建的模板数量已达上限",
"preset.form.name.label": "模板名称",
"preset.form.name.placeholder": "请输入模板名称",
"preset.form.name.errmsg.duplicated": "该名称已存在,请使用其他名称。",
"preset.form.notification_subject.label": "通知主题",
"preset.form.notification_subject.placeholder": "请输入通知主题",
"preset.form.notification_message.label": "通知内容",
"preset.form.notification_message.placeholder": "请输入通知内容",
"preset.form.script_command.label": "脚本命令",
"preset.form.script_command.placeholder": "请输入脚本命令",
"preset.dropdown.notification.button": "使用预设通知",
"preset.dropdown.script.button": "使用预设脚本",
"preset.dropdown.option_group.builtin": "内置模板",
"preset.dropdown.option_group.custom": "自定义模板"
}

View File

@ -5,7 +5,7 @@
"provider.35cn": "三五互联",
"provider.acmeca": "ACME 自定义 CA 端点",
"provider.acmedns": "ACME-DNS",
"provider.acmehttpreq": "ACME 自定义 HTTP 端点",
"provider.acmehttpreq": "ACME 自定义端点",
"provider.actalisssl": "Actalis SSL",
"provider.akamai": "Akamai",
"provider.akamai.cdn": "Akamai - 内容分发网络 CDN",

View File

@ -9,7 +9,7 @@
"workflow.search.placeholder": "按工作流名称搜索……",
"workflow.action.create.button": "新建工作流",
"workflow.action.edit.menu": "编辑工作流",
"workflow.action.modify.menu": "编辑工作流",
"workflow.action.duplicate.menu": "复制工作流",
"workflow.action.duplicate.modal.title": "复制「{{name}}」",
"workflow.action.duplicate.modal.content": "确定要复制该工作流吗?",

View File

@ -197,6 +197,7 @@
"workflow_node.deploy.form.shared_domain_match_pattern.option.wildcard.label": "通配符匹配(泛域名)",
"workflow_node.deploy.form.shared_domain_match_pattern.option.certsan.label": "根据证书自动匹配",
"workflow_node.deploy.form.shared_domain_match_pattern.help_wildcard": "注意:对于支持泛解析的站点,<strong>精确匹配</strong>一个泛域名仅包含该站点本身、不包括相关子域名站点。",
"workflow_node.deploy.form.shared_script_command.vartips": "支持的变量:<br><ol style=\"list-style: disc;\"><li><strong>${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_PATH}</strong><br>证书文件路径,等同于表单中相应字段的值。</li><li><strong>${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_SERVER_PATH}</strong><br>证书文件(仅含服务器证书)路径,等同于表单中相应字段的值。</li><li><strong>${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_INTERMEDIA_PATH}</strong><br>证书文件(仅含中间证书)路径,等同于表单中相应字段的值。</li><li><strong>${CERTIMATE_DEPLOYER_CMDVAR_PRIVATEKEY_PATH}</strong><br>私钥文件路径,等同于表单中相应字段的值。</li><li><strong>${CERTIMATE_DEPLOYER_CMDVAR_PFX_PASSWORD}</strong><br>PFX 导出密码,等同于表单中相应字段的值。</li><li><strong>${CERTIMATE_DEPLOYER_CMDVAR_JKS_ALIAS}</strong><br>JKS 别名,等同于表单中相应字段的值。</li><li><strong>${CERTIMATE_DEPLOYER_CMDVAR_JKS_KEYPASS}</strong><br>JKS 私钥访问口令,等同于表单中相应字段的值。</li><li><strong>${CERTIMATE_DEPLOYER_CMDVAR_JKS_STOREPASS}</strong><br>JKS 密钥库存储口令,等同于表单中相应字段的值。</li></ol>",
"workflow_node.deploy.form.1panel_console_auto_restart.label": "部署后自动重启 1Panel 服务",
"workflow_node.deploy.form.1panel_site_node_name.label": "1Panel 子节点名称(可选)",
"workflow_node.deploy.form.1panel_site_node_name.placeholder": "请输入 1Panel 子节点名称",
@ -638,18 +639,18 @@
"workflow_node.deploy.form.local_format.option.pem.label": "PEM 格式(*.pem, *.crt, *.key",
"workflow_node.deploy.form.local_format.option.pfx.label": "PFX 格式(*.pfx, *.p12",
"workflow_node.deploy.form.local_format.option.jks.label": "JKS 格式(*.jks",
"workflow_node.deploy.form.local_key_path.label": "私钥文件路径",
"workflow_node.deploy.form.local_key_path.label": "私钥文件保存路径",
"workflow_node.deploy.form.local_key_path.placeholder": "请输入私钥文件本地路径",
"workflow_node.deploy.form.local_key_path.help": "注意:路径需包含完整的文件名,而不是只有目录。",
"workflow_node.deploy.form.local_cert_path.label": "证书文件路径",
"workflow_node.deploy.form.local_cert_path.label": "证书文件保存路径",
"workflow_node.deploy.form.local_cert_path.placeholder": "请输入证书文件本地路径",
"workflow_node.deploy.form.local_cert_path.help": "注意:路径需包含完整的文件名,而不是只有目录。",
"workflow_node.deploy.form.local_fullchaincert_path.label": "证书链文件路径",
"workflow_node.deploy.form.local_fullchaincert_path.label": "证书链文件保存路径",
"workflow_node.deploy.form.local_fullchaincert_path.placeholder": "请输入证书链文件本地路径",
"workflow_node.deploy.form.local_servercert_path.label": "服务器证书文件路径(可选)",
"workflow_node.deploy.form.local_servercert_path.label": "服务器证书文件保存路径(可选)",
"workflow_node.deploy.form.local_servercert_path.placeholder": "请输入服务器证书文件本地路径",
"workflow_node.deploy.form.local_servercert_path.help": "注意:路径需包含完整的文件名,而不是只有目录。不填写时将不会保存服务器证书。",
"workflow_node.deploy.form.local_intermediacert_path.label": "中间证书文件路径(可选)",
"workflow_node.deploy.form.local_intermediacert_path.label": "中间证书文件保存路径(可选)",
"workflow_node.deploy.form.local_intermediacert_path.placeholder": "请输入中间证书文件本地路径",
"workflow_node.deploy.form.local_intermediacert_path.help": "注意:路径需包含完整的文件名,而不是只有目录。不填写时将不会保存中间证书。",
"workflow_node.deploy.form.local_pfx_password.label": "PFX 导出密码",
@ -673,13 +674,12 @@
"workflow_node.deploy.form.local_pre_command.placeholder": "请输入保存文件前执行的命令",
"workflow_node.deploy.form.local_post_command.label": "后置命令(可选)",
"workflow_node.deploy.form.local_post_command.placeholder": "请输入保存文件后执行的命令",
"workflow_node.deploy.form.local_preset_scripts.button": "使用预设脚本",
"workflow_node.deploy.form.local_preset_scripts.option.sh_backup_files.label": "POSIX Bash - 备份原证书文件",
"workflow_node.deploy.form.local_preset_scripts.option.ps_backup_files.label": "PowerShell - 备份原证书文件",
"workflow_node.deploy.form.local_preset_scripts.option.sh_reload_nginx.label": "POSIX Bash - 重启 nginx 进程",
"workflow_node.deploy.form.local_preset_scripts.option.ps_binding_iis.label": "PowerShell - 导入并绑定到 IIS",
"workflow_node.deploy.form.local_preset_scripts.option.ps_binding_netsh.label": "PowerShell - 导入并绑定到 netsh",
"workflow_node.deploy.form.local_preset_scripts.option.ps_binding_rdp.label": "PowerShell - 导入并绑定到 RDP",
"workflow_node.deploy.form.local_preset_scripts.sh_backup_files": "POSIX Bash - 备份原证书文件",
"workflow_node.deploy.form.local_preset_scripts.ps_backup_files": "PowerShell - 备份原证书文件",
"workflow_node.deploy.form.local_preset_scripts.sh_reload_nginx": "POSIX Bash - 重启 nginx 进程",
"workflow_node.deploy.form.local_preset_scripts.ps_binding_iis": "PowerShell - 导入并绑定到 IIS",
"workflow_node.deploy.form.local_preset_scripts.ps_binding_netsh": "PowerShell - 导入并绑定到 netsh",
"workflow_node.deploy.form.local_preset_scripts.ps_binding_rdp": "PowerShell - 导入并绑定到 RDP",
"workflow_node.deploy.form.netlify_site_id.label": "Netlify 网站 ID",
"workflow_node.deploy.form.netlify_site_id.placeholder": "请输入 netlify 网站 ID",
"workflow_node.deploy.form.netlify_site_id.tooltip": "这是什么?请参阅 <a href=\"https://docs.netlify.com/api/get-started/#get-site\" target=\"_blank\">https://docs.netlify.com/api/get-started/#get-site</a>",
@ -723,18 +723,18 @@
"workflow_node.deploy.form.ssh_format.option.pem.label": "PEM 格式(*.pem, *.crt, *.key",
"workflow_node.deploy.form.ssh_format.option.pfx.label": "PFX 格式(*.pfx, *.p12",
"workflow_node.deploy.form.ssh_format.option.jks.label": "JKS 格式(*.jks",
"workflow_node.deploy.form.ssh_key_path.label": "私钥文件路径",
"workflow_node.deploy.form.ssh_key_path.label": "私钥文件保存路径",
"workflow_node.deploy.form.ssh_key_path.placeholder": "请输入私钥文件远程路径",
"workflow_node.deploy.form.ssh_key_path.help": "注意:路径需包含完整的文件名,而不是只有目录。",
"workflow_node.deploy.form.ssh_cert_path.label": "证书文件路径",
"workflow_node.deploy.form.ssh_cert_path.label": "证书文件保存路径",
"workflow_node.deploy.form.ssh_cert_path.placeholder": "请输入证书文件远程路径",
"workflow_node.deploy.form.ssh_cert_path.help": "注意:路径需包含完整的文件名,而不是只有目录。",
"workflow_node.deploy.form.ssh_fullchaincert_path.label": "证书链文件路径",
"workflow_node.deploy.form.ssh_fullchaincert_path.label": "证书链文件保存路径",
"workflow_node.deploy.form.ssh_fullchaincert_path.placeholder": "请输入证书链文件远程路径",
"workflow_node.deploy.form.ssh_servercert_path.label": "服务器证书文件路径(可选)",
"workflow_node.deploy.form.ssh_servercert_path.label": "服务器证书文件保存路径(可选)",
"workflow_node.deploy.form.ssh_servercert_path.placeholder": "请输入服务器证书文件远程路径",
"workflow_node.deploy.form.ssh_servercert_path.help": "注意:路径需包含完整的文件名,而不是只有目录。不填写时将不上传服务器证书。",
"workflow_node.deploy.form.ssh_intermediacert_path.label": "中间证书文件路径(可选)",
"workflow_node.deploy.form.ssh_intermediacert_path.label": "中间证书文件保存路径(可选)",
"workflow_node.deploy.form.ssh_intermediacert_path.placeholder": "请输入中间证书文件远程路径",
"workflow_node.deploy.form.ssh_intermediacert_path.help": "注意:路径需包含完整的文件名,而不是只有目录。不填写时将不上传中间证书。",
"workflow_node.deploy.form.ssh_pfx_password.label": "PFX 导出密码",
@ -753,16 +753,15 @@
"workflow_node.deploy.form.ssh_pre_command.placeholder": "请输入上传文件前执行的命令",
"workflow_node.deploy.form.ssh_post_command.label": "后置命令(可选)",
"workflow_node.deploy.form.ssh_post_command.placeholder": "请输入上传文件后执行的命令",
"workflow_node.deploy.form.ssh_preset_scripts.button": "使用预设脚本",
"workflow_node.deploy.form.ssh_preset_scripts.option.sh_backup_files.label": "POSIX Bash - 备份原证书文件",
"workflow_node.deploy.form.ssh_preset_scripts.option.ps_backup_files.label": "PowerShell - 备份原证书文件",
"workflow_node.deploy.form.ssh_preset_scripts.option.sh_reload_nginx.label": "POSIX Bash - 重启 nginx 进程",
"workflow_node.deploy.form.ssh_preset_scripts.option.sh_replace_synologydsm_ssl.label": "POSIX Bash - 替换群晖 DSM 证书",
"workflow_node.deploy.form.ssh_preset_scripts.option.sh_replace_fnos_ssl.label": "POSIX Bash - 替换飞牛 fnOS 证书",
"workflow_node.deploy.form.ssh_preset_scripts.option.sh_replace_qnap_ssl.label": "POSIX Bash - 替换威联通 QNAP 证书",
"workflow_node.deploy.form.ssh_preset_scripts.option.ps_binding_iis.label": "PowerShell - 导入并绑定到 IIS",
"workflow_node.deploy.form.ssh_preset_scripts.option.ps_binding_netsh.label": "PowerShell - 导入并绑定到 netsh",
"workflow_node.deploy.form.ssh_preset_scripts.option.ps_binding_rdp.label": "PowerShell - 导入并绑定到 RDP",
"workflow_node.deploy.form.ssh_preset_scripts.sh_backup_files": "POSIX Bash - 备份原证书文件",
"workflow_node.deploy.form.ssh_preset_scripts.ps_backup_files": "PowerShell - 备份原证书文件",
"workflow_node.deploy.form.ssh_preset_scripts.sh_reload_nginx": "POSIX Bash - 重启 nginx 进程",
"workflow_node.deploy.form.ssh_preset_scripts.sh_replace_synologydsm_ssl": "POSIX Bash - 替换群晖 DSM 证书",
"workflow_node.deploy.form.ssh_preset_scripts.sh_replace_fnos_ssl": "POSIX Bash - 替换飞牛 fnOS 证书",
"workflow_node.deploy.form.ssh_preset_scripts.sh_replace_qnap_ssl": "POSIX Bash - 替换威联通 QNAP 证书",
"workflow_node.deploy.form.ssh_preset_scripts.ps_binding_iis": "PowerShell - 导入并绑定到 IIS",
"workflow_node.deploy.form.ssh_preset_scripts.ps_binding_netsh": "PowerShell - 导入并绑定到 netsh",
"workflow_node.deploy.form.ssh_preset_scripts.ps_binding_rdp": "PowerShell - 导入并绑定到 RDP",
"workflow_node.deploy.form.ssh_use_scp.label": "回退使用 SCP",
"workflow_node.deploy.form.ssh_use_scp.tooltip": "如果你的远程服务器不支持 SFTP请开启此选项回退为 SCP。",
"workflow_node.deploy.form.tencentcloud_cdn_endpoint.label": "腾讯云接口端点(可选)",
@ -1003,6 +1002,7 @@
"workflow_node.deploy.form.webhook_data.placeholder": "请输入 Webhook 回调数据以覆盖默认值",
"workflow_node.deploy.form.webhook_data.help": "提示:不填写时,将使用所选部署目标授权的默认 Webhook 回调数据。",
"workflow_node.deploy.form.webhook_data.errmsg.json_invalid": "请输入有效的 JSON 格式字符串",
"workflow_node.deploy.form.webhook_data.vartips": "支持的变量:<br><ol style=\"list-style: disc;\"><li><strong>${CERTIMATE_DEPLOYER_COMMONNAME}</strong><br>证书的主域名(即 <i>CommonName</i>)。</li><li><strong>${CERTIMATE_DEPLOYER_SUBJECTALTNAMES}</strong><br>证书的多域名,以半角分号隔开(即 <i>SubjectAltNames</i>)。</li><li><strong>${CERTIMATE_DEPLOYER_CERTIFICATE}</strong><br>证书文件 PEM 格式内容。</li><li><strong>${CERTIMATE_DEPLOYER_CERTIFICATE_SERVER}</strong><br>证书文件仅含服务器证书PEM 格式内容。</li><li><strong>${CERTIMATE_DEPLOYER_CERTIFICATE_INTERMEDIA}</strong><br>证书文件仅含中间证书PEM 格式内容。</li><li><strong>${CERTIMATE_DEPLOYER_PRIVATEKEY}</strong><br>私钥文件 PEM 格式内容。</li></ol>",
"workflow_node.deploy.form.webhook_timeout.label": "Webhook 超时时间(可选)",
"workflow_node.deploy.form.webhook_timeout.placeholder": "请输入 Webhook 超时时间",
"workflow_node.deploy.form.webhook_timeout.unit": "秒",
@ -1022,7 +1022,8 @@
"workflow_node.notify.form.subject.label": "通知主题",
"workflow_node.notify.form.subject.placeholder": "请输入通知主题",
"workflow_node.notify.form.message.label": "通知内容",
"workflow_node.notify.form.template.guide": "<details><summary>通知主题或内容中使用「Mustache」语法即双大括号包裹、并以「$」符号开头的文本会被视为模板插值,将在推送时被替换为实际值。(展开查看更多)</summary><br>支持的模板插值:<ol style=\"list-style: disc;\"><li><em>workflow.id</em>:工作流 ID。</li><li><em>workflow.name</em>:工作流名称。</li><li><em>run.id</em>:运行 ID。</li><li><em>error.nodeId</em>:执行失败时的节点 ID。如果在此之前有多个执行失败的节点始终表示最近的一个。</li><li><em>error.nodeName</em>:执行失败时的节点名称。如果在此之前有多个执行失败的节点,始终表示最近的一个。</li><li><em>error.message</em>:执行失败时的错误信息。如果在此之前有多个执行失败的节点,始终表示最近的一个。</li><li><em>certificate.domain</em>:证书主域名(即 <i>CommonName</i>)。如果在此之前有多个输出证书的节点,始终表示最近的一个。</li><li><em>certificate.domains</em>:证书多域名列表(即 <i>SubjectAltNames</i>)。如果在此之前有多个输出证书的节点,始终表示最近的一个。</li><li><em>certificate.notBefore</em>:证书生效时间,以 RFC3339 格式化。如果在此之前有多个输出证书的节点,始终表示最近的一个。</li><li><em>certificate.notAfter</em>:证书过期时间,以 RFC3339 格式化。如果在此之前有多个输出证书的节点,始终表示最近的一个。</li><li><em>certificate.hoursLeft</em>:证书剩余小时数。如果在此之前有多个输出证书的节点,始终表示最近的一个。</li><li><em>certificate.daysLeft</em>:证书剩余天数。如果在此之前有多个输出证书的节点,始终表示最近的一个。</li><li><em>certificate.validity</em>:证书是否有效。如果在此之前有多个输出证书的节点,始终表示最近的一个。</li><li><em>now</em>:服务器当前时间,以 RFC3339 格式化。</li></ol><br>示例:<br><em>Your workflow {{ $workflow.name }} has failed on node {{ $error.nodeName }} at {{ $now }}.</em><br><br>更多内容请查看文档。</details>",
"workflow_node.notify.form.message.placeholder": "请输入通知内容",
"workflow_node.notify.form.template.guide": "<details><summary>通知主题或内容中使用「Mustache」语法即双大括号包裹、并以「$」符号开头的文本会被视为模板插值,将在推送时被替换为实际值。</summary><br>支持的模板插值:<ol style=\"list-style: disc;\"><li><em>workflow.id</em>:工作流 ID。</li><li><em>workflow.name</em>:工作流名称。</li><li><em>run.id</em>:运行 ID。</li><li><em>error.nodeId</em>:执行失败时的节点 ID。如果在此之前有多个执行失败的节点始终表示最近的一个。</li><li><em>error.nodeName</em>:执行失败时的节点名称。如果在此之前有多个执行失败的节点,始终表示最近的一个。</li><li><em>error.message</em>:执行失败时的错误信息。如果在此之前有多个执行失败的节点,始终表示最近的一个。</li><li><em>certificate.domain</em>:证书主域名(即 <i>CommonName</i>)。如果在此之前有多个输出证书的节点,始终表示最近的一个。</li><li><em>certificate.domains</em>:证书多域名列表(即 <i>SubjectAltNames</i>)。如果在此之前有多个输出证书的节点,始终表示最近的一个。</li><li><em>certificate.notBefore</em>:证书生效时间,以 RFC3339 格式化。如果在此之前有多个输出证书的节点,始终表示最近的一个。</li><li><em>certificate.notAfter</em>:证书过期时间,以 RFC3339 格式化。如果在此之前有多个输出证书的节点,始终表示最近的一个。</li><li><em>certificate.hoursLeft</em>:证书剩余小时数。如果在此之前有多个输出证书的节点,始终表示最近的一个。</li><li><em>certificate.daysLeft</em>:证书剩余天数。如果在此之前有多个输出证书的节点,始终表示最近的一个。</li><li><em>certificate.validity</em>:证书是否有效。如果在此之前有多个输出证书的节点,始终表示最近的一个。</li><li><em>now</em>:服务器当前时间,以 RFC3339 格式化。</li></ol><br>示例:<br><em>Your workflow {{ $workflow.name }} has failed on node {{ $error.nodeName }} at {{ $now }}.</em><br><br>更多内容请查看文档。</details>",
"workflow_node.notify.form.provider.label": "通知渠道",
"workflow_node.notify.form.provider.placeholder": "请选择通知渠道",
"workflow_node.notify.form.provider.search.placeholder": "搜索通知渠道……",
@ -1049,6 +1050,7 @@
"workflow_node.notify.form.webhook_data.placeholder": "请输入 Webhook 回调数据以覆盖默认值",
"workflow_node.notify.form.webhook_data.help": "提示:不填写时,将使用所选通知渠道授权的默认 Webhook 回调数据。",
"workflow_node.notify.form.webhook_data.errmsg.json_invalid": "请输入有效的 JSON 格式字符串",
"workflow_node.notify.form.webhook_data.vartips": "支持的变量:<br><ol style=\"list-style: disc;\"><li><strong>${CERTIMATE_NOTIFIER_SUBJECT}</strong><br>通知主题。</li><li><strong>${CERTIMATE_NOTIFIER_MESSAGE}</strong><br>通知内容。</ol>",
"workflow_node.notify.form.webhook_timeout.label": "Webhook 超时时间(可选)",
"workflow_node.notify.form.webhook_timeout.placeholder": "请输入 Webhook 超时时间",
"workflow_node.notify.form.webhook_timeout.unit": "秒",

View File

@ -4,6 +4,7 @@ import { Navigate, Outlet, useLocation, useNavigate } from "react-router-dom";
import {
IconBrandGithub,
IconCertificate,
IconCodeDots,
IconFingerprint,
IconHelpCircle,
IconHierarchy3,
@ -176,6 +177,7 @@ const SiderMenu = memo(({ collapsed, onSelect }: { collapsed?: boolean; onSelect
const MENU_KEY_WORKFLOWS = "/workflows";
const MENU_KEY_CERTIFICATES = "/certificates";
const MENU_KEY_ACCESSES = "/accesses";
const MENU_KEY_PRESETS = "/presets";
const MENU_KEY_SETTINGS = "/settings";
const menuItems: Required<MenuProps>["items"] = (
[
@ -183,6 +185,7 @@ const SiderMenu = memo(({ collapsed, onSelect }: { collapsed?: boolean; onSelect
[MENU_KEY_WORKFLOWS, "workflow.page.title", <IconHierarchy3 size="1em" />],
[MENU_KEY_CERTIFICATES, "certificate.page.title", <IconCertificate size="1em" />],
[MENU_KEY_ACCESSES, "access.page.title", <IconFingerprint size="1em" />],
[MENU_KEY_PRESETS, "preset.page.title", <IconCodeDots size="1em" />],
[MENU_KEY_SETTINGS, "settings.page.title", <IconSettings size="1em" />],
] satisfies Array<[string, string, React.ReactNode]>
).map(([key, label, icon]) => {

View File

@ -33,9 +33,7 @@ const AccessList = () => {
const { appSettings: globalAppSettings } = useAppSettings();
const { accesses, loadedAtOnce, fetchAccesses, deleteAccess } = useAccessesStore(
useZustandShallowSelector(["accesses", "loadedAtOnce", "fetchAccesses", "deleteAccess"])
);
const { loadedAtOnce, fetchAccesses, deleteAccess } = useAccessesStore(useZustandShallowSelector(["loadedAtOnce", "fetchAccesses", "deleteAccess"]));
useMount(() => {
fetchAccesses().catch((err) => {
if (err instanceof ClientResponseError && err.isAbort) {
@ -118,7 +116,7 @@ const AccessList = () => {
items: [
{
key: "edit",
label: t("access.action.edit.menu"),
label: t("access.action.modify.menu"),
icon: (
<span className="anticon scale-125">
<IconEdit size="1em" />
@ -199,10 +197,11 @@ const AccessList = () => {
};
const { loading, run: refreshData } = useRequest(
() => {
const startIndex = (page - 1) * pageSize;
const endIndex = startIndex + pageSize;
const list = accesses
async () => {
const list = await fetchAccesses();
const startIdx = (page - 1) * pageSize;
const endIdx = startIdx + pageSize;
const items = list
.filter((e) => {
const keyword = (filters["keyword"] as string | undefined)?.trim();
if (keyword) {
@ -227,12 +226,12 @@ const AccessList = () => {
}
});
return Promise.resolve({
items: list.slice(startIndex, endIndex),
totalItems: list.length,
items: items.slice(startIdx, endIdx),
totalItems: items.length,
});
},
{
refreshDeps: [accesses, filters, page, pageSize],
refreshDeps: [filters, page, pageSize],
onBefore: () => {
setSearchParams((prev) => {
if (filters["keyword"]) {
@ -270,7 +269,7 @@ const AccessList = () => {
const handleReloadClick = () => {
if (loading) return;
fetchAccesses();
refreshData();
};
const handlePaginationChange = (page: number, pageSize: number) => {

View File

@ -1,8 +1,8 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import { IconBrowserShare, IconCertificate, IconDots, IconExternalLink, IconReload, IconShieldCancel, IconTrash } from "@tabler/icons-react";
import { useRequest } from "ahooks";
import { useMount, useRequest } from "ahooks";
import { App, Button, Dropdown, Input, Segmented, Skeleton, Table, type TableProps, Typography, theme } from "antd";
import dayjs from "dayjs";
import { ClientResponseError } from "pocketbase";
@ -12,10 +12,9 @@ import CertificateDetailDrawer from "@/components/certificate/CertificateDetailD
import Empty from "@/components/Empty";
import Show from "@/components/Show";
import { CERTIFICATE_SOURCES, type CertificateModel } from "@/domain/certificate";
import { SETTINGS_NAMES } from "@/domain/settings";
import { useAppSettings } from "@/hooks";
import { useAppSettings, useZustandShallowSelector } from "@/hooks";
import { get as getCertificate, list as listCertificates, remove as removeCertificate } from "@/repository/certificate";
import { get as getSettings } from "@/repository/settings";
import { usePersistenceSettingsStore } from "@/stores/settings";
import { getErrMsg } from "@/utils/error";
const CertificateList = () => {
@ -30,7 +29,15 @@ const CertificateList = () => {
const { appSettings: globalAppSettings } = useAppSettings();
const [expiryThreshold, setExpiryThreshold] = useState(0);
const { settings: persistenceSettings, loadSettings: loadPersistenceSettings } = usePersistenceSettingsStore(
useZustandShallowSelector(["settings", "loadSettings"])
);
useMount(() => loadPersistenceSettings(false));
const [expiryThreshold, setExpiryThreshold] = useState(() => persistenceSettings.certificatesWarningDaysBeforeExpire || 0);
useEffect(() => {
setExpiryThreshold(persistenceSettings.certificatesWarningDaysBeforeExpire || 0);
}, [persistenceSettings.certificatesWarningDaysBeforeExpire]);
const [filters, setFilters] = useState<Record<string, unknown>>(() => {
return {
@ -271,14 +278,6 @@ const CertificateList = () => {
return prev;
});
if (expiryThreshold === 0) {
const settings = await getSettings(SETTINGS_NAMES.PERSISTENCE);
const threshold = settings?.content?.certificatesWarningDaysBeforeExpire ?? 0;
if (threshold !== expiryThreshold) {
setExpiryThreshold(threshold);
}
}
},
onSuccess: (res) => {
setTableData(res.items);

View File

@ -0,0 +1,70 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { Tabs } from "antd";
import Show from "@/components/Show";
import PresetListNotifyTemplates from "./PresetListNotifyTemplates";
import PresetListScriptTemplates from "./PresetListScriptTemplates";
type PresetUsages = "notification" | "script";
const PresetList = () => {
const [searchParams, setSearchParams] = useSearchParams();
const { t } = useTranslation();
const [tabKey, setTabKey] = useState<PresetUsages>(() => {
return (searchParams.get("usage") || "notification") as PresetUsages;
});
const handleTabChange = (key: string) => {
setTabKey(key as PresetUsages);
setSearchParams((prev) => {
prev.set("usage", key);
return prev;
});
};
return (
<div className="px-6 py-4">
<div className="container">
<h1>{t("preset.page.title")}</h1>
<p className="text-base text-gray-500">{t("preset.page.subtitle")}</p>
</div>
<div className="container">
<Tabs
className="-mt-2"
activeKey={tabKey}
items={[
{
key: "notification",
label: t("preset.props.usage.notification"),
},
{
key: "script",
label: t("preset.props.usage.script"),
},
]}
size="large"
onChange={(key) => handleTabChange(key)}
/>
<div className="relative">
<Show>
<Show.Case when={tabKey === "notification"}>
<PresetListNotifyTemplates />
</Show.Case>
<Show.Case when={tabKey === "script"}>
<PresetListScriptTemplates />
</Show.Case>
</Show>
</div>
</div>
</div>
);
};
export default PresetList;

View File

@ -0,0 +1,341 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { IconDots, IconEdit, IconPlus, IconTrash } from "@tabler/icons-react";
import { useControllableValue, useMount } from "ahooks";
import { App, Button, Card, Dropdown, Form, Input, Typography } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { nanoid } from "nanoid/non-secure";
import { ClientResponseError } from "pocketbase";
import { z } from "zod";
import DrawerForm from "@/components/DrawerForm";
import Tips from "@/components/Tips";
import { useAntdForm, useZustandShallowSelector } from "@/hooks";
import { useNotifyTemplatesStore } from "@/stores/settings";
import { getErrMsg } from "@/utils/error";
const MAX_TEMPLATE_COUNT = 99;
type PresetTemplate = {
name: string;
subject: string;
message: string;
};
const PresetListNotifyTemplates = () => {
const { t } = useTranslation();
const { message, modal, notification } = App.useApp();
const { templates, loading, loadedAtOnce, fetchTemplates, setTemplates, addTemplate, removeTemplateByIndex } = useNotifyTemplatesStore();
useMount(() => {
fetchTemplates().catch((err) => {
if (err instanceof ClientResponseError && err.isAbort) {
return;
}
console.error(err);
notification.error({ title: t("common.text.request_error"), description: getErrMsg(err) });
});
});
const [createDrawerOpen, setCreateDrawerOpen] = useState(false);
const [detailDrawerOpen, setDetailDrawerOpen] = useState(false);
const [detailDrawerRecord, setDetailDrawerRecord] = useState<PresetTemplate>();
const [detailDrawerIndex, setDetailDrawerIndex] = useState<number>();
const handleCreateClick = () => {
if (!loadedAtOnce) return;
if (templates.length >= MAX_TEMPLATE_COUNT) {
message.warning(t("preset.warning.excceeded"));
return;
}
setCreateDrawerOpen(true);
};
const handleRecordDetailClick = (template: PresetTemplate, index: number) => {
setDetailDrawerIndex(index);
setDetailDrawerRecord({ ...template });
setDetailDrawerOpen(true);
};
const handleRecordDeleteClick = (template: PresetTemplate, index: number) => {
modal.confirm({
title: <span className="text-error">{t("preset.action.delete.modal.title", { name: template.name })}</span>,
content: <span dangerouslySetInnerHTML={{ __html: t("preset.action.delete.modal.content") }} />,
icon: (
<span className="anticon" role="img">
<IconTrash className="text-error" size="1em" />
</span>
),
okText: t("common.button.confirm"),
okButtonProps: { danger: true },
onOk: async () => {
try {
await removeTemplateByIndex(index);
} catch (err) {
console.error(err);
notification.error({ title: t("common.text.request_error"), description: getErrMsg(err) });
}
},
});
};
const handleCreateDrawerSubmit = async (values: PresetTemplate) => {
try {
await addTemplate(values);
setCreateDrawerOpen(false);
} catch (err) {
console.error(err);
notification.error({ title: t("common.text.request_error"), description: getErrMsg(err) });
}
};
const handleModifyDrawerSubmit = async (values: PresetTemplate) => {
try {
const newTemplates = [...templates];
newTemplates[detailDrawerIndex!] = values;
await setTemplates(newTemplates);
setDetailDrawerIndex(void 0);
setDetailDrawerRecord(void 0);
setDetailDrawerOpen(false);
} catch (err) {
console.error(err);
notification.error({ title: t("common.text.request_error"), description: getErrMsg(err) });
}
};
return (
<>
<Tips className="mb-4" message={<span dangerouslySetInnerHTML={{ __html: t("preset.props.usage.notification.tips") }}></span>} />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
<div className="h-40">
<Card
className="size-full text-gray-500 transition-all select-none hover:text-stone-800 dark:hover:text-stone-200"
styles={{
body: {
height: "100%",
},
}}
hoverable
onClick={handleCreateClick}
>
<div className="flex size-full flex-col items-center justify-center gap-4 py-4">
<IconPlus size={36} stroke="1.25" />
<div>{t("preset.action.create.button")}</div>
</div>
</Card>
</div>
{templates.map((template, index) => (
<div className="h-40">
<Card
key={template.name}
className="size-full"
styles={{
root: {
height: "10rem",
},
body: {
height: "100%",
padding: "1rem",
},
header: {
padding: "0.5rem 1rem",
},
}}
extra={
<Dropdown
menu={{
items: [
{
key: "edit",
label: t("preset.action.modify.menu"),
icon: (
<span className="anticon scale-125">
<IconEdit size="1em" />
</span>
),
onClick: (e) => {
e.domEvent.stopPropagation();
handleRecordDetailClick(template, index);
},
},
{
type: "divider",
},
{
key: "delete",
label: t("preset.action.delete.menu"),
danger: true,
icon: (
<span className="anticon scale-125">
<IconTrash size="1em" />
</span>
),
onClick: (e) => {
e.domEvent.stopPropagation();
handleRecordDeleteClick(template, index);
},
},
],
}}
trigger={["click"]}
>
<Button
icon={<IconDots size="1.25em" />}
type="text"
onClick={(e) => {
e.stopPropagation();
}}
/>
</Dropdown>
}
hoverable
title={<Typography.Text ellipsis>{template.name}</Typography.Text>}
onClick={() => {
handleRecordDetailClick(template, index);
}}
>
<Typography.Text ellipsis type="secondary">
{template.subject}
</Typography.Text>
<Typography.Paragraph ellipsis={{ rows: 2 }} type="secondary">
{template.message}
</Typography.Paragraph>
</Card>
</div>
))}
{loading && !loadedAtOnce && (
<div className="h-40">
<Card className="size-full" loading size="small" />
</div>
)}
</div>
<InternalEditDrawer
data={{ name: "", subject: "", message: "" }}
mode={"create"}
open={createDrawerOpen}
afterClose={() => setCreateDrawerOpen(false)}
onOpenChange={(open) => setCreateDrawerOpen(open)}
onSubmit={handleCreateDrawerSubmit}
/>
<InternalEditDrawer
data={detailDrawerRecord}
mode={"modify"}
open={detailDrawerOpen}
afterClose={() => setDetailDrawerOpen(false)}
onOpenChange={(open) => setDetailDrawerOpen(open)}
onSubmit={handleModifyDrawerSubmit}
/>
</>
);
};
const InternalEditDrawer = ({
mode,
data,
onSubmit,
...props
}: {
afterClose?: () => void;
mode: "create" | "modify";
data?: Nullish<PresetTemplate>;
open: boolean;
onOpenChange?: (open: boolean) => void;
onSubmit?: (record: PresetTemplate) => void;
}) => {
const { t } = useTranslation();
const { templates } = useNotifyTemplatesStore(useZustandShallowSelector(["templates"]));
const [open, setOpen] = useControllableValue<boolean>(props, {
valuePropName: "open",
defaultValuePropName: "defaultOpen",
trigger: "onOpenChange",
});
const afterClose = () => {
formInst.resetFields();
props.afterClose?.();
};
const formSchema = z
.object({
name: z.string().nonempty(t("preset.form.name.placeholder")),
subject: z.string().nonempty(t("preset.form.notification_subject.placeholder")),
message: z.string().nonempty(t("preset.form.notification_message.placeholder")),
})
.superRefine((values, ctx) => {
if (values.name) {
const name = String(values.name).trim();
const duplicatedCount = templates.filter((t) => String(t.name).trim() === name).length;
if (duplicatedCount > (mode === "create" ? 0 : 1)) {
ctx.addIssue({
code: "custom",
message: t("preset.form.name.errmsg.duplicated"),
path: ["name"],
});
}
}
});
const formRule = createSchemaFieldRule(formSchema);
const { form: formInst, formProps } = useAntdForm<z.infer<typeof formSchema>>({
name: "viewPresetListNotifyTemplates_InternalDrawerForm_" + nanoid(),
initialValues: data,
});
const handleFormFinish = async (values: z.infer<typeof formSchema>) => {
switch (mode) {
case "create":
case "modify":
{
onSubmit?.(values);
}
break;
default:
throw "Invalid props: `mode`";
}
setOpen(false);
};
return (
<DrawerForm
{...formProps}
clearOnDestroy
drawerProps={{ autoFocus: true, destroyOnHidden: true, size: "large", afterOpenChange: (open) => !open && afterClose?.() }}
form={formInst}
layout="vertical"
okText={mode === "create" ? t("common.button.create") : mode === "modify" ? t("common.button.save") : void 0}
open={open}
preserve={false}
title={mode === "create" ? t("preset.action.create.modal.title") : mode === "modify" ? t("preset.action.modify.modal.title") : void 0}
validateTrigger="onSubmit"
onFinish={handleFormFinish}
onOpenChange={props.onOpenChange}
>
<Form.Item name="name" label={t("preset.form.name.label")} rules={[formRule]}>
<Input maxLength={100} placeholder={t("preset.form.name.placeholder")} />
</Form.Item>
<Form.Item name="subject" label={t("preset.form.notification_subject.label")} rules={[formRule]}>
<Input placeholder={t("preset.form.notification_subject.placeholder")} />
</Form.Item>
<Form.Item name="message" label={t("preset.form.notification_message.label")} rules={[formRule]}>
<Input.TextArea autoSize={{ minRows: 10 }} placeholder={t("preset.form.notification_message.placeholder")} />
</Form.Item>
</DrawerForm>
);
};
export default PresetListNotifyTemplates;

View File

@ -0,0 +1,333 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { IconDots, IconEdit, IconPlus, IconTrash } from "@tabler/icons-react";
import { useControllableValue, useMount } from "ahooks";
import { App, Button, Card, Dropdown, Form, Input, Typography } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { nanoid } from "nanoid/non-secure";
import { ClientResponseError } from "pocketbase";
import { z } from "zod";
import CodeInput from "@/components/CodeInput";
import DrawerForm from "@/components/DrawerForm";
import Tips from "@/components/Tips";
import { useAntdForm, useZustandShallowSelector } from "@/hooks";
import { useScriptTemplatesStore } from "@/stores/settings";
import { getErrMsg } from "@/utils/error";
const MAX_TEMPLATE_COUNT = 99;
type PresetTemplate = {
name: string;
command: string;
};
const PresetListScriptTemplates = () => {
const { t } = useTranslation();
const { message, modal, notification } = App.useApp();
const { templates, loading, loadedAtOnce, fetchTemplates, setTemplates, addTemplate, removeTemplateByIndex } = useScriptTemplatesStore();
useMount(() => {
fetchTemplates().catch((err) => {
if (err instanceof ClientResponseError && err.isAbort) {
return;
}
console.error(err);
notification.error({ title: t("common.text.request_error"), description: getErrMsg(err) });
});
});
const [createDrawerOpen, setCreateDrawerOpen] = useState(false);
const [detailDrawerOpen, setDetailDrawerOpen] = useState(false);
const [detailDrawerRecord, setDetailDrawerRecord] = useState<PresetTemplate>();
const [detailDrawerIndex, setDetailDrawerIndex] = useState<number>();
const handleCreateClick = () => {
if (!loadedAtOnce) return;
if (templates.length >= MAX_TEMPLATE_COUNT) {
message.warning(t("preset.warning.excceeded"));
return;
}
setCreateDrawerOpen(true);
};
const handleRecordDetailClick = (template: PresetTemplate, index: number) => {
setDetailDrawerIndex(index);
setDetailDrawerRecord({ ...template });
setDetailDrawerOpen(true);
};
const handleRecordDeleteClick = (template: PresetTemplate, index: number) => {
modal.confirm({
title: <span className="text-error">{t("preset.action.delete.modal.title", { name: template.name })}</span>,
content: <span dangerouslySetInnerHTML={{ __html: t("preset.action.delete.modal.content") }} />,
icon: (
<span className="anticon" role="img">
<IconTrash className="text-error" size="1em" />
</span>
),
okText: t("common.button.confirm"),
okButtonProps: { danger: true },
onOk: async () => {
try {
await removeTemplateByIndex(index);
} catch (err) {
console.error(err);
notification.error({ title: t("common.text.request_error"), description: getErrMsg(err) });
}
},
});
};
const handleCreateDrawerSubmit = async (values: PresetTemplate) => {
try {
await addTemplate(values);
setCreateDrawerOpen(false);
} catch (err) {
console.error(err);
notification.error({ title: t("common.text.request_error"), description: getErrMsg(err) });
}
};
const handleModifyDrawerSubmit = async (values: PresetTemplate) => {
try {
const newTemplates = [...templates];
newTemplates[detailDrawerIndex!] = values;
await setTemplates(newTemplates);
setDetailDrawerIndex(void 0);
setDetailDrawerRecord(void 0);
setDetailDrawerOpen(false);
} catch (err) {
console.error(err);
notification.error({ title: t("common.text.request_error"), description: getErrMsg(err) });
}
};
return (
<>
<Tips className="mb-4" message={<span dangerouslySetInnerHTML={{ __html: t("preset.props.usage.script.tips") }}></span>} />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
<div className="h-40">
<Card
className="size-full text-gray-500 transition-all select-none hover:text-stone-800 dark:hover:text-stone-200"
styles={{
root: {
height: "100%",
},
body: {
height: "100%",
},
}}
hoverable
onClick={handleCreateClick}
>
<div className="flex size-full flex-col items-center justify-center gap-4 py-4">
<IconPlus size={36} stroke="1.25" />
<div>{t("preset.action.create.button")}</div>
</div>
</Card>
</div>
{templates.map((template, index) => (
<div className="h-40">
<Card
key={template.name}
className="size-full"
styles={{
body: {
height: "100%",
padding: "1rem",
},
header: {
padding: "0.5rem 1rem",
},
}}
extra={
<Dropdown
menu={{
items: [
{
key: "edit",
label: t("preset.action.modify.menu"),
icon: (
<span className="anticon scale-125">
<IconEdit size="1em" />
</span>
),
onClick: (e) => {
e.domEvent.stopPropagation();
handleRecordDetailClick(template, index);
},
},
{
type: "divider",
},
{
key: "delete",
label: t("preset.action.delete.menu"),
danger: true,
icon: (
<span className="anticon scale-125">
<IconTrash size="1em" />
</span>
),
onClick: (e) => {
e.domEvent.stopPropagation();
handleRecordDeleteClick(template, index);
},
},
],
}}
trigger={["click"]}
>
<Button
icon={<IconDots size="1.25em" />}
type="text"
onClick={(e) => {
e.stopPropagation();
}}
/>
</Dropdown>
}
hoverable
title={<Typography.Text ellipsis>{template.name}</Typography.Text>}
onClick={() => {
handleRecordDetailClick(template, index);
}}
>
<Typography.Paragraph className="whitespace-pre-line" ellipsis={{ rows: 3 }} type="secondary">
{template.command}
</Typography.Paragraph>
</Card>
</div>
))}
{loading && !loadedAtOnce && (
<div className="h-40">
<Card className="size-full" loading size="small" />
</div>
)}
</div>
<InternalEditDrawer
data={{ name: "", command: "" }}
mode={"create"}
open={createDrawerOpen}
afterClose={() => setCreateDrawerOpen(false)}
onOpenChange={(open) => setCreateDrawerOpen(open)}
onSubmit={handleCreateDrawerSubmit}
/>
<InternalEditDrawer
data={detailDrawerRecord}
mode={"modify"}
open={detailDrawerOpen}
afterClose={() => setDetailDrawerOpen(false)}
onOpenChange={(open) => setDetailDrawerOpen(open)}
onSubmit={handleModifyDrawerSubmit}
/>
</>
);
};
const InternalEditDrawer = ({
mode,
data,
onSubmit,
...props
}: {
afterClose?: () => void;
mode: "create" | "modify";
data?: Nullish<PresetTemplate>;
open: boolean;
onOpenChange?: (open: boolean) => void;
onSubmit?: (record: PresetTemplate) => void;
}) => {
const { t } = useTranslation();
const { templates } = useScriptTemplatesStore(useZustandShallowSelector(["templates"]));
const [open, setOpen] = useControllableValue<boolean>(props, {
valuePropName: "open",
defaultValuePropName: "defaultOpen",
trigger: "onOpenChange",
});
const afterClose = () => {
formInst.resetFields();
props.afterClose?.();
};
const formSchema = z
.object({
name: z.string().nonempty(t("preset.form.name.placeholder")),
command: z.string().nonempty(t("preset.form.script_command.placeholder")),
})
.superRefine((values, ctx) => {
if (values.name) {
const name = String(values.name).trim();
const duplicatedCount = templates.filter((t) => String(t.name).trim() === name).length;
if (duplicatedCount > (mode === "create" ? 0 : 1)) {
ctx.addIssue({
code: "custom",
message: t("preset.form.name.errmsg.duplicated"),
path: ["name"],
});
}
}
});
const formRule = createSchemaFieldRule(formSchema);
const { form: formInst, formProps } = useAntdForm<z.infer<typeof formSchema>>({
name: "viewPresetListScriptTemplates_InternalDrawerForm_" + nanoid(),
initialValues: data,
});
const handleFormFinish = async (values: z.infer<typeof formSchema>) => {
switch (mode) {
case "create":
case "modify":
{
onSubmit?.(values);
}
break;
default:
throw "Invalid props: `mode`";
}
setOpen(false);
};
return (
<DrawerForm
{...formProps}
clearOnDestroy
drawerProps={{ autoFocus: true, destroyOnHidden: true, size: "large", afterOpenChange: (open) => !open && afterClose?.() }}
form={formInst}
layout="vertical"
okText={mode === "create" ? t("common.button.create") : mode === "modify" ? t("common.button.save") : void 0}
open={open}
preserve={false}
title={mode === "create" ? t("preset.action.create.modal.title") : mode === "modify" ? t("preset.action.modify.modal.title") : void 0}
validateTrigger="onSubmit"
onFinish={handleFormFinish}
onOpenChange={props.onOpenChange}
>
<Form.Item name="name" label={t("preset.form.name.label")} rules={[formRule]}>
<Input maxLength={100} placeholder={t("preset.form.name.placeholder")} />
</Form.Item>
<Form.Item name="command" label={t("preset.form.script_command.label")} rules={[formRule]}>
<CodeInput height="auto" minHeight="256px" language={["shell", "powershell"]} placeholder={t("preset.form.script_command.placeholder")} />
</Form.Item>
</DrawerForm>
);
};
export default PresetListScriptTemplates;

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { IconBracketsAngle, IconDatabaseCog, IconInfoCircle, IconPalette, IconPlugConnected, IconUserShield } from "@tabler/icons-react";
import { IconDatabaseCog, IconHeartRateMonitor, IconInfoCircle, IconPalette, IconPlugConnected, IconUserShield } from "@tabler/icons-react";
import { Menu } from "antd";
const Settings = () => {
@ -15,7 +15,7 @@ const Settings = () => {
["appearance", "settings.appearance.tab", <IconPalette size="1em" />],
["ssl-provider", "settings.sslprovider.tab", <IconPlugConnected size="1em" />],
["persistence", "settings.persistence.tab", <IconDatabaseCog size="1em" />],
["diagnostics", "settings.diagnostics.tab", <IconBracketsAngle size="1em" />],
["diagnostics", "settings.diagnostics.tab", <IconHeartRateMonitor size="1em" />],
["about", "settings.about.tab", <IconInfoCircle size="1em" />],
] satisfies [string, string, React.ReactElement][];
const [menuKey, setMenuKey] = useState<string>(() => location.pathname.split("/")[2]);

View File

@ -1,14 +1,15 @@
import { createContext, useContext, useEffect, useState } from "react";
import { createContext, useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMount } from "ahooks";
import { App, Button, Divider, Form, InputNumber, Skeleton } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { produce } from "immer";
import { z } from "zod";
import Show from "@/components/Show";
import { type PersistenceSettingsContent, SETTINGS_NAMES, type SettingsModel } from "@/domain/settings";
import { useAntdForm } from "@/hooks";
import { get as getSettings, save as saveSettings } from "@/repository/settings";
import { type PersistenceSettingsContent } from "@/domain/settings";
import { useAntdForm, useZustandShallowSelector } from "@/hooks";
import { usePersistenceSettingsStore } from "@/stores/settings";
import { getErrMsg } from "@/utils/error";
const SettingsPersistence = () => {
@ -16,26 +17,14 @@ const SettingsPersistence = () => {
const { message, notification } = App.useApp();
const [settings, setSettings] = useState<SettingsModel<PersistenceSettingsContent>>();
const [loading, setLoading] = useState(true);
const { settings, loading, loadSettings, saveSettings } = usePersistenceSettingsStore(
useZustandShallowSelector(["settings", "loading", "loadSettings", "saveSettings"])
);
useMount(() => loadSettings());
useEffect(() => {
const fetchData = async () => {
setLoading(true);
const settings = await getSettings(SETTINGS_NAMES.PERSISTENCE);
setSettings(settings);
setLoading(false);
};
fetchData();
}, []);
const updateContextSettings = async (settings: MaybeModelRecordWithId<SettingsModel<PersistenceSettingsContent>>) => {
const updateContextSettings = async (settings: PersistenceSettingsContent) => {
try {
const resp = await saveSettings(settings);
setSettings(resp);
await saveSettings(settings);
message.success(t("common.text.operation_succeeded"));
} catch (err) {
@ -77,13 +66,12 @@ const SettingsPersistenceAlerting = ({ className, style }: { className?: string;
formProps,
} = useAntdForm<z.infer<typeof formSchema>>({
initialValues: {
certificatesWarningDaysBeforeExpire: settings?.content?.certificatesWarningDaysBeforeExpire,
certificatesWarningDaysBeforeExpire: settings?.certificatesWarningDaysBeforeExpire,
},
onSubmit: async (values) => {
updateSettings(
produce(settings!, (draft) => {
draft.content ??= {} as PersistenceSettingsContent;
draft.content.certificatesWarningDaysBeforeExpire = values.certificatesWarningDaysBeforeExpire;
draft.certificatesWarningDaysBeforeExpire = values.certificatesWarningDaysBeforeExpire;
})
);
},
@ -144,15 +132,14 @@ const SettingsPersistenceDataRetention = ({ className, style }: { className?: st
formProps,
} = useAntdForm<z.infer<typeof formSchema>>({
initialValues: {
certificatesRetentionMaxDays: settings?.content?.certificatesRetentionMaxDays,
workflowRunsRetentionMaxDays: settings?.content?.workflowRunsRetentionMaxDays,
certificatesRetentionMaxDays: settings?.certificatesRetentionMaxDays,
workflowRunsRetentionMaxDays: settings?.workflowRunsRetentionMaxDays,
},
onSubmit: async (values) => {
updateSettings(
produce(settings!, (draft) => {
draft.content ??= {} as PersistenceSettingsContent;
draft.content.certificatesRetentionMaxDays = values.certificatesRetentionMaxDays;
draft.content.workflowRunsRetentionMaxDays = values.workflowRunsRetentionMaxDays;
draft.certificatesRetentionMaxDays = values.certificatesRetentionMaxDays;
draft.workflowRunsRetentionMaxDays = values.workflowRunsRetentionMaxDays;
})
);
},
@ -218,8 +205,8 @@ const SettingsPersistenceDataRetention = ({ className, style }: { className?: st
const InternalSettingsContext = createContext(
{} as {
loading: boolean;
settings: SettingsModel<PersistenceSettingsContent>;
updateSettings: (settings: MaybeModelRecordWithId<SettingsModel<PersistenceSettingsContent>>) => Promise<void>;
settings: PersistenceSettingsContent;
updateSettings: (settings: PersistenceSettingsContent) => Promise<void>;
}
);

View File

@ -1,5 +1,6 @@
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMount } from "ahooks";
import { App, Button, Card, Form, Input, Select, Skeleton } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { produce } from "immer";
@ -8,9 +9,9 @@ import { z } from "zod";
import Show from "@/components/Show";
import Tips from "@/components/Tips";
import { type CAProviderType, CA_PROVIDERS } from "@/domain/provider";
import { SETTINGS_NAMES, type SSLProviderSettingsContent, type SettingsModel } from "@/domain/settings";
import { useAntdForm } from "@/hooks";
import { get as getSettings, save as saveSettings } from "@/repository/settings";
import { type SSLProviderSettingsContent } from "@/domain/settings";
import { useAntdForm, useZustandShallowSelector } from "@/hooks";
import { useSSLProviderSettingsStore } from "@/stores/settings";
import { mergeCls } from "@/utils/css";
import { getErrMsg } from "@/utils/error";
@ -19,26 +20,14 @@ const SettingsSSLProvider = () => {
const { message, notification } = App.useApp();
const [settings, setSettings] = useState<SettingsModel<SSLProviderSettingsContent>>();
const [loading, setLoading] = useState(true);
const { settings, loading, loadSettings, saveSettings } = useSSLProviderSettingsStore(
useZustandShallowSelector(["settings", "loading", "loadSettings", "saveSettings"])
);
useMount(() => loadSettings());
const [formInst] = Form.useForm<{ provider?: string }>();
const [formPending, setFormPending] = useState(false);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
const settings = await getSettings(SETTINGS_NAMES.SSL_PROVIDER);
setSettings(settings);
setProviderValue(settings.content?.provider || CA_PROVIDERS.LETSENCRYPT);
setLoading(false);
};
fetchData();
}, []);
const providers = [
[CA_PROVIDERS.LETSENCRYPT, "provider.letsencrypt", "letsencrypt.org", "/imgs/providers/letsencrypt.svg"],
[CA_PROVIDERS.LETSENCRYPTSTAGING, "provider.letsencryptstaging", "letsencrypt.org", "/imgs/providers/letsencrypt.svg"],
@ -84,13 +73,16 @@ const SettingsSSLProvider = () => {
}
}, [providerValue]);
const updateContextSettings = async (settings: MaybeModelRecordWithId<SettingsModel<SSLProviderSettingsContent>>) => {
useEffect(() => {
setProviderValue(settings.provider || CA_PROVIDERS.LETSENCRYPT);
}, [settings.provider]);
const updateContextSettings = async (settings: SSLProviderSettingsContent) => {
setFormPending(true);
try {
const resp = await saveSettings(settings);
setSettings(resp);
setProviderValue(resp.content?.provider);
await saveSettings(settings);
setProviderValue(settings.provider);
message.success(t("common.text.operation_succeeded"));
} catch (err) {
@ -157,8 +149,8 @@ const InternalSettingsContext = createContext(
{} as {
loading: boolean;
pending: boolean;
settings: SettingsModel<SSLProviderSettingsContent>;
updateSettings: (settings: MaybeModelRecordWithId<SettingsModel<SSLProviderSettingsContent>>) => Promise<void>;
settings: SSLProviderSettingsContent;
updateSettings: (settings: SSLProviderSettingsContent) => Promise<void>;
}
);
@ -168,14 +160,12 @@ const InternalSharedForm = ({ children, provider }: { children?: React.ReactNode
const { pending, settings, updateSettings } = useContext(InternalSettingsContext);
const { form: formInst, formProps } = useAntdForm<NonNullable<unknown>>({
initialValues: settings?.content?.config?.[provider],
initialValues: settings?.config?.[provider],
onSubmit: async (values) => {
const newSettings = produce(settings, (draft) => {
draft.content ??= {} as SSLProviderSettingsContent;
draft.content.provider = provider;
draft.content.config ??= {} as SSLProviderSettingsContent["config"];
draft.content.config[provider] = values;
draft.provider = provider;
draft.config ??= {} as SSLProviderSettingsContent["config"];
draft.config[provider] = values;
});
await updateSettings(newSettings);
@ -185,8 +175,8 @@ const InternalSharedForm = ({ children, provider }: { children?: React.ReactNode
const [formChanged, setFormChanged] = useState(false);
useEffect(() => {
setFormChanged(provider !== settings?.content?.provider);
}, [provider, settings?.content?.provider]);
setFormChanged(provider !== settings?.provider);
}, [provider, settings?.provider]);
const handleFormChange = () => {
setFormChanged(true);

View File

@ -133,8 +133,8 @@ const WorkflowList = () => {
menu={{
items: [
{
key: "edit",
label: t("workflow.action.edit.menu"),
key: "modify",
label: t("workflow.action.modify.menu"),
icon: (
<span className="anticon scale-125">
<IconEdit size="1em" />

View File

@ -1,7 +1,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { IconArrowRight, IconCode, IconSquarePlus2 } from "@tabler/icons-react";
import { IconArrowRight, IconSquarePlus2, IconUpload } from "@tabler/icons-react";
import { App, Button, Card, Spin, Typography } from "antd";
import Show from "@/components/Show";
@ -285,18 +285,12 @@ const WorkflowNew = () => {
<div className="container">
<div className="my-1.5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
<Card className="size-full" styles={{ body: { padding: "1rem 1.5rem" } }} variant="borderless">
<Card className="size-full" styles={{ body: { padding: "1rem 0.5rem" } }} variant="borderless">
<div className="flex flex-col gap-3">
<Button
className="border-none px-0 shadow-none"
block
icon={<IconSquarePlus2 size="1.25em" />}
variant="solid"
onClick={() => handleTemplateClick(TEMPLATE_KEY_BLANK)}
>
<Button block icon={<IconSquarePlus2 size="1.25em" />} type="text" onClick={() => handleTemplateClick(TEMPLATE_KEY_BLANK)}>
<div className="w-full text-left">{t("workflow.new.button.create")}</div>
</Button>
<Button className="border-none px-0 shadow-none" block icon={<IconCode size="1.25em" />} variant="solid" onClick={handleImportClick}>
<Button block icon={<IconUpload size="1.25em" />} type="text" onClick={handleImportClick}>
<div className="w-full text-left">{t("workflow.new.button.import")}</div>
</Button>
</div>

View File

@ -3,9 +3,11 @@ import { ClientResponseError } from "pocketbase";
import { CA_PROVIDERS } from "@/domain/provider";
import {
type EmailsSettingsContent,
type NotifyTemplateContent,
type PersistenceSettingsContent,
SETTINGS_NAMES,
type SSLProviderSettingsContent,
type ScriptTemplateContent,
type SettingsModel,
type SettingsNames,
} from "@/domain/settings";
@ -14,6 +16,8 @@ import { COLLECTION_NAME_SETTINGS, getPocketBase } from "./_pocketbase";
interface SettingsContentMap {
[SETTINGS_NAMES.EMAILS]: EmailsSettingsContent;
[SETTINGS_NAMES.NOTIFY_TEMPLATE]: NotifyTemplateContent;
[SETTINGS_NAMES.SCRIPT_TEMPLATE]: ScriptTemplateContent;
[SETTINGS_NAMES.SSL_PROVIDER]: SSLProviderSettingsContent;
[SETTINGS_NAMES.PERSISTENCE]: PersistenceSettingsContent;
}
@ -47,6 +51,20 @@ export const get = async <K extends SettingsNames | string, T extends NonNullabl
}
break;
case SETTINGS_NAMES.NOTIFY_TEMPLATE:
{
resp.content ??= {};
(resp.content as NotifyTemplateContent).templates ??= [];
}
break;
case SETTINGS_NAMES.SCRIPT_TEMPLATE:
{
resp.content ??= {};
(resp.content as ScriptTemplateContent).templates ??= [];
}
break;
case SETTINGS_NAMES.SSL_PROVIDER:
{
resp.content ??= {};

View File

@ -8,6 +8,7 @@ import ConsoleLayout from "@/pages/ConsoleLayout";
import Dashboard from "@/pages/dashboard/Dashboard";
import ErrorLayout from "@/pages/ErrorLayout";
import Login from "@/pages/login/Login";
import PresetList from "@/pages/presets/PresetList";
import Settings from "@/pages/settings/Settings";
import SettingsAbout from "@/pages/settings/SettingsAbout";
import SettingsAccount from "@/pages/settings/SettingsAccount";
@ -64,6 +65,10 @@ export const router = createHashRouter([
},
],
},
{
path: "/presets",
element: <PresetList />,
},
{
path: "/settings",
element: <Settings />,

View File

@ -17,7 +17,7 @@ export const useAccessesStore = create<AccessesStore>((set, get) => {
fetchAccesses: async (refresh = true) => {
if (!refresh) {
if (get().loadedAtOnce) {
return;
return get().accesses;
}
}
@ -31,6 +31,8 @@ export const useAccessesStore = create<AccessesStore>((set, get) => {
fetcher = null;
set({ loading: false });
}
return get().accesses;
},
createAccess: async (access) => {

View File

@ -7,7 +7,7 @@ export interface AccessesState {
}
export interface AccessesActions {
fetchAccesses: (refresh?: boolean) => Promise<void>;
fetchAccesses: (refresh?: boolean) => Promise<AccessModel[]>;
createAccess: (access: MaybeModelRecord<AccessModel>) => Promise<AccessModel>;
updateAccess: (access: MaybeModelRecordWithId<AccessModel>) => Promise<AccessModel>;
deleteAccess: (access: MaybeModelRecordWithId<AccessModel> | MaybeModelRecordWithId<AccessModel>[]) => Promise<AccessModel>;

View File

@ -8,7 +8,7 @@ import { type ContactEmailsState, type ContactEmailsStore } from "./types";
export const useContactEmailsStore = create<ContactEmailsStore>((set, get) => {
let fetcher: Promise<SettingsModel<EmailsSettingsContent>> | null = null; // 防止多次重复请求
let settings: SettingsModel<EmailsSettingsContent>; // 记录当前设置的其他字段,保存回数据库时用
let model: SettingsModel<EmailsSettingsContent>; // 记录当前设置的其他字段,保存回数据库时用
return {
emails: [],
@ -18,7 +18,7 @@ export const useContactEmailsStore = create<ContactEmailsStore>((set, get) => {
fetchEmails: async (refresh = true) => {
if (!refresh) {
if (get().loadedAtOnce) {
return;
return get().emails;
}
}
@ -26,27 +26,29 @@ export const useContactEmailsStore = create<ContactEmailsStore>((set, get) => {
try {
set({ loading: true });
settings = await fetcher;
set({ emails: settings.content.emails?.filter((s) => !!s)?.sort() ?? [], loadedAtOnce: true });
model = await fetcher;
set({ emails: model.content.emails?.filter((s) => !!s)?.sort() ?? [], loadedAtOnce: true });
} finally {
fetcher = null;
set({ loading: false });
}
return get().emails;
},
setEmails: async (emails) => {
settings ??= await getSettings(SETTINGS_NAMES.EMAILS);
settings = await saveSettings<EmailsSettingsContent>({
...settings,
model ??= await getSettings(SETTINGS_NAMES.EMAILS);
model = await saveSettings<EmailsSettingsContent>({
...model,
content: {
...settings.content,
...model.content,
emails: emails,
},
});
set(
produce((state: ContactEmailsState) => {
state.emails = settings.content.emails?.sort() ?? [];
state.emails = model.content.emails?.sort() ?? [];
state.loadedAtOnce = true;
})
);

View File

@ -5,7 +5,7 @@
}
export interface ContactEmailsActions {
fetchEmails: (refresh?: boolean) => Promise<void>;
fetchEmails: (refresh?: boolean) => Promise<string[]>;
setEmails: (emails: string[]) => Promise<void>;
addEmail: (email: string) => Promise<void>;
removeEmail: (email: string) => Promise<void>;

View File

@ -0,0 +1,4 @@
export { useContactEmailsStore } from "./contact";
export { usePersistenceSettingsStore } from "./persistence";
export { useSSLProviderSettingsStore } from "./sslprovider";
export { useNotifyTemplatesStore, useScriptTemplatesStore } from "./template";

View File

@ -0,0 +1,52 @@
import { produce } from "immer";
import { create } from "zustand";
import { type PersistenceSettingsContent, SETTINGS_NAMES, type SettingsModel } from "@/domain/settings";
import { get as getSettings, save as saveSettings } from "@/repository/settings";
import { type PersistenceSettingsState, type PersistenceSettingsStore } from "./types";
export const usePersistenceSettingsStore = create<PersistenceSettingsStore>((set, get) => {
let fetcher: Promise<SettingsModel<PersistenceSettingsContent>> | null = null; // 防止多次重复请求
let model: SettingsModel<PersistenceSettingsContent>; // 记录当前设置的其他字段,保存回数据库时用
return {
settings: {} as PersistenceSettingsContent,
loading: false,
loadedAtOnce: false,
loadSettings: async (refresh = true) => {
if (!refresh) {
if (get().loadedAtOnce) {
return;
}
}
fetcher ??= getSettings(SETTINGS_NAMES.PERSISTENCE);
try {
set({ loading: true });
model = await fetcher;
set({ settings: model.content, loadedAtOnce: true });
} finally {
fetcher = null;
set({ loading: false });
}
},
saveSettings: async (settings) => {
model ??= await getSettings(SETTINGS_NAMES.PERSISTENCE);
model = await saveSettings<PersistenceSettingsContent>({
...model,
content: settings,
});
set(
produce((state: PersistenceSettingsState) => {
state.settings = model.content;
state.loadedAtOnce = true;
})
);
},
};
});

View File

@ -0,0 +1,14 @@
import { type PersistenceSettingsContent } from "@/domain/settings";
export interface PersistenceSettingsState {
settings: PersistenceSettingsContent;
loading: boolean;
loadedAtOnce: boolean;
}
export interface PersistenceSettingsActions {
loadSettings: (refresh?: boolean) => Promise<void>;
saveSettings: (settings: PersistenceSettingsContent) => Promise<void>;
}
export interface PersistenceSettingsStore extends PersistenceSettingsState, PersistenceSettingsActions {}

View File

@ -0,0 +1,52 @@
import { produce } from "immer";
import { create } from "zustand";
import { SETTINGS_NAMES, type SSLProviderSettingsContent, type SettingsModel } from "@/domain/settings";
import { get as getSettings, save as saveSettings } from "@/repository/settings";
import { type SSLProviderSettingsState, type SSLProviderSettingsStore } from "./types";
export const useSSLProviderSettingsStore = create<SSLProviderSettingsStore>((set, get) => {
let fetcher: Promise<SettingsModel<SSLProviderSettingsContent>> | null = null; // 防止多次重复请求
let model: SettingsModel<SSLProviderSettingsContent>; // 记录当前设置的其他字段,保存回数据库时用
return {
settings: {} as SSLProviderSettingsContent,
loading: false,
loadedAtOnce: false,
loadSettings: async (refresh = true) => {
if (!refresh) {
if (get().loadedAtOnce) {
return;
}
}
fetcher ??= getSettings(SETTINGS_NAMES.SSL_PROVIDER);
try {
set({ loading: true });
model = await fetcher;
set({ settings: model.content, loadedAtOnce: true });
} finally {
fetcher = null;
set({ loading: false });
}
},
saveSettings: async (settings) => {
model ??= await getSettings(SETTINGS_NAMES.SSL_PROVIDER);
model = await saveSettings<SSLProviderSettingsContent>({
...model,
content: settings,
});
set(
produce((state: SSLProviderSettingsState) => {
state.settings = model.content;
state.loadedAtOnce = true;
})
);
},
};
});

View File

@ -0,0 +1,14 @@
import { type SSLProviderSettingsContent } from "@/domain/settings";
export interface SSLProviderSettingsState {
settings: SSLProviderSettingsContent;
loading: boolean;
loadedAtOnce: boolean;
}
export interface SSLProviderSettingsActions {
loadSettings: (refresh?: boolean) => Promise<void>;
saveSettings: (settings: SSLProviderSettingsContent) => Promise<void>;
}
export interface SSLProviderSettingsStore extends SSLProviderSettingsState, SSLProviderSettingsActions {}

View File

@ -0,0 +1,163 @@
import { produce } from "immer";
import { create } from "zustand";
import { type NotifyTemplateContent, SETTINGS_NAMES, type ScriptTemplateContent, type SettingsModel } from "@/domain/settings";
import { get as getSettings, save as saveSettings } from "@/repository/settings";
import { type NotifyTemplatesState, type NotifyTemplatesStore, type ScriptTemplatesState, type ScriptTemplatesStore } from "./types";
export const useNotifyTemplatesStore = create<NotifyTemplatesStore>((set, get) => {
let fetcher: Promise<SettingsModel<NotifyTemplateContent>> | null = null; // 防止多次重复请求
let model: SettingsModel<NotifyTemplateContent>; // 记录当前设置的其他字段,保存回数据库时用
return {
templates: [],
loading: false,
loadedAtOnce: false,
fetchTemplates: async (refresh = true) => {
if (!refresh) {
if (get().loadedAtOnce) {
return;
}
}
fetcher ??= getSettings(SETTINGS_NAMES.NOTIFY_TEMPLATE);
try {
set({ loading: true });
model = await fetcher;
set({ templates: model.content.templates ?? [], loadedAtOnce: true });
} finally {
fetcher = null;
set({ loading: false });
}
},
setTemplates: async (templates) => {
model ??= await getSettings(SETTINGS_NAMES.NOTIFY_TEMPLATE);
model = await saveSettings<NotifyTemplateContent>({
...model,
content: {
...model.content,
templates: templates,
},
});
set(
produce((state: NotifyTemplatesState) => {
state.templates = model.content.templates ?? [];
state.loadedAtOnce = true;
})
);
},
addTemplate: async (template) => {
const templates = produce(get().templates, (draft) => {
const index = draft.findIndex((t) => t.name === template.name);
if (index !== -1) {
draft[index] = template;
} else {
draft.push(template);
}
return draft;
});
get().setTemplates(templates);
},
removeTemplateByIndex: async (index) => {
const templates = produce(get().templates, (draft) => {
draft = draft.filter((_, i) => i !== index);
return draft;
});
get().setTemplates(templates);
},
removeTemplateByName: async (name) => {
const templates = produce(get().templates, (draft) => {
draft = draft.filter((e) => e.name !== name);
return draft;
});
get().setTemplates(templates);
},
};
});
export const useScriptTemplatesStore = create<ScriptTemplatesStore>((set, get) => {
let fetcher: Promise<SettingsModel<ScriptTemplateContent>> | null = null; // 防止多次重复请求
let model: SettingsModel<ScriptTemplateContent>; // 记录当前设置的其他字段,保存回数据库时用
return {
templates: [],
loading: false,
loadedAtOnce: false,
fetchTemplates: async (refresh = true) => {
if (!refresh) {
if (get().loadedAtOnce) {
return;
}
}
fetcher ??= getSettings(SETTINGS_NAMES.SCRIPT_TEMPLATE);
try {
set({ loading: true });
model = await fetcher;
set({ templates: model.content.templates ?? [], loadedAtOnce: true });
} finally {
fetcher = null;
set({ loading: false });
}
},
setTemplates: async (templates) => {
model ??= await getSettings(SETTINGS_NAMES.SCRIPT_TEMPLATE);
model = await saveSettings<ScriptTemplateContent>({
...model,
content: {
...model.content,
templates: templates,
},
});
set(
produce((state: ScriptTemplatesState) => {
state.templates = model.content.templates ?? [];
state.loadedAtOnce = true;
})
);
},
addTemplate: async (template) => {
const templates = produce(get().templates, (draft) => {
const index = draft.findIndex((t) => t.name === template.name);
if (index !== -1) {
draft[index] = template;
} else {
draft.push(template);
}
return draft;
});
get().setTemplates(templates);
},
removeTemplateByIndex: async (index) => {
const templates = produce(get().templates, (draft) => {
draft = draft.filter((_, i) => i !== index);
return draft;
});
get().setTemplates(templates);
},
removeTemplateByName: async (name) => {
const templates = produce(get().templates, (draft) => {
draft = draft.filter((e) => e.name !== name);
return draft;
});
get().setTemplates(templates);
},
};
});

View File

@ -0,0 +1,42 @@
type NotifyTemplate = {
name: string;
subject: string;
message: string;
};
export interface NotifyTemplatesState {
templates: NotifyTemplate[];
loading: boolean;
loadedAtOnce: boolean;
}
export interface NotifyTemplatesActions {
fetchTemplates: (refresh?: boolean) => Promise<void>;
setTemplates: (templates: NotifyTemplate[]) => Promise<void>;
addTemplate: (template: NotifyTemplate) => Promise<void>;
removeTemplateByIndex: (index: number) => Promise<void>;
removeTemplateByName: (name: string) => Promise<void>;
}
export interface NotifyTemplatesStore extends NotifyTemplatesState, NotifyTemplatesActions {}
type ScriptTemplate = {
name: string;
command: string;
};
export interface ScriptTemplatesState {
templates: ScriptTemplate[];
loading: boolean;
loadedAtOnce: boolean;
}
export interface ScriptTemplatesActions {
fetchTemplates: (refresh?: boolean) => Promise<void>;
setTemplates: (templates: ScriptTemplate[]) => Promise<void>;
addTemplate: (template: ScriptTemplate) => Promise<void>;
removeTemplateByIndex: (index: number) => Promise<void>;
removeTemplateByName: (name: string) => Promise<void>;
}
export interface ScriptTemplatesStore extends ScriptTemplatesState, ScriptTemplatesActions {}