diff --git a/wails3-app/.gitignore b/wails3-app/.gitignore
new file mode 100644
index 0000000..e68f2d4
--- /dev/null
+++ b/wails3-app/.gitignore
@@ -0,0 +1,30 @@
+# Go
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+*.test
+*.out
+go.work
+
+# Frontend
+node_modules/
+dist/
+build/
+*.log
+
+# Assets (generated)
+assets/
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
+
+# OS
+.DS_Store
+Thumbs.db
+
diff --git a/wails3-app/README.md b/wails3-app/README.md
new file mode 100644
index 0000000..96fa40e
--- /dev/null
+++ b/wails3-app/README.md
@@ -0,0 +1,121 @@
+# VideoConcat - Wails3 版本
+
+这是使用 Wails3 重构的视频拼接工具。
+
+## 项目结构
+
+```
+wails3-app/
+├── app.go # 应用入口
+├── go.mod # Go 模块文件
+├── services/ # 后端服务
+│ ├── video_service.go # 视频处理服务
+│ ├── extract_service.go # 抽帧服务
+│ ├── auth_service.go # 认证服务
+│ ├── file_service.go # 文件服务
+│ └── log.go # 日志工具
+├── frontend/ # 前端代码
+│ ├── package.json
+│ ├── vite.config.js
+│ ├── index.html
+│ └── src/
+│ ├── main.js
+│ ├── App.vue
+│ └── components/
+│ ├── VideoTab.vue
+│ ├── ExtractTab.vue
+│ └── LoginDialog.vue
+└── assets/ # 编译后的前端资源(自动生成)
+```
+
+## 环境要求
+
+- Go 1.21 或更高版本
+- Node.js 16 或更高版本
+- FFmpeg(需要安装并添加到系统 PATH)
+
+## 安装步骤
+
+### 1. 安装 Wails3
+
+```bash
+# 克隆 Wails 仓库
+git clone https://github.com/wailsapp/wails.git
+cd wails
+git checkout v3-alpha
+
+# 安装 Wails3 CLI
+cd v3/cmd/wails3
+go install
+```
+
+### 2. 安装前端依赖
+
+```bash
+cd frontend
+npm install
+```
+
+### 3. 构建前端
+
+```bash
+npm run build
+```
+
+### 4. 运行应用
+
+```bash
+# 在项目根目录
+go run app.go
+```
+
+## 功能说明
+
+### 视频拼接
+
+1. **组合拼接模式**:从每个文件夹随机选择视频进行组合
+2. **顺序拼接模式**:按索引顺序从每个文件夹选择对应位置的视频
+
+### 视频抽帧
+
+- 随机删除视频中的一帧
+- 支持批量处理
+- 自动处理 HEVC 编码
+
+### 视频元数据修改
+
+- 通过修改视频元数据改变文件 MD5
+- 不重新编码视频,处理速度快
+
+## 注意事项
+
+1. 确保 FFmpeg 已安装并可在命令行中使用
+2. 视频处理需要足够的磁盘空间
+3. 临时文件存储在系统临时目录
+
+## 开发说明
+
+### 后端服务
+
+所有后端服务都在 `services/` 目录下,使用 Go 编写。
+
+### 前端
+
+前端使用 Vue3 + Vite 构建,代码在 `frontend/` 目录下。
+
+### 前后端通信
+
+Wails3 使用自动绑定机制,后端服务通过 `app.Bind()` 绑定后,前端可以通过 `window.go.services.ServiceName` 访问。
+
+## 已知问题
+
+1. 文件选择对话框在浏览器环境中需要使用 HTML5 API,实际部署时可能需要使用 Wails3 的文件对话框 API
+2. 进度回调函数需要根据 Wails3 的实际 API 进行调整
+
+## 后续改进
+
+1. 添加视频预览功能
+2. 优化错误处理和用户提示
+3. 添加更多视频格式支持
+4. 优化 UI 设计
+
diff --git a/wails3-app/app.go b/wails3-app/app.go
new file mode 100644
index 0000000..acba917
--- /dev/null
+++ b/wails3-app/app.go
@@ -0,0 +1,50 @@
+package main
+
+import (
+ "embed"
+
+ "github.com/wailsapp/wails/v3/pkg/application"
+ "videoconcat/services"
+)
+
+//go:embed assets
+var assets embed.FS
+
+func main() {
+ app := application.New(application.Options{
+ Name: "VideoConcat",
+ Description: "视频拼接工具",
+ Assets: application.AssetOptions{FS: assets},
+ Mac: application.MacOptions{
+ ApplicationShouldTerminateAfterLastWindowClosed: true,
+ },
+ })
+
+ // 创建服务
+ videoService := services.NewVideoService()
+ extractService := services.NewExtractService()
+ authService := services.NewAuthService()
+ fileService := services.NewFileService()
+
+ // 绑定服务到前端
+ app.Bind(videoService)
+ app.Bind(extractService)
+ app.Bind(authService)
+ app.Bind(fileService)
+
+ // 创建窗口
+ window := app.NewWebviewWindow(application.WebviewWindowOptions{
+ Title: "视频拼接工具",
+ Width: 1100,
+ Height: 800,
+ MinWidth: 800,
+ MinHeight: 600,
+ })
+
+ window.SetURL("index.html")
+
+ if err := app.Run(); err != nil {
+ panic(err)
+ }
+}
+
diff --git a/wails3-app/frontend/index.html b/wails3-app/frontend/index.html
new file mode 100644
index 0000000..a69bfcc
--- /dev/null
+++ b/wails3-app/frontend/index.html
@@ -0,0 +1,29 @@
+
+
+
+
+
+ 视频拼接工具
+
+
+
+
+
+
+
+
diff --git a/wails3-app/frontend/package.json b/wails3-app/frontend/package.json
new file mode 100644
index 0000000..99ce6e3
--- /dev/null
+++ b/wails3-app/frontend/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "videoconcat-frontend",
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "vue": "^3.4.0"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^5.0.0",
+ "vite": "^5.0.0"
+ }
+}
+
diff --git a/wails3-app/frontend/src/App.vue b/wails3-app/frontend/src/App.vue
new file mode 100644
index 0000000..ed3bff4
--- /dev/null
+++ b/wails3-app/frontend/src/App.vue
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+
diff --git a/wails3-app/frontend/src/components/ExtractTab.vue b/wails3-app/frontend/src/components/ExtractTab.vue
new file mode 100644
index 0000000..9ce181e
--- /dev/null
+++ b/wails3-app/frontend/src/components/ExtractTab.vue
@@ -0,0 +1,334 @@
+
+
+
+
+
+
+
+
diff --git a/wails3-app/frontend/src/components/LoginDialog.vue b/wails3-app/frontend/src/components/LoginDialog.vue
new file mode 100644
index 0000000..e84767e
--- /dev/null
+++ b/wails3-app/frontend/src/components/LoginDialog.vue
@@ -0,0 +1,200 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ errorMessage }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/wails3-app/frontend/src/components/VideoTab.vue b/wails3-app/frontend/src/components/VideoTab.vue
new file mode 100644
index 0000000..51ac63e
--- /dev/null
+++ b/wails3-app/frontend/src/components/VideoTab.vue
@@ -0,0 +1,403 @@
+
+
+
视频拼接
+
+
+
+
+
+
+
+
+
+
+ 最大可生成:{{ maxNum }} 个
+
+
+
+
+
+
+
+
+
+
拼接结果
+
+
+
+ | 序号 |
+ 文件名 |
+ 大小 |
+ 时长 |
+ 状态 |
+ 进度 |
+
+
+
+
+ | {{ result.index }} |
+ {{ result.fileName }} |
+ {{ result.size }} |
+ {{ result.seconds }}秒 |
+
+ {{ result.status }}
+ |
+
+
+
+ {{ result.progress }}
+
+ |
+
+
+
+
+
+
+
+
+
+
+
diff --git a/wails3-app/frontend/src/main.js b/wails3-app/frontend/src/main.js
new file mode 100644
index 0000000..eaa7028
--- /dev/null
+++ b/wails3-app/frontend/src/main.js
@@ -0,0 +1,5 @@
+import { createApp } from 'vue'
+import App from './App.vue'
+
+createApp(App).mount('#app')
+
diff --git a/wails3-app/frontend/vite.config.js b/wails3-app/frontend/vite.config.js
new file mode 100644
index 0000000..251fc38
--- /dev/null
+++ b/wails3-app/frontend/vite.config.js
@@ -0,0 +1,12 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+
+export default defineConfig({
+ plugins: [vue()],
+ build: {
+ outDir: '../assets',
+ emptyOutDir: true,
+ },
+ base: './',
+})
+
diff --git a/wails3-app/go.mod b/wails3-app/go.mod
new file mode 100644
index 0000000..419ce59
--- /dev/null
+++ b/wails3-app/go.mod
@@ -0,0 +1,12 @@
+module videoconcat
+
+go 1.21
+
+require (
+ github.com/wailsapp/wails/v3 v3.0.0-alpha.57
+)
+
+require (
+ github.com/google/uuid v1.3.0 // indirect
+)
+
diff --git a/wails3-app/services/auth_service.go b/wails3-app/services/auth_service.go
new file mode 100644
index 0000000..fc500b7
--- /dev/null
+++ b/wails3-app/services/auth_service.go
@@ -0,0 +1,104 @@
+package services
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net"
+ "net/http"
+ "os"
+ "time"
+)
+
+// AuthService 认证服务
+type AuthService struct {
+ baseURL string
+ client *http.Client
+}
+
+// NewAuthService 创建认证服务实例
+func NewAuthService() *AuthService {
+ return &AuthService{
+ baseURL: "https://admin.xiangbing.vip",
+ client: &http.Client{
+ Timeout: 30 * time.Second,
+ },
+ }
+}
+
+// LoginRequest 登录请求
+type LoginRequest struct {
+ Username string `json:"Username"`
+ Password string `json:"Password"`
+ Platform string `json:"Platform"`
+ PcName string `json:"PcName"`
+ PcUserName string `json:"PcUserName"`
+ Ips string `json:"Ips"`
+}
+
+// LoginResponse 登录响应
+type LoginResponse struct {
+ Code int `json:"Code"`
+ Msg string `json:"Msg"`
+ Data interface{} `json:"Data"`
+}
+
+// Login 用户登录
+func (s *AuthService) Login(ctx context.Context, username, password string) (*LoginResponse, error) {
+ // 获取机器信息
+ pcMachineName, _ := os.Hostname()
+ pcUserName := os.Getenv("USERNAME")
+ if pcUserName == "" {
+ pcUserName = os.Getenv("USER")
+ }
+
+ // 获取 IP 地址
+ var ips string
+ addrs, err := net.InterfaceAddrs()
+ if err == nil {
+ for _, addr := range addrs {
+ if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
+ if ipnet.IP.To4() != nil {
+ ips = ipnet.IP.String()
+ break
+ }
+ }
+ }
+ }
+
+ reqData := LoginRequest{
+ Username: username,
+ Password: password,
+ Platform: "pc",
+ PcName: pcMachineName,
+ PcUserName: pcUserName,
+ Ips: ips,
+ }
+
+ jsonData, err := json.Marshal(reqData)
+ if err != nil {
+ return nil, fmt.Errorf("序列化请求数据失败: %v", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "POST", s.baseURL+"/api/base/login", bytes.NewBuffer(jsonData))
+ if err != nil {
+ return nil, fmt.Errorf("创建请求失败: %v", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("请求失败: %v", err)
+ }
+ defer resp.Body.Close()
+
+ var loginResp LoginResponse
+ if err := json.NewDecoder(resp.Body).Decode(&loginResp); err != nil {
+ return nil, fmt.Errorf("解析响应失败: %v", err)
+ }
+
+ return &loginResp, nil
+}
+
diff --git a/wails3-app/services/extract_service.go b/wails3-app/services/extract_service.go
new file mode 100644
index 0000000..12804e0
--- /dev/null
+++ b/wails3-app/services/extract_service.go
@@ -0,0 +1,318 @@
+package services
+
+import (
+ "context"
+ "fmt"
+ "math/rand"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+)
+
+// ExtractService 抽帧服务
+type ExtractService struct {
+ mu sync.Mutex
+}
+
+// NewExtractService 创建抽帧服务实例
+func NewExtractService() *ExtractService {
+ return &ExtractService{}
+}
+
+// ExtractFrameRequest 抽帧请求
+type ExtractFrameRequest struct {
+ FolderPath string `json:"folderPath"`
+ ExtractCount int `json:"extractCount"` // 每个视频生成的数量
+}
+
+// ExtractFrameResult 抽帧结果
+type ExtractFrameResult struct {
+ VideoPath string `json:"videoPath"`
+ OutputPath string `json:"outputPath"`
+ Success bool `json:"success"`
+ Error string `json:"error,omitempty"`
+}
+
+// ListVideos 列出文件夹中的视频文件
+func (s *ExtractService) ListVideos(ctx context.Context, folderPath string) ([]string, error) {
+ pattern := filepath.Join(folderPath, "*.mp4")
+ matches, err := filepath.Glob(pattern)
+ if err != nil {
+ return nil, fmt.Errorf("查找视频文件失败: %v", err)
+ }
+ return matches, nil
+}
+
+// RemoveFrameRandom 随机删除视频中的一帧
+func (s *ExtractService) RemoveFrameRandom(ctx context.Context, inputPath string, outputPath string) error {
+ // 创建临时目录
+ tempDir := filepath.Join(os.TempDir(), fmt.Sprintf("extract_%d", time.Now().UnixNano()))
+ defer os.RemoveAll(tempDir)
+ os.MkdirAll(tempDir, 0755)
+
+ // 获取视频信息
+ cmd := exec.Command("ffprobe", "-v", "error", "-show_entries", "format=duration:stream=codec_name,r_frame_rate", "-of", "default=noprint_wrappers=1:nokey=1", inputPath)
+ output, err := cmd.Output()
+ if err != nil {
+ return fmt.Errorf("获取视频信息失败: %v", err)
+ }
+
+ lines := strings.Split(strings.TrimSpace(string(output)), "\n")
+ var duration float64
+ var codecName string
+ var frameRate float64 = 30.0 // 默认帧率
+
+ for i, line := range lines {
+ line = strings.TrimSpace(line)
+ if i == 0 && line != "" {
+ fmt.Sscanf(line, "%f", &duration)
+ }
+ if strings.HasPrefix(line, "hevc") || strings.HasPrefix(line, "h264") {
+ codecName = line
+ }
+ if strings.Contains(line, "/") {
+ parts := strings.Split(line, "/")
+ if len(parts) == 2 {
+ var num, den float64
+ fmt.Sscanf(parts[0], "%f", &num)
+ fmt.Sscanf(parts[1], "%f", &den)
+ if den > 0 {
+ frameRate = num / den
+ }
+ }
+ }
+ }
+
+ // 检查时长
+ if duration < 20 {
+ return fmt.Errorf("视频时长太短(%.2f秒),无法抽帧(需要至少20秒)", duration)
+ }
+
+ // 如果是 HEVC,先转换为 H.264
+ if codecName == "hevc" {
+ videoConvert := filepath.Join(tempDir, "convert.mp4")
+ cmd := exec.Command("ffmpeg", "-i", inputPath, "-c:v", "libx264", "-y", videoConvert)
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("转换HEVC失败: %v", err)
+ }
+ inputPath = videoConvert
+ }
+
+ // 随机选择要删除的帧时间点(避开开头和结尾)
+ rand.Seed(time.Now().UnixNano())
+ minFrameTime := 20.0
+ maxFrameTime := duration - 5.0
+ if maxFrameTime <= minFrameTime {
+ maxFrameTime = minFrameTime + 1.0
+ }
+ randomFrame := minFrameTime + rand.Float64()*(maxFrameTime-minFrameTime)
+
+ // 分割视频
+ videoPart1 := filepath.Join(tempDir, "part1.mp4")
+ videoPart2 := filepath.Join(tempDir, "part2.mp4")
+
+ // 第一部分:0 到 randomFrame - 0.016
+ cmd = exec.Command("ffmpeg", "-i", inputPath, "-ss", "0", "-t", fmt.Sprintf("%.6f", randomFrame-0.016), "-c", "copy", "-y", videoPart1)
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("裁剪第一部分失败: %v", err)
+ }
+
+ // 第二部分:randomFrame 到结束
+ cmd = exec.Command("ffmpeg", "-i", inputPath, "-ss", fmt.Sprintf("%.6f", randomFrame), "-c", "copy", "-y", videoPart2)
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("裁剪第二部分失败: %v", err)
+ }
+
+ // 合并视频
+ concatFile := filepath.Join(tempDir, "concat.txt")
+ file, err := os.Create(concatFile)
+ if err != nil {
+ return fmt.Errorf("创建concat文件失败: %v", err)
+ }
+ file.WriteString(fmt.Sprintf("file '%s'\n", strings.ReplaceAll(videoPart1, "\\", "/")))
+ file.WriteString(fmt.Sprintf("file '%s'\n", strings.ReplaceAll(videoPart2, "\\", "/")))
+ file.Close()
+
+ cmd = exec.Command("ffmpeg", "-f", "concat", "-safe", "0", "-i", concatFile, "-c", "copy", "-y", outputPath)
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("合并视频失败: %v", err)
+ }
+
+ // 验证输出文件
+ if _, err := os.Stat(outputPath); err != nil {
+ return fmt.Errorf("输出文件不存在: %v", err)
+ }
+
+ return nil
+}
+
+// ExtractFrames 批量抽帧
+func (s *ExtractService) ExtractFrames(ctx context.Context, req ExtractFrameRequest) ([]ExtractFrameResult, error) {
+ var results []ExtractFrameResult
+ // 列出所有视频
+ videos, err := s.ListVideos(ctx, req.FolderPath)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(videos) == 0 {
+ return nil, fmt.Errorf("没有找到视频文件")
+ }
+
+ // 创建输出目录
+ outputDir := filepath.Join(req.FolderPath, "out")
+ if err := os.MkdirAll(outputDir, 0755); err != nil {
+ return nil, fmt.Errorf("创建输出目录失败: %v", err)
+ }
+
+ // 生成任务列表
+ type Task struct {
+ VideoPath string
+ Index int
+ OutputPath string
+ }
+
+ var tasks []Task
+ for _, video := range videos {
+ originalFileName := strings.TrimSuffix(filepath.Base(video), filepath.Ext(video))
+ extension := filepath.Ext(video)
+ for i := 1; i <= req.ExtractCount; i++ {
+ outputFileName := fmt.Sprintf("%s_%04d%s", originalFileName, i, extension)
+ outputPath := filepath.Join(outputDir, outputFileName)
+ tasks = append(tasks, Task{
+ VideoPath: video,
+ Index: i,
+ OutputPath: outputPath,
+ })
+ }
+ }
+
+ // 并发处理(限制并发数)
+ semaphore := make(chan struct{}, 10)
+ var wg sync.WaitGroup
+ total := len(tasks)
+ current := 0
+ var mu sync.Mutex
+
+ for _, task := range tasks {
+ wg.Add(1)
+ go func(t Task) {
+ defer wg.Done()
+ semaphore <- struct{}{}
+ defer func() { <-semaphore }()
+
+ // 检查文件是否已存在
+ if _, err := os.Stat(t.OutputPath); err == nil {
+ mu.Lock()
+ current++
+ result := ExtractFrameResult{
+ VideoPath: t.VideoPath,
+ OutputPath: t.OutputPath,
+ Success: true,
+ }
+ results = append(results, result)
+ mu.Unlock()
+ return
+ }
+
+ err := s.RemoveFrameRandom(ctx, t.VideoPath, t.OutputPath)
+ mu.Lock()
+ current++
+ var result ExtractFrameResult
+ if err != nil {
+ result = ExtractFrameResult{
+ VideoPath: t.VideoPath,
+ Success: false,
+ Error: err.Error(),
+ }
+ } else {
+ result = ExtractFrameResult{
+ VideoPath: t.VideoPath,
+ OutputPath: t.OutputPath,
+ Success: true,
+ }
+ }
+ results = append(results, result)
+ mu.Unlock()
+ }(task)
+ }
+
+ wg.Wait()
+ return results, nil
+}
+
+// ModifyByMetadata 通过修改元数据改变文件 MD5
+func (s *ExtractService) ModifyByMetadata(ctx context.Context, inputPath string, outputPath string) error {
+ comment := fmt.Sprintf("JSY_%s", time.Now().Format("20060102150405"))
+ cmd := exec.Command("ffmpeg", "-i", inputPath,
+ "-c", "copy",
+ "-metadata", fmt.Sprintf("comment=%s", comment),
+ "-y", outputPath)
+
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("修改元数据失败: %v", err)
+ }
+
+ return nil
+}
+
+// ModifyVideosMetadata 批量修改视频元数据
+func (s *ExtractService) ModifyVideosMetadata(ctx context.Context, folderPath string) ([]ExtractFrameResult, error) {
+ var results []ExtractFrameResult
+ videos, err := s.ListVideos(ctx, folderPath)
+ if err != nil {
+ return nil, err
+ }
+
+ outputDir := filepath.Join(folderPath, "out")
+ if err := os.MkdirAll(outputDir, 0755); err != nil {
+ return nil, fmt.Errorf("创建输出目录失败: %v", err)
+ }
+
+ semaphore := make(chan struct{}, 10)
+ var wg sync.WaitGroup
+ total := len(videos)
+ current := 0
+ var mu sync.Mutex
+
+ for _, video := range videos {
+ wg.Add(1)
+ go func(videoPath string) {
+ defer wg.Done()
+ semaphore <- struct{}{}
+ defer func() { <-semaphore }()
+
+ rand.Seed(time.Now().UnixNano())
+ randomNum := rand.Intn(90000) + 10000
+ outputFileName := fmt.Sprintf("modify%d%s", randomNum, filepath.Base(videoPath))
+ outputPath := filepath.Join(outputDir, outputFileName)
+
+ err := s.ModifyByMetadata(ctx, videoPath, outputPath)
+ mu.Lock()
+ current++
+ var result ExtractFrameResult
+ if err != nil {
+ result = ExtractFrameResult{
+ VideoPath: videoPath,
+ Success: false,
+ Error: err.Error(),
+ }
+ } else {
+ result = ExtractFrameResult{
+ VideoPath: videoPath,
+ OutputPath: outputPath,
+ Success: true,
+ }
+ }
+ results = append(results, result)
+ mu.Unlock()
+ }(video)
+ }
+
+ wg.Wait()
+ return results, nil
+}
+
diff --git a/wails3-app/services/file_service.go b/wails3-app/services/file_service.go
new file mode 100644
index 0000000..e2ff66e
--- /dev/null
+++ b/wails3-app/services/file_service.go
@@ -0,0 +1,76 @@
+package services
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+)
+
+// FileService 文件服务
+type FileService struct{}
+
+// NewFileService 创建文件服务实例
+func NewFileService() *FileService {
+ return &FileService{}
+}
+
+// SelectFolder 选择文件夹(返回路径)
+func (s *FileService) SelectFolder(ctx context.Context) (string, error) {
+ // 在 Wails3 中,文件选择需要通过前端实现
+ // 这里只是占位,实际应该通过前端调用系统对话框
+ return "", nil
+}
+
+// SelectFile 选择文件(返回路径)
+func (s *FileService) SelectFile(ctx context.Context, filter string) (string, error) {
+ // 在 Wails3 中,文件选择需要通过前端实现
+ return "", nil
+}
+
+// OpenFolder 打开文件夹
+func (s *FileService) OpenFolder(ctx context.Context, folderPath string) error {
+ // Windows 下打开文件夹
+ cmd := "explorer"
+ args := []string{folderPath}
+
+ // 这里需要使用 exec.Command,但为了简化,我们返回路径让前端处理
+ // 或者使用系统调用
+ return nil
+}
+
+// FileExists 检查文件是否存在
+func (s *FileService) FileExists(ctx context.Context, filePath string) (bool, error) {
+ _, err := os.Stat(filePath)
+ if err == nil {
+ return true, nil
+ }
+ if os.IsNotExist(err) {
+ return false, nil
+ }
+ return false, err
+}
+
+// GetFileSize 获取文件大小(字节)
+func (s *FileService) GetFileSize(ctx context.Context, filePath string) (int64, error) {
+ info, err := os.Stat(filePath)
+ if err != nil {
+ return 0, err
+ }
+ return info.Size(), nil
+}
+
+// EnsureDirectory 确保目录存在
+func (s *FileService) EnsureDirectory(ctx context.Context, dirPath string) error {
+ return os.MkdirAll(dirPath, 0755)
+}
+
+// ListFiles 列出目录中的文件
+func (s *FileService) ListFiles(ctx context.Context, dirPath string, pattern string) ([]string, error) {
+ patternPath := filepath.Join(dirPath, pattern)
+ matches, err := filepath.Glob(patternPath)
+ if err != nil {
+ return nil, err
+ }
+ return matches, nil
+}
+
diff --git a/wails3-app/services/log.go b/wails3-app/services/log.go
new file mode 100644
index 0000000..15e463e
--- /dev/null
+++ b/wails3-app/services/log.go
@@ -0,0 +1,60 @@
+package services
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+var logDir string
+
+func init() {
+ // 设置日志目录
+ exePath, err := os.Executable()
+ if err != nil {
+ logDir = "./Log"
+ } else {
+ logDir = filepath.Join(filepath.Dir(exePath), "Log")
+ }
+ os.MkdirAll(logDir, 0755)
+}
+
+// LogInfo 记录信息日志
+func LogInfo(message string) {
+ logMessage("INFO", message)
+}
+
+// LogError 记录错误日志
+func LogError(message string) {
+ logMessage("ERROR", message)
+}
+
+// LogWarn 记录警告日志
+func LogWarn(message string) {
+ logMessage("WARN", message)
+}
+
+// LogDebug 记录调试日志
+func LogDebug(message string) {
+ logMessage("DEBUG", message)
+}
+
+// logMessage 写入日志消息
+func logMessage(level, message string) {
+ // 控制台输出
+ log.Printf("[%s] %s", level, message)
+
+ // 文件输出
+ logFile := filepath.Join(logDir, fmt.Sprintf("log%s.log", time.Now().Format("20060102")))
+ file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
+ if err != nil {
+ return
+ }
+ defer file.Close()
+
+ timestamp := time.Now().Format("15:04:05")
+ file.WriteString(fmt.Sprintf("%s [%s] %s\n", timestamp, level, message))
+}
+
diff --git a/wails3-app/services/video_service.go b/wails3-app/services/video_service.go
new file mode 100644
index 0000000..62f74f0
--- /dev/null
+++ b/wails3-app/services/video_service.go
@@ -0,0 +1,413 @@
+package services
+
+import (
+ "context"
+ "crypto/md5"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "math/rand"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+)
+
+// VideoService 视频处理服务
+type VideoService struct {
+ mu sync.Mutex
+}
+
+// NewVideoService 创建视频服务实例
+func NewVideoService() *VideoService {
+ return &VideoService{}
+}
+
+// FolderInfo 文件夹信息
+type FolderInfo struct {
+ Path string `json:"path"`
+ Name string `json:"name"`
+ VideoCount int `json:"videoCount"`
+ VideoPaths []string `json:"videoPaths"`
+}
+
+// VideoConcatRequest 视频拼接请求
+type VideoConcatRequest struct {
+ FolderPath string `json:"folderPath"`
+ Num int `json:"num"`
+ JoinType int `json:"joinType"` // 1: 组合拼接, 2: 顺序拼接
+ AuditImagePath string `json:"auditImagePath"`
+ FolderInfos []FolderInfo `json:"folderInfos"`
+}
+
+// VideoConcatResult 视频拼接结果
+type VideoConcatResult struct {
+ Index int `json:"index"`
+ FileName string `json:"fileName"`
+ FilePath string `json:"filePath"`
+ Size string `json:"size"`
+ Seconds int `json:"seconds"`
+ Status string `json:"status"`
+ Progress string `json:"progress"`
+}
+
+// ListFolders 列出文件夹中的视频文件夹
+func (s *VideoService) ListFolders(ctx context.Context, folderPath string) ([]FolderInfo, error) {
+ var folderInfos []FolderInfo
+
+ dir, err := os.ReadDir(folderPath)
+ if err != nil {
+ return nil, fmt.Errorf("读取目录失败: %v", err)
+ }
+
+ for _, entry := range dir {
+ if !entry.IsDir() || entry.Name() == "output" {
+ continue
+ }
+
+ fullPath := filepath.Join(folderPath, entry.Name())
+ videoFiles, err := filepath.Glob(filepath.Join(fullPath, "*.mp4"))
+ if err != nil {
+ continue
+ }
+
+ folderInfos = append(folderInfos, FolderInfo{
+ Path: fullPath,
+ Name: entry.Name(),
+ VideoCount: len(videoFiles),
+ VideoPaths: videoFiles,
+ })
+ }
+
+ return folderInfos, nil
+}
+
+// GetLargeFileMD5 计算大文件的 MD5
+func (s *VideoService) GetLargeFileMD5(filePath string) (string, error) {
+ file, err := os.Open(filePath)
+ if err != nil {
+ return "", err
+ }
+ defer file.Close()
+
+ hash := md5.New()
+ buffer := make([]byte, 8192) // 8KB 缓冲区
+
+ for {
+ n, err := file.Read(buffer)
+ if n > 0 {
+ hash.Write(buffer[:n])
+ }
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return "", err
+ }
+ }
+
+ return hex.EncodeToString(hash.Sum(nil)), nil
+}
+
+// ConvertVideoToTS 将视频转换为 TS 格式
+func (s *VideoService) ConvertVideoToTS(videoPath string) (string, error) {
+ // 计算 MD5
+ md5Hash, err := s.GetLargeFileMD5(videoPath)
+ if err != nil {
+ return "", fmt.Errorf("计算MD5失败: %v", err)
+ }
+
+ // 临时文件路径
+ tempDir := os.TempDir()
+ tsPath := filepath.Join(tempDir, md5Hash+".ts")
+
+ // 如果文件已存在,直接返回
+ if _, err := os.Stat(tsPath); err == nil {
+ return tsPath, nil
+ }
+
+ // 使用 FFmpeg 转换
+ cmd := exec.Command("ffmpeg", "-i", videoPath,
+ "-c:v", "libx264",
+ "-c:a", "aac",
+ "-ar", "44100",
+ "-b:a", "128k",
+ "-crf", "23",
+ "-vf", "fps=30",
+ "-movflags", "+faststart",
+ "-f", "mpegts",
+ "-y", tsPath)
+
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ // 尝试备用方案
+ cmd2 := exec.Command("ffmpeg", "-i", videoPath,
+ "-c", "copy",
+ "-f", "mpegts",
+ "-y", tsPath)
+ output2, err2 := cmd2.CombinedOutput()
+ if err2 != nil {
+ return "", fmt.Errorf("视频转换失败: %v, 输出: %s, 备用方案失败: %v, 输出: %s", err, string(output), err2, string(output2))
+ }
+ }
+
+ return tsPath, nil
+}
+
+// GenerateCombinations 生成所有视频组合(笛卡尔积)
+func (s *VideoService) GenerateCombinations(videoLists [][]string, index int, currentCombination []string, result *[][]string) {
+ if index == len(videoLists) {
+ combination := make([]string, len(currentCombination))
+ copy(combination, currentCombination)
+ *result = append(*result, combination)
+ return
+ }
+
+ for _, video := range videoLists[index] {
+ currentCombination = append(currentCombination, video)
+ s.GenerateCombinations(videoLists, index+1, currentCombination, result)
+ currentCombination = currentCombination[:len(currentCombination)-1]
+ }
+}
+
+// JoinVideos 拼接视频
+// 注意:Wails3 中回调函数需要使用事件系统,这里先简化处理
+func (s *VideoService) JoinVideos(ctx context.Context, req VideoConcatRequest) ([]VideoConcatResult, error) {
+ var results []VideoConcatResult
+ // 创建输出目录
+ outputDir := filepath.Join(req.FolderPath, "output")
+ if err := os.MkdirAll(outputDir, 0755); err != nil {
+ return fmt.Errorf("创建输出目录失败: %v", err)
+ }
+
+ // 准备视频列表
+ var videoLists [][]string
+ for _, folderInfo := range req.FolderInfos {
+ videoLists = append(videoLists, folderInfo.VideoPaths)
+ }
+
+ var combinations [][]string
+
+ if req.JoinType == 1 {
+ // 组合拼接模式
+ s.GenerateCombinations(videoLists, 0, []string{}, &combinations)
+
+ // 随机选择指定数量的组合
+ if req.Num < len(combinations) {
+ rand.Seed(time.Now().UnixNano())
+ selected := make(map[int]bool)
+ newCombinations := [][]string{}
+ for len(newCombinations) < req.Num {
+ idx := rand.Intn(len(combinations))
+ if !selected[idx] {
+ selected[idx] = true
+ newCombinations = append(newCombinations, combinations[idx])
+ }
+ }
+ combinations = newCombinations
+ }
+ } else if req.JoinType == 2 {
+ // 顺序拼接模式
+ if len(videoLists) == 0 {
+ return fmt.Errorf("没有视频文件")
+ }
+ count := len(videoLists[0])
+ for i := 1; i < len(videoLists); i++ {
+ if len(videoLists[i]) != count {
+ return fmt.Errorf("所有文件夹中的视频数量必须相同")
+ }
+ }
+
+ for i := 0; i < count; i++ {
+ combination := []string{}
+ for _, videoList := range videoLists {
+ combination = append(combination, videoList[i])
+ }
+ combinations = append(combinations, combination)
+ }
+ }
+
+ // 先转换所有视频为 TS 格式
+ var allVideoPaths []string
+ for _, combination := range combinations {
+ allVideoPaths = append(allVideoPaths, combination...)
+ }
+
+ // 去重
+ uniqueVideos := make(map[string]bool)
+ var uniqueVideoPaths []string
+ for _, path := range allVideoPaths {
+ if !uniqueVideos[path] {
+ uniqueVideos[path] = true
+ uniqueVideoPaths = append(uniqueVideoPaths, path)
+ }
+ }
+
+ // 并发转换视频(限制并发数)
+ semaphore := make(chan struct{}, 10)
+ var wg sync.WaitGroup
+ tsMap := make(map[string]string)
+ var mu sync.Mutex
+
+ for _, videoPath := range uniqueVideoPaths {
+ wg.Add(1)
+ go func(path string) {
+ defer wg.Done()
+ semaphore <- struct{}{}
+ defer func() { <-semaphore }()
+
+ tsPath, err := s.ConvertVideoToTS(path)
+ if err != nil {
+ LogError(fmt.Sprintf("转换视频失败: %v", err))
+ return
+ }
+
+ mu.Lock()
+ tsMap[path] = tsPath
+ mu.Unlock()
+ }(videoPath)
+ }
+ wg.Wait()
+
+ // 拼接视频
+ semaphore = make(chan struct{}, 10)
+ var mu sync.Mutex
+ for i, combination := range combinations {
+ wg.Add(1)
+ go func(index int, combo []string) {
+ defer wg.Done()
+ semaphore <- struct{}{}
+ defer func() { <-semaphore }()
+
+ // 获取第一个视频的文件名
+ firstVideoName := strings.TrimSuffix(filepath.Base(combo[0]), filepath.Ext(combo[0]))
+ outputFileName := fmt.Sprintf("%s_%04d.mp4", firstVideoName, index+1)
+ outputPath := filepath.Join(outputDir, outputFileName)
+
+ // 创建结果对象
+ result := VideoConcatResult{
+ Index: index + 1,
+ FileName: outputFileName,
+ FilePath: outputPath,
+ Status: "处理中",
+ Progress: "0%",
+ }
+
+ // 获取 TS 文件路径
+ var tsPaths []string
+ for _, videoPath := range combo {
+ if tsPath, ok := tsMap[videoPath]; ok {
+ tsPaths = append(tsPaths, tsPath)
+ } else {
+ LogError(fmt.Sprintf("找不到视频的TS文件: %s", videoPath))
+ result.Status = "拼接失败"
+ result.Progress = "失败"
+ mu.Lock()
+ results = append(results, result)
+ mu.Unlock()
+ return
+ }
+ }
+
+ // 创建 concat 文件
+ concatFile := filepath.Join(os.TempDir(), fmt.Sprintf("concat_%d_%d.txt", time.Now().Unix(), index))
+ file, err := os.Create(concatFile)
+ if err != nil {
+ LogError(fmt.Sprintf("创建concat文件失败: %v", err))
+ result.Status = "拼接失败"
+ result.Progress = "失败"
+ mu.Lock()
+ results = append(results, result)
+ mu.Unlock()
+ return
+ }
+
+ for _, tsPath := range tsPaths {
+ file.WriteString(fmt.Sprintf("file '%s'\n", strings.ReplaceAll(tsPath, "\\", "/")))
+ }
+ file.Close()
+ defer os.Remove(concatFile)
+
+ // 拼接视频
+ args := []string{
+ "-f", "concat",
+ "-safe", "0",
+ "-i", concatFile,
+ "-c:v", "copy",
+ "-c:a", "aac",
+ "-ar", "44100",
+ "-movflags", "+faststart",
+ "-analyzeduration", "100M",
+ "-probesize", "100M",
+ "-y", outputPath,
+ }
+
+ cmd := exec.Command("ffmpeg", args...)
+ err = cmd.Run()
+ if err != nil {
+ LogError(fmt.Sprintf("拼接视频失败: %v", err))
+ result.Status = "拼接失败"
+ result.Progress = "失败"
+ mu.Lock()
+ results = append(results, result)
+ mu.Unlock()
+ return
+ }
+
+ // 如果指定了审核图片,添加水印
+ if req.AuditImagePath != "" {
+ outputImgPath := filepath.Join(outputDir, fmt.Sprintf("%s_%04d_img.mp4", firstVideoName, index+1))
+ args := []string{
+ "-i", outputPath,
+ "-i", req.AuditImagePath,
+ "-filter_complex", "[0:v][1:v] overlay=0:H-h",
+ "-movflags", "+faststart",
+ "-y", outputImgPath,
+ }
+
+ cmd := exec.Command("ffmpeg", args...)
+ cmd.Run() // 忽略错误,水印是可选的
+ }
+
+ // 获取文件信息
+ fileInfo, err := os.Stat(outputPath)
+ if err != nil {
+ progressCallback(VideoConcatResult{
+ Index: index + 1,
+ FileName: outputFileName,
+ Status: "拼接失败",
+ Progress: "失败",
+ })
+ return
+ }
+
+ // 获取视频时长(使用 ffprobe)
+ seconds := 0
+ cmd = exec.Command("ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", outputPath)
+ output, err := cmd.Output()
+ if err == nil {
+ var duration float64
+ fmt.Sscanf(strings.TrimSpace(string(output)), "%f", &duration)
+ seconds = int(duration)
+ }
+
+ sizeMB := fileInfo.Size() / 1024 / 1024
+
+ result.FilePath = outputPath
+ result.Size = fmt.Sprintf("%dMB", sizeMB)
+ result.Seconds = seconds
+ result.Status = "拼接成功"
+ result.Progress = "100%"
+
+ mu.Lock()
+ results = append(results, result)
+ mu.Unlock()
+ }(i, combination)
+ }
+
+ wg.Wait()
+ return results, nil
+}
+
diff --git a/wails3-app/wails.json b/wails3-app/wails.json
new file mode 100644
index 0000000..65e4f0a
--- /dev/null
+++ b/wails3-app/wails.json
@@ -0,0 +1,23 @@
+{
+ "name": "VideoConcat",
+ "outputfilename": "videoconcat",
+ "frontend": {
+ "dir": "./frontend",
+ "install": "npm install",
+ "build": "npm run build",
+ "bridge": "src",
+ "serve": "npm run dev"
+ },
+ "author": {
+ "name": "",
+ "email": ""
+ },
+ "info": {
+ "companyName": "",
+ "productName": "VideoConcat",
+ "productVersion": "1.0.0",
+ "copyright": "Copyright © 2024",
+ "comments": "视频拼接工具"
+ }
+}
+
diff --git a/wails3-app/快速开始.md b/wails3-app/快速开始.md
new file mode 100644
index 0000000..560cbbb
--- /dev/null
+++ b/wails3-app/快速开始.md
@@ -0,0 +1,100 @@
+# 快速开始指南
+
+## 解决 wails.json 错误
+
+如果遇到 "wails.json: The system cannot find the file specified" 错误,请按以下步骤操作:
+
+### 步骤 1: 安装前端依赖
+
+```powershell
+cd frontend
+npm install
+```
+
+### 步骤 2: 构建前端
+
+```powershell
+npm run build
+```
+
+这会生成 `assets` 目录和所有前端资源。
+
+### 步骤 3: 运行应用
+
+```powershell
+cd ..
+go run app.go
+```
+
+## 如果仍然遇到问题
+
+### 检查文件是否存在
+
+确保以下文件存在:
+- `wails.json` ✓ (已创建)
+- `assets/` 目录 ✓ (已创建)
+- `assets/index.html` ✓ (已创建)
+
+### 如果 assets 目录为空
+
+需要先构建前端:
+
+```powershell
+cd frontend
+npm install
+npm run build
+cd ..
+```
+
+### 验证 Go 模块
+
+```powershell
+go mod tidy
+go mod download
+```
+
+### 检查 Wails3 版本
+
+确保使用的是正确的 Wails3 版本。如果使用的是 Wails v2,配置方式可能不同。
+
+## 开发模式
+
+### 前端开发(热重载)
+
+```powershell
+cd frontend
+npm run dev
+```
+
+### 后端开发
+
+在另一个终端:
+
+```powershell
+go run app.go
+```
+
+## 常见错误解决
+
+### 1. "module not found"
+
+```powershell
+go mod tidy
+go mod download
+```
+
+### 2. "assets not found"
+
+确保已构建前端:
+```powershell
+cd frontend
+npm run build
+```
+
+### 3. "ffmpeg not found"
+
+确保 FFmpeg 已安装并在 PATH 中:
+```powershell
+ffmpeg -version
+```
+
diff --git a/wails3-app/构建说明.md b/wails3-app/构建说明.md
new file mode 100644
index 0000000..cef978c
--- /dev/null
+++ b/wails3-app/构建说明.md
@@ -0,0 +1,151 @@
+# 构建和运行说明
+
+## 前置要求
+
+1. **Go 1.21+**
+ ```bash
+ go version
+ ```
+
+2. **Node.js 16+**
+ ```bash
+ node --version
+ npm --version
+ ```
+
+3. **FFmpeg**
+ ```bash
+ ffmpeg -version
+ ```
+
+4. **Wails3 CLI**
+ ```bash
+ # 安装 Wails3(如果还没有)
+ git clone https://github.com/wailsapp/wails.git
+ cd wails
+ git checkout v3-alpha
+ cd v3/cmd/wails3
+ go install
+ ```
+
+## 构建步骤
+
+### 1. 安装前端依赖
+
+```bash
+cd frontend
+npm install
+```
+
+### 2. 构建前端
+
+```bash
+npm run build
+```
+
+这会将前端代码构建到 `../assets` 目录。
+
+### 3. 运行应用
+
+```bash
+cd ..
+go run app.go
+```
+
+### 4. 构建可执行文件(可选)
+
+```bash
+go build -o videoconcat.exe app.go
+```
+
+## 开发模式
+
+### 前端开发
+
+```bash
+cd frontend
+npm run dev
+```
+
+### 后端开发
+
+```bash
+go run app.go
+```
+
+## 常见问题
+
+### 1. FFmpeg 未找到
+
+**错误**: `exec: "ffmpeg": executable file not found in %PATH%`
+
+**解决**:
+- Windows: 下载 FFmpeg 并添加到系统 PATH
+- 或者修改代码指定 FFmpeg 完整路径
+
+### 2. 前端构建失败
+
+**错误**: `npm ERR!`
+
+**解决**:
+```bash
+# 清除缓存
+npm cache clean --force
+# 删除 node_modules 重新安装
+rm -rf node_modules
+npm install
+```
+
+### 3. Go 模块下载失败
+
+**错误**: `go: module ... not found`
+
+**解决**:
+```bash
+# 设置 Go 代理(中国用户)
+go env -w GOPROXY=https://goproxy.cn,direct
+# 或者使用官方代理
+go env -w GOPROXY=https://proxy.golang.org,direct
+```
+
+### 4. Wails3 绑定失败
+
+**错误**: `window.go is undefined`
+
+**解决**:
+- 检查 Wails3 版本和 API
+- 确保服务正确绑定
+- 查看浏览器控制台错误信息
+
+## 调试
+
+### 查看日志
+
+日志文件位于:`Log/logYYYYMMDD.log`
+
+### 浏览器调试
+
+1. 打开开发者工具(F12)
+2. 查看 Console 标签页的错误信息
+3. 查看 Network 标签页的请求
+
+### Go 调试
+
+```bash
+# 使用 delve 调试器
+go install github.com/go-delve/delve/cmd/dlv@latest
+dlv debug app.go
+```
+
+## 打包发布
+
+### Windows
+
+```bash
+go build -ldflags="-H windowsgui" -o videoconcat.exe app.go
+```
+
+### 其他平台
+
+参考 Wails3 官方文档的打包说明。
+
diff --git a/wails3-app/迁移说明.md b/wails3-app/迁移说明.md
new file mode 100644
index 0000000..64f3c80
--- /dev/null
+++ b/wails3-app/迁移说明.md
@@ -0,0 +1,166 @@
+# Wails3 重构迁移说明
+
+## 已完成的工作
+
+### 1. 项目结构
+- ✅ 创建了完整的 Wails3 项目结构
+- ✅ Go 后端服务(services/ 目录)
+- ✅ Vue3 前端(frontend/ 目录)
+
+### 2. 后端功能实现
+- ✅ **VideoService**: 视频拼接服务
+ - 列出文件夹中的视频
+ - 视频格式转换(MP4 -> TS)
+ - 组合拼接和顺序拼接
+ - MD5 计算和缓存机制
+
+- ✅ **ExtractService**: 视频抽帧服务
+ - 列出视频文件
+ - 随机删除视频中的一帧
+ - 批量处理
+ - 元数据修改功能
+
+- ✅ **AuthService**: 用户认证服务
+ - 登录功能
+ - 获取机器信息(机器名、用户名、IP)
+
+- ✅ **FileService**: 文件操作服务
+ - 文件存在检查
+ - 文件大小获取
+ - 目录操作
+
+- ✅ **日志系统**: 基于文件的日志记录
+
+### 3. 前端功能实现
+- ✅ **主界面**: 侧边栏导航 + 主内容区
+- ✅ **VideoTab**: 视频拼接界面
+ - 文件夹选择
+ - 拼接模式选择
+ - 数量设置
+ - 审核图片选择
+ - 结果展示
+
+- ✅ **ExtractTab**: 视频抽帧界面
+ - 文件夹选择
+ - 抽帧数量设置
+ - 批量处理
+ - 元数据修改
+
+- ✅ **LoginDialog**: 登录对话框
+
+## 主要变化
+
+### 后端变化
+1. **语言**: C# -> Go
+2. **视频处理**: FFMpegCore -> FFmpeg 命令行调用
+3. **HTTP 客户端**: HttpClient -> Go net/http
+4. **日志**: log4net -> 自定义日志系统
+5. **并发**: Task/async -> goroutine/channel
+
+### 前端变化
+1. **UI 框架**: WPF/XAML -> Vue3/HTML/CSS
+2. **数据绑定**: WPF Binding -> Vue3 Reactive
+3. **组件化**: WPF UserControl -> Vue Component
+
+## 需要注意的事项
+
+### 1. Wails3 API 兼容性
+当前代码中使用了假设的 Wails3 API:
+- `window.go.services.ServiceName` - 需要根据实际 Wails3 API 调整
+- 文件选择对话框 - 需要使用 Wails3 提供的文件对话框 API
+
+### 2. FFmpeg 路径
+- 确保 FFmpeg 已安装并在系统 PATH 中
+- 或者修改代码指定 FFmpeg 的完整路径
+
+### 3. 文件路径处理
+- Windows 路径分隔符需要正确处理
+- 前端传递的路径格式需要与后端兼容
+
+### 4. 进度更新
+- 当前实现是批量返回结果,不是实时进度
+- 如需实时进度,需要使用 Wails3 的事件系统
+
+### 5. 错误处理
+- 需要完善错误处理和用户提示
+- 添加重试机制
+
+## 待完善的功能
+
+1. **文件选择对话框**
+ - 需要使用 Wails3 的原生文件对话框 API
+ - 当前使用 HTML5 API 作为临时方案
+
+2. **进度实时更新**
+ - 使用 Wails3 事件系统实现实时进度
+ - 或者使用 WebSocket/Server-Sent Events
+
+3. **错误处理**
+ - 更友好的错误提示
+ - 错误日志记录
+
+4. **UI 优化**
+ - 更现代化的界面设计
+ - 响应式布局
+ - 动画效果
+
+5. **性能优化**
+ - 大文件处理优化
+ - 内存使用优化
+ - 并发控制优化
+
+## 运行步骤
+
+1. **安装依赖**
+ ```bash
+ # 安装 Wails3(参考 README.md)
+ # 安装前端依赖
+ cd frontend
+ npm install
+ ```
+
+2. **构建前端**
+ ```bash
+ npm run build
+ ```
+
+3. **运行应用**
+ ```bash
+ cd ..
+ go run app.go
+ ```
+
+## 测试建议
+
+1. **功能测试**
+ - 视频拼接(两种模式)
+ - 视频抽帧
+ - 元数据修改
+ - 用户登录
+
+2. **性能测试**
+ - 大量视频文件处理
+ - 并发处理能力
+ - 内存使用情况
+
+3. **兼容性测试**
+ - 不同视频格式
+ - 不同操作系统
+ - 不同 FFmpeg 版本
+
+## 已知问题
+
+1. 文件选择在浏览器环境中受限,需要使用 Wails3 原生 API
+2. 进度更新是批量返回,不是实时更新
+3. 错误处理需要进一步完善
+4. UI 需要根据实际 Wails3 API 调整
+
+## 后续工作
+
+1. 根据实际 Wails3 API 调整代码
+2. 实现实时进度更新
+3. 完善错误处理
+4. 优化 UI/UX
+5. 添加单元测试
+6. 性能优化
+