Merge pull request #224 from Dvorinka/feat/settings-i18n

feat(i18n): localized admin-defined strings
pull/3589/head
Tomáš Dvořák 2 weeks ago committed by GitHub
commit a939c6e383
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -223,6 +223,7 @@ Order = user-visible value first; each ships with backend + UI + tests.
- [x] Thumbnail generation controls (upstream #2178 items 7+8) — `PolicySetting.ThumbForceProxy` skips the backend's native thumbnail API even when supported, implying the local proxy pipeline; `PolicySetting.ThumbStoragePolicyID` redirects generated thumb entities to a designated policy (honored via `PreferredStoragePolicy` for thumbnail uploads in `PrepareUpload`); Thumbnails section gains both controls, en+zh locales
- [x] Per-user blob relocation (upstream #2729/misc) — `RelocateEntityService` gains a third scope `src_user_id` (mutually exclusive with `entity_ids`/`src_policy_id`); `NewRelocateUserTask` selects entities by `created_by` with the same cursor-resumable transfer path; admin user editor gains a "Relocate files" button opening the relocate dialog prefilled with the user scope; en+zh locales
- [x] Tencent Captcha (upstream #2178) — `captcha_type=tcaptcha` now performs real verification: `pkg/tcaptcha` calls Tencent Cloud `DescribeCaptchaResult` with full TC3-HMAC-SHA256 request signing (CaptchaAppId/AppSecretKey + SecretId/SecretKey, CaptchaType 9, client IP propagated); login/register/forgot-password flows emit `{ticket, randstr}` from the TCaptcha.js popup widget via a new `TCaptcha` verify-button component; admin Captcha section gains the provider option + four credential fields; en+zh locales
- [x] Localized admin strings (#25/#2691) — `setting.Provider.Localized` resolves any `<key>_i18n` JSON map by language tag (exact → bare primary subtag → wildcard `*` → base value); `SiteBasicLocalized` covers site name/title/description; consumed by site config, announcement endpoint, share-preview OG tags, index.html placeholders, WOPI breadcrumb, and email templates (recipient language); SKU gains `name_i18n`/`des_i18n` columns resolved per buyer language in the shop; admin gets a reusable `LocalizedFields` accordion (per-language inputs) wired into site name/description/announcement and SKU name/description; en+zh locales
## 6. Phase D — desktop, all platforms

File diff suppressed because one or more lines are too long

@ -664,6 +664,8 @@ var (
{Name: "points", Type: field.TypeInt64, Nullable: true},
{Name: "label", Type: field.TypeString, Nullable: true},
{Name: "des", Type: field.TypeString, Nullable: true},
{Name: "name_i18n", Type: field.TypeJSON, Nullable: true},
{Name: "des_i18n", Type: field.TypeJSON, Nullable: true},
{Name: "enabled", Type: field.TypeBool, Default: true},
{Name: "weight", Type: field.TypeInt, Default: 0},
}

@ -19671,6 +19671,8 @@ type SkuMutation struct {
addpoints *int64
label *string
des *string
name_i18n *map[string]string
des_i18n *map[string]string
enabled *bool
weight *int
addweight *int
@ -20335,6 +20337,104 @@ func (m *SkuMutation) ResetDes() {
delete(m.clearedFields, sku.FieldDes)
}
// SetNameI18n sets the "name_i18n" field.
func (m *SkuMutation) SetNameI18n(value map[string]string) {
m.name_i18n = &value
}
// NameI18n returns the value of the "name_i18n" field in the mutation.
func (m *SkuMutation) NameI18n() (r map[string]string, exists bool) {
v := m.name_i18n
if v == nil {
return
}
return *v, true
}
// OldNameI18n returns the old "name_i18n" field's value of the Sku entity.
// If the Sku object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *SkuMutation) OldNameI18n(ctx context.Context) (v map[string]string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldNameI18n is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldNameI18n requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldNameI18n: %w", err)
}
return oldValue.NameI18n, nil
}
// ClearNameI18n clears the value of the "name_i18n" field.
func (m *SkuMutation) ClearNameI18n() {
m.name_i18n = nil
m.clearedFields[sku.FieldNameI18n] = struct{}{}
}
// NameI18nCleared returns if the "name_i18n" field was cleared in this mutation.
func (m *SkuMutation) NameI18nCleared() bool {
_, ok := m.clearedFields[sku.FieldNameI18n]
return ok
}
// ResetNameI18n resets all changes to the "name_i18n" field.
func (m *SkuMutation) ResetNameI18n() {
m.name_i18n = nil
delete(m.clearedFields, sku.FieldNameI18n)
}
// SetDesI18n sets the "des_i18n" field.
func (m *SkuMutation) SetDesI18n(value map[string]string) {
m.des_i18n = &value
}
// DesI18n returns the value of the "des_i18n" field in the mutation.
func (m *SkuMutation) DesI18n() (r map[string]string, exists bool) {
v := m.des_i18n
if v == nil {
return
}
return *v, true
}
// OldDesI18n returns the old "des_i18n" field's value of the Sku entity.
// If the Sku object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *SkuMutation) OldDesI18n(ctx context.Context) (v map[string]string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldDesI18n is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldDesI18n requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldDesI18n: %w", err)
}
return oldValue.DesI18n, nil
}
// ClearDesI18n clears the value of the "des_i18n" field.
func (m *SkuMutation) ClearDesI18n() {
m.des_i18n = nil
m.clearedFields[sku.FieldDesI18n] = struct{}{}
}
// DesI18nCleared returns if the "des_i18n" field was cleared in this mutation.
func (m *SkuMutation) DesI18nCleared() bool {
_, ok := m.clearedFields[sku.FieldDesI18n]
return ok
}
// ResetDesI18n resets all changes to the "des_i18n" field.
func (m *SkuMutation) ResetDesI18n() {
m.des_i18n = nil
delete(m.clearedFields, sku.FieldDesI18n)
}
// SetEnabled sets the "enabled" field.
func (m *SkuMutation) SetEnabled(b bool) {
m.enabled = &b
@ -20461,7 +20561,7 @@ func (m *SkuMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *SkuMutation) Fields() []string {
fields := make([]string, 0, 13)
fields := make([]string, 0, 15)
if m.created_at != nil {
fields = append(fields, sku.FieldCreatedAt)
}
@ -20495,6 +20595,12 @@ func (m *SkuMutation) Fields() []string {
if m.des != nil {
fields = append(fields, sku.FieldDes)
}
if m.name_i18n != nil {
fields = append(fields, sku.FieldNameI18n)
}
if m.des_i18n != nil {
fields = append(fields, sku.FieldDesI18n)
}
if m.enabled != nil {
fields = append(fields, sku.FieldEnabled)
}
@ -20531,6 +20637,10 @@ func (m *SkuMutation) Field(name string) (ent.Value, bool) {
return m.Label()
case sku.FieldDes:
return m.Des()
case sku.FieldNameI18n:
return m.NameI18n()
case sku.FieldDesI18n:
return m.DesI18n()
case sku.FieldEnabled:
return m.Enabled()
case sku.FieldWeight:
@ -20566,6 +20676,10 @@ func (m *SkuMutation) OldField(ctx context.Context, name string) (ent.Value, err
return m.OldLabel(ctx)
case sku.FieldDes:
return m.OldDes(ctx)
case sku.FieldNameI18n:
return m.OldNameI18n(ctx)
case sku.FieldDesI18n:
return m.OldDesI18n(ctx)
case sku.FieldEnabled:
return m.OldEnabled(ctx)
case sku.FieldWeight:
@ -20656,6 +20770,20 @@ func (m *SkuMutation) SetField(name string, value ent.Value) error {
}
m.SetDes(v)
return nil
case sku.FieldNameI18n:
v, ok := value.(map[string]string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetNameI18n(v)
return nil
case sku.FieldDesI18n:
v, ok := value.(map[string]string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetDesI18n(v)
return nil
case sku.FieldEnabled:
v, ok := value.(bool)
if !ok {
@ -20781,6 +20909,12 @@ func (m *SkuMutation) ClearedFields() []string {
if m.FieldCleared(sku.FieldDes) {
fields = append(fields, sku.FieldDes)
}
if m.FieldCleared(sku.FieldNameI18n) {
fields = append(fields, sku.FieldNameI18n)
}
if m.FieldCleared(sku.FieldDesI18n) {
fields = append(fields, sku.FieldDesI18n)
}
return fields
}
@ -20813,6 +20947,12 @@ func (m *SkuMutation) ClearField(name string) error {
case sku.FieldDes:
m.ClearDes()
return nil
case sku.FieldNameI18n:
m.ClearNameI18n()
return nil
case sku.FieldDesI18n:
m.ClearDesI18n()
return nil
}
return fmt.Errorf("unknown Sku nullable field %s", name)
}
@ -20854,6 +20994,12 @@ func (m *SkuMutation) ResetField(name string) error {
case sku.FieldDes:
m.ResetDes()
return nil
case sku.FieldNameI18n:
m.ResetNameI18n()
return nil
case sku.FieldDesI18n:
m.ResetDesI18n()
return nil
case sku.FieldEnabled:
m.ResetEnabled()
return nil

@ -542,11 +542,11 @@ func init() {
// sku.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field.
sku.UpdateDefaultUpdatedAt = skuDescUpdatedAt.UpdateDefault.(func() time.Time)
// skuDescEnabled is the schema descriptor for enabled field.
skuDescEnabled := skuFields[8].Descriptor()
skuDescEnabled := skuFields[10].Descriptor()
// sku.DefaultEnabled holds the default value on creation for the enabled field.
sku.DefaultEnabled = skuDescEnabled.Default.(bool)
// skuDescWeight is the schema descriptor for weight field.
skuDescWeight := skuFields[9].Descriptor()
skuDescWeight := skuFields[11].Descriptor()
// sku.DefaultWeight holds the default value on creation for the weight field.
sku.DefaultWeight = skuDescWeight.Default.(int)
ssobindingMixin := schema.SsoBinding{}.Mixin()

@ -33,6 +33,10 @@ func (Sku) Fields() []ent.Field {
Optional(),
field.String("des").
Optional(),
field.JSON("name_i18n", map[string]string{}).
Optional(),
field.JSON("des_i18n", map[string]string{}).
Optional(),
field.Bool("enabled").
Default(true),
field.Int("weight").

@ -3,6 +3,7 @@
package ent
import (
"encoding/json"
"fmt"
"strings"
"time"
@ -39,6 +40,10 @@ type Sku struct {
Label string `json:"label,omitempty"`
// Des holds the value of the "des" field.
Des string `json:"des,omitempty"`
// NameI18n holds the value of the "name_i18n" field.
NameI18n map[string]string `json:"name_i18n,omitempty"`
// DesI18n holds the value of the "des_i18n" field.
DesI18n map[string]string `json:"des_i18n,omitempty"`
// Enabled holds the value of the "enabled" field.
Enabled bool `json:"enabled,omitempty"`
// Weight holds the value of the "weight" field.
@ -51,6 +56,8 @@ func (*Sku) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
for i := range columns {
switch columns[i] {
case sku.FieldNameI18n, sku.FieldDesI18n:
values[i] = new([]byte)
case sku.FieldEnabled:
values[i] = new(sql.NullBool)
case sku.FieldID, sku.FieldAmount, sku.FieldDuration, sku.FieldPrice, sku.FieldPoints, sku.FieldWeight:
@ -148,6 +155,22 @@ func (s *Sku) assignValues(columns []string, values []any) error {
} else if value.Valid {
s.Des = value.String
}
case sku.FieldNameI18n:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field name_i18n", values[i])
} else if value != nil && len(*value) > 0 {
if err := json.Unmarshal(*value, &s.NameI18n); err != nil {
return fmt.Errorf("unmarshal field name_i18n: %w", err)
}
}
case sku.FieldDesI18n:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field des_i18n", values[i])
} else if value != nil && len(*value) > 0 {
if err := json.Unmarshal(*value, &s.DesI18n); err != nil {
return fmt.Errorf("unmarshal field des_i18n: %w", err)
}
}
case sku.FieldEnabled:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field enabled", values[i])
@ -233,6 +256,12 @@ func (s *Sku) String() string {
builder.WriteString("des=")
builder.WriteString(s.Des)
builder.WriteString(", ")
builder.WriteString("name_i18n=")
builder.WriteString(fmt.Sprintf("%v", s.NameI18n))
builder.WriteString(", ")
builder.WriteString("des_i18n=")
builder.WriteString(fmt.Sprintf("%v", s.DesI18n))
builder.WriteString(", ")
builder.WriteString("enabled=")
builder.WriteString(fmt.Sprintf("%v", s.Enabled))
builder.WriteString(", ")

@ -37,6 +37,10 @@ const (
FieldLabel = "label"
// FieldDes holds the string denoting the des field in the database.
FieldDes = "des"
// FieldNameI18n holds the string denoting the name_i18n field in the database.
FieldNameI18n = "name_i18n"
// FieldDesI18n holds the string denoting the des_i18n field in the database.
FieldDesI18n = "des_i18n"
// FieldEnabled holds the string denoting the enabled field in the database.
FieldEnabled = "enabled"
// FieldWeight holds the string denoting the weight field in the database.
@ -59,6 +63,8 @@ var Columns = []string{
FieldPoints,
FieldLabel,
FieldDes,
FieldNameI18n,
FieldDesI18n,
FieldEnabled,
FieldWeight,
}

@ -664,6 +664,26 @@ func DesContainsFold(v string) predicate.Sku {
return predicate.Sku(sql.FieldContainsFold(FieldDes, v))
}
// NameI18nIsNil applies the IsNil predicate on the "name_i18n" field.
func NameI18nIsNil() predicate.Sku {
return predicate.Sku(sql.FieldIsNull(FieldNameI18n))
}
// NameI18nNotNil applies the NotNil predicate on the "name_i18n" field.
func NameI18nNotNil() predicate.Sku {
return predicate.Sku(sql.FieldNotNull(FieldNameI18n))
}
// DesI18nIsNil applies the IsNil predicate on the "des_i18n" field.
func DesI18nIsNil() predicate.Sku {
return predicate.Sku(sql.FieldIsNull(FieldDesI18n))
}
// DesI18nNotNil applies the NotNil predicate on the "des_i18n" field.
func DesI18nNotNil() predicate.Sku {
return predicate.Sku(sql.FieldNotNull(FieldDesI18n))
}
// EnabledEQ applies the EQ predicate on the "enabled" field.
func EnabledEQ(v bool) predicate.Sku {
return predicate.Sku(sql.FieldEQ(FieldEnabled, v))

@ -152,6 +152,18 @@ func (sc *SkuCreate) SetNillableDes(s *string) *SkuCreate {
return sc
}
// SetNameI18n sets the "name_i18n" field.
func (sc *SkuCreate) SetNameI18n(m map[string]string) *SkuCreate {
sc.mutation.SetNameI18n(m)
return sc
}
// SetDesI18n sets the "des_i18n" field.
func (sc *SkuCreate) SetDesI18n(m map[string]string) *SkuCreate {
sc.mutation.SetDesI18n(m)
return sc
}
// SetEnabled sets the "enabled" field.
func (sc *SkuCreate) SetEnabled(b bool) *SkuCreate {
sc.mutation.SetEnabled(b)
@ -348,6 +360,14 @@ func (sc *SkuCreate) createSpec() (*Sku, *sqlgraph.CreateSpec) {
_spec.SetField(sku.FieldDes, field.TypeString, value)
_node.Des = value
}
if value, ok := sc.mutation.NameI18n(); ok {
_spec.SetField(sku.FieldNameI18n, field.TypeJSON, value)
_node.NameI18n = value
}
if value, ok := sc.mutation.DesI18n(); ok {
_spec.SetField(sku.FieldDesI18n, field.TypeJSON, value)
_node.DesI18n = value
}
if value, ok := sc.mutation.Enabled(); ok {
_spec.SetField(sku.FieldEnabled, field.TypeBool, value)
_node.Enabled = value
@ -588,6 +608,42 @@ func (u *SkuUpsert) ClearDes() *SkuUpsert {
return u
}
// SetNameI18n sets the "name_i18n" field.
func (u *SkuUpsert) SetNameI18n(v map[string]string) *SkuUpsert {
u.Set(sku.FieldNameI18n, v)
return u
}
// UpdateNameI18n sets the "name_i18n" field to the value that was provided on create.
func (u *SkuUpsert) UpdateNameI18n() *SkuUpsert {
u.SetExcluded(sku.FieldNameI18n)
return u
}
// ClearNameI18n clears the value of the "name_i18n" field.
func (u *SkuUpsert) ClearNameI18n() *SkuUpsert {
u.SetNull(sku.FieldNameI18n)
return u
}
// SetDesI18n sets the "des_i18n" field.
func (u *SkuUpsert) SetDesI18n(v map[string]string) *SkuUpsert {
u.Set(sku.FieldDesI18n, v)
return u
}
// UpdateDesI18n sets the "des_i18n" field to the value that was provided on create.
func (u *SkuUpsert) UpdateDesI18n() *SkuUpsert {
u.SetExcluded(sku.FieldDesI18n)
return u
}
// ClearDesI18n clears the value of the "des_i18n" field.
func (u *SkuUpsert) ClearDesI18n() *SkuUpsert {
u.SetNull(sku.FieldDesI18n)
return u
}
// SetEnabled sets the "enabled" field.
func (u *SkuUpsert) SetEnabled(v bool) *SkuUpsert {
u.Set(sku.FieldEnabled, v)
@ -873,6 +929,48 @@ func (u *SkuUpsertOne) ClearDes() *SkuUpsertOne {
})
}
// SetNameI18n sets the "name_i18n" field.
func (u *SkuUpsertOne) SetNameI18n(v map[string]string) *SkuUpsertOne {
return u.Update(func(s *SkuUpsert) {
s.SetNameI18n(v)
})
}
// UpdateNameI18n sets the "name_i18n" field to the value that was provided on create.
func (u *SkuUpsertOne) UpdateNameI18n() *SkuUpsertOne {
return u.Update(func(s *SkuUpsert) {
s.UpdateNameI18n()
})
}
// ClearNameI18n clears the value of the "name_i18n" field.
func (u *SkuUpsertOne) ClearNameI18n() *SkuUpsertOne {
return u.Update(func(s *SkuUpsert) {
s.ClearNameI18n()
})
}
// SetDesI18n sets the "des_i18n" field.
func (u *SkuUpsertOne) SetDesI18n(v map[string]string) *SkuUpsertOne {
return u.Update(func(s *SkuUpsert) {
s.SetDesI18n(v)
})
}
// UpdateDesI18n sets the "des_i18n" field to the value that was provided on create.
func (u *SkuUpsertOne) UpdateDesI18n() *SkuUpsertOne {
return u.Update(func(s *SkuUpsert) {
s.UpdateDesI18n()
})
}
// ClearDesI18n clears the value of the "des_i18n" field.
func (u *SkuUpsertOne) ClearDesI18n() *SkuUpsertOne {
return u.Update(func(s *SkuUpsert) {
s.ClearDesI18n()
})
}
// SetEnabled sets the "enabled" field.
func (u *SkuUpsertOne) SetEnabled(v bool) *SkuUpsertOne {
return u.Update(func(s *SkuUpsert) {
@ -1334,6 +1432,48 @@ func (u *SkuUpsertBulk) ClearDes() *SkuUpsertBulk {
})
}
// SetNameI18n sets the "name_i18n" field.
func (u *SkuUpsertBulk) SetNameI18n(v map[string]string) *SkuUpsertBulk {
return u.Update(func(s *SkuUpsert) {
s.SetNameI18n(v)
})
}
// UpdateNameI18n sets the "name_i18n" field to the value that was provided on create.
func (u *SkuUpsertBulk) UpdateNameI18n() *SkuUpsertBulk {
return u.Update(func(s *SkuUpsert) {
s.UpdateNameI18n()
})
}
// ClearNameI18n clears the value of the "name_i18n" field.
func (u *SkuUpsertBulk) ClearNameI18n() *SkuUpsertBulk {
return u.Update(func(s *SkuUpsert) {
s.ClearNameI18n()
})
}
// SetDesI18n sets the "des_i18n" field.
func (u *SkuUpsertBulk) SetDesI18n(v map[string]string) *SkuUpsertBulk {
return u.Update(func(s *SkuUpsert) {
s.SetDesI18n(v)
})
}
// UpdateDesI18n sets the "des_i18n" field to the value that was provided on create.
func (u *SkuUpsertBulk) UpdateDesI18n() *SkuUpsertBulk {
return u.Update(func(s *SkuUpsert) {
s.UpdateDesI18n()
})
}
// ClearDesI18n clears the value of the "des_i18n" field.
func (u *SkuUpsertBulk) ClearDesI18n() *SkuUpsertBulk {
return u.Update(func(s *SkuUpsert) {
s.ClearDesI18n()
})
}
// SetEnabled sets the "enabled" field.
func (u *SkuUpsertBulk) SetEnabled(v bool) *SkuUpsertBulk {
return u.Update(func(s *SkuUpsert) {

@ -224,6 +224,30 @@ func (su *SkuUpdate) ClearDes() *SkuUpdate {
return su
}
// SetNameI18n sets the "name_i18n" field.
func (su *SkuUpdate) SetNameI18n(m map[string]string) *SkuUpdate {
su.mutation.SetNameI18n(m)
return su
}
// ClearNameI18n clears the value of the "name_i18n" field.
func (su *SkuUpdate) ClearNameI18n() *SkuUpdate {
su.mutation.ClearNameI18n()
return su
}
// SetDesI18n sets the "des_i18n" field.
func (su *SkuUpdate) SetDesI18n(m map[string]string) *SkuUpdate {
su.mutation.SetDesI18n(m)
return su
}
// ClearDesI18n clears the value of the "des_i18n" field.
func (su *SkuUpdate) ClearDesI18n() *SkuUpdate {
su.mutation.ClearDesI18n()
return su
}
// SetEnabled sets the "enabled" field.
func (su *SkuUpdate) SetEnabled(b bool) *SkuUpdate {
su.mutation.SetEnabled(b)
@ -388,6 +412,18 @@ func (su *SkuUpdate) sqlSave(ctx context.Context) (n int, err error) {
if su.mutation.DesCleared() {
_spec.ClearField(sku.FieldDes, field.TypeString)
}
if value, ok := su.mutation.NameI18n(); ok {
_spec.SetField(sku.FieldNameI18n, field.TypeJSON, value)
}
if su.mutation.NameI18nCleared() {
_spec.ClearField(sku.FieldNameI18n, field.TypeJSON)
}
if value, ok := su.mutation.DesI18n(); ok {
_spec.SetField(sku.FieldDesI18n, field.TypeJSON, value)
}
if su.mutation.DesI18nCleared() {
_spec.ClearField(sku.FieldDesI18n, field.TypeJSON)
}
if value, ok := su.mutation.Enabled(); ok {
_spec.SetField(sku.FieldEnabled, field.TypeBool, value)
}
@ -613,6 +649,30 @@ func (suo *SkuUpdateOne) ClearDes() *SkuUpdateOne {
return suo
}
// SetNameI18n sets the "name_i18n" field.
func (suo *SkuUpdateOne) SetNameI18n(m map[string]string) *SkuUpdateOne {
suo.mutation.SetNameI18n(m)
return suo
}
// ClearNameI18n clears the value of the "name_i18n" field.
func (suo *SkuUpdateOne) ClearNameI18n() *SkuUpdateOne {
suo.mutation.ClearNameI18n()
return suo
}
// SetDesI18n sets the "des_i18n" field.
func (suo *SkuUpdateOne) SetDesI18n(m map[string]string) *SkuUpdateOne {
suo.mutation.SetDesI18n(m)
return suo
}
// ClearDesI18n clears the value of the "des_i18n" field.
func (suo *SkuUpdateOne) ClearDesI18n() *SkuUpdateOne {
suo.mutation.ClearDesI18n()
return suo
}
// SetEnabled sets the "enabled" field.
func (suo *SkuUpdateOne) SetEnabled(b bool) *SkuUpdateOne {
suo.mutation.SetEnabled(b)
@ -807,6 +867,18 @@ func (suo *SkuUpdateOne) sqlSave(ctx context.Context) (_node *Sku, err error) {
if suo.mutation.DesCleared() {
_spec.ClearField(sku.FieldDes, field.TypeString)
}
if value, ok := suo.mutation.NameI18n(); ok {
_spec.SetField(sku.FieldNameI18n, field.TypeJSON, value)
}
if suo.mutation.NameI18nCleared() {
_spec.ClearField(sku.FieldNameI18n, field.TypeJSON)
}
if value, ok := suo.mutation.DesI18n(); ok {
_spec.SetField(sku.FieldDesI18n, field.TypeJSON, value)
}
if suo.mutation.DesI18nCleared() {
_spec.ClearField(sku.FieldDesI18n, field.TypeJSON)
}
if value, ok := suo.mutation.Enabled(); ok {
_spec.SetField(sku.FieldEnabled, field.TypeBool, value)
}

@ -388,6 +388,8 @@
"customFooterHTMLDes": "Custom HTML code inserted at the bottom of the page.",
"announcement": "Announcement",
"announcementDes": "Announcements displayed to logged-in users. Blank value will not be displayed. After this content is changed, all users will see the announcement again.",
"translations": "Translations",
"translationsDes": "Per-language overrides. Leave blank to fall back to the default value. Users see the variant matching their language setting or browser language.",
"supportHTML": "Enter HTML or plain text.",
"branding": "Branding",
"smallIcon": "Small icon",

@ -388,6 +388,8 @@
"customFooterHTMLDes": "在页面底部插入的自定义 HTML 代码。",
"announcement": "站点公告",
"announcementDes": "展示给已登录用户的公告,留空不展示。当此项内容更改时,所有用户会重新看到公告。",
"translations": "多语言版本",
"translationsDes": "按语言填写不同版本,留空则回退到默认内容。用户将看到与其语言设置或浏览器语言匹配的版本。",
"supportHTML": "支持 HTML 代码。",
"branding": "图标",
"smallIcon": "小图标",

@ -670,6 +670,8 @@ export interface Sku {
points?: number;
label?: string;
des?: string;
name_i18n?: { [lang: string]: string };
des_i18n?: { [lang: string]: string };
enabled: boolean;
weight: number;
created_at?: string;

@ -0,0 +1,81 @@
import { Accordion, AccordionDetails, AccordionSummary, Stack, Typography } from "@mui/material";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { languages } from "../../../i18n.ts";
import { DenseFilledTextField } from "../../Common/StyledComponents.tsx";
import CaretDown from "../../Icons/CaretDown.tsx";
export interface LocalizedFieldsProps {
// JSON-encoded { "<lang>": "text" } map; "" or "{}" means no overrides.
value?: string;
onChange: (json: string) => void;
multiline?: boolean;
rows?: number;
}
// LocalizedFields edits the "<key>_i18n" sibling map of a base setting: one
// input per supported language, empty inputs are dropped from the map so the
// base value stays the fallback.
const LocalizedFields = ({ value, onChange, multiline, rows }: LocalizedFieldsProps) => {
const { t } = useTranslation("dashboard");
const map = useMemo(() => {
if (!value) {
return {} as { [k: string]: string };
}
try {
const parsed = JSON.parse(value);
return typeof parsed === "object" && parsed !== null ? (parsed as { [k: string]: string }) : {};
} catch {
return {};
}
}, [value]);
const setLang = (code: string, text: string) => {
const next = { ...map, [code]: text };
if (!text) {
delete next[code];
}
onChange(JSON.stringify(next));
};
return (
<Accordion
disableGutters
elevation={0}
sx={{
mt: 1,
"&:before": { display: "none" },
border: (theme) => `1px dashed ${theme.palette.divider}`,
borderRadius: 1,
}}
>
<AccordionSummary expandIcon={<CaretDown />} sx={{ minHeight: 40 }}>
<Typography variant="body2" color="text.secondary">
{t("settings.translations")}
</Typography>
</AccordionSummary>
<AccordionDetails>
<Stack spacing={1.5}>
{languages.map((l) => (
<DenseFilledTextField
key={l.code}
fullWidth
size="small"
label={l.displayName}
value={map[l.code] ?? ""}
multiline={multiline}
rows={multiline ? rows : undefined}
onChange={(e) => setLang(l.code, e.target.value)}
/>
))}
<Typography variant="caption" color="text.secondary">
{t("settings.translationsDes")}
</Typography>
</Stack>
</AccordionDetails>
</Accordion>
);
};
export default LocalizedFields;

@ -162,11 +162,15 @@ const Settings = () => {
<SettingsWrapper
settings={[
"siteName",
"siteName_i18n",
"siteDes",
"siteDes_i18n",
"siteURL",
"download_cdn_routes",
"download_cdn_shuffle",
"siteScript",
"announcement",
"announcement_i18n",
"pwa_small_icon",
"pwa_medium_icon",
"pwa_large_icon",

@ -7,6 +7,7 @@ import { DenseFilledTextField } from "../../../Common/StyledComponents.tsx";
import SettingForm from "../../../Pages/Setting/SettingForm.tsx";
import { NoMarginHelperText, SettingSection, SettingSectionContent, StyledInputAdornment } from "../Settings.tsx";
import { SettingContext } from "../SettingWrapper.tsx";
import LocalizedFields from "../LocalizedFields.tsx";
import GeneralImagePreview from "./GeneralImagePreview.tsx";
import LogoPreview from "./LogoPreview.tsx";
import SiteURLInput from "./SiteURLInput.tsx";
@ -32,6 +33,10 @@ const SiteInformation = () => {
required
inputProps={{ maxLength: 255 }}
/>
<LocalizedFields
value={values.siteName_i18n}
onChange={(v) => setSettings({ siteName_i18n: v })}
/>
<NoMarginHelperText>{t("settings.mainTitleDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
@ -44,6 +49,12 @@ const SiteInformation = () => {
multiline
rows={4}
/>
<LocalizedFields
multiline
rows={2}
value={values.siteDes_i18n}
onChange={(v) => setSettings({ siteDes_i18n: v })}
/>
<NoMarginHelperText>{t("settings.siteDescriptionDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
@ -104,6 +115,12 @@ const SiteInformation = () => {
onChange={(e) => setSettings({ announcement: e.target.value })}
value={values.announcement}
/>
<LocalizedFields
multiline
rows={2}
value={values.announcement_i18n}
onChange={(v) => setSettings({ announcement_i18n: v })}
/>
<NoMarginHelperText>{t("settings.announcementDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>

@ -36,6 +36,7 @@ import Add from "../../../Icons/Add.tsx";
import Dismiss from "../../../Icons/Dismiss.tsx";
import Edit from "../../../Icons/Edit.tsx";
import SettingForm from "../../../Pages/Setting/SettingForm.tsx";
import LocalizedFields from "../LocalizedFields.tsx";
import { NoMarginHelperText } from "../Settings.tsx";
const DAY_SECONDS = 86400;
@ -54,6 +55,8 @@ interface SkuForm {
points: number;
label: string;
des: string;
nameI18n: { [lang: string]: string };
desI18n: { [lang: string]: string };
enabled: boolean;
}
@ -67,9 +70,13 @@ const emptyForm = (type: string): SkuForm => ({
points: 0,
label: "",
des: "",
nameI18n: {},
desI18n: {},
enabled: true,
});
const nonEmpty = (m: { [lang: string]: string }) => (Object.keys(m).length > 0 ? m : undefined);
const SkuTable = ({ type }: SkuTableProps) => {
const { t } = useTranslation("dashboard");
const dispatch = useAppDispatch();
@ -109,6 +116,8 @@ const SkuTable = ({ type }: SkuTableProps) => {
points: s.points ?? 0,
label: s.label ?? "",
des: s.des ?? "",
nameI18n: s.name_i18n ?? {},
desI18n: s.des_i18n ?? {},
enabled: s.enabled,
});
};
@ -129,6 +138,8 @@ const SkuTable = ({ type }: SkuTableProps) => {
points: form.allowPoints ? Math.max(1, form.points) : undefined,
label: form.label || undefined,
des: form.des || undefined,
name_i18n: nonEmpty(form.nameI18n),
des_i18n: nonEmpty(form.desI18n),
enabled: form.enabled,
weight: 0,
}),
@ -226,6 +237,10 @@ const SkuTable = ({ type }: SkuTableProps) => {
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
<NoMarginHelperText>{t("vas.productNameDes")}</NoMarginHelperText>
<LocalizedFields
value={JSON.stringify(form.nameI18n)}
onChange={(v) => setForm({ ...form, nameI18n: JSON.parse(v) })}
/>
</SettingForm>
{type === "storage" ? (
@ -322,6 +337,12 @@ const SkuTable = ({ type }: SkuTableProps) => {
onChange={(e) => setForm({ ...form, des: e.target.value })}
/>
<NoMarginHelperText>{t("vas.productDescriptionDes")}</NoMarginHelperText>
<LocalizedFields
multiline
rows={2}
value={JSON.stringify(form.desI18n)}
onChange={(v) => setForm({ ...form, desI18n: JSON.parse(v) })}
/>
</SettingForm>
<SettingForm lgWidth={5}>

@ -381,6 +381,8 @@ func (c *vasClient) UpsertSku(ctx context.Context, s *ent.Sku) (*ent.Sku, error)
SetNillablePoints(s.Points).
SetLabel(s.Label).
SetDes(s.Des).
SetNameI18n(s.NameI18n).
SetDesI18n(s.DesI18n).
SetEnabled(s.Enabled).
SetWeight(s.Weight).
Save(ctx)
@ -394,6 +396,8 @@ func (c *vasClient) UpsertSku(ctx context.Context, s *ent.Sku) (*ent.Sku, error)
SetNillablePoints(s.Points).
SetLabel(s.Label).
SetDes(s.Des).
SetNameI18n(s.NameI18n).
SetDesI18n(s.DesI18n).
SetEnabled(s.Enabled).
SetWeight(s.Weight).
Save(ctx)

@ -2,6 +2,7 @@ package middleware
import (
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gin-gonic/gin"
"io"
@ -54,7 +55,7 @@ func FrontendFileHandler(dep dependency.Dep) gin.HandlerFunc {
if (path == "/index.html") || (path == "/") || !fs.Exists("/", path) {
// 读取、替换站点设置
settingClient := dep.SettingProvider()
siteBasic := settingClient.SiteBasic(c)
siteBasic := settingClient.SiteBasicLocalized(c, setting.ParseAcceptLanguage(c.GetHeader("Accept-Language")))
pwaOpts := settingClient.PWA(c)
theme := settingClient.Theme(c)
finalHTML := util.Replace(map[string]string{

@ -15,6 +15,7 @@ import (
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/cloudreve/Cloudreve/v4/service/explorer"
"github.com/cloudreve/Cloudreve/v4/service/share"
@ -128,7 +129,7 @@ func extractShareParams(c *gin.Context) (id, password string) {
func renderShareOGPage(c *gin.Context, dep dependency.Dep, id, password string) string {
settings := dep.SettingProvider()
siteBasic := settings.SiteBasic(c)
siteBasic := settings.SiteBasicLocalized(c, setting.ParseAcceptLanguage(c.GetHeader("Accept-Language")))
pwa := settings.PWA(c)
base := settings.SiteURL(c)

@ -33,7 +33,7 @@ func NewResetEmail(ctx context.Context, settings setting.Provider, user *ent.Use
selected := selectTemplate(templates, user)
resetCtx := ResetContext{
CommonContext: commonContext(ctx, settings),
CommonContext: commonContext(ctx, settings, user),
User: user,
Url: url,
}
@ -79,7 +79,7 @@ func NewActivationEmail(ctx context.Context, settings setting.Provider, user *en
selected := selectTemplate(templates, user)
activationCtx := ActivationContext{
CommonContext: commonContext(ctx, settings),
CommonContext: commonContext(ctx, settings, user),
User: user,
Url: url,
}
@ -109,7 +109,7 @@ func NewActivationEmail(ctx context.Context, settings setting.Provider, user *en
return resTitle.String(), resBody.String(), nil
}
func commonContext(ctx context.Context, settings setting.Provider) *CommonContext {
func commonContext(ctx context.Context, settings setting.Provider, user *ent.User) *CommonContext {
logo := settings.Logo(ctx)
siteUrl := settings.SiteURL(ctx)
resolvedLogo := *logo
@ -125,8 +125,13 @@ func commonContext(ctx context.Context, settings setting.Provider) *CommonContex
resolvedLogo.Normal = siteUrl.ResolveReference(logoPath).String()
}
lang := ""
if user != nil {
lang = user.Settings.Language
}
return &CommonContext{
SiteBasic: settings.SiteBasic(ctx),
SiteBasic: settings.SiteBasicLocalized(ctx, lang),
Logo: &resolvedLogo,
SiteUrl: siteUrl.String(),
}

@ -22,6 +22,14 @@ type (
Provider interface {
// Site basic information
SiteBasic(ctx context.Context) *SiteBasic
// SiteBasicLocalized returns site basic info with Name, Title and
// Description resolved for the given language tag via sibling
// "<key>_i18n" settings.
SiteBasicLocalized(ctx context.Context, lang string) *SiteBasic
// Localized returns the value of a setting key translated for lang.
// A sibling "<key>_i18n" JSON object (language tag -> text) is
// consulted first; falls back to the plain "<key>" value.
Localized(ctx context.Context, key, lang string) string
// PWA related settings
PWA(ctx context.Context) *PWASetting
// RegisterEnabled returns true if public sign-up is enabled.
@ -1114,15 +1122,91 @@ func (s *settingProvider) ShareDefaults(ctx context.Context) *ShareDefaults {
}
func (s *settingProvider) SiteBasic(ctx context.Context) *SiteBasic {
return s.SiteBasicLocalized(ctx, "")
}
func (s *settingProvider) SiteBasicLocalized(ctx context.Context, lang string) *SiteBasic {
return &SiteBasic{
Name: s.getString(ctx, "siteName", ""),
Title: s.getString(ctx, "siteTitle", ""),
Name: s.Localized(ctx, "siteName", lang),
Title: s.Localized(ctx, "siteTitle", lang),
ID: s.getString(ctx, "siteID", ""),
Description: s.getString(ctx, "siteDes", ""),
Description: s.Localized(ctx, "siteDes", lang),
Script: s.getString(ctx, "siteScript", ""),
}
}
func (s *settingProvider) Localized(ctx context.Context, key, lang string) string {
base := s.getString(ctx, key, "")
if lang == "" {
return base
}
raw := s.getString(ctx, key+"_i18n", "")
if raw == "" {
return base
}
var translations map[string]string
if err := json.Unmarshal([]byte(raw), &translations); err != nil || len(translations) == 0 {
return base
}
return MatchLanguage(translations, lang, base)
}
// MatchLanguage picks the best translation for lang from translations.
// Exact tag match wins, then a bare primary subtag ("zh" for "zh-CN"), then
// any entry sharing the primary subtag (for bare requests like "zh"), then
// the "*" wildcard entry; fallback is returned otherwise.
func MatchLanguage(translations map[string]string, lang, fallback string) string {
if lang == "" || len(translations) == 0 {
return fallback
}
if v, ok := translations[lang]; ok && v != "" {
return v
}
primary := lang
if i := strings.IndexAny(lang, "-_"); i >= 0 {
primary = lang[:i]
if v, ok := translations[primary]; ok && v != "" {
return v
}
} else {
// Bare request ("zh") may match a regional entry ("zh-CN").
keys := make([]string, 0, len(translations))
for k := range translations {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if strings.IndexAny(k, "-_") > 0 && k[:strings.IndexAny(k, "-_")] == primary && translations[k] != "" {
return translations[k]
}
}
}
if v, ok := translations["*"]; ok && v != "" {
return v
}
return fallback
}
// ParseAcceptLanguage returns the first language tag of an Accept-Language
// header value, ignoring quality weights — browsers send the preferred tag
// first. "" when the header is empty or malformed.
func ParseAcceptLanguage(header string) string {
if header == "" {
return ""
}
first := strings.TrimSpace(strings.Split(header, ",")[0])
if i := strings.Index(first, ";"); i >= 0 {
first = strings.TrimSpace(first[:i])
}
return first
}
func (s *settingProvider) PWA(ctx context.Context) *PWASetting {
return &PWASetting{
SmallIcon: s.getString(ctx, "pwa_small_icon", ""),

@ -84,3 +84,53 @@ func TestDownloadURLBase(t *testing.T) {
require.Equal(t, "https://a.example.com", p.DownloadURLBase(ctx).String())
}
}
func TestLocalizedSetting(t *testing.T) {
ctx := context.Background()
p := NewProvider(stubAdapter{
"siteName": "Cloudreve",
"siteName_i18n": `{"zh-CN":"云盘","zh":"简中","*":"Intl"}`,
})
// Exact tag wins over the bare-subtag and wildcard entries.
require.Equal(t, "云盘", p.Localized(ctx, "siteName", "zh-CN"))
// Regional request falls back to the bare primary-subtag entry.
require.Equal(t, "简中", p.Localized(ctx, "siteName", "zh-TW"))
// Bare request may match a regional entry.
require.Equal(t, "简中", p.Localized(ctx, "siteName", "zh"))
// Wildcard entry covers unmatched languages.
require.Equal(t, "Intl", p.Localized(ctx, "siteName", "fr-FR"))
// Empty lang or missing map falls back to the base value.
require.Equal(t, "Cloudreve", p.Localized(ctx, "siteName", ""))
require.Equal(t, "", p.Localized(ctx, "other", "zh-CN"))
// Without a wildcard, unmatched languages get the base value.
p = NewProvider(stubAdapter{"siteName": "Cloudreve", "siteName_i18n": `{"zh-CN":"云盘"}`})
require.Equal(t, "Cloudreve", p.Localized(ctx, "siteName", "fr-FR"))
// Malformed i18n JSON is ignored.
p = NewProvider(stubAdapter{"siteName": "Cloudreve", "siteName_i18n": "{bad"})
require.Equal(t, "Cloudreve", p.Localized(ctx, "siteName", "zh-CN"))
}
func TestSiteBasicLocalized(t *testing.T) {
p := NewProvider(stubAdapter{
"siteName": "Cloudreve",
"siteName_i18n": `{"zh-CN":"云盘"}`,
"siteDes": "desc",
"siteDes_i18n": `{"zh-CN":"描述"}`,
})
b := p.SiteBasicLocalized(context.Background(), "zh-CN")
require.Equal(t, "云盘", b.Name)
require.Equal(t, "描述", b.Description)
b = p.SiteBasicLocalized(context.Background(), "")
require.Equal(t, "Cloudreve", b.Name)
require.Equal(t, "desc", b.Description)
}
func TestParseAcceptLanguage(t *testing.T) {
require.Equal(t, "zh-CN", ParseAcceptLanguage("zh-CN,zh;q=0.9,en;q=0.8"))
require.Equal(t, "en-US", ParseAcceptLanguage("en-US;q=0.7"))
require.Equal(t, "", ParseAcceptLanguage(""))
}

@ -235,7 +235,14 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) {
}
u := inventory.UserFromContext(c)
siteBasic := settings.SiteBasic(c)
lang := ""
if u != nil {
lang = u.Settings.Language
}
if lang == "" {
lang = setting.ParseAcceptLanguage(c.GetHeader("Accept-Language"))
}
siteBasic := settings.SiteBasicLocalized(c, lang)
themes := settings.Theme(c)
userRes := user.BuildUser(u, dep.HashIDEncoder())
logo := settings.Logo(c)

@ -358,7 +358,7 @@ func (service *WopiService) FileInfo(c *gin.Context) (*WopiFileInfo, error) {
info := &WopiFileInfo{
BaseFileName: file.DisplayName(),
Version: hashid.EncodeEntityID(hasher, targetEntity.ID()),
BreadcrumbBrandName: settings.SiteBasic(c).Name,
BreadcrumbBrandName: settings.SiteBasicLocalized(c, user.Settings.Language).Name,
BreadcrumbBrandUrl: siteUrl.String(),
FileSharingPostMessage: file.OwnerID() == user.ID,
EnableShare: file.OwnerID() == user.ID,

@ -317,7 +317,7 @@ func (s *AnnouncementService) Get(c *gin.Context) (*AnnouncementResponse, error)
dep := dependency.FromContext(c)
u := inventory.UserFromContext(c)
current := dep.SettingProvider().Announcement(c)
current := dep.SettingProvider().Localized(c, "announcement", u.Settings.Language)
if current == "" || current == u.Settings.DismissedAnnouncement {
return &AnnouncementResponse{}, nil
}

@ -12,6 +12,7 @@ import (
"github.com/cloudreve/Cloudreve/v4/pkg/activity"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/gin-gonic/gin"
)
@ -133,18 +134,26 @@ func (service *SkuListService) List(c *gin.Context) ([]*SkuResponse, error) {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to list products", err)
}
lang := ""
if u := inventory.UserFromContext(c); u != nil {
lang = u.Settings.Language
}
if lang == "" {
lang = setting.ParseAcceptLanguage(c.GetHeader("Accept-Language"))
}
res := make([]*SkuResponse, 0, len(skus))
for _, s := range skus {
r := &SkuResponse{
ID: hashid.EncodeSkuID(dep.HashIDEncoder(), s.ID),
Name: s.Name,
Name: setting.MatchLanguage(s.NameI18n, lang, s.Name),
Type: string(s.Type),
Amount: s.Amount,
Duration: s.Duration,
Price: s.Price,
Points: s.Points,
Label: s.Label,
Des: s.Des,
Des: setting.MatchLanguage(s.DesI18n, lang, s.Des),
}
if s.Type == sku.TypeGroup {
if g, err := dep.GroupClient().GetByID(c, int(s.Amount)); err == nil {

Loading…
Cancel
Save