Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6021e04f0d | |||
| 7fc91f2ed9 | |||
| c482e5b896 | |||
| 07e62e9c2d | |||
| 5797b472d9 | |||
| 3caee11e38 | |||
| 2dd3647923 | |||
| ccb8ce01d9 | |||
| 4e3b69608f | |||
| 1dbc33b0dc |
@@ -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)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
- 修复完整备份上传 Google 云端硬盘失败:由小文件上传改为官方可恢复分块上传,支持大型邮箱备份。
|
||||||
|
- 同一份本地加密备份只显示一次文件名,下方分别显示 Telegram 与 Google 云端硬盘的上传百分比、已上传大小和结果。
|
||||||
|
- 手动发送改为后台任务,刷新或离开页面后上传仍会继续,返回备份页可继续查看进度。
|
||||||
|
- Google 授权失效、空间不足、请求限流、Drive API 未启用及网络超时会显示对应中文处理建议。
|
||||||
|
- 定时备份的云端推送失败也会直接显示具体原因,不再只提示查看服务器日志。
|
||||||
|
|
||||||
|
**完整更新日志**:[v1.2.34...v1.2.35](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.34...v1.2.35)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
- Google 云端硬盘配置弹窗增加 Google Drive API 启用说明和官方控制台直达入口。
|
||||||
|
- 明确提示必须在 OAuth 客户端所属的同一 Google Cloud 项目中启用 Drive API。
|
||||||
|
- 补充启用 API 后重新连接 Google 账号的操作顺序,减少授权成功但无法上传的配置误区。
|
||||||
|
- 写信与编辑草稿弹窗改为更紧凑的居中布局,重新整理字段、工具栏和发送操作区,完整保留附件、格式、签名、日程、预览和定时发送能力。
|
||||||
|
- 修复超长授权码、链接和代码内容撑宽编辑器的问题,桌面端与手机端均会在正文范围内安全换行。
|
||||||
|
- 启用浏览器原生拼写检查,并统一普通发送与定时发送的收件人校验。
|
||||||
|
- 关闭写信窗口时立即保存最新正文与附件,保存失败会保留窗口并提示,避免等待自动保存期间丢失草稿。
|
||||||
|
- 写信格式栏增加正文与标题 1/2/3 段落样式、实时字数统计和“更多格式”菜单,将完整格式能力稳定收纳在两行内。
|
||||||
|
- 提高邮箱与管理后台次级文字的对比度,改善浅色与深色模式下的阅读清晰度。
|
||||||
|
|
||||||
|
**完整更新日志**:[v1.2.35...v1.2.36](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.35...v1.2.36)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- 写信页发送区改为 Gmail 风格拆分按钮,主按钮直接发送,右侧下拉菜单提供“定时发送”。
|
||||||
|
- 移除容易被误认为日期选择器的独立日历方块,保留原有定时预设和自定义发送时间功能。
|
||||||
|
|
||||||
|
**完整更新日志**:[v1.2.36...v1.2.37](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.36...v1.2.37)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
- 写信发送拆分按钮统一使用 Gmail 风格蓝色主操作,浅色与暗色模式都保持清晰白字及一致悬停状态。
|
||||||
|
- 修复“定时发送”菜单按左侧展开导致右边框越过写信窗口的问题,菜单改为右对齐并增加边缘避让和垂直间距。
|
||||||
|
- 将默认主操作按钮从通用强调色中拆分,暗色主题下后台、个人设置、规则、弹窗确认、登录注册等页面统一使用深灰底白字;勾选框、进度条、选中态等仍保留清晰的强调色。
|
||||||
|
- 已保存的浅色或暗色主题现在会在应用启动时统一恢复,切换到后台、登录及其他页面后不再丢失主题状态。
|
||||||
|
|
||||||
|
**完整更新日志**:[v1.2.37...v1.2.38](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.37...v1.2.38)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- 修复桌面端文件夹或标签较多时侧栏被内容撑出视口、底部项目无法访问的问题;账号、邮箱切换和写信入口保持固定,邮件导航区域独立滚动。
|
||||||
|
- 同步包含 v1.2.38 的暗色主按钮与 Gmail 蓝色发送按钮修复,更新完成后写信发送按钮在浅色和暗色模式下均为蓝底白字。
|
||||||
|
|
||||||
|
**完整更新日志**:[v1.2.38...v1.2.39](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.38...v1.2.39)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
- 修复桌面端文件夹或标签较多时侧栏被内容撑出视口、底部项目无法访问的问题;账号、邮箱切换和写信入口保持固定,邮件导航区域独立滚动。
|
||||||
|
- 写信发送按钮在浅色和暗色模式下统一为 Gmail 风格蓝底白字,定时发送菜单在桌面和手机端均不会越过写信窗口。
|
||||||
|
- 暗色主题默认主操作按钮改为深灰底白字,并与勾选框、进度条、危险按钮等语义颜色分离;进入后台或登录页后也会保持已选择的主题。
|
||||||
|
- 修复备份后台任务测试结束过早导致发布流程偶发失败的问题。
|
||||||
|
|
||||||
|
**完整更新日志**:[v1.2.39...v1.2.40](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.39...v1.2.40)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- 调整写信页定时发送菜单宽度,使其与“发送 + 下拉箭头”组合按钮左右边缘完全对齐,同时保留桌面和手机端边界避让。
|
||||||
|
|
||||||
|
**完整更新日志**:[v1.2.40...v1.2.41](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.40...v1.2.41)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Third-party notices
|
||||||
|
|
||||||
|
## PurCarte theme background
|
||||||
|
|
||||||
|
`apps/web/public/purcarte-moonlit.webp` is distributed with
|
||||||
|
[komari-theme-purcarte](https://github.com/Montia37/komari-theme-purcarte),
|
||||||
|
copyright (c) 2025 Montia37, under the MIT License.
|
||||||
|
|
||||||
|
The project uses the asset as a replaceable interface background and does not
|
||||||
|
include the PurCarte application code.
|
||||||
@@ -40,6 +40,7 @@ type App struct {
|
|||||||
telegramDeliveryMu sync.Mutex
|
telegramDeliveryMu sync.Mutex
|
||||||
backupMu sync.Mutex
|
backupMu sync.Mutex
|
||||||
backupJob *backupJob
|
backupJob *backupJob
|
||||||
|
backupTransfers map[string]*backupTransfer
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -83,7 +84,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
|||||||
}
|
}
|
||||||
db.SetMaxOpenConns(1)
|
db.SetMaxOpenConns(1)
|
||||||
|
|
||||||
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker(), telegramURL: "https://api.telegram.org", telegramPairs: map[string]telegramPairing{}}
|
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker(), telegramURL: "https://api.telegram.org", telegramPairs: map[string]telegramPairing{}, backupTransfers: map[string]*backupTransfer{}}
|
||||||
a.externalIMAP = a
|
a.externalIMAP = a
|
||||||
if err := a.configureSQLite(context.Background()); err != nil {
|
if err := a.configureSQLite(context.Background()); err != nil {
|
||||||
db.Close()
|
db.Close()
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import (
|
|||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/textproto"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -31,6 +30,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const backupTelegramLimit = 49 << 20
|
const backupTelegramLimit = 49 << 20
|
||||||
|
const googleDriveUploadChunkSize = 8 << 20
|
||||||
|
|
||||||
type backupJob struct {
|
type backupJob struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
@@ -45,6 +45,17 @@ type backupItem struct {
|
|||||||
SHA256 string `json:"sha256,omitempty"`
|
SHA256 string `json:"sha256,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type backupTransfer struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Uploaded int64 `json:"uploaded"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
StartedAt time.Time `json:"startedAt"`
|
||||||
|
FinishedAt time.Time `json:"finishedAt,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type backupListResponse struct {
|
type backupListResponse struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
TelegramSet bool `json:"telegramSet"`
|
TelegramSet bool `json:"telegramSet"`
|
||||||
@@ -53,6 +64,7 @@ type backupListResponse struct {
|
|||||||
Items []backupItem `json:"items"`
|
Items []backupItem `json:"items"`
|
||||||
Schedule backupSchedule `json:"schedule"`
|
Schedule backupSchedule `json:"schedule"`
|
||||||
GoogleDrive googleDriveStatus `json:"googleDrive"`
|
GoogleDrive googleDriveStatus `json:"googleDrive"`
|
||||||
|
Transfers []backupTransfer `json:"transfers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type createBackupRequest struct {
|
type createBackupRequest struct {
|
||||||
@@ -66,6 +78,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 +137,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"`
|
||||||
@@ -171,7 +189,13 @@ func (a *App) handleListBackups(w http.ResponseWriter, r *http.Request) {
|
|||||||
copy := *job
|
copy := *job
|
||||||
job = ©
|
job = ©
|
||||||
}
|
}
|
||||||
|
transfers := make([]backupTransfer, 0, len(a.backupTransfers))
|
||||||
|
for _, transfer := range a.backupTransfers {
|
||||||
|
copy := *transfer
|
||||||
|
transfers = append(transfers, copy)
|
||||||
|
}
|
||||||
a.backupMu.Unlock()
|
a.backupMu.Unlock()
|
||||||
|
sort.Slice(transfers, func(i, j int) bool { return transfers[i].StartedAt.After(transfers[j].StartedAt) })
|
||||||
schedule, _ := a.loadBackupSchedule(r.Context())
|
schedule, _ := a.loadBackupSchedule(r.Context())
|
||||||
schedule.ServerIP = detectPublicServerIP(r.Context(), a.config().PublicHostname)
|
schedule.ServerIP = detectPublicServerIP(r.Context(), a.config().PublicHostname)
|
||||||
telegramToken, telegramDestination, _ := a.backupTelegramCredentials(r.Context(), schedule)
|
telegramToken, telegramDestination, _ := a.backupTelegramCredentials(r.Context(), schedule)
|
||||||
@@ -179,7 +203,7 @@ func (a *App) handleListBackups(w http.ResponseWriter, r *http.Request) {
|
|||||||
Enabled: a.backupAssetsAvailable(),
|
Enabled: a.backupAssetsAvailable(),
|
||||||
TelegramSet: strings.TrimSpace(telegramToken) != "" && validTelegramPrivateChatID(telegramDestination),
|
TelegramSet: strings.TrimSpace(telegramToken) != "" && validTelegramPrivateChatID(telegramDestination),
|
||||||
TelegramLimit: backupTelegramLimit, Job: job, Items: items, Schedule: schedule,
|
TelegramLimit: backupTelegramLimit, Job: job, Items: items, Schedule: schedule,
|
||||||
GoogleDrive: a.loadGoogleDriveStatus(r.Context()),
|
GoogleDrive: a.loadGoogleDriveStatus(r.Context()), Transfers: transfers,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,42 +216,84 @@ 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()
|
||||||
path, err := a.createDisasterBackup(ctx, password)
|
path, err := a.createDisasterBackup(ctx, password)
|
||||||
password = ""
|
password = ""
|
||||||
|
var deliveryMessages []string
|
||||||
if err == nil {
|
if err == nil {
|
||||||
var deliveryErrors []error
|
var deliveryErrors []error
|
||||||
|
if uploadGoogleDrive {
|
||||||
|
a.queueBackupTransfer("googleDrive", path)
|
||||||
|
}
|
||||||
|
if sendTelegram {
|
||||||
|
a.queueBackupTransfer("telegram", path)
|
||||||
|
}
|
||||||
if uploadGoogleDrive {
|
if uploadGoogleDrive {
|
||||||
if driveErr := a.uploadBackupToGoogleDrive(ctx, path); driveErr != nil {
|
if driveErr := a.uploadBackupToGoogleDrive(ctx, path); driveErr != nil {
|
||||||
|
message := googleDriveUploadMessage(driveErr)
|
||||||
|
a.finishBackupTransfer("googleDrive", path, message)
|
||||||
|
deliveryMessages = append(deliveryMessages, message)
|
||||||
deliveryErrors = append(deliveryErrors, fmt.Errorf("google drive: %w", driveErr))
|
deliveryErrors = append(deliveryErrors, fmt.Errorf("google drive: %w", driveErr))
|
||||||
|
} else {
|
||||||
|
a.finishBackupTransfer("googleDrive", path, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if sendTelegram {
|
if sendTelegram {
|
||||||
if telegramErr := a.sendBackupToTelegram(ctx, path); telegramErr != nil {
|
if telegramErr := a.sendBackupToTelegram(ctx, path); telegramErr != nil {
|
||||||
|
a.finishBackupTransfer("telegram", path, telegramErr.Error())
|
||||||
|
deliveryMessages = append(deliveryMessages, "Telegram 发送失败:"+telegramErr.Error())
|
||||||
deliveryErrors = append(deliveryErrors, fmt.Errorf("telegram: %w", telegramErr))
|
deliveryErrors = append(deliveryErrors, fmt.Errorf("telegram: %w", telegramErr))
|
||||||
|
} else {
|
||||||
|
a.finishBackupTransfer("telegram", path, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = errors.Join(deliveryErrors...)
|
err = errors.Join(deliveryErrors...)
|
||||||
@@ -235,7 +301,11 @@ func (a *App) handleCreateBackup(w http.ResponseWriter, r *http.Request) {
|
|||||||
a.backupMu.Lock()
|
a.backupMu.Lock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.backupJob.Status = "failed"
|
a.backupJob.Status = "failed"
|
||||||
a.backupJob.Error = "本地备份或所选推送未全部完成,请查看服务日志"
|
if len(deliveryMessages) > 0 {
|
||||||
|
a.backupJob.Error = strings.Join(deliveryMessages, ";")
|
||||||
|
} else {
|
||||||
|
a.backupJob.Error = "本地备份创建失败,请检查服务器存储空间"
|
||||||
|
}
|
||||||
a.log.Error("create disaster backup", "error", err)
|
a.log.Error("create disaster backup", "error", err)
|
||||||
} else {
|
} else {
|
||||||
a.backupJob.Status = "success"
|
a.backupJob.Status = "success"
|
||||||
@@ -351,13 +421,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 +776,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,
|
||||||
}
|
}
|
||||||
@@ -725,6 +848,10 @@ func (a *App) handleDeleteBackup(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = os.Remove(path + ".sha256")
|
_ = os.Remove(path + ".sha256")
|
||||||
|
a.backupMu.Lock()
|
||||||
|
delete(a.backupTransfers, backupTransferKey("telegram", path))
|
||||||
|
delete(a.backupTransfers, backupTransferKey("googleDrive", path))
|
||||||
|
a.backupMu.Unlock()
|
||||||
respondJSON(w, 200, map[string]any{"ok": true})
|
respondJSON(w, 200, map[string]any{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,12 +864,21 @@ func (a *App) handleSendBackupTelegram(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, 404, "备份不存在")
|
respondError(w, 404, "备份不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.sendBackupToTelegram(r.Context(), path); err != nil {
|
if !a.startBackupTransfer("telegram", path) {
|
||||||
a.log.Error("send backup telegram", "error", err)
|
respondError(w, http.StatusConflict, "该备份正在发送到 Telegram")
|
||||||
respondError(w, 502, err.Error())
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respondJSON(w, 200, map[string]any{"ok": true})
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
|
||||||
|
defer cancel()
|
||||||
|
if err := a.sendBackupToTelegram(ctx, path); err != nil {
|
||||||
|
a.finishBackupTransfer("telegram", path, err.Error())
|
||||||
|
a.log.Error("send backup telegram", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.finishBackupTransfer("telegram", path, "")
|
||||||
|
}()
|
||||||
|
respondJSON(w, http.StatusAccepted, map[string]any{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleSendBackupGoogleDrive(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleSendBackupGoogleDrive(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -754,12 +890,89 @@ func (a *App) handleSendBackupGoogleDrive(w http.ResponseWriter, r *http.Request
|
|||||||
respondError(w, 404, "备份不存在")
|
respondError(w, 404, "备份不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.uploadBackupToGoogleDrive(r.Context(), path); err != nil {
|
if !a.startBackupTransfer("googleDrive", path) {
|
||||||
a.log.Error("upload backup to google drive", "error", err)
|
respondError(w, http.StatusConflict, "该备份正在上传到 Google 云端硬盘")
|
||||||
respondError(w, 502, "上传 Google 云端硬盘失败")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respondJSON(w, 200, map[string]bool{"ok": true})
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
|
||||||
|
defer cancel()
|
||||||
|
if err := a.uploadBackupToGoogleDrive(ctx, path); err != nil {
|
||||||
|
message := googleDriveUploadMessage(err)
|
||||||
|
a.finishBackupTransfer("googleDrive", path, message)
|
||||||
|
a.log.Error("upload backup to google drive", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.finishBackupTransfer("googleDrive", path, "")
|
||||||
|
}()
|
||||||
|
respondJSON(w, http.StatusAccepted, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func backupTransferKey(provider, path string) string {
|
||||||
|
return provider + ":" + filepath.Base(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) startBackupTransfer(provider, path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
key := backupTransferKey(provider, path)
|
||||||
|
a.backupMu.Lock()
|
||||||
|
defer a.backupMu.Unlock()
|
||||||
|
if transfer := a.backupTransfers[key]; transfer != nil && transfer.Status == "running" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
a.backupTransfers[key] = &backupTransfer{Provider: provider, Name: filepath.Base(path), Status: "running", Total: info.Size(), StartedAt: a.now().UTC()}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ensureBackupTransfer(provider, path string) {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := backupTransferKey(provider, path)
|
||||||
|
a.backupMu.Lock()
|
||||||
|
defer a.backupMu.Unlock()
|
||||||
|
if transfer := a.backupTransfers[key]; transfer == nil {
|
||||||
|
a.backupTransfers[key] = &backupTransfer{Provider: provider, Name: filepath.Base(path), Status: "running", Total: info.Size(), StartedAt: a.now().UTC()}
|
||||||
|
} else if transfer.Status == "queued" {
|
||||||
|
transfer.Status = "running"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) queueBackupTransfer(provider, path string) {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.backupMu.Lock()
|
||||||
|
defer a.backupMu.Unlock()
|
||||||
|
a.backupTransfers[backupTransferKey(provider, path)] = &backupTransfer{Provider: provider, Name: filepath.Base(path), Status: "queued", Total: info.Size(), StartedAt: a.now().UTC()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) updateBackupTransfer(provider, path string, uploaded int64) {
|
||||||
|
a.backupMu.Lock()
|
||||||
|
defer a.backupMu.Unlock()
|
||||||
|
if transfer := a.backupTransfers[backupTransferKey(provider, path)]; transfer != nil {
|
||||||
|
transfer.Uploaded = uploaded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) finishBackupTransfer(provider, path, message string) {
|
||||||
|
a.backupMu.Lock()
|
||||||
|
defer a.backupMu.Unlock()
|
||||||
|
if transfer := a.backupTransfers[backupTransferKey(provider, path)]; transfer != nil {
|
||||||
|
transfer.FinishedAt = a.now().UTC()
|
||||||
|
if message == "" {
|
||||||
|
transfer.Status = "success"
|
||||||
|
transfer.Uploaded = transfer.Total
|
||||||
|
} else {
|
||||||
|
transfer.Status = "failed"
|
||||||
|
transfer.Error = message
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) googleDriveClient(ctx context.Context) (*http.Client, error) {
|
func (a *App) googleDriveClient(ctx context.Context) (*http.Client, error) {
|
||||||
@@ -813,7 +1026,7 @@ func (a *App) googleDriveFolderID(ctx context.Context, client *http.Client, name
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
return "", fmt.Errorf("drive folder lookup %s: %s", resp.Status, raw)
|
return "", &googleDriveAPIError{Operation: "folder lookup", StatusCode: resp.StatusCode, Body: string(raw)}
|
||||||
}
|
}
|
||||||
var list struct {
|
var list struct {
|
||||||
Files []struct {
|
Files []struct {
|
||||||
@@ -836,7 +1049,7 @@ func (a *App) googleDriveFolderID(ctx context.Context, client *http.Client, name
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
return "", fmt.Errorf("drive folder create %s: %s", resp.Status, raw)
|
return "", &googleDriveAPIError{Operation: "folder create", StatusCode: resp.StatusCode, Body: string(raw)}
|
||||||
}
|
}
|
||||||
var created struct {
|
var created struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -851,6 +1064,7 @@ func (a *App) googleDriveFolderID(ctx context.Context, client *http.Client, name
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) uploadBackupToGoogleDrive(ctx context.Context, path string) error {
|
func (a *App) uploadBackupToGoogleDrive(ctx context.Context, path string) error {
|
||||||
|
a.ensureBackupTransfer("googleDrive", path)
|
||||||
client, err := a.googleDriveClient(ctx)
|
client, err := a.googleDriveClient(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -860,7 +1074,7 @@ func (a *App) uploadBackupToGoogleDrive(ctx context.Context, path string) error
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
req, err := newGoogleDriveUploadRequest(ctx, path, folderID)
|
req, size, err := newGoogleDriveResumableRequest(ctx, path, folderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -871,53 +1085,107 @@ func (a *App) uploadBackupToGoogleDrive(ctx context.Context, path string) error
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
return fmt.Errorf("drive upload %s: %s", resp.Status, raw)
|
return &googleDriveAPIError{Operation: "start upload", StatusCode: resp.StatusCode, Body: string(raw)}
|
||||||
}
|
}
|
||||||
return nil
|
location := strings.TrimSpace(resp.Header.Get("Location"))
|
||||||
|
if location == "" {
|
||||||
|
return errors.New("Google 云端硬盘未返回可恢复上传地址")
|
||||||
|
}
|
||||||
|
return a.uploadGoogleDriveChunks(ctx, client, location, path, size)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newGoogleDriveUploadRequest(ctx context.Context, path, folderID string) (*http.Request, error) {
|
type googleDriveAPIError struct {
|
||||||
|
Operation string
|
||||||
|
StatusCode int
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *googleDriveAPIError) Error() string {
|
||||||
|
return fmt.Sprintf("google drive %s returned %d: %s", e.Operation, e.StatusCode, e.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGoogleDriveResumableRequest(ctx context.Context, path, folderID string) (*http.Request, int64, error) {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
metadata, _ := json.Marshal(map[string]any{"name": filepath.Base(path), "parents": []string{folderID}})
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable&fields=id,name", strings.NewReader(string(metadata)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json; charset=UTF-8")
|
||||||
|
req.Header.Set("X-Upload-Content-Type", "application/octet-stream")
|
||||||
|
req.Header.Set("X-Upload-Content-Length", fmt.Sprint(info.Size()))
|
||||||
|
return req, info.Size(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) uploadGoogleDriveChunks(ctx context.Context, client *http.Client, location, path string, size int64) error {
|
||||||
file, err := os.Open(path)
|
file, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
reader, writerSide := io.Pipe()
|
defer file.Close()
|
||||||
mw := multipart.NewWriter(writerSide)
|
for offset := int64(0); offset < size; {
|
||||||
go func() {
|
length := int64(googleDriveUploadChunkSize)
|
||||||
var writeErr error
|
if remaining := size - offset; remaining < length {
|
||||||
defer func() { _ = file.Close(); _ = mw.Close(); _ = writerSide.CloseWithError(writeErr) }()
|
length = remaining
|
||||||
head := textproto.MIMEHeader{}
|
}
|
||||||
head.Set("Content-Type", "application/json; charset=UTF-8")
|
end := offset + length - 1
|
||||||
part, err := mw.CreatePart(head)
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, location, io.NewSectionReader(file, offset, length))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr = err
|
return err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
metadata, _ := json.Marshal(map[string]any{"name": filepath.Base(path), "parents": []string{folderID}})
|
req.ContentLength = length
|
||||||
if _, err = part.Write(metadata); err != nil {
|
req.Header.Set("Content-Type", "application/octet-stream")
|
||||||
writeErr = err
|
req.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, end, size))
|
||||||
return
|
resp, err := client.Do(req)
|
||||||
}
|
|
||||||
head = textproto.MIMEHeader{}
|
|
||||||
head.Set("Content-Type", "application/octet-stream")
|
|
||||||
part, err = mw.CreatePart(head)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr = err
|
return err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
_, writeErr = io.Copy(part, file)
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
}()
|
_ = resp.Body.Close()
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,name", reader)
|
if resp.StatusCode == http.StatusPermanentRedirect {
|
||||||
if err != nil {
|
offset += length
|
||||||
_ = file.Close()
|
a.updateBackupTransfer("googleDrive", path, offset)
|
||||||
_ = writerSide.CloseWithError(err)
|
continue
|
||||||
return nil, err
|
}
|
||||||
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 && end+1 == size {
|
||||||
|
a.updateBackupTransfer("googleDrive", path, size)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &googleDriveAPIError{Operation: "upload chunk", StatusCode: resp.StatusCode, Body: string(raw)}
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "multipart/related; boundary="+mw.Boundary())
|
return errors.New("Google 云端硬盘不能上传空备份文件")
|
||||||
return req, nil
|
}
|
||||||
|
|
||||||
|
func googleDriveUploadMessage(err error) string {
|
||||||
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return "Google 云端硬盘上传超时,请检查服务器网络后重试"
|
||||||
|
}
|
||||||
|
var apiErr *googleDriveAPIError
|
||||||
|
if errors.As(err, &apiErr) {
|
||||||
|
body := strings.ToLower(apiErr.Body)
|
||||||
|
switch {
|
||||||
|
case apiErr.StatusCode == http.StatusUnauthorized || strings.Contains(body, "invalid_grant"):
|
||||||
|
return "Google 授权已失效,请打开配置,断开后重新连接"
|
||||||
|
case strings.Contains(body, "storagequota") || strings.Contains(body, "storage quota"):
|
||||||
|
return "Google 云端硬盘空间不足,请清理空间后重试"
|
||||||
|
case apiErr.StatusCode == http.StatusTooManyRequests || strings.Contains(body, "ratelimit"):
|
||||||
|
return "Google 云端硬盘请求过于频繁,请稍后重试"
|
||||||
|
case apiErr.StatusCode == http.StatusForbidden:
|
||||||
|
return "Google 云端硬盘无上传权限,请确认 Drive API 已启用并重新连接"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message := strings.ToLower(err.Error())
|
||||||
|
if strings.Contains(message, "oauth2") || strings.Contains(message, "token") {
|
||||||
|
return "Google 授权已失效,请打开配置,断开后重新连接"
|
||||||
|
}
|
||||||
|
return "上传 Google 云端硬盘失败,请检查服务器网络或重新连接 Google 账号"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) sendBackupToTelegram(ctx context.Context, path string) error {
|
func (a *App) sendBackupToTelegram(ctx context.Context, path string) error {
|
||||||
|
a.ensureBackupTransfer("telegram", path)
|
||||||
schedule, _ := a.loadBackupSchedule(ctx)
|
schedule, _ := a.loadBackupSchedule(ctx)
|
||||||
token, chatID, err := a.backupTelegramCredentials(ctx, schedule)
|
token, chatID, err := a.backupTelegramCredentials(ctx, schedule)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1052,7 +1320,7 @@ func (a *App) sendTelegramDocument(ctx context.Context, token, chatID, path stri
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
_, writeErr = io.Copy(part, file)
|
_, writeErr = io.Copy(part, &backupProgressReader{reader: file, onProgress: func(uploaded int64) { a.updateBackupTransfer("telegram", path, uploaded) }})
|
||||||
}()
|
}()
|
||||||
endpoint := strings.TrimRight(a.telegramURL, "/") + "/bot" + token + "/sendDocument"
|
endpoint := strings.TrimRight(a.telegramURL, "/") + "/bot" + token + "/sendDocument"
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, reader)
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, reader)
|
||||||
@@ -1072,6 +1340,21 @@ func (a *App) sendTelegramDocument(ctx context.Context, token, chatID, path stri
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type backupProgressReader struct {
|
||||||
|
reader io.Reader
|
||||||
|
uploaded int64
|
||||||
|
onProgress func(int64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *backupProgressReader) Read(p []byte) (int, error) {
|
||||||
|
n, err := r.reader.Read(p)
|
||||||
|
if n > 0 {
|
||||||
|
r.uploaded += int64(n)
|
||||||
|
r.onProgress(r.uploaded)
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) listBackups() ([]backupItem, error) {
|
func (a *App) listBackups() ([]backupItem, error) {
|
||||||
dir := a.config().BackupDir
|
dir := a.config().BackupDir
|
||||||
if dir == "" {
|
if dir == "" {
|
||||||
@@ -1160,6 +1443,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":
|
||||||
@@ -1212,18 +1500,34 @@ func (a *App) runScheduledBackup(ctx context.Context) {
|
|||||||
path, runErr := a.createDisasterBackup(ctx, password)
|
path, runErr := a.createDisasterBackup(ctx, password)
|
||||||
password = ""
|
password = ""
|
||||||
localBackupSucceeded := runErr == nil
|
localBackupSucceeded := runErr == nil
|
||||||
|
var deliveryMessages []string
|
||||||
if localBackupSucceeded {
|
if localBackupSucceeded {
|
||||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
_, _ = a.db.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES('backupScheduleLastRun',?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`, now, now)
|
_, _ = a.db.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES('backupScheduleLastRun',?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`, now, now)
|
||||||
var deliveryErrors []error
|
var deliveryErrors []error
|
||||||
|
if schedule.GoogleDriveEnabled {
|
||||||
|
a.queueBackupTransfer("googleDrive", path)
|
||||||
|
}
|
||||||
|
if schedule.TelegramEnabled {
|
||||||
|
a.queueBackupTransfer("telegram", path)
|
||||||
|
}
|
||||||
if schedule.GoogleDriveEnabled {
|
if schedule.GoogleDriveEnabled {
|
||||||
if driveErr := a.uploadBackupToGoogleDrive(ctx, path); driveErr != nil {
|
if driveErr := a.uploadBackupToGoogleDrive(ctx, path); driveErr != nil {
|
||||||
|
message := googleDriveUploadMessage(driveErr)
|
||||||
|
a.finishBackupTransfer("googleDrive", path, message)
|
||||||
|
deliveryMessages = append(deliveryMessages, message)
|
||||||
deliveryErrors = append(deliveryErrors, fmt.Errorf("google drive: %w", driveErr))
|
deliveryErrors = append(deliveryErrors, fmt.Errorf("google drive: %w", driveErr))
|
||||||
|
} else {
|
||||||
|
a.finishBackupTransfer("googleDrive", path, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if schedule.TelegramEnabled {
|
if schedule.TelegramEnabled {
|
||||||
if telegramErr := a.sendBackupToTelegram(ctx, path); telegramErr != nil {
|
if telegramErr := a.sendBackupToTelegram(ctx, path); telegramErr != nil {
|
||||||
|
a.finishBackupTransfer("telegram", path, telegramErr.Error())
|
||||||
|
deliveryMessages = append(deliveryMessages, "Telegram 发送失败:"+telegramErr.Error())
|
||||||
deliveryErrors = append(deliveryErrors, fmt.Errorf("telegram: %w", telegramErr))
|
deliveryErrors = append(deliveryErrors, fmt.Errorf("telegram: %w", telegramErr))
|
||||||
|
} else {
|
||||||
|
a.finishBackupTransfer("telegram", path, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
runErr = errors.Join(deliveryErrors...)
|
runErr = errors.Join(deliveryErrors...)
|
||||||
@@ -1232,7 +1536,11 @@ func (a *App) runScheduledBackup(ctx context.Context) {
|
|||||||
publicError := ""
|
publicError := ""
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
status = "failed"
|
status = "failed"
|
||||||
publicError = "定时备份或云端推送失败,请查看服务日志"
|
if len(deliveryMessages) > 0 {
|
||||||
|
publicError = strings.Join(deliveryMessages, ";")
|
||||||
|
} else {
|
||||||
|
publicError = "定时备份创建失败,请检查服务器存储空间"
|
||||||
|
}
|
||||||
a.log.Error("scheduled backup", "error", runErr)
|
a.log.Error("scheduled backup", "error", runErr)
|
||||||
}
|
}
|
||||||
a.backupMu.Lock()
|
a.backupMu.Lock()
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ package app
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
|
||||||
"mime/multipart"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -13,6 +12,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBackupEndpointsRejectMismatchedConfirmation(t *testing.T) {
|
func TestBackupEndpointsRejectMismatchedConfirmation(t *testing.T) {
|
||||||
@@ -57,42 +57,109 @@ func TestDiscoverTelegramGroupsReturnsUniqueCandidates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGoogleDriveUploadRequestUsesMultipartRelated(t *testing.T) {
|
func TestGoogleDriveResumableRequest(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "newszxcn-backup-test.tar.zst.enc")
|
path := filepath.Join(dir, "newszxcn-backup-test.tar.zst.enc")
|
||||||
if err := os.WriteFile(path, []byte("encrypted backup"), 0o600); err != nil {
|
if err := os.WriteFile(path, []byte("encrypted backup"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
req, err := newGoogleDriveUploadRequest(context.Background(), path, "folder-123")
|
req, size, err := newGoogleDriveResumableRequest(context.Background(), path, "folder-123")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
mediaType, params, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
|
if size != int64(len("encrypted backup")) {
|
||||||
if err != nil || mediaType != "multipart/related" || params["boundary"] == "" {
|
t.Fatalf("upload size = %d", size)
|
||||||
t.Fatalf("content type = %q, %v", req.Header.Get("Content-Type"), err)
|
|
||||||
}
|
}
|
||||||
reader := multipart.NewReader(req.Body, params["boundary"])
|
if req.URL.Query().Get("uploadType") != "resumable" || req.Header.Get("X-Upload-Content-Length") != fmt.Sprint(size) {
|
||||||
metadataPart, err := reader.NextPart()
|
t.Fatalf("resumable request = %s headers=%v", req.URL, req.Header)
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
}
|
||||||
var metadata struct {
|
var metadata struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Parents []string `json:"parents"`
|
Parents []string `json:"parents"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(metadataPart).Decode(&metadata); err != nil {
|
if err := json.NewDecoder(req.Body).Decode(&metadata); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if metadata.Name != filepath.Base(path) || len(metadata.Parents) != 1 || metadata.Parents[0] != "folder-123" {
|
if metadata.Name != filepath.Base(path) || len(metadata.Parents) != 1 || metadata.Parents[0] != "folder-123" {
|
||||||
t.Fatalf("metadata = %+v", metadata)
|
t.Fatalf("metadata = %+v", metadata)
|
||||||
}
|
}
|
||||||
filePart, err := reader.NextPart()
|
}
|
||||||
|
|
||||||
|
func TestBackupProgressReaderReportsBytes(t *testing.T) {
|
||||||
|
var updates []int64
|
||||||
|
reader := &backupProgressReader{reader: strings.NewReader("encrypted backup"), onProgress: func(uploaded int64) {
|
||||||
|
updates = append(updates, uploaded)
|
||||||
|
}}
|
||||||
|
raw, err := io.ReadAll(reader)
|
||||||
|
if err != nil || string(raw) != "encrypted backup" {
|
||||||
|
t.Fatalf("read = %q, %v", raw, err)
|
||||||
|
}
|
||||||
|
if len(updates) == 0 || updates[len(updates)-1] != int64(len(raw)) {
|
||||||
|
t.Fatalf("progress updates = %v", updates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoogleDriveUploadMessage(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{http.StatusUnauthorized, `{}`, "授权已失效"},
|
||||||
|
{http.StatusForbidden, `{"reason":"storageQuotaExceeded"}`, "空间不足"},
|
||||||
|
{http.StatusForbidden, `{}`, "无上传权限"},
|
||||||
|
{http.StatusTooManyRequests, `{}`, "请求过于频繁"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
message := googleDriveUploadMessage(&googleDriveAPIError{Operation: "upload", StatusCode: test.status, Body: test.body})
|
||||||
|
if !strings.Contains(message, test.want) {
|
||||||
|
t.Fatalf("message %q does not contain %q", message, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoogleDriveChunkUploadAndProgress(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "large-backup.tar.zst.enc")
|
||||||
|
size := int64(googleDriveUploadChunkSize + 3)
|
||||||
|
file, err := os.Create(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
raw, err := io.ReadAll(filePart)
|
if err := file.Truncate(size); err != nil {
|
||||||
if err != nil || string(raw) != "encrypted backup" {
|
t.Fatal(err)
|
||||||
t.Fatalf("uploaded bytes = %q, %v", raw, err)
|
}
|
||||||
|
_ = file.Close()
|
||||||
|
var ranges []string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ranges = append(ranges, r.Header.Get("Content-Range"))
|
||||||
|
_, _ = io.Copy(io.Discard, r.Body)
|
||||||
|
if len(ranges) == 1 {
|
||||||
|
w.WriteHeader(http.StatusPermanentRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, `{"id":"uploaded"}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
a := newTestApp(t)
|
||||||
|
stopTestWorkers(a)
|
||||||
|
if !a.startBackupTransfer("googleDrive", path) {
|
||||||
|
t.Fatal("failed to start transfer")
|
||||||
|
}
|
||||||
|
if err := a.uploadGoogleDriveChunks(context.Background(), server.Client(), server.URL, path, size); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wantRanges := []string{
|
||||||
|
fmt.Sprintf("bytes 0-%d/%d", googleDriveUploadChunkSize-1, size),
|
||||||
|
fmt.Sprintf("bytes %d-%d/%d", googleDriveUploadChunkSize, size-1, size),
|
||||||
|
}
|
||||||
|
if len(ranges) != len(wantRanges) || ranges[0] != wantRanges[0] || ranges[1] != wantRanges[1] {
|
||||||
|
t.Fatalf("content ranges = %v, want %v", ranges, wantRanges)
|
||||||
|
}
|
||||||
|
transfer := a.backupTransfers[backupTransferKey("googleDrive", path)]
|
||||||
|
if transfer == nil || transfer.Uploaded != size {
|
||||||
|
t.Fatalf("transfer = %+v", transfer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +187,150 @@ 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)
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(10 * time.Second)
|
||||||
|
for {
|
||||||
|
a.backupMu.Lock()
|
||||||
|
status := a.backupJob.Status
|
||||||
|
a.backupMu.Unlock()
|
||||||
|
if status != "running" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("manual backup did not finish before timeout")
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
@@ -21,7 +21,6 @@ import {
|
|||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
SidebarProvider,
|
SidebarProvider,
|
||||||
SidebarRail,
|
|
||||||
SidebarTrigger,
|
SidebarTrigger,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
@@ -64,8 +63,8 @@ function ProtectedContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider className="admin-app-shell">
|
||||||
<Sidebar collapsible="icon">
|
<Sidebar collapsible="none" className="admin-sidebar-panel">
|
||||||
<SidebarHeader className="border-b">
|
<SidebarHeader className="border-b">
|
||||||
<div className="space-y-1 group-data-[collapsible=icon]:space-y-0">
|
<div className="space-y-1 group-data-[collapsible=icon]:space-y-0">
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
@@ -99,7 +98,7 @@ function ProtectedContent() {
|
|||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton size="lg" className="group-data-[collapsible=icon]:!p-0" asChild>
|
<SidebarMenuButton size="lg" asChild>
|
||||||
<Link to="/profile">
|
<Link to="/profile">
|
||||||
<Avatar className="h-8 w-8 rounded-lg">
|
<Avatar className="h-8 w-8 rounded-lg">
|
||||||
<AvatarFallback className="rounded-lg bg-muted text-foreground">
|
<AvatarFallback className="rounded-lg bg-muted text-foreground">
|
||||||
@@ -115,15 +114,14 @@ function ProtectedContent() {
|
|||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<Button variant="outline" size="sm" className="w-full gap-2 text-xs" onClick={logout}>
|
<Button variant="destructive" size="sm" className="w-full gap-2 text-xs" onClick={logout} aria-label="退出登录" title="退出登录">
|
||||||
<LogOut className="h-3.5 w-3.5" />退出登录
|
<LogOut className="h-3.5 w-3.5" /><span>退出登录</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
<SidebarRail />
|
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
<SidebarInset>
|
<SidebarInset className="admin-content-panel">
|
||||||
<div className="flex min-h-svh flex-col bg-muted/20">
|
<div className="flex min-h-0 flex-1 flex-col bg-transparent">
|
||||||
<div className="flex h-12 items-center gap-3 border-b bg-background px-3 md:hidden">
|
<div className="flex h-12 items-center gap-3 border-b bg-background px-3 md:hidden">
|
||||||
<SidebarTrigger aria-label="打开导航" />
|
<SidebarTrigger aria-label="打开导航" />
|
||||||
<div className="min-w-0 flex-1 truncate text-sm font-semibold">
|
<div className="min-w-0 flex-1 truncate text-sm font-semibold">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const buttonVariants = cva(
|
|||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"bg-primary text-primary-foreground hover:bg-primary/90",
|
"bg-[hsl(var(--action-primary))] text-[hsl(var(--action-primary-foreground))] hover:bg-[hsl(var(--action-primary)/0.9)]",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
outline:
|
outline:
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const DialogContent = React.forwardRef<
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card text-card-foreground p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -180,21 +180,6 @@ const Sidebar = React.forwardRef<
|
|||||||
) => {
|
) => {
|
||||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||||
|
|
||||||
if (collapsible === "none") {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||||
@@ -219,6 +204,22 @@ const Sidebar = React.forwardRef<
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (collapsible === "none") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
className={cn(
|
||||||
|
"hidden min-h-svh w-[--sidebar-width] shrink-0 self-stretch flex-col bg-sidebar text-sidebar-foreground md:flex",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -519,7 +520,7 @@ const SidebarMenuItem = React.forwardRef<
|
|||||||
SidebarMenuItem.displayName = "SidebarMenuItem"
|
SidebarMenuItem.displayName = "SidebarMenuItem"
|
||||||
|
|
||||||
const sidebarMenuButtonVariants = cva(
|
const sidebarMenuButtonVariants = cva(
|
||||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-[hsl(var(--sidebar-active))] data-[active=true]:font-medium data-[active=true]:text-[hsl(var(--sidebar-active-foreground))] data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -730,7 +731,7 @@ const SidebarMenuSubButton = React.forwardRef<
|
|||||||
data-active={isActive}
|
data-active={isActive}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
"data-[active=true]:bg-[hsl(var(--sidebar-active))] data-[active=true]:text-[hsl(var(--sidebar-active-foreground))]",
|
||||||
size === "sm" && "text-xs",
|
size === "sm" && "text-xs",
|
||||||
size === "md" && "text-sm",
|
size === "md" && "text-sm",
|
||||||
"group-data-[collapsible=icon]:hidden",
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
|||||||
+265
-28
@@ -12,10 +12,12 @@
|
|||||||
--popover-foreground: 0 0% 9%;
|
--popover-foreground: 0 0% 9%;
|
||||||
--primary: 0 0% 12%;
|
--primary: 0 0% 12%;
|
||||||
--primary-foreground: 0 0% 98%;
|
--primary-foreground: 0 0% 98%;
|
||||||
|
--action-primary: 0 0% 12%;
|
||||||
|
--action-primary-foreground: 0 0% 98%;
|
||||||
--secondary: 0 0% 96.1%;
|
--secondary: 0 0% 96.1%;
|
||||||
--secondary-foreground: 0 0% 12%;
|
--secondary-foreground: 0 0% 12%;
|
||||||
--muted: 0 0% 96.1%;
|
--muted: 0 0% 96.1%;
|
||||||
--muted-foreground: 0 0% 42%;
|
--muted-foreground: 0 0% 36%;
|
||||||
--accent: 0 0% 95%;
|
--accent: 0 0% 95%;
|
||||||
--accent-foreground: 0 0% 12%;
|
--accent-foreground: 0 0% 12%;
|
||||||
--destructive: 358 88% 61%;
|
--destructive: 358 88% 61%;
|
||||||
@@ -33,6 +35,11 @@
|
|||||||
--sidebar-accent-foreground: 0 0% 12%;
|
--sidebar-accent-foreground: 0 0% 12%;
|
||||||
--sidebar-border: 0 0% 89.8%;
|
--sidebar-border: 0 0% 89.8%;
|
||||||
--sidebar-ring: 0 0% 42%;
|
--sidebar-ring: 0 0% 42%;
|
||||||
|
--sidebar-active: 207 100% 92%;
|
||||||
|
--sidebar-active-foreground: 202 100% 30%;
|
||||||
|
--compose-send: 217 89% 43%;
|
||||||
|
--compose-send-hover: 216 94% 32%;
|
||||||
|
--mail-selected: 210 100% 96%;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { @apply border-border; }
|
* { @apply border-border; }
|
||||||
@@ -53,37 +60,55 @@
|
|||||||
html.dark { color-scheme: dark; }
|
html.dark { color-scheme: dark; }
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 240 10% 3.9%;
|
--background: 220 4% 14%;
|
||||||
--foreground: 0 0% 98%;
|
--foreground: 0 0% 94%;
|
||||||
--card: 240 10% 3.9%;
|
--card: 220 4% 16%;
|
||||||
--card-foreground: 0 0% 98%;
|
--card-foreground: 0 0% 94%;
|
||||||
--popover: 240 10% 3.9%;
|
--popover: 220 4% 18%;
|
||||||
--popover-foreground: 0 0% 98%;
|
--popover-foreground: 0 0% 94%;
|
||||||
--primary: 0 0% 98%;
|
--primary: 204 100% 55%;
|
||||||
--primary-foreground: 240 5.9% 10%;
|
--primary-foreground: 0 0% 100%;
|
||||||
--secondary: 240 3.7% 15.9%;
|
--action-primary: 220 4% 25%;
|
||||||
--secondary-foreground: 0 0% 98%;
|
--action-primary-foreground: 0 0% 94%;
|
||||||
--muted: 240 3.7% 15.9%;
|
--secondary: 220 4% 21%;
|
||||||
--muted-foreground: 240 5% 64.9%;
|
--secondary-foreground: 0 0% 94%;
|
||||||
--accent: 240 3.7% 15.9%;
|
--muted: 220 4% 21%;
|
||||||
--accent-foreground: 0 0% 98%;
|
--muted-foreground: 220 3% 72%;
|
||||||
--destructive: 0 62.8% 30.6%;
|
--accent: 220 4% 25%;
|
||||||
|
--accent-foreground: 0 0% 96%;
|
||||||
|
--destructive: 359 74% 47%;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
--border: 240 3.7% 15.9%;
|
--border: 0 0% 100% / 0.12;
|
||||||
--input: 240 3.7% 15.9%;
|
--input: 0 0% 100% / 0.16;
|
||||||
--ring: 240 4.9% 83.9%;
|
--ring: 204 100% 55%;
|
||||||
--sidebar-background: 240 5.9% 10%;
|
--sidebar-background: 220 4% 19%;
|
||||||
--sidebar-foreground: 240 4.8% 95.9%;
|
--sidebar-foreground: 0 0% 86%;
|
||||||
--sidebar-primary: 0 0% 98%;
|
--sidebar-primary: 204 100% 55%;
|
||||||
--sidebar-primary-foreground: 240 5.9% 10%;
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
--sidebar-accent: 240 3.7% 15.9%;
|
--sidebar-accent: 220 4% 25%;
|
||||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
--sidebar-accent-foreground: 0 0% 96%;
|
||||||
--sidebar-border: 240 3.7% 15.9%;
|
--sidebar-border: 0 0% 100% / 0.1;
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
--sidebar-ring: 204 100% 55%;
|
||||||
|
--sidebar-active: 204 100% 29%;
|
||||||
|
--sidebar-active-foreground: 204 100% 76%;
|
||||||
|
--compose-send: 204 100% 40%;
|
||||||
|
--compose-send-hover: 204 100% 47%;
|
||||||
|
--mail-selected: 220 5% 24%;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
|
.compose-send-button.compose-send-button {
|
||||||
|
background: hsl(var(--compose-send));
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-send-button.compose-send-button:hover {
|
||||||
|
background: hsl(var(--compose-send-hover));
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
.mail-shell-grid {
|
.mail-shell-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--app-sidebar-width, 18rem) minmax(0, 1fr);
|
grid-template-columns: var(--app-sidebar-width, 18rem) minmax(0, 1fr);
|
||||||
@@ -106,6 +131,167 @@
|
|||||||
max-width: 384px;
|
max-width: 384px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark .mail-list-pane {
|
||||||
|
border-color: hsl(0 0% 100% / 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .mail-sidebar-pane {
|
||||||
|
border-color: hsl(0 0% 100% / 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.mail-app-shell,
|
||||||
|
.settings-app-shell,
|
||||||
|
.admin-app-shell {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
background-image: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-sidebar-wrap,
|
||||||
|
.mail-list-pane,
|
||||||
|
.mail-detail-pane,
|
||||||
|
.settings-sidebar-panel,
|
||||||
|
.settings-content-panel,
|
||||||
|
.admin-content-panel {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
background-color: rgb(255 255 255) !important;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-sidebar-wrap,
|
||||||
|
.settings-sidebar-panel {
|
||||||
|
border-right: 1px solid hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar-panel,
|
||||||
|
.admin-sidebar-panel [data-sidebar="sidebar"] {
|
||||||
|
border-right: 1px solid hsl(var(--border));
|
||||||
|
background-color: rgb(255 255 255) !important;
|
||||||
|
box-shadow: none;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-sidebar-pane {
|
||||||
|
background-color: transparent !important;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-list-surface,
|
||||||
|
.mail-detail-surface,
|
||||||
|
.mail-content-grid,
|
||||||
|
.compact-mail-surface {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-content-panel > main,
|
||||||
|
.admin-content-panel > div {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-glass-content [data-slot="card"],
|
||||||
|
.settings-glass-content main > div > section.bg-card,
|
||||||
|
.settings-glass-content main > div > div > section.bg-card {
|
||||||
|
background-color: rgb(255 255 255) !important;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .mail-app-shell,
|
||||||
|
.dark .settings-app-shell,
|
||||||
|
.dark .admin-app-shell {
|
||||||
|
background-color: hsl(220 4% 13%);
|
||||||
|
background-image:
|
||||||
|
linear-gradient(hsl(220 20% 4% / 0.34), hsl(220 20% 4% / 0.34)),
|
||||||
|
url("/purcarte-moonlit.webp");
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .mail-sidebar-wrap,
|
||||||
|
.dark .mail-list-pane,
|
||||||
|
.dark .mail-detail-pane,
|
||||||
|
.dark .settings-sidebar-panel,
|
||||||
|
.dark .settings-content-panel,
|
||||||
|
.dark .admin-content-panel {
|
||||||
|
border-color: hsl(0 0% 100% / 0.14);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .mail-sidebar-wrap {
|
||||||
|
background-color: rgb(0 0 0 / 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .mail-sidebar-pane {
|
||||||
|
background-color: transparent !important;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .mail-list-pane {
|
||||||
|
background-color: rgb(0 0 0 / 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .mail-list-surface {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .mail-detail-pane,
|
||||||
|
.dark .mail-detail-surface {
|
||||||
|
background-color: rgb(0 0 0 / 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .mail-content-grid {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .compact-mail-surface {
|
||||||
|
background-color: rgb(0 0 0 / 0.5) !important;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .settings-sidebar-panel,
|
||||||
|
.dark .settings-content-panel,
|
||||||
|
.dark .admin-content-panel {
|
||||||
|
background-color: rgb(0 0 0 / 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .admin-sidebar-panel,
|
||||||
|
.dark .admin-sidebar-panel [data-sidebar="sidebar"] {
|
||||||
|
background-color: rgb(0 0 0 / 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .settings-glass-content [data-slot="card"],
|
||||||
|
.dark .settings-glass-content main > div > section.bg-card,
|
||||||
|
.dark .settings-glass-content main > div > div > section.bg-card {
|
||||||
|
background-color: rgb(0 0 0 / 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .admin-page [data-slot="card"],
|
||||||
|
.dark .admin-page .overview-setup-panel {
|
||||||
|
background-color: rgb(0 0 0 / 0.5) !important;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.mail-shell-grid,
|
||||||
|
.settings-shell-grid {
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
gap: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-content-grid {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.mail-content-grid {
|
.mail-content-grid {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
@@ -151,7 +337,58 @@
|
|||||||
|
|
||||||
.admin-page [data-slot="card"] {
|
.admin-page [data-slot="card"] {
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
box-shadow: none;
|
border-color: hsl(var(--border));
|
||||||
|
background-color: rgb(255 255 255) !important;
|
||||||
|
box-shadow: 0 1px 2px hsl(0 0% 0% / 0.04);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-page .overview-setup-panel {
|
||||||
|
background-color: rgb(255 255 255) !important;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-stat {
|
||||||
|
border-right: 1px solid hsl(var(--border));
|
||||||
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-stat:nth-child(2n) {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-stat:nth-last-child(-n + 2) {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.overview-stat:nth-child(2n) {
|
||||||
|
border-right: 1px solid hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-stat:nth-child(4n) {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-stat:nth-last-child(-n + 4) {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1280px) {
|
||||||
|
.overview-stat {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-stat:nth-child(4n) {
|
||||||
|
border-right: 1px solid hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-stat:last-child {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-page [data-slot="card-header"] {
|
.admin-page [data-slot="card-header"] {
|
||||||
|
|||||||
@@ -204,9 +204,10 @@ 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 BackupTransfer = { provider: "telegram" | "googleDrive"; name: string; status: "queued" | "running" | "success" | "failed"; uploaded: number; total: number; startedAt: string; finishedAt?: string; error?: string }
|
||||||
|
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; transfers: BackupTransfer[] }
|
||||||
export type SystemSettings = {
|
export type SystemSettings = {
|
||||||
publicHostname: string
|
publicHostname: string
|
||||||
publicBaseUrl: string
|
publicBaseUrl: string
|
||||||
|
|||||||
@@ -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" }),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
|||||||
import { Navigate, RouterProvider, createBrowserRouter } from "react-router-dom"
|
import { Navigate, RouterProvider, createBrowserRouter } from "react-router-dom"
|
||||||
import { Toaster } from "@/components/ui/toaster"
|
import { Toaster } from "@/components/ui/toaster"
|
||||||
import { LanguageDomSync } from "@/lib/language"
|
import { LanguageDomSync } from "@/lib/language"
|
||||||
|
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||||
import { ProtectedLayout } from "@/components/protected-layout"
|
import { ProtectedLayout } from "@/components/protected-layout"
|
||||||
import { AdminOnly } from "@/components/admin-only"
|
import { AdminOnly } from "@/components/admin-only"
|
||||||
import "./index.css"
|
import "./index.css"
|
||||||
@@ -15,6 +16,8 @@ const AdminPage = React.lazy(() => import("@/pages/admin").then((module) => ({ d
|
|||||||
const ProfilePage = React.lazy(() => import("@/pages/profile").then((module) => ({ default: module.ProfilePage })))
|
const ProfilePage = React.lazy(() => import("@/pages/profile").then((module) => ({ default: module.ProfilePage })))
|
||||||
const NotFoundPage = React.lazy(() => import("@/pages/not-found").then((module) => ({ default: module.NotFoundPage })))
|
const NotFoundPage = React.lazy(() => import("@/pages/not-found").then((module) => ({ default: module.NotFoundPage })))
|
||||||
|
|
||||||
|
applyTheme(getInitialTheme())
|
||||||
|
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 10_000 } } })
|
const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 10_000 } } })
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{ path: "/login", element: <LoginPage /> },
|
{ path: "/login", element: <LoginPage /> },
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import * as React from "react"
|
|||||||
import DOMPurify from "dompurify"
|
import DOMPurify from "dompurify"
|
||||||
import { useSearchParams } from "react-router-dom"
|
import { useSearchParams } from "react-router-dom"
|
||||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
import { ArrowRight, BookOpen, CheckCircle2, ChevronDown, Circle, ClipboardList, Cloud, Copy, Download, ExternalLink, Eye, EyeOff, Github, Globe2, HardDrive, KeyRound, Loader2, Mail, Mailbox, MoreHorizontal, RefreshCcw, Scale, Search, Send, ShieldCheck, Star, Trash2, Users } from "lucide-react"
|
import { BookOpen, CheckCircle2, ChevronDown, Circle, ClipboardList, Cloud, Copy, Download, ExternalLink, Eye, EyeOff, Github, Globe2, HardDrive, KeyRound, Loader2, Mail, Mailbox, MoreHorizontal, RefreshCcw, Scale, Search, Send, ShieldCheck, Star, Trash2, Users } from "lucide-react"
|
||||||
import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, MaildirSyncHealth, PermissionGroup, PermissionInfo, PermissionLimits, SystemSettings } from "@/lib/api"
|
import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, MaildirSyncHealth, PermissionGroup, PermissionInfo, PermissionLimits, SystemSettings } from "@/lib/api"
|
||||||
import { cn, decodeMimeHeader, formatBytes, formatDate } from "@/lib/utils"
|
import { cn, decodeMimeHeader, formatBytes, formatDate } from "@/lib/utils"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -24,7 +24,7 @@ import { SystemVersionDialog } from "@/components/system-version-dialog"
|
|||||||
import { useMe } from "@/hooks/use-me"
|
import { useMe } from "@/hooks/use-me"
|
||||||
import { useToast } from "@/hooks/use-toast"
|
import { useToast } from "@/hooks/use-toast"
|
||||||
import { hasAnyPermission, hasPermission } from "@/lib/permissions"
|
import { hasAnyPermission, hasPermission } from "@/lib/permissions"
|
||||||
import type { PermissionKey, TelegramPairing } from "@/lib/api-types"
|
import type { BackupTransfer, PermissionKey, TelegramPairing } from "@/lib/api-types"
|
||||||
|
|
||||||
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "backups" | "settings"
|
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "backups" | "settings"
|
||||||
type SettingsTab = "base" | "smtp" | "storage" | "mail" | "notifications" | "externalImap" | "templates" | "security" | "about"
|
type SettingsTab = "base" | "smtp" | "storage" | "mail" | "notifications" | "externalImap" | "templates" | "security" | "about"
|
||||||
@@ -134,19 +134,23 @@ export function AdminPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-[calc(100svh-3rem)] md:h-svh">
|
<ScrollArea className="h-[calc(100svh-3rem)] md:h-full">
|
||||||
<main className="admin-page mx-auto w-full max-w-[1180px] px-3 pb-10 pt-3 sm:px-4 sm:pt-4">
|
<main className="admin-page mx-auto w-full max-w-[1440px] px-3 pb-8 pt-3 sm:px-4 sm:pt-4">
|
||||||
<AdminPageHeader section={section} refreshing={refreshing} onRefresh={refreshAdminPage} />
|
<AdminPageHeader section={section} refreshing={refreshing} onRefresh={refreshAdminPage} />
|
||||||
|
|
||||||
{sectionQuery?.isError && <QueryFailure error={sectionQuery.error} onRetry={() => { void sectionQuery.refetch() }} />}
|
{sectionQuery?.isError && <QueryFailure error={sectionQuery.error} onRetry={() => { void sectionQuery.refetch() }} />}
|
||||||
|
|
||||||
{section === "overview" && canOverview && (
|
{section === "overview" && canOverview && (
|
||||||
<div className="mb-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
<section className="overview-stats mb-3 grid grid-cols-2 overflow-hidden rounded-lg border bg-card md:grid-cols-4 xl:grid-cols-8">
|
||||||
<Stat icon={<Users />} label="账号" value={overview.data?.users || 0} />
|
<Stat icon={<Users />} label="账号" value={overview.data?.users || 0} />
|
||||||
<Stat icon={<Globe2 />} label="邮件域名" value={overview.data?.domains || 0} />
|
<Stat icon={<Globe2 />} label="邮件域名" value={overview.data?.domains || 0} />
|
||||||
<Stat icon={<Mailbox />} label="邮箱" value={overview.data?.mailboxes || 0} />
|
<Stat icon={<Mailbox />} label="邮箱" value={overview.data?.mailboxes || 0} />
|
||||||
<Stat icon={<ShieldCheck />} label="存储用量" value={formatBytes(overview.data?.storageBytes || 0)} />
|
<Stat icon={<ShieldCheck />} label="存储用量" value={formatBytes(overview.data?.storageBytes || 0)} />
|
||||||
</div>
|
<Stat label="活跃账号" value={overview.data?.activeUsers || 0} />
|
||||||
|
<Stat label="活跃邮箱" value={overview.data?.activeMailboxes || 0} />
|
||||||
|
<Stat label="邮件转发" value={overview.data?.aliases || 0} />
|
||||||
|
<Stat label="未读邮件" value={overview.data?.unreadMessages || 0} />
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{section === "overview" && <OverviewSection overview={overview.data} domains={domainItems} settings={settings.data} visibleSections={visibleSections} onSectionChange={(next) => setParams(next === "overview" ? {} : { section: next })} />}
|
{section === "overview" && <OverviewSection overview={overview.data} domains={domainItems} settings={settings.data} visibleSections={visibleSections} onSectionChange={(next) => setParams(next === "overview" ? {} : { section: next })} />}
|
||||||
@@ -191,44 +195,32 @@ function AdminPageHeader({ section, refreshing, onRefresh }: { section: Section;
|
|||||||
function OverviewSection({ overview, domains, settings, visibleSections, onSectionChange }: { overview?: { activeUsers: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number }; domains: Domain[]; settings?: SystemSettings; visibleSections: Section[]; onSectionChange: (section: Section) => void }) {
|
function OverviewSection({ overview, domains, settings, visibleSections, onSectionChange }: { overview?: { activeUsers: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number }; domains: Domain[]; settings?: SystemSettings; visibleSections: Section[]; onSectionChange: (section: Section) => void }) {
|
||||||
const checklist = setupChecklist(overview, domains, settings).filter((item) => visibleSections.includes(item.section))
|
const checklist = setupChecklist(overview, domains, settings).filter((item) => visibleSections.includes(item.section))
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-3">
|
||||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_420px]">
|
<section className="overview-setup-panel rounded-lg border bg-card p-4 text-card-foreground">
|
||||||
<Card>
|
<h2 className="mb-3 text-sm font-semibold">首次配置</h2>
|
||||||
<CardHeader><CardTitle>系统状态</CardTitle></CardHeader>
|
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
|
||||||
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<InfoBox label="活跃账号" value={overview?.activeUsers || 0} />
|
|
||||||
<InfoBox label="活跃邮箱" value={overview?.activeMailboxes || 0} />
|
|
||||||
<InfoBox label="邮件转发" value={overview?.aliases || 0} />
|
|
||||||
<InfoBox label="未读邮件" value={overview?.unreadMessages || 0} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader><CardTitle>首次配置</CardTitle></CardHeader>
|
|
||||||
<CardContent className="space-y-2">
|
|
||||||
{checklist.map((item) => (
|
{checklist.map((item) => (
|
||||||
<Button key={item.key} type="button" variant="outline" className="h-auto w-full justify-start gap-3 px-3 py-2 text-left font-normal" onClick={() => onSectionChange(item.section)}>
|
<Button key={item.key} type="button" variant="outline" className="h-auto min-h-[62px] w-full justify-start gap-2.5 px-3 py-2 text-left font-normal" onClick={() => onSectionChange(item.section)}>
|
||||||
{item.done ? <CheckCircle2 className="h-4 w-4 shrink-0 text-green-600" /> : <Circle className="h-4 w-4 shrink-0 text-muted-foreground" />}
|
{item.done ? <CheckCircle2 className="h-4 w-4 shrink-0 text-green-600" /> : <Circle className="h-4 w-4 shrink-0 text-muted-foreground" />}
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block font-medium">{item.title}</span>
|
<span className="block font-medium">{item.title}</span>
|
||||||
<span className="block truncate text-xs text-muted-foreground">{item.detail}</span>
|
<span className="block truncate text-xs text-muted-foreground">{item.detail}</span>
|
||||||
</span>
|
</span>
|
||||||
<ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</section>
|
||||||
</div>
|
<div className="grid items-start gap-3 lg:grid-cols-2">
|
||||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_420px]">
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle>DNS 状态</CardTitle></CardHeader>
|
<CardHeader className="pb-3"><CardTitle>DNS 状态</CardTitle></CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="grid gap-2 sm:grid-cols-2">
|
||||||
{domains.map((domain) => <DomainBadgeRow key={domain.id} domain={domain} />)}
|
{domains.map((domain) => <DomainBadgeRow key={domain.id} domain={domain} />)}
|
||||||
{domains.length === 0 && <Empty text="暂无域名" />}
|
{domains.length === 0 && <Empty text="暂无域名" />}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle>运行提示</CardTitle></CardHeader>
|
<CardHeader className="pb-3"><CardTitle>运行信息</CardTitle></CardHeader>
|
||||||
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
<CardContent className="grid gap-2 text-sm text-muted-foreground sm:grid-cols-2">
|
||||||
<InfoLine label="公网地址" value={settings?.publicBaseUrl || "-"} />
|
<InfoLine label="公网地址" value={settings?.publicBaseUrl || "-"} />
|
||||||
<InfoLine label="SMTP" value={settings?.smtpHost ? `${settings.smtpHost}:${settings.smtpPort}` : "-"} />
|
<InfoLine label="SMTP" value={settings?.smtpHost ? `${settings.smtpHost}:${settings.smtpPort}` : "-"} />
|
||||||
<InfoLine label="注册" value={settings?.openRegistration ? "已开放" : "关闭"} />
|
<InfoLine label="注册" value={settings?.openRegistration ? "已开放" : "关闭"} />
|
||||||
@@ -271,7 +263,7 @@ function BackupsSection() {
|
|||||||
const backups = useQuery({
|
const backups = useQuery({
|
||||||
queryKey: ["admin", "backups"],
|
queryKey: ["admin", "backups"],
|
||||||
queryFn: api.backups,
|
queryFn: api.backups,
|
||||||
refetchInterval: (query) => query.state.data?.job?.status === "running" ? 2000 : false,
|
refetchInterval: (query) => query.state.data?.job?.status === "running" || query.state.data?.transfers?.some((item) => item.status === "running" || item.status === "queued") ? 2000 : false,
|
||||||
})
|
})
|
||||||
const [createOpen, setCreateOpen] = React.useState(false)
|
const [createOpen, setCreateOpen] = React.useState(false)
|
||||||
const [password, setPassword] = React.useState("")
|
const [password, setPassword] = React.useState("")
|
||||||
@@ -298,6 +290,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 +322,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)}...` : "文件可能已损坏,请勿用于恢复。" }),
|
||||||
@@ -340,7 +338,7 @@ function BackupsSection() {
|
|||||||
})
|
})
|
||||||
const sendTelegram = useMutation({
|
const sendTelegram = useMutation({
|
||||||
mutationFn: api.sendBackupTelegram,
|
mutationFn: api.sendBackupTelegram,
|
||||||
onSuccess: () => toast({ title: "已发送到 Telegram" }),
|
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["admin", "backups"] }); toast({ title: "已开始发送到 Telegram" }) },
|
||||||
onError: (error) => toast({ title: "发送失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
onError: (error) => toast({ title: "发送失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
||||||
})
|
})
|
||||||
const testBackupTelegram = useMutation({
|
const testBackupTelegram = useMutation({
|
||||||
@@ -364,12 +362,12 @@ function BackupsSection() {
|
|||||||
})
|
})
|
||||||
const sendDrive = useMutation({
|
const sendDrive = useMutation({
|
||||||
mutationFn: api.sendBackupGoogleDrive,
|
mutationFn: api.sendBackupGoogleDrive,
|
||||||
onSuccess: () => toast({ title: "已上传到 Google 云端硬盘" }),
|
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["admin", "backups"] }); toast({ title: "已开始上传到 Google 云端硬盘" }) },
|
||||||
onError: (error) => toast({ title: "上传失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
onError: (error) => toast({ title: "上传失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
||||||
})
|
})
|
||||||
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 +385,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 +429,18 @@ 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()
|
||||||
|
}
|
||||||
|
const transferGroups = Object.values((backups.data?.transfers || []).reduce<Record<string, BackupTransfer[]>>((groups, transfer) => {
|
||||||
|
;(groups[transfer.name] ||= []).push(transfer)
|
||||||
|
return groups
|
||||||
|
}, {})).sort((left, right) => Date.parse(right[0]?.startedAt || "") - Date.parse(left[0]?.startedAt || ""))
|
||||||
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 +456,19 @@ 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>}
|
||||||
|
{transferGroups.slice(0, 3).map((transfers) => <div key={transfers[0].name} className="space-y-3 rounded-md border px-3 py-3">
|
||||||
|
<div className="truncate text-sm font-medium" title={transfers[0].name}>{transfers[0].name}</div>
|
||||||
|
{transfers.sort((a, b) => (a.provider === "telegram" ? 0 : 1) - (b.provider === "telegram" ? 0 : 1)).map((transfer) => {
|
||||||
|
const percent = transfer.total > 0 ? Math.min(100, Math.round(transfer.uploaded * 100 / transfer.total)) : 0
|
||||||
|
const label = transfer.provider === "telegram" ? "Telegram" : "Google 云端硬盘"
|
||||||
|
return <div key={transfer.provider} className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-3 text-xs"><span>{label}</span><span className={cn("shrink-0", transfer.status === "failed" ? "text-destructive" : "text-muted-foreground")}>{transfer.status === "queued" ? "等待上传" : transfer.status === "running" ? `${percent}% · ${formatBytes(transfer.uploaded)} / ${formatBytes(transfer.total)}` : transfer.status === "success" ? "上传完成" : "上传失败"}</span></div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-muted"><div className={cn("h-full transition-[width]", transfer.status === "failed" ? "bg-destructive" : transfer.status === "success" ? "bg-green-600" : "bg-primary")} style={{ width: `${transfer.status === "success" ? 100 : percent}%` }} /></div>
|
||||||
|
{transfer.error && <p className="text-xs text-destructive">{transfer.error}</p>}
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
</div>)}
|
||||||
<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 +503,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">
|
||||||
@@ -542,6 +558,7 @@ function BackupsSection() {
|
|||||||
<DialogContent className="w-[calc(100vw-2rem)] max-w-lg rounded-lg">
|
<DialogContent className="w-[calc(100vw-2rem)] max-w-lg rounded-lg">
|
||||||
<DialogHeader><DialogTitle>Google 云端硬盘</DialogTitle></DialogHeader>
|
<DialogHeader><DialogTitle>Google 云端硬盘</DialogTitle></DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900"><div className="font-medium">连接前必须启用 Google Drive API</div><p className="mt-1 text-xs">请在 OAuth 客户端所属的同一个 Google Cloud 项目中启用 API。启用后若仍提示无权限,请先断开连接,再重新授权。</p><Button asChild type="button" variant="link" className="mt-1 h-auto p-0 text-amber-900"><a href="https://console.cloud.google.com/apis/library/drive.googleapis.com" target="_blank" rel="noreferrer">打开 Google Drive API <ExternalLink className="ml-1 h-3.5 w-3.5" /></a></Button></div>
|
||||||
<div className="space-y-2"><Label htmlFor="google-client-id">OAuth 客户端 ID</Label><Input id="google-client-id" value={googleClientId} onChange={(e) => setGoogleClientId(e.target.value)} /></div>
|
<div className="space-y-2"><Label htmlFor="google-client-id">OAuth 客户端 ID</Label><Input id="google-client-id" value={googleClientId} onChange={(e) => setGoogleClientId(e.target.value)} /></div>
|
||||||
<div className="space-y-2"><Label htmlFor="google-client-secret">OAuth 客户端密钥</Label><Input id="google-client-secret" type="password" value={googleClientSecret} onChange={(e) => setGoogleClientSecret(e.target.value)} placeholder={backups.data?.googleDrive.clientSecretSet ? "已安全保存,留空不变" : "请输入客户端密钥"} /></div>
|
<div className="space-y-2"><Label htmlFor="google-client-secret">OAuth 客户端密钥</Label><Input id="google-client-secret" type="password" value={googleClientSecret} onChange={(e) => setGoogleClientSecret(e.target.value)} placeholder={backups.data?.googleDrive.clientSecretSet ? "已安全保存,留空不变" : "请输入客户端密钥"} /></div>
|
||||||
<div className="space-y-2"><Label htmlFor="google-folder-name">备份文件夹</Label><Input id="google-folder-name" value={googleFolderName} onChange={(e) => setGoogleFolderName(e.target.value)} /></div>
|
<div className="space-y-2"><Label htmlFor="google-folder-name">备份文件夹</Label><Input id="google-folder-name" value={googleFolderName} onChange={(e) => setGoogleFolderName(e.target.value)} /></div>
|
||||||
@@ -553,15 +570,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>
|
||||||
@@ -2187,8 +2215,8 @@ function sendAuditBadgeVariant(event?: string) {
|
|||||||
return "secondary"
|
return "secondary"
|
||||||
}
|
}
|
||||||
|
|
||||||
function Stat({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) {
|
function Stat({ icon, label, value }: { icon?: React.ReactNode; label: string; value: React.ReactNode }) {
|
||||||
return <Card><CardContent className="flex items-center gap-3 p-4 sm:gap-4 sm:p-5"><div className="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-muted text-foreground sm:h-10 sm:w-10">{icon}</div><div className="min-w-0"><div className="truncate text-xl font-semibold tracking-tight sm:text-2xl">{value}</div><div className="text-xs text-muted-foreground">{label}</div></div></CardContent></Card>
|
return <div className="overview-stat flex min-h-[64px] items-center gap-2.5 p-3">{icon && <div className="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-muted text-foreground [&>svg]:h-4 [&>svg]:w-4">{icon}</div>}<div className="min-w-0"><div className="truncate text-lg font-semibold leading-6">{value}</div><div className="truncate text-xs text-muted-foreground">{label}</div></div></div>
|
||||||
}
|
}
|
||||||
function InfoBox({ label, value }: { label: string; value: React.ReactNode }) { return <div className="rounded-lg border p-4"><div className="text-xl font-semibold tracking-tight sm:text-2xl">{value}</div><div className="text-xs text-muted-foreground">{label}</div></div> }
|
function InfoBox({ label, value }: { label: string; value: React.ReactNode }) { return <div className="rounded-lg border p-4"><div className="text-xl font-semibold tracking-tight sm:text-2xl">{value}</div><div className="text-xs text-muted-foreground">{label}</div></div> }
|
||||||
function Empty({ text }: { text: string }) { return <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">{text}</div> }
|
function Empty({ text }: { text: string }) { return <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">{text}</div> }
|
||||||
|
|||||||
+151
-53
@@ -1307,8 +1307,8 @@ export function MailPage() {
|
|||||||
setAdvancedSearchOpen(false)
|
setAdvancedSearchOpen(false)
|
||||||
}
|
}
|
||||||
const sidebarContent = (
|
const sidebarContent = (
|
||||||
<Sidebar collapsible="none" className="h-full w-full border-r border-border bg-sidebar text-sidebar-foreground">
|
<Sidebar collapsible="none" className="mail-sidebar-pane h-full min-h-0 w-full overflow-hidden border-r border-border bg-sidebar text-sidebar-foreground">
|
||||||
<SidebarHeader className={cn("pb-2 pt-3", sidebarCollapsed ? "px-2" : "px-3")}>
|
<SidebarHeader className={cn("shrink-0 pb-2 pt-3", sidebarCollapsed ? "px-2" : "px-3")}>
|
||||||
<AccountHeader
|
<AccountHeader
|
||||||
collapsed={sidebarCollapsed}
|
collapsed={sidebarCollapsed}
|
||||||
name={me.data?.user.displayName || selectedMailbox?.address || "NewSzxcn"}
|
name={me.data?.user.displayName || selectedMailbox?.address || "NewSzxcn"}
|
||||||
@@ -1352,7 +1352,7 @@ export function MailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent className="px-1">
|
<SidebarContent className="min-h-0 overflow-x-hidden overflow-y-auto overscroll-contain px-1 pb-4">
|
||||||
{!sidebarCollapsed && publicSettings.isError && <SidebarQueryFailure label="邮箱设置读取失败" onRetry={() => { void publicSettings.refetch() }} />}
|
{!sidebarCollapsed && publicSettings.isError && <SidebarQueryFailure label="邮箱设置读取失败" onRetry={() => { void publicSettings.refetch() }} />}
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
{!sidebarCollapsed && (
|
{!sidebarCollapsed && (
|
||||||
@@ -1739,7 +1739,7 @@ export function MailPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className={cn("mail-content-grid min-h-0 flex-1 bg-background", selectedId && "is-reading")}>
|
<div className={cn("mail-content-grid min-h-0 flex-1 bg-background", selectedId && "is-reading")}>
|
||||||
<div className={cn("mail-list-pane min-w-0", selectedId && "max-[767px]:hidden")}>
|
<div className={cn("mail-list-pane min-w-0", selectedId && "max-[767px]:hidden")}>
|
||||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
<div className="mail-list-surface flex h-full min-h-0 flex-col bg-card">
|
||||||
<div className="shrink-0 border-b px-3 pb-2.5 pt-2.5">
|
<div className="shrink-0 border-b px-3 pb-2.5 pt-2.5">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
@@ -1821,7 +1821,7 @@ export function MailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className={cn("mail-detail-pane min-w-0", !selectedId && "max-[767px]:hidden")}>
|
<section className={cn("mail-detail-pane min-w-0", !selectedId && "max-[767px]:hidden")}>
|
||||||
<div className="h-full min-h-0 bg-background">
|
<div className="mail-detail-surface h-full min-h-0 bg-background">
|
||||||
{!selectedId && (
|
{!selectedId && (
|
||||||
<div className="grid h-full place-items-center text-muted-foreground">
|
<div className="grid h-full place-items-center text-muted-foreground">
|
||||||
<div className="flex flex-col items-center gap-4 text-center">
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
@@ -1872,7 +1872,7 @@ export function MailPage() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-svh overflow-hidden bg-background">
|
<div className="mail-app-shell h-svh overflow-hidden bg-background">
|
||||||
<SidebarProvider className="h-full min-h-0 w-full min-w-0 flex-col">
|
<SidebarProvider className="h-full min-h-0 w-full min-w-0 flex-col">
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<div className="flex h-full min-h-0 flex-col">
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
@@ -1884,7 +1884,7 @@ export function MailPage() {
|
|||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent side="left" className="w-[86vw] max-w-80 p-0 [&>button]:hidden" aria-describedby={undefined}>
|
<SheetContent side="left" className="w-[86vw] max-w-80 p-0 [&>button]:hidden" aria-describedby={undefined}>
|
||||||
<SheetTitle className="sr-only">邮箱导航</SheetTitle>
|
<SheetTitle className="sr-only">邮箱导航</SheetTitle>
|
||||||
<div className="h-svh">{sidebarContent}</div>
|
<div className="h-svh min-h-0 overflow-hidden">{sidebarContent}</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
<div className="min-w-0 flex-1 text-sm font-semibold">{mailView === "label" && selectedLabel ? <Badge variant="outline" className="gap-1.5 rounded-md font-normal"><span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: labelDotColor(selectedLabel) }} />{selectedLabel.name}</Badge> : viewTitle}</div>
|
<div className="min-w-0 flex-1 text-sm font-semibold">{mailView === "label" && selectedLabel ? <Badge variant="outline" className="gap-1.5 rounded-md font-normal"><span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: labelDotColor(selectedLabel) }} />{selectedLabel.name}</Badge> : viewTitle}</div>
|
||||||
@@ -1900,7 +1900,7 @@ export function MailPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mail-shell-grid h-full min-h-0 w-full min-w-0 overflow-hidden">
|
<div className="mail-shell-grid h-full min-h-0 w-full min-w-0 overflow-hidden">
|
||||||
<div className="min-w-0">
|
<div className="mail-sidebar-wrap h-full min-h-0 min-w-0 overflow-hidden">
|
||||||
{sidebarContent}
|
{sidebarContent}
|
||||||
</div>
|
</div>
|
||||||
<section className="flex h-full min-h-0 min-w-0 flex-col">
|
<section className="flex h-full min-h-0 min-w-0 flex-col">
|
||||||
@@ -3030,7 +3030,7 @@ function CompactMailView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background">
|
<div className="compact-mail-surface flex min-h-0 min-w-0 flex-1 flex-col bg-background">
|
||||||
<div className="flex min-h-11 shrink-0 items-center gap-3 border-b px-3 sm:px-4">
|
<div className="flex min-h-11 shrink-0 items-center gap-3 border-b px-3 sm:px-4">
|
||||||
<div className={cn("flex min-w-0 items-center gap-2.5", selectedIds.length === 0 && "flex-1")}>
|
<div className={cn("flex min-w-0 items-center gap-2.5", selectedIds.length === 0 && "flex-1")}>
|
||||||
<Checkbox aria-label="选择当前页邮件" checked={allSelected ? true : someSelected ? "indeterminate" : false} onCheckedChange={(value) => onSelectAll(value === true)} />
|
<Checkbox aria-label="选择当前页邮件" checked={allSelected ? true : someSelected ? "indeterminate" : false} onCheckedChange={(value) => onSelectAll(value === true)} />
|
||||||
@@ -3121,7 +3121,7 @@ function CompactMessageDetail({
|
|||||||
language: Language
|
language: Language
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col bg-background">
|
<div className="compact-mail-surface flex min-h-0 flex-1 flex-col bg-background">
|
||||||
<div className="shrink-0 border-b px-3 py-2 sm:px-4">
|
<div className="shrink-0 border-b px-3 py-2 sm:px-4">
|
||||||
<div className="flex min-h-10 items-center gap-2 sm:hidden">
|
<div className="flex min-h-10 items-center gap-2 sm:hidden">
|
||||||
<Button variant="ghost" size="icon" onClick={onBack} aria-label="返回">
|
<Button variant="ghost" size="icon" onClick={onBack} aria-label="返回">
|
||||||
@@ -3393,7 +3393,7 @@ function CompactMessageRow({ message, active, checked, scheduled, onCheckedChang
|
|||||||
<div onClick={onClick} onContextMenu={onContextMenu} className={cn(
|
<div onClick={onClick} onContextMenu={onContextMenu} className={cn(
|
||||||
"cursor-pointer border-b border-l-2 px-3 py-2.5 text-[13px] transition-colors sm:grid sm:grid-cols-[28px_24px_minmax(112px,180px)_minmax(0,1fr)_86px_30px] sm:items-center sm:gap-2 sm:px-3 sm:py-2",
|
"cursor-pointer border-b border-l-2 px-3 py-2.5 text-[13px] transition-colors sm:grid sm:grid-cols-[28px_24px_minmax(112px,180px)_minmax(0,1fr)_86px_30px] sm:items-center sm:gap-2 sm:px-3 sm:py-2",
|
||||||
message.isRead ? "border-l-transparent hover:bg-accent/50" : "border-l-primary bg-primary/5 font-semibold hover:bg-primary/10",
|
message.isRead ? "border-l-transparent hover:bg-accent/50" : "border-l-primary bg-primary/5 font-semibold hover:bg-primary/10",
|
||||||
active && "bg-accent"
|
active && "bg-[hsl(var(--mail-selected))]"
|
||||||
)}>
|
)}>
|
||||||
<div className="flex gap-3 sm:contents">
|
<div className="flex gap-3 sm:contents">
|
||||||
<Checkbox aria-label="选择邮件" checked={checked} onCheckedChange={(value) => onCheckedChange(value === true)} onClick={(event) => event.stopPropagation()} className="mt-0.5 shrink-0 sm:mt-0" />
|
<Checkbox aria-label="选择邮件" checked={checked} onCheckedChange={(value) => onCheckedChange(value === true)} onClick={(event) => event.stopPropagation()} className="mt-0.5 shrink-0 sm:mt-0" />
|
||||||
@@ -3493,7 +3493,7 @@ function AccountHeader({ collapsed, name, email, darkMode, language, onToggleThe
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<Button type="button" variant="ghost" size="icon" className="size-7 rounded-md text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={onToggleTheme} title={darkMode ? "切换到浅色模式" : "切换到深色模式"} aria-label={darkMode ? "切换到浅色模式" : "切换到深色模式"}>
|
<Button type="button" variant="ghost" size="icon" className={cn("size-7 rounded-md text-muted-foreground hover:bg-transparent hover:text-foreground", darkMode && "text-amber-400 hover:text-amber-300")} onClick={onToggleTheme} title={darkMode ? "切换到浅色模式" : "切换到深色模式"} aria-label={darkMode ? "切换到浅色模式" : "切换到深色模式"}>
|
||||||
{darkMode ? <Sun className="h-3.5 w-3.5" /> : <Moon className="h-3.5 w-3.5" />}
|
{darkMode ? <Sun className="h-3.5 w-3.5" /> : <Moon className="h-3.5 w-3.5" />}
|
||||||
</Button>
|
</Button>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -3830,7 +3830,7 @@ function MessageRow({
|
|||||||
return <div onClick={onClick} onContextMenu={onContextMenu} className={cn(
|
return <div onClick={onClick} onContextMenu={onContextMenu} className={cn(
|
||||||
"group cursor-pointer border-b border-l-2 px-3 py-2.5 transition-colors",
|
"group cursor-pointer border-b border-l-2 px-3 py-2.5 transition-colors",
|
||||||
message.isRead ? "border-l-transparent hover:bg-accent/60" : "border-l-primary bg-primary/5 font-semibold hover:bg-primary/10",
|
message.isRead ? "border-l-transparent hover:bg-accent/60" : "border-l-primary bg-primary/5 font-semibold hover:bg-primary/10",
|
||||||
active && "bg-accent",
|
active && "bg-[hsl(var(--mail-selected))]",
|
||||||
checked && "bg-accent/70"
|
checked && "bg-accent/70"
|
||||||
)}>
|
)}>
|
||||||
<div className="flex gap-2.5">
|
<div className="flex gap-2.5">
|
||||||
@@ -3916,6 +3916,7 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
|||||||
const [subjectValue, setSubjectValue] = React.useState(draft?.subject || "")
|
const [subjectValue, setSubjectValue] = React.useState(draft?.subject || "")
|
||||||
const [draftStatus, setDraftStatus] = React.useState<"idle" | "saving" | "saved" | "error">("idle")
|
const [draftStatus, setDraftStatus] = React.useState<"idle" | "saving" | "saved" | "error">("idle")
|
||||||
const [lastSavedAt, setLastSavedAt] = React.useState<Date | null>(null)
|
const [lastSavedAt, setLastSavedAt] = React.useState<Date | null>(null)
|
||||||
|
const [closing, setClosing] = React.useState(false)
|
||||||
const [scheduleDialogOpen, setScheduleDialogOpen] = React.useState(false)
|
const [scheduleDialogOpen, setScheduleDialogOpen] = React.useState(false)
|
||||||
const [sendIntent, setSendIntent] = React.useState<ComposeSendIntent | null>(null)
|
const [sendIntent, setSendIntent] = React.useState<ComposeSendIntent | null>(null)
|
||||||
const sendStartedRef = React.useRef(false)
|
const sendStartedRef = React.useRef(false)
|
||||||
@@ -4031,7 +4032,7 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
|||||||
}, [files])
|
}, [files])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!open || sendStartedRef.current || !hasDraftContent || !canManageDrafts) return
|
if (!open || closing || sendStartedRef.current || !hasDraftContent || !canManageDrafts) return
|
||||||
const payloadKey = JSON.stringify({ ...composePayload, draftId })
|
const payloadKey = JSON.stringify({ ...composePayload, draftId })
|
||||||
if (payloadKey === lastSavedPayloadRef.current) return
|
if (payloadKey === lastSavedPayloadRef.current) return
|
||||||
const timer = window.setTimeout(async () => {
|
const timer = window.setTimeout(async () => {
|
||||||
@@ -4052,7 +4053,7 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
|||||||
}
|
}
|
||||||
}, 5000)
|
}, 5000)
|
||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}, [open, hasDraftContent, composePayload, draftId, qc, canManageDrafts])
|
}, [open, closing, hasDraftContent, composePayload, draftId, qc, canManageDrafts])
|
||||||
|
|
||||||
function buildSendWarnings(attachmentsCount: number) {
|
function buildSendWarnings(attachmentsCount: number) {
|
||||||
const warnings: string[] = []
|
const warnings: string[] = []
|
||||||
@@ -4104,6 +4105,10 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
|||||||
const to = splitEmails(toValue)
|
const to = splitEmails(toValue)
|
||||||
const cc = showCc ? splitEmails(ccValue) : []
|
const cc = showCc ? splitEmails(ccValue) : []
|
||||||
const bcc = showBcc ? splitEmails(bccValue) : []
|
const bcc = showBcc ? splitEmails(bccValue) : []
|
||||||
|
if (to.length === 0) {
|
||||||
|
toast({ title: "请填写收件人" })
|
||||||
|
return
|
||||||
|
}
|
||||||
const text = body.text
|
const text = body.text
|
||||||
const html = body.html || plainTextToHtml(text)
|
const html = body.html || plainTextToHtml(text)
|
||||||
const payload: SendPayload = { mailboxId: senderMailbox.id, to, cc, bcc, subject: subjectValue, text, html, attachments }
|
const payload: SendPayload = { mailboxId: senderMailbox.id, to, cc, bcc, subject: subjectValue, text, html, attachments }
|
||||||
@@ -4139,11 +4144,18 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
|||||||
}
|
}
|
||||||
if (!attachmentsWithinLimit()) return
|
if (!attachmentsWithinLimit()) return
|
||||||
const attachments = await Promise.all(files.map(fileToAttachment))
|
const attachments = await Promise.all(files.map(fileToAttachment))
|
||||||
|
const to = splitEmails(toValue)
|
||||||
|
const cc = showCc ? splitEmails(ccValue) : []
|
||||||
|
const bcc = showBcc ? splitEmails(bccValue) : []
|
||||||
|
if (to.length === 0) {
|
||||||
|
toast({ title: "请填写收件人" })
|
||||||
|
return
|
||||||
|
}
|
||||||
const payload: SendPayload & { draftId?: string; sendAt: string } = {
|
const payload: SendPayload & { draftId?: string; sendAt: string } = {
|
||||||
mailboxId: senderMailbox.id,
|
mailboxId: senderMailbox.id,
|
||||||
to: splitEmails(toValue),
|
to,
|
||||||
cc: showCc ? splitEmails(ccValue) : [],
|
cc,
|
||||||
bcc: showBcc ? splitEmails(bccValue) : [],
|
bcc,
|
||||||
subject: subjectValue,
|
subject: subjectValue,
|
||||||
text: body.text,
|
text: body.text,
|
||||||
html: body.html || plainTextToHtml(body.text),
|
html: body.html || plainTextToHtml(body.text),
|
||||||
@@ -4163,15 +4175,45 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function closeCompose() {
|
||||||
|
if (closing) return
|
||||||
|
if (!canManageDrafts || sendStartedRef.current || !hasDraftContent) {
|
||||||
|
onOpenChange(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setClosing(true)
|
||||||
|
setDraftStatus("saving")
|
||||||
|
try {
|
||||||
|
const attachments = await Promise.all(files.map(fileToAttachment))
|
||||||
|
const payload: DraftPayload = { ...composePayload, attachments }
|
||||||
|
const saved = await api.saveDraft(payload, draftId || undefined)
|
||||||
|
setDraftId(saved.id)
|
||||||
|
lastSavedPayloadRef.current = JSON.stringify({ ...payload, draftId: saved.id })
|
||||||
|
setLastSavedAt(new Date())
|
||||||
|
setDraftStatus("saved")
|
||||||
|
await Promise.all([
|
||||||
|
qc.invalidateQueries({ queryKey: ["messages"] }),
|
||||||
|
qc.invalidateQueries({ queryKey: ["folders"] }),
|
||||||
|
qc.invalidateQueries({ queryKey: ["mail-stats"] }),
|
||||||
|
])
|
||||||
|
onOpenChange(false)
|
||||||
|
} catch (error) {
|
||||||
|
setDraftStatus("error")
|
||||||
|
toast({ title: "草稿保存失败", description: error instanceof Error ? error.message : "请稍后重试" })
|
||||||
|
} finally {
|
||||||
|
setClosing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={(nextOpen) => { if (nextOpen) onOpenChange(true); else void closeCompose() }}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="flex h-svh w-screen max-w-none overflow-hidden p-0 sm:h-auto sm:max-h-[92vh] sm:w-[min(92vw,72rem)]"
|
className="flex h-svh w-screen max-w-none overflow-hidden p-0 sm:h-[min(88vh,48rem)] sm:w-[min(92vw,56rem)]"
|
||||||
onInteractOutside={(event) => event.preventDefault()}
|
onInteractOutside={(event) => event.preventDefault()}
|
||||||
onPointerDownOutside={(event) => event.preventDefault()}
|
onPointerDownOutside={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
<form key={draft?.key || "new"} className="flex min-h-0 flex-1 flex-col sm:max-h-[90vh]" onSubmit={submit}>
|
<form key={draft?.key || "new"} className="flex min-h-0 min-w-0 flex-1 flex-col" onSubmit={submit}>
|
||||||
<DialogHeader className="border-b px-4 py-3 text-left sm:px-6 sm:py-4">
|
<DialogHeader className="border-b bg-muted/20 px-4 py-3 text-left sm:px-5">
|
||||||
<DialogTitle className="flex min-w-0 flex-col gap-1 pr-8 sm:flex-row sm:items-center sm:justify-between sm:gap-4 sm:pr-6">
|
<DialogTitle className="flex min-w-0 flex-col gap-1 pr-8 sm:flex-row sm:items-center sm:justify-between sm:gap-4 sm:pr-6">
|
||||||
<span>{draftId ? "编辑草稿" : "写信"}</span>
|
<span>{draftId ? "编辑草稿" : "写信"}</span>
|
||||||
<span className={cn("text-xs font-normal", draftStatus === "error" ? "text-destructive" : "text-muted-foreground")}>
|
<span className={cn("text-xs font-normal", draftStatus === "error" ? "text-destructive" : "text-muted-foreground")}>
|
||||||
@@ -4219,7 +4261,7 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
|||||||
<Input name="bcc" placeholder="bcc@example.com" value={bccValue} onChange={(event) => setBccValue(event.target.value)} className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" />
|
<Input name="bcc" placeholder="bcc@example.com" value={bccValue} onChange={(event) => setBccValue(event.target.value)} className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" />
|
||||||
</ComposeField>
|
</ComposeField>
|
||||||
)}
|
)}
|
||||||
<ComposeField label="主 题">
|
<ComposeField label="主题">
|
||||||
<Input name="subject" placeholder="输入主题" value={subjectValue} onChange={(event) => setSubjectValue(event.target.value)} className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" />
|
<Input name="subject" placeholder="输入主题" value={subjectValue} onChange={(event) => setSubjectValue(event.target.value)} className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" />
|
||||||
</ComposeField>
|
</ComposeField>
|
||||||
{defaultSignature.isError && (
|
{defaultSignature.isError && (
|
||||||
@@ -4241,10 +4283,25 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
|||||||
onRemoveFile={(index) => { setAttachmentsTouched(true); setFiles((current) => current.filter((_, itemIndex) => itemIndex !== index)) }}
|
onRemoveFile={(index) => { setAttachmentsTouched(true); setFiles((current) => current.filter((_, itemIndex) => itemIndex !== index)) }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="grid grid-cols-3 gap-2 border-t bg-background px-4 py-3 sm:flex sm:flex-row sm:justify-end sm:px-6 sm:py-4">
|
<DialogFooter className="flex flex-row items-center justify-between gap-3 border-t bg-muted/15 px-4 py-3 sm:px-5">
|
||||||
<Button type="button" variant="outline" className="min-h-10 px-3" onClick={() => onOpenChange(false)}>取消</Button>
|
{canSend && (
|
||||||
{canSchedule && <Button type="button" variant="outline" className="min-h-10 px-3" disabled={send.isPending || scheduleSend.isPending || !senderMailbox} onClick={() => setScheduleDialogOpen(true)}><Calendar className="h-4 w-4" />定时</Button>}
|
<div className="flex min-w-0 items-center">
|
||||||
{canSend && <Button className="min-h-10 px-4" disabled={send.isPending || !senderMailbox}><Send className="h-4 w-4" />{send.isPending ? "发送中..." : "发送"}</Button>}
|
<Button className={cn("compose-send-button min-h-10 px-4", canSchedule && "rounded-r-none")} disabled={send.isPending || !senderMailbox}><Send className="h-4 w-4" />{send.isPending ? "发送中..." : "发送"}</Button>
|
||||||
|
{canSchedule && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button type="button" size="icon" className="compose-send-button h-10 w-9 shrink-0 rounded-l-none border-l border-white/30 px-0" title="发送选项" aria-label="发送选项" disabled={send.isPending || scheduleSend.isPending || !senderMailbox}>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" side="top" sideOffset={8} collisionPadding={12} className="w-[120px] min-w-[120px]">
|
||||||
|
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => setScheduleDialogOpen(true)}><Clock3 className="h-4 w-4" />定时发送</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button type="button" variant="ghost" className="min-h-10 px-3" disabled={closing} onClick={() => { void closeCompose() }}>{closing ? "保存中..." : "取消"}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
<ScheduleSendDialog open={scheduleDialogOpen} pending={scheduleSend.isPending} onOpenChange={setScheduleDialogOpen} onConfirm={scheduleAt} />
|
<ScheduleSendDialog open={scheduleDialogOpen} pending={scheduleSend.isPending} onOpenChange={setScheduleDialogOpen} onConfirm={scheduleAt} />
|
||||||
@@ -4264,8 +4321,8 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
|||||||
|
|
||||||
function ComposeField({ label, children, action }: { label: string; children: React.ReactNode; action?: React.ReactNode }) {
|
function ComposeField({ label, children, action }: { label: string; children: React.ReactNode; action?: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-14 flex-col gap-2 border-b px-4 py-2 sm:flex-row sm:items-center sm:px-6">
|
<div className="flex min-h-12 flex-col gap-1 border-b px-4 py-1.5 transition-colors focus-within:bg-muted/20 sm:flex-row sm:items-center sm:gap-2 sm:px-5">
|
||||||
<Label className="shrink-0 text-base font-normal text-foreground sm:w-20">{label}</Label>
|
<Label className="shrink-0 text-sm font-normal text-muted-foreground sm:w-16">{label}</Label>
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center">
|
<div className="flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
{children}
|
{children}
|
||||||
{action}
|
{action}
|
||||||
@@ -4337,11 +4394,17 @@ type InsertDialogValue = { url: string; text: string; alt: string }
|
|||||||
const composerFontOptions = ["Arial", "Georgia", "Times New Roman", "Courier New", "Microsoft YaHei"]
|
const composerFontOptions = ["Arial", "Georgia", "Times New Roman", "Courier New", "Microsoft YaHei"]
|
||||||
const composerFontSizeOptions = [
|
const composerFontSizeOptions = [
|
||||||
["2", "小号"],
|
["2", "小号"],
|
||||||
["3", "正文"],
|
["3", "标准"],
|
||||||
["4", "中号"],
|
["4", "中号"],
|
||||||
["5", "大号"],
|
["5", "大号"],
|
||||||
] as const
|
] as const
|
||||||
const composerFontSizeValueByKey: Record<string, string> = { "2": "13px", "3": "16px", "4": "20px", "5": "24px" }
|
const composerFontSizeValueByKey: Record<string, string> = { "2": "13px", "3": "16px", "4": "20px", "5": "24px" }
|
||||||
|
const composerParagraphOptions: ReadonlyArray<{ key: string; label: string; level?: 1 | 2 | 3 }> = [
|
||||||
|
{ key: "paragraph", label: "正文" },
|
||||||
|
{ key: "heading-1", label: "标题 1", level: 1 as const },
|
||||||
|
{ key: "heading-2", label: "标题 2", level: 2 as const },
|
||||||
|
{ key: "heading-3", label: "标题 3", level: 3 as const },
|
||||||
|
] as const
|
||||||
const composerTextColors = [["#111827", "默认"], ["#dc2626", "红色"], ["#2563eb", "蓝色"], ["#16a34a", "绿色"], ["#9333ea", "紫色"]] as const
|
const composerTextColors = [["#111827", "默认"], ["#dc2626", "红色"], ["#2563eb", "蓝色"], ["#16a34a", "绿色"], ["#9333ea", "紫色"]] as const
|
||||||
const composerHighlightColors = [["transparent", "无高亮"], ["#fef3c7", "黄色"], ["#dcfce7", "绿色"], ["#dbeafe", "蓝色"], ["#fce7f3", "粉色"]] as const
|
const composerHighlightColors = [["transparent", "无高亮"], ["#fef3c7", "黄色"], ["#dcfce7", "绿色"], ["#dbeafe", "蓝色"], ["#fce7f3", "粉色"]] as const
|
||||||
const composerEmojiOptions = ["😀", "😄", "😊", "🙂", "😉", "😍", "😘", "😎", "🤔", "👍", "👏", "🙏", "💪", "🎉", "🔥", "✨", "❤️", "✅", "📌", "📅", "☕", "💡", "🚀", "⭐"]
|
const composerEmojiOptions = ["😀", "😄", "😊", "🙂", "😉", "😍", "😘", "😎", "🤔", "👍", "👏", "🙏", "💪", "🎉", "🔥", "✨", "❤️", "✅", "📌", "📅", "☕", "💡", "🚀", "⭐"]
|
||||||
@@ -4383,7 +4446,15 @@ function fontLabel(value: string) {
|
|||||||
|
|
||||||
function fontSizeLabel(value: string) {
|
function fontSizeLabel(value: string) {
|
||||||
const normalized = normalizeFontSize(value) || "3"
|
const normalized = normalizeFontSize(value) || "3"
|
||||||
return composerFontSizeOptions.find(([size]) => size === normalized)?.[1] || "正文"
|
return composerFontSizeOptions.find(([size]) => size === normalized)?.[1] || "标准"
|
||||||
|
}
|
||||||
|
|
||||||
|
function paragraphStyleLabel(editor?: Editor | null) {
|
||||||
|
if (!editor) return "正文"
|
||||||
|
for (const option of composerParagraphOptions) {
|
||||||
|
if (option.level && editor.isActive("heading", { level: option.level })) return option.label
|
||||||
|
}
|
||||||
|
return "正文"
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeInsertUrl(value: string, kind: InsertDialogState["kind"]) {
|
function normalizeInsertUrl(value: string, kind: InsertDialogState["kind"]) {
|
||||||
@@ -4509,8 +4580,10 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
|
|||||||
content: composerInitialHtml(defaultValue, defaultHtml),
|
content: composerInitialHtml(defaultValue, defaultHtml),
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
class: "mail-html min-h-[240px] min-w-0 flex-1 overflow-y-auto px-4 py-4 text-base leading-7 outline-none sm:min-h-[280px] sm:px-6 sm:py-5",
|
class: "mail-html min-h-[220px] min-w-0 flex-1 overflow-y-auto px-4 py-4 text-base leading-7 outline-none sm:min-h-[260px] sm:px-5",
|
||||||
"aria-label": "正文",
|
"aria-label": "正文",
|
||||||
|
spellcheck: "true",
|
||||||
|
autocorrect: "on",
|
||||||
},
|
},
|
||||||
handlePaste(view, event) {
|
handlePaste(view, event) {
|
||||||
const clipboard = event.clipboardData
|
const clipboard = event.clipboardData
|
||||||
@@ -4564,6 +4637,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
|
|||||||
const activeFontSize = normalizeFontSize(textStyleAttributes?.fontSize || "") || "3"
|
const activeFontSize = normalizeFontSize(textStyleAttributes?.fontSize || "") || "3"
|
||||||
const activeColor = textStyleAttributes?.color || ""
|
const activeColor = textStyleAttributes?.color || ""
|
||||||
const activeHighlight = textStyleAttributes?.backgroundColor || ""
|
const activeHighlight = textStyleAttributes?.backgroundColor || ""
|
||||||
|
const characterCount = editor?.getText().replace(/\s/g, "").length || 0
|
||||||
void selectionVersion
|
void selectionVersion
|
||||||
|
|
||||||
function applyFont(font: string) {
|
function applyFont(font: string) {
|
||||||
@@ -4631,9 +4705,9 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[330px] flex-1 flex-col bg-background sm:min-h-[420px]">
|
<div className="flex min-h-[300px] min-w-0 flex-1 flex-col bg-background sm:min-h-[360px]">
|
||||||
<Input ref={fileInputRef} type="file" multiple className="hidden" onChange={handlePickedFiles} />
|
<Input ref={fileInputRef} type="file" multiple className="hidden" onChange={handlePickedFiles} />
|
||||||
<div className="flex min-h-11 flex-wrap items-center gap-1 overflow-visible border-b px-3 py-2 sm:px-6">
|
<div className="flex min-h-11 flex-wrap items-center gap-1 overflow-visible border-b px-3 py-2 sm:px-5">
|
||||||
<ToolbarButton label="撤销" disabled={!editor?.can().undo()} onClick={() => editor?.chain().focus().undo().run()}><Undo2 className="h-4 w-4" /></ToolbarButton>
|
<ToolbarButton label="撤销" disabled={!editor?.can().undo()} onClick={() => editor?.chain().focus().undo().run()}><Undo2 className="h-4 w-4" /></ToolbarButton>
|
||||||
<ToolbarButton label="重做" disabled={!editor?.can().redo()} onClick={() => editor?.chain().focus().redo().run()}><Redo2 className="h-4 w-4" /></ToolbarButton>
|
<ToolbarButton label="重做" disabled={!editor?.can().redo()} onClick={() => editor?.chain().focus().redo().run()}><Redo2 className="h-4 w-4" /></ToolbarButton>
|
||||||
<Separator orientation="vertical" className="mx-2 h-6" />
|
<Separator orientation="vertical" className="mx-2 h-6" />
|
||||||
@@ -4650,7 +4724,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
|
|||||||
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => editor?.chain().focus().setHorizontalRule().run()}><span className="h-4 w-4 border-t border-current" aria-hidden />分隔线</DropdownMenuItem>
|
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => editor?.chain().focus().setHorizontalRule().run()}><span className="h-4 w-4 border-t border-current" aria-hidden />分隔线</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<span className="rounded-md border px-2 py-1 text-xs text-muted-foreground">附件 {maxAttachmentText}</span>
|
<span className="px-1 text-xs text-muted-foreground" title={`单个附件上限 ${maxAttachmentText}`}>附件上限 {maxAttachmentText}</span>
|
||||||
<ToolbarTextButton label="日程" icon={<Calendar className="h-4 w-4" />} onClick={() => setScheduleOpen(true)} />
|
<ToolbarTextButton label="日程" icon={<Calendar className="h-4 w-4" />} onClick={() => setScheduleOpen(true)} />
|
||||||
<DropdownMenu open={emojiOpen} onOpenChange={setEmojiOpen}>
|
<DropdownMenu open={emojiOpen} onOpenChange={setEmojiOpen}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -4675,9 +4749,24 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{formatOpen && (
|
{formatOpen && (
|
||||||
<div className="flex min-h-14 flex-wrap items-center gap-1 overflow-visible border-b bg-muted/40 px-3 py-2 sm:px-6">
|
<div className="flex min-h-14 flex-wrap items-center gap-1 overflow-visible border-b bg-muted/40 px-3 py-2 sm:px-5">
|
||||||
<ToolbarButton label="清除格式" disabled={!editor} onClick={() => editor?.chain().focus().unsetAllMarks().clearNodes().run()}><Eraser className="h-4 w-4" /></ToolbarButton>
|
<ToolbarButton label="清除格式" disabled={!editor} onClick={() => editor?.chain().focus().unsetAllMarks().clearNodes().run()}><Eraser className="h-4 w-4" /></ToolbarButton>
|
||||||
<Separator orientation="vertical" className="mx-2 h-6" />
|
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button type="button" variant="ghost" size="sm" className={cn("h-8 min-w-[76px] justify-between rounded-md border border-transparent px-2 font-normal hover:border-border hover:bg-accent", paragraphStyleLabel(editor) !== "正文" && "border-primary/35 bg-primary/10 text-primary")} onMouseDown={(event) => event.preventDefault()} disabled={!editor}>
|
||||||
|
{paragraphStyleLabel(editor)}<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
{composerParagraphOptions.map((option) => (
|
||||||
|
<DropdownMenuItem key={option.key} className={composerMenuItemClass} onSelect={() => option.level ? editor?.chain().focus().toggleHeading({ level: option.level }).run() : editor?.chain().focus().setParagraph().run()}>
|
||||||
|
<Check className={cn("h-4 w-4", paragraphStyleLabel(editor) === option.label ? "opacity-100" : "opacity-0")} />
|
||||||
|
<span className={cn(option.level === 1 && "text-lg font-semibold", option.level === 2 && "text-base font-semibold", option.level === 3 && "text-sm font-semibold")}>{option.label}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button type="button" variant="ghost" size="sm" className={cn("h-8 min-w-[112px] justify-between rounded-md border border-transparent px-2 font-normal hover:border-border hover:bg-accent hover:shadow-sm", activeFont && "border-primary/35 bg-primary/10 text-primary shadow-sm")} onMouseDown={(event) => event.preventDefault()} disabled={!editor}>
|
<Button type="button" variant="ghost" size="sm" className={cn("h-8 min-w-[112px] justify-between rounded-md border border-transparent px-2 font-normal hover:border-border hover:bg-accent hover:shadow-sm", activeFont && "border-primary/35 bg-primary/10 text-primary shadow-sm")} onMouseDown={(event) => event.preventDefault()} disabled={!editor}>
|
||||||
@@ -4708,7 +4797,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
|
|||||||
))}
|
))}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<Separator orientation="vertical" className="mx-2 h-6" />
|
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||||
<ToolbarButton label="加粗" active={editor?.isActive("bold")} disabled={!editor} onClick={() => editor?.chain().focus().toggleBold().run()}><Bold className="h-4 w-4" /></ToolbarButton>
|
<ToolbarButton label="加粗" active={editor?.isActive("bold")} disabled={!editor} onClick={() => editor?.chain().focus().toggleBold().run()}><Bold className="h-4 w-4" /></ToolbarButton>
|
||||||
<ToolbarButton label="斜体" active={editor?.isActive("italic")} disabled={!editor} onClick={() => editor?.chain().focus().toggleItalic().run()}><Italic className="h-4 w-4" /></ToolbarButton>
|
<ToolbarButton label="斜体" active={editor?.isActive("italic")} disabled={!editor} onClick={() => editor?.chain().focus().toggleItalic().run()}><Italic className="h-4 w-4" /></ToolbarButton>
|
||||||
<ToolbarButton label="下划线" active={editor?.isActive("underline")} disabled={!editor} onClick={() => editor?.chain().focus().toggleUnderline().run()}><Underline className="h-4 w-4" /></ToolbarButton>
|
<ToolbarButton label="下划线" active={editor?.isActive("underline")} disabled={!editor} onClick={() => editor?.chain().focus().toggleUnderline().run()}><Underline className="h-4 w-4" /></ToolbarButton>
|
||||||
@@ -4743,30 +4832,39 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
|
|||||||
))}
|
))}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<Separator orientation="vertical" className="mx-2 h-6" />
|
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||||
<ToolbarButton label="无序列表" active={editor?.isActive("bulletList")} disabled={!editor} onClick={() => editor?.chain().focus().toggleBulletList().run()}><List className="h-4 w-4" /></ToolbarButton>
|
<ToolbarButton label="无序列表" active={editor?.isActive("bulletList")} disabled={!editor} onClick={() => editor?.chain().focus().toggleBulletList().run()}><List className="h-4 w-4" /></ToolbarButton>
|
||||||
<ToolbarButton label="有序列表" active={editor?.isActive("orderedList")} disabled={!editor} onClick={() => editor?.chain().focus().toggleOrderedList().run()}><ListOrdered className="h-4 w-4" /></ToolbarButton>
|
<ToolbarButton label="有序列表" active={editor?.isActive("orderedList")} disabled={!editor} onClick={() => editor?.chain().focus().toggleOrderedList().run()}><ListOrdered className="h-4 w-4" /></ToolbarButton>
|
||||||
<ToolbarButton label="减少缩进" disabled={!editor?.can().liftListItem("listItem")} onClick={() => editor?.chain().focus().liftListItem("listItem").run()}><IndentDecrease className="h-4 w-4" /></ToolbarButton>
|
<DropdownMenu>
|
||||||
<ToolbarButton label="增加缩进" disabled={!editor?.can().sinkListItem("listItem")} onClick={() => editor?.chain().focus().sinkListItem("listItem").run()}><IndentIncrease className="h-4 w-4" /></ToolbarButton>
|
<DropdownMenuTrigger asChild>
|
||||||
<Separator orientation="vertical" className="mx-2 h-6" />
|
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground" title="更多格式" aria-label="更多格式" onMouseDown={(event) => event.preventDefault()} disabled={!editor}>
|
||||||
<ToolbarButton label="左对齐" active={editor?.isActive({ textAlign: "left" })} disabled={!editor} onClick={() => editor?.chain().focus().setTextAlign("left").run()}><AlignLeft className="h-4 w-4" /></ToolbarButton>
|
<Ellipsis className="h-4 w-4" />
|
||||||
<ToolbarButton label="居中" active={editor?.isActive({ textAlign: "center" })} disabled={!editor} onClick={() => editor?.chain().focus().setTextAlign("center").run()}><AlignCenter className="h-4 w-4" /></ToolbarButton>
|
</Button>
|
||||||
<ToolbarButton label="右对齐" active={editor?.isActive({ textAlign: "right" })} disabled={!editor} onClick={() => editor?.chain().focus().setTextAlign("right").run()}><AlignRight className="h-4 w-4" /></ToolbarButton>
|
</DropdownMenuTrigger>
|
||||||
<ToolbarButton label="引用" active={editor?.isActive("blockquote")} disabled={!editor} onClick={() => editor?.chain().focus().toggleBlockquote().run()}><Quote className="h-4 w-4" /></ToolbarButton>
|
<DropdownMenuContent align="end" className="w-44">
|
||||||
<ToolbarButton label="代码块" active={editor?.isActive("codeBlock")} disabled={!editor} onClick={() => editor?.chain().focus().toggleCodeBlock().run()}><Code2 className="h-4 w-4" /></ToolbarButton>
|
<DropdownMenuItem className={composerMenuItemClass} disabled={!editor?.can().liftListItem("listItem")} onSelect={() => editor?.chain().focus().liftListItem("listItem").run()}><IndentDecrease className="h-4 w-4" />减少缩进</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className={composerMenuItemClass} disabled={!editor?.can().sinkListItem("listItem")} onSelect={() => editor?.chain().focus().sinkListItem("listItem").run()}><IndentIncrease className="h-4 w-4" />增加缩进</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => editor?.chain().focus().setTextAlign("left").run()}><AlignLeft className="h-4 w-4" />左对齐</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => editor?.chain().focus().setTextAlign("center").run()}><AlignCenter className="h-4 w-4" />居中</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => editor?.chain().focus().setTextAlign("right").run()}><AlignRight className="h-4 w-4" />右对齐</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => editor?.chain().focus().toggleBlockquote().run()}><Quote className="h-4 w-4" />引用</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => editor?.chain().focus().toggleCodeBlock().run()}><Code2 className="h-4 w-4" />代码块</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<span className="ml-auto whitespace-nowrap px-1 text-xs tabular-nums text-muted-foreground" aria-live="polite">{characterCount} 字</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"composer-editor relative flex min-h-[240px] flex-1 border-b focus-within:bg-card/40 sm:min-h-[280px]",
|
"composer-editor relative flex min-h-[220px] min-w-0 flex-1 overflow-hidden border-b focus-within:bg-card/40 sm:min-h-[260px]",
|
||||||
"[&_.ProseMirror]:min-h-[240px] [&_.ProseMirror]:w-full [&_.ProseMirror]:flex-1 [&_.ProseMirror]:overflow-y-auto [&_.ProseMirror]:px-4 [&_.ProseMirror]:py-4 [&_.ProseMirror]:text-base [&_.ProseMirror]:leading-7 [&_.ProseMirror]:outline-none sm:[&_.ProseMirror]:min-h-[280px] sm:[&_.ProseMirror]:px-6 sm:[&_.ProseMirror]:py-5",
|
"[&_.ProseMirror]:min-h-[220px] [&_.ProseMirror]:min-w-0 [&_.ProseMirror]:w-full [&_.ProseMirror]:max-w-full [&_.ProseMirror]:flex-1 [&_.ProseMirror]:overflow-x-hidden [&_.ProseMirror]:overflow-y-auto [&_.ProseMirror]:px-4 [&_.ProseMirror]:py-4 [&_.ProseMirror]:text-base [&_.ProseMirror]:leading-7 [&_.ProseMirror]:outline-none [&_.ProseMirror]:[overflow-wrap:anywhere] sm:[&_.ProseMirror]:min-h-[260px] sm:[&_.ProseMirror]:px-5",
|
||||||
"[&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0 [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-muted-foreground [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)]",
|
"[&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0 [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-muted-foreground [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)]",
|
||||||
"[&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ul]:pl-6 [&_.ProseMirror_ol]:pl-6 [&_.ProseMirror_blockquote]:border-l-4 [&_.ProseMirror_blockquote]:border-border [&_.ProseMirror_blockquote]:pl-4 [&_.ProseMirror_blockquote]:text-muted-foreground [&_.ProseMirror_pre]:rounded-md [&_.ProseMirror_pre]:bg-muted [&_.ProseMirror_pre]:p-3",
|
"[&_.ProseMirror_a]:break-all [&_.ProseMirror_p]:max-w-full [&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ul]:pl-6 [&_.ProseMirror_ol]:pl-6 [&_.ProseMirror_blockquote]:border-l-4 [&_.ProseMirror_blockquote]:border-border [&_.ProseMirror_blockquote]:pl-4 [&_.ProseMirror_blockquote]:text-muted-foreground [&_.ProseMirror_pre]:max-w-full [&_.ProseMirror_pre]:whitespace-pre-wrap [&_.ProseMirror_pre]:break-words [&_.ProseMirror_pre]:rounded-md [&_.ProseMirror_pre]:bg-muted [&_.ProseMirror_pre]:p-3",
|
||||||
empty && "bg-background"
|
empty && "bg-background"
|
||||||
)}>
|
)}>
|
||||||
<EditorContent editor={editor} className="flex min-h-0 flex-1" />
|
<EditorContent editor={editor} className="flex min-h-0 min-w-0 flex-1 overflow-hidden" />
|
||||||
</div>
|
</div>
|
||||||
{files.length > 0 && (
|
{files.length > 0 && (
|
||||||
<div className="border-t px-4 py-3 sm:px-6">
|
<div className="border-t px-4 py-3 sm:px-5">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{files.map((file, index) => (
|
{files.map((file, index) => (
|
||||||
<Badge key={`${file.name}-${file.size}-${index}`} variant="outline" className="h-8 gap-2 rounded-md px-2 font-normal">
|
<Badge key={`${file.name}-${file.size}-${index}`} variant="outline" className="h-8 gap-2 rounded-md px-2 font-normal">
|
||||||
@@ -4788,7 +4886,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>邮件预览</DialogTitle>
|
<DialogTitle>邮件预览</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="mail-html max-h-[60vh] overflow-y-auto rounded-md border bg-background p-5 text-sm leading-7" dangerouslySetInnerHTML={{ __html: sanitizeComposerHtml(editor?.getHTML() || "") || "<p></p>" }} />
|
<div className="mail-html max-h-[60vh] max-w-full overflow-x-hidden overflow-y-auto rounded-md border bg-background p-5 text-sm leading-7 [overflow-wrap:anywhere] [&_a]:break-all [&_pre]:max-w-full [&_pre]:whitespace-pre-wrap [&_pre]:break-words" dangerouslySetInnerHTML={{ __html: sanitizeComposerHtml(editor?.getHTML() || "") || "<p></p>" }} />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -343,7 +343,7 @@ export function ProfilePage() {
|
|||||||
if (me.isError || !user) return <div className="grid h-svh place-items-center text-muted-foreground">登录状态已失效</div>
|
if (me.isError || !user) return <div className="grid h-svh place-items-center text-muted-foreground">登录状态已失效</div>
|
||||||
|
|
||||||
const sidebarContent = (
|
const sidebarContent = (
|
||||||
<div className="flex h-full w-[var(--app-sidebar-width)] shrink-0 flex-col border-r border-border bg-card">
|
<div className="settings-sidebar-panel flex h-full w-[var(--app-sidebar-width)] shrink-0 flex-col border-r border-border bg-card">
|
||||||
<div className="h-[64px] border-b">
|
<div className="h-[64px] border-b">
|
||||||
<AccountHeader name={user.displayName || selectedMailbox?.address || "NewSzxcn"} email={user.email || selectedMailbox?.address} darkMode={darkMode} onToggleTheme={() => setDarkMode((v) => !v)} onBack={() => navigate("/")} />
|
<AccountHeader name={user.displayName || selectedMailbox?.address || "NewSzxcn"} email={user.email || selectedMailbox?.address} darkMode={darkMode} onToggleTheme={() => setDarkMode((v) => !v)} onBack={() => navigate("/")} />
|
||||||
</div>
|
</div>
|
||||||
@@ -397,7 +397,7 @@ export function ProfilePage() {
|
|||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-svh overflow-hidden bg-background">
|
<div className="settings-app-shell h-svh overflow-hidden bg-background">
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<div className="flex h-full min-h-0 flex-col">
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-3">
|
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-3">
|
||||||
@@ -421,9 +421,9 @@ export function ProfilePage() {
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full min-h-0 w-full">
|
<div className="settings-shell-grid flex h-full min-h-0 w-full">
|
||||||
{sidebarContent}
|
{sidebarContent}
|
||||||
<section className="min-w-0 flex-1 overflow-y-auto">
|
<section className="settings-content-panel settings-glass-content min-w-0 flex-1 overflow-y-auto">
|
||||||
<main className="pb-12">
|
<main className="pb-12">
|
||||||
<SettingsPageHeader title={pageTitle} subtitle={pageSubtitle} action={pageAction} activeTab={tab === "profile" ? accountTab : undefined} onAccountTabChange={setAccountTab} />
|
<SettingsPageHeader title={pageTitle} subtitle={pageSubtitle} action={pageAction} activeTab={tab === "profile" ? accountTab : undefined} onAccountTabChange={setAccountTab} />
|
||||||
<div className={contentFrameClass(tab)}>{renderTab()}</div>
|
<div className={contentFrameClass(tab)}>{renderTab()}</div>
|
||||||
@@ -2867,7 +2867,7 @@ function AccountHeader({ name, email, darkMode, onToggleTheme, onBack }: { name:
|
|||||||
<div className="min-w-0 truncate text-sm font-semibold leading-5">{displayName}</div>
|
<div className="min-w-0 truncate text-sm font-semibold leading-5">{displayName}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<Button type="button" variant="ghost" size="icon" className="size-[28px] rounded-md text-muted-foreground" aria-label={darkMode ? "切换浅色模式" : "切换深色模式"} title={darkMode ? "浅色模式" : "深色模式"} onClick={onToggleTheme}>
|
<Button type="button" variant="ghost" size="icon" className={cn("size-[28px] rounded-md text-muted-foreground", darkMode && "text-amber-400 hover:text-amber-300")} aria-label={darkMode ? "切换浅色模式" : "切换深色模式"} title={darkMode ? "浅色模式" : "深色模式"} onClick={onToggleTheme}>
|
||||||
{darkMode ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
{darkMode ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="ghost" size="icon" className="size-[28px] rounded-md text-muted-foreground" aria-label="返回邮箱" title="返回邮箱" onClick={onBack}>
|
<Button type="button" variant="ghost" size="icon" className="size-[28px] rounded-md text-muted-foreground" aria-label="返回邮箱" title="返回邮箱" onClick={onBack}>
|
||||||
|
|||||||
Reference in New Issue
Block a user