feat: v0.4.0 — 配置架构重构 + 一键多端推送 + 项目级代理控制\n\n架构重构:\n- 配置从 Platform→User→Project 重构为扁平的 Projects+Groups+Credentials 模型\n- 侧边栏完全重写,适配新分组→项目树结构\n- 新增分组(Group)管理和凭据(Credential)管理\n\n新功能:\n- 一键多端推送:Push All 按钮,将当前分支/标签推送到所有远程仓库\n- 项目级代理控制:每个项目独立设置代理(跟随全局/强制开启/强制关闭)\n- GitClient 新增 RunWithProxy 方法,支持按调用级别覆盖全局代理\n- 所有网络操作(Pull/Push/Fetch)均支持项目级代理设置\n\n其他:\n- 新增 CHANGELOG.md 版本更新日志\n- 更新 README/README_CN 文档,反映最新 72+ API 方法\n- 版本号升级到 0.4.0(config.yml/info.json/Info.plist)
Some checks failed
Release / build-windows (push) Has been cancelled

This commit is contained in:
zyj
2026-03-12 11:41:26 +08:00
parent 3ec453cc38
commit c787c99622
17 changed files with 1443 additions and 727 deletions

View File

@@ -28,8 +28,10 @@ func New() *AppService {
if err != nil {
log.Printf("加载配置失败: %v, 使用默认配置", err)
cfg = &config.AppConfig{
Platforms: make(map[string]config.Platform),
Settings: config.Settings{Concurrency: 6, NetworkCheck: true, LogLevel: "info"},
Projects: []config.ProjectItem{},
Groups: []config.Group{},
Credentials: []config.Credential{},
Settings: config.Settings{Concurrency: 6, NetworkCheck: true, LogLevel: "info"},
}
}
return &AppService{
@@ -54,51 +56,91 @@ func (s *AppService) SelectDirectory() (string, error) {
return path, nil
}
// --- 平台/项目 树形结构 ---
// --- 项目/分组 树形结构 ---
// TreeNode 前端侧边栏树节点
type TreeNode struct {
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type"` // platform, user, project
Type string `json:"type"` // group, project
Path string `json:"path,omitempty"`
Icon string `json:"icon,omitempty"`
Children []TreeNode `json:"children,omitempty"`
}
// GetProjectTree 获取项目树,供前端侧边栏渲染
// GetProjectTree 获取项目树,按分组组织
func (s *AppService) GetProjectTree() []TreeNode {
var tree []TreeNode
for platformName, platform := range s.config.Platforms {
platformNode := TreeNode{
Key: platformName,
Label: platformName,
Type: "platform",
// 按分组归类项目
groupProjects := make(map[string][]config.ProjectItem)
for _, proj := range s.config.Projects {
g := proj.Group
if g == "" {
g = "未分组"
}
for _, user := range platform.Users {
userNode := TreeNode{
Key: platformName + "/" + user.Username,
Label: user.Username,
Type: "user",
}
for _, proj := range user.Projects {
userNode.Children = append(userNode.Children, TreeNode{
Key: platformName + "/" + user.Username + "/" + proj.Name,
Label: proj.Name,
Type: "project",
Path: proj.Path,
})
}
platformNode.Children = append(platformNode.Children, userNode)
}
tree = append(tree, platformNode)
groupProjects[g] = append(groupProjects[g], proj)
}
// 构建分组 → icon 映射
groupIcon := make(map[string]string)
for _, g := range s.config.Groups {
groupIcon[g.Name] = g.Icon
}
var tree []TreeNode
// 先输出已定义的分组(保持顺序)
rendered := make(map[string]bool)
for _, g := range s.config.Groups {
projects, ok := groupProjects[g.Name]
if !ok {
projects = nil
}
node := TreeNode{
Key: "group:" + g.Name,
Label: g.Name,
Type: "group",
Icon: g.Icon,
}
for _, proj := range projects {
node.Children = append(node.Children, TreeNode{
Key: "project:" + proj.Path,
Label: proj.Name,
Type: "project",
Path: proj.Path,
})
}
tree = append(tree, node)
rendered[g.Name] = true
}
// 输出"未分组"或不在 groups 列表里的项目
for groupName, projects := range groupProjects {
if rendered[groupName] {
continue
}
node := TreeNode{
Key: "group:" + groupName,
Label: groupName,
Type: "group",
Icon: "folder",
}
for _, proj := range projects {
node.Children = append(node.Children, TreeNode{
Key: "project:" + proj.Path,
Label: proj.Name,
Type: "project",
Path: proj.Path,
})
}
tree = append(tree, node)
}
return tree
}
// --- 项目管理 ---
// CloneProject 克隆远程仓库到本地目录,并添加到项目
func (s *AppService) CloneProject(platform, username, repoURL, parentDir, name string) error {
// CloneProject 克隆远程仓库到本地目录,并添加到项目列表
func (s *AppService) CloneProject(repoURL, parentDir, name, group string) error {
if repoURL == "" {
return fmt.Errorf("仓库地址不能为空")
}
@@ -109,198 +151,219 @@ func (s *AppService) CloneProject(platform, username, repoURL, parentDir, name s
return fmt.Errorf("项目名称不能为空")
}
// 目标路径: parentDir/name
targetPath := parentDir + "/" + name
// 执行 git clone
_, err := s.gitClient.Clone(repoURL, targetPath)
if err != nil {
return fmt.Errorf("克隆失败: %w", err)
}
// 克隆成功后添加到项目树
return s.AddProject(platform, username, name, targetPath)
return s.AddProject(name, targetPath, group)
}
// AddProject 添加项目到指定平台/用户下
func (s *AppService) AddProject(platform, username, name, path string) error {
p, ok := s.config.Platforms[platform]
if !ok {
return fmt.Errorf("平台 %s 不存在", platform)
// AddProject 添加项目
func (s *AppService) AddProject(name, path, group string) error {
// 检查路径重复
for _, proj := range s.config.Projects {
if proj.Path == path {
return fmt.Errorf("项目路径 %s 已存在", path)
}
}
for i, user := range p.Users {
if user.Username == username {
// 检查重复
for _, proj := range user.Projects {
if proj.Name == name {
return fmt.Errorf("项目 %s 已存在", name)
}
s.config.Projects = append(s.config.Projects, config.ProjectItem{
Name: name,
Path: path,
Group: group,
})
// 如果分组不存在,自动创建
if group != "" {
found := false
for _, g := range s.config.Groups {
if g.Name == group {
found = true
break
}
s.config.Platforms[platform].Users[i].Projects = append(
s.config.Platforms[platform].Users[i].Projects,
config.Project{Name: name, Path: path},
)
}
if !found {
s.config.Groups = append(s.config.Groups, config.Group{Name: group, Icon: "folder"})
}
}
return config.SaveConfig(s.config)
}
// RemoveProject 删除项目(按路径匹配)
func (s *AppService) RemoveProject(path string) error {
for i, proj := range s.config.Projects {
if proj.Path == path {
s.config.Projects = append(s.config.Projects[:i], s.config.Projects[i+1:]...)
return config.SaveConfig(s.config)
}
}
return fmt.Errorf("用户 %s 不存在于平台 %s", username, platform)
return fmt.Errorf("项目不存在: %s", path)
}
// RemoveProject 从指定平台/用户下删除项目
func (s *AppService) RemoveProject(platform, username, name string) error {
p, ok := s.config.Platforms[platform]
if !ok {
return fmt.Errorf("平台 %s 不存在", platform)
}
for i, user := range p.Users {
if user.Username == username {
projects := user.Projects
for j, proj := range projects {
if proj.Name == name {
s.config.Platforms[platform].Users[i].Projects = append(projects[:j], projects[j+1:]...)
return config.SaveConfig(s.config)
// MoveProjectToGroup 移动项目到指定分组
func (s *AppService) MoveProjectToGroup(path, group string) error {
for i, proj := range s.config.Projects {
if proj.Path == path {
s.config.Projects[i].Group = group
// 自动创建分组
if group != "" {
found := false
for _, g := range s.config.Groups {
if g.Name == group {
found = true
break
}
}
if !found {
s.config.Groups = append(s.config.Groups, config.Group{Name: group, Icon: "folder"})
}
}
return fmt.Errorf("项目 %s 不存在", name)
return config.SaveConfig(s.config)
}
}
return fmt.Errorf("用户 %s 不存在于平台 %s", username, platform)
return fmt.Errorf("项目不存在: %s", path)
}
// --- 平台管理 ---
// --- 分组管理 ---
// AddPlatform 添加新平台
func (s *AppService) AddPlatform(name, baseURL string) error {
// GetGroups 获取所有分组
func (s *AppService) GetGroups() []config.Group {
return s.config.Groups
}
// AddGroup 添加分组
func (s *AppService) AddGroup(name, icon string) error {
if name == "" {
return fmt.Errorf("平台名称不能为空")
return fmt.Errorf("分组名称不能为空")
}
if _, ok := s.config.Platforms[name]; ok {
return fmt.Errorf("平台 %s 已存在", name)
for _, g := range s.config.Groups {
if g.Name == name {
return fmt.Errorf("分组 %s 已存在", name)
}
}
s.config.Platforms[name] = config.Platform{
BaseURL: baseURL,
Users: []config.User{},
if icon == "" {
icon = "folder"
}
s.config.Groups = append(s.config.Groups, config.Group{Name: name, Icon: icon})
return config.SaveConfig(s.config)
}
// UpdatePlatform 修改平台信息base_url
func (s *AppService) UpdatePlatform(name, baseURL string) error {
p, ok := s.config.Platforms[name]
if !ok {
return fmt.Errorf("平台 %s 不存在", name)
// UpdateGroup 修改分组
func (s *AppService) UpdateGroup(oldName, newName, icon string) error {
if newName == "" {
return fmt.Errorf("分组名称不能为空")
}
p.BaseURL = baseURL
s.config.Platforms[name] = p
return config.SaveConfig(s.config)
for i, g := range s.config.Groups {
if g.Name == oldName {
// 如果改名,检查冲突并同步更新项目的分组引用
if oldName != newName {
for _, g2 := range s.config.Groups {
if g2.Name == newName {
return fmt.Errorf("分组 %s 已存在", newName)
}
}
for j, proj := range s.config.Projects {
if proj.Group == oldName {
s.config.Projects[j].Group = newName
}
}
}
s.config.Groups[i].Name = newName
if icon != "" {
s.config.Groups[i].Icon = icon
}
return config.SaveConfig(s.config)
}
}
return fmt.Errorf("分组 %s 不存在", oldName)
}
// RemovePlatform 删除平台
func (s *AppService) RemovePlatform(name string) error {
if _, ok := s.config.Platforms[name]; !ok {
return fmt.Errorf("平台 %s 不存在", name)
// RemoveGroup 删除分组(分组下的项目变为"未分组"
func (s *AppService) RemoveGroup(name string) error {
for i, g := range s.config.Groups {
if g.Name == name {
s.config.Groups = append(s.config.Groups[:i], s.config.Groups[i+1:]...)
// 将该分组下项目归入未分组
for j, proj := range s.config.Projects {
if proj.Group == name {
s.config.Projects[j].Group = ""
}
}
return config.SaveConfig(s.config)
}
}
delete(s.config.Platforms, name)
return config.SaveConfig(s.config)
return fmt.Errorf("分组 %s 不存在", name)
}
// --- 用户管理 ---
// --- 凭证管理 ---
// AddUser 添加用户到指定平台
func (s *AppService) AddUser(platform, username, token string) error {
p, ok := s.config.Platforms[platform]
if !ok {
return fmt.Errorf("平台 %s 不存在", platform)
// CredentialInfo 凭证信息(前端展示用)
type CredentialInfo struct {
Platform string `json:"platform"`
BaseURL string `json:"baseUrl"`
Username string `json:"username"`
Token string `json:"token"`
}
// GetCredentials 获取所有凭证
func (s *AppService) GetCredentials() []CredentialInfo {
var result []CredentialInfo
for _, c := range s.config.Credentials {
result = append(result, CredentialInfo{
Platform: c.Platform,
BaseURL: c.BaseURL,
Username: c.Username,
Token: c.Token,
})
}
return result
}
// AddCredential 添加凭证
func (s *AppService) AddCredential(platform, baseURL, username, token string) error {
if platform == "" {
return fmt.Errorf("平台不能为空")
}
if username == "" {
return fmt.Errorf("用户名不能为空")
}
for _, user := range p.Users {
if user.Username == username {
return fmt.Errorf("用户 %s 已存在于平台 %s", username, platform)
// 检查重复(同平台同用户名)
for _, c := range s.config.Credentials {
if c.Platform == platform && c.Username == username {
return fmt.Errorf("凭证已存在: %s/%s", platform, username)
}
}
p.Users = append(p.Users, config.User{
s.config.Credentials = append(s.config.Credentials, config.Credential{
Platform: platform,
BaseURL: baseURL,
Username: username,
Token: token,
Projects: []config.Project{},
})
s.config.Platforms[platform] = p
return config.SaveConfig(s.config)
}
// UpdateUser 修改用户信息用户名、token
func (s *AppService) UpdateUser(platform, oldUsername, newUsername, token string) error {
p, ok := s.config.Platforms[platform]
if !ok {
return fmt.Errorf("平台 %s 不存在", platform)
}
if newUsername == "" {
return fmt.Errorf("新用户名不能为空")
}
for i, user := range p.Users {
if user.Username == oldUsername {
// 如果改了用户名,检查新名字是否冲突
if oldUsername != newUsername {
for _, u := range p.Users {
if u.Username == newUsername {
return fmt.Errorf("用户 %s 已存在于平台 %s", newUsername, platform)
}
}
}
s.config.Platforms[platform].Users[i].Username = newUsername
s.config.Platforms[platform].Users[i].Token = token
// UpdateCredential 更新凭证
func (s *AppService) UpdateCredential(platform, username, baseURL, token string) error {
for i, c := range s.config.Credentials {
if c.Platform == platform && c.Username == username {
s.config.Credentials[i].BaseURL = baseURL
s.config.Credentials[i].Token = token
return config.SaveConfig(s.config)
}
}
return fmt.Errorf("用户 %s 不存在于平台 %s", oldUsername, platform)
return fmt.Errorf("凭证不存在: %s/%s", platform, username)
}
// RemoveUser 从平台删除用户
func (s *AppService) RemoveUser(platform, username string) error {
p, ok := s.config.Platforms[platform]
if !ok {
return fmt.Errorf("平台 %s 不存在", platform)
}
for i, user := range p.Users {
if user.Username == username {
s.config.Platforms[platform] = config.Platform{
BaseURL: p.BaseURL,
Users: append(p.Users[:i], p.Users[i+1:]...),
}
// RemoveCredential 删除凭证
func (s *AppService) RemoveCredential(platform, username string) error {
for i, c := range s.config.Credentials {
if c.Platform == platform && c.Username == username {
s.config.Credentials = append(s.config.Credentials[:i], s.config.Credentials[i+1:]...)
return config.SaveConfig(s.config)
}
}
return fmt.Errorf("用户 %s 不存在于平台 %s", username, platform)
}
// GetUserInfo 获取用户信息
func (s *AppService) GetUserInfo(platform, username string) (*UserInfo, error) {
p, ok := s.config.Platforms[platform]
if !ok {
return nil, fmt.Errorf("平台 %s 不存在", platform)
}
for _, user := range p.Users {
if user.Username == username {
return &UserInfo{
Username: user.Username,
Token: user.Token,
}, nil
}
}
return nil, fmt.Errorf("用户 %s 不存在于平台 %s", username, platform)
}
// GetPlatformInfo 获取平台信息
func (s *AppService) GetPlatformInfo(name string) (*PlatformInfo, error) {
p, ok := s.config.Platforms[name]
if !ok {
return nil, fmt.Errorf("平台 %s 不存在", name)
}
return &PlatformInfo{
Name: name,
BaseURL: p.BaseURL,
}, nil
return fmt.Errorf("凭证不存在: %s/%s", platform, username)
}
// --- Git 操作 ---
@@ -312,6 +375,7 @@ type ProjectStatus struct {
Remotes []RemoteItem `json:"remotes"`
CurrentRemote string `json:"currentRemote"`
ChangedFiles []FileInfo `json:"changedFiles"`
UseProxy *bool `json:"useProxy"` // nil=跟随全局, true=开启, false=关闭
}
// RemoteItem 远程仓库信息
@@ -328,18 +392,6 @@ type FileInfo struct {
Staged bool `json:"staged"`
}
// UserInfo 用户信息
type UserInfo struct {
Username string `json:"username"`
Token string `json:"token"`
}
// PlatformInfo 平台信息
type PlatformInfo struct {
Name string `json:"name"`
BaseURL string `json:"baseUrl"`
}
// GetProjectStatus 获取项目 git 状态
func (s *AppService) GetProjectStatus(path string) (*ProjectStatus, error) {
// 先校验路径是否存在
@@ -367,12 +419,22 @@ func (s *AppService) GetProjectStatus(path string) (*ProjectStatus, error) {
currentRemote = remotes[0].Name
}
// 获取项目代理设置
var useProxy *bool
for _, proj := range s.config.Projects {
if proj.Path == path {
useProxy = proj.UseProxy
break
}
}
return &ProjectStatus{
Branch: strings.TrimSpace(branch),
RemoteURL: remoteURL,
Remotes: remotes,
CurrentRemote: currentRemote,
ChangedFiles: []FileInfo{},
UseProxy: useProxy,
}, nil
}
@@ -547,7 +609,8 @@ func (s *AppService) PullProject(path, remote string) (string, error) {
if err != nil {
return "", fmt.Errorf("获取当前分支失败: %w", err)
}
return s.gitClient.PullFrom(path, remote, strings.TrimSpace(branch))
proxy := s.GetProjectProxy(path)
return s.gitClient.RunWithProxy(path, proxy, "pull", remote, strings.TrimSpace(branch))
}
// PushProject 推送项目(当前分支,指定 remote
@@ -559,7 +622,102 @@ func (s *AppService) PushProject(path, remote string) (string, error) {
if err != nil {
return "", fmt.Errorf("获取当前分支失败: %w", err)
}
return s.gitClient.PushTo(path, remote, strings.TrimSpace(branch))
proxy := s.GetProjectProxy(path)
return s.gitClient.RunWithProxy(path, proxy, "push", remote, strings.TrimSpace(branch))
}
// PushAllResult 一键多端推送结果
type PushAllResult struct {
Remote string `json:"remote"`
Success bool `json:"success"`
Message string `json:"message"`
}
// PushToAllRemotes 一键推送到所有远程仓库(当前分支)
func (s *AppService) PushToAllRemotes(path string) ([]PushAllResult, error) {
if _, err := os.Stat(path); os.IsNotExist(err) {
return nil, fmt.Errorf("项目路径不存在: %s", path)
}
branch, err := s.gitClient.Branch(path)
if err != nil {
return nil, fmt.Errorf("获取当前分支失败: %w", err)
}
branch = strings.TrimSpace(branch)
remoteList, err := s.gitClient.RemoteList(path)
if err != nil {
return nil, fmt.Errorf("获取远程仓库列表失败: %w", err)
}
proxy := s.GetProjectProxy(path)
var results []PushAllResult
for _, r := range remoteList {
result := PushAllResult{Remote: r.Name}
_, pushErr := s.gitClient.RunWithProxy(path, proxy, "push", r.Name, branch)
if pushErr != nil {
result.Message = pushErr.Error()
} else {
result.Success = true
result.Message = "推送成功"
}
results = append(results, result)
}
return results, nil
}
// PushTagToAllRemotes 一键推送标签到所有远程仓库
func (s *AppService) PushTagToAllRemotes(path, tagName string) ([]PushAllResult, error) {
if _, err := os.Stat(path); os.IsNotExist(err) {
return nil, fmt.Errorf("项目路径不存在: %s", path)
}
tagName = strings.TrimSpace(tagName)
if tagName == "" {
return nil, fmt.Errorf("标签名不能为空")
}
remoteList, err := s.gitClient.RemoteList(path)
if err != nil {
return nil, fmt.Errorf("获取远程仓库列表失败: %w", err)
}
proxy := s.GetProjectProxy(path)
var results []PushAllResult
for _, r := range remoteList {
result := PushAllResult{Remote: r.Name}
_, pushErr := s.gitClient.RunWithProxy(path, proxy, "push", r.Name, tagName)
if pushErr != nil {
result.Message = pushErr.Error()
} else {
result.Success = true
result.Message = "推送成功"
}
results = append(results, result)
}
return results, nil
}
// --- 项目代理开关 ---
// GetProjectProxy 获取项目代理设置nil 表示跟随全局)
func (s *AppService) GetProjectProxy(path string) *bool {
for _, proj := range s.config.Projects {
if proj.Path == path {
return proj.UseProxy
}
}
return nil
}
// SetProjectProxy 设置项目代理开关
// useProxy: true=强制启用, false=强制禁用, nil(传空)=跟随全局
func (s *AppService) SetProjectProxy(path string, useProxy *bool) error {
for i, proj := range s.config.Projects {
if proj.Path == path {
s.config.Projects[i].UseProxy = useProxy
return config.SaveConfig(s.config)
}
}
return fmt.Errorf("项目不存在: %s", path)
}
// GetCommitDiff 获取指定提交的 diff
@@ -612,10 +770,11 @@ func (s *AppService) GetCommitFileDiff(path, hash, filePath string) (string, err
// FetchProject 拉取远程信息(指定 remote空则 fetch --all
func (s *AppService) FetchProject(path, remote string) (string, error) {
proxy := s.GetProjectProxy(path)
if remote == "" {
return s.gitClient.Fetch(path)
return s.gitClient.RunWithProxy(path, proxy, "fetch")
}
return s.gitClient.FetchRemote(path, remote)
return s.gitClient.RunWithProxy(path, proxy, "fetch", remote)
}
// CommitLog 提交记录
@@ -1269,30 +1428,26 @@ type ProjectOverview struct {
// GetAllProjectOverview 获取所有项目的概览状态
func (s *AppService) GetAllProjectOverview() []ProjectOverview {
var results []ProjectOverview
for platformName, platform := range s.config.Platforms {
for _, user := range platform.Users {
for _, proj := range user.Projects {
overview := ProjectOverview{
Key: platformName + "/" + user.Username + "/" + proj.Name,
Name: proj.Name,
Path: proj.Path,
}
if _, err := os.Stat(proj.Path); os.IsNotExist(err) {
overview.Error = "路径不存在"
results = append(results, overview)
continue
}
branch, hasChanges, unpushed, err := s.gitClient.QuickStatus(proj.Path)
if err != nil {
overview.Error = err.Error()
} else {
overview.Branch = branch
overview.HasChanges = hasChanges
overview.Unpushed = unpushed
}
results = append(results, overview)
}
for _, proj := range s.config.Projects {
overview := ProjectOverview{
Key: proj.Path,
Name: proj.Name,
Path: proj.Path,
}
if _, err := os.Stat(proj.Path); os.IsNotExist(err) {
overview.Error = "路径不存在"
results = append(results, overview)
continue
}
branch, hasChanges, unpushed, err := s.gitClient.QuickStatus(proj.Path)
if err != nil {
overview.Error = err.Error()
} else {
overview.Branch = branch
overview.HasChanges = hasChanges
overview.Unpushed = unpushed
}
results = append(results, overview)
}
return results
}

View File

@@ -29,10 +29,20 @@ func NewGitClient() *GitClient {
// Run 执行 git 命令,支持超时和代理设置
func (g *GitClient) Run(path string, args ...string) (string, error) {
return g.RunWithProxy(path, nil, args...)
}
// RunWithProxy 执行 git 命令useProxy 可覆盖全局代理设置
// useProxy == nil 时跟随 GitClient 自身的 Enabled 设置
func (g *GitClient) RunWithProxy(path string, useProxy *bool, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), g.Timeout)
defer cancel()
header := []string{"-C", path, "-c", "core.quotePath=false"}
if g.Enabled {
enableProxy := g.Enabled
if useProxy != nil {
enableProxy = *useProxy
}
if enableProxy {
header = append(header, "-c", "http.proxy="+g.Proxy, "-c", "https.proxy="+g.Proxy)
}
argsArr := append(header, args...)