diff --git a/backend/tests/integration/test_cloud_auth.py b/backend/tests/integration/test_cloud_auth.py index 96fe4cb17..010b6b8dc 100644 --- a/backend/tests/integration/test_cloud_auth.py +++ b/backend/tests/integration/test_cloud_auth.py @@ -6,7 +6,7 @@ Regression tests for: - Cloud endpoints use CLOUD_AUTH permission (not SETTINGS_READ) """ -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest from httpx import AsyncClient @@ -484,88 +484,100 @@ class TestCloudRouteRegionPlumbing: """ @staticmethod - def _make_response(json_body: dict, status: int = 200): - """Build a MagicMock httpx.Response stand-in for patched posts/gets.""" - response = MagicMock() - response.status_code = status - response.text = "{}" - response.json.return_value = json_body - response.cookies = {} - return response + def _capturing_client(response_json: dict, status: int = 200): + """Build an httpx.AsyncClient backed by MockTransport that records every + outbound request URL. Returns ``(client, captured_urls)``. + + Using MockTransport (rather than ``patch.object(httpx.AsyncClient, ...)``) + is critical: class-level method patches also intercept the ASGI test + client's own requests, so the route handler never runs and the + assertions end up inspecting the test-client URL instead of the + backend's outbound URL. MockTransport only affects the client we + inject into the backend via ``set_shared_http_client``. + """ + import httpx + + captured: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(str(request.url)) + return httpx.Response(status, json=response_json) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + return client, captured @pytest.mark.asyncio @pytest.mark.integration async def test_set_token_route_with_china_region_hits_cn_endpoint(self, async_client: AsyncClient): """POST /cloud/token with region=china routes get_user_profile to api.bambulab.cn.""" - import httpx + from backend.app.services.bambu_cloud import set_shared_http_client - with ( - patch("backend.app.core.auth.is_auth_enabled", return_value=False), - patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get, - ): - mock_get.return_value = self._make_response({"uid": "123", "email": "x"}) + mock_client, captured_urls = self._capturing_client({"uid": "123", "email": "x"}) + set_shared_http_client(mock_client) + try: + with patch("backend.app.core.auth.is_auth_enabled", return_value=False): + response = await async_client.post( + "/api/v1/cloud/token", + json={"access_token": "cn-token", "region": "china"}, + ) - response = await async_client.post( - "/api/v1/cloud/token", - json={"access_token": "cn-token", "region": "china"}, - ) - - assert response.status_code == 200 - # The profile check call must have hit api.bambulab.cn, never .com - called_urls = [str(call.args[0]) for call in mock_get.call_args_list if call.args] - assert any("api.bambulab.cn" in url for url in called_urls), called_urls - assert not any("api.bambulab.com" in url for url in called_urls), called_urls + assert response.status_code == 200 + assert any("api.bambulab.cn" in url for url in captured_urls), captured_urls + assert not any("api.bambulab.com" in url for url in captured_urls), captured_urls + finally: + set_shared_http_client(None) + await mock_client.aclose() @pytest.mark.asyncio @pytest.mark.integration async def test_login_route_with_china_region_hits_cn_endpoint(self, async_client: AsyncClient): """POST /cloud/login with region=china routes login_request to api.bambulab.cn.""" - import httpx + from backend.app.services.bambu_cloud import set_shared_http_client - with ( - patch("backend.app.core.auth.is_auth_enabled", return_value=False), - patch.object(httpx.AsyncClient, "post", new_callable=AsyncMock) as mock_post, - ): - mock_post.return_value = self._make_response({"loginType": "verifyCode"}) + mock_client, captured_urls = self._capturing_client({"loginType": "verifyCode"}) + set_shared_http_client(mock_client) + try: + with patch("backend.app.core.auth.is_auth_enabled", return_value=False): + response = await async_client.post( + "/api/v1/cloud/login", + json={"email": "user@example.com", "password": "x", "region": "china"}, + ) - response = await async_client.post( - "/api/v1/cloud/login", - json={"email": "user@example.com", "password": "x", "region": "china"}, - ) - - assert response.status_code == 200 - called_urls = [str(call.args[0]) for call in mock_post.call_args_list if call.args] - assert any("api.bambulab.cn" in url for url in called_urls), called_urls - assert not any("api.bambulab.com" in url for url in called_urls), called_urls + assert response.status_code == 200 + assert any("api.bambulab.cn" in url for url in captured_urls), captured_urls + assert not any("api.bambulab.com" in url for url in captured_urls), captured_urls + finally: + set_shared_http_client(None) + await mock_client.aclose() @pytest.mark.asyncio @pytest.mark.integration async def test_verify_route_with_china_region_hits_cn_tfa_endpoint(self, async_client: AsyncClient): """POST /cloud/verify with region=china + tfa_key routes TOTP to bambulab.cn.""" - import httpx + from backend.app.services.bambu_cloud import set_shared_http_client - with ( - patch("backend.app.core.auth.is_auth_enabled", return_value=False), - patch.object(httpx.AsyncClient, "post", new_callable=AsyncMock) as mock_post, - ): - mock_post.return_value = self._make_response({"token": "t"}) + mock_client, captured_urls = self._capturing_client({"token": "t"}) + set_shared_http_client(mock_client) + try: + with patch("backend.app.core.auth.is_auth_enabled", return_value=False): + response = await async_client.post( + "/api/v1/cloud/verify", + json={ + "email": "user@example.com", + "code": "123456", + "tfa_key": "tfa-xyz", + "region": "china", + }, + ) - response = await async_client.post( - "/api/v1/cloud/verify", - json={ - "email": "user@example.com", - "code": "123456", - "tfa_key": "tfa-xyz", - "region": "china", - }, - ) - - assert response.status_code == 200 - called_urls = [str(call.args[0]) for call in mock_post.call_args_list if call.args] - # TOTP endpoint lives on bambulab.cn (without the api. prefix), - # NOT bambulab.com — that's exactly the bug we just fixed. - assert any("bambulab.cn/api/sign-in/tfa" in url for url in called_urls), called_urls - assert not any("bambulab.com" in url for url in called_urls), called_urls + assert response.status_code == 200 + # TOTP endpoint lives on bambulab.cn (without the api. prefix), + # NOT bambulab.com — that's exactly the bug we just fixed. + assert any("bambulab.cn/api/sign-in/tfa" in url for url in captured_urls), captured_urls + assert not any("bambulab.com" in url for url in captured_urls), captured_urls + finally: + set_shared_http_client(None) + await mock_client.aclose() @pytest.mark.asyncio @pytest.mark.integration diff --git a/static/assets/index-pzZiBCy1.js b/static/assets/index-DvmJH2YP.js similarity index 90% rename from static/assets/index-pzZiBCy1.js rename to static/assets/index-DvmJH2YP.js index ecc687831..ee5d2ed85 100644 --- a/static/assets/index-pzZiBCy1.js +++ b/static/assets/index-DvmJH2YP.js @@ -29,7 +29,7 @@ Valor ON: a string exata que significa "ON". Deixe vazio para detecção automá 乘数:Wh→kWh使用0.001,MWh→kWh使用1000。`,mqttStateHint:`JSON路径从JSON负载中提取值。原始值请留空。 ON值:表示"ON"的确切字符串。留空以自动检测(ON、true、1)。`,restControl:"Control",restOnUrl:"Turn ON URL",restOffUrl:"Turn OFF URL",restOnBody:"ON Request Body",restOffBody:"OFF Request Body",restMethod:"HTTP Method",restHeaders:"Custom Headers (JSON)",restStatusUrl:"Status URL",restStatusPath:"State JSON Path",restStatusOnValue:"ON Value",restPowerUrl:"功率URL",restPowerPath:"Power JSON Path",restPowerMultiplier:"功率乘数",restEnergyUrl:"能耗URL",restEnergyPath:"Energy JSON Path",restEnergyMultiplier:"能耗乘数",restUrlRequired:"At least one URL (ON or OFF) is required for REST plugs",restHeadersHint:'e.g. {"Authorization": "Bearer your-token"}',restBodyHint:'e.g. ON, {"state": "on"}',restStatusHint:"URL to poll for current state",restPathHint:"e.g. state or data.power.status",restPowerUrlHint:"功率数据的独立URL(留空则使用状态URL)",restEnergyUrlHint:"能耗数据的独立URL(留空则使用状态URL)",restEnergyHint:"每个值可以使用独立的URL,或回退到状态URL。使用乘数进行单位转换(例如:0.001 将 Wh 转换为 kWh)。",testConnection:"Test Connection",connectionSuccess:"Connection successful",noSwitchesInSwitchbar:"开关栏中没有开关",enableSwitchbarHint:'在设置 > 智能插座中启用"在开关栏显示"'},notifications:{providerTypes:{callmebot:"CallMeBot/WhatsApp",ntfy:"ntfy",pushover:"Pushover",telegram:"Telegram",email:"电子邮件",discord:"Discord",webhook:"Webhook",homeassistant:"Home Assistant"},providerDescriptions:{email:"SMTP 电子邮件通知",telegram:"通过 Telegram 机器人发送通知",discord:"通过 Webhook 发送到 Discord 频道",ntfy:"免费、可自托管的推送通知",pushover:"简单、可靠的推送通知",callmebot:"通过 CallMeBot 免费发送 WhatsApp 通知",webhook:"通用 HTTP POST 到任意 URL",homeassistant:"Home Assistant 仪表板中的持久通知"},lastSuccess:"上次:{{date}}",error:"错误",printer:"打印机:",allPrinters:"所有打印机",sendTestNotification:"发送测试通知",eventSettings:"事件设置",enabled:"已启用",sendFromProvider:"从此提供商发送通知",printEvents:"打印事件",printerStatus:"打印机状态",amsAlarms:"AMS 警报",amsHtAlarms:"AMS-HT 警报",printQueue:"打印队列",start:"开始",plateCheck:"热床检测",complete:"完成",failed:"失败",stopped:"已停止",progress:"进度",offline:"离线",lowFilament:"耗材不足",maintenance:"维护",amsHumidity:"AMS 湿度",amsTemp:"AMS 温度",amsHtHumidity:"AMS-HT 湿度",amsHtTemp:"AMS-HT 温度",bedCooled:"热床已冷却",firstLayer:"首层完成",quiet:"免打扰",digest:"摘要 {{time}}",printStarted:"打印已开始",plateNotEmpty:"热床非空",plateNotEmptyDescription:"打印前检测到物体",printCompleted:"打印已完成",bedCooledLabel:"热床已冷却",bedCooledDescription:"打印后热床温度降至阈值以下",firstLayerCompleteLabel:"首层打印完成",firstLayerCompleteDescription:"首层完成时发送带照片的通知",missingSpoolAssignmentLabel:"缺少料卷分配",missingSpoolAssignmentDescription:"当打印开始且所需料盘没有分配料卷时发送通知",printFailed:"打印失败",printStopped:"打印已停止",progressMilestones:"进度里程碑",progressMilestonesDescription:"在 25%、50%、75% 时通知",printerOffline:"打印机离线",printerError:"打印机错误",lowFilamentLabel:"耗材不足",maintenanceDue:"需要维护",maintenanceDueDescription:"需要维护时通知",amsHumidityHigh:"AMS 湿度过高",amsHumidityHighDescription:"普通 AMS 湿度超过阈值",amsTemperatureHigh:"AMS 温度过高",amsTemperatureHighDescription:"普通 AMS 温度超过阈值",amsHtHumidityHigh:"AMS-HT 湿度过高",amsHtHumidityHighDescription:"AMS-HT 湿度超过阈值",amsHtTemperatureHigh:"AMS-HT 温度过高",amsHtTemperatureHighDescription:"AMS-HT 温度超过阈值",jobAdded:"任务已添加",jobAddedDescription:"任务已添加到队列",jobAssigned:"任务已分配",jobAssignedDescription:"基于模型的任务已分配给打印机",jobStarted:"任务已开始",jobStartedDescription:"队列任务已开始打印",jobWaiting:"任务等待中",jobWaitingDescription:"任务正在等待耗材或打印机",jobSkipped:"任务已跳过",jobSkippedDescription:"任务已跳过(上一个失败)",jobFailed:"任务失败",jobFailedDescription:"任务启动失败",queueComplete:"队列已完成",queueCompleteDescription:"所有队列任务已完成",quietHours:"免打扰时段",noNotificationsDuring:"在此时段内不发送通知",editProviderToChangeQuietHours:"编辑提供商以更改免打扰时段",dailyDigest:"每日摘要",batchNotifications:"将通知汇总为每日摘要",sendAt:"发送于 {{time}}",editProviderToChangeDigestTime:"编辑提供商以更改摘要时间",edit:"编辑",deleteProvider:"删除通知提供商",deleteConfirm:'确定要删除"{{name}}"吗?此操作无法撤销。',delete:"删除",addTitle:"添加通知提供商",editTitle:"编辑通知提供商",nameLabel:"名称 *",namePlaceholder:"我的通知",providerTypeLabel:"提供商类型 *",configuration:"配置",testConfiguration:"测试配置",printerFilter:"打印机筛选",onlyFromPrinter:"仅发送来自此打印机的事件通知",quietHoursDnd:"免打扰时段",quietStart:"开始",quietEnd:"结束",dailyDigestLabel:"每日摘要",sendDigestAt:"发送摘要于",digestCollected:"事件将被收集并在此时间作为单条摘要发送",notificationEvents:"通知事件",progressPercent:"(25%、50%、75%)",bedCooledAfterPrint:"(打印完成后)",cancel:"取消",save:"保存",add:"添加",nameRequired:"名称为必填项",fieldRequired:"{{field}}为必填项",phoneNumber:"电话号码",apiKey:"API 密钥",serverUrl:"服务器 URL",topic:"主题",authToken:"认证令牌",userKey:"用户密钥",appToken:"应用令牌",priority:"优先级",botToken:"机器人令牌",chatId:"聊天 ID",smtpServer:"SMTP 服务器",smtpPort:"SMTP 端口",security:"安全",authentication:"认证",username:"用户名",password:"密码",fromEmail:"发件人邮箱",toEmail:"收件人邮箱",webhookUrl:"Webhook URL",payloadFormat:"负载格式",authorization:"授权",titleFieldName:"标题字段名",messageFieldName:"消息字段名",editTemplate:"编辑模板:{{name}}",titleLabel:"标题",bodyLabel:"正文",titlePlaceholder:"通知标题...",bodyPlaceholder:"通知正文...",availableVariables:"可用变量",clickToInsert:"点击插入到正文光标位置",livePreview:"实时预览",hide:"隐藏",show:"显示",loadingPreview:"加载预览中...",enterTemplateContent:"输入模板内容以查看预览",titlePreview:"标题:",bodyPreview:"正文:",resetToDefault:"恢复默认",titleRequired:"标题为必填项",bodyRequired:"正文为必填项",notificationLog:"通知日志",showFailedOnly:"仅显示失败",last24Hours:"最近 24 小时",last7Days:"最近 7 天",last30Days:"最近 30 天",last90Days:"最近 90 天",justNow:"刚刚",noFailedNotifications:"没有失败的通知",noNotificationsLogged:"没有通知记录",unknownProvider:"未知提供商",logTitle:"标题",logMessage:"消息",logError:"错误",logProvider:"提供商:{{type}}",logTime:"时间:{{time}}",refresh:"刷新",clearOld:"清除旧记录",statsSummary:"最近 {{days}} 天:",statsNotifications:"条通知",statsSent:"{{count}} 条已发送",statsFailed:"{{count}} 条失败",eventTypes:{print_start:"打印已开始",print_complete:"打印完成",print_failed:"打印失败",print_stopped:"打印已停止",print_progress:"进度",printer_offline:"打印机离线",printer_error:"打印机错误",filament_low:"耗材不足",maintenance_due:"需要维护",test:"测试"},userEmail:{title:"通知",emailNotifications:"邮件通知",emailNotificationsDesc:"接收您自己打印任务的邮件通知。邮件将通过高级身份验证中配置的 SMTP 设置发送。",sendingTo:"通知将发送至",noEmailWarning:"您的账户没有邮件地址。请联系管理员添加。",printJobNotifications:"打印任务通知",printJobNotificationsDesc:"选择哪些事件会触发您提交的打印任务的邮件通知。",printJobStarts:"打印任务开始",printJobStartsDesc:"当您的打印任务开始时收到通知。",printJobFinishes:"打印任务完成",printJobFinishesDesc:"当您的打印任务成功完成时收到通知。",printErrors:"打印错误",printErrorsDesc:"当您的打印任务失败或遇到错误时收到通知。",printJobStops:"打印任务停止",printJobStopsDesc:"当您的打印任务被取消或停止时收到通知。",saveSuccess:"通知偏好设置已保存。",saveError:"保存通知偏好设置失败。"}},richTextEditor:{bold:"粗体",italic:"斜体",underline:"下划线",bulletList:"无序列表",numberedList:"有序列表",alignLeft:"左对齐",alignCenter:"居中对齐",alignRight:"右对齐",addLink:"添加链接",removeLink:"移除链接"},externalLinks:{noLinksConfigured:"未配置外部链接",deleteLink:"删除链接",removeCustomIcon:"移除自定义图标",openInNewTab:"在新标签页中打开",placeholders:{linkName:"我的链接"}},keyboardShortcuts:{title:"键盘快捷键",navigation:"导航",archivesSection:"归档",kProfilesSection:"K 值配置",generalSection:"通用",shortcuts:{goToPrinters:"前往打印机",goToArchives:"前往归档",goToQueue:"前往队列",goToStats:"前往统计",goToProfiles:"前往云端配置",goToSettings:"前往设置",focusSearch:"聚焦搜索",openUploadModal:"打开上传对话框",clearSelection:"清除选择 / 取消焦点",contextMenu:"卡片右键菜单",refreshProfiles:"刷新配置",newProfile:"新建配置",exitSelectionMode:"退出选择模式",showHelp:"显示此帮助"},footer:"按 Esc 或点击外部关闭"},notificationLog:{title:"通知日志",events:{printStarted:"打印开始",printComplete:"打印完成",printFailed:"打印失败",printStopped:"打印停止",progress:"进度",printerOffline:"打印机离线",printerError:"打印机错误",lowFilament:"耗材不足",maintenanceDue:"维护到期",test:"测试"},timeAgo:{justNow:"刚刚",minutesAgo:"{{minutes}} 分钟前",hoursAgo:"{{hours}} 小时前"}},restoreBackup:{title:"恢复备份",restoring:"恢复中...",restoreComplete:"恢复完成",restoreFailed:"恢复失败",importSettings:"从备份文件导入设置",pleaseWait:"请稍候,正在恢复您的数据",clickToSelect:"点击选择备份文件(.json 或 .zip)",howDuplicateHandling:"重复处理方式:",categories:{printers:"打印机",smartPlugs:"智能插座",notificationProviders:"通知提供商",filaments:"耗材",archives:"归档",pendingUploads:"待处理上传",settingsTemplates:"设置和模板"},matchingInfo:{printers:"按序列号匹配",smartPlugs:"按 IP 地址匹配",notificationProviders:"按名称匹配",filaments:"按名称 + 类型 + 品牌匹配",archives:"按内容哈希匹配",pendingUploads:"按文件名匹配",settingsTemplates:"始终覆盖"},replaceExisting:"替换现有数据",keepExisting:"保留现有数据",replaceDescription:"用备份数据覆盖已存在的项目",keepDescription:"仅恢复不存在的项目",caution:"注意:",cautionText:"覆盖将用备份数据替换您当前的配置。出于安全考虑,打印机访问码永远不会被覆盖。",itemsRestored:"已恢复项目",itemsSkipped:"已跳过项目",restored:"已恢复",skipped:"已跳过(已存在)",filesLabel:"文件(3MF、缩略图等)",newApiKeysGenerated:"已生成新 API 密钥",newApiKeysWarning:"这些密钥仅显示一次。请立即复制!",processingBackup:"处理备份文件中...",noDataFound:"备份文件中未找到可恢复的数据。",failedToRestore:"恢复备份失败。请检查文件格式。"},backupExport:{title:"导出备份",selectData:"选择要包含的数据",selectAll:"全选",selectNone:"全不选",categoryDescriptions:{settings:"语言、主题、更新偏好",notifications:"ntfy、Pushover、Discord 等",templates:"自定义消息模板",smartPlugs:"Tasmota 插座配置",externalLinks:"侧边栏外部服务链接",printers:"打印机信息(不含访问码)",plateDetection:"空打印板参考图像",filaments:"耗材类型和成本",maintenance:"自定义维护计划",archives:"所有打印数据 + 文件(3MF、缩略图、照片)",projects:"项目、材料清单和附件",pendingUploads:"虚拟打印机待审核的上传",apiKeys:"Webhook API 密钥(导入时生成新密钥)"},requiresPrinters:"需要选择打印机",zipFileWarning:"将创建 ZIP 文件。",zipFileDescription:"包括所有 3MF 文件、缩略图、延时摄影和照片。这可能需要一些时间并生成较大的文件。",includeAccessCodes:"包含访问码",includeAccessCodesDescription:"用于转移到另一台机器",includeAccessCodesWarning:"访问码将以明文形式包含。请妥善保管此备份文件!",categoriesSelected:"已选择 {{selectedCount}} 个类别"},pendingUploads:{placeholders:{notes:"添加关于此打印的备注..."},discardUpload:"丢弃上传",archiveAllUploads:"归档所有上传",discardAllUploads:"丢弃所有上传",archive:"归档",timeAgo:{justNow:"刚刚",minutesAgo:"{{minutes}} 分钟前",hoursAgo:"{{hours}} 小时前",daysAgo:"{{days}} 天前"}},apiBrowser:{placeholders:{requestBody:"JSON 请求体...",searchEndpoints:"搜索端点..."}},configureAmsSlot:{title:"配置 AMS 槽位",slotConfigured:"槽位已配置!",configuringSlot:"正在配置槽位:",slotLabel:"{{ams}} 槽位 {{slot}}",searchPresets:"搜索预设...",colorPlaceholder:"颜色名称或十六进制(例如:棕色、FF8800)",clearCustomColor:"清除自定义颜色",noCloudPresets:"无云端预设。登录拓竹云以同步。",noPresetsAvailable:"无可用预设。登录拓竹云或导入本地配置。",noMatchingPresets:"未找到匹配的预设。",custom:"自定义",builtin:"内置",settingsSentToPrinter:"设置已发送到打印机",filamentProfile:"耗材配置",kProfileLabel:"K 值配置(压力推进)",filteringFor:"筛选:{{material}}",noKProfile:"无 K 值配置(使用默认值 0.020)",noMatchingKProfiles:"未找到匹配的 K 值配置。将使用默认 K=0.020。",selectFilamentFirst:"请先选择耗材配置",kFromCalibration:"K={{value}}(来自打印机校准)",customColorLabel:"自定义颜色(可选)",presetColors:"{{name}} 颜色:",showLessColors:"显示更少颜色",showMoreColors:"显示更多颜色",clear:"清除",hexLabel:"十六进制:#{{hex}}",resetting:"重置中...",resetSlot:"重置槽位",cancel:"取消",configuring:"配置中...",configureSlot:"配置槽位"},githubBackup:{title:"GitHub 备份",history:"历史",downloadBackup:"下载备份",restoreBackup:"恢复备份",noBackupsYet:"暂无备份"},emailSettings:{placeholders:{fromName:"BamBuddy"}},tagManagement:{searchTags:"搜索标签...",renameTag:"重命名标签",deleteTag:"删除标签"},notificationTemplates:{placeholders:{title:"通知标题...",body:"通知正文..."}},batchTag:{placeholders:{newTag:"输入新标签..."}},photoGallery:{deletePhoto:"删除照片"},filamentHoverCard:{copySpoolUuid:"复制耗材 UUID"},kProfilesView:{hasNote:"有备注",copyProfile:"复制配置"},layout:{openMenu:"打开菜单",noPermissionSystemInfo:"您没有查看系统信息的权限"},dashboard:{dragToReorder:"拖动以重新排列",hideWidget:"隐藏小部件"},notificationProviderCard:{deleteNotificationProvider:"删除通知提供商"},fileManagerModal:{closeFileManager:"关闭文件管理器",sortFiles:"排序文件",goToParentFolder:"返回上级文件夹",threeView:"3D 视图"},embeddedCameraViewer:{refreshStream:"刷新流",close:"关闭",zoomOut:"缩小",resetZoom:"重置缩放",zoomIn:"放大",dragToResize:"拖动调整大小"},timelapseViewer:{skipBack5s:"后退 5 秒",skipForward5s:"前进 5 秒"},notificationProviders:{descriptions:{email:"SMTP 邮件通知",telegram:"通过 Telegram 机器人通知",discord:"通过 Webhook 发送到 Discord 频道",ntfy:"免费、可自托管的推送通知",pushover:"简单、可靠的推送通知",callmebot:"通过 CallMeBot 的免费 WhatsApp 通知",webhook:"通用 HTTP POST 到任意 URL"}},logViewer:{searchPlaceholder:"搜索消息或日志名称...",noLogEntries:"未找到日志条目"},switchbarPopover:{noSwitchesInSwitchbar:"切换栏中没有开关"},projectPageModal:{placeholders:{title:"标题",designer:"设计师",license:"许可证",description:"输入描述...",profileTitle:"配置标题",profileDescription:"配置描述..."}},spoolmanSettings:{},time:{unknown:"-",waiting:"等待中",justNow:"刚刚",now:"现在",minsAgo:"{{count}} 分钟前",inMins:"{{count}} 分钟后",hoursAgo:"{{count}} 小时前",inHours:"{{count}} 小时后",daysAgo:"{{count}} 天前",inDays:"{{count}} 天后"},spoolbuddy:{nav:{dashboard:"仪表板",ams:"AMS",inventory:"库存",writeTag:"写入",settings:"设置"},status:{nfcReady:"NFC 就绪",nfcOff:"NFC 关闭",offline:"离线",online:"在线",noPrinters:"无打印机",deviceOffline:"设备离线",waitingConnection:"等待设备连接...",systemReady:"系统就绪",status:"状态"},dashboard:{readyToScan:"准备扫描",idleMessage:"将耗材放在秤上以识别",nfcHint:"NFC 标签将自动读取",device:"设备",syncWeight:"同步重量",weightSynced:"已同步!",unknownTag:"未知标签",newTag:"检测到新标签",onScale:"在秤上",linkSpool:"链接到耗材",linkTagTitle:"将标签链接到耗材",linkTag:"链接标签",selectSpool:"选择要链接此标签的耗材:",noUntagged:"未找到没有标签的耗材",tagDetected:"检测到标签",noTag:"无标签",tagId:"标签",grossWeight:"毛重",spoolSize:"耗材盘尺寸",close:"关闭",currentSpool:"当前耗材"},modal:{spoolDetected:"检测到耗材",assignToAms:"分配到 AMS",syncWeight:"同步重量",weightSynced:"已同步!",syncing:"同步中...",newTagDetected:"检测到新标签",addToInventory:"添加到库存",assignToAmsTitle:"分配到 AMS",selectSlot:"选择槽位",assign:"分配",assigning:"分配中...",assignSuccess:"已分配!",assignError:"分配耗材失败。请重试。",noPrinterSelected:"选择打印机...",noAmsDetected:"此打印机未检测到 AMS",slot:"槽位"},weight:{noReading:"无读数",stable:"稳定",measuring:"测量中...",tare:"去皮",calibrate:"校准"},spool:{remaining:"剩余",material:"材料",brand:"品牌",color:"颜色",coreWeight:"空盘",labelWeight:"标签",scaleWeight:"秤重",netWeight:"净重",lastUsed:"上次使用"},ams:{noData:"未检测到 AMS",connectAms:"连接 AMS 以查看耗材槽位",noPrinter:"未选择打印机",selectPrinter:"从顶部栏选择打印机",printerDisconnected:"打印机已断开",humidity:"湿度",level:"余量",active:"活跃",slot:"槽位",empty:"空"},inventory:{search:"搜索耗材...",empty:"库存中没有耗材",noResults:"没有匹配的耗材",spools:"个耗材",addSpool:"添加耗材"},settings:{tabDevice:"设备",tabDisplay:"显示",tabScale:"秤",tabUpdates:"更新",nfcReader:"NFC 读卡器",type:"类型",connection:"连接",notConnected:"不适用",deviceInfo:"设备信息",hostname:"主机",uptime:"运行时间",brightness:"亮度",saved:"已保存",noBacklight:"未检测到 DSI 背光。亮度控制需要 DSI 显示屏。",screenBlank:"屏幕熄灭超时",screenBlankDesc:"不活动后屏幕关闭。触摸唤醒。",displayNote:"亮度作为软件滤镜应用。",scaleCalibration:"秤校准",currentWeight:"当前重量",tareOffset:"去皮",calFactor:"系数",knownWeight:"已知重量",calStep1:"移除秤上所有物品并按设置零点。",calStep2:"将已知重量放在秤上。",setZero:"设置零点",calibrateNow:"校准",calibrated:"已校准",tareSet:"去皮命令已发送。等待设备响应...",tareFailed:"发送去皮命令失败",zeroSet:"零点已设置。将已知重量放在秤上。",calibrationDone:"校准完成!",calibrationFailed:"校准失败",lastCalibrated:"上次校准",stable:"稳定",settling:"稳定中...",firmware:"固件",scale:"秤",noDevice:"未找到 SpoolBuddy 设备",daemonVersion:"守护进程版本",currentVersion:"当前",versionPending:"等待守护进程...",checking:"检查中...",checkUpdates:"检查更新",updateAvailable:"有可用更新",updateInstructions:"通过 SSH 更新:运行 SpoolBuddy 安装脚本进行升级。",upToDate:"已是最新",includeBeta:"包含测试版本"},writeTag:{tabExisting:"现有耗材",tabNew:"新耗材",tabReplace:"替换标签",searchPlaceholder:"按材料、颜色、品牌搜索...",noUntaggedSpools:"没有无标签的耗材",noTaggedSpools:"没有有标签的耗材",selectSpool:"选择一个耗材,然后将空白 NTAG 放在读卡器上",placeTag:"将 NTAG 放在读卡器上",tagReady:"检测到标签 — 准备写入",writeTag:"写入标签",replaceTag:"替换标签",writing:"写入标签中...",waiting:"等待 SpoolBuddy...",writeSuccess:"标签写入成功!",writeFailed:"写入失败",queueFailed:"排队写入命令失败",tryAgain:"重试",cancel:"取消",replaceWarning:"旧标签将被取消链接。新标签将替换它。",deviceOffline:"SpoolBuddy 离线",material:"材料",colorName:"颜色名称",color:"颜色",brand:"品牌",weight:"重量 (g)",createSpool:"创建耗材",creating:"创建中...",spoolCreated:"耗材已创建!准备写入。",createFailed:"创建耗材失败"},quickMenu:{printerPower:"打印机电源",systemControls:"系统",restartDaemon:"重启守护进程",restartBrowser:"重启浏览器",reboot:"重启",shutdown:"关机",swipeToClose:"向下滑动关闭",confirmTitle:"确认",confirmShutdown:"确定要关闭SpoolBuddy吗?您需要物理访问才能重新开启。",confirmReboot:"确定要重启SpoolBuddy吗?",confirmRestartDaemon:"重启SpoolBuddy守护进程?NFC和秤将暂时不可用。",confirmRestartBrowser:"重启kiosk浏览器?屏幕将短暂变黑。",confirm:"确认",confirmPlugOn:"开启 {{name}}?",confirmPlugOff:"关闭 {{name}}?",turnOn:"开启",turnOff:"关闭"}},bugReport:{title:"报告错误",description:"描述",descriptionPlaceholder:"出了什么问题?请描述问题...",email:"邮箱(可选)",emailPlaceholder:"your@email.com",emailPrivacy:"如果提供,您的邮箱将包含在GitHub Issue的折叠部分中,以便维护者后续跟进。",screenshot:"截图",uploadOrPaste:"上传、粘贴或拖拽图片",dataCollectedSummary:"报告中包含哪些数据?",dataIncluded:"包含:",dataIncludedList:"应用版本、操作系统、架构、Python版本、数据库统计(仅计数)、打印机型号、喷嘴数量、固件版本、连接状态、集成状态(Spoolman、MQTT、HA)、非敏感设置、网络接口数量、Docker详情、依赖版本。",dataNeverIncluded:"绝不包含:",dataNeverIncludedList:"打印机名称、序列号、访问代码、密码、IP地址、邮箱地址、API密钥、令牌、Webhook URL、主机名或用户名。",submit:"提交",startLogging:"开始调试日志",stepEnableLogging:"调试日志已启用",stepReproduce:"请现在重现问题",stepStopLogging:"停止并提交报告",stopAndSubmit:"停止并提交",maxDuration:"{{minutes}}分钟后自动停止",stoppingLogs:"正在收集日志并提交...",submitting:"正在提交错误报告...",submitSuccess:"错误报告提交成功!",submitFailed:"提交错误报告失败",thankYou:"谢谢!",submitted:"您的错误报告已提交。",viewIssue:"查看Issue",unexpectedError:"发生了意外错误"},failureDetection:{title:"AI 故障检测",description:"通过自托管的 Obico ML API 监控打印,并对检测到的故障自动采取行动。",mlUrl:"Obico ML API 地址",mlUrlHint:"您自托管的 Obico ml_api 容器的基础 URL(例如 http://192.168.1.10:3333)。",test:"测试",testSuccess:"ML API 可访问且正常。",testFailed:"无法访问 ML API。",sensitivity:"灵敏度",sensitivityLow:"低(减少误报)",sensitivityMedium:"中(平衡)",sensitivityHigh:"高(更早检测,更多误报)",sensitivityHint:"调整触发警告和故障的置信度阈值。",action:"检测到故障时的操作",actionNotify:"仅通知",actionPause:"暂停打印",actionPauseOff:"暂停并切断电源",pollInterval:"检查间隔(秒)",pollIntervalHint:"打印过程中每台打印机的检查频率。最小 5 秒,最大 120 秒。",externalUrlMissing:"External URL is not set.",externalUrlHint:"The ML API fetches the camera snapshot by URL. Set the External URL in General settings so the ML API container can reach Bambuddy.",perPrinterTitle:"监控的打印机",perPrinterHint:"选择检测服务要监视哪些打印机。",monitorAll:"监控所有已连接的打印机",statusTitle:"状态",serviceRunning:"服务运行中",thresholds:"低 / 高阈值",activePrinters:"活动打印",noActivePrints:"当前没有正在进行的打印。",historyTitle:"最近检测",noHistory:"暂无检测记录。"}},Yue={en:{translation:que},de:{translation:$ue},fr:{translation:Vue},ja:{translation:Gue},it:{translation:Wue},"pt-BR":{translation:Kue},"zh-CN":{translation:Xue}};uo.use(GY).use(wue).init({resources:Yue,fallbackLng:"en",supportedLngs:["en","de","fr","ja","it","pt-BR","zh-CN"],detection:{order:["localStorage","navigator","htmlTag"],lookupLocalStorage:"bambutrack_language",caches:["localStorage"]},interpolation:{escapeValue:!1},react:{useSuspense:!1}});const nH=[{code:"en",name:"English",nativeName:"English"},{code:"de",name:"German",nativeName:"Deutsch"},{code:"fr",name:"French",nativeName:"Français"},{code:"ja",name:"Japanese",nativeName:"日本語"},{code:"it",name:"Italian",nativeName:"Italiano"},{code:"pt-BR",name:"Portuguese (Brazil)",nativeName:"Português (Brasil)"},{code:"zh-CN",name:"Chinese (Simplified)",nativeName:"简体中文"}];var rH="popstate";function Que(t={}){function e(r,i){let{pathname:s,search:o,hash:l}=r.location;return z3("",{pathname:s,search:o,hash:l},i.state&&i.state.usr||null,i.state&&i.state.key||"default")}function n(r,i){return typeof i=="string"?i:W0(i)}return Jue(e,n,null,t)}function Pa(t,e){if(t===!1||t===null||typeof t>"u")throw new Error(e)}function rc(t,e){if(!t){typeof console<"u"&&console.warn(e);try{throw new Error(e)}catch{}}}function Zue(){return Math.random().toString(36).substring(2,10)}function aH(t,e){return{usr:t.state,key:t.key,idx:e}}function z3(t,e,n=null,r){return{pathname:typeof t=="string"?t:t.pathname,search:"",hash:"",...typeof e=="string"?ly(e):e,state:n,key:e&&e.key||r||Zue()}}function W0({pathname:t="/",search:e="",hash:n=""}){return e&&e!=="?"&&(t+=e.charAt(0)==="?"?e:"?"+e),n&&n!=="#"&&(t+=n.charAt(0)==="#"?n:"#"+n),t}function ly(t){let e={};if(t){let n=t.indexOf("#");n>=0&&(e.hash=t.substring(n),t=t.substring(0,n));let r=t.indexOf("?");r>=0&&(e.search=t.substring(r),t=t.substring(0,r)),t&&(e.pathname=t)}return e}function Jue(t,e,n,r={}){let{window:i=document.defaultView,v5Compat:s=!1}=r,o=i.history,l="POP",c=null,d=u();d==null&&(d=0,o.replaceState({...o.state,idx:d},""));function u(){return(o.state||{idx:null}).idx}function m(){l="POP";let b=u(),g=b==null?null:b-d;d=b,c&&c({action:l,location:v.location,delta:g})}function p(b,g){l="PUSH";let _=z3(v.location,b,g);d=u()+1;let C=aH(_,d),P=v.createHref(_);try{o.pushState(C,"",P)}catch(N){if(N instanceof DOMException&&N.name==="DataCloneError")throw N;i.location.assign(P)}s&&c&&c({action:l,location:v.location,delta:1})}function f(b,g){l="REPLACE";let _=z3(v.location,b,g);d=u();let C=aH(_,d),P=v.createHref(_);o.replaceState(C,"",P),s&&c&&c({action:l,location:v.location,delta:0})}function x(b){return eme(b)}let v={get action(){return l},get location(){return t(i,o)},listen(b){if(c)throw new Error("A history only accepts one active listener");return i.addEventListener(rH,m),c=b,()=>{i.removeEventListener(rH,m),c=null}},createHref(b){return e(i,b)},createURL:x,encodeLocation(b){let g=x(b);return{pathname:g.pathname,search:g.search,hash:g.hash}},push:p,replace:f,go(b){return o.go(b)}};return v}function eme(t,e=!1){let n="http://localhost";typeof window<"u"&&(n=window.location.origin!=="null"?window.location.origin:window.location.href),Pa(n,"No window.location.(origin|href) available to create URL");let r=typeof t=="string"?t:W0(t);return r=r.replace(/ $/,"%20"),!e&&r.startsWith("//")&&(r=n+r),new URL(r,n)}function WY(t,e,n="/"){return tme(t,e,n,!1)}function tme(t,e,n,r){let i=typeof e=="string"?ly(e):e,s=cm(i.pathname||"/",n);if(s==null)return null;let o=KY(t);nme(o);let l=null;for(let c=0;l==null&&c{let u={relativePath:d===void 0?o.path||"":d,caseSensitive:o.caseSensitive===!0,childrenIndex:l,route:o};if(u.relativePath.startsWith("/")){if(!u.relativePath.startsWith(r)&&c)return;Pa(u.relativePath.startsWith(r),`Absolute route path "${u.relativePath}" nested under path "${r}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),u.relativePath=u.relativePath.slice(r.length)}let m=Ju([r,u.relativePath]),p=n.concat(u);o.children&&o.children.length>0&&(Pa(o.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${m}".`),KY(o.children,e,p,m,c)),!(o.path==null&&!o.index)&&e.push({path:m,score:cme(m,o.index),routesMeta:p})};return t.forEach((o,l)=>{if(o.path===""||!o.path?.includes("?"))s(o,l);else for(let c of XY(o.path))s(o,l,!0,c)}),e}function XY(t){let e=t.split("/");if(e.length===0)return[];let[n,...r]=e,i=n.endsWith("?"),s=n.replace(/\?$/,"");if(r.length===0)return i?[s,""]:[s];let o=XY(r.join("/")),l=[];return l.push(...o.map(c=>c===""?s:[s,c].join("/"))),i&&l.push(...o),l.map(c=>t.startsWith("/")&&c===""?"/":c)}function nme(t){t.sort((e,n)=>e.score!==n.score?n.score-e.score:dme(e.routesMeta.map(r=>r.childrenIndex),n.routesMeta.map(r=>r.childrenIndex)))}var rme=/^:[\w-]+$/,ame=3,ime=2,sme=1,ome=10,lme=-2,iH=t=>t==="*";function cme(t,e){let n=t.split("/"),r=n.length;return n.some(iH)&&(r+=lme),e&&(r+=ime),n.filter(i=>!iH(i)).reduce((i,s)=>i+(rme.test(s)?ame:s===""?sme:ome),r)}function dme(t,e){return t.length===e.length&&t.slice(0,-1).every((r,i)=>r===e[i])?t[t.length-1]-e[e.length-1]:0}function ume(t,e,n=!1){let{routesMeta:r}=t,i={},s="/",o=[];for(let l=0;l{if(u==="*"){let x=l[p]||"";o=s.slice(0,s.length-x.length).replace(/(.)\/+$/,"$1")}const f=l[p];return m&&!f?d[u]=void 0:d[u]=(f||"").replace(/%2F/g,"/"),d},{}),pathname:s,pathnameBase:o,pattern:t}}function mme(t,e=!1,n=!0){rc(t==="*"||!t.endsWith("*")||t.endsWith("/*"),`Route path "${t}" will be treated as if it were "${t.replace(/\*$/,"/*")}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${t.replace(/\*$/,"/*")}".`);let r=[],i="^"+t.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(o,l,c)=>(r.push({paramName:l,isOptional:c!=null}),c?"/?([^\\/]+)?":"/([^\\/]+)")).replace(/\/([\w-]+)\?(\/|$)/g,"(/$1)?$2");return t.endsWith("*")?(r.push({paramName:"*"}),i+=t==="*"||t==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):n?i+="\\/*$":t!==""&&t!=="/"&&(i+="(?:(?=\\/|$))"),[new RegExp(i,e?void 0:"i"),r]}function hme(t){try{return t.split("/").map(e=>decodeURIComponent(e).replace(/\//g,"%2F")).join("/")}catch(e){return rc(!1,`The URL path "${t}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${e}).`),t}}function cm(t,e){if(e==="/")return t;if(!t.toLowerCase().startsWith(e.toLowerCase()))return null;let n=e.endsWith("/")?e.length-1:e.length,r=t.charAt(n);return r&&r!=="/"?null:t.slice(n)||"/"}var pme=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;function fme(t,e="/"){let{pathname:n,search:r="",hash:i=""}=typeof t=="string"?ly(t):t,s;return n?(n=n.replace(/\/\/+/g,"/"),n.startsWith("/")?s=sH(n.substring(1),"/"):s=sH(n,e)):s=e,{pathname:s,search:xme(r),hash:yme(i)}}function sH(t,e){let n=e.replace(/\/+$/,"").split("/");return t.split("/").forEach(i=>{i===".."?n.length>1&&n.pop():i!=="."&&n.push(i)}),n.length>1?n.join("/"):"/"}function yM(t,e,n,r){return`Cannot include a '${t}' character in a manually specified \`to.${e}\` field [${JSON.stringify(r)}]. Please separate it out to the \`to.${n}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function gme(t){return t.filter((e,n)=>n===0||e.route.path&&e.route.path.length>0)}function oO(t){let e=gme(t);return e.map((n,r)=>r===e.length-1?n.pathname:n.pathnameBase)}function lO(t,e,n,r=!1){let i;typeof t=="string"?i=ly(t):(i={...t},Pa(!i.pathname||!i.pathname.includes("?"),yM("?","pathname","search",i)),Pa(!i.pathname||!i.pathname.includes("#"),yM("#","pathname","hash",i)),Pa(!i.search||!i.search.includes("#"),yM("#","search","hash",i)));let s=t===""||i.pathname==="",o=s?"/":i.pathname,l;if(o==null)l=n;else{let m=e.length-1;if(!r&&o.startsWith("..")){let p=o.split("/");for(;p[0]==="..";)p.shift(),m-=1;i.pathname=p.join("/")}l=m>=0?e[m]:"/"}let c=fme(i,l),d=o&&o!=="/"&&o.endsWith("/"),u=(s||o===".")&&n.endsWith("/");return!c.pathname.endsWith("/")&&(d||u)&&(c.pathname+="/"),c}var Ju=t=>t.join("/").replace(/\/\/+/g,"/"),bme=t=>t.replace(/\/+$/,"").replace(/^\/*/,"/"),xme=t=>!t||t==="?"?"":t.startsWith("?")?t:"?"+t,yme=t=>!t||t==="#"?"":t.startsWith("#")?t:"#"+t,vme=class{constructor(t,e,n,r=!1){this.status=t,this.statusText=e||"",this.internal=r,n instanceof Error?(this.data=n.toString(),this.error=n):this.data=n}};function wme(t){return t!=null&&typeof t.status=="number"&&typeof t.statusText=="string"&&typeof t.internal=="boolean"&&"data"in t}function Sme(t){return t.map(e=>e.route.path).filter(Boolean).join("/").replace(/\/\/*/g,"/")||"/"}var YY=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";function QY(t,e){let n=t;if(typeof n!="string"||!pme.test(n))return{absoluteURL:void 0,isExternal:!1,to:n};let r=n,i=!1;if(YY)try{let s=new URL(window.location.href),o=n.startsWith("//")?new URL(s.protocol+n):new URL(n),l=cm(o.pathname,e);o.origin===s.origin&&l!=null?n=l+o.search+o.hash:i=!0}catch{rc(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:r,isExternal:i,to:n}}Object.getOwnPropertyNames(Object.prototype).sort().join("\0");var ZY=["POST","PUT","PATCH","DELETE"];new Set(ZY);var _me=["GET",...ZY];new Set(_me);var cy=w.createContext(null);cy.displayName="DataRouter";var DP=w.createContext(null);DP.displayName="DataRouterState";var kme=w.createContext(!1),JY=w.createContext({isTransitioning:!1});JY.displayName="ViewTransition";var Nme=w.createContext(new Map);Nme.displayName="Fetchers";var Cme=w.createContext(null);Cme.displayName="Await";var gl=w.createContext(null);gl.displayName="Navigation";var Bw=w.createContext(null);Bw.displayName="Location";var hc=w.createContext({outlet:null,matches:[],isDataRoute:!1});hc.displayName="Route";var cO=w.createContext(null);cO.displayName="RouteError";var eQ="REACT_ROUTER_ERROR",Pme="REDIRECT",Tme="ROUTE_ERROR_RESPONSE";function Ame(t){if(t.startsWith(`${eQ}:${Pme}:{`))try{let e=JSON.parse(t.slice(28));if(typeof e=="object"&&e&&typeof e.status=="number"&&typeof e.statusText=="string"&&typeof e.location=="string"&&typeof e.reloadDocument=="boolean"&&typeof e.replace=="boolean")return e}catch{}}function jme(t){if(t.startsWith(`${eQ}:${Tme}:{`))try{let e=JSON.parse(t.slice(40));if(typeof e=="object"&&e&&typeof e.status=="number"&&typeof e.statusText=="string")return new vme(e.status,e.statusText,e.data)}catch{}}function Mme(t,{relative:e}={}){Pa(dy(),"useHref() may be used only in the context of a component.");let{basename:n,navigator:r}=w.useContext(gl),{hash:i,pathname:s,search:o}=qw(t,{relative:e}),l=s;return n!=="/"&&(l=s==="/"?n:Ju([n,s])),r.createHref({pathname:l,search:o,hash:i})}function dy(){return w.useContext(Bw)!=null}function Kc(){return Pa(dy(),"useLocation() may be used only in the context of a component."),w.useContext(Bw).location}var tQ="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function nQ(t){w.useContext(gl).static||w.useLayoutEffect(t)}function Lo(){let{isDataRoute:t}=w.useContext(hc);return t?Vme():Eme()}function Eme(){Pa(dy(),"useNavigate() may be used only in the context of a component.");let t=w.useContext(cy),{basename:e,navigator:n}=w.useContext(gl),{matches:r}=w.useContext(hc),{pathname:i}=Kc(),s=JSON.stringify(oO(r)),o=w.useRef(!1);return nQ(()=>{o.current=!0}),w.useCallback((c,d={})=>{if(rc(o.current,tQ),!o.current)return;if(typeof c=="number"){n.go(c);return}let u=lO(c,JSON.parse(s),i,d.relative==="path");t==null&&e!=="/"&&(u.pathname=u.pathname==="/"?e:Ju([e,u.pathname])),(d.replace?n.replace:n.push)(u,d.state,d)},[e,n,s,i,t])}var rQ=w.createContext(null);function uy(){return w.useContext(rQ)}function Dme(t){let e=w.useContext(hc).outlet;return w.useMemo(()=>e&&w.createElement(rQ.Provider,{value:t},e),[e,t])}function Hw(){let{matches:t}=w.useContext(hc),e=t[t.length-1];return e?e.params:{}}function qw(t,{relative:e}={}){let{matches:n}=w.useContext(hc),{pathname:r}=Kc(),i=JSON.stringify(oO(n));return w.useMemo(()=>lO(t,JSON.parse(i),r,e==="path"),[t,i,r,e])}function Fme(t,e){return aQ(t,e)}function aQ(t,e,n,r,i){Pa(dy(),"useRoutes() may be used only in the context of a component.");let{navigator:s}=w.useContext(gl),{matches:o}=w.useContext(hc),l=o[o.length-1],c=l?l.params:{},d=l?l.pathname:"/",u=l?l.pathnameBase:"/",m=l&&l.route;{let _=m&&m.path||"";sQ(d,!m||_.endsWith("*")||_.endsWith("*?"),`You rendered descendant (or called \`useRoutes()\`) at "${d}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. -Please change the parent to .`)}let p=Kc(),f;if(e){let _=typeof e=="string"?ly(e):e;Pa(u==="/"||_.pathname?.startsWith(u),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${u}" but pathname "${_.pathname}" was given in the \`location\` prop.`),f=_}else f=p;let x=f.pathname||"/",v=x;if(u!=="/"){let _=u.replace(/^\//,"").split("/");v="/"+x.replace(/^\//,"").split("/").slice(_.length).join("/")}let b=WY(t,{pathname:v});rc(m||b!=null,`No routes matched location "${f.pathname}${f.search}${f.hash}" `),rc(b==null||b[b.length-1].route.element!==void 0||b[b.length-1].route.Component!==void 0||b[b.length-1].route.lazy!==void 0,`Matched leaf route at location "${f.pathname}${f.search}${f.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let g=zme(b&&b.map(_=>Object.assign({},_,{params:Object.assign({},c,_.params),pathname:Ju([u,s.encodeLocation?s.encodeLocation(_.pathname.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:_.pathname]),pathnameBase:_.pathnameBase==="/"?u:Ju([u,s.encodeLocation?s.encodeLocation(_.pathnameBase.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:_.pathnameBase])})),o,n,r,i);return e&&g?w.createElement(Bw.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",...f},navigationType:"POP"}},g):g}function Rme(){let t=$me(),e=wme(t)?`${t.status} ${t.statusText}`:t instanceof Error?t.message:JSON.stringify(t),n=t instanceof Error?t.stack:null,r="rgba(200,200,200, 0.5)",i={padding:"0.5rem",backgroundColor:r},s={padding:"2px 4px",backgroundColor:r},o=null;return console.error("Error handled by React Router default ErrorBoundary:",t),o=w.createElement(w.Fragment,null,w.createElement("p",null,"💿 Hey developer 👋"),w.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",w.createElement("code",{style:s},"ErrorBoundary")," or"," ",w.createElement("code",{style:s},"errorElement")," prop on your route.")),w.createElement(w.Fragment,null,w.createElement("h2",null,"Unexpected Application Error!"),w.createElement("h3",{style:{fontStyle:"italic"}},e),n?w.createElement("pre",{style:i},n):null,o)}var Lme=w.createElement(Rme,null),iQ=class extends w.Component{constructor(t){super(t),this.state={location:t.location,revalidation:t.revalidation,error:t.error}}static getDerivedStateFromError(t){return{error:t}}static getDerivedStateFromProps(t,e){return e.location!==t.location||e.revalidation!=="idle"&&t.revalidation==="idle"?{error:t.error,location:t.location,revalidation:t.revalidation}:{error:t.error!==void 0?t.error:e.error,location:e.location,revalidation:t.revalidation||e.revalidation}}componentDidCatch(t,e){this.props.onError?this.props.onError(t,e):console.error("React Router caught the following error during render",t)}render(){let t=this.state.error;if(this.context&&typeof t=="object"&&t&&"digest"in t&&typeof t.digest=="string"){const n=jme(t.digest);n&&(t=n)}let e=t!==void 0?w.createElement(hc.Provider,{value:this.props.routeContext},w.createElement(cO.Provider,{value:t,children:this.props.component})):this.props.children;return this.context?w.createElement(Ome,{error:t},e):e}};iQ.contextType=kme;var vM=new WeakMap;function Ome({children:t,error:e}){let{basename:n}=w.useContext(gl);if(typeof e=="object"&&e&&"digest"in e&&typeof e.digest=="string"){let r=Ame(e.digest);if(r){let i=vM.get(e);if(i)throw i;let s=QY(r.location,n);if(YY&&!vM.get(e))if(s.isExternal||r.reloadDocument)window.location.href=s.absoluteURL||s.to;else{const o=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(s.to,{replace:r.replace}));throw vM.set(e,o),o}return w.createElement("meta",{httpEquiv:"refresh",content:`0;url=${s.absoluteURL||s.to}`})}}return t}function Ime({routeContext:t,match:e,children:n}){let r=w.useContext(cy);return r&&r.static&&r.staticContext&&(e.route.errorElement||e.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=e.route.id),w.createElement(hc.Provider,{value:t},n)}function zme(t,e=[],n=null,r=null,i=null){if(t==null){if(!n)return null;if(n.errors)t=n.matches;else if(e.length===0&&!n.initialized&&n.matches.length>0)t=n.matches;else return null}let s=t,o=n?.errors;if(o!=null){let u=s.findIndex(m=>m.route.id&&o?.[m.route.id]!==void 0);Pa(u>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(o).join(",")}`),s=s.slice(0,Math.min(s.length,u+1))}let l=!1,c=-1;if(n)for(let u=0;u=0?s=s.slice(0,c+1):s=[s[0]];break}}}let d=n&&r?(u,m)=>{r(u,{location:n.location,params:n.matches?.[0]?.params??{},unstable_pattern:Sme(n.matches),errorInfo:m})}:void 0;return s.reduceRight((u,m,p)=>{let f,x=!1,v=null,b=null;n&&(f=o&&m.route.id?o[m.route.id]:void 0,v=m.route.errorElement||Lme,l&&(c<0&&p===0?(sQ("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),x=!0,b=null):c===p&&(x=!0,b=m.route.hydrateFallbackElement||null)));let g=e.concat(s.slice(0,p+1)),_=()=>{let C;return f?C=v:x?C=b:m.route.Component?C=w.createElement(m.route.Component,null):m.route.element?C=m.route.element:C=u,w.createElement(Ime,{match:m,routeContext:{outlet:u,matches:g,isDataRoute:n!=null},children:C})};return n&&(m.route.ErrorBoundary||m.route.errorElement||p===0)?w.createElement(iQ,{location:n.location,revalidation:n.revalidation,component:v,error:f,children:_(),routeContext:{outlet:null,matches:g,isDataRoute:!0},onError:d}):_()},null)}function dO(t){return`${t} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function Ume(t){let e=w.useContext(cy);return Pa(e,dO(t)),e}function Bme(t){let e=w.useContext(DP);return Pa(e,dO(t)),e}function Hme(t){let e=w.useContext(hc);return Pa(e,dO(t)),e}function uO(t){let e=Hme(t),n=e.matches[e.matches.length-1];return Pa(n.route.id,`${t} can only be used on routes that contain a unique "id"`),n.route.id}function qme(){return uO("useRouteId")}function $me(){let t=w.useContext(cO),e=Bme("useRouteError"),n=uO("useRouteError");return t!==void 0?t:e.errors?.[n]}function Vme(){let{router:t}=Ume("useNavigate"),e=uO("useNavigate"),n=w.useRef(!1);return nQ(()=>{n.current=!0}),w.useCallback(async(i,s={})=>{rc(n.current,tQ),n.current&&(typeof i=="number"?await t.navigate(i):await t.navigate(i,{fromRouteId:e,...s}))},[t,e])}var oH={};function sQ(t,e,n){!e&&!oH[t]&&(oH[t]=!0,rc(!1,n))}w.memo(Gme);function Gme({routes:t,future:e,state:n,onError:r}){return aQ(t,void 0,n,r,e)}function Ex({to:t,replace:e,state:n,relative:r}){Pa(dy()," may be used only in the context of a component.");let{static:i}=w.useContext(gl);rc(!i," must not be used on the initial render in a . This is a no-op, but you should modify your code so the is only ever rendered in response to some user interaction or state change.");let{matches:s}=w.useContext(hc),{pathname:o}=Kc(),l=Lo(),c=lO(t,oO(s),o,r==="path"),d=JSON.stringify(c);return w.useEffect(()=>{l(JSON.parse(d),{replace:e,state:n,relative:r})},[l,d,r,e,n]),null}function oQ(t){return Dme(t.context)}function Dr(t){Pa(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function Wme({basename:t="/",children:e=null,location:n,navigationType:r="POP",navigator:i,static:s=!1,unstable_useTransitions:o}){Pa(!dy(),"You cannot render a inside another . You should never have more than one in your app.");let l=t.replace(/^\/*/,"/"),c=w.useMemo(()=>({basename:l,navigator:i,static:s,unstable_useTransitions:o,future:{}}),[l,i,s,o]);typeof n=="string"&&(n=ly(n));let{pathname:d="/",search:u="",hash:m="",state:p=null,key:f="default"}=n,x=w.useMemo(()=>{let v=cm(d,l);return v==null?null:{location:{pathname:v,search:u,hash:m,state:p,key:f},navigationType:r}},[l,d,u,m,p,f,r]);return rc(x!=null,` is not able to match the URL "${d}${u}${m}" because it does not start with the basename, so the won't render anything.`),x==null?null:w.createElement(gl.Provider,{value:c},w.createElement(Bw.Provider,{children:e,value:x}))}function Kme({children:t,location:e}){return Fme(U3(t),e)}function U3(t,e=[]){let n=[];return w.Children.forEach(t,(r,i)=>{if(!w.isValidElement(r))return;let s=[...e,i];if(r.type===w.Fragment){n.push.apply(n,U3(r.props.children,s));return}Pa(r.type===Dr,`[${typeof r.type=="string"?r.type:r.type.name}] is not a component. All component children of must be a or `),Pa(!r.props.index||!r.props.children,"An index route cannot have child routes.");let o={id:r.props.id||s.join("-"),caseSensitive:r.props.caseSensitive,element:r.props.element,Component:r.props.Component,index:r.props.index,path:r.props.path,middleware:r.props.middleware,loader:r.props.loader,action:r.props.action,hydrateFallbackElement:r.props.hydrateFallbackElement,HydrateFallback:r.props.HydrateFallback,errorElement:r.props.errorElement,ErrorBoundary:r.props.ErrorBoundary,hasErrorBoundary:r.props.hasErrorBoundary===!0||r.props.ErrorBoundary!=null||r.props.errorElement!=null,shouldRevalidate:r.props.shouldRevalidate,handle:r.props.handle,lazy:r.props.lazy};r.props.children&&(o.children=U3(r.props.children,s)),n.push(o)}),n}var Mk="get",Ek="application/x-www-form-urlencoded";function FP(t){return typeof HTMLElement<"u"&&t instanceof HTMLElement}function Xme(t){return FP(t)&&t.tagName.toLowerCase()==="button"}function Yme(t){return FP(t)&&t.tagName.toLowerCase()==="form"}function Qme(t){return FP(t)&&t.tagName.toLowerCase()==="input"}function Zme(t){return!!(t.metaKey||t.altKey||t.ctrlKey||t.shiftKey)}function Jme(t,e){return t.button===0&&(!e||e==="_self")&&!Zme(t)}function B3(t=""){return new URLSearchParams(typeof t=="string"||Array.isArray(t)||t instanceof URLSearchParams?t:Object.keys(t).reduce((e,n)=>{let r=t[n];return e.concat(Array.isArray(r)?r.map(i=>[n,i]):[[n,r]])},[]))}function ehe(t,e){let n=B3(t);return e&&e.forEach((r,i)=>{n.has(i)||e.getAll(i).forEach(s=>{n.append(i,s)})}),n}var H_=null;function the(){if(H_===null)try{new FormData(document.createElement("form"),0),H_=!1}catch{H_=!0}return H_}var nhe=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function wM(t){return t!=null&&!nhe.has(t)?(rc(!1,`"${t}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${Ek}"`),null):t}function rhe(t,e){let n,r,i,s,o;if(Yme(t)){let l=t.getAttribute("action");r=l?cm(l,e):null,n=t.getAttribute("method")||Mk,i=wM(t.getAttribute("enctype"))||Ek,s=new FormData(t)}else if(Xme(t)||Qme(t)&&(t.type==="submit"||t.type==="image")){let l=t.form;if(l==null)throw new Error('Cannot submit a