tab bar selected image invalid

本文介绍如何在使用TabBarController设置界面框架时,在检查器中配置TabBarItem的选中图片属性,而不是使用默认的选中图片选项。

config tab bar selected image attribute in inspector when you setup frame with tabbarcontroller.

 

instead of selected image in  tab bar item attribute inspector.

package main import ( "crypto/md5" "errors" "fmt" "image" "image/color" "io" "log" "net/http" "os" "path/filepath" "runtime" "strconv" "strings" "sync" "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/data/binding" "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/widget" "github.com/playwright-community/playwright-go" "main.go/dataModel/CookieModel" "main.go/dataModel/ShopModel" "main.go/dataModel/SkuModel" "main.go/dataModel/UserModel" "main.go/res" "main.go/tuuz/database" ) // PlaywrightService 管理Playwright实例 type PlaywrightService struct { PW *playwright.Playwright Browser playwright.Browser Context playwright.BrowserContext Page playwright.Page } // 新增分页状态结构体 type PaginationState struct { CurrentPage int PageSize int TotalPages int TotalProducts int Products []SkuModel.DataItem // 添加绑定数据 PageInfo binding.String } // 全局状态 type AppState struct { Window fyne.Window CurrentUser UserModel.UserInfo Shops []ShopModel.Account ProductTabs *container.AppTabs StatusBar *widget.Label ShopListBinding binding.UntypedList LoginForm *widget.Form LeftPanel *fyne.Container FilterFilePath string FilterKeywords []string ShopListPanel *fyne.Container FilterPanel *fyne.Container KeywordCount *widget.Label TabShopMap map[string]ShopModel.Account SplitContainer *container.Split TopPanel *fyne.Container ContentPanel *fyne.Container NeedsRefresh bool LastRefreshTime time.Time PaginationStates map[string]*PaginationState Playwright *PlaywrightService // Playwright服务 UrlEntry *widget.Entry // URL输入框 } // 添加状态检查快捷键 func addStateDebugShortcut(window fyne.Window, appState *AppState) { window.Canvas().SetOnTypedKey(func(ev *fyne.KeyEvent) { if ev.Name == fyne.KeyF5 { refreshLeftPanel(appState) appState.StatusBar.SetText("手动刷新UI") } else if ev.Name == fyne.KeyS { fmt.Println("===== 应用状态快照 =====") fmt.Printf("当前用户: %s\n", appState.CurrentUser.LoginName) fmt.Printf("店铺数量: %d\n", len(appState.Shops)) fmt.Printf("最后刷新时间: %s\n", appState.LastRefreshTime.Format("15:04:05.000")) fmt.Println("=======================") } }) } // 初始化Playwright服务 func initPlaywrightService() (*PlaywrightService, error) { pw, err := playwright.Run() if err != nil { return nil, fmt.Errorf("启动Playwright失败: %w", err) } browser, err := pw.Chromium.Launch(playwright.BrowserTypeLaunchOptions{ Headless: playwright.Bool(false), }) if err != nil { return nil, fmt.Errorf("启动浏览器失败: %w", err) } context, err := browser.NewContext() if err != nil { return nil, fmt.Errorf("创建上下文失败: %w", err) } page, err := context.NewPage() if err != nil { return nil, fmt.Errorf("创建页面失败: %w", err) } return &PlaywrightService{ PW: pw, Browser: browser, Context: context, Page: page, }, nil } func main() { os.Setenv("PLAYWRIGHT_BROWSERS_PATH", "./browsers") database.Init() UserModel.UserInit() ShopModel.ShopInit() CookieModel.CreateCookieInfoTable() SkuModel.ProductInit() // 创建缓存目录 if err := os.MkdirAll("cacheimg", 0755); err != nil { log.Printf("创建缓存目录失败: %v", err) } myApp := app.New() myWindow := myApp.NewWindow("店铺管理工具") myWindow.Resize(fyne.NewSize(1200, 800)) // 初始化Playwright服务 pwService, err := initPlaywrightService() if err != nil { log.Fatalf("初始化Playwright失败: %v", err) } defer func() { if err := pwService.Browser.Close(); err != nil { log.Printf("关闭浏览器失败: %v", err) } // 修复错误1: 使用正确的停止方法 if err := pwService.PW.Stop(); err != nil { log.Printf("停止Playwright失败: %v", err) } }() // 初始化应用状态 appState := &AppState{ FilterFilePath: getDefaultFilterPath(), TabShopMap: make(map[string]ShopModel.Account), LastRefreshTime: time.Now(), PaginationStates: make(map[string]*PaginationState), Playwright: pwService, // 注入Playwright服务 } // 注册调试快捷键 addStateDebugShortcut(myWindow, appState) // 启动状态监听器 startStateListener(appState) // 尝试加载默认过滤文件 go loadFilterFile(appState) // 创建状态栏 appState.StatusBar = widget.NewLabel("就绪") // 创建URL访问控件 appState.UrlEntry = widget.NewEntry() appState.UrlEntry.SetPlaceHolder("输入URL") visitButton := widget.NewButton("访问", func() { url := appState.UrlEntry.Text if url == "" { appState.StatusBar.SetText("请输入URL") return } appState.StatusBar.SetText(fmt.Sprintf("正在访问: %s...", url)) go func() { // 访问URL // 修复错误2和3: 使用正确的返回类型和状态码访问方式 response, err := visitUrlWithPlaywright(appState, url) if err != nil { fyne.DoAndWait(func() { appState.StatusBar.SetText(fmt.Sprintf("访问失败: %v", err)) }) return } fyne.DoAndWait(func() { // 使用Status()方法获取状态码 appState.StatusBar.SetText(fmt.Sprintf("访问完成! 状态码: %d", response.Status())) }) }() }) // 创建底部控制栏 bottomControlBar := container.NewBorder( nil, nil, nil, visitButton, appState.UrlEntry, ) // 创建底部区域(状态栏 + URL控件) bottomArea := container.NewVBox( bottomControlBar, widget.NewSeparator(), container.NewHBox(layout.NewSpacer(), appState.StatusBar), ) // 创建主布局 mainContent := createMainUI(myWindow, appState) // 设置整体布局 content := container.NewBorder( nil, // 顶部 bottomArea, // 底部(包含URL控件和状态栏) nil, // 左侧 nil, // 右侧 mainContent, ) myWindow.SetContent(content) // 启动时尝试自动登录 go tryAutoLogin(appState) myWindow.ShowAndRun() } // 使用Playwright访问URL并拦截响应 // 修复错误2和3: 使用接口类型作为返回类型 func visitUrlWithPlaywright(appState *AppState, url string) (playwright.Response, error) { // 设置响应拦截器 appState.Playwright.Page.OnResponse(func(response playwright.Response) { log.Printf("响应: %s - %d", response.URL(), response.Status()) }) // 导航到URL response, err := appState.Playwright.Page.Goto(url) if err != nil { return nil, fmt.Errorf("导航失败: %w", err) } // 等待页面加载完成 if err := appState.Playwright.Page.WaitForLoadState(playwright.PageWaitForLoadStateOptions{ State: playwright.LoadStateNetworkidle, }); err != nil { return nil, fmt.Errorf("等待页面加载失败: %w", err) } return response, nil } // 新增状态监听器 - 定期检查状态变化 func startStateListener(appState *AppState) { go func() { for { time.Sleep(100 * time.Millisecond) // 每100ms检查一次 if appState.NeedsRefresh { fyne.DoAndWait(func() { refreshLeftPanel(appState) appState.NeedsRefresh = false }) } } }() } // 获取默认过滤文件路径 func getDefaultFilterPath() string { if runtime.GOOS == "windows" { return filepath.Join("filter.txt") } return filepath.Join(os.Getenv("HOME"), "filter.txt") } // 修改 refreshAllProductTabs 函数 func refreshAllProductTabs(appState *AppState) { if appState.ProductTabs == nil || len(appState.ProductTabs.Items) == 0 { return } // 遍历所有标签页并刷新 for _, tab := range appState.ProductTabs.Items { // 通过标签页标题获取店铺 shop, exists := appState.TabShopMap[tab.Text] if !exists { continue } // 重新加载商品 go func(shop ShopModel.Account) { products, err := loadProductsForShop(shop, appState) if err != nil { fyne.DoAndWait(func() { appState.StatusBar.SetText(fmt.Sprintf("刷新 %s 商品失败: %s", shop.AccountName, err.Error())) }) return } fyne.DoAndWait(func() { // 更新标签页内容 tab.Content = container.NewMax(createProductTable(products)) appState.ProductTabs.Refresh() appState.StatusBar.SetText(fmt.Sprintf("已刷新 %s 的商品", shop.AccountName)) }) }(shop) } } // 加载过滤文件 func loadFilterFile(appState *AppState) { if appState.FilterFilePath == "" { log.Printf("加载本地过滤文件失败: %s", appState.FilterFilePath) return } if _, err := os.Stat(appState.FilterFilePath); os.IsNotExist(err) { err := os.WriteFile(appState.FilterFilePath, []byte{}, 0644) if err != nil { log.Printf("创建过滤文件失败: %v", err) } return } content, err := os.ReadFile(appState.FilterFilePath) if err != nil { log.Printf("读取过滤文件失败: %v", err) return } lines := strings.Split(string(content), "\n") appState.FilterKeywords = []string{} for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed != "" { appState.FilterKeywords = append(appState.FilterKeywords, trimmed) } } fyne.DoAndWait(func() { appState.StatusBar.SetText(fmt.Sprintf("已加载 %d 个过滤关键字", len(appState.FilterKeywords))) // 更新关键字数量标签 if appState.KeywordCount != nil { // 修正为 KeywordCount appState.KeywordCount.SetText(fmt.Sprintf("关键字数量: %d", len(appState.FilterKeywords))) } // 刷新所有已打开的商品标签页 refreshAllProductTabs(appState) }) } // 修改 createMainUI 函数 - 保存分割布局引用 func createMainUI(window fyne.Window, appState *AppState) fyne.CanvasObject { appState.Window = window // 创建整个左侧面板 leftPanel := createLeftPanel(window, appState) appState.LeftPanel = leftPanel // 右侧面板 appState.ProductTabs = container.NewAppTabs() appState.ProductTabs.SetTabLocation(container.TabLocationTop) rightPanel := container.NewBorder( widget.NewLabelWithStyle("商品信息", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), nil, nil, nil, container.NewMax(appState.ProductTabs), ) // 使用HSplit布局 - 保存引用 split := container.NewHSplit(leftPanel, rightPanel) split.SetOffset(0.25) appState.SplitContainer = split // 保存分割布局引用 return split } // 修改createFilterPanel函数 - 返回容器并保存引用 func createFilterPanel(appState *AppState) *fyne.Container { // 创建文件路径标签 pathLabel := widget.NewLabel("过滤文件: " + appState.FilterFilePath) pathLabel.Wrapping = fyne.TextWrapWord // 创建选择文件按钮 selectButton := widget.NewButton("选择过滤文件", func() { dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) { if err != nil { dialog.ShowError(err, appState.Window) return } if reader == nil { return // 用户取消 } // 更新文件路径 appState.FilterFilePath = reader.URI().Path() pathLabel.SetText("过滤文件: " + appState.FilterFilePath) // 加载过滤文件 go func() { loadFilterFile(appState) // 刷新所有已打开的商品标签页 refreshAllProductTabs(appState) }() }, appState.Window) }) // 创建刷新按钮 refreshButton := widget.NewButton("刷新过滤", func() { if appState.FilterFilePath != "" { appState.StatusBar.SetText("刷新过滤关键字...") go func() { loadFilterFile(appState) // 刷新所有已打开的商品标签页 refreshAllProductTabs(appState) }() } else { appState.StatusBar.SetText("请先选择过滤文件") } }) // 创建"增加商品"按钮 addProductsButton := widget.NewButton("增加商品", func() { if appState.ProductTabs.Selected() == nil { appState.StatusBar.SetText("请先选择一个店铺标签页") return } shopName := appState.ProductTabs.Selected().Text appState.StatusBar.SetText(fmt.Sprintf("为 %s 增加1000条商品...", shopName)) go func() { // 生成1000条模拟商品 newProducts := make([]SkuModel.DataItem, 1000) for i := 0; i < 1000; i++ { newProducts[i] = SkuModel.DataItem{ ProductID: fmt.Sprintf("ADD%04d", i+1), Name: fmt.Sprintf("%s - 新增商品%d", shopName, i+1), MarketPrice: (i + 1000) * 1000, // 从1000开始 DiscountPrice: (i + 1000) * 800, // 折扣价 Img: "https://p3-aio.ecombdimg.com/obj/ecom-shop-material/jpeg_m_c3042f069cc881202925e3ebecec509b_sx_285253_www790-1232", Pics: []string{ "https://p3-aio.ecombdimg.com/obj/ecom-shop-material/jpeg_m_ebf42d1ffd3990cb0d016e692d54061a_sx_303601_www790-1232", "https://p3-aio.ecombdimg.com/obj/ecom-shop-material/jpeg_m_20da83e457ae1a2c254d56eb058223d0_sx_200127_www750-611", "https://p3-aio.ecombdimg.com/obj/ecom-shop-material/jpeg_m_20da83e457ae1a2c254d56eb058223d0_sx_200127_www750-611", "https://p3-aio.ecombdimg.com/obj/ecom-shop-material/jpeg_m_b774f0f89ebf73ad6533b2d9481c8c12_sx_616294_www750-1599", "https://p3-aio.ecombdimg.com/obj/ecom-shop-material/jpeg_m_bb759148a1bfea0b8d04d53c2cbd9142_sx_289701_www790-1232", "https://p3-aio.ecombdimg.com/obj/ecom-shop-material/jpeg_m_b774f0f89ebf73ad6533b2d9481c8c12_sx_616294_www750-1599", "https://p3-aio.ecombdimg.com/obj/ecom-shop-material/jpeg_m_20da83e457ae1a2c254d56eb058223d0_sx_200127_www750-611", "https://p3-aio.ecombdimg.com/obj/ecom-shop-material/jpeg_m_96664bdd76ae61e0c92c00b1466e23c3_sx_499102_www750-1621", }, } } fyne.DoAndWait(func() { // 获取该店铺的TabState tabState, exists := appState.PaginationStates[shopName] if !exists { // 如果不存在,则创建一个新的TabState tabState = &PaginationState{ PageSize: 10, CurrentPage: 1, } appState.PaginationStates[shopName] = tabState } // 添加到现有商品列表 tabState.Products = append(tabState.Products, newProducts...) // 刷新当前标签页 refreshCurrentProductTab(appState, shopName, tabState.Products) appState.StatusBar.SetText(fmt.Sprintf("已为 %s 增加1000条商品,总数: %d", shopName, len(tabState.Products))) }) }() }) // 修改按钮容器,添加新按钮 buttonContainer := container.NewHBox( selectButton, refreshButton, addProductsButton, // 新增按钮 ) // 创建关键字计数标签 - 保存引用 keywordCount := widget.NewLabel(fmt.Sprintf("关键字数量: %d", len(appState.FilterKeywords))) keywordCount.TextStyle = fyne.TextStyle{Bold: true} appState.KeywordCount = keywordCount // 创建面板容器 panel := container.NewVBox( widget.NewSeparator(), widget.NewLabelWithStyle("商品过滤", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), pathLabel, keywordCount, buttonContainer, ) return panel } // 修改 createLoggedInPanel 函数 - 确保注销时直接刷新 func createLoggedInPanel(appState *AppState) fyne.CanvasObject { return container.NewVBox( widget.NewLabelWithStyle("登录状态", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), widget.NewSeparator(), container.NewHBox( widget.NewLabel("用户:"), widget.NewLabel(appState.CurrentUser.LoginName), ), container.NewHBox( widget.NewLabel("店铺数量:"), widget.NewLabel(fmt.Sprintf("%d", len(appState.Shops))), ), widget.NewSeparator(), container.NewCenter( widget.NewButton("注销", func() { // 重置状态 appState.CurrentUser = UserModel.UserInfo{} appState.Shops = nil appState.ProductTabs.Items = nil appState.ProductTabs.Refresh() appState.TabShopMap = make(map[string]ShopModel.Account) // 直接调用刷新函数 refreshLeftPanel(appState) appState.StatusBar.SetText("已注销") }), ), ) } // 重构创建顶部面板函数 - 确保状态正确反映 func createTopPanel(appState *AppState) *fyne.Container { // 添加调试日志 fmt.Printf("创建顶部面板: 登录状态=%t, 用户名=%s\n", appState.CurrentUser.LoginName != "", appState.CurrentUser.LoginName) var content fyne.CanvasObject if appState.CurrentUser.LoginName != "" { content = createLoggedInPanel(appState) } else { content = createLoginForm(appState) } return container.NewMax(content) } // 重构 createContentPanel 函数 - 添加详细日志 func createContentPanel(appState *AppState) *fyne.Container { // 添加详细调试日志 fmt.Printf("创建内容面板: 登录状态=%t, 用户名=%s, 店铺数量=%d\n", appState.CurrentUser.LoginName != "", appState.CurrentUser.LoginName, len(appState.Shops)) if appState.CurrentUser.LoginName != "" { if len(appState.Shops) > 0 { return createShopListPanel(appState) } return container.NewCenter( widget.NewLabel("没有可用的店铺"), ) } return container.NewCenter( widget.NewLabel("请先登录查看店铺列表"), ) } // 重构刷新函数 - 确保完全重建UI func refreshLeftPanel(appState *AppState) { if appState.SplitContainer == nil { return } // 添加详细调试信息 fmt.Printf("刷新左侧面板 - 时间: %s, 用户: %s, 店铺数量: %d\n", time.Now().Format("15:04:05.000"), appState.CurrentUser.LoginName, len(appState.Shops)) // 创建新的左侧面板 newLeftPanel := createLeftPanel(appState.Window, appState) // 添加调试背景色(登录状态不同颜色不同) var debugColor color.Color if appState.CurrentUser.LoginName != "" { debugColor = color.NRGBA{R: 0, G: 100, B: 0, A: 30} // 登录状态绿色半透明 } else { debugColor = color.NRGBA{R: 100, G: 0, B: 0, A: 30} // 未登录状态红色半透明 } debugPanel := container.NewMax( canvas.NewRectangle(debugColor), newLeftPanel, ) // 替换分割布局中的左侧面板 appState.SplitContainer.Leading = debugPanel appState.LeftPanel = debugPanel // 刷新分割布局 appState.SplitContainer.Refresh() // 强制重绘整个窗口 appState.Window.Content().Refresh() appState.LastRefreshTime = time.Now() } // 重构 createLeftPanel 函数 - 确保使用正确的状态 func createLeftPanel(window fyne.Window, appState *AppState) *fyne.Container { // 创建顶部面板(用户状态/登录表单) topPanel := createTopPanel(appState) // 创建内容面板(店铺列表或提示) contentPanel := createContentPanel(appState) // 创建过滤面板 filterPanel := createFilterPanel(appState) // 使用Border布局 return container.NewBorder( topPanel, // 顶部 filterPanel, // 底部 nil, nil, // 左右 contentPanel, // 中间内容 ) } // 修改登录按钮回调 - 确保状态正确更新 func createLoginForm(appState *AppState) fyne.CanvasObject { usernameEntry := widget.NewEntry() passwordEntry := widget.NewPasswordEntry() usernameEntry.PlaceHolder = "输入邮箱地址" passwordEntry.PlaceHolder = "输入密码" // 登录按钮回调 loginButton := widget.NewButton("登录", func() { appState.StatusBar.SetText("登录中...") go func() { // 模拟网络延迟 time.Sleep(500 * time.Millisecond) // 获取店铺信息 shops := ShopModel.Api_select_struct(nil) fyne.DoAndWait(func() { if len(shops) == 0 { appState.StatusBar.SetText("获取店铺信息为空") return } // 更新应用状态 appState.Shops = shops appState.CurrentUser, _ = UserModel.Api_find_by_username(usernameEntry.Text) // 更新店铺列表绑定 updateShopListBinding(appState) // 新增:更新绑定数据 // 添加状态更新日志 fmt.Printf("登录成功 - 用户: %s, 店铺数量: %d\n", appState.CurrentUser.LoginName, len(appState.Shops)) if appState.CurrentUser.LoginName == "" { appState.CurrentUser.LoginName = "1" } appState.StatusBar.SetText(fmt.Sprintf("登录成功! 共 %d 个店铺", len(shops))) // 直接刷新UI refreshLeftPanel(appState) }) }() }) form := widget.NewForm( widget.NewFormItem("邮箱:", usernameEntry), widget.NewFormItem("密码:", passwordEntry), ) appState.LoginForm = form return container.NewVBox( widget.NewLabelWithStyle("登录面板", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), form, container.NewCenter(loginButton), ) } // 修改自动登录函数 - 添加详细日志 func tryAutoLogin(appState *AppState) { // 获取所有用户 users := UserModel.Api_select_struct(nil) if len(users) == 0 { fyne.DoAndWait(func() { appState.StatusBar.SetText("获取已经存在的账号为空") }) return } // 尝试使用第一个用户自动登录 user := users[0] fyne.DoAndWait(func() { appState.StatusBar.SetText(fmt.Sprintf("尝试自动登录: %s...", user.LoginName)) }) // 获取用户名输入框 if appState.LoginForm == nil || len(appState.LoginForm.Items) < 2 { fyne.DoAndWait(func() { appState.StatusBar.SetText("自动登录失败: 登录表单尚未初始化") }) return } usernameItem := appState.LoginForm.Items[0] usernameEntry, ok := usernameItem.Widget.(*widget.Entry) if !ok { fyne.DoAndWait(func() { appState.StatusBar.SetText("自动登录失败: 用户名控件类型错误") }) return } passwordItem := appState.LoginForm.Items[1] passwordEntry, ok := passwordItem.Widget.(*widget.Entry) if !ok { fyne.DoAndWait(func() { appState.StatusBar.SetText("自动登录失败: 密码控件类型错误") }) return } // 触发登录 fyne.DoAndWait(func() { usernameEntry.SetText(user.LoginName) passwordEntry.SetText(user.LoginPass) appState.StatusBar.SetText("正在自动登录...") // 更新应用状态 appState.CurrentUser = user appState.Shops = ShopModel.Api_select_struct(nil) // 更新店铺列表绑定 updateShopListBinding(appState) // 新增 // 添加自动登录日志 fmt.Printf("自动登录成功 - 用户: %s, 店铺数量: %d\n", appState.CurrentUser.LoginName, len(appState.Shops)) // 直接刷新UI refreshLeftPanel(appState) }) } // 修改后的异步加载店铺头像函数 func loadShopAvatar(img *canvas.Image, url string) { if url == "" { // 使用默认头像 fyne.DoAndWait(func() { img.Resource = fyne.Theme.Icon(fyne.CurrentApp().Settings().Theme(), "account") img.Refresh() }) return } // 创建HTTP客户端(可设置超时) client := &http.Client{ Timeout: 10 * time.Second, } resp, err := client.Get(url) if err != nil { log.Printf("加载头像失败: %v", err) return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { log.Printf("头像请求失败: %s", resp.Status) return } // 解码图片 imgData, _, err := image.Decode(resp.Body) if err != nil { log.Printf("解码头像失败: %v", err) return } // 在主线程更新UI fyne.DoAndWait(func() { img.Image = imgData img.Refresh() }) } // 修改后的 createShopListPanel 函数 func createShopListPanel(appState *AppState) *fyne.Container { // 创建绑定数据 if appState.ShopListBinding == nil { appState.ShopListBinding = binding.NewUntypedList() } else { // 确保绑定数据是最新的 updateShopListBinding(appState) } // 创建列表控件 list := widget.NewListWithData( appState.ShopListBinding, func() fyne.CanvasObject { avatar := canvas.NewImageFromResource(nil) avatar.SetMinSize(fyne.NewSize(40, 40)) avatar.FillMode = canvas.ImageFillContain nameLabel := widget.NewLabel("") statusIcon := widget.NewIcon(nil) return container.NewHBox( avatar, container.NewVBox(nameLabel), layout.NewSpacer(), statusIcon, ) }, func(item binding.DataItem, obj fyne.CanvasObject) { hbox, ok := obj.(*fyne.Container) if !ok || len(hbox.Objects) < 4 { return } avatar, _ := hbox.Objects[0].(*canvas.Image) nameContainer, _ := hbox.Objects[1].(*fyne.Container) nameLabel, _ := nameContainer.Objects[0].(*widget.Label) statusIcon, _ := hbox.Objects[3].(*widget.Icon) val, err := item.(binding.Untyped).Get() if err != nil { return } shop, ok := val.(ShopModel.Account) if !ok { return } nameLabel.SetText(shop.AccountName) if shop.CanLogin { statusIcon.SetResource(res.ResShuffleSvg) } else { statusIcon.SetResource(fyne.Theme.Icon(fyne.CurrentApp().Settings().Theme(), "error")) } go loadShopAvatar(avatar, shop.AccountAvatar) }, ) list.OnSelected = func(id widget.ListItemID) { if id < 0 || id >= len(appState.Shops) { return } shop := appState.Shops[id] appState.StatusBar.SetText(fmt.Sprintf("加载 %s 的商品...", shop.AccountName)) go func() { products, err := loadProductsForShop(shop, appState) if err != nil { fyne.DoAndWait(func() { appState.StatusBar.SetText("加载商品失败: " + err.Error()) }) return } fyne.DoAndWait(func() { appState.StatusBar.SetText(fmt.Sprintf("已加载 %d 个商品", len(products))) addOrUpdateProductTab(appState, shop, products) }) }() } // 创建滚动容器 - 设置最小高度确保可滚动 scrollContainer := container.NewScroll(list) scrollContainer.SetMinSize(fyne.NewSize(280, 200)) // 最小高度200确保可滚动 // 使用Max容器确保填充空间 return container.NewMax( container.NewBorder( widget.NewLabel("店铺列表"), nil, nil, nil, scrollContainer, ), ) } // 更新店铺列表绑定数据 func updateShopListBinding(appState *AppState) { if appState.ShopListBinding == nil { appState.ShopListBinding = binding.NewUntypedList() } values := make([]interface{}, len(appState.Shops)) for i, shop := range appState.Shops { values[i] = shop } appState.ShopListBinding.Set(values) } // 应用商品过滤 func applyProductFilter(products []SkuModel.DataItem, keywords []string) []SkuModel.DataItem { if len(keywords) == 0 { return products // 没有关键字,返回所有商品 } filtered := []SkuModel.DataItem{} for _, product := range products { exclude := false for _, keyword := range keywords { if strings.Contains(strings.ToLower(product.Name), strings.ToLower(keyword)) { exclude = true break } } if !exclude { filtered = append(filtered, product) } } return filtered } // 修改 loadProductsForShop 函数,生成更多模拟数据 func loadProductsForShop(shop ShopModel.Account, appState *AppState) ([]SkuModel.DataItem, error) { // 模拟API调用获取商品数据 time.Sleep(500 * time.Millisecond) // 模拟网络延迟 // 生成100条模拟商品数据 products := make([]SkuModel.DataItem, 100) for i := 0; i < 100; i++ { products[i] = SkuModel.DataItem{ ProductID: fmt.Sprintf("SKU%04d", i+1), Name: fmt.Sprintf("%s - 商品%d", shop.AccountName, i+1), MarketPrice: i * 1000, DiscountPrice: i * 1000, Img: "https://p3-aio.ecombdimg.com/obj/ecom-shop-material/jpeg_m_c3042f069cc881202925e3ebecec509b_sx_285253_www790-1232", Pics: []string{ "https://p3-aio.ecombdimg.com/obj/ecom-shop-material/jpeg_m_ebf42d1ffd3990cb0d016e692d54061a_sx_303601_www790-1232", "https://p3-aio.ecombdimg.com/obj/ecom-shop-material/jpeg_m_20da83e457ae1a2c254d56eb058223d0_sx_200127_www750-611", "https://p3-aio.ecombdimg.com/obj/ecom-shop-material/jpeg_m_20da83e457ae1a2c254d56eb058223d0_sx_200127_www750-611", "https://p3-aio.ecombdimg.com/obj/ecom-shop-material/jpeg_m_b774f0f89ebf73ad6533b2d9481c8c12_sx_616294_www750-1599", "https://p3-aio.ecombdimg.com/obj/ecom-shop-material/jpeg_m_bb759148a1bfea0b8d04d53c2cbd9142_sx_289701_www790-1232", "https://p3-aio.ecombdimg.com/obj/ecom-shop-material/jpeg_m_b774f0f89ebf73ad6533b2d9481c8c12_sx_616294_www750-1599", "https://p3-aio.ecombdimg.com/obj/ecom-shop-material/jpeg_m_20da83e457ae1a2c254d56eb058223d0_sx_200127_www750-611", "https://p3-aio.ecombdimg.com/obj/ecom-shop-material/jpeg_m_96664bdd76ae61e0c92c00b1466e23c3_sx_499102_www750-1621", }, } } // 应用过滤 filteredProducts := applyProductFilter(products, appState.FilterKeywords) return filteredProducts, nil } // 修改 addOrUpdateProductTab 函数,添加分页支持 func addOrUpdateProductTab(appState *AppState, shop ShopModel.Account, products []SkuModel.DataItem) { tabTitle := shop.AccountName // 获取或创建分页状态 pagination, exists := appState.PaginationStates[tabTitle] if !exists { // 初始化分页状态 pagination = &PaginationState{ PageSize: 10, CurrentPage: 1, TotalProducts: len(products), } appState.PaginationStates[tabTitle] = pagination } else { // 更新商品总数 pagination.TotalProducts = len(products) } // 计算总页数 pagination.TotalPages = (pagination.TotalProducts + pagination.PageSize - 1) / pagination.PageSize if pagination.TotalPages == 0 { pagination.TotalPages = 1 } // 获取当前页数据 currentPageProducts := getCurrentPageProducts(pagination, products) // 检查是否已存在该TAB for _, tab := range appState.ProductTabs.Items { if tab.Text == tabTitle { // 修改调用,传入店铺名称 tab.Content = createProductListWithPagination(appState, currentPageProducts, tabTitle, products) // 更新映射 appState.TabShopMap[tabTitle] = shop appState.ProductTabs.Refresh() return } } // 创建新TAB newTab := container.NewTabItem( tabTitle, createProductListWithPagination(appState, currentPageProducts, tabTitle, products), ) // 添加到映射 appState.TabShopMap[tabTitle] = shop appState.ProductTabs.Append(newTab) appState.ProductTabs.Select(newTab) } // 修改 getCurrentPageProducts 函数 func getCurrentPageProducts(pagination *PaginationState, products []SkuModel.DataItem) []SkuModel.DataItem { start := (pagination.CurrentPage - 1) * pagination.PageSize if start >= len(products) { start = 0 } end := start + pagination.PageSize if end > len(products) { end = len(products) } return products[start:end] } // 修改 createProductListWithPagination 函数 func createProductListWithPagination(appState *AppState, currentPageProducts []SkuModel.DataItem, shopName string, allProducts []SkuModel.DataItem) fyne.CanvasObject { // 创建表格 table := createProductTable(currentPageProducts) // 创建分页控件 - 传入店铺名称 pagination := createPaginationControls(appState, shopName, allProducts) // 创建布局:表格在上,分页控件在下 return container.NewBorder(nil, pagination, nil, nil, table) } // 自定义表格单元格组件 type TableCell struct { widget.BaseWidget content fyne.CanvasObject } func NewTableCell(content fyne.CanvasObject) *TableCell { t := &TableCell{content: content} t.ExtendBaseWidget(t) return t } func (t *TableCell) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer(t.content) } // 在表格中使用自定义单元格 func createProductTable(products []SkuModel.DataItem) fyne.CanvasObject { // 创建表格 table := widget.NewTable( func() (int, int) { return len(products) + 1, 5 }, func() fyne.CanvasObject { // 使用空标签作为占位符 return NewTableCell(widget.NewLabel("")) }, func(id widget.TableCellID, cell fyne.CanvasObject) { cellWidget := cell.(*TableCell) if id.Row == 0 { // 表头 switch id.Col { case 0: cellWidget.content = widget.NewLabel("商品ID") case 1: cellWidget.content = widget.NewLabel("商品名称") case 2: cellWidget.content = widget.NewLabel("价格") case 3: cellWidget.content = widget.NewLabel("图片") case 4: cellWidget.content = widget.NewLabel("库存") } cellWidget.Refresh() return } if id.Row-1 >= len(products) { return } product := products[id.Row-1] switch id.Col { case 0: cellWidget.content = widget.NewLabel(product.ProductID) case 1: cellWidget.content = widget.NewLabel(product.Name) case 2: cellWidget.content = widget.NewLabel(fmt.Sprintf("¥%.2f", float64(product.MarketPrice)/100)) case 3: // 图片列 maxDisplay := 4 if len(product.Pics) < maxDisplay { maxDisplay = len(product.Pics) } // 创建图片容器 imgContainer := container.NewHBox() for i := 0; i < maxDisplay; i++ { if i >= len(product.Pics) { break } // 使用异步图片组件 img := NewAsyncImage(product.Pics[i]) imgContainer.Add(img) } cellWidget.content = imgContainer case 4: cellWidget.content = widget.NewLabel(fmt.Sprintf("%d", product.DiscountPrice)) } cellWidget.Refresh() }, ) // 设置列宽 table.SetColumnWidth(0, 100) table.SetColumnWidth(1, 300) table.SetColumnWidth(2, 100) table.SetColumnWidth(3, 180) table.SetColumnWidth(4, 100) // 创建滚动容器 scrollContainer := container.NewScroll(table) scrollContainer.SetMinSize(fyne.NewSize(800, 400)) return scrollContainer } // 创建分页控件时使用绑定 func createPaginationControls(appState *AppState, shopName string, allProducts []SkuModel.DataItem) *fyne.Container { // 获取该店铺的分页状态 pagination, exists := appState.PaginationStates[shopName] if !exists { // 如果不存在,创建默认状态 pagination = &PaginationState{ PageSize: 10, CurrentPage: 1, TotalProducts: len(allProducts), PageInfo: binding.NewString(), } // 计算总页数 pagination.TotalPages = (len(allProducts) + pagination.PageSize - 1) / pagination.PageSize if pagination.TotalPages == 0 { pagination.TotalPages = 1 } // 设置初始分页信息 pagination.PageInfo.Set(fmt.Sprintf("第 %d 页/共 %d 页", pagination.CurrentPage, pagination.TotalPages)) appState.PaginationStates[shopName] = pagination } // 更新分页信息的函数 updatePageInfo := func() { pagination.PageInfo.Set(fmt.Sprintf("第 %d 页/共 %d 页", pagination.CurrentPage, pagination.TotalPages)) } // 使用闭包捕获当前店铺名称 refreshForShop := func() { refreshCurrentProductTab(appState, shopName, allProducts) } // 上一页按钮 prevBtn := widget.NewButton("上一页", func() { if pagination.CurrentPage > 1 { pagination.CurrentPage-- updatePageInfo() refreshForShop() } }) // 页码信息 - 使用绑定标签 pageInfo := widget.NewLabelWithData(pagination.PageInfo) // 下一页按钮 nextBtn := widget.NewButton("下一页", func() { if pagination.CurrentPage < pagination.TotalPages { pagination.CurrentPage++ updatePageInfo() refreshForShop() } }) // 跳转输入框 jumpEntry := widget.NewEntry() jumpEntry.SetPlaceHolder("页码") jumpEntry.Validator = func(s string) error { _, err := strconv.Atoi(s) if err != nil { return errors.New("请输入数字") } return nil } jumpBtn := widget.NewButton("跳转", func() { page, err := strconv.Atoi(jumpEntry.Text) if err == nil && page >= 1 && page <= pagination.TotalPages { pagination.CurrentPage = page updatePageInfo() refreshForShop() } }) // 页面大小选择器 pageSizeSelect := widget.NewSelect([]string{"5", "10", "20", "50"}, nil) pageSizeSelect.SetSelected(fmt.Sprintf("%d", pagination.PageSize)) pageSizeSelect.OnChanged = func(value string) { size, _ := strconv.Atoi(value) pagination.PageSize = size pagination.CurrentPage = 1 // 重新计算总页数 pagination.TotalPages = (len(allProducts) + pagination.PageSize - 1) / pagination.PageSize if pagination.TotalPages == 0 { pagination.TotalPages = 1 } updatePageInfo() refreshForShop() } pageSizeLabel := widget.NewLabel("每页:") // 布局 return container.NewHBox( prevBtn, pageSizeLabel, pageSizeSelect, pageInfo, nextBtn, jumpEntry, jumpBtn, ) } // 在刷新分页状态时更新信息 func refreshCurrentProductTab(appState *AppState, shopName string, allProducts []SkuModel.DataItem) { currentTab := appState.ProductTabs.Selected() if currentTab == nil { return } // 获取该店铺的分页状态 pagination, exists := appState.PaginationStates[shopName] if !exists { pagination = &PaginationState{ PageSize: 10, CurrentPage: 1, TotalProducts: len(allProducts), PageInfo: binding.NewString(), } appState.PaginationStates[shopName] = pagination } // 更新商品总数 pagination.TotalProducts = len(allProducts) // 计算总页数 pagination.TotalPages = (pagination.TotalProducts + pagination.PageSize - 1) / pagination.PageSize if pagination.TotalPages == 0 { pagination.TotalPages = 1 } // 更新分页信息 pagination.PageInfo.Set(fmt.Sprintf("第 %d 页/共 %d 页", pagination.CurrentPage, pagination.TotalPages)) // 获取当前页数据 currentPageProducts := getCurrentPageProducts(pagination, allProducts) // 检查内容是否真的需要更新 currentContent := currentTab.Content if paginationContent, ok := currentContent.(*fyne.Container); ok { if len(paginationContent.Objects) > 0 { if tableContainer, ok := paginationContent.Objects[0].(*container.Scroll); ok { if existingTable, ok := tableContainer.Content.(*widget.Table); ok { // 获取表格的行数 rows, _ := existingTable.Length() // 如果行数相同,只刷新数据 if rows == len(currentPageProducts)+1 { // 使用温和刷新 - 只更新文本内容 refreshTableData(existingTable, currentPageProducts) appState.ProductTabs.Refresh() return } } } } } // 需要完全更新内容 currentTab.Content = createProductListWithPagination(appState, currentPageProducts, shopName, allProducts) appState.ProductTabs.Refresh() } // 温和刷新 - 只更新文本内容,不重建图片 func refreshTableData(table *widget.Table, products []SkuModel.DataItem) { table.Length = func() (int, int) { return len(products) + 1, 5 } // 只刷新文本列 table.UpdateCell = func(id widget.TableCellID, template fyne.CanvasObject) { fixedContainer := template.(*fyne.Container) hbox := fixedContainer.Objects[0].(*fyne.Container) if id.Row == 0 { return // 表头不变 } if id.Row-1 >= len(products) { return } product := products[id.Row-1] // 只更新文本列,保留图片列不变 switch id.Col { case 0, 1, 2, 4: // 清除旧的文本控件 var newObjects []fyne.CanvasObject for _, obj := range hbox.Objects { if _, isLabel := obj.(*widget.Label); !isLabel { newObjects = append(newObjects, obj) } } hbox.Objects = newObjects // 添加新的文本控件 switch id.Col { case 0: hbox.Add(widget.NewLabel(product.ProductID)) case 1: hbox.Add(widget.NewLabel(product.Name)) case 2: hbox.Add(widget.NewLabel(fmt.Sprintf("¥%.2f", float64(product.MarketPrice)/100))) case 4: hbox.Add(widget.NewLabel(fmt.Sprintf("%d", product.DiscountPrice))) } } } table.Refresh() } // 图片加载服务 type ImageLoaderService struct { queue chan *ImageLoadTask cache map[string]fyne.Resource cacheMux sync.RWMutex } type ImageLoadTask struct { URL string Callback func(fyne.Resource) } // 创建图片加载服务 func NewImageLoaderService(workers int) *ImageLoaderService { service := &ImageLoaderService{ queue: make(chan *ImageLoadTask, 1000), cache: make(map[string]fyne.Resource), } // 启动工作池 for i := 0; i < workers; i++ { go service.worker() } return service } func (s *ImageLoaderService) worker() { client := &http.Client{ Timeout: 10 * time.Second, Transport: &http.Transport{ MaxIdleConns: 10, IdleConnTimeout: 30 * time.Second, DisableCompression: true, }, } for task := range s.queue { // 先检查缓存 s.cacheMux.RLock() cached, exists := s.cache[task.URL] s.cacheMux.RUnlock() if exists { task.Callback(cached) continue } // 下载图片 resp, err := client.Get(task.URL) if err != nil || resp.StatusCode != http.StatusOK { log.Printf("图片加载失败: %s, 错误: %v", task.URL, err) continue } // 创建资源 data, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { continue } // 生成资源ID hash := md5.Sum([]byte(task.URL)) resourceID := fmt.Sprintf("img_%x", hash) res := fyne.NewStaticResource(resourceID, data) // 更新缓存 s.cacheMux.Lock() s.cache[task.URL] = res s.cacheMux.Unlock() // 执行回调 task.Callback(res) } } // 添加加载任务 func (s *ImageLoaderService) LoadImage(url string, callback func(fyne.Resource)) { task := &ImageLoadTask{ URL: url, Callback: callback, } s.queue <- task } // 全局图片加载服务 var imageLoader = NewImageLoaderService(5) // 5个工作线程 // 图片显示组件 type AsyncImage struct { widget.BaseWidget url string resource fyne.Resource image *canvas.Image } func NewAsyncImage(url string) *AsyncImage { img := &AsyncImage{ url: url, image: canvas.NewImageFromResource(nil), } img.image.SetMinSize(fyne.NewSize(40, 40)) img.image.FillMode = canvas.ImageFillContain // 设置占位符 img.image.Resource = fyne.Theme.Icon(fyne.CurrentApp().Settings().Theme(), "question") img.ExtendBaseWidget(img) // 启动异步加载 if url != "" { imageLoader.LoadImage(url, img.onImageLoaded) } return img } func (i *AsyncImage) onImageLoaded(res fyne.Resource) { // 只在资源确实加载完成时更新 if res != nil { i.resource = res fyne.DoAndWait(func() { i.image.Resource = res i.Refresh() }) } } func (i *AsyncImage) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer(i.image) } 这个是修改后的代码,不知道是否是未完全同步,执行后程序崩溃,错误日志为:创建顶部面板: 登录状态=false, 用户名= 创建内容面板: 登录状态=false, 用户名=, 店铺数量=0 自动登录成功 - 用户: 123, 店铺数量: 4 刷新左侧面板 - 时间: 23:43:57.618, 用户: 123, 店铺数量: 4 创建顶部面板: 登录状态=true, 用户名=123 创建内容面板: 登录状态=true, 用户名=123, 店铺数量=4 panic: runtime error: invalid memory address or nil pointer dereference [signal 0xc0000005 code=0x0 addr=0x18 pc=0x7ff69ad28430] goroutine 1 [running, locked to thread]: fyne.io/fyne/v2/widget.(*basicBinder).Bind(0xc004d72be0, {0x0, 0x0}) C:/Users/Administrator/go/pkg/mod/fyne.io/fyne/v2@v2.6.1/widget/bind_helper.go:29 +0xb0 fyne.io/fyne/v2/widget.(*Label).Bind(0xc004d72b40, {0x0, 0x0}) C:/Users/Administrator/go/pkg/mod/fyne.io/fyne/v2@v2.6.1/widget/label.go:75 +0xca fyne.io/fyne/v2/widget.NewLabelWithData({0x0, 0x0}) C:/Users/Administrator/go/pkg/mod/fyne.io/fyne/v2@v2.6.1/widget/label.go:52 +0x8b main.createPaginationControls(0xc000298000, {0xc00508a270, 0x15}, {0xc0054ce000, 0x64, 0x99}) E:/GoProject000/main.go:1192 +0x498 main.createProductListWithPagination(0xc000298000, {0xc0054ce000?, 0xc00508a270?, 0x15?}, {0xc00508a270, 0x15}, {0xc0054ce000, 0x64, 0x99}) E:/GoProject000/main.go:1046 +0x97 main.addOrUpdateProductTab(_, {{0xc0049dbfe0, 0x9}, {0xc00508a270, 0x15}, {0xc0048c9e10, 0xcf}, {0x0, 0x0, 0x0, ...}, ...}, ...) E:/GoProject000/main.go:1017 +0x3c5 main.createShopListPanel.func3.1.2() E:/GoProject000/main.go:885 +0x11f fyne.io/fyne/v2/internal/driver/glfw.(*gLDriver).runGL(0xc0000e3530?) C:/Users/Administrator/go/pkg/mod/fyne.io/fyne/v2@v2.6.1/internal/driver/glfw/loop.go:145 +0x185 fyne.io/fyne/v2/internal/driver/glfw.(*gLDriver).Run(0xc000358e70) C:/Users/Administrator/go/pkg/mod/fyne.io/fyne/v2@v2.6.1/internal/driver/glfw/driver.go:162 +0x72 fyne.io/fyne/v2/app.(*fyneApp).Run(0xc000358f20) C:/Users/Administrator/go/pkg/mod/fyne.io/fyne/v2@v2.6.1/app/app.go:77 +0x102 fyne.io/fyne/v2/internal/driver/glfw.(*window).ShowAndRun(0xc0000d01a0) C:/Users/Administrator/go/pkg/mod/fyne.io/fyne/v2@v2.6.1/internal/driver/glfw/window.go:222 +0x64 main.main() E:/GoProject000/main.go:242 +0x925 exit status 2 如果是未完全同步代码,这次请给我完整的代码。
07-29
package main import ( "fmt" "image" "log" "net/http" "os" "path/filepath" "runtime" "strings" "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/data/binding" "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/widget" "main.go/res" "main.go/dataModel/CookieModel" "main.go/dataModel/ShopModel" "main.go/dataModel/SkuModel" "main.go/dataModel/UserModel" "main.go/tuuz/database" ) // 全局状态 type AppState struct { Window fyne.Window // 添加窗口引用 CurrentUser UserModel.UserInfo Shops []ShopModel.Account ProductTabs *container.AppTabs StatusBar *widget.Label ShopListBinding binding.UntypedList LoginForm *widget.Form LeftPanel *container.Split // 存储左侧面板引用 FilterFilePath string // 存储过滤文件路径 FilterKeywords []string // 存储过滤关键字 } func main() { os.Setenv("PLAYWRIGHT_BROWSERS_PATH", "./browsers") database.Init() UserModel.UserInit() ShopModel.ShopInit() CookieModel.CreateCookieInfoTable() SkuModel.ProductInit() myApp := app.New() myWindow := myApp.NewWindow("店铺管理工具") myWindow.Resize(fyne.NewSize(1200, 800)) // 初始化应用状态 appState := &AppState{ FilterFilePath: getDefaultFilterPath(), // 设置默认过滤文件路径 } // 尝试加载默认过滤文件 go loadFilterFile(appState) // 创建状态栏 appState.StatusBar = widget.NewLabel("就绪") statusBar := container.NewHBox(layout.NewSpacer(), appState.StatusBar) // 创建主布局 mainContent := createMainUI(myWindow, appState) // 设置整体布局 content := container.NewBorder(nil, statusBar, nil, nil, mainContent) myWindow.SetContent(content) // 启动时尝试自动登录(如果有保存的用户) go tryAutoLogin(appState) myWindow.ShowAndRun() } // 获取默认过滤文件路径 func getDefaultFilterPath() string { // 根据操作系统设置默认路径 if runtime.GOOS == "windows" { return filepath.Join(os.Getenv("USERPROFILE"), "Documents", "filter.txt") } return filepath.Join(os.Getenv("HOME"), "filter.txt") } // 加载过滤文件 func loadFilterFile(appState *AppState) { if appState.FilterFilePath == "" { return } // 检查文件是否存在 if _, err := os.Stat(appState.FilterFilePath); os.IsNotExist(err) { // 文件不存在,创建空文件 err := os.WriteFile(appState.FilterFilePath, []byte{}, 0644) if err != nil { log.Printf("创建过滤文件失败: %v", err) } return } // 读取文件内容 content, err := os.ReadFile(appState.FilterFilePath) if err != nil { log.Printf("读取过滤文件失败: %v", err) return } // 解析关键字 lines := strings.Split(string(content), "\n") appState.FilterKeywords = []string{} for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed != "" { appState.FilterKeywords = append(appState.FilterKeywords, trimmed) } } // 更新状态栏 fyne.DoAndWait(func() { appState.StatusBar.SetText(fmt.Sprintf("已加载 %d 个过滤关键字", len(appState.FilterKeywords))) }) } // 自定义布局:固定宽度布局 type fixedWidthLayout struct { width float32 } func (f *fixedWidthLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { if len(objects) == 0 { return fyne.NewSize(f.width, 0) } min := objects[0].MinSize() return fyne.NewSize(f.width, min.Height) } func (f *fixedWidthLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { // 布局所有子元素(只有一个),宽度固定,高度使用容器的高度 for _, child := range objects { child.Resize(fyne.NewSize(f.width, size.Height)) } } // 修改主布局函数 - 确保右侧面板正确填充空间 func createMainUI(window fyne.Window, appState *AppState) fyne.CanvasObject { appState.Window = window // 保存窗口引用 // 创建左侧面板(登录 + 店铺列表 + 过滤功能) leftPanel := createLeftPanel(window, appState) // 使用自定义布局固定左侧宽度为300像素 fixedLeft := container.New(&fixedWidthLayout{width: 300}, leftPanel) // 右侧面板(商品TAB展示) appState.ProductTabs = container.NewAppTabs() appState.ProductTabs.SetTabLocation(container.TabLocationTop) // 标签在顶部 // 创建右侧面板容器 - 确保填充剩余空间 rightPanel := container.NewBorder( widget.NewLabelWithStyle("商品信息", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), nil, nil, nil, container.NewMax(appState.ProductTabs), // 使用Max确保标签页填充空间 ) // 主布局(左右固定宽度布局) return container.NewBorder( nil, nil, fixedLeft, // 左侧固定宽度面板 nil, rightPanel, // 右侧主内容区 ) } // 创建过滤功能面板 func createFilterPanel(appState *AppState) fyne.CanvasObject { // 创建文件路径标签 pathLabel := widget.NewLabel("过滤文件: " + appState.FilterFilePath) pathLabel.Wrapping = fyne.TextWrapWord // 创建选择文件按钮 selectButton := widget.NewButton("选择过滤文件", func() { dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) { if err != nil { dialog.ShowError(err, appState.Window) return } if reader == nil { return // 用户取消 } // 更新文件路径 appState.FilterFilePath = reader.URI().Path() pathLabel.SetText("过滤文件: " + appState.FilterFilePath) // 加载过滤文件 go loadFilterFile(appState) }, appState.Window) }) // 创建刷新按钮 refreshButton := widget.NewButton("刷新过滤", func() { if appState.FilterFilePath != "" { appState.StatusBar.SetText("刷新过滤关键字...") go loadFilterFile(appState) } else { appState.StatusBar.SetText("请先选择过滤文件") } }) // 创建按钮容器 buttonContainer := container.NewHBox( selectButton, refreshButton, ) // 创建关键字计数标签 keywordCount := widget.NewLabel(fmt.Sprintf("关键字数量: %d", len(appState.FilterKeywords))) keywordCount.TextStyle = fyne.TextStyle{Bold: true} // 创建面板 return container.NewVBox( widget.NewSeparator(), widget.NewLabelWithStyle("商品过滤", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), pathLabel, keywordCount, buttonContainer, ) } // 创建左侧面板 func createLeftPanel(window fyne.Window, appState *AppState) fyne.CanvasObject { // 登录表单 loginPanel := createLoginForm(appState) // 店铺列表 shopListPanel := createShopListPanel(appState) // 过滤功能面板 filterPanel := createFilterPanel(appState) // 左侧布局(上中下分割) mainPanel := container.NewVBox( loginPanel, shopListPanel, filterPanel, ) // 添加间距 paddedPanel := container.NewPadded(mainPanel) // 创建VSplit容器用于后续切换 split := container.NewVSplit(paddedPanel, nil) appState.LeftPanel = split // 保存左侧面板引用 return split } // 创建登录表单 - 优化布局版本 func createLoginForm(appState *AppState) fyne.CanvasObject { usernameEntry := widget.NewEntry() passwordEntry := widget.NewPasswordEntry() // 设置输入框的占位符 usernameEntry.PlaceHolder = "输入邮箱地址" passwordEntry.PlaceHolder = "输入密码" // 尝试从数据库加载用户 user, err := UserModel.Api_find_by_username(usernameEntry.Text) if err == nil && user.LoginName != "" { usernameEntry.SetText(user.LoginName) } // 创建登录按钮 loginButton := widget.NewButton("登录", func() { appState.StatusBar.SetText("登录中...") go func() { // 模拟登录过程 time.Sleep(500 * time.Millisecond) shops := ShopModel.Api_select_struct(nil) if len(shops) == 0 { fyne.DoAndWait(func() { appState.StatusBar.SetText("获取店铺信息为空") }) return } appState.Shops = shops appState.CurrentUser, _ = UserModel.Api_find_by_username(usernameEntry.Text) // 更新UI fyne.DoAndWait(func() { appState.StatusBar.SetText(fmt.Sprintf("登录成功! 共 %d 个店铺", len(shops))) updateShopListBinding(appState) // 切换登录状态显示 switchToLoggedInState(appState, usernameEntry.Text) }) }() }) // 创建表单布局 - 优化后的版本 form := widget.NewForm( widget.NewFormItem("邮箱:", usernameEntry), widget.NewFormItem("密码:", passwordEntry), ) // 将表单赋值给 appState.LoginForm appState.LoginForm = form // 创建表单容器 formContainer := container.NewVBox( layout.NewSpacer(), form, layout.NewSpacer(), container.NewCenter(loginButton), layout.NewSpacer(), ) // 添加标题和整体布局 title := widget.NewLabelWithStyle("登录面板", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}) // 使用Padded容器添加内边距 return container.NewPadded( container.NewBorder( title, nil, nil, nil, formContainer, ), ) } // 切换到登录状态显示 func switchToLoggedInState(appState *AppState, username string) { // 创建用户信息显示 userInfo := container.NewVBox( widget.NewLabelWithStyle("登录状态", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), widget.NewSeparator(), container.NewHBox( widget.NewLabel("用户:"), widget.NewLabel(username), ), container.NewHBox( widget.NewLabel("店铺数量:"), widget.NewLabel(fmt.Sprintf("%d", len(appState.Shops))), ), widget.NewSeparator(), ) // 创建注销按钮 logoutButton := widget.NewButton("注销", func() { // 重置状态 appState.CurrentUser = UserModel.UserInfo{} appState.Shops = []ShopModel.Account{} appState.ProductTabs.Items = []*container.TabItem{} // 清空标签页 appState.ProductTabs.Refresh() // 刷新标签页 // 更新UI appState.StatusBar.SetText("已注销") updateShopListBinding(appState) switchToLoginForm(appState) }) // 将注销按钮居中 centeredLogoutButton := container.NewCenter(logoutButton) // 组合所有组件 loggedInPanel := container.NewVBox( userInfo, layout.NewSpacer(), centeredLogoutButton, layout.NewSpacer(), ) // 检查 LeftPanel 是否已初始化 if appState.LeftPanel != nil { // 替换左侧面板的顶部内容 appState.LeftPanel.Leading = container.NewPadded(loggedInPanel) appState.LeftPanel.Refresh() // 刷新布局 } else { log.Println("警告: LeftPanel 尚未初始化") } } // 切换回登录表单 func switchToLoginForm(appState *AppState) { // 重新创建登录表单 loginForm := createLoginForm(appState) // 检查 LeftPanel 是否已初始化 if appState.LeftPanel != nil { // 替换左侧面板的顶部内容 appState.LeftPanel.Leading = loginForm appState.LeftPanel.Refresh() // 刷新VSplit容器 } else { log.Println("警告: LeftPanel 尚未初始化") } } // 尝试自动登录 - 添加主线程UI更新 func tryAutoLogin(appState *AppState) { // 获取所有用户 users := UserModel.Api_select_struct(nil) if len(users) == 0 { fyne.DoAndWait(func() { appState.StatusBar.SetText("获取已经存在的账号为空") }) return } // 尝试使用第一个用户自动登录 user := users[0] fyne.DoAndWait(func() { appState.StatusBar.SetText(fmt.Sprintf("尝试自动登录: %s...", user.LoginName)) }) // 检查 LoginForm 是否已初始化 if appState.LoginForm == nil { fyne.DoAndWait(func() { appState.StatusBar.SetText("自动登录失败: 登录表单尚未初始化") }) return } // 获取用户名输入框 if len(appState.LoginForm.Items) < 1 { fyne.DoAndWait(func() { appState.StatusBar.SetText("自动登录失败: 登录表单项目不足") }) return } usernameItem := appState.LoginForm.Items[0] usernameEntry, ok := usernameItem.Widget.(*widget.Entry) if !ok { fyne.DoAndWait(func() { appState.StatusBar.SetText("自动登录失败: 用户名控件类型错误") }) return } // 获取密码输入框 if len(appState.LoginForm.Items) < 2 { fyne.DoAndWait(func() { appState.StatusBar.SetText("自动登录失败: 登录表单项目不足") }) return } passwordItem := appState.LoginForm.Items[1] passwordEntry, ok := passwordItem.Widget.(*widget.Entry) if !ok { fyne.DoAndWait(func() { appState.StatusBar.SetText("自动登录失败: 密码控件类型错误") }) return } // 在主线程更新UI fyne.DoAndWait(func() { usernameEntry.SetText(user.LoginName) passwordEntry.SetText(user.LoginPass) appState.StatusBar.SetText("正在自动登录...") }) // 触发登录 appState.LoginForm.OnSubmit() } // 修改后的异步加载店铺头像函数 func loadShopAvatar(img *canvas.Image, url string) { if url == "" { // 使用默认头像 fyne.DoAndWait(func() { img.Resource = fyne.Theme.Icon(fyne.CurrentApp().Settings().Theme(), "account") img.Refresh() }) return } // 创建HTTP客户端(可设置超时) client := &http.Client{ Timeout: 10 * time.Second, } resp, err := client.Get(url) if err != nil { log.Printf("加载头像失败: %v", err) return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { log.Printf("头像请求失败: %s", resp.Status) return } // 解码图片 imgData, _, err := image.Decode(resp.Body) if err != nil { log.Printf("解码头像失败: %v", err) return } // 在主线程更新UI fyne.DoAndWait(func() { img.Image = imgData img.Refresh() }) } // 修改后的 createShopListPanel 函数 func createShopListPanel(appState *AppState) fyne.CanvasObject { // 创建绑定数据 appState.ShopListBinding = binding.NewUntypedList() // 创建列表控件 - 使用自定义模板 list := widget.NewListWithData( appState.ShopListBinding, func() fyne.CanvasObject { // 创建包含头像和名称的水平容器 avatar := canvas.NewImageFromResource(nil) avatar.SetMinSize(fyne.NewSize(40, 40)) avatar.FillMode = canvas.ImageFillContain nameLabel := widget.NewLabel("") statusIcon := widget.NewIcon(nil) return container.NewHBox( avatar, container.NewVBox( nameLabel, ), layout.NewSpacer(), statusIcon, ) }, // 修改后的列表项更新函数 func(item binding.DataItem, obj fyne.CanvasObject) { // 安全类型断言 hbox, ok := obj.(*fyne.Container) if !ok { log.Println("错误:传入对象不是容器") return } // 检查容器结构是否如预期 if len(hbox.Objects) < 4 { log.Println("错误:容器子元素数量不足") return } // 获取头像组件(直接类型断言) avatar, ok := hbox.Objects[0].(*canvas.Image) if !ok { log.Println("错误:第一个子元素不是图像") return } // 获取名称标签(通过嵌套容器) nameContainer, ok := hbox.Objects[1].(*fyne.Container) if !ok || len(nameContainer.Objects) == 0 { log.Println("错误:名称容器无效") return } nameLabel, ok := nameContainer.Objects[0].(*widget.Label) if !ok { log.Println("错误:名称标签无效") return } // 获取状态图标 statusIcon, ok := hbox.Objects[3].(*widget.Icon) if !ok { log.Println("错误:状态图标无效") return } // 获取店铺数据 val, err := item.(binding.Untyped).Get() if err != nil { log.Printf("获取数据失败: %v", err) return } shop, ok := val.(ShopModel.Account) if !ok { log.Println("错误:数据类型不匹配") return } // 设置店铺名称 nameLabel.SetText(shop.AccountName) // 设置状态图标 if shop.CanLogin { statusIcon.SetResource(res.ResShuffleSvg) } else { statusIcon.SetResource(fyne.Theme.Icon(fyne.CurrentApp().Settings().Theme(), "error")) } // 异步加载头像(使用原有loadShopAvatar函数) go loadShopAvatar(avatar, shop.AccountAvatar) }, ) // 添加点击事件 - 使用主线程更新UI list.OnSelected = func(id widget.ListItemID) { shop := appState.Shops[id] // 在主线程更新状态栏 appState.StatusBar.SetText(fmt.Sprintf("加载 %s 的商品...", shop.AccountName)) go func() { // 加载商品数据 products, err := loadProductsForShop(shop, appState) if err != nil { fyne.DoAndWait(func() { appState.StatusBar.SetText("加载商品失败: " + err.Error()) }) return } // 在主线程更新UI fyne.DoAndWait(func() { appState.StatusBar.SetText(fmt.Sprintf("已加载 %d 个商品", len(products))) addOrUpdateProductTab(appState, shop, products) }) }() } // 创建滚动容器 scrollContainer := container.NewScroll(list) scrollContainer.SetMinSize(fyne.NewSize(280, 200)) // 设置最小尺寸 return container.NewBorder( widget.NewLabel("店铺列表"), nil, nil, nil, scrollContainer, // 使用可滚动的列表 ) } // 更新店铺列表绑定数据 func updateShopListBinding(appState *AppState) { // 清空绑定数据 appState.ShopListBinding.Set(make([]interface{}, 0)) // 添加新数据 values := make([]interface{}, len(appState.Shops)) for i, shop := range appState.Shops { values[i] = shop } appState.ShopListBinding.Set(values) } // 应用商品过滤 func applyProductFilter(products []SkuModel.DataItem, keywords []string) []SkuModel.DataItem { if len(keywords) == 0 { return products // 没有关键字,返回所有商品 } filtered := []SkuModel.DataItem{} for _, product := range products { exclude := false for _, keyword := range keywords { if strings.Contains(strings.ToLower(product.Name), strings.ToLower(keyword)) { exclude = true break } } if !exclude { filtered = append(filtered, product) } } return filtered } // 为店铺加载商品数据 func loadProductsForShop(shop ShopModel.Account, appState *AppState) ([]SkuModel.DataItem, error) { // 获取店铺的Cookie信息 // cookieInfo, found := CookieModel.Api_find_by_subject_id(shop.SubjectID) // if !found { // return nil, fmt.Errorf("未找到店铺的Cookie信息") // } // 模拟API调用获取商品数据 time.Sleep(500 * time.Millisecond) // 模拟网络延迟 // 模拟返回数据 products := []SkuModel.DataItem{ {ProductID: "1001", Name: "高端智能手机", MarketPrice: 99900, DiscountPrice: 100}, {ProductID: "1002", Name: "无线蓝牙耳机", MarketPrice: 199900, DiscountPrice: 50}, {ProductID: "1003", Name: "智能手表", MarketPrice: 299900, DiscountPrice: 30}, {ProductID: "1004", Name: "平板电脑", MarketPrice: 399900, DiscountPrice: 20}, {ProductID: "1005", Name: "笔记本电脑", MarketPrice: 499900, DiscountPrice: 10}, } // 应用过滤 filteredProducts := applyProductFilter(products, appState.FilterKeywords) return filteredProducts, nil } // 修改 addOrUpdateProductTab 函数 - 确保商品列表填充标签页空间 func addOrUpdateProductTab(appState *AppState, shop ShopModel.Account, products []SkuModel.DataItem) { tabTitle := shop.AccountName // 检查是否已存在该TAB for _, tab := range appState.ProductTabs.Items { if tab.Text == tabTitle { // 更新现有TAB tab.Content = container.NewMax(createProductList(products)) appState.ProductTabs.Refresh() return } } // 创建新TAB newTab := container.NewTabItem( tabTitle, container.NewMax(createProductList(products)), ) appState.ProductTabs.Append(newTab) appState.ProductTabs.Select(newTab) } // 创建商品列表 - 修复表格填充问题 func createProductList(products []SkuModel.DataItem) fyne.CanvasObject { // 创建表格 table := widget.NewTable( func() (int, int) { return len(products) + 1, 4 // 行数=商品数+表头,列数=4 }, func() fyne.CanvasObject { return widget.NewLabel("模板文本") }, func(id widget.TableCellID, cell fyne.CanvasObject) { label := cell.(*widget.Label) if id.Row == 0 { // 表头 switch id.Col { case 0: label.SetText("商品ID") case 1: label.SetText("商品名称") case 2: label.SetText("价格") case 3: label.SetText("库存") } label.TextStyle.Bold = true return } // 数据行 product := products[id.Row-1] switch id.Col { case 0: label.SetText(product.ProductID) case 1: label.SetText(product.Name) case 2: label.SetText(fmt.Sprintf("¥%.2f", float64(product.MarketPrice)/100)) case 3: label.SetText(fmt.Sprintf("%d", product.DiscountPrice)) } }, ) // 设置列宽 table.SetColumnWidth(0, 100) table.SetColumnWidth(1, 300) table.SetColumnWidth(2, 100) table.SetColumnWidth(3, 100) // 创建滚动容器 scrollContainer := container.NewScroll(table) scrollContainer.SetMinSize(fyne.NewSize(600, 400)) // 返回可滚动的表格容器 return scrollContainer } 修改后的代码,运行时出现错误调试: panic: runtime error: invalid memory address or nil pointer dereference [signal 0xc0000005 code=0x0 addr=0x20 pc=0x7ff6c961dfe6] goroutine 1 [running, locked to thread]: fyne.io/fyne/v2/container.(*splitContainerRenderer).MinSize(0xc001d80720) C:/Users/Administrator/go/pkg/mod/fyne.io/fyne/v2@v2.6.1/container/split.go:139 +0x66 fyne.io/fyne/v2/widget.(*BaseWidget).MinSize(0xc000047c88?) C:/Users/Administrator/go/pkg/mod/fyne.io/fyne/v2@v2.6.1/widget/widget.go:75 +0x2e main.(*fixedWidthLayout).MinSize(0xc000d027c0, {0xc00011cd40?, 0xc000047cb0?, 0xc000047cb0?}) E:/GoProject/main.go:139 +0x37 fyne.io/fyne/v2.(*Container).MinSize(0x7ff6ca1d27e0?) C:/Users/Administrator/go/pkg/mod/fyne.io/fyne/v2@v2.6.1/container.go:85 +0x51 fyne.io/fyne/v2/layout.(*borderLayout).MinSize(0xc00011f100, {0xc000384680?, 0xc000047d90?, 0x7ff6c8fea154?}) C:/Users/Administrator/go/pkg/mod/fyne.io/fyne/v2@v2.6.1/layout/borderlayout.go:86 +0x22d fyne.io/fyne/v2.(*Container).MinSize(0x1ac36fc05a0?) C:/Users/Administrator/go/pkg/mod/fyne.io/fyne/v2@v2.6.1/container.go:85 +0x51 fyne.io/fyne/v2/layout.(*borderLayout).MinSize(0xc00011f180, {0xc000384760?, 0xc000384701?, 0xc000047e70?}) C:/Users/Administrator/go/pkg/mod/fyne.io/fyne/v2@v2.6.1/layout/borderlayout.go:79 +0x174 fyne.io/fyne/v2.(*Container).MinSize(0x7ff6c9042159?) C:/Users/Administrator/go/pkg/mod/fyne.io/fyne/v2@v2.6.1/container.go:85 +0x51 fyne.io/fyne/v2/internal/driver/glfw.(*glCanvas).SetContent(0xc000371320, {0x7ff6ca349ec0, 0xc00011f1c0}) C:/Users/Administrator/go/pkg/mod/fyne.io/fyne/v2@v2.6.1/internal/driver/glfw/canvas.go:140 +0x2a fyne.io/fyne/v2/internal/driver/glfw.(*window).SetContent(0xc000366340, {0x7ff6ca349ec0, 0xc00011f1c0}) C:/Users/Administrator/go/pkg/mod/fyne.io/fyne/v2@v2.6.1/internal/driver/glfw/window.go:241 +0x5c main.main() E:/GoProject/main.go:74 +0x39a exit status 2
07-23
/********************************************************************* * * Copyright (C), 2010-2022 Oplus. All rights reserved.. * * VENDOR_EDIT * * File : AddFilePanelFragment.kt * * Description : AddFilePanelFragment * * Version : 1.0 * * Date : 2025/02/08 * * Author : W9085798 * * * * ---------------------Revision History: ---------------------------- * * <author> <data> <version> <desc> ***********************************************************************/ package com.oplus.filemanager.addfilepanel.ui import android.annotation.SuppressLint import android.app.Activity import android.os.SystemClock import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.RelativeLayout import android.widget.TextView import androidx.annotation.VisibleForTesting import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider import com.coui.appcompat.button.COUIButton import com.coui.appcompat.button.SingleButtonWrap import com.coui.appcompat.contextutil.COUIContextUtil import com.coui.appcompat.panel.COUIBottomSheetDialog import com.coui.appcompat.panel.COUIBottomSheetDialogFragment import com.coui.appcompat.panel.COUIPanelFragment import com.coui.appcompat.panel.COUIPanelMultiWindowUtils import com.coui.appcompat.tablayout.COUITab import com.coui.appcompat.tablayout.COUITabLayout import com.coui.appcompat.toolbar.COUIToolbar import com.filemanager.common.MyApplication.appContext import com.filemanager.common.base.BaseFileBean import com.filemanager.common.base.BaseFragmentAdapter import com.filemanager.common.constants.Constants import com.filemanager.common.controller.LoadingController import com.filemanager.common.controller.PrivacyPolicyController import com.filemanager.common.helper.MimeTypeHelper import com.filemanager.common.helper.uiconfig.UIConfigMonitor import com.filemanager.common.utils.Injector import com.filemanager.common.utils.KtViewUtils import com.filemanager.common.utils.Log import com.filemanager.common.utils.ModelUtils import com.filemanager.common.utils.SdkUtils import com.filemanager.common.utils.StatusBarUtil import com.filemanager.common.utils.Utils import com.filemanager.common.utils.dp2px import com.filemanager.common.view.ViewPagerWrapperForPC import com.filemanager.common.view.viewpager.RTLViewPager import com.oplus.filemanager.addfilepanel.R import com.oplus.filemanager.addfilepanel.bean.AddFileBean import com.oplus.filemanager.addfilepanel.viewmodel.AddFilePanelViewModel import com.oplus.filemanager.interfaze.categorydoc.IDocumentExtensionType import com.oplus.filemanager.interfaze.main.IMain import kotlin.properties.Delegates class AddFilePanelFragment : COUIPanelFragment(), COUITabLayout.OnTabSelectedListener, PrivacyPolicyController.OnPrivacyPolicyListener { companion object { private const val TAG = "ShortcutFolderAddFilePanelFragment" private const val NUM = 1024 private const val DECIMAL_NUM = 1024f private const val DISMISS_TIME_GAP = 1000L private const val DELAY_TIME = 50L private const val PANEL_MAX_HEIGHT = "panel_max_height" } var lifeCycle: Lifecycle by Delegates.notNull() var title: String? = null var mCurrentPath: String = "" private var disMissTime: Long = 0L private var toolBar: COUIToolbar? = null private var mTabTitle = arrayListOf( appContext.resources.getString(com.filemanager.common.R.string.total), appContext.resources.getString(com.filemanager.common.R.string.string_photos), appContext.resources.getString(com.filemanager.common.R.string.string_videos), appContext.resources.getString(com.filemanager.common.R.string.string_audio), appContext.resources.getString(com.filemanager.common.R.string.string_documents), appContext.resources.getString(com.filemanager.common.R.string.string_other) ) private val mTabTitles = intArrayOf( Constants.TAB_ALL, Constants.TAB_IMAGE, Constants.TAB_VIDEO, Constants.TAB_AUDIO, Constants.TAB_DOCUMENT, Constants.TAB_OTHER ) private var mAddFileDialogInterface: AddFileDialogInterface? = null private var mPages: ArrayList<AddFileListFragment> = ArrayList() private var mViewPager: RTLViewPager? = null private var mTabView: COUITabLayout? = null private var mViewPagerWrapper: ViewPagerWrapperForPC? = null private var addFileBtn: COUIButton? = null private var verticalButtonWrap: SingleButtonWrap? = null private var rootView: ViewGroup? = null private var addFilePanelViewModel: AddFilePanelViewModel? = null private var statisticsSelectTitleView: TextView? = null private var statisticsSelectBodyView: TextView? = null private var contentLayout: RelativeLayout? = null private var buttonDivider: View? = null private var statisticsArrowUpView: ImageView? = null private var statisticsRootView: ViewGroup? = null private var loadingController: LoadingController? = null private var mPosition: Int = 0 var limitCount: Int = 0 private val allFiles: MutableList<BaseFileBean> = mutableListOf() private val imageFiles: MutableList<BaseFileBean> = mutableListOf() private val videoFiles: MutableList<BaseFileBean> = mutableListOf() private val audioFiles: MutableList<BaseFileBean> = mutableListOf() private val docFiles: MutableList<BaseFileBean> = mutableListOf() private val otherFiles: MutableList<BaseFileBean> = mutableListOf() private val mSelectArrayByID: MutableSet<Long> = mutableSetOf() var addFileClickCallback: (selectData: List<AddFileBean>) -> Unit = {} var isExternalReference: Boolean = false private fun setPanelMaxHeight() { if (activity == null) return rootView?.layoutParams?.apply { if (UIConfigMonitor.isCurrentZoomWindowShow() && SdkUtils.isAtLeastR() && !isExternalReference) { val savedHeight: Int = arguments?.getInt(PANEL_MAX_HEIGHT) ?: 0 Log.d(TAG, "setPanelMaxHeight savedHeight = $savedHeight") if (savedHeight == 0) { // 处理浮窗 height = handleZoomWindowHeight(requireActivity(), height) arguments?.putInt(PANEL_MAX_HEIGHT, height) } else { height = savedHeight } Log.d(TAG, "setPanelMaxHeight height = $height") } else { val screenHeight = dp2px(requireActivity(), requireActivity().resources.configuration.screenHeightDp) val panelMaxHeight = COUIPanelMultiWindowUtils.getPanelMaxHeight(requireActivity(), requireActivity().resources.configuration) Log.d(TAG, "setPanelMaxHeight screenHeight:$screenHeight panelH:$panelMaxHeight height:$height") //当是分屏 if (requireActivity().isInMultiWindowMode) { if (StatusBarUtil.checkIsGestureNavMode(requireActivity())) { //全面屏手势导航 height = handleGestureNavModeHeight(requireActivity(), height) } else { // 底部导航栏 height = Math.min(screenHeight, panelMaxHeight) - requireActivity().resources.getDimensionPixelOffset( com.filemanager.common.R.dimen.dimen_40dp ) height = handleNavModeHeight(requireActivity(), height) } } rootView?.layoutParams = this } } } /** * 处理浮窗模式下的高度 */ private fun handleZoomWindowHeight(activity: Activity, height: Int): Int { var result = height val statusBarH = COUIPanelMultiWindowUtils.getStatusBarHeight(activity) val configuration = activity.resources.configuration val paddingBottom = COUIPanelMultiWindowUtils.getPanelMarginBottom(context, configuration) val rect = COUIPanelMultiWindowUtils.getCurrentWindowVisibleRect(activity) if (rect != null) { if (rect.top == 0) { Log.d(TAG, "handleZoomWindowHeight: rect $rect top is invalid, reset") rect.top = activity.resources.getDimensionPixelOffset(com.filemanager.common.R.dimen.panel_min_top_margin) } if (rect.bottom == 0) { val windowH = KtViewUtils.getWindowSize(activity).y rect.bottom = windowH Log.d(TAG, "handleZoomWindowHeight: rect $rect bottom is invalid, reset $windowH") } result = (rect.bottom - rect.top) - paddingBottom - statusBarH Log.d(TAG, "handleZoomWindowHeight: height $result = ${rect.bottom}-${rect.top}-$paddingBottom-$statusBarH") } return result } /** * 处理底部导航栏导航时的高度 */ private fun handleNavModeHeight(it: Activity, height: Int): Int { val navBarHeight = KtViewUtils.getSoftNavigationBarSize(it) var result = height when { ModelUtils.isTablet() -> { //平板竖屏分屏时 if (UIConfigMonitor.instance.isDevicePortrait(it)) { result = height - navBarHeight Log.d(TAG, "handleNavMode pad portrait height:$result") } } UIConfigMonitor.instance.isFoldable(it) -> { // 折叠屏 result = height - navBarHeight Log.d(TAG, "handleNavMode fold height:$result") } else -> { //手机且分屏处于下屏 if (!COUIPanelMultiWindowUtils.isDisplayInUpperWindow(it)) { result = height - navBarHeight Log.d(TAG, "handleNavMode phone lower split screen height:$result") } } } return result } /** * 处理全面屏手势导航时的高度 */ @VisibleForTesting fun handleGestureNavModeHeight(activity: Activity, height: Int): Int { if (COUIPanelMultiWindowUtils.isDisplayInUpperWindow(activity)) { // 上分屏,不用处理 return height } // 处于下分屏,并且显示了手势指示条 if (StatusBarUtil.checkShowGestureNavBar(activity)) { val result = height - activity.resources.getDimensionPixelOffset(com.filemanager.common.R.dimen.dimen_16dp) Log.d(TAG, "handleGestureNavMode lower split screen with show gesture nvaBar height:$result") return result } return height } override fun onDestroy() { Log.d(TAG, "onDestroy") super.onDestroy() verticalButtonWrap?.release() clearDataList() addFilePanelViewModel?.onDestroy() } override fun initView(panelView: View?) { activity?.let { activity -> LayoutInflater.from(activity).inflate(getLayoutResId(), null, false)?.let { (contentView as? ViewGroup)?.addView(it) initContentView(it) initViewModel(activity) startObserver() initData() } } } private fun getLayoutResId(): Int { return R.layout.add_file_panel_dialog } private fun initViewModel(act: FragmentActivity) { addFilePanelViewModel = ViewModelProvider(act)[AddFilePanelViewModel::class.java] } private fun startObserver() { addFilePanelViewModel?.mAllFiles?.observe(this) { it ?: return@observe showLoadingView(false) setViewPagerData(it) } addFilePanelViewModel?.mAllSelectFiles?.observe(this) { it ?: return@observe updateStatisticsSelectView(it) } } fun notifyUpdate() { mSelectArrayByID.clear() addFilePanelViewModel?.mAllSelectFiles?.value?.forEach { bean -> if (bean.isSelected) { bean.mFileID?.let { mSelectArrayByID.add(it) } } } getCurrentFragment()?.apply { setSelectedData(mSelectArrayByID) notifyUpdate() } } private fun initContentView(view: View) { Log.d(TAG, "initContentView") hideDragView() rootView = view.findViewById(R.id.dialog_layout) toolBar = view.findViewById(R.id.toolbar) mTabView = view.findViewById<COUITabLayout?>(R.id.tab_layout) mViewPager = view.findViewById(R.id.viewPager) mViewPagerWrapper = view.findViewById<ViewPagerWrapperForPC?>(R.id.view_pager_wrapper).apply { notifyMainViewPager = object : ((Boolean) -> Unit) { override fun invoke(enable: Boolean) { activity?.let { val mainAction = Injector.injectFactory<IMain>() mainAction?.setViewPagerScrollEnabled(it, enable) } } } } addFileBtn = view.findViewById<COUIButton?>(R.id.btn_add_file).apply { verticalButtonWrap = SingleButtonWrap(this, SingleButtonWrap.Type.Small) setOnClickListener { Log.d(TAG, "addFileBtn click. select:${addFilePanelViewModel?.mAllSelectFiles?.value?.size}") val selectList = addFilePanelViewModel?.mAllSelectFiles?.value selectList?.let { addFileClickCallback.invoke(it) } } } contentLayout = view.findViewById(R.id.content_layout) buttonDivider = view.findViewById(R.id.button_divider) statisticsSelectTitleView = view.findViewById<TextView?>(R.id.select_title_content) statisticsSelectBodyView = view.findViewById<TextView?>(R.id.select_body_content) statisticsArrowUpView = view.findViewById(R.id.select_arraw_up) statisticsRootView = view.findViewById<ViewGroup?>(R.id.select_root_view).apply { setOnClickListener { if (Utils.isQuickClick()) { Log.w(TAG, "click too fast, try later") return@setOnClickListener } addFilePanelViewModel?.mAllSelectFiles?.value?.let { mAddFileDialogInterface?.replaceSelectedPanelFragment(it) } } } setPanelMaxHeight() } private fun initData() { initToolBar() resetStatisticsView() val fragments = childFragmentManager.fragments for (i in mTabTitles.indices) { initFragment(i, fragments) } initViewPager() if (PrivacyPolicyController.hasAgreePrivacy()) { loadData() } else { PrivacyPolicyController.bindPrivacyPolicyListener(this) } } private fun loadData() { Log.d(TAG, "loadData") showLoadingView(true) // 加载数据 activity?.let { addFilePanelViewModel?.loadAllFiles(it) } } private fun resetStatisticsView() { addFileBtn?.isEnabled = false statisticsSelectTitleView?.apply { visibility = View.VISIBLE text = appContext.resources.getString(com.filemanager.common.R.string.not_selected_file) } statisticsSelectBodyView?.apply { visibility = View.GONE text = "" } statisticsArrowUpView?.apply { visibility = View.GONE } statisticsRootView?.apply { isClickable = false } } private fun initToolBar() { toolBar?.apply { visibility = View.VISIBLE title = appContext.resources.getString(com.filemanager.common.R.string.encryption_file_select_title) isTitleCenterStyle = true inflateMenu(R.menu.add_file_panel_dialog_menu_bar) menu.findItem(R.id.cancel).setOnMenuItemClickListener { mAddFileDialogInterface?.dismissAddFileDialog() true } } if ((mTabView != null) && (mViewPager != null)) { mTabView?.let { it.setupWithViewPager(mViewPager, false) it.addOnTabSelectedListener(this) it.isUpdateindicatorposition = true } } } fun setAddFileDialogInterface(addFileDialog: AddFileDialogInterface) { mAddFileDialogInterface = addFileDialog } private fun initViewPager() { mViewPager?.let { it.offscreenPageLimit = mTabTitles.size it.adapter = ViewPagerFragmentStateAdapter(this) it.currentItem = 0 it.overScrollMode = View.OVER_SCROLL_NEVER } } private fun initFragment(position: Int, fragments: List<Fragment>): Fragment { Log.d(TAG, "initFragment") var fragment = if (fragments.isEmpty()) null else fragments[position] if (fragment == null) { fragment = AddFileListFragment() fragment.isExternalReference = isExternalReference fragment.lifeCycle = lifeCycle fragment.mOnItemSelectCallback = { _, fileID -> mSelectArrayByID.add(fileID) addFilePanelViewModel?.updateAllSelectFiles(mSelectArrayByID) } fragment.mOnItemUnSelectCallback = { _, fileID -> mSelectArrayByID.remove(fileID) addFilePanelViewModel?.updateAllSelectFiles(mSelectArrayByID) } fragment.mFileEmptyCallback = { isShowEmpty -> handleFileEmptyView(isShowEmpty) } fragment.mCurrentPath = mCurrentPath fragment.limitCount = limitCount } if (fragment is AddFileListFragment) { mPages.add(fragment) } return fragment } private fun handleFileEmptyView(isShowEmptyView: Boolean) { if (isShowEmptyView) { showLoadingView(false) buttonDivider?.alpha = 0F } else { buttonDivider?.alpha = 1F } } private fun showLoadingView(isShow: Boolean) { if (isShow) { Log.d(TAG, "showLoadingView disMissTime $disMissTime") activity?.let { loadingController = LoadingController(it, this) //如果异常loadingView刚刚调用,就不调用显示loadingView,防止loadingView一直显示 Log.d(TAG, "showLoadingView getLoadingShowStartTime ${loadingController?.getLoadingShowStartTime()}") if (SystemClock.elapsedRealtime() - disMissTime >= DISMISS_TIME_GAP || loadingController?.getLoadingShowStartTime() == 0L) { loadingController?.showLoading(contentLayout) } } } else { Log.d(TAG, "showLoadingView dismissLoading") loadingController?.dismissLoading() disMissTime = SystemClock.elapsedRealtime() } } override fun onShow(isShowOnFirstPanel: Boolean?) { super.onShow(isShowOnFirstPanel) ((parentFragment as? COUIBottomSheetDialogFragment)?.dialog as? COUIBottomSheetDialog) ?.setPanelBackgroundTintColor(COUIContextUtil.getAttrColor(context, com.support.appcompat.R.attr.couiColorBackgroundElevatedWithCard)) } private fun clearDataList() { allFiles.clear() imageFiles.clear() videoFiles.clear() audioFiles.clear() docFiles.clear() otherFiles.clear() mSelectArrayByID.clear() } private fun setViewPagerData(data: MutableList<AddFileBean>) { Log.d(TAG, "setViewPagerData") clearDataList() allFiles.addAll(data) val documentExtensionType = Injector.injectFactory<IDocumentExtensionType>() data.forEach { file -> val localType = file.mLocalType if (MimeTypeHelper.isImageType(localType)) { imageFiles.add(file) } else if (MimeTypeHelper.isVideoType(localType)) { videoFiles.add(file) } else if (MimeTypeHelper.isAudioType(localType)) { audioFiles.add(file) } else if ((MimeTypeHelper.isDocType(localType) || MimeTypeHelper.isOtherDocType(localType)) && documentExtensionType?.isCategoryDocSupportType(file) == true ) { //isCategoryDocSupportType确保面板上文档分类显示的内容和文管文档分类一致(部分未知格式后缀会识解析TXT类型) docFiles.add(file) } else { otherFiles.add(file) } } updateData(mTabTitles[mPosition]) } private fun updateStatisticsSelectView(data: MutableList<AddFileBean>) { if (data.size <= 0) { Log.d(TAG, "unselect file") resetStatisticsView() return } addFileBtn?.isEnabled = true statisticsSelectTitleView?.apply { visibility = View.VISIBLE text = appContext.resources.getQuantityString( com.filemanager.common.R.plurals.mark_selected_items_new, data.size, data.size ) } statisticsSelectBodyView?.apply { visibility = View.VISIBLE val size: Long = data.sumOf { it.mSize } val totalSize: String = updateSizeFormat(size) text = appContext.resources.getString(com.filemanager.common.R.string.selected_size, totalSize) } statisticsArrowUpView?.apply { visibility = View.VISIBLE } statisticsRootView?.apply { isClickable = true } } @SuppressLint("DefaultLocale") fun updateSizeFormat(size: Long): String { return if (size < NUM) { size.toString() + "B" } else if (size < NUM * NUM) { String.format("%.1f", (size / DECIMAL_NUM)) + " KB" } else if (size < NUM * NUM * NUM) { String.format("%.1f", (size / NUM / DECIMAL_NUM)) + " MB" } else { String.format("%.1f", (size / NUM / NUM / DECIMAL_NUM)) + " GB" } } private fun updateData(fragmentType: Int) { val fragment = getCurrentFragment() fragment?.mFragmentType = fragmentType when (fragmentType) { Constants.TAB_ALL -> fragment?.setData(allFiles) Constants.TAB_IMAGE -> fragment?.setData(imageFiles) Constants.TAB_VIDEO -> fragment?.setData(videoFiles) Constants.TAB_AUDIO -> fragment?.setData(audioFiles) Constants.TAB_DOCUMENT -> fragment?.setData(docFiles) Constants.TAB_OTHER -> fragment?.setData(otherFiles) } fragment?.setSelectedData(mSelectArrayByID) Log.d( TAG, "allFiles=${allFiles.size}, imageFiles=${imageFiles.size}, videoFiles=${videoFiles.size}, " + "audioFiles=${audioFiles.size}, docFiles=${docFiles.size}, otherFiles=${otherFiles.size}" ) } override fun onTabSelected(tab: COUITab?) { Log.d(TAG, "onTabSelected tab.postion=${tab?.position}") tab?.let { mPosition = it.position if (loadingController?.isShowing() != true) { updateData(mTabTitles[mPosition]) } } } override fun onTabUnselected(p0: COUITab?) {} override fun onTabReselected(p0: COUITab?) {} private fun getCurrentFragment(): AddFileListFragment? { return if (mPosition < mPages.size) { mPages[mPosition] } else { null } } inner class ViewPagerFragmentStateAdapter(fragment: Fragment) : BaseFragmentAdapter(fragment) { override fun getItemCount(): Int { return mTabTitle.size } override fun createFragment(position: Int): Fragment { return mPages[position] } override fun getPageTitle(position: Int): CharSequence { return mTabTitle[position] } } override fun onAgreeResult(agree: Boolean, noLongerRemind: Boolean) { loadData() } } interface AddFileDialogInterface { fun dismissAddFileDialog() fun replaceSelectedPanelFragment(selectData: MutableList<AddFileBean>) }这是对应的kotlin代码,怎么处理
07-29
<think>我们正在解决一个关于自定义TabBar的问题,具体错误是'invalid page.json ["tabBar"]'错误。根据提供的引用,我们可以总结出配置自定义TabBar的关键步骤: 1. 在app.json(对于uni-app可能是pages.json)中,设置tabBar的custom属性为true。 2. 在list数组中,仍然需要列出所有tabBar对应的页面路径,即使我们使用自定义TabBar。 3. 创建自定义TabBar组件,通常放置在根目录下的`custom-tab-bar`文件夹中(微信小程序规范),并在页面中引入使用。 但是,用户遇到了'page.json'中的tabBar无效错误。注意,在uni-app中,配置文件通常是`pages.json`,而不是`page.json`。因此,首先需要确认配置文件名称是否正确。 根据引用[1]和[2]:需要在配置文件中(app.json或pages.json)的tabBar项中设置`custom: true`,同时list数组必须包含所有tabbar页面,且不能清空。 根据引用[3]:除了设置`custom: true`,还需要在相应页面的.json文件中声明usingComponents(如果是微信小程序原生开发,但uni-app中可能不需要这一步,因为uni-app有自己的组件机制)。 引用[4]提供了一个uni-app中自定义TabBar的示例,其中在组件的data中定义了list,这个list需要和pages.json中tabBar的list对应。 因此,错误可能是由于: - 配置文件名称错误:应该是`pages.json`而不是`page.json`(但用户错误写成了`page.json`,可能是笔误)。 - 在pages.json中tabBar的配置不正确,例如缺少必须的字段,或者list数组为空。 - 自定义TabBar组件的路径或配置不正确。 解决方案步骤: 1. **确认配置文件名称和位置**:在uni-app项目中,配置tabBar是在根目录下的`pages.json`文件中,而不是`page.json`。请检查文件名称是否正确。 2. **检查pages.json中tabBar配置**: - 确保设置了`"custom": true`。 - `list`数组不能为空,并且要包含自定义TabBar中所有的页面路径(即使你自定义TabBar,这里还是要写)。 - `list`数组中的每一项至少需要包含`pagePath`和`text`(根据引用[3]的示例,还有其他字段,但至少这两个必须有)。 3. **自定义TabBar组件的创建**: - 在根目录创建`custom-tab-bar`文件夹(微信小程序规范),然后在该文件夹中创建index.vue(uni-app中)或者微信小程序的四个组件文件(.js, .json, .wxml, .wxss)。 - 在自定义TabBar组件中,需要有一个与配置文件中`list`数组对应的列表,用于渲染TabBar项并处理切换事件。 4. **在页面中使用自定义TabBar**:确保在需要显示TabBar的页面的布局中,预留了TabBar的位置,并且自定义TabBar组件被正确引入和显示。 5. **注意uni-app和微信小程序原生的差异**:如果使用uni-app,自定义TabBar的方式与微信小程序原生类似,但要注意uni-app的项目结构。在uni-app中,我们通常使用vue文件来编写自定义TabBar组件。 错误'invalid page.json ["tabBar"]'通常意味着在`pages.json`中tabBar的配置有误。请检查: - 是否在`pages.json`中正确设置了tabBar?比如,tabBar是一个对象,包含`custom`和`list`等属性。 - `list`数组中的每一项是否都正确?例如,`pagePath`必须是存在的页面路径(在pages数组中已定义),且路径字符串正确。 以下是一个pages.json中tabBar配置的示例(参考引用[3]): ```json { "pages": [ // ... 页面列表 ], "tabBar": { "custom": true, "list": [ { "pagePath": "pages/index/index", "text": "首页", "iconPath": "/static/tabbar/index.png", "selectedIconPath": "/static/tabbar/index_selected.png" }, { "pagePath": "pages/mine/index", "text": "我的" } ] } } ``` 注意:即使我们使用自定义TabBar,这里的`list`也不能省略,因为框架需要知道哪些页面是tabBar页面。 另外,自定义组件部分,参考引用[4]的代码,我们在`custom-tab-bar`组件中定义了一个类似的list,用于渲染和切换。注意,这个list中的pagePath要和pages.json中的list中的pagePath一致。 最后,请确保自定义TabBar组件的路径正确。在微信小程序中,自定义TabBar组件必须放在`custom-tab-bar`目录下,且其组件名为“index”。在uni-app中,也是放置在`custom-tab-bar`目录下,但使用vue文件。 如果按照以上步骤检查配置,应该能解决'invalid page.json ["tabBar"]'错误。 相关问题: 1. 如何在uni-app中正确配置自定义TabBar? 2. 自定义TabBar时,为什么需要在pages.json的tabBar中保留list数组? 3. 自定义TabBar组件需要实现哪些功能? </think>### 解决 `custom-tab-bar` 出现 "invalid page.json ["tabBar"]" 错误的方案 根据引用内容分析,该错误通常由 **TabBar 配置不符合规范** 引起。以下是关键解决步骤: #### 1. 验证 `pages.json` 的 TabBar 配置(核心) 在配置文件中必须同时满足: - ✅ 设置 `"custom": true` 开启自定义模式 - ✅ **保留完整的 `list` 数组**(即使使用自定义组件) - ✅ 确保 `list` 中的 `pagePath` 与自定义组件的路径一致 ```json // pages.json 正确配置示例 { "tabBar": { "custom": true, // 必须开启 "list": [ // 必须保留且不能为空 { "pagePath": "pages/index/index", // 需与实际页面路径一致 "text": "首页", "iconPath": "/static/tabbar/home.png", "selectedIconPath": "/static/tabbar/home-active.png" }, { "pagePath": "pages/user/user", // 需与自定义组件配置匹配 "text": "我的" } ] } } ``` > **关键点**:引用[1][2][3]均强调 `list` 数组必须保留且包含所有 TabBar 页面路径,即使使用自定义组件[^1][^2][^3]。 #### 2. 检查自定义组件结构 在 `custom-tab-bar` 目录中的组件需包含完整路径映射: ```javascript // custom-tab-bar/index.js 配置示例 (引用[4]) Component({ data: { list: [ { pagePath: "/pages/index/index", // 必须与 pages.json 的 list 完全一致 text: "首页" }, { pagePath: "/pages/user/user", text: "我的" } ] }, methods: { switchTab(e) { wx.switchTab({ url: e.currentTarget.dataset.path }) } } }) ``` #### 3. 常见错误排查 | 错误原因 | 解决方案 | |--------------------------|----------------------------| | `list` 数组被清空或缺失 | 补充完整 list 数组 | | `pagePath` 路径大小写不匹配 | 检查路径拼写(小程序强制区分大小写)| | 缺少 `custom: true` 声明 | 在 tabBar 对象中添加该属性 | | 页面路径未在 `pages` 注册 | 在 pages.json 的 `pages` 数组中声明路径 | #### 4. 文件结构验证 确保目录结构符合规范: ``` 根目录/ ├── custom-tab-bar/ # 自定义组件目录(名称固定) │ ├── index.js # 组件逻辑 │ ├── index.json # 组件配置 │ ├── index.wxml # 模板 │ └── index.wxss # 样式 └── pages.json # 配置文件 ``` > **注意**:uni-app 中所有路径均基于项目根目录,避免使用相对路径 `../`(引用[4])[^4]。 ### 相关问题 1. 如何在自定义 TabBar 中实现图标闪烁动画效果? 2. 自定义 TabBar 如何同步显示页面加载状态? 3. TabBar 自定义组件与普通组件的通信机制有何不同? [^1]: 根目录创建 custom-tab-bar 文件,并在page.json文件里面tabbar设置项中添加 custom 属性,并设置为 true,list数组不要清空 [^2]: 自定义 tabBar 中包含的页面,在 list 页面路径也必须得有 [^3]: 在 app.json 文件中配置 custom 属性,并在相应页面的 .json 文件中声明 usingComponents [^4]: uniapp 开发微信小程序中使用 custom-tab-bar 创建自定义 tabbar
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值