release: prepare v1.2.34
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
- 手动备份与定时备份统一使用同一个恢复密码,避免每次创建备份时再次输入不同密码造成混淆。
|
||||||
|
- 已保存备份密码时,点击“创建备份”不再显示第二套密码输入框,直接使用系统安全保存的密码。
|
||||||
|
- 首次创建备份且尚未设置密码时,仍要求输入并二次确认;首次密码会保存为后续手动与定时备份的统一恢复密码。
|
||||||
|
- 定时备份页面精简为“恢复密码”摘要,仅显示首尾字符掩码,例如 `A••••••••9`;设置或更换密码时使用独立弹窗,不再挤占主页面。
|
||||||
|
- 密码更新使用独立接口,不会连带修改尚未保存的备份周期、Telegram 或 Google 云端硬盘设置。
|
||||||
|
- 页面只接收密码首尾掩码,不会返回完整恢复密码;更换密码时仍必须重新输入并确认。
|
||||||
|
- 增加统一密码、密码掩码、已保存密码手动备份及首次并发创建的后端保护与回归测试。
|
||||||
|
|
||||||
|
**完整更新日志**:[v1.2.33...v1.2.34](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.33...v1.2.34)
|
||||||
@@ -66,6 +66,7 @@ type backupSchedule struct {
|
|||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Days int `json:"days"`
|
Days int `json:"days"`
|
||||||
PasswordSet bool `json:"passwordSet"`
|
PasswordSet bool `json:"passwordSet"`
|
||||||
|
PasswordHint string `json:"passwordHint,omitempty"`
|
||||||
ServerIP string `json:"serverIp"`
|
ServerIP string `json:"serverIp"`
|
||||||
ChatID string `json:"chatId"`
|
ChatID string `json:"chatId"`
|
||||||
TelegramMode string `json:"telegramMode"`
|
TelegramMode string `json:"telegramMode"`
|
||||||
@@ -124,6 +125,11 @@ type updateBackupScheduleRequest struct {
|
|||||||
GoogleFolderName string `json:"googleFolderName"`
|
GoogleFolderName string `json:"googleFolderName"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type updateBackupPasswordRequest struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
ConfirmPassword string `json:"confirmPassword"`
|
||||||
|
}
|
||||||
|
|
||||||
type testBackupTelegramRequest struct {
|
type testBackupTelegramRequest struct {
|
||||||
Mode string `json:"mode"`
|
Mode string `json:"mode"`
|
||||||
ChatID string `json:"chatId"`
|
ChatID string `json:"chatId"`
|
||||||
@@ -192,27 +198,53 @@ func (a *App) handleCreateBackup(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !validBackupPassword(req.Password) {
|
a.backupMu.Lock()
|
||||||
badRequest(w, errors.New("备份密码至少需要 8 个字符"))
|
locked := true
|
||||||
|
defer func() {
|
||||||
|
if locked {
|
||||||
|
a.backupMu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if a.backupJob != nil && a.backupJob.Status == "running" {
|
||||||
|
respondError(w, http.StatusConflict, "已有备份任务正在运行")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Password != req.ConfirmPassword {
|
password, err := a.savedBackupPassword(r.Context())
|
||||||
badRequest(w, errors.New("两次输入的备份密码不一致"))
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
respondError(w, http.StatusInternalServerError, "无法读取已保存的备份密码")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if password == "" {
|
||||||
|
if !validBackupPassword(req.Password) {
|
||||||
|
badRequest(w, errors.New("首次创建备份时,密码至少需要 8 个字符"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Password != req.ConfirmPassword {
|
||||||
|
badRequest(w, errors.New("两次输入的备份密码不一致"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if !a.backupAssetsAvailable() {
|
if !a.backupAssetsAvailable() {
|
||||||
respondError(w, http.StatusServiceUnavailable, "当前部署尚未启用完整备份")
|
respondError(w, http.StatusServiceUnavailable, "当前部署尚未启用完整备份")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.backupMu.Lock()
|
if password == "" {
|
||||||
if a.backupJob != nil && a.backupJob.Status == "running" {
|
ciphertext, encryptErr := a.encryptBackupPassword(req.Password)
|
||||||
a.backupMu.Unlock()
|
if encryptErr != nil {
|
||||||
respondError(w, http.StatusConflict, "已有备份任务正在运行")
|
respondError(w, http.StatusInternalServerError, "无法安全保存备份密码")
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
|
if _, err = a.db.ExecContext(r.Context(), `INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`, ciphertext, now); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "无法保存备份密码")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
password = req.Password
|
||||||
}
|
}
|
||||||
a.backupJob = &backupJob{Status: "running", StartedAt: a.now().UTC()}
|
a.backupJob = &backupJob{Status: "running", StartedAt: a.now().UTC()}
|
||||||
a.backupMu.Unlock()
|
a.backupMu.Unlock()
|
||||||
password, sendTelegram, uploadGoogleDrive := req.Password, req.SendTelegram, req.UploadGoogleDrive
|
locked = false
|
||||||
|
sendTelegram, uploadGoogleDrive := req.SendTelegram, req.UploadGoogleDrive
|
||||||
go func() {
|
go func() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -351,13 +383,66 @@ func (a *App) handleUpdateBackupSettings(w http.ResponseWriter, r *http.Request)
|
|||||||
respondError(w, 500, "保存失败")
|
respondError(w, 500, "保存失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respondJSON(w, 200, backupSchedule{Enabled: req.Enabled, Days: req.Days, PasswordSet: ciphertext != "", ServerIP: detectPublicServerIP(r.Context(), a.config().PublicHostname), ChatID: chatID, TelegramMode: telegramMode, TelegramEnabled: req.TelegramEnabled, GoogleDriveEnabled: req.GoogleDriveEnabled})
|
passwordHint := ""
|
||||||
|
if password, err := a.decryptBackupPassword(ciphertext); err == nil {
|
||||||
|
passwordHint = backupPasswordHint(password)
|
||||||
|
}
|
||||||
|
respondJSON(w, 200, backupSchedule{Enabled: req.Enabled, Days: req.Days, PasswordSet: ciphertext != "", PasswordHint: passwordHint, ServerIP: detectPublicServerIP(r.Context(), a.config().PublicHostname), ChatID: chatID, TelegramMode: telegramMode, TelegramEnabled: req.TelegramEnabled, GoogleDriveEnabled: req.GoogleDriveEnabled})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleUpdateBackupPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.requireSystemAdmin(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req updateBackupPasswordRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
badRequest(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validBackupPassword(req.Password) {
|
||||||
|
badRequest(w, errors.New("备份密码至少需要 8 个字符"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Password != req.ConfirmPassword {
|
||||||
|
badRequest(w, errors.New("两次输入的备份密码不一致"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ciphertext, err := a.encryptBackupPassword(req.Password)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "无法安全保存备份密码")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
|
if _, err = a.db.ExecContext(r.Context(), `INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`, ciphertext, now); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "无法保存备份密码")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, map[string]any{"passwordSet": true, "passwordHint": backupPasswordHint(req.Password)})
|
||||||
}
|
}
|
||||||
|
|
||||||
func validBackupPassword(password string) bool {
|
func validBackupPassword(password string) bool {
|
||||||
return len(password) >= 8 && len(password) <= 1024 && !strings.ContainsAny(password, "\r\n\x00")
|
return len(password) >= 8 && len(password) <= 1024 && !strings.ContainsAny(password, "\r\n\x00")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func backupPasswordHint(password string) string {
|
||||||
|
runes := []rune(password)
|
||||||
|
if len(runes) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(runes[0]) + strings.Repeat("•", minimumInt(len(runes)-2, 10)) + string(runes[len(runes)-1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) savedBackupPassword(ctx context.Context) (string, error) {
|
||||||
|
var ciphertext string
|
||||||
|
if err := a.db.QueryRowContext(ctx, `SELECT value FROM system_settings WHERE key='backupPasswordCipher'`).Scan(&ciphertext); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(ciphertext) == "" {
|
||||||
|
return "", sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return a.decryptBackupPassword(ciphertext)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) handleTestBackupTelegram(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleTestBackupTelegram(w http.ResponseWriter, r *http.Request) {
|
||||||
if !a.requireSystemAdmin(w, r) {
|
if !a.requireSystemAdmin(w, r) {
|
||||||
return
|
return
|
||||||
@@ -653,8 +738,8 @@ func (a *App) backupAssetsAvailable() bool {
|
|||||||
func writeRuntimeBackupEnv(path string) error {
|
func writeRuntimeBackupEnv(path string) error {
|
||||||
values := make([]string, 0)
|
values := make([]string, 0)
|
||||||
containerOnly := map[string]bool{
|
containerOnly := map[string]bool{
|
||||||
"LANQIN_BACKUP_DIR": true,
|
"LANQIN_BACKUP_DIR": true,
|
||||||
"LANQIN_BACKUP_SOURCE_DIR": true,
|
"LANQIN_BACKUP_SOURCE_DIR": true,
|
||||||
"LANQIN_UPDATE_SERVICE_TOKEN": true,
|
"LANQIN_UPDATE_SERVICE_TOKEN": true,
|
||||||
"LANQIN_UPDATE_SERVICE_URL": true,
|
"LANQIN_UPDATE_SERVICE_URL": true,
|
||||||
}
|
}
|
||||||
@@ -1160,6 +1245,11 @@ func (a *App) loadBackupSchedule(ctx context.Context) (backupSchedule, error) {
|
|||||||
}
|
}
|
||||||
case "backupPasswordCipher":
|
case "backupPasswordCipher":
|
||||||
result.PasswordSet = value != ""
|
result.PasswordSet = value != ""
|
||||||
|
if value != "" {
|
||||||
|
if password, err := a.decryptBackupPassword(value); err == nil {
|
||||||
|
result.PasswordHint = backupPasswordHint(password)
|
||||||
|
}
|
||||||
|
}
|
||||||
case "backupTelegramEnabled":
|
case "backupTelegramEnabled":
|
||||||
result.TelegramEnabled = value == "true"
|
result.TelegramEnabled = value == "true"
|
||||||
case "backupGoogleDriveEnabled":
|
case "backupGoogleDriveEnabled":
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBackupEndpointsRejectMismatchedConfirmation(t *testing.T) {
|
func TestBackupEndpointsRejectMismatchedConfirmation(t *testing.T) {
|
||||||
@@ -120,6 +121,137 @@ func TestBackupPasswordValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBackupPasswordHint(t *testing.T) {
|
||||||
|
if got := backupPasswordHint("A23456789Z"); got != "A••••••••Z" {
|
||||||
|
t.Fatalf("password hint = %q", got)
|
||||||
|
}
|
||||||
|
if got := backupPasswordHint("ab"); got != "ab" {
|
||||||
|
t.Fatalf("two-character password hint = %q", got)
|
||||||
|
}
|
||||||
|
if got := backupPasswordHint(""); got != "" {
|
||||||
|
t.Fatalf("empty password hint = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSavedBackupPasswordAndHint(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
a := newTestAppWithConfig(t, Config{
|
||||||
|
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||||
|
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
|
||||||
|
AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret",
|
||||||
|
})
|
||||||
|
stopTestWorkers(a)
|
||||||
|
ciphertext, err := a.encryptBackupPassword("A23456789Z")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
now := a.now().UTC().Format("2006-01-02T15:04:05Z")
|
||||||
|
if _, err = a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?)`, ciphertext, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
password, err := a.savedBackupPassword(context.Background())
|
||||||
|
if err != nil || password != "A23456789Z" {
|
||||||
|
t.Fatalf("saved password = %q, %v", password, err)
|
||||||
|
}
|
||||||
|
schedule, err := a.loadBackupSchedule(context.Background())
|
||||||
|
if err != nil || !schedule.PasswordSet || schedule.PasswordHint != "A••••••••Z" {
|
||||||
|
t.Fatalf("schedule password state = %+v, %v", schedule, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateBackupPasswordDoesNotChangeScheduleSettings(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
a := newTestAppWithConfig(t, Config{
|
||||||
|
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||||
|
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
|
||||||
|
AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret",
|
||||||
|
})
|
||||||
|
stopTestWorkers(a)
|
||||||
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
|
for key, value := range map[string]string{
|
||||||
|
"backupScheduleEnabled": "true",
|
||||||
|
"backupScheduleDays": "30",
|
||||||
|
"backupTelegramMode": "custom",
|
||||||
|
"backupTelegramChatId": "-1001234567890",
|
||||||
|
"backupGoogleFolderName": "Existing Backups",
|
||||||
|
} {
|
||||||
|
if _, err := a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?)`, key, value, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(a.Router())
|
||||||
|
defer server.Close()
|
||||||
|
admin := &testClient{t: t, server: server}
|
||||||
|
var response map[string]any
|
||||||
|
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@example.com", "password": "ChangeMe123!"}, &response); code != http.StatusOK {
|
||||||
|
t.Fatalf("login code=%d body=%v", code, response)
|
||||||
|
}
|
||||||
|
response = nil
|
||||||
|
if code := admin.do("POST", "/api/admin/backups/password", map[string]string{"password": "NewSharedPassword9", "confirmPassword": "NewSharedPassword9"}, &response); code != http.StatusOK {
|
||||||
|
t.Fatalf("password update code=%d body=%v", code, response)
|
||||||
|
}
|
||||||
|
if response["passwordHint"] != "N••••••••••9" {
|
||||||
|
t.Fatalf("password hint = %v", response["passwordHint"])
|
||||||
|
}
|
||||||
|
password, err := a.savedBackupPassword(context.Background())
|
||||||
|
if err != nil || password != "NewSharedPassword9" {
|
||||||
|
t.Fatalf("saved password = %q, %v", password, err)
|
||||||
|
}
|
||||||
|
for key, want := range map[string]string{
|
||||||
|
"backupScheduleEnabled": "true",
|
||||||
|
"backupScheduleDays": "30",
|
||||||
|
"backupTelegramMode": "custom",
|
||||||
|
"backupTelegramChatId": "-1001234567890",
|
||||||
|
"backupGoogleFolderName": "Existing Backups",
|
||||||
|
} {
|
||||||
|
var got string
|
||||||
|
if err := a.db.QueryRow(`SELECT value FROM system_settings WHERE key=?`, key).Scan(&got); err != nil || got != want {
|
||||||
|
t.Fatalf("setting %s = %q, %v; want %q", key, got, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualBackupReusesSavedPassword(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
deployDir := filepath.Join(dir, "deploy")
|
||||||
|
if err := os.MkdirAll(deployDir, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(deployDir, "docker-compose.yml"), []byte("services: {}\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a := newTestAppWithConfig(t, Config{
|
||||||
|
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||||
|
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
|
||||||
|
AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret", BackupSourceDir: deployDir,
|
||||||
|
BackupDir: filepath.Join(dir, "data", "disaster-backups"),
|
||||||
|
})
|
||||||
|
stopTestWorkers(a)
|
||||||
|
ciphertext, err := a.encryptBackupPassword("SharedBackupPassword9")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
now := a.now().UTC().Format("2006-01-02T15:04:05Z")
|
||||||
|
if _, err = a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?)`, ciphertext, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(a.Router())
|
||||||
|
defer server.Close()
|
||||||
|
admin := &testClient{t: t, server: server}
|
||||||
|
var response map[string]any
|
||||||
|
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@example.com", "password": "ChangeMe123!"}, &response); code != http.StatusOK {
|
||||||
|
t.Fatalf("login code=%d body=%v", code, response)
|
||||||
|
}
|
||||||
|
response = nil
|
||||||
|
if code := admin.do("POST", "/api/admin/backups", map[string]any{"password": "", "confirmPassword": "", "sendTelegram": false, "uploadGoogleDrive": false}, &response); code != http.StatusAccepted {
|
||||||
|
t.Fatalf("manual backup code=%d body=%v", code, response)
|
||||||
|
}
|
||||||
|
password, err := a.savedBackupPassword(context.Background())
|
||||||
|
if err != nil || password != "SharedBackupPassword9" {
|
||||||
|
t.Fatalf("saved password changed: %q, %v", password, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPublicServerIPValidation(t *testing.T) {
|
func TestPublicServerIPValidation(t *testing.T) {
|
||||||
for _, value := range []string{"203.0.113.10", "2001:4860:4860::8888"} {
|
for _, value := range []string{"203.0.113.10", "2001:4860:4860::8888"} {
|
||||||
if !isPublicIP(net.ParseIP(value)) {
|
if !isPublicIP(net.ParseIP(value)) {
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ func (a *App) Router() http.Handler {
|
|||||||
r.Post("/admin/system/update", a.handleSystemUpdate)
|
r.Post("/admin/system/update", a.handleSystemUpdate)
|
||||||
r.Get("/admin/backups", a.handleListBackups)
|
r.Get("/admin/backups", a.handleListBackups)
|
||||||
r.Post("/admin/backups/settings", a.handleUpdateBackupSettings)
|
r.Post("/admin/backups/settings", a.handleUpdateBackupSettings)
|
||||||
|
r.Post("/admin/backups/password", a.handleUpdateBackupPassword)
|
||||||
r.Post("/admin/backups/telegram/test", a.handleTestBackupTelegram)
|
r.Post("/admin/backups/telegram/test", a.handleTestBackupTelegram)
|
||||||
r.Post("/admin/backups/telegram/discover-group", a.handleDiscoverBackupTelegramGroup)
|
r.Post("/admin/backups/telegram/discover-group", a.handleDiscoverBackupTelegramGroup)
|
||||||
r.Post("/admin/backups/google-drive/connect", a.handleGoogleDriveConnect)
|
r.Post("/admin/backups/google-drive/connect", a.handleGoogleDriveConnect)
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ export type SystemUpdateResult = {
|
|||||||
}
|
}
|
||||||
export type BackupItem = { name: string; size: number; createdAt: string; sha256?: string }
|
export type BackupItem = { name: string; size: number; createdAt: string; sha256?: string }
|
||||||
export type BackupJob = { status: "running" | "success" | "failed"; startedAt: string; error?: string }
|
export type BackupJob = { status: "running" | "success" | "failed"; startedAt: string; error?: string }
|
||||||
export type BackupSchedule = { enabled: boolean; days: number; passwordSet: boolean; serverIp: string; chatId: string; telegramMode: "system" | "custom"; telegramEnabled: boolean; googleDriveEnabled: boolean }
|
export type BackupSchedule = { enabled: boolean; days: number; passwordSet: boolean; passwordHint?: string; serverIp: string; chatId: string; telegramMode: "system" | "custom"; telegramEnabled: boolean; googleDriveEnabled: boolean }
|
||||||
export type GoogleDriveBackupStatus = { clientId: string; clientSecretSet: boolean; connected: boolean; folderName: string }
|
export type GoogleDriveBackupStatus = { clientId: string; clientSecretSet: boolean; connected: boolean; folderName: string }
|
||||||
export type BackupList = { enabled: boolean; telegramSet: boolean; telegramLimit: number; job?: BackupJob; items: BackupItem[]; schedule: BackupSchedule; googleDrive: GoogleDriveBackupStatus }
|
export type BackupList = { enabled: boolean; telegramSet: boolean; telegramLimit: number; job?: BackupJob; items: BackupItem[]; schedule: BackupSchedule; googleDrive: GoogleDriveBackupStatus }
|
||||||
export type SystemSettings = {
|
export type SystemSettings = {
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ export const api = {
|
|||||||
backups: () => request<BackupList>("/api/admin/backups"),
|
backups: () => request<BackupList>("/api/admin/backups"),
|
||||||
createBackup: (password: string, confirmPassword: string, sendTelegram: boolean, uploadGoogleDrive: boolean) => request<{ ok: boolean; message: string }>("/api/admin/backups", { method: "POST", body: JSON.stringify({ password, confirmPassword, sendTelegram, uploadGoogleDrive }) }),
|
createBackup: (password: string, confirmPassword: string, sendTelegram: boolean, uploadGoogleDrive: boolean) => request<{ ok: boolean; message: string }>("/api/admin/backups", { method: "POST", body: JSON.stringify({ password, confirmPassword, sendTelegram, uploadGoogleDrive }) }),
|
||||||
updateBackupSettings: (payload: { enabled: boolean; days: number; password: string; confirmPassword: string; serverIp: string; chatId: string; telegramMode: "system" | "custom"; telegramEnabled: boolean; googleDriveEnabled: boolean; googleClientId: string; googleClientSecret: string; googleFolderName: string }) => request<import("./api-types").BackupSchedule>("/api/admin/backups/settings", { method: "POST", body: JSON.stringify(payload) }),
|
updateBackupSettings: (payload: { enabled: boolean; days: number; password: string; confirmPassword: string; serverIp: string; chatId: string; telegramMode: "system" | "custom"; telegramEnabled: boolean; googleDriveEnabled: boolean; googleClientId: string; googleClientSecret: string; googleFolderName: string }) => request<import("./api-types").BackupSchedule>("/api/admin/backups/settings", { method: "POST", body: JSON.stringify(payload) }),
|
||||||
|
updateBackupPassword: (password: string, confirmPassword: string) => request<{ passwordSet: boolean; passwordHint: string }>("/api/admin/backups/password", { method: "POST", body: JSON.stringify({ password, confirmPassword }) }),
|
||||||
testBackupTelegram: (payload: { mode: "system" | "custom"; chatId: string }) => request<{ ok: boolean }>("/api/admin/backups/telegram/test", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
testBackupTelegram: (payload: { mode: "system" | "custom"; chatId: string }) => request<{ ok: boolean }>("/api/admin/backups/telegram/test", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||||
discoverBackupTelegramGroup: (pairingCode: string) => request<{ items: TelegramPrivateChat[] }>("/api/admin/backups/telegram/discover-group", { method: "POST", body: JSON.stringify({ pairingCode }) }),
|
discoverBackupTelegramGroup: (pairingCode: string) => request<{ items: TelegramPrivateChat[] }>("/api/admin/backups/telegram/discover-group", { method: "POST", body: JSON.stringify({ pairingCode }) }),
|
||||||
connectGoogleDrive: () => request<{ url: string }>("/api/admin/backups/google-drive/connect", { method: "POST" }),
|
connectGoogleDrive: () => request<{ url: string }>("/api/admin/backups/google-drive/connect", { method: "POST" }),
|
||||||
|
|||||||
@@ -298,6 +298,7 @@ function BackupsSection() {
|
|||||||
const [backupGroupPairing, setBackupGroupPairing] = React.useState<TelegramPairing | null>(null)
|
const [backupGroupPairing, setBackupGroupPairing] = React.useState<TelegramPairing | null>(null)
|
||||||
const [discoveredBackupGroups, setDiscoveredBackupGroups] = React.useState<{ chatId: string; displayName: string }[]>([])
|
const [discoveredBackupGroups, setDiscoveredBackupGroups] = React.useState<{ chatId: string; displayName: string }[]>([])
|
||||||
const [googleConfigOpen, setGoogleConfigOpen] = React.useState(false)
|
const [googleConfigOpen, setGoogleConfigOpen] = React.useState(false)
|
||||||
|
const [passwordConfigOpen, setPasswordConfigOpen] = React.useState(false)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!backups.data) return
|
if (!backups.data) return
|
||||||
const days = backups.data.schedule.days || 7
|
const days = backups.data.schedule.days || 7
|
||||||
@@ -329,10 +330,15 @@ function BackupsSection() {
|
|||||||
onError: (error) => toast({ title: "无法创建备份", description: error instanceof Error ? error.message : "请稍后重试" }),
|
onError: (error) => toast({ title: "无法创建备份", description: error instanceof Error ? error.message : "请稍后重试" }),
|
||||||
})
|
})
|
||||||
const saveSchedule = useMutation({
|
const saveSchedule = useMutation({
|
||||||
mutationFn: () => api.updateBackupSettings({ enabled: scheduleEnabled, days: scheduleDays === "custom" ? Number(customDays) : Number(scheduleDays), password: schedulePassword, confirmPassword: scheduleConfirmPassword, serverIp: "", chatId: backupChatId, telegramMode, telegramEnabled, googleDriveEnabled, googleClientId, googleClientSecret, googleFolderName }),
|
mutationFn: () => api.updateBackupSettings({ enabled: scheduleEnabled, days: scheduleDays === "custom" ? Number(customDays) : Number(scheduleDays), password: "", confirmPassword: "", serverIp: "", chatId: backupChatId, telegramMode, telegramEnabled, googleDriveEnabled, googleClientId, googleClientSecret, googleFolderName }),
|
||||||
onSuccess: async () => { setSchedulePassword(""); setScheduleConfirmPassword(""); setGoogleClientSecret(""); await qc.invalidateQueries({ queryKey: ["admin", "backups"] }); toast({ title: "备份设置已保存" }) },
|
onSuccess: async () => { setGoogleClientSecret(""); await qc.invalidateQueries({ queryKey: ["admin", "backups"] }); toast({ title: "备份设置已保存" }) },
|
||||||
onError: (error) => toast({ title: "保存失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
onError: (error) => toast({ title: "保存失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
||||||
})
|
})
|
||||||
|
const savePassword = useMutation({
|
||||||
|
mutationFn: () => api.updateBackupPassword(schedulePassword, scheduleConfirmPassword),
|
||||||
|
onSuccess: async () => { setPasswordConfigOpen(false); setSchedulePassword(""); setScheduleConfirmPassword(""); setShowSchedulePassword(false); await qc.invalidateQueries({ queryKey: ["admin", "backups"] }); toast({ title: "统一备份密码已保存", description: "以后创建的手动和定时备份都会使用新密码。" }) },
|
||||||
|
onError: (error) => toast({ title: "密码保存失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
||||||
|
})
|
||||||
const verify = useMutation({
|
const verify = useMutation({
|
||||||
mutationFn: api.verifyBackup,
|
mutationFn: api.verifyBackup,
|
||||||
onSuccess: (result) => toast({ title: result.ok ? "备份校验通过" : "备份校验失败", description: result.ok ? `SHA-256:${result.sha256.slice(0, 16)}...` : "文件可能已损坏,请勿用于恢复。" }),
|
onSuccess: (result) => toast({ title: result.ok ? "备份校验通过" : "备份校验失败", description: result.ok ? `SHA-256:${result.sha256.slice(0, 16)}...` : "文件可能已损坏,请勿用于恢复。" }),
|
||||||
@@ -369,7 +375,7 @@ function BackupsSection() {
|
|||||||
})
|
})
|
||||||
const connectDrive = useMutation({
|
const connectDrive = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
await api.updateBackupSettings({ enabled: scheduleEnabled, days: scheduleDays === "custom" ? Number(customDays) : Number(scheduleDays), password: schedulePassword, confirmPassword: scheduleConfirmPassword, serverIp: "", chatId: backupChatId, telegramMode, telegramEnabled, googleDriveEnabled: false, googleClientId, googleClientSecret, googleFolderName })
|
await api.updateBackupSettings({ enabled: scheduleEnabled, days: scheduleDays === "custom" ? Number(customDays) : Number(scheduleDays), password: "", confirmPassword: "", serverIp: "", chatId: backupChatId, telegramMode, telegramEnabled, googleDriveEnabled: false, googleClientId, googleClientSecret, googleFolderName })
|
||||||
return api.connectGoogleDrive()
|
return api.connectGoogleDrive()
|
||||||
},
|
},
|
||||||
onSuccess: ({ url }) => { window.location.href = url },
|
onSuccess: ({ url }) => { window.location.href = url },
|
||||||
@@ -387,8 +393,8 @@ function BackupsSection() {
|
|||||||
const job = backups.data?.job
|
const job = backups.data?.job
|
||||||
const canCreate = backups.data?.enabled && job?.status !== "running"
|
const canCreate = backups.data?.enabled && job?.status !== "running"
|
||||||
function submitCreate() {
|
function submitCreate() {
|
||||||
if (password.length < 8) { toast({ title: "密码至少需要 8 个字符" }); return }
|
if (!backups.data?.schedule.passwordSet && password.length < 8) { toast({ title: "密码至少需要 8 个字符" }); return }
|
||||||
if (password !== confirmPassword) { toast({ title: "两次输入的密码不一致" }); return }
|
if (!backups.data?.schedule.passwordSet && password !== confirmPassword) { toast({ title: "两次输入的密码不一致" }); return }
|
||||||
create.mutate()
|
create.mutate()
|
||||||
}
|
}
|
||||||
function generateCreatePassword() {
|
function generateCreatePassword() {
|
||||||
@@ -431,11 +437,14 @@ function BackupsSection() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
function submitSchedule() {
|
function submitSchedule() {
|
||||||
if (schedulePassword && schedulePassword.length < 8) { toast({ title: "备份密码至少需要 8 个字符" }); return }
|
if (scheduleEnabled && !backups.data?.schedule.passwordSet) { setPasswordConfigOpen(true); toast({ title: "请先设置统一备份密码" }); return }
|
||||||
if (schedulePassword !== scheduleConfirmPassword) { toast({ title: "两次输入的备份密码不一致" }); return }
|
|
||||||
if (scheduleEnabled && !schedulePassword && !backups.data?.schedule.passwordSet) { toast({ title: "请设置并确认备份密码" }); return }
|
|
||||||
saveSchedule.mutate()
|
saveSchedule.mutate()
|
||||||
}
|
}
|
||||||
|
function submitPassword() {
|
||||||
|
if (schedulePassword.length < 8) { toast({ title: "备份密码至少需要 8 个字符" }); return }
|
||||||
|
if (schedulePassword !== scheduleConfirmPassword) { toast({ title: "两次输入的备份密码不一致" }); return }
|
||||||
|
savePassword.mutate()
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.35fr)_minmax(300px,.65fr)]">
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.35fr)_minmax(300px,.65fr)]">
|
||||||
@@ -451,7 +460,7 @@ function BackupsSection() {
|
|||||||
{!backups.data?.enabled && <div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900">当前版本缺少完整备份组件。请更新到最新修复版本,更新完成后刷新本页即可创建备份。</div>}
|
{!backups.data?.enabled && <div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900">当前版本缺少完整备份组件。请更新到最新修复版本,更新完成后刷新本页即可创建备份。</div>}
|
||||||
{job?.status === "failed" && <div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">{job.error || "备份生成失败"}</div>}
|
{job?.status === "failed" && <div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">{job.error || "备份生成失败"}</div>}
|
||||||
{job?.status === "success" && <div className="rounded-md border border-green-300 bg-green-50 px-3 py-2 text-sm text-green-800">最近一次备份已完成。</div>}
|
{job?.status === "success" && <div className="rounded-md border border-green-300 bg-green-50 px-3 py-2 text-sm text-green-800">最近一次备份已完成。</div>}
|
||||||
{!job && <p className="text-sm text-muted-foreground">创建时必须设置独立备份密码。密码不会保存,丢失后无法解密恢复。</p>}
|
{!job && <p className="text-sm text-muted-foreground">手动备份与定时备份共用同一个恢复密码,避免不同备份使用不同密码。</p>}
|
||||||
<div className="border-t pt-3">
|
<div className="border-t pt-3">
|
||||||
<div className="mb-2 flex items-center justify-between"><span className="text-sm font-medium">本地备份</span><span className="text-xs text-muted-foreground">保留最近 10 份</span></div>
|
<div className="mb-2 flex items-center justify-between"><span className="text-sm font-medium">本地备份</span><span className="text-xs text-muted-foreground">保留最近 10 份</span></div>
|
||||||
<div className="divide-y rounded-md border">
|
<div className="divide-y rounded-md border">
|
||||||
@@ -486,11 +495,10 @@ function BackupsSection() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex-row items-center justify-between space-y-0 pb-3"><div><CardTitle>定时备份</CardTitle><p className="mt-1 text-sm text-muted-foreground">按周期创建加密备份并保存到选定位置。</p></div><Switch checked={scheduleEnabled} onCheckedChange={setScheduleEnabled} /></CardHeader>
|
<CardHeader className="flex-row items-center justify-between space-y-0 pb-3"><div><CardTitle>定时备份</CardTitle><p className="mt-1 text-sm text-muted-foreground">按周期创建加密备份并保存到选定位置。</p></div><Switch checked={scheduleEnabled} onCheckedChange={setScheduleEnabled} /></CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
<div className="space-y-2"><Label>备份周期</Label><div className={cn("grid gap-2", scheduleDays === "custom" && "grid-cols-[minmax(0,1fr)_5.5rem]")}><Select value={scheduleDays} onValueChange={setScheduleDays}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="3">每 3 天</SelectItem><SelectItem value="5">每 5 天</SelectItem><SelectItem value="7">每 7 天</SelectItem><SelectItem value="30">每 30 天</SelectItem><SelectItem value="custom">自定义</SelectItem></SelectContent></Select>{scheduleDays === "custom" && <Input id="backup-custom-days" aria-label="自定义天数" title="自定义天数" type="number" min={1} max={365} value={customDays} onChange={(event) => setCustomDays(event.target.value)} />}</div></div>
|
<div className="space-y-2"><Label>备份周期</Label><div className={cn("grid gap-2", scheduleDays === "custom" && "grid-cols-[minmax(0,1fr)_5.5rem]")}><Select value={scheduleDays} onValueChange={setScheduleDays}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="3">每 3 天</SelectItem><SelectItem value="5">每 5 天</SelectItem><SelectItem value="7">每 7 天</SelectItem><SelectItem value="30">每 30 天</SelectItem><SelectItem value="custom">自定义</SelectItem></SelectContent></Select>{scheduleDays === "custom" && <Input id="backup-custom-days" aria-label="自定义天数" title="自定义天数" type="number" min={1} max={365} value={customDays} onChange={(event) => setCustomDays(event.target.value)} />}</div></div>
|
||||||
<div className="space-y-2"><div className="flex h-7 items-center justify-between gap-2"><Label htmlFor="backup-server-ip">服务器 IP</Label><Button type="button" variant="ghost" size="icon" className="h-7 w-7" title="重新检测" aria-label="重新检测服务器 IP" disabled={backups.isFetching} onClick={() => backups.refetch()}><RefreshCcw className={cn("h-4 w-4", backups.isFetching && "animate-spin")} /></Button></div><Input id="backup-server-ip" readOnly value={backups.data?.schedule.serverIp || ""} placeholder={backups.isLoading ? "正在自动检测" : "未检测到,请检查邮局主机名 DNS"} /><p className="text-xs text-muted-foreground">根据当前邮局主机名的公网 DNS 自动识别。</p></div>
|
<div className="space-y-2"><div className="flex h-7 items-center justify-between gap-2"><Label htmlFor="backup-server-ip">服务器 IP</Label><Button type="button" variant="ghost" size="icon" className="h-7 w-7" title="重新检测" aria-label="重新检测服务器 IP" disabled={backups.isFetching} onClick={() => backups.refetch()}><RefreshCcw className={cn("h-4 w-4", backups.isFetching && "animate-spin")} /></Button></div><Input id="backup-server-ip" readOnly value={backups.data?.schedule.serverIp || ""} placeholder={backups.isLoading ? "正在自动检测" : "未检测到,请检查邮局主机名 DNS"} /><p className="text-xs text-muted-foreground">根据当前邮局主机名的公网 DNS 自动识别。</p></div>
|
||||||
<div className="space-y-2"><div className="flex h-7 items-center justify-between gap-2"><Label htmlFor="backup-schedule-password">备份密码</Label><PasswordTools value={schedulePassword} visible={showSchedulePassword} onVisibleChange={setShowSchedulePassword} onGenerate={generateSchedulePassword} /></div><Input id="backup-schedule-password" type={showSchedulePassword ? "text" : "password"} autoComplete="new-password" value={schedulePassword} onChange={(event) => setSchedulePassword(event.target.value)} placeholder={backups.data?.schedule.passwordSet ? "已保存,留空不变" : "至少 8 个字符"} /></div>
|
<div className="space-y-2"><div className="flex h-7 items-center"><Label>恢复密码</Label></div><div className="flex h-10 items-center gap-3 rounded-md border bg-background px-3"><KeyRound className="h-4 w-4 shrink-0 text-muted-foreground" /><span className="min-w-0 flex-1 truncate font-mono text-sm">{backups.data?.schedule.passwordHint || "尚未设置"}</span><Button type="button" variant="ghost" size="sm" className="shrink-0" onClick={() => setPasswordConfigOpen(true)}>{backups.data?.schedule.passwordSet ? "更换" : "设置"}</Button></div><p className="text-xs text-muted-foreground">手动与定时备份共用。</p></div>
|
||||||
<div className="space-y-2"><div className="flex h-7 items-center"><Label htmlFor="backup-schedule-confirm-password">确认备份密码</Label></div><Input id="backup-schedule-confirm-password" type={showSchedulePassword ? "text" : "password"} autoComplete="new-password" value={scheduleConfirmPassword} onChange={(event) => setScheduleConfirmPassword(event.target.value)} placeholder={schedulePassword ? "再次输入备份密码" : "留空则不修改"} /></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y rounded-md border">
|
<div className="divide-y rounded-md border">
|
||||||
<div className="flex items-center gap-3 p-3">
|
<div className="flex items-center gap-3 p-3">
|
||||||
@@ -553,15 +561,26 @@ function BackupsSection() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
<Dialog open={passwordConfigOpen} onOpenChange={(open) => { if (!savePassword.isPending) { setPasswordConfigOpen(open); if (!open) { setSchedulePassword(""); setScheduleConfirmPassword(""); setShowSchedulePassword(false) } } }}>
|
||||||
|
<DialogContent className="w-[calc(100vw-2rem)] max-w-md rounded-lg">
|
||||||
|
<DialogHeader><DialogTitle>{backups.data?.schedule.passwordSet ? "更换统一备份密码" : "设置统一备份密码"}</DialogTitle></DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{backups.data?.schedule.passwordSet && <div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900">当前密码:<span className="font-mono">{backups.data.schedule.passwordHint}</span>。更换只影响以后创建的备份,已有备份仍需原密码恢复。</div>}
|
||||||
|
<div className="space-y-2"><div className="flex h-7 items-center justify-between gap-2"><Label htmlFor="backup-schedule-password">新备份密码</Label><PasswordTools value={schedulePassword} visible={showSchedulePassword} onVisibleChange={setShowSchedulePassword} onGenerate={generateSchedulePassword} /></div><Input id="backup-schedule-password" type={showSchedulePassword ? "text" : "password"} autoComplete="new-password" value={schedulePassword} onChange={(event) => setSchedulePassword(event.target.value)} placeholder="至少 8 个字符" /></div>
|
||||||
|
<div className="space-y-2"><Label htmlFor="backup-schedule-confirm-password">确认新备份密码</Label><Input id="backup-schedule-confirm-password" type={showSchedulePassword ? "text" : "password"} autoComplete="new-password" value={scheduleConfirmPassword} onChange={(event) => setScheduleConfirmPassword(event.target.value)} placeholder="再次输入新备份密码" /></div>
|
||||||
|
<p className="text-xs text-muted-foreground">生成密码后可查看、复制或下载密码文本。请存入密码管理器,并与备份文件分开保存。</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter><Button type="button" variant="outline" onClick={() => setPasswordConfigOpen(false)} disabled={savePassword.isPending}>取消</Button><Button type="button" onClick={submitPassword} disabled={savePassword.isPending}>{savePassword.isPending ? "保存中" : "保存密码"}</Button></DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
<Dialog open={createOpen} onOpenChange={(open) => { if (!create.isPending) setCreateOpen(open) }}>
|
<Dialog open={createOpen} onOpenChange={(open) => { if (!create.isPending) setCreateOpen(open) }}>
|
||||||
<DialogContent className="w-[calc(100vw-2rem)] max-w-md rounded-lg">
|
<DialogContent className="w-[calc(100vw-2rem)] max-w-md rounded-lg">
|
||||||
<DialogHeader><DialogTitle>创建完整备份</DialogTitle></DialogHeader>
|
<DialogHeader><DialogTitle>创建完整备份</DialogTitle></DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2"><div className="flex h-7 items-center justify-between gap-2"><Label htmlFor="backup-password">备份密码</Label><PasswordTools value={password} visible={showCreatePassword} onVisibleChange={setShowCreatePassword} onGenerate={generateCreatePassword} /></div><Input id="backup-password" type={showCreatePassword ? "text" : "password"} autoComplete="new-password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="自己输入或自动生成" /></div>
|
{backups.data?.schedule.passwordSet ? <div className="rounded-md border bg-muted/40 px-3 py-3"><div className="text-sm font-medium">使用已保存的备份密码</div><div className="mt-1 font-mono text-sm text-muted-foreground">{backups.data.schedule.passwordHint || "密码已安全保存"}</div><p className="mt-1 text-xs text-muted-foreground">与定时备份共用同一个恢复密码。</p></div> : <><div className="space-y-2"><div className="flex h-7 items-center justify-between gap-2"><Label htmlFor="backup-password">首次设置备份密码</Label><PasswordTools value={password} visible={showCreatePassword} onVisibleChange={setShowCreatePassword} onGenerate={generateCreatePassword} /></div><Input id="backup-password" type={showCreatePassword ? "text" : "password"} autoComplete="new-password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="自己输入或自动生成" /></div><div className="space-y-2"><Label htmlFor="backup-confirm-password">确认备份密码</Label><Input id="backup-confirm-password" type={showCreatePassword ? "text" : "password"} autoComplete="new-password" value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} /></div></>}
|
||||||
<div className="space-y-2"><Label htmlFor="backup-confirm-password">确认备份密码</Label><Input id="backup-confirm-password" type={showCreatePassword ? "text" : "password"} autoComplete="new-password" value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} /></div>
|
|
||||||
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2"><div><div className="text-sm font-medium">完成后发送到 Telegram</div><div className="text-xs text-muted-foreground">同时发送详细恢复说明和加密附件。</div></div><Switch checked={sendAfterCreate} onCheckedChange={setSendAfterCreate} disabled={!backups.data?.telegramSet} /></div>
|
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2"><div><div className="text-sm font-medium">完成后发送到 Telegram</div><div className="text-xs text-muted-foreground">同时发送详细恢复说明和加密附件。</div></div><Switch checked={sendAfterCreate} onCheckedChange={setSendAfterCreate} disabled={!backups.data?.telegramSet} /></div>
|
||||||
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2"><div><div className="text-sm font-medium">上传到 Google 云端硬盘</div><div className="text-xs text-muted-foreground">保存加密备份到已连接的云端文件夹。</div></div><Switch checked={driveAfterCreate} onCheckedChange={setDriveAfterCreate} disabled={!backups.data?.googleDrive.connected} /></div>
|
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2"><div><div className="text-sm font-medium">上传到 Google 云端硬盘</div><div className="text-xs text-muted-foreground">保存加密备份到已连接的云端文件夹。</div></div><Switch checked={driveAfterCreate} onCheckedChange={setDriveAfterCreate} disabled={!backups.data?.googleDrive.connected} /></div>
|
||||||
<p className="text-xs text-muted-foreground">下载的是明文密码文本,请导入密码管理器后妥善处理,不要与备份文件存放在同一位置。</p>
|
{!backups.data?.schedule.passwordSet && <p className="text-xs text-muted-foreground">首次设置后,手动和定时备份都会使用这个密码。请保存到密码管理器,不要与备份文件放在同一位置。</p>}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter><Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={create.isPending}>取消</Button><Button type="button" onClick={submitCreate} disabled={create.isPending}>{create.isPending ? "启动中" : "开始备份"}</Button></DialogFooter>
|
<DialogFooter><Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={create.isPending}>取消</Button><Button type="button" onClick={submitCreate} disabled={create.isPending}>{create.isPending ? "启动中" : "开始备份"}</Button></DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user