整合配置列表

This commit is contained in:
2026-07-07 13:16:07 +08:00
parent 4420532a66
commit e08480f609
7 changed files with 359 additions and 77 deletions
+38 -41
View File
@@ -27,14 +27,23 @@ type App struct {
window fyne.Window
profileWindow fyne.Window
// Profile list window.
profileListWindow fyne.Window
profileList *widget.List
listSelectedIndex int
// UI widgets
profileSelect *widget.Select
stateLabel *widget.Label
ipLabel *widget.Label
ip6Label *widget.Label
uptimeLabel *widget.Label
rxLabel *widget.Label
txLabel *widget.Label
rxV4Label *widget.Label
txV4Label *widget.Label
rxV6Label *widget.Label
txV6Label *widget.Label
rxTotalLabel *widget.Label
txTotalLabel *widget.Label
connectBtn *widget.Button
disconnectBtn *widget.Button
@@ -71,10 +80,11 @@ func Run() {
}
a := &App{
fyneApp: app.NewWithID(paths.BundleID),
db: store,
kc: keychain.New(),
langSetting: cfg.Language,
fyneApp: app.NewWithID(paths.BundleID),
db: store,
kc: keychain.New(),
langSetting: cfg.Language,
listSelectedIndex: -1,
}
a.window = a.fyneApp.NewWindow(i18n.T("WindowTitle"))
@@ -128,6 +138,16 @@ func (a *App) loadProfiles() {
a.profileSelect.SetSelected("")
a.profileSelect.Refresh()
}
a.listSelectedIndex = -1
a.refreshProfileList()
}
// refreshProfileList refreshes the profile list widget if the profile
// list window is currently open.
func (a *App) refreshProfileList() {
if a.profileList != nil {
a.profileList.Refresh()
}
}
// profileNames returns the names of all loaded profiles.
@@ -154,39 +174,6 @@ func (a *App) onAddProfile() {
a.showProfileDialog(nil)
}
// onEditProfile shows a dialog to edit the current profile.
func (a *App) onEditProfile() {
if a.currentProfile == nil {
dialog.NewCustom(i18n.T("DlgNoProfileTitle"), i18n.T("BtnOK"),
widget.NewLabel(i18n.T("DlgNoProfileEditMsg")), a.window).Show()
return
}
p := *a.currentProfile
a.showProfileDialog(&p)
}
// onDeleteProfile deletes the current profile after confirmation.
func (a *App) onDeleteProfile() {
if a.currentProfile == nil {
return
}
name := a.currentProfile.Name
dialog.NewCustomConfirm(i18n.T("DlgDeleteProfileTitle"),
i18n.T("BtnDelete"), i18n.T("BtnCancel"),
widget.NewLabel(i18n.T("DlgDeleteProfileMsg", map[string]interface{}{"name": name})),
func(ok bool) {
if !ok {
return
}
if err := a.db.DeleteProfile(a.currentProfile.ID); err != nil {
showError(i18n.T("DlgError"), err.Error(), a.window)
return
}
_ = a.kc.DeleteAll(name)
a.loadProfiles()
}, a.window).Show()
}
// onResetDB deletes the SQLite database file after confirmation,
// then re-creates it. All profiles, credentials, and logs are lost.
func (a *App) onResetDB() {
@@ -235,8 +222,12 @@ func (a *App) onResetDB() {
a.ipLabel.SetText(i18n.T("IpNone"))
a.ip6Label.SetText(i18n.T("Ip6None"))
a.uptimeLabel.SetText(i18n.T("UptimeNone"))
a.rxLabel.SetText(i18n.T("RxZero"))
a.txLabel.SetText(i18n.T("TxZero"))
a.rxV4Label.SetText(i18n.T("RxV4Zero"))
a.txV4Label.SetText(i18n.T("TxV4Zero"))
a.rxV6Label.SetText(i18n.T("RxV6Zero"))
a.txV6Label.SetText(i18n.T("TxV6Zero"))
a.rxTotalLabel.SetText(i18n.T("RxTotalZero"))
a.txTotalLabel.SetText(i18n.T("TxTotalZero"))
a.connectBtn.Enable()
a.disconnectBtn.Disable()
}, a.window).Show()
@@ -279,6 +270,12 @@ func (a *App) rebuildUI() {
selectedName = a.currentProfile.Name
}
// Close the profile list window if open; it holds cached strings
// and will be recreated with the new language on next open.
if a.profileListWindow != nil {
a.profileListWindow.Close()
}
// Rebuild window content (creates fresh widgets with new strings).
a.window.SetTitle(i18n.T("WindowTitle"))
a.window.SetContent(a.buildMainWindow())
+97 -1
View File
@@ -1,6 +1,7 @@
package ui
import (
"fmt"
"strconv"
"lmvpn/internal/i18n"
@@ -8,6 +9,7 @@ import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/widget"
)
@@ -18,7 +20,7 @@ var (
authCodes = []string{string(model.AuthModeBoth), string(model.AuthModeJWT), string(model.AuthModePassword)}
routeCodes = []string{string(model.RoutingFull), string(model.RoutingSplit), string(model.RoutingCustom)}
protoCodes = []string{"wss", "ws"}
protoCodes = []string{"wss", "ws"}
)
func authModeLabels() []string {
@@ -267,3 +269,97 @@ func parseIntDefault(s string, def int) int {
}
return n
}
// showProfileListWindow opens a window listing all saved profiles with
// buttons to add, edit, delete, and reset the database. Only one
// instance is allowed; if already open it is brought to the front.
func (a *App) showProfileListWindow() {
if a.profileListWindow != nil {
a.profileListWindow.RequestFocus()
return
}
a.profileList = widget.NewList(
func() int { return len(a.profiles) },
func() fyne.CanvasObject {
name := widget.NewLabel("")
name.TextStyle = fyne.TextStyle{Bold: true}
host := widget.NewLabel("")
return container.NewVBox(name, host)
},
func(id widget.ListItemID, obj fyne.CanvasObject) {
box := obj.(*fyne.Container)
nameLbl := box.Objects[0].(*widget.Label)
hostLbl := box.Objects[1].(*widget.Label)
if id >= 0 && id < len(a.profiles) {
p := a.profiles[id]
nameLbl.SetText(p.Name)
hostLbl.SetText(fmt.Sprintf("%s:%d", p.Host, p.Port))
}
},
)
a.profileList.OnSelected = func(id widget.ListItemID) {
a.listSelectedIndex = id
}
a.profileList.OnUnselected = func(_ widget.ListItemID) {
a.listSelectedIndex = -1
}
addBtn := widget.NewButton(i18n.T("BtnAddProfile"), a.onAddProfile)
editBtn := widget.NewButton(i18n.T("BtnEdit"), a.onEditProfileFromList)
deleteBtn := widget.NewButton(i18n.T("BtnDelete"), a.onDeleteProfileFromList)
resetDBBtn := widget.NewButton(i18n.T("BtnResetDB"), a.onResetDB)
buttons := container.NewGridWithColumns(4, addBtn, editBtn, deleteBtn, resetDBBtn)
win := a.fyneApp.NewWindow(i18n.T("ProfileListTitle"))
a.profileListWindow = win
win.SetOnClosed(func() {
a.profileListWindow = nil
a.profileList = nil
a.listSelectedIndex = -1
})
win.SetContent(container.NewBorder(nil, buttons, nil, nil, a.profileList))
win.Resize(fyne.NewSize(460, 400))
win.Show()
}
// onEditProfileFromList opens the profile editor for the profile
// selected in the list window.
func (a *App) onEditProfileFromList() {
idx := a.listSelectedIndex
if idx < 0 || idx >= len(a.profiles) {
dialog.NewCustom(i18n.T("DlgNoListSelectTitle"), i18n.T("BtnOK"),
widget.NewLabel(i18n.T("DlgNoListSelectEditMsg")), a.profileListWindow).Show()
return
}
p := a.profiles[idx]
a.showProfileDialog(&p)
}
// onDeleteProfileFromList deletes the profile selected in the list
// window after confirmation.
func (a *App) onDeleteProfileFromList() {
idx := a.listSelectedIndex
if idx < 0 || idx >= len(a.profiles) {
dialog.NewCustom(i18n.T("DlgNoListSelectTitle"), i18n.T("BtnOK"),
widget.NewLabel(i18n.T("DlgNoListSelectDeleteMsg")), a.profileListWindow).Show()
return
}
p := a.profiles[idx]
dialog.NewCustomConfirm(i18n.T("DlgDeleteProfileTitle"),
i18n.T("BtnDelete"), i18n.T("BtnCancel"),
widget.NewLabel(i18n.T("DlgDeleteProfileMsg", map[string]interface{}{"name": p.Name})),
func(ok bool) {
if !ok {
return
}
if err := a.db.DeleteProfile(p.ID); err != nil {
showError(i18n.T("DlgError"), err.Error(), a.profileListWindow)
return
}
_ = a.kc.DeleteAll(p.Name)
a.loadProfiles()
}, a.profileListWindow).Show()
}
+52 -17
View File
@@ -32,15 +32,21 @@ func (a *App) buildMainWindow() fyne.CanvasObject {
a.ipLabel = widget.NewLabel(i18n.T("IpNone"))
a.ip6Label = widget.NewLabel(i18n.T("Ip6None"))
a.uptimeLabel = widget.NewLabel(i18n.T("UptimeNone"))
a.rxLabel = widget.NewLabel(i18n.T("RxZero"))
a.txLabel = widget.NewLabel(i18n.T("TxZero"))
a.rxV4Label = widget.NewLabel(i18n.T("RxV4Zero"))
a.txV4Label = widget.NewLabel(i18n.T("TxV4Zero"))
a.rxV6Label = widget.NewLabel(i18n.T("RxV6Zero"))
a.txV6Label = widget.NewLabel(i18n.T("TxV6Zero"))
a.rxTotalLabel = widget.NewLabel(i18n.T("RxTotalZero"))
a.txTotalLabel = widget.NewLabel(i18n.T("TxTotalZero"))
statusCard := widget.NewCard(i18n.T("StatusLabel"), "", container.NewVBox(
a.stateLabel,
a.ipLabel,
a.ip6Label,
a.uptimeLabel,
container.NewHBox(a.rxLabel, a.txLabel),
container.NewHBox(a.rxV4Label, a.txV4Label),
container.NewHBox(a.rxV6Label, a.txV6Label),
container.NewHBox(a.rxTotalLabel, a.txTotalLabel),
))
// Buttons.
@@ -49,25 +55,17 @@ func (a *App) buildMainWindow() fyne.CanvasObject {
a.disconnectBtn = widget.NewButton(i18n.T("BtnDisconnect"), a.onDisconnect)
a.disconnectBtn.Disable()
addBtn := widget.NewButton(i18n.T("BtnAddProfile"), a.onAddProfile)
editBtn := widget.NewButton(i18n.T("BtnEdit"), a.onEditProfile)
deleteBtn := widget.NewButton(i18n.T("BtnDelete"), a.onDeleteProfile)
resetDBBtn := widget.NewButton(i18n.T("BtnResetDB"), a.onResetDB)
profileListBtn := widget.NewButton(i18n.T("BtnProfileList"), a.showProfileListWindow)
buttons := container.NewGridWithColumns(2,
a.connectBtn, a.disconnectBtn,
)
profileButtons := container.NewGridWithColumns(3,
addBtn, editBtn, deleteBtn,
)
return container.NewVBox(
widget.NewLabel(i18n.T("ProfileLabel")),
a.profileSelect,
buttons,
profileButtons,
resetDBBtn,
profileListBtn,
statusCard,
widget.NewLabel(fmt.Sprintf("v%s", Version)),
)
@@ -185,8 +183,12 @@ func (a *App) eventLoop() {
a.ipLabel.SetText(i18n.T("IpNone"))
a.ip6Label.SetText(i18n.T("Ip6None"))
a.uptimeLabel.SetText(i18n.T("UptimeNone"))
a.rxLabel.SetText(i18n.T("RxZero"))
a.txLabel.SetText(i18n.T("TxZero"))
a.rxV4Label.SetText(i18n.T("RxV4Zero"))
a.txV4Label.SetText(i18n.T("TxV4Zero"))
a.rxV6Label.SetText(i18n.T("RxV6Zero"))
a.txV6Label.SetText(i18n.T("TxV6Zero"))
a.rxTotalLabel.SetText(i18n.T("RxTotalZero"))
a.txTotalLabel.SetText(i18n.T("TxTotalZero"))
a.connectBtn.Enable()
a.disconnectBtn.Disable()
}
@@ -256,8 +258,24 @@ func (a *App) applyStats(s stats.Snapshot) {
if s.State == stats.StateConnected {
a.stateLabel.SetText(i18n.T("StateConnected"))
}
a.rxLabel.SetText(i18n.T("RxLabel", map[string]interface{}{"bytes": formatBytes(s.RxBytes)}))
a.txLabel.SetText(i18n.T("TxLabel", map[string]interface{}{"bytes": formatBytes(s.TxBytes)}))
a.rxV4Label.SetText(i18n.T("RxV4Label", map[string]interface{}{
"bytes": formatBytes(s.RxBytesV4), "speed": formatSpeed(s.RxSpeedV4),
}))
a.txV4Label.SetText(i18n.T("TxV4Label", map[string]interface{}{
"bytes": formatBytes(s.TxBytesV4), "speed": formatSpeed(s.TxSpeedV4),
}))
a.rxV6Label.SetText(i18n.T("RxV6Label", map[string]interface{}{
"bytes": formatBytes(s.RxBytesV6), "speed": formatSpeed(s.RxSpeedV6),
}))
a.txV6Label.SetText(i18n.T("TxV6Label", map[string]interface{}{
"bytes": formatBytes(s.TxBytesV6), "speed": formatSpeed(s.TxSpeedV6),
}))
a.rxTotalLabel.SetText(i18n.T("RxTotalLabel", map[string]interface{}{
"bytes": formatBytes(s.RxBytes), "speed": formatSpeed(s.RxSpeed),
}))
a.txTotalLabel.SetText(i18n.T("TxTotalLabel", map[string]interface{}{
"bytes": formatBytes(s.TxBytes), "speed": formatSpeed(s.TxSpeed),
}))
if s.Uptime > 0 {
a.uptimeLabel.SetText(i18n.T("UptimeLabel", map[string]interface{}{"uptime": formatDuration(s.Uptime)}))
}
@@ -277,6 +295,23 @@ func formatBytes(b int64) string {
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp])
}
// formatSpeed formats a bytes/sec rate as a decimal bits/sec rate
// (kbps / Mbps / Gbps). bps is the byte rate; it is converted to bits
// by multiplying by 8 and scaled by 1000 (decimal, network convention).
func formatSpeed(bps int64) string {
const unit = 1000
bits := bps * 8
if bits < unit {
return fmt.Sprintf("%d bps", bits)
}
div, exp := int64(unit), 0
for n := bits / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cbps", float64(bits)/float64(div), "kMGTPE"[exp])
}
// formatDuration formats an uptime duration.
func formatDuration(d time.Duration) string {
d = d.Round(time.Second)