diff --git a/.env.example b/.env.example index 9f1ef83e..8286c89d 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,8 @@ # ---- Application ---- GOCHAT_ENV=prod GOCHAT_VERSION=v1.0.0 +# Keep disabled in production. Enable only for controlled QA/UAT environments. +GOCHAT_ALLOW_FAKE_CHANNEL=false # ---- Server ---- GOCHAT_SERVER_HOST=0.0.0.0 @@ -152,4 +154,4 @@ GOCHAT_OIDC_DEFAULT_SCOPES=openid,profile,email GOCHAT_RATE_LIMIT_REQUESTS_PER_MINUTE=100 # requests per minute per IP GOCHAT_RATE_LIMIT_WINDOW_SECONDS=60 # seconds GOCHAT_ADMIN_EMAIL=admin@example.com -GOCHAT_ADMIN_PASSWORD=CHANGE_ME_TO_A_STRONG_PASSWORD \ No newline at end of file +GOCHAT_ADMIN_PASSWORD=CHANGE_ME_TO_A_STRONG_PASSWORD diff --git a/.playwright-cli/page-2026-07-11T10-19-44-072Z.yml b/.playwright-cli/page-2026-07-11T10-19-44-072Z.yml new file mode 100644 index 00000000..e69de29b diff --git a/.playwright-cli/page-2026-07-11T10-20-22-628Z.yml b/.playwright-cli/page-2026-07-11T10-20-22-628Z.yml new file mode 100644 index 00000000..73c3e3cd --- /dev/null +++ b/.playwright-cli/page-2026-07-11T10-20-22-628Z.yml @@ -0,0 +1,217 @@ +- generic [ref=f2e4]: + - complementary [ref=f2e5]: + - generic [ref=f2e6]: + - button "112233" [ref=f2e12] + - generic [ref=f2e14]: + - link "搜索……" [ref=f2e15] [cursor=pointer]: + - /url: /app/accounts/1/search + - button [ref=f2e19] [cursor=pointer] + - navigation [ref=f2e21]: + - list [ref=f2e22]: + - listitem [ref=f2e23] [cursor=pointer]: + - button "我的收件箱" [ref=f2e24] + - listitem [ref=f2e29] [cursor=pointer]: + - button "会话" [ref=f2e30] + - listitem [ref=f2e35] [cursor=pointer]: + - button "联系人" [ref=f2e36] + - listitem [ref=f2e41] [cursor=pointer]: + - button "报告" [ref=f2e42] + - listitem [ref=f2e47] [cursor=pointer]: + - button "活动" [ref=f2e48] + - listitem [ref=f2e53] [cursor=pointer]: + - button "帮助中心" [ref=f2e54] + - listitem [ref=f2e59] [cursor=pointer]: + - button "设置" [ref=f2e60] + - button "Super Admin1 admin@gochat.local" [ref=f2e68] [cursor=pointer]: + - img [ref=f2e71]: + - generic [ref=f2e72]: SA + - generic [ref=f2e73]: + - generic [ref=f2e74]: Super Admin1 + - generic [ref=f2e75]: admin@gochat.local + - main [ref=f2e78]: + - generic [ref=f2e81]: + - heading "个人资料设置" [level=1] [ref=f2e84] + - generic [ref=f2e87]: + - generic [ref=f2e88]: + - generic [ref=f2e89]: 个人资料图片 + - img [ref=f2e91]: + - generic [ref=f2e92]: SA + - generic [ref=f2e93]: + - generic [ref=f2e94]: + - text: 您的全名 + - textbox "您的全名" [ref=f2e95]: + - /placeholder: 请输入您的姓名 + - text: Super Admin1 + - generic [ref=f2e96]: + - text: 展示名 + - textbox "展示名" [ref=f2e97]: + - /placeholder: 请输入显示名称,将会在对话中显示 + - generic [ref=f2e98]: + - text: 您的电子邮件地址 + - textbox "您的电子邮件地址" [ref=f2e99]: + - /placeholder: 请输入您的名字,这将会在对话中显示 + - text: admin@gochat.local + - button "更新个人资料" [ref=f2e101] [cursor=pointer] + - generic [ref=f2e103]: + - generic [ref=f2e105]: + - heading "界面" [level=4] [ref=f2e106] + - paragraph [ref=f2e107]: 自定义您的 GoChat 仪表板的外观和感觉。 + - generic [ref=f2e110]: + - generic [ref=f2e111]: + - generic [ref=f2e112]: + - generic [ref=f2e113]: 字体大小 + - paragraph [ref=f2e114]: 根据您的偏好调整仪表板中的文本大小。 + - combobox [ref=f2e118]: + - option "较小" + - option "小" + - option "默认" [selected] + - option "大" + - option "较大" + - generic [ref=f2e119]: + - generic [ref=f2e120]: + - generic [ref=f2e121]: 首选语言 + - paragraph [ref=f2e122]: 选择您想要使用的语言。 + - combobox [ref=f2e126]: + - option "使用账户默认值" + - option "中文" [selected] + - option "English" + - generic [ref=f2e127]: + - generic [ref=f2e129]: + - heading "个人消息签名" [level=4] [ref=f2e130] + - paragraph [ref=f2e131]: 创建一个唯一的消息签名来显示在您从任何收件箱发送的消息的末尾。 您也可以包含一个内联图片,它支持在直播聊天、电子邮件和API收件箱中。 + - generic [ref=f2e134]: + - generic [ref=f2e137]: + - toolbar [ref=f2e138]: + - button "Toggle strong style" [ref=f2e140] [cursor=pointer] + - button "Toggle emphasis" [ref=f2e144] [cursor=pointer] + - button "Add or remove link" [disabled] [ref=f2e148] + - button "Undo last change" [disabled] [ref=f2e152] + - button "Redo last undone change" [disabled] [ref=f2e156] + - button "Upload image" [ref=f2e160] [cursor=pointer] + - paragraph [ref=f2e164]: test + - button "保存消息签名" [ref=f2e166] [cursor=pointer] + - generic [ref=f2e168]: + - generic [ref=f2e170]: + - heading "发送消息的快捷键" [level=4] [ref=f2e171] + - paragraph [ref=f2e172]: 您可以根据您的写入偏好选择一个快捷键 (输入或 Cmd/Ctrl+Enter)。 + - generic [ref=f2e175]: + - generic [ref=f2e177] [cursor=pointer]: + - generic [ref=f2e178]: + - heading "Enter (↵)" [level=3] [ref=f2e180] + - radio "Enter (↵) 按 Enter 键发送消息,而不是单击发送按钮。 Light themed image for Enter (↵)" [ref=f2e181] + - paragraph [ref=f2e182]: 按 Enter 键发送消息,而不是单击发送按钮。 + - img "Light themed image for Enter (↵)" [ref=f2e183] + - generic [ref=f2e185] [cursor=pointer]: + - generic [ref=f2e186]: + - heading "Cmd/Ctrl + Enter (⌘ + ↵)" [level=3] [ref=f2e188] + - radio "Cmd/Ctrl + Enter (⌘ + ↵) 按下 Cmd/Ctrl + Enter 发送消息,而不是点击发送按钮。 Light themed image for Cmd/Ctrl + Enter (⌘ + ↵)" [checked] [ref=f2e189] + - paragraph [ref=f2e190]: 按下 Cmd/Ctrl + Enter 发送消息,而不是点击发送按钮。 + - img "Light themed image for Cmd/Ctrl + Enter (⌘ + ↵)" [ref=f2e191] + - generic [ref=f2e192]: + - heading "密码" [level=4] [ref=f2e195] + - generic [ref=f2e199]: + - generic [ref=f2e200]: + - text: 当前密码 + - textbox "当前密码" [ref=f2e201]: + - /placeholder: 请输入当前密码 + - generic [ref=f2e202]: + - text: 新密码 + - textbox "新密码" [ref=f2e203]: + - /placeholder: 请输入新密码 + - generic [ref=f2e204]: + - text: 重新输入一次密码 + - textbox "重新输入一次密码" [ref=f2e205]: + - /placeholder: 请重新输入您的新密码 + - button "更改密码" [disabled] [ref=f2e207] + - generic [ref=f2e210]: + - generic [ref=f2e212]: + - heading "音频提醒" [level=4] [ref=f2e213] + - paragraph [ref=f2e214]: 在仪表板中为新消息和会话启用音频提醒。 + - generic [ref=f2e217]: + - generic [ref=f2e218]: + - generic [ref=f2e219]: + - generic [ref=f2e220]: 提醒音调: + - combobox [ref=f2e223]: + - option "Ding" [selected] + - option "Bell" + - option "Chime" + - option "Magic" + - option "Ping" + - button [ref=f2e224] [cursor=pointer] + - generic [ref=f2e226]: + - generic [ref=f2e227]: 会话提醒事件 + - generic [ref=f2e228]: + - generic [ref=f2e229]: + - checkbox "分配给我的会话" [ref=f2e230] + - generic [ref=f2e231]: 分配给我的会话 + - generic [ref=f2e232]: + - checkbox "未分配的会话" [ref=f2e233] + - generic [ref=f2e234]: 未分配的会话 + - generic [ref=f2e235]: + - checkbox "分配给其他人的开放会话" [ref=f2e236] + - generic [ref=f2e237]: 分配给其他人的开放会话 + - generic [ref=f2e238]: 您尚未选择任何选项,您不会收到任何音频提醒。 + - generic [ref=f2e239]: + - generic [ref=f2e240]: 提醒条件: + - generic [ref=f2e241]: + - generic [ref=f2e242]: + - checkbox "仅在浏览器窗口未激活时发送音频提醒" [checked] [ref=f2e243]: ✓ + - generic [ref=f2e244]: 仅在浏览器窗口未激活时发送音频提醒 + - generic [ref=f2e245]: + - checkbox "每30秒发送一次提醒,直到所有分配的会话被阅读" [ref=f2e246] + - generic [ref=f2e247]: 每30秒发送一次提醒,直到所有分配的会话被阅读 + - generic [ref=f2e249]: + - heading "通知偏好" [level=4] [ref=f2e252] + - generic [ref=f2e255]: + - generic [ref=f2e256]: + - generic [ref=f2e257]: + - generic [ref=f2e258]: 通知类型 + - generic [ref=f2e260]: 电子邮件 + - generic [ref=f2e262]: 推送通知 + - generic [ref=f2e266]: + - generic [ref=f2e267]: 新会话已创建 + - checkbox [checked] [ref=f2e270]: ✓ + - checkbox [checked] [ref=f2e272]: ✓ + - generic [ref=f2e274]: + - generic [ref=f2e275]: 会话已分配给您 + - checkbox [checked] [ref=f2e278]: ✓ + - checkbox [checked] [ref=f2e280]: ✓ + - generic [ref=f2e282]: + - generic [ref=f2e283]: 您在会话中被提及 + - checkbox [checked] [ref=f2e286]: ✓ + - checkbox [checked] [ref=f2e288]: ✓ + - generic [ref=f2e290]: + - generic [ref=f2e291]: 在分配的会话中创建了新消息 + - checkbox [checked] [ref=f2e294]: ✓ + - checkbox [checked] [ref=f2e296]: ✓ + - generic [ref=f2e298]: + - generic [ref=f2e299]: 在参与的会话中创建了新消息 + - checkbox [checked] [ref=f2e302]: ✓ + - checkbox [checked] [ref=f2e304]: ✓ + - generic [ref=f2e306]: + - generic [ref=f2e307]: 会话错过了首次响应 SLA + - checkbox [checked] [ref=f2e310]: ✓ + - checkbox [checked] [ref=f2e312]: ✓ + - generic [ref=f2e314]: + - generic [ref=f2e315]: 会话错过了下次响应 SLA + - checkbox [checked] [ref=f2e318]: ✓ + - checkbox [checked] [ref=f2e320]: ✓ + - generic [ref=f2e322]: + - generic [ref=f2e323]: 会话错过了解决 SLA + - checkbox [checked] [ref=f2e326]: ✓ + - checkbox [checked] [ref=f2e328]: ✓ + - text: ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ + - generic [ref=f2e329]: + - generic [ref=f2e330]: 为您的浏览器启用推送通知,以便您能够接收它们 + - switch "切换开关" [ref=f2e334] [cursor=pointer] + - generic [ref=f2e338]: + - generic [ref=f2e340]: + - heading "访问令牌" [level=4] [ref=f2e341] + - paragraph [ref=f2e342]: 如果您正在构建基于 API 的集成,这个令牌可以被使用 + - generic [ref=f2e345]: + - generic [ref=f2e346]: + - textbox [ref=f2e347]: gochat_pat_23fd97a29962a50bc275d44e57cc1fdc58f243f8cf2bceb148083f744e1c3b4f + - button [ref=f2e348] [cursor=pointer] + - generic [ref=f2e351]: + - button "复制" [ref=f2e352] [cursor=pointer] + - button "重置" [ref=f2e356] [cursor=pointer] \ No newline at end of file diff --git a/.playwright-cli/page-2026-07-11T10-20-41-940Z.yml b/.playwright-cli/page-2026-07-11T10-20-41-940Z.yml new file mode 100644 index 00000000..bbe31813 --- /dev/null +++ b/.playwright-cli/page-2026-07-11T10-20-41-940Z.yml @@ -0,0 +1,16 @@ +- main [ref=e4]: + - heading "登录到GoChat" [level=2] [ref=e6] + - generic [ref=e9]: + - generic [ref=e10]: + - generic [ref=e11]: Email + - textbox "实例 @companyname.com" [ref=e14] + - generic [ref=e15]: + - generic [ref=e16]: + - text: 密码 + - paragraph [ref=e17]: + - link "忘记密码了?" [ref=e18] [cursor=pointer]: + - /url: /app/auth/reset/password + - generic [ref=e20]: + - textbox "密码" [ref=e21] + - button "Show password" [ref=e22] [cursor=pointer] + - button "登录" [ref=e24] [cursor=pointer] \ No newline at end of file diff --git a/.playwright-cli/page-2026-07-11T10-21-00-784Z.yml b/.playwright-cli/page-2026-07-11T10-21-00-784Z.yml new file mode 100644 index 00000000..bfa6ff6e --- /dev/null +++ b/.playwright-cli/page-2026-07-11T10-21-00-784Z.yml @@ -0,0 +1,218 @@ +- generic [ref=f2e4]: + - complementary [ref=f2e5]: + - generic [ref=f2e6]: + - button "112233" [ref=f2e12] + - generic [ref=f2e14]: + - link "搜索……" [ref=f2e15] [cursor=pointer]: + - /url: /app/accounts/1/search + - button [ref=f2e19] [cursor=pointer] + - navigation [ref=f2e21]: + - list [ref=f2e22]: + - listitem [ref=f2e23] [cursor=pointer]: + - button "我的收件箱" [ref=f2e24] + - listitem [ref=f2e29] [cursor=pointer]: + - button "会话" [ref=f2e30] + - listitem [ref=f2e35] [cursor=pointer]: + - button "联系人" [ref=f2e36] + - listitem [ref=f2e41] [cursor=pointer]: + - button "报告" [ref=f2e42] + - listitem [ref=f2e47] [cursor=pointer]: + - button "活动" [ref=f2e48] + - listitem [ref=f2e53] [cursor=pointer]: + - button "帮助中心" [ref=f2e54] + - listitem [ref=f2e59] [cursor=pointer]: + - button "设置" [ref=f2e60] + - button "Super Admin1 admin@gochat.local" [ref=f2e68] [cursor=pointer]: + - img [ref=f2e71]: + - generic [ref=f2e72]: SA + - generic [ref=f2e73]: + - generic [ref=f2e74]: Super Admin1 + - generic [ref=f2e75]: admin@gochat.local + - main [ref=f2e78]: + - generic [ref=f2e81]: + - heading "个人资料设置" [level=1] [ref=f2e84] + - generic [ref=f2e87]: + - generic [ref=f2e88]: + - generic [ref=f2e89]: 个人资料图片 + - img [ref=f2e91]: + - generic [ref=f2e92]: SA + - generic [ref=f2e93]: + - generic [ref=f2e94]: + - text: 您的全名 + - textbox "您的全名" [ref=f2e95]: + - /placeholder: 请输入您的姓名 + - text: Super Admin1 + - generic [ref=f2e96]: + - text: 展示名 + - textbox "展示名" [ref=f2e97]: + - /placeholder: 请输入显示名称,将会在对话中显示 + - text: QA 展示名 + - generic [ref=f2e98]: + - text: 您的电子邮件地址 + - textbox "您的电子邮件地址" [ref=f2e99]: + - /placeholder: 请输入您的名字,这将会在对话中显示 + - text: admin@gochat.local + - button "更新个人资料" [ref=f2e101] [cursor=pointer] + - generic [ref=f2e103]: + - generic [ref=f2e105]: + - heading "界面" [level=4] [ref=f2e106] + - paragraph [ref=f2e107]: 自定义您的 GoChat 仪表板的外观和感觉。 + - generic [ref=f2e110]: + - generic [ref=f2e111]: + - generic [ref=f2e112]: + - generic [ref=f2e113]: 字体大小 + - paragraph [ref=f2e114]: 根据您的偏好调整仪表板中的文本大小。 + - combobox [ref=f2e118]: + - option "较小" + - option "小" + - option "默认" [selected] + - option "大" + - option "较大" + - generic [ref=f2e119]: + - generic [ref=f2e120]: + - generic [ref=f2e121]: 首选语言 + - paragraph [ref=f2e122]: 选择您想要使用的语言。 + - combobox [ref=f2e126]: + - option "使用账户默认值" + - option "中文" [selected] + - option "English" + - generic [ref=f2e127]: + - generic [ref=f2e129]: + - heading "个人消息签名" [level=4] [ref=f2e130] + - paragraph [ref=f2e131]: 创建一个唯一的消息签名来显示在您从任何收件箱发送的消息的末尾。 您也可以包含一个内联图片,它支持在直播聊天、电子邮件和API收件箱中。 + - generic [ref=f2e134]: + - generic [ref=f2e137]: + - toolbar [ref=f2e138]: + - button "Toggle strong style" [ref=f2e140] [cursor=pointer] + - button "Toggle emphasis" [ref=f2e144] [cursor=pointer] + - button "Add or remove link" [disabled] [ref=f2e148] + - button "Undo last change" [disabled] [ref=f2e152] + - button "Redo last undone change" [disabled] [ref=f2e156] + - button "Upload image" [ref=f2e160] [cursor=pointer] + - paragraph [ref=f2e164]: test + - button "保存消息签名" [ref=f2e166] [cursor=pointer] + - generic [ref=f2e168]: + - generic [ref=f2e170]: + - heading "发送消息的快捷键" [level=4] [ref=f2e171] + - paragraph [ref=f2e172]: 您可以根据您的写入偏好选择一个快捷键 (输入或 Cmd/Ctrl+Enter)。 + - generic [ref=f2e175]: + - generic [ref=f2e177] [cursor=pointer]: + - generic [ref=f2e178]: + - heading "Enter (↵)" [level=3] [ref=f2e180] + - radio "Enter (↵) 按 Enter 键发送消息,而不是单击发送按钮。 Light themed image for Enter (↵)" [ref=f2e181] + - paragraph [ref=f2e182]: 按 Enter 键发送消息,而不是单击发送按钮。 + - img "Light themed image for Enter (↵)" [ref=f2e183] + - generic [ref=f2e185] [cursor=pointer]: + - generic [ref=f2e186]: + - heading "Cmd/Ctrl + Enter (⌘ + ↵)" [level=3] [ref=f2e188] + - radio "Cmd/Ctrl + Enter (⌘ + ↵) 按下 Cmd/Ctrl + Enter 发送消息,而不是点击发送按钮。 Light themed image for Cmd/Ctrl + Enter (⌘ + ↵)" [checked] [ref=f2e189] + - paragraph [ref=f2e190]: 按下 Cmd/Ctrl + Enter 发送消息,而不是点击发送按钮。 + - img "Light themed image for Cmd/Ctrl + Enter (⌘ + ↵)" [ref=f2e191] + - generic [ref=f2e192]: + - heading "密码" [level=4] [ref=f2e195] + - generic [ref=f2e199]: + - generic [ref=f2e200]: + - text: 当前密码 + - textbox "当前密码" [ref=f2e201]: + - /placeholder: 请输入当前密码 + - generic [ref=f2e202]: + - text: 新密码 + - textbox "新密码" [ref=f2e203]: + - /placeholder: 请输入新密码 + - generic [ref=f2e204]: + - text: 重新输入一次密码 + - textbox "重新输入一次密码" [ref=f2e205]: + - /placeholder: 请重新输入您的新密码 + - button "更改密码" [disabled] [ref=f2e207] + - generic [ref=f2e210]: + - generic [ref=f2e212]: + - heading "音频提醒" [level=4] [ref=f2e213] + - paragraph [ref=f2e214]: 在仪表板中为新消息和会话启用音频提醒。 + - generic [ref=f2e217]: + - generic [ref=f2e218]: + - generic [ref=f2e219]: + - generic [ref=f2e220]: 提醒音调: + - combobox [ref=f2e223]: + - option "Ding" [selected] + - option "Bell" + - option "Chime" + - option "Magic" + - option "Ping" + - button [ref=f2e224] [cursor=pointer] + - generic [ref=f2e226]: + - generic [ref=f2e227]: 会话提醒事件 + - generic [ref=f2e228]: + - generic [ref=f2e229]: + - checkbox "分配给我的会话" [ref=f2e230] + - generic [ref=f2e231]: 分配给我的会话 + - generic [ref=f2e232]: + - checkbox "未分配的会话" [ref=f2e233] + - generic [ref=f2e234]: 未分配的会话 + - generic [ref=f2e235]: + - checkbox "分配给其他人的开放会话" [ref=f2e236] + - generic [ref=f2e237]: 分配给其他人的开放会话 + - generic [ref=f2e238]: 您尚未选择任何选项,您不会收到任何音频提醒。 + - generic [ref=f2e239]: + - generic [ref=f2e240]: 提醒条件: + - generic [ref=f2e241]: + - generic [ref=f2e242]: + - checkbox "仅在浏览器窗口未激活时发送音频提醒" [checked] [ref=f2e243]: ✓ + - generic [ref=f2e244]: 仅在浏览器窗口未激活时发送音频提醒 + - generic [ref=f2e245]: + - checkbox "每30秒发送一次提醒,直到所有分配的会话被阅读" [ref=f2e246] + - generic [ref=f2e247]: 每30秒发送一次提醒,直到所有分配的会话被阅读 + - generic [ref=f2e249]: + - heading "通知偏好" [level=4] [ref=f2e252] + - generic [ref=f2e255]: + - generic [ref=f2e256]: + - generic [ref=f2e257]: + - generic [ref=f2e258]: 通知类型 + - generic [ref=f2e260]: 电子邮件 + - generic [ref=f2e262]: 推送通知 + - generic [ref=f2e266]: + - generic [ref=f2e267]: 新会话已创建 + - checkbox [checked] [ref=f2e270]: ✓ + - checkbox [checked] [ref=f2e272]: ✓ + - generic [ref=f2e274]: + - generic [ref=f2e275]: 会话已分配给您 + - checkbox [checked] [ref=f2e278]: ✓ + - checkbox [checked] [ref=f2e280]: ✓ + - generic [ref=f2e282]: + - generic [ref=f2e283]: 您在会话中被提及 + - checkbox [checked] [ref=f2e286]: ✓ + - checkbox [checked] [ref=f2e288]: ✓ + - generic [ref=f2e290]: + - generic [ref=f2e291]: 在分配的会话中创建了新消息 + - checkbox [checked] [ref=f2e294]: ✓ + - checkbox [checked] [ref=f2e296]: ✓ + - generic [ref=f2e298]: + - generic [ref=f2e299]: 在参与的会话中创建了新消息 + - checkbox [checked] [ref=f2e302]: ✓ + - checkbox [checked] [ref=f2e304]: ✓ + - generic [ref=f2e306]: + - generic [ref=f2e307]: 会话错过了首次响应 SLA + - checkbox [checked] [ref=f2e310]: ✓ + - checkbox [checked] [ref=f2e312]: ✓ + - generic [ref=f2e314]: + - generic [ref=f2e315]: 会话错过了下次响应 SLA + - checkbox [checked] [ref=f2e318]: ✓ + - checkbox [checked] [ref=f2e320]: ✓ + - generic [ref=f2e322]: + - generic [ref=f2e323]: 会话错过了解决 SLA + - checkbox [checked] [ref=f2e326]: ✓ + - checkbox [checked] [ref=f2e328]: ✓ + - text: ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ + - generic [ref=f2e329]: + - generic [ref=f2e330]: 为您的浏览器启用推送通知,以便您能够接收它们 + - switch "切换开关" [ref=f2e334] [cursor=pointer] + - generic [ref=f2e338]: + - generic [ref=f2e340]: + - heading "访问令牌" [level=4] [ref=f2e341] + - paragraph [ref=f2e342]: 如果您正在构建基于 API 的集成,这个令牌可以被使用 + - generic [ref=f2e345]: + - generic [ref=f2e346]: + - textbox [ref=f2e347]: gochat_pat_23fd97a29962a50bc275d44e57cc1fdc58f243f8cf2bceb148083f744e1c3b4f + - button [ref=f2e348] [cursor=pointer] + - generic [ref=f2e351]: + - button "复制" [ref=f2e352] [cursor=pointer] + - button "重置" [ref=f2e356] [cursor=pointer] \ No newline at end of file diff --git a/.playwright-cli/page-2026-07-11T10-22-06-344Z.yml b/.playwright-cli/page-2026-07-11T10-22-06-344Z.yml new file mode 100644 index 00000000..a7cc9bd6 --- /dev/null +++ b/.playwright-cli/page-2026-07-11T10-22-06-344Z.yml @@ -0,0 +1,220 @@ +- generic [ref=f2e3]: + - generic [ref=f2e4]: + - complementary [ref=f2e5]: + - generic [ref=f2e6]: + - button "112233" [ref=f2e12] + - generic [ref=f2e14]: + - link "搜索……" [ref=f2e15] [cursor=pointer]: + - /url: /app/accounts/1/search + - button [ref=f2e19] [cursor=pointer] + - navigation [ref=f2e21]: + - list [ref=f2e22]: + - listitem [ref=f2e23] [cursor=pointer]: + - button "我的收件箱" [ref=f2e24] + - listitem [ref=f2e29] [cursor=pointer]: + - button "会话" [ref=f2e30] + - listitem [ref=f2e35] [cursor=pointer]: + - button "联系人" [ref=f2e36] + - listitem [ref=f2e41] [cursor=pointer]: + - button "报告" [ref=f2e42] + - listitem [ref=f2e47] [cursor=pointer]: + - button "活动" [ref=f2e48] + - listitem [ref=f2e53] [cursor=pointer]: + - button "帮助中心" [ref=f2e54] + - listitem [ref=f2e59] [cursor=pointer]: + - button "设置" [ref=f2e60] + - button "Super Admin1 admin@gochat.local" [ref=f2e68] [cursor=pointer]: + - img [ref=f2e71]: + - generic [ref=f2e72]: SA + - generic [ref=f2e73]: + - generic [ref=f2e74]: Super Admin1 + - generic [ref=f2e75]: admin@gochat.local + - main [ref=f2e78]: + - generic [ref=f2e81]: + - heading "个人资料设置" [level=1] [ref=f2e84] + - generic [ref=f2e87]: + - generic [ref=f2e88]: + - generic [ref=f2e89]: 个人资料图片 + - img [ref=f2e91]: + - img "Super Admin1" [ref=f2e372] + - generic [ref=f2e93]: + - generic [ref=f2e94]: + - text: 您的全名 + - textbox "您的全名" [ref=f2e95]: + - /placeholder: 请输入您的姓名 + - text: Super Admin1 + - generic [ref=f2e96]: + - text: 展示名 + - textbox "展示名" [ref=f2e97]: + - /placeholder: 请输入显示名称,将会在对话中显示 + - text: QA 展示名 + - generic [ref=f2e98]: + - text: 您的电子邮件地址 + - textbox "您的电子邮件地址" [ref=f2e99]: + - /placeholder: 请输入您的名字,这将会在对话中显示 + - text: admin@gochat.local + - button "更新个人资料" [active] [ref=f2e101] [cursor=pointer] + - generic [ref=f2e103]: + - generic [ref=f2e105]: + - heading "界面" [level=4] [ref=f2e106] + - paragraph [ref=f2e107]: 自定义您的 GoChat 仪表板的外观和感觉。 + - generic [ref=f2e110]: + - generic [ref=f2e111]: + - generic [ref=f2e112]: + - generic [ref=f2e113]: 字体大小 + - paragraph [ref=f2e114]: 根据您的偏好调整仪表板中的文本大小。 + - combobox [ref=f2e118]: + - option "较小" + - option "小" + - option "默认" [selected] + - option "大" + - option "较大" + - generic [ref=f2e119]: + - generic [ref=f2e120]: + - generic [ref=f2e121]: 首选语言 + - paragraph [ref=f2e122]: 选择您想要使用的语言。 + - combobox [ref=f2e126]: + - option "使用账户默认值" + - option "中文" [selected] + - option "English" + - generic [ref=f2e127]: + - generic [ref=f2e129]: + - heading "个人消息签名" [level=4] [ref=f2e130] + - paragraph [ref=f2e131]: 创建一个唯一的消息签名来显示在您从任何收件箱发送的消息的末尾。 您也可以包含一个内联图片,它支持在直播聊天、电子邮件和API收件箱中。 + - generic [ref=f2e134]: + - generic [ref=f2e137]: + - toolbar [ref=f2e138]: + - button "Toggle strong style" [ref=f2e140] [cursor=pointer] + - button "Toggle emphasis" [ref=f2e144] [cursor=pointer] + - button "Add or remove link" [disabled] [ref=f2e148] + - button "Undo last change" [disabled] [ref=f2e152] + - button "Redo last undone change" [disabled] [ref=f2e156] + - button "Upload image" [ref=f2e160] [cursor=pointer] + - paragraph [ref=f2e164]: test + - button "保存消息签名" [ref=f2e166] [cursor=pointer] + - generic [ref=f2e168]: + - generic [ref=f2e170]: + - heading "发送消息的快捷键" [level=4] [ref=f2e171] + - paragraph [ref=f2e172]: 您可以根据您的写入偏好选择一个快捷键 (输入或 Cmd/Ctrl+Enter)。 + - generic [ref=f2e175]: + - generic [ref=f2e177] [cursor=pointer]: + - generic [ref=f2e178]: + - heading "Enter (↵)" [level=3] [ref=f2e180] + - radio "Enter (↵) 按 Enter 键发送消息,而不是单击发送按钮。 Light themed image for Enter (↵)" [ref=f2e181] + - paragraph [ref=f2e182]: 按 Enter 键发送消息,而不是单击发送按钮。 + - img "Light themed image for Enter (↵)" [ref=f2e183] + - generic [ref=f2e185] [cursor=pointer]: + - generic [ref=f2e186]: + - heading "Cmd/Ctrl + Enter (⌘ + ↵)" [level=3] [ref=f2e188] + - radio "Cmd/Ctrl + Enter (⌘ + ↵) 按下 Cmd/Ctrl + Enter 发送消息,而不是点击发送按钮。 Light themed image for Cmd/Ctrl + Enter (⌘ + ↵)" [checked] [ref=f2e189] + - paragraph [ref=f2e190]: 按下 Cmd/Ctrl + Enter 发送消息,而不是点击发送按钮。 + - img "Light themed image for Cmd/Ctrl + Enter (⌘ + ↵)" [ref=f2e191] + - generic [ref=f2e192]: + - heading "密码" [level=4] [ref=f2e195] + - generic [ref=f2e199]: + - generic [ref=f2e200]: + - text: 当前密码 + - textbox "当前密码" [ref=f2e201]: + - /placeholder: 请输入当前密码 + - generic [ref=f2e202]: + - text: 新密码 + - textbox "新密码" [ref=f2e203]: + - /placeholder: 请输入新密码 + - generic [ref=f2e204]: + - text: 重新输入一次密码 + - textbox "重新输入一次密码" [ref=f2e205]: + - /placeholder: 请重新输入您的新密码 + - button "更改密码" [disabled] [ref=f2e207] + - generic [ref=f2e210]: + - generic [ref=f2e212]: + - heading "音频提醒" [level=4] [ref=f2e213] + - paragraph [ref=f2e214]: 在仪表板中为新消息和会话启用音频提醒。 + - generic [ref=f2e217]: + - generic [ref=f2e218]: + - generic [ref=f2e219]: + - generic [ref=f2e220]: 提醒音调: + - combobox [ref=f2e223]: + - option "Ding" [selected] + - option "Bell" + - option "Chime" + - option "Magic" + - option "Ping" + - button [ref=f2e224] [cursor=pointer] + - generic [ref=f2e226]: + - generic [ref=f2e227]: 会话提醒事件 + - generic [ref=f2e228]: + - generic [ref=f2e229]: + - checkbox "分配给我的会话" [ref=f2e230] + - generic [ref=f2e231]: 分配给我的会话 + - generic [ref=f2e232]: + - checkbox "未分配的会话" [ref=f2e233] + - generic [ref=f2e234]: 未分配的会话 + - generic [ref=f2e235]: + - checkbox "分配给其他人的开放会话" [ref=f2e236] + - generic [ref=f2e237]: 分配给其他人的开放会话 + - generic [ref=f2e238]: 您尚未选择任何选项,您不会收到任何音频提醒。 + - generic [ref=f2e239]: + - generic [ref=f2e240]: 提醒条件: + - generic [ref=f2e241]: + - generic [ref=f2e242]: + - checkbox "仅在浏览器窗口未激活时发送音频提醒" [checked] [ref=f2e243]: ✓ + - generic [ref=f2e244]: 仅在浏览器窗口未激活时发送音频提醒 + - generic [ref=f2e245]: + - checkbox "每30秒发送一次提醒,直到所有分配的会话被阅读" [ref=f2e246] + - generic [ref=f2e247]: 每30秒发送一次提醒,直到所有分配的会话被阅读 + - generic [ref=f2e249]: + - heading "通知偏好" [level=4] [ref=f2e252] + - generic [ref=f2e255]: + - generic [ref=f2e256]: + - generic [ref=f2e257]: + - generic [ref=f2e258]: 通知类型 + - generic [ref=f2e260]: 电子邮件 + - generic [ref=f2e262]: 推送通知 + - generic [ref=f2e266]: + - generic [ref=f2e267]: 新会话已创建 + - checkbox [checked] [ref=f2e270]: ✓ + - checkbox [checked] [ref=f2e272]: ✓ + - generic [ref=f2e274]: + - generic [ref=f2e275]: 会话已分配给您 + - checkbox [checked] [ref=f2e278]: ✓ + - checkbox [checked] [ref=f2e280]: ✓ + - generic [ref=f2e282]: + - generic [ref=f2e283]: 您在会话中被提及 + - checkbox [checked] [ref=f2e286]: ✓ + - checkbox [checked] [ref=f2e288]: ✓ + - generic [ref=f2e290]: + - generic [ref=f2e291]: 在分配的会话中创建了新消息 + - checkbox [checked] [ref=f2e294]: ✓ + - checkbox [checked] [ref=f2e296]: ✓ + - generic [ref=f2e298]: + - generic [ref=f2e299]: 在参与的会话中创建了新消息 + - checkbox [checked] [ref=f2e302]: ✓ + - checkbox [checked] [ref=f2e304]: ✓ + - generic [ref=f2e306]: + - generic [ref=f2e307]: 会话错过了首次响应 SLA + - checkbox [checked] [ref=f2e310]: ✓ + - checkbox [checked] [ref=f2e312]: ✓ + - generic [ref=f2e314]: + - generic [ref=f2e315]: 会话错过了下次响应 SLA + - checkbox [checked] [ref=f2e318]: ✓ + - checkbox [checked] [ref=f2e320]: ✓ + - generic [ref=f2e322]: + - generic [ref=f2e323]: 会话错过了解决 SLA + - checkbox [checked] [ref=f2e326]: ✓ + - checkbox [checked] [ref=f2e328]: ✓ + - text: ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ + - generic [ref=f2e329]: + - generic [ref=f2e330]: 为您的浏览器启用推送通知,以便您能够接收它们 + - switch "切换开关" [ref=f2e334] [cursor=pointer] + - generic [ref=f2e338]: + - generic [ref=f2e340]: + - heading "访问令牌" [level=4] [ref=f2e341] + - paragraph [ref=f2e342]: 如果您正在构建基于 API 的集成,这个令牌可以被使用 + - generic [ref=f2e345]: + - generic [ref=f2e346]: + - textbox [ref=f2e347]: gochat_pat_23fd97a29962a50bc275d44e57cc1fdc58f243f8cf2bceb148083f744e1c3b4f + - button [ref=f2e348] [cursor=pointer] + - generic [ref=f2e351]: + - button "复制" [ref=f2e352] [cursor=pointer] + - button "重置" [ref=f2e356] [cursor=pointer] + - generic [ref=f2e373]: "{ \"code\": \"BAD_REQUEST\", \"message\": \"failed to create upload record: ERROR: column \\\"upload_uuid\\\" of relation \\\"direct_uploads\\\" does not exist (SQLSTATE 42703)\" }" \ No newline at end of file diff --git a/.playwright-cli/page-2026-07-11T10-24-18-858Z.yml b/.playwright-cli/page-2026-07-11T10-24-18-858Z.yml new file mode 100644 index 00000000..8a400dc7 --- /dev/null +++ b/.playwright-cli/page-2026-07-11T10-24-18-858Z.yml @@ -0,0 +1,220 @@ +- generic [ref=f4e3]: + - generic [ref=f4e4]: + - complementary [ref=f4e5]: + - generic [ref=f4e6]: + - button "112233" [ref=f4e12] + - generic [ref=f4e14]: + - link "搜索……" [ref=f4e15] [cursor=pointer]: + - /url: /app/accounts/1/search + - button [ref=f4e19] [cursor=pointer] + - navigation [ref=f4e21]: + - list [ref=f4e22]: + - listitem [ref=f4e23] [cursor=pointer]: + - button "我的收件箱" [ref=f4e24] + - listitem [ref=f4e29] [cursor=pointer]: + - button "会话" [ref=f4e30] + - listitem [ref=f4e35] [cursor=pointer]: + - button "联系人" [ref=f4e36] + - listitem [ref=f4e41] [cursor=pointer]: + - button "报告" [ref=f4e42] + - listitem [ref=f4e47] [cursor=pointer]: + - button "活动" [ref=f4e48] + - listitem [ref=f4e53] [cursor=pointer]: + - button "帮助中心" [ref=f4e54] + - listitem [ref=f4e59] [cursor=pointer]: + - button "设置" [ref=f4e60] + - button "QA 展示名 admin@gochat.local" [ref=f4e369] [cursor=pointer]: + - img [ref=f4e71]: + - generic [ref=f4e370]: Q展 + - generic [ref=f4e73]: + - generic [ref=f4e74]: QA 展示名 + - generic [ref=f4e75]: admin@gochat.local + - main [ref=f4e78]: + - generic [ref=f4e81]: + - heading "个人资料设置" [level=1] [ref=f4e84] + - generic [ref=f4e87]: + - generic [ref=f4e88]: + - generic [ref=f4e89]: 个人资料图片 + - img [ref=f4e91]: + - img "Super Admin1" [ref=f4e368] + - generic [ref=f4e93]: + - generic [ref=f4e94]: + - text: 您的全名 + - textbox "您的全名" [ref=f4e95]: + - /placeholder: 请输入您的姓名 + - text: Super Admin1 + - generic [ref=f4e96]: + - text: 展示名 + - textbox "展示名" [ref=f4e97]: + - /placeholder: 请输入显示名称,将会在对话中显示 + - text: QA 展示名 + - generic [ref=f4e98]: + - text: 您的电子邮件地址 + - textbox "您的电子邮件地址" [ref=f4e99]: + - /placeholder: 请输入您的名字,这将会在对话中显示 + - text: admin@gochat.local + - button "更新个人资料" [active] [ref=f4e101] [cursor=pointer] + - generic [ref=f4e103]: + - generic [ref=f4e105]: + - heading "界面" [level=4] [ref=f4e106] + - paragraph [ref=f4e107]: 自定义您的 GoChat 仪表板的外观和感觉。 + - generic [ref=f4e110]: + - generic [ref=f4e111]: + - generic [ref=f4e112]: + - generic [ref=f4e113]: 字体大小 + - paragraph [ref=f4e114]: 根据您的偏好调整仪表板中的文本大小。 + - combobox [ref=f4e118]: + - option "较小" + - option "小" + - option "默认" [selected] + - option "大" + - option "较大" + - generic [ref=f4e119]: + - generic [ref=f4e120]: + - generic [ref=f4e121]: 首选语言 + - paragraph [ref=f4e122]: 选择您想要使用的语言。 + - combobox [ref=f4e126]: + - option "使用账户默认值" + - option "中文" [selected] + - option "English" + - generic [ref=f4e127]: + - generic [ref=f4e129]: + - heading "个人消息签名" [level=4] [ref=f4e130] + - paragraph [ref=f4e131]: 创建一个唯一的消息签名来显示在您从任何收件箱发送的消息的末尾。 您也可以包含一个内联图片,它支持在直播聊天、电子邮件和API收件箱中。 + - generic [ref=f4e134]: + - generic [ref=f4e137]: + - toolbar [ref=f4e138]: + - button "Toggle strong style" [ref=f4e140] [cursor=pointer] + - button "Toggle emphasis" [ref=f4e144] [cursor=pointer] + - button "Add or remove link" [disabled] [ref=f4e148] + - button "Undo last change" [disabled] [ref=f4e152] + - button "Redo last undone change" [disabled] [ref=f4e156] + - button "Upload image" [ref=f4e160] [cursor=pointer] + - paragraph [ref=f4e164]: test + - button "保存消息签名" [ref=f4e166] [cursor=pointer] + - generic [ref=f4e168]: + - generic [ref=f4e170]: + - heading "发送消息的快捷键" [level=4] [ref=f4e171] + - paragraph [ref=f4e172]: 您可以根据您的写入偏好选择一个快捷键 (输入或 Cmd/Ctrl+Enter)。 + - generic [ref=f4e175]: + - generic [ref=f4e177] [cursor=pointer]: + - generic [ref=f4e178]: + - heading "Enter (↵)" [level=3] [ref=f4e180] + - radio "Enter (↵) 按 Enter 键发送消息,而不是单击发送按钮。 Light themed image for Enter (↵)" [ref=f4e181] + - paragraph [ref=f4e182]: 按 Enter 键发送消息,而不是单击发送按钮。 + - img "Light themed image for Enter (↵)" [ref=f4e183] + - generic [ref=f4e185] [cursor=pointer]: + - generic [ref=f4e186]: + - heading "Cmd/Ctrl + Enter (⌘ + ↵)" [level=3] [ref=f4e188] + - radio "Cmd/Ctrl + Enter (⌘ + ↵) 按下 Cmd/Ctrl + Enter 发送消息,而不是点击发送按钮。 Light themed image for Cmd/Ctrl + Enter (⌘ + ↵)" [checked] [ref=f4e189] + - paragraph [ref=f4e190]: 按下 Cmd/Ctrl + Enter 发送消息,而不是点击发送按钮。 + - img "Light themed image for Cmd/Ctrl + Enter (⌘ + ↵)" [ref=f4e191] + - generic [ref=f4e192]: + - heading "密码" [level=4] [ref=f4e195] + - generic [ref=f4e199]: + - generic [ref=f4e200]: + - text: 当前密码 + - textbox "当前密码" [ref=f4e201]: + - /placeholder: 请输入当前密码 + - generic [ref=f4e202]: + - text: 新密码 + - textbox "新密码" [ref=f4e203]: + - /placeholder: 请输入新密码 + - generic [ref=f4e204]: + - text: 重新输入一次密码 + - textbox "重新输入一次密码" [ref=f4e205]: + - /placeholder: 请重新输入您的新密码 + - button "更改密码" [disabled] [ref=f4e207] + - generic [ref=f4e210]: + - generic [ref=f4e212]: + - heading "音频提醒" [level=4] [ref=f4e213] + - paragraph [ref=f4e214]: 在仪表板中为新消息和会话启用音频提醒。 + - generic [ref=f4e217]: + - generic [ref=f4e218]: + - generic [ref=f4e219]: + - generic [ref=f4e220]: 提醒音调: + - combobox [ref=f4e223]: + - option "Ding" [selected] + - option "Bell" + - option "Chime" + - option "Magic" + - option "Ping" + - button [ref=f4e224] [cursor=pointer] + - generic [ref=f4e226]: + - generic [ref=f4e227]: 会话提醒事件 + - generic [ref=f4e228]: + - generic [ref=f4e229]: + - checkbox "分配给我的会话" [ref=f4e230] + - generic [ref=f4e231]: 分配给我的会话 + - generic [ref=f4e232]: + - checkbox "未分配的会话" [ref=f4e233] + - generic [ref=f4e234]: 未分配的会话 + - generic [ref=f4e235]: + - checkbox "分配给其他人的开放会话" [ref=f4e236] + - generic [ref=f4e237]: 分配给其他人的开放会话 + - generic [ref=f4e238]: 您尚未选择任何选项,您不会收到任何音频提醒。 + - generic [ref=f4e239]: + - generic [ref=f4e240]: 提醒条件: + - generic [ref=f4e241]: + - generic [ref=f4e242]: + - checkbox "仅在浏览器窗口未激活时发送音频提醒" [checked] [ref=f4e243]: ✓ + - generic [ref=f4e244]: 仅在浏览器窗口未激活时发送音频提醒 + - generic [ref=f4e245]: + - checkbox "每30秒发送一次提醒,直到所有分配的会话被阅读" [ref=f4e246] + - generic [ref=f4e247]: 每30秒发送一次提醒,直到所有分配的会话被阅读 + - generic [ref=f4e249]: + - heading "通知偏好" [level=4] [ref=f4e252] + - generic [ref=f4e255]: + - generic [ref=f4e256]: + - generic [ref=f4e257]: + - generic [ref=f4e258]: 通知类型 + - generic [ref=f4e260]: 电子邮件 + - generic [ref=f4e262]: 推送通知 + - generic [ref=f4e266]: + - generic [ref=f4e267]: 新会话已创建 + - checkbox [checked] [ref=f4e270]: ✓ + - checkbox [checked] [ref=f4e272]: ✓ + - generic [ref=f4e274]: + - generic [ref=f4e275]: 会话已分配给您 + - checkbox [checked] [ref=f4e278]: ✓ + - checkbox [checked] [ref=f4e280]: ✓ + - generic [ref=f4e282]: + - generic [ref=f4e283]: 您在会话中被提及 + - checkbox [checked] [ref=f4e286]: ✓ + - checkbox [checked] [ref=f4e288]: ✓ + - generic [ref=f4e290]: + - generic [ref=f4e291]: 在分配的会话中创建了新消息 + - checkbox [checked] [ref=f4e294]: ✓ + - checkbox [checked] [ref=f4e296]: ✓ + - generic [ref=f4e298]: + - generic [ref=f4e299]: 在参与的会话中创建了新消息 + - checkbox [checked] [ref=f4e302]: ✓ + - checkbox [checked] [ref=f4e304]: ✓ + - generic [ref=f4e306]: + - generic [ref=f4e307]: 会话错过了首次响应 SLA + - checkbox [checked] [ref=f4e310]: ✓ + - checkbox [checked] [ref=f4e312]: ✓ + - generic [ref=f4e314]: + - generic [ref=f4e315]: 会话错过了下次响应 SLA + - checkbox [checked] [ref=f4e318]: ✓ + - checkbox [checked] [ref=f4e320]: ✓ + - generic [ref=f4e322]: + - generic [ref=f4e323]: 会话错过了解决 SLA + - checkbox [checked] [ref=f4e326]: ✓ + - checkbox [checked] [ref=f4e328]: ✓ + - text: ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ + - generic [ref=f4e329]: + - generic [ref=f4e330]: 为您的浏览器启用推送通知,以便您能够接收它们 + - switch "切换开关" [ref=f4e334] [cursor=pointer] + - generic [ref=f4e338]: + - generic [ref=f4e340]: + - heading "访问令牌" [level=4] [ref=f4e341] + - paragraph [ref=f4e342]: 如果您正在构建基于 API 的集成,这个令牌可以被使用 + - generic [ref=f4e345]: + - generic [ref=f4e346]: + - textbox [ref=f4e347]: gochat_pat_23fd97a29962a50bc275d44e57cc1fdc58f243f8cf2bceb148083f744e1c3b4f + - button [ref=f4e348] [cursor=pointer] + - generic [ref=f4e351]: + - button "复制" [ref=f4e352] [cursor=pointer] + - button "重置" [ref=f4e356] [cursor=pointer] + - generic [ref=f4e371]: 您的个人资料已更新成功 \ No newline at end of file diff --git a/.playwright-cli/page-2026-07-11T10-26-23-770Z.yml b/.playwright-cli/page-2026-07-11T10-26-23-770Z.yml new file mode 100644 index 00000000..a67d38bc --- /dev/null +++ b/.playwright-cli/page-2026-07-11T10-26-23-770Z.yml @@ -0,0 +1,200 @@ +- generic [ref=f6e4]: + - complementary [ref=f6e5]: + - generic [ref=f6e6]: + - button [ref=f6e12] + - generic [ref=f6e13]: + - link "搜索……" [ref=f6e14] [cursor=pointer]: + - /url: /app/accounts/1/search + - button [ref=f6e18] [cursor=pointer] + - navigation [ref=f6e20]: + - list [ref=f6e21]: + - listitem [ref=f6e22] [cursor=pointer]: + - button "我的收件箱" [ref=f6e23] + - listitem [ref=f6e28] [cursor=pointer]: + - button "会话" [ref=f6e29] + - listitem [ref=f6e34] [cursor=pointer]: + - button "报告" [ref=f6e35] + - listitem [ref=f6e40] [cursor=pointer]: + - button "设置" [ref=f6e41] + - button "QA 展示名 admin@gochat.local" [ref=f6e49] [cursor=pointer]: + - img [ref=f6e52]: + - generic [ref=f6e53]: Q展 + - generic [ref=f6e54]: + - generic [ref=f6e55]: QA 展示名 + - generic [ref=f6e56]: admin@gochat.local + - main [ref=f6e59]: + - generic [ref=f6e62]: + - heading "个人资料设置" [level=1] [ref=f6e65] + - generic [ref=f6e68]: + - generic [ref=f6e69]: + - generic [ref=f6e70]: 个人资料图片 + - img [ref=f6e72]: + - generic [ref=f6e73]: SA + - generic [ref=f6e74]: + - generic [ref=f6e75]: + - text: 您的全名 + - textbox "您的全名" [ref=f6e76]: + - /placeholder: 请输入您的姓名 + - text: Super Admin1 + - generic [ref=f6e77]: + - text: 展示名 + - textbox "展示名" [ref=f6e78]: + - /placeholder: 请输入显示名称,将会在对话中显示 + - text: QA 展示名 + - generic [ref=f6e79]: + - text: 您的电子邮件地址 + - textbox "您的电子邮件地址" [ref=f6e80]: + - /placeholder: 请输入您的名字,这将会在对话中显示 + - text: admin@gochat.local + - button "更新个人资料" [ref=f6e82] [cursor=pointer] + - generic [ref=f6e84]: + - generic [ref=f6e86]: + - heading "界面" [level=4] [ref=f6e87] + - paragraph [ref=f6e88]: 自定义您的 GoChat 仪表板的外观和感觉。 + - generic [ref=f6e91]: + - generic [ref=f6e92]: + - generic [ref=f6e93]: + - generic [ref=f6e94]: 字体大小 + - paragraph [ref=f6e95]: 根据您的偏好调整仪表板中的文本大小。 + - combobox [ref=f6e99]: + - option "较小" + - option "小" + - option "默认" [selected] + - option "大" + - option "较大" + - generic [ref=f6e100]: + - generic [ref=f6e101]: + - generic [ref=f6e102]: 首选语言 + - paragraph [ref=f6e103]: 选择您想要使用的语言。 + - combobox [ref=f6e107]: + - option "使用账户默认值" + - option "中文" [selected] + - option "English" + - generic [ref=f6e108]: + - generic [ref=f6e110]: + - heading "个人消息签名" [level=4] [ref=f6e111] + - paragraph [ref=f6e112]: 创建一个唯一的消息签名来显示在您从任何收件箱发送的消息的末尾。 您也可以包含一个内联图片,它支持在直播聊天、电子邮件和API收件箱中。 + - generic [ref=f6e115]: + - generic [ref=f6e118]: + - toolbar [ref=f6e119]: + - button "Toggle strong style" [ref=f6e121] [cursor=pointer] + - button "Toggle emphasis" [ref=f6e125] [cursor=pointer] + - button "Add or remove link" [disabled] [ref=f6e129] + - button "Undo last change" [disabled] [ref=f6e133] + - button "Redo last undone change" [disabled] [ref=f6e137] + - button "Upload image" [ref=f6e141] [cursor=pointer] + - paragraph [ref=f6e145]: test + - button "保存消息签名" [ref=f6e147] [cursor=pointer] + - generic [ref=f6e149]: + - generic [ref=f6e151]: + - heading "发送消息的快捷键" [level=4] [ref=f6e152] + - paragraph [ref=f6e153]: 您可以根据您的写入偏好选择一个快捷键 (输入或 Cmd/Ctrl+Enter)。 + - generic [ref=f6e156]: + - generic [ref=f6e158] [cursor=pointer]: + - generic [ref=f6e159]: + - heading "Enter (↵)" [level=3] [ref=f6e161] + - radio "Enter (↵) 按 Enter 键发送消息,而不是单击发送按钮。 Light themed image for Enter (↵)" [ref=f6e162] + - paragraph [ref=f6e163]: 按 Enter 键发送消息,而不是单击发送按钮。 + - img "Light themed image for Enter (↵)" [ref=f6e164] + - generic [ref=f6e166] [cursor=pointer]: + - generic [ref=f6e167]: + - heading "Cmd/Ctrl + Enter (⌘ + ↵)" [level=3] [ref=f6e169] + - radio "Cmd/Ctrl + Enter (⌘ + ↵) 按下 Cmd/Ctrl + Enter 发送消息,而不是点击发送按钮。 Light themed image for Cmd/Ctrl + Enter (⌘ + ↵)" [checked] [ref=f6e170] + - paragraph [ref=f6e171]: 按下 Cmd/Ctrl + Enter 发送消息,而不是点击发送按钮。 + - img "Light themed image for Cmd/Ctrl + Enter (⌘ + ↵)" [ref=f6e172] + - generic [ref=f6e173]: + - heading "密码" [level=4] [ref=f6e176] + - generic [ref=f6e180]: + - generic [ref=f6e181]: + - text: 当前密码 + - textbox "当前密码" [ref=f6e182]: + - /placeholder: 请输入当前密码 + - generic [ref=f6e183]: + - text: 新密码 + - textbox "新密码" [ref=f6e184]: + - /placeholder: 请输入新密码 + - generic [ref=f6e185]: + - text: 重新输入一次密码 + - textbox "重新输入一次密码" [ref=f6e186]: + - /placeholder: 请重新输入您的新密码 + - button "更改密码" [disabled] [ref=f6e188] + - generic [ref=f6e191]: + - generic [ref=f6e193]: + - heading "音频提醒" [level=4] [ref=f6e194] + - paragraph [ref=f6e195]: 在仪表板中为新消息和会话启用音频提醒。 + - generic [ref=f6e198]: + - generic [ref=f6e199]: + - generic [ref=f6e200]: + - generic [ref=f6e201]: 提醒音调: + - combobox [ref=f6e204]: + - option "Ding" [selected] + - option "Bell" + - option "Chime" + - option "Magic" + - option "Ping" + - button [ref=f6e205] [cursor=pointer] + - generic [ref=f6e207]: + - generic [ref=f6e208]: 会话提醒事件 + - generic [ref=f6e209]: + - generic [ref=f6e210]: + - checkbox "分配给我的会话" [ref=f6e211] + - generic [ref=f6e212]: 分配给我的会话 + - generic [ref=f6e213]: + - checkbox "未分配的会话" [ref=f6e214] + - generic [ref=f6e215]: 未分配的会话 + - generic [ref=f6e216]: + - checkbox "分配给其他人的开放会话" [ref=f6e217] + - generic [ref=f6e218]: 分配给其他人的开放会话 + - generic [ref=f6e219]: 您尚未选择任何选项,您不会收到任何音频提醒。 + - generic [ref=f6e220]: + - generic [ref=f6e221]: 提醒条件: + - generic [ref=f6e222]: + - generic [ref=f6e223]: + - checkbox "仅在浏览器窗口未激活时发送音频提醒" [checked] [ref=f6e224]: ✓ + - generic [ref=f6e225]: 仅在浏览器窗口未激活时发送音频提醒 + - generic [ref=f6e226]: + - checkbox "每30秒发送一次提醒,直到所有分配的会话被阅读" [ref=f6e227] + - generic [ref=f6e228]: 每30秒发送一次提醒,直到所有分配的会话被阅读 + - generic [ref=f6e230]: + - heading "通知偏好" [level=4] [ref=f6e233] + - generic [ref=f6e236]: + - generic [ref=f6e237]: + - generic [ref=f6e238]: + - generic [ref=f6e239]: 通知类型 + - generic [ref=f6e241]: 电子邮件 + - generic [ref=f6e243]: 推送通知 + - generic [ref=f6e247]: + - generic [ref=f6e248]: 新会话已创建 + - checkbox [checked] [ref=f6e251]: ✓ + - checkbox [checked] [ref=f6e253]: ✓ + - generic [ref=f6e255]: + - generic [ref=f6e256]: 会话已分配给您 + - checkbox [checked] [ref=f6e259]: ✓ + - checkbox [checked] [ref=f6e261]: ✓ + - generic [ref=f6e263]: + - generic [ref=f6e264]: 您在会话中被提及 + - checkbox [checked] [ref=f6e267]: ✓ + - checkbox [checked] [ref=f6e269]: ✓ + - generic [ref=f6e271]: + - generic [ref=f6e272]: 在分配的会话中创建了新消息 + - checkbox [checked] [ref=f6e275]: ✓ + - checkbox [checked] [ref=f6e277]: ✓ + - generic [ref=f6e279]: + - generic [ref=f6e280]: 在参与的会话中创建了新消息 + - checkbox [checked] [ref=f6e283]: ✓ + - checkbox [checked] [ref=f6e285]: ✓ + - text: ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ + - generic [ref=f6e286]: + - generic [ref=f6e287]: 为您的浏览器启用推送通知,以便您能够接收它们 + - switch "切换开关" [ref=f6e291] [cursor=pointer] + - generic [ref=f6e295]: + - generic [ref=f6e297]: + - heading "访问令牌" [level=4] [ref=f6e298] + - paragraph [ref=f6e299]: 如果您正在构建基于 API 的集成,这个令牌可以被使用 + - generic [ref=f6e302]: + - generic [ref=f6e303]: + - textbox [ref=f6e304]: gochat_pat_23fd97a29962a50bc275d44e57cc1fdc58f243f8cf2bceb148083f744e1c3b4f + - button [ref=f6e305] [cursor=pointer] + - generic [ref=f6e308]: + - button "复制" [ref=f6e309] [cursor=pointer] + - button "重置" [ref=f6e313] [cursor=pointer] \ No newline at end of file diff --git a/.playwright-cli/page-2026-07-11T10-26-48-789Z.yml b/.playwright-cli/page-2026-07-11T10-26-48-789Z.yml new file mode 100644 index 00000000..6a485c6d --- /dev/null +++ b/.playwright-cli/page-2026-07-11T10-26-48-789Z.yml @@ -0,0 +1,16 @@ +- main [ref=f4e4]: + - heading "登录到GoChat" [level=2] [ref=f4e6] + - generic [ref=f4e9]: + - generic [ref=f4e10]: + - generic [ref=f4e11]: Email + - textbox "实例 @companyname.com" [ref=f4e14] + - generic [ref=f4e15]: + - generic [ref=f4e16]: + - text: 密码 + - paragraph [ref=f4e17]: + - link "忘记密码了?" [ref=f4e18] [cursor=pointer]: + - /url: /app/auth/reset/password + - generic [ref=f4e20]: + - textbox "密码" [ref=f4e21] + - button "Show password" [ref=f4e22] [cursor=pointer] + - button "登录" [ref=f4e24] [cursor=pointer] \ No newline at end of file diff --git a/.playwright-cli/page-2026-07-11T10-27-34-399Z.yml b/.playwright-cli/page-2026-07-11T10-27-34-399Z.yml new file mode 100644 index 00000000..a0348824 --- /dev/null +++ b/.playwright-cli/page-2026-07-11T10-27-34-399Z.yml @@ -0,0 +1,218 @@ +- generic [ref=f8e4]: + - complementary [ref=f8e5]: + - generic [ref=f8e6]: + - button "112233" [ref=f8e12] + - generic [ref=f8e14]: + - link "搜索……" [ref=f8e15] [cursor=pointer]: + - /url: /app/accounts/1/search + - button [ref=f8e19] [cursor=pointer] + - navigation [ref=f8e21]: + - list [ref=f8e22]: + - listitem [ref=f8e23] [cursor=pointer]: + - button "我的收件箱" [ref=f8e24] + - listitem [ref=f8e29] [cursor=pointer]: + - button "会话" [ref=f8e30] + - listitem [ref=f8e35] [cursor=pointer]: + - button "联系人" [ref=f8e36] + - listitem [ref=f8e41] [cursor=pointer]: + - button "报告" [ref=f8e42] + - listitem [ref=f8e47] [cursor=pointer]: + - button "活动" [ref=f8e48] + - listitem [ref=f8e53] [cursor=pointer]: + - button "帮助中心" [ref=f8e54] + - listitem [ref=f8e59] [cursor=pointer]: + - button "设置" [ref=f8e60] + - button "QA 展示名 admin@gochat.local" [ref=f8e68] [cursor=pointer]: + - img [ref=f8e71]: + - img "QA 展示名" [ref=f8e72] + - generic [ref=f8e73]: + - generic [ref=f8e74]: QA 展示名 + - generic [ref=f8e75]: admin@gochat.local + - main [ref=f8e78]: + - generic [ref=f8e81]: + - heading "个人资料设置" [level=1] [ref=f8e84] + - generic [ref=f8e87]: + - generic [ref=f8e88]: + - generic [ref=f8e89]: 个人资料图片 + - img [ref=f8e91]: + - img "Super Admin1" [ref=f8e92] + - generic [ref=f8e93]: + - generic [ref=f8e94]: + - text: 您的全名 + - textbox "您的全名" [ref=f8e95]: + - /placeholder: 请输入您的姓名 + - text: Super Admin1 + - generic [ref=f8e96]: + - text: 展示名 + - textbox "展示名" [ref=f8e97]: + - /placeholder: 请输入显示名称,将会在对话中显示 + - text: QA 展示名 + - generic [ref=f8e98]: + - text: 您的电子邮件地址 + - textbox "您的电子邮件地址" [ref=f8e99]: + - /placeholder: 请输入您的名字,这将会在对话中显示 + - text: admin@gochat.local + - button "更新个人资料" [ref=f8e101] [cursor=pointer] + - generic [ref=f8e103]: + - generic [ref=f8e105]: + - heading "界面" [level=4] [ref=f8e106] + - paragraph [ref=f8e107]: 自定义您的 GoChat 仪表板的外观和感觉。 + - generic [ref=f8e110]: + - generic [ref=f8e111]: + - generic [ref=f8e112]: + - generic [ref=f8e113]: 字体大小 + - paragraph [ref=f8e114]: 根据您的偏好调整仪表板中的文本大小。 + - combobox [ref=f8e118]: + - option "较小" + - option "小" + - option "默认" [selected] + - option "大" + - option "较大" + - generic [ref=f8e119]: + - generic [ref=f8e120]: + - generic [ref=f8e121]: 首选语言 + - paragraph [ref=f8e122]: 选择您想要使用的语言。 + - combobox [ref=f8e126]: + - option "使用账户默认值" + - option "中文" [selected] + - option "English" + - generic [ref=f8e127]: + - generic [ref=f8e129]: + - heading "个人消息签名" [level=4] [ref=f8e130] + - paragraph [ref=f8e131]: 创建一个唯一的消息签名来显示在您从任何收件箱发送的消息的末尾。 您也可以包含一个内联图片,它支持在直播聊天、电子邮件和API收件箱中。 + - generic [ref=f8e134]: + - generic [ref=f8e137]: + - toolbar [ref=f8e138]: + - button "Toggle strong style" [ref=f8e140] [cursor=pointer] + - button "Toggle emphasis" [ref=f8e144] [cursor=pointer] + - button "Add or remove link" [disabled] [ref=f8e148] + - button "Undo last change" [disabled] [ref=f8e152] + - button "Redo last undone change" [disabled] [ref=f8e156] + - button "Upload image" [ref=f8e160] [cursor=pointer] + - paragraph [ref=f8e164]: test + - button "保存消息签名" [ref=f8e166] [cursor=pointer] + - generic [ref=f8e168]: + - generic [ref=f8e170]: + - heading "发送消息的快捷键" [level=4] [ref=f8e171] + - paragraph [ref=f8e172]: 您可以根据您的写入偏好选择一个快捷键 (输入或 Cmd/Ctrl+Enter)。 + - generic [ref=f8e175]: + - generic [ref=f8e177] [cursor=pointer]: + - generic [ref=f8e178]: + - heading "Enter (↵)" [level=3] [ref=f8e180] + - radio "Enter (↵) 按 Enter 键发送消息,而不是单击发送按钮。 Light themed image for Enter (↵)" [ref=f8e181] + - paragraph [ref=f8e182]: 按 Enter 键发送消息,而不是单击发送按钮。 + - img "Light themed image for Enter (↵)" [ref=f8e183] + - generic [ref=f8e185] [cursor=pointer]: + - generic [ref=f8e186]: + - heading "Cmd/Ctrl + Enter (⌘ + ↵)" [level=3] [ref=f8e188] + - radio "Cmd/Ctrl + Enter (⌘ + ↵) 按下 Cmd/Ctrl + Enter 发送消息,而不是点击发送按钮。 Light themed image for Cmd/Ctrl + Enter (⌘ + ↵)" [checked] [ref=f8e189] + - paragraph [ref=f8e190]: 按下 Cmd/Ctrl + Enter 发送消息,而不是点击发送按钮。 + - img "Light themed image for Cmd/Ctrl + Enter (⌘ + ↵)" [ref=f8e191] + - generic [ref=f8e192]: + - heading "密码" [level=4] [ref=f8e195] + - generic [ref=f8e199]: + - generic [ref=f8e200]: + - text: 当前密码 + - textbox "当前密码" [ref=f8e201]: + - /placeholder: 请输入当前密码 + - generic [ref=f8e202]: + - text: 新密码 + - textbox "新密码" [ref=f8e203]: + - /placeholder: 请输入新密码 + - generic [ref=f8e204]: + - text: 重新输入一次密码 + - textbox "重新输入一次密码" [ref=f8e205]: + - /placeholder: 请重新输入您的新密码 + - button "更改密码" [disabled] [ref=f8e207] + - generic [ref=f8e210]: + - generic [ref=f8e212]: + - heading "音频提醒" [level=4] [ref=f8e213] + - paragraph [ref=f8e214]: 在仪表板中为新消息和会话启用音频提醒。 + - generic [ref=f8e217]: + - generic [ref=f8e218]: + - generic [ref=f8e219]: + - generic [ref=f8e220]: 提醒音调: + - combobox [ref=f8e223]: + - option "Ding" [selected] + - option "Bell" + - option "Chime" + - option "Magic" + - option "Ping" + - button [ref=f8e224] [cursor=pointer] + - generic [ref=f8e226]: + - generic [ref=f8e227]: 会话提醒事件 + - generic [ref=f8e228]: + - generic [ref=f8e229]: + - checkbox "分配给我的会话" [ref=f8e230] + - generic [ref=f8e231]: 分配给我的会话 + - generic [ref=f8e232]: + - checkbox "未分配的会话" [ref=f8e233] + - generic [ref=f8e234]: 未分配的会话 + - generic [ref=f8e235]: + - checkbox "分配给其他人的开放会话" [ref=f8e236] + - generic [ref=f8e237]: 分配给其他人的开放会话 + - generic [ref=f8e238]: 您尚未选择任何选项,您不会收到任何音频提醒。 + - generic [ref=f8e239]: + - generic [ref=f8e240]: 提醒条件: + - generic [ref=f8e241]: + - generic [ref=f8e242]: + - checkbox "仅在浏览器窗口未激活时发送音频提醒" [checked] [ref=f8e243]: ✓ + - generic [ref=f8e244]: 仅在浏览器窗口未激活时发送音频提醒 + - generic [ref=f8e245]: + - checkbox "每30秒发送一次提醒,直到所有分配的会话被阅读" [ref=f8e246] + - generic [ref=f8e247]: 每30秒发送一次提醒,直到所有分配的会话被阅读 + - generic [ref=f8e249]: + - heading "通知偏好" [level=4] [ref=f8e252] + - generic [ref=f8e255]: + - generic [ref=f8e256]: + - generic [ref=f8e257]: + - generic [ref=f8e258]: 通知类型 + - generic [ref=f8e260]: 电子邮件 + - generic [ref=f8e262]: 推送通知 + - generic [ref=f8e266]: + - generic [ref=f8e267]: 新会话已创建 + - checkbox [checked] [ref=f8e270]: ✓ + - checkbox [checked] [ref=f8e272]: ✓ + - generic [ref=f8e274]: + - generic [ref=f8e275]: 会话已分配给您 + - checkbox [checked] [ref=f8e278]: ✓ + - checkbox [checked] [ref=f8e280]: ✓ + - generic [ref=f8e282]: + - generic [ref=f8e283]: 您在会话中被提及 + - checkbox [checked] [ref=f8e286]: ✓ + - checkbox [checked] [ref=f8e288]: ✓ + - generic [ref=f8e290]: + - generic [ref=f8e291]: 在分配的会话中创建了新消息 + - checkbox [checked] [ref=f8e294]: ✓ + - checkbox [checked] [ref=f8e296]: ✓ + - generic [ref=f8e298]: + - generic [ref=f8e299]: 在参与的会话中创建了新消息 + - checkbox [checked] [ref=f8e302]: ✓ + - checkbox [checked] [ref=f8e304]: ✓ + - generic [ref=f8e306]: + - generic [ref=f8e307]: 会话错过了首次响应 SLA + - checkbox [checked] [ref=f8e310]: ✓ + - checkbox [checked] [ref=f8e312]: ✓ + - generic [ref=f8e314]: + - generic [ref=f8e315]: 会话错过了下次响应 SLA + - checkbox [checked] [ref=f8e318]: ✓ + - checkbox [checked] [ref=f8e320]: ✓ + - generic [ref=f8e322]: + - generic [ref=f8e323]: 会话错过了解决 SLA + - checkbox [checked] [ref=f8e326]: ✓ + - checkbox [checked] [ref=f8e328]: ✓ + - text: ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ + - generic [ref=f8e329]: + - generic [ref=f8e330]: 为您的浏览器启用推送通知,以便您能够接收它们 + - switch "切换开关" [ref=f8e334] [cursor=pointer] + - generic [ref=f8e338]: + - generic [ref=f8e340]: + - heading "访问令牌" [level=4] [ref=f8e341] + - paragraph [ref=f8e342]: 如果您正在构建基于 API 的集成,这个令牌可以被使用 + - generic [ref=f8e345]: + - generic [ref=f8e346]: + - textbox [ref=f8e347]: gochat_pat_23fd97a29962a50bc275d44e57cc1fdc58f243f8cf2bceb148083f744e1c3b4f + - button [ref=f8e348] [cursor=pointer] + - generic [ref=f8e351]: + - button "复制" [ref=f8e352] [cursor=pointer] + - button "重置" [ref=f8e356] [cursor=pointer] \ No newline at end of file diff --git a/.playwright-cli/page-2026-07-11T10-28-13-679Z.yml b/.playwright-cli/page-2026-07-11T10-28-13-679Z.yml new file mode 100644 index 00000000..adb7c2ff --- /dev/null +++ b/.playwright-cli/page-2026-07-11T10-28-13-679Z.yml @@ -0,0 +1,18 @@ +- generic [ref=f5e3]: + - main [ref=f5e4]: + - heading "登录到GoChat" [level=2] [ref=f5e6] + - generic [ref=f5e9]: + - generic [ref=f5e10]: + - generic [ref=f5e11]: Email + - textbox "实例 @companyname.com" [ref=f5e14]: admin@gochat.local + - generic [ref=f5e15]: + - generic [ref=f5e16]: + - text: 密码 + - paragraph [ref=f5e17]: + - link "忘记密码了?" [ref=f5e18] [cursor=pointer]: + - /url: /app/auth/reset/password + - generic [ref=f5e20]: + - textbox "密码" [ref=f5e21]: CHANGE_ME_TO_A_STRONG_PASSWORD + - button "Show password" [ref=f5e22] [cursor=pointer] + - button "登录" [ref=f5e24] [cursor=pointer] + - generic [ref=f5e26]: "[object Object]" \ No newline at end of file diff --git a/.playwright-cli/page-2026-07-11T10-29-26-429Z.yml b/.playwright-cli/page-2026-07-11T10-29-26-429Z.yml new file mode 100644 index 00000000..e69de29b diff --git a/.playwright-cli/page-2026-07-11T10-29-56-755Z.yml b/.playwright-cli/page-2026-07-11T10-29-56-755Z.yml new file mode 100644 index 00000000..40a42fba --- /dev/null +++ b/.playwright-cli/page-2026-07-11T10-29-56-755Z.yml @@ -0,0 +1,63 @@ +- generic [ref=f6e4]: + - complementary [ref=f6e5]: + - generic [ref=f6e6]: + - button "112233" [ref=f6e12] + - generic [ref=f6e14]: + - link "搜索……" [ref=f6e15] [cursor=pointer]: + - /url: /app/accounts/1/search + - button [ref=f6e19] [cursor=pointer] + - navigation [ref=f6e21]: + - list [ref=f6e22]: + - listitem [ref=f6e23] [cursor=pointer]: + - button "我的收件箱" [ref=f6e24] + - listitem [ref=f6e29] [cursor=pointer]: + - button "会话" [ref=f6e30] + - listitem [ref=f6e35] [cursor=pointer]: + - button "联系人" [ref=f6e36] + - listitem [ref=f6e41] [cursor=pointer]: + - button "报告" [ref=f6e42] + - listitem [ref=f6e47] [cursor=pointer]: + - button "活动" [ref=f6e48] + - listitem [ref=f6e53] [cursor=pointer]: + - button "帮助中心" [ref=f6e54] + - list [ref=f6e60]: + - listitem [ref=f6e61]: + - link "文章" [ref=f6e62]: + - /url: /app/accounts/1/portals/portals_articles_index + - listitem [ref=f6e64]: + - link "类别" [ref=f6e65]: + - /url: /app/accounts/1/portals/portals_categories_index + - listitem [ref=f6e67]: + - link "语言" [ref=f6e68]: + - /url: /app/accounts/1/portals/portals_locales_index + - listitem [ref=f6e70]: + - link "设置" [ref=f6e71]: + - /url: /app/accounts/1/portals/portals_settings_index + - listitem [ref=f6e73] [cursor=pointer]: + - button "设置" [ref=f6e74] + - button "Super Admin1 admin@gochat.local" [ref=f6e82] [cursor=pointer]: + - img [ref=f6e85]: + - generic [ref=f6e86]: SA + - generic [ref=f6e87]: + - generic [ref=f6e88]: Super Admin1 + - generic [ref=f6e89]: admin@gochat.local + - main [ref=f6e92]: + - generic [ref=f6e95]: + - generic [ref=f6e98]: + - button "返回文章" [ref=f6e99] [cursor=pointer] + - generic [ref=f6e103]: + - button "预览" [active] [ref=f6e104] [cursor=pointer] + - generic [ref=f6e106]: + - button "发布" [disabled] [ref=f6e107] + - button [ref=f6e111] [cursor=pointer] + - main [ref=f6e113]: + - generic [ref=f6e114]: + - generic [ref=f6e115]: + - textbox "Title" [ref=f6e118]: asdfasdf + - generic [ref=f6e119]: + - button "Super Admin1" [ref=f6e122] [cursor=pointer]: + - img [ref=f6e124]: + - generic [ref=f6e125]: SA + - button "Smoke Guides" [ref=f6e130] [cursor=pointer] + - button "更多属性" [ref=f6e136] [cursor=pointer] + - paragraph [ref=f6e144]: asdfasdfasdfasdfadsf \ No newline at end of file diff --git a/.playwright-cli/page-2026-07-11T10-34-06-292Z.yml b/.playwright-cli/page-2026-07-11T10-34-06-292Z.yml new file mode 100644 index 00000000..e69de29b diff --git a/.playwright-cli/page-2026-07-11T11-14-38-804Z.yml b/.playwright-cli/page-2026-07-11T11-14-38-804Z.yml new file mode 100644 index 00000000..bbe31813 --- /dev/null +++ b/.playwright-cli/page-2026-07-11T11-14-38-804Z.yml @@ -0,0 +1,16 @@ +- main [ref=e4]: + - heading "登录到GoChat" [level=2] [ref=e6] + - generic [ref=e9]: + - generic [ref=e10]: + - generic [ref=e11]: Email + - textbox "实例 @companyname.com" [ref=e14] + - generic [ref=e15]: + - generic [ref=e16]: + - text: 密码 + - paragraph [ref=e17]: + - link "忘记密码了?" [ref=e18] [cursor=pointer]: + - /url: /app/auth/reset/password + - generic [ref=e20]: + - textbox "密码" [ref=e21] + - button "Show password" [ref=e22] [cursor=pointer] + - button "登录" [ref=e24] [cursor=pointer] \ No newline at end of file diff --git a/.playwright-cli/page-2026-07-11T11-15-17-732Z.yml b/.playwright-cli/page-2026-07-11T11-15-17-732Z.yml new file mode 100644 index 00000000..e69de29b diff --git a/.playwright-cli/page-2026-07-11T11-15-48-091Z.yml b/.playwright-cli/page-2026-07-11T11-15-48-091Z.yml new file mode 100644 index 00000000..d1d0cea5 --- /dev/null +++ b/.playwright-cli/page-2026-07-11T11-15-48-091Z.yml @@ -0,0 +1,98 @@ +- generic [ref=f1e4]: + - complementary [ref=f1e5]: + - generic [ref=f1e6]: + - button "112233" [ref=f1e12] + - generic [ref=f1e14]: + - link "搜索……" [ref=f1e15] [cursor=pointer]: + - /url: /app/accounts/1/search + - button [ref=f1e19] [cursor=pointer] + - navigation [ref=f1e21]: + - list [ref=f1e22]: + - listitem [ref=f1e23] [cursor=pointer]: + - button "我的收件箱" [ref=f1e24] + - listitem [ref=f1e29] [cursor=pointer]: + - button "会话" [ref=f1e30] + - listitem [ref=f1e73] [cursor=pointer]: + - button "联系人" [ref=f1e74] + - listitem [ref=f1e79] [cursor=pointer]: + - button "报告" [ref=f1e80] + - listitem [ref=f1e85] [cursor=pointer]: + - button "活动" [ref=f1e86] + - listitem [ref=f1e91] [cursor=pointer]: + - button "帮助中心" [ref=f1e92] + - listitem [ref=f1e97] [cursor=pointer]: + - button "设置" [ref=f1e98] + - list [ref=f1e159]: + - listitem [ref=f1e160]: + - link "账户设置" [ref=f1e161]: + - /url: /app/accounts/1/settings/general + - listitem [ref=f1e165]: + - link "客服代理" [ref=f1e166]: + - /url: /app/accounts/1/settings/agents/list + - listitem [ref=f1e170]: + - link "团队" [ref=f1e171]: + - /url: /app/accounts/1/settings/teams/list + - listitem [ref=f1e175]: + - link "客服分配" [ref=f1e176]: + - /url: /app/accounts/1/settings/assignment-policy/index + - listitem [ref=f1e180]: + - link "收件箱" [ref=f1e181]: + - /url: /app/accounts/1/settings/inboxes/list + - listitem [ref=f1e185]: + - link "标签" [ref=f1e186]: + - /url: /app/accounts/1/settings/labels/list + - listitem [ref=f1e190]: + - link "自定义属性" [ref=f1e191]: + - /url: /app/accounts/1/settings/custom-attributes/list + - listitem [ref=f1e195]: + - link "自动化" [ref=f1e196]: + - /url: /app/accounts/1/settings/automation/list + - listitem [ref=f1e200]: + - link "机器人" [ref=f1e201]: + - /url: /app/accounts/1/settings/agent-bots + - listitem [ref=f1e205]: + - link "宏" [ref=f1e206]: + - /url: /app/accounts/1/settings/macros + - listitem [ref=f1e210]: + - link "预设回复" [ref=f1e211]: + - /url: /app/accounts/1/settings/canned-response/list + - listitem [ref=f1e215]: + - link "集成方式" [ref=f1e216]: + - /url: /app/accounts/1/settings/integrations + - listitem [ref=f1e220]: + - link "会话工作流" [ref=f1e221]: + - /url: /app/accounts/1/settings/conversation-workflow + - button "Super Admin1 admin@gochat.local" [ref=f1e106] [cursor=pointer]: + - img [ref=f1e109]: + - generic [ref=f1e110]: SA + - generic [ref=f1e111]: + - generic [ref=f1e112]: Super Admin1 + - generic [ref=f1e113]: admin@gochat.local + - main [ref=f1e116]: + - generic [ref=f1e227]: + - heading "帐户设置" [level=1] [ref=f1e230] + - generic [ref=f1e232]: + - heading "常规设置" [level=4] [ref=f1e235] + - generic [ref=f1e238]: + - generic [ref=f1e239]: + - generic [ref=f1e240]: 帐户名称 + - textbox "您的帐户名称" [ref=f1e244]: "112233" + - generic [ref=f1e245]: + - generic [ref=f1e246]: 站点语言 + - combobox [ref=f1e249]: + - option "English" + - option "中文" [selected] + - button "更新设置" [ref=f1e251] [cursor=pointer] + - generic [ref=f1e253]: + - generic [ref=f1e254]: + - heading "转录语音消息" [level=4] [ref=f1e255] + - paragraph [ref=f1e256]: 自动转录对话中的语音消息。当发送或收到语音消息时生成转录的文本,并将其显示在消息旁边。 + - switch "切换开关" [checked] [ref=f1e259] [cursor=pointer] + - generic [ref=f1e263]: + - generic [ref=f1e265]: + - heading "账号 ID" [level=4] [ref=f1e266] + - paragraph [ref=f1e267]: 如果您正在构建基于 API 的集成,那么此 ID 是必需的 + - generic [ref=f1e270]: + - button "复制" [ref=f1e272] [cursor=pointer] + - code [ref=f1e275]: "1" + - generic [ref=f1e276]: v \ No newline at end of file diff --git a/.playwright-cli/page-2026-07-11T11-16-00-815Z.yml b/.playwright-cli/page-2026-07-11T11-16-00-815Z.yml new file mode 100644 index 00000000..5d480cbf --- /dev/null +++ b/.playwright-cli/page-2026-07-11T11-16-00-815Z.yml @@ -0,0 +1,102 @@ +- generic [ref=f1e4]: + - complementary [ref=f1e5]: + - generic [ref=f1e6]: + - button "112233" [ref=f1e12] + - generic [ref=f1e14]: + - link "搜索……" [ref=f1e15] [cursor=pointer]: + - /url: /app/accounts/1/search + - button [ref=f1e19] [cursor=pointer] + - navigation [ref=f1e21]: + - list [ref=f1e22]: + - listitem [ref=f1e23] [cursor=pointer]: + - button "我的收件箱" [ref=f1e24] + - listitem [ref=f1e29] [cursor=pointer]: + - button "会话" [ref=f1e30] + - listitem [ref=f1e73] [cursor=pointer]: + - button "联系人" [ref=f1e74] + - listitem [ref=f1e79] [cursor=pointer]: + - button "报告" [ref=f1e80] + - listitem [ref=f1e85] [cursor=pointer]: + - button "活动" [ref=f1e86] + - listitem [ref=f1e91] [cursor=pointer]: + - button "帮助中心" [ref=f1e92] + - listitem [ref=f1e97] [cursor=pointer]: + - button "设置" [ref=f1e98] + - list [ref=f1e159]: + - listitem [ref=f1e160]: + - link "账户设置" [ref=f1e161]: + - /url: /app/accounts/1/settings/general + - listitem [ref=f1e165]: + - link "客服代理" [ref=f1e166]: + - /url: /app/accounts/1/settings/agents/list + - listitem [ref=f1e170]: + - link "团队" [ref=f1e171]: + - /url: /app/accounts/1/settings/teams/list + - listitem [ref=f1e175]: + - link "客服分配" [ref=f1e176]: + - /url: /app/accounts/1/settings/assignment-policy/index + - listitem [ref=f1e180]: + - link "收件箱" [active] [ref=f1e181]: + - /url: /app/accounts/1/settings/inboxes/list + - listitem [ref=f1e185]: + - link "标签" [ref=f1e186]: + - /url: /app/accounts/1/settings/labels/list + - listitem [ref=f1e190]: + - link "自定义属性" [ref=f1e191]: + - /url: /app/accounts/1/settings/custom-attributes/list + - listitem [ref=f1e195]: + - link "自动化" [ref=f1e196]: + - /url: /app/accounts/1/settings/automation/list + - listitem [ref=f1e200]: + - link "机器人" [ref=f1e201]: + - /url: /app/accounts/1/settings/agent-bots + - listitem [ref=f1e205]: + - link "宏" [ref=f1e206]: + - /url: /app/accounts/1/settings/macros + - listitem [ref=f1e210]: + - link "预设回复" [ref=f1e211]: + - /url: /app/accounts/1/settings/canned-response/list + - listitem [ref=f1e215]: + - link "集成方式" [ref=f1e216]: + - /url: /app/accounts/1/settings/integrations + - listitem [ref=f1e220]: + - link "会话工作流" [ref=f1e221]: + - /url: /app/accounts/1/settings/conversation-workflow + - button "Super Admin1 admin@gochat.local" [ref=f1e106] [cursor=pointer]: + - img [ref=f1e109]: + - generic [ref=f1e110]: SA + - generic [ref=f1e111]: + - generic [ref=f1e112]: Super Admin1 + - generic [ref=f1e113]: admin@gochat.local + - main [ref=f1e116]: + - generic [ref=f1e278]: + - generic [ref=f1e279]: + - heading "收件箱" [level=1] [ref=f1e281] + - paragraph [ref=f1e283]: 频道是客户选择与您互动的方式。收件箱则是您用来管理特定频道互动的地方,它可以整合来自不同来源的通信,比如电子邮件、即时聊天和社交媒体等。 + - generic [ref=f1e284]: + - searchbox "Search inboxes..." [ref=f1e288] + - generic [ref=f1e289]: + - generic [ref=f1e290]: 2 inboxes + - link [ref=f1e292] [cursor=pointer]: + - /url: /app/accounts/1/settings/inboxes/new + - button "添加收件箱" [ref=f1e293] + - main [ref=f1e295]: + - generic [ref=f1e296]: + - generic [ref=f1e297]: + - generic [ref=f1e301]: + - generic [ref=f1e302]: test + - generic [ref=f1e303]: INBOX_MGMT.CHANNELS.undefined + - generic [ref=f1e304]: + - link [ref=f1e305] [cursor=pointer]: + - /url: /app/accounts/1/settings/inboxes/2 + - button [ref=f1e306] + - button [ref=f1e308] [cursor=pointer] + - generic [ref=f1e310]: + - generic [ref=f1e314]: + - generic [ref=f1e315]: Test Website Inbox1 + - generic [ref=f1e316]: 网站 + - generic [ref=f1e317]: + - link [ref=f1e318] [cursor=pointer]: + - /url: /app/accounts/1/settings/inboxes/1 + - button [ref=f1e319] + - button [ref=f1e321] [cursor=pointer] \ No newline at end of file diff --git a/.playwright-cli/page-2026-07-11T11-16-26-579Z.yml b/.playwright-cli/page-2026-07-11T11-16-26-579Z.yml new file mode 100644 index 00000000..cad965e9 --- /dev/null +++ b/.playwright-cli/page-2026-07-11T11-16-26-579Z.yml @@ -0,0 +1,265 @@ +- generic [ref=f1e3]: + - generic [ref=f1e4]: + - complementary [ref=f1e5]: + - generic [ref=f1e6]: + - button "112233" [ref=f1e12] + - generic [ref=f1e14]: + - link "搜索……" [ref=f1e15] [cursor=pointer]: + - /url: /app/accounts/1/search + - button [ref=f1e19] [cursor=pointer] + - navigation [ref=f1e21]: + - list [ref=f1e22]: + - listitem [ref=f1e23] [cursor=pointer]: + - button "我的收件箱" [ref=f1e24] + - listitem [ref=f1e29] [cursor=pointer]: + - button "会话" [ref=f1e30] + - listitem [ref=f1e73] [cursor=pointer]: + - button "联系人" [ref=f1e74] + - listitem [ref=f1e79] [cursor=pointer]: + - button "报告" [ref=f1e80] + - listitem [ref=f1e85] [cursor=pointer]: + - button "活动" [ref=f1e86] + - listitem [ref=f1e91] [cursor=pointer]: + - button "帮助中心" [ref=f1e92] + - listitem [ref=f1e97] [cursor=pointer]: + - button "设置" [ref=f1e98] + - list [ref=f1e159]: + - listitem [ref=f1e160]: + - link "账户设置" [ref=f1e161]: + - /url: /app/accounts/1/settings/general + - listitem [ref=f1e165]: + - link "客服代理" [ref=f1e166]: + - /url: /app/accounts/1/settings/agents/list + - listitem [ref=f1e170]: + - link "团队" [ref=f1e171]: + - /url: /app/accounts/1/settings/teams/list + - listitem [ref=f1e175]: + - link "客服分配" [ref=f1e176]: + - /url: /app/accounts/1/settings/assignment-policy/index + - listitem [ref=f1e180]: + - link "收件箱" [ref=f1e181]: + - /url: /app/accounts/1/settings/inboxes/list + - listitem [ref=f1e185]: + - link "标签" [ref=f1e186]: + - /url: /app/accounts/1/settings/labels/list + - listitem [ref=f1e190]: + - link "自定义属性" [ref=f1e191]: + - /url: /app/accounts/1/settings/custom-attributes/list + - listitem [ref=f1e195]: + - link "自动化" [ref=f1e196]: + - /url: /app/accounts/1/settings/automation/list + - listitem [ref=f1e200]: + - link "机器人" [ref=f1e201]: + - /url: /app/accounts/1/settings/agent-bots + - listitem [ref=f1e205]: + - link "宏" [ref=f1e206]: + - /url: /app/accounts/1/settings/macros + - listitem [ref=f1e210]: + - link "预设回复" [ref=f1e211]: + - /url: /app/accounts/1/settings/canned-response/list + - listitem [ref=f1e215]: + - link "集成方式" [ref=f1e216]: + - /url: /app/accounts/1/settings/integrations + - listitem [ref=f1e220]: + - link "会话工作流" [ref=f1e221]: + - /url: /app/accounts/1/settings/conversation-workflow + - button "Super Admin1 admin@gochat.local" [ref=f1e106] [cursor=pointer]: + - img [ref=f1e109]: + - generic [ref=f1e110]: SA + - generic [ref=f1e111]: + - generic [ref=f1e112]: Super Admin1 + - generic [ref=f1e113]: admin@gochat.local + - main [ref=f1e116]: + - generic [ref=f1e323]: + - heading "后退 收件箱" [level=1] [ref=f1e325]: + - button "后退" [ref=f1e326] [cursor=pointer] + - generic [ref=f1e328]: 收件箱 + - generic [ref=f1e329]: + - generic [ref=f1e331]: + - heading "Test Website Inbox1" [level=2] [ref=f1e332] + - list [ref=f1e334]: + - listitem [ref=f1e335]: + - generic [ref=f1e336] [cursor=pointer]: 设置 + - listitem [ref=f1e337]: + - generic [ref=f1e338] [cursor=pointer]: 协作者 + - listitem [ref=f1e339]: + - generic [ref=f1e340] [cursor=pointer]: 工作时间 + - listitem [ref=f1e341]: + - generic [ref=f1e342] [cursor=pointer]: 客户满意度 + - listitem [ref=f1e343]: + - generic [ref=f1e344] [cursor=pointer]: 预聊天表单 + - listitem [ref=f1e345]: + - generic [ref=f1e346] [cursor=pointer]: 配置 + - listitem [ref=f1e347]: + - generic [ref=f1e348] [cursor=pointer]: 机器人配置 + - generic [ref=f1e351]: + - generic [ref=f1e352]: + - generic [ref=f1e353]: 频道头像 + - generic [ref=f1e359]: + - generic [ref=f1e360]: 网站名称 + - 'textbox "输入您的网站名称 (e.g: Acme Inc)" [ref=f1e365]': Test Website Inbox1 + - generic [ref=f1e367]: + - generic [ref=f1e368]: 网站域名 + - 'textbox "输入您的网站域名(e.g: acme.com)" [ref=f1e373]': http://localhost:3036 + - generic [ref=f1e374]: + - generic [ref=f1e375]: + - generic [ref=f1e376]: 帮助中心 + - combobox [ref=f1e379]: + - option "选择帮助中心" [disabled] + - option "啥都没有" + - option "Smoke Help Center" [selected] + - paragraph [ref=f1e381]: 将帮助中心附加到收件箱 + - generic [ref=f1e382]: + - generic [ref=f1e383]: + - generic [ref=f1e384]: 发件人姓名 + - button "配置您的业务名称" [ref=f1e386] [cursor=pointer] + - generic [ref=f1e388]: + - generic [ref=f1e390] [cursor=pointer]: + - generic [ref=f1e391]: + - heading "友好的" [level=3] [ref=f1e393] + - radio "友好的 在发件人姓名中添加回复的代理名称,使其更加友好。 S Smith 发自 Chatwoot " [ref=f1e394] + - paragraph [ref=f1e395]: 在发件人姓名中添加回复的代理名称,使其更加友好。 + - generic [ref=f1e396]: + - img [ref=f1e398]: + - generic [ref=f1e399]: S + - generic [ref=f1e400]: + - generic [ref=f1e401]: + - generic [ref=f1e402]: Smith + - generic [ref=f1e403]: 发自 + - generic [ref=f1e404]: Chatwoot + - generic [ref=f1e405]: + - generic [ref=f1e407] [cursor=pointer]: + - generic [ref=f1e408]: + - heading "专业的" [level=3] [ref=f1e410] + - radio "专业的 仅在电子邮件标题中使用配置的业务名称作为发件人姓名。 C Chatwoot " [checked] [ref=f1e411] + - paragraph [ref=f1e412]: 仅在电子邮件标题中使用配置的业务名称作为发件人姓名。 + - generic [ref=f1e413]: + - img [ref=f1e415]: + - generic [ref=f1e416]: C + - generic [ref=f1e417]: + - generic [ref=f1e418]: Chatwoot + - generic [ref=f1e420]: + - generic [ref=f1e421] [cursor=pointer]: Widget features + - generic [ref=f1e423]: + - generic [ref=f1e425]: + - generic [ref=f1e426]: 欢迎标题: + - textbox "你好!" [ref=f1e431]: "111" + - generic [ref=f1e433]: + - generic [ref=f1e434]: 欢迎标签行 + - generic [ref=f1e437]: + - generic [ref=f1e440]: + - toolbar [ref=f1e441]: + - button "Toggle strong style" [ref=f1e443] [cursor=pointer] + - button "Toggle emphasis" [ref=f1e447] [cursor=pointer] + - button "Add or remove link" [disabled] [ref=f1e451] + - button "Undo last change" [disabled] [ref=f1e455] + - button "Redo last undone change" [disabled] [ref=f1e459] + - paragraph [ref=f1e463]: "22222" + - generic [ref=f1e464]: 5 / 255 + - generic [ref=f1e467]: + - generic [ref=f1e468]: 窗口小部件颜色 + - button "#1f93ff" [ref=f1e473] [cursor=pointer] + - generic [ref=f1e479]: + - generic [ref=f1e480]: Bubble + - generic [ref=f1e482]: + - generic [ref=f1e483]: + - generic [ref=f1e484]: "Position:" + - combobox [ref=f1e486]: + - option "左侧" + - option "右侧" [selected] + - generic [ref=f1e488]: + - generic [ref=f1e489]: "类型:" + - combobox [ref=f1e491]: + - option "标准" [selected] + - option "扩展气泡" + - generic [ref=f1e493]: + - generic [ref=f1e494]: Launcher Title + - textbox "联系我们" [ref=f1e499] + - generic [ref=f1e500]: + - generic [ref=f1e501]: + - generic [ref=f1e502]: 设置回复时间 + - combobox [ref=f1e505]: + - option "几分钟前" [selected] + - option "几个小时前" + - option "一天内" + - paragraph [ref=f1e507]: 此回复时间将会显示在实时聊天窗口上 + - generic [ref=f1e509]: + - generic [ref=f1e510]: 特性 + - generic [ref=f1e512]: + - generic [ref=f1e513]: + - checkbox [ref=f1e514] + - generic [ref=f1e515]: 在小部件上显示文件选择器 + - generic [ref=f1e516]: + - checkbox [ref=f1e517] + - generic [ref=f1e518]: 在小部件上显示表情选择器 + - generic [ref=f1e519]: + - checkbox [ref=f1e520] + - generic [ref=f1e521]: 允许用户从小部件结束对话 + - generic [ref=f1e522]: + - checkbox [ref=f1e523] + - generic [ref=f1e524]: 使用收件箱名称和头像作为机器人 + - generic [ref=f1e525] [cursor=pointer]: Channel Preferences + - generic [ref=f1e527]: + - generic [ref=f1e528]: + - generic [ref=f1e529]: + - generic [ref=f1e530]: + - generic [ref=f1e531]: 开启频道问候功能 + - switch "切换开关" [checked] [ref=f1e532] [cursor=pointer] + - generic [ref=f1e536]: 当客户开始对话并发送第一条消息时,自动发送问候消息。 + - generic [ref=f1e542]: + - toolbar [ref=f1e543]: + - button "Toggle strong style" [ref=f1e545] [cursor=pointer] + - button "Toggle emphasis" [ref=f1e549] [cursor=pointer] + - button "Toggle code font" [ref=f1e553] [cursor=pointer] + - button "Add or remove link" [disabled] [ref=f1e557] + - button "Toggle strikethrough" [ref=f1e561] [cursor=pointer] + - button "Wrap in bullet list" [ref=f1e565] [cursor=pointer] + - button "Wrap in ordered list" [ref=f1e569] [cursor=pointer] + - button "Undo last change" [disabled] [ref=f1e573] + - button "Redo last undone change" [disabled] [ref=f1e577] + - paragraph [ref=f1e581]: Hello from GoChat + - generic [ref=f1e583]: + - generic [ref=f1e584]: + - generic [ref=f1e585]: 启用电子邮件收集框 + - switch "切换开关" [checked] [ref=f1e586] [cursor=pointer] + - generic [ref=f1e590]: 在新对话中启用或禁用电子邮件收集框 + - generic [ref=f1e592]: + - generic [ref=f1e593]: + - generic [ref=f1e594]: 允许在对话解决后发送消息 + - switch "切换开关" [checked] [ref=f1e595] [cursor=pointer] + - generic [ref=f1e599]: 允许最终用户在对话解决后发送消息。 + - generic [ref=f1e601]: + - generic [ref=f1e602]: + - generic [ref=f1e603]: 通过电子邮件启用对话连续性 + - switch "切换开关" [checked] [ref=f1e604] [cursor=pointer] + - generic [ref=f1e608]: 如果有联系人的电子邮件地址,对话将会继续在电子邮件中进行。 + - button "更新" [ref=f1e610] [cursor=pointer] + - generic [ref=f1e614]: + - generic [ref=f1e615]: + - generic [ref=f1e616]: + - button "预览" [ref=f1e617] [cursor=pointer] + - button "脚本" [ref=f1e619] [cursor=pointer] + - generic [ref=f1e620]: + - generic [ref=f1e621]: Chat mode + - switch "切换开关" [ref=f1e622] [cursor=pointer] + - generic [ref=f1e627]: + - generic [ref=f1e628]: + - generic [ref=f1e631]: + - heading "111" [level=2] [ref=f1e632] + - paragraph [ref=f1e633]: + - paragraph [ref=f1e634]: "22222" + - generic [ref=f1e635]: + - generic [ref=f1e637]: + - generic [ref=f1e638]: + - generic [ref=f1e639]: + - generic [ref=f1e640]: 我们在线 + - generic [ref=f1e641]: 通常在几分钟内回复您 + - img [ref=f1e643]: + - generic [ref=f1e644]: C + - button "开始会话" [ref=f1e645] [cursor=pointer] + - generic [ref=f1e649]: 由 GoChat 支持 + - button [ref=f1e654] [cursor=pointer] + - generic [ref=f1e658]: + - generic [ref=f1e661]: 离线的 + - button "刷新" [ref=f1e662] [cursor=pointer] + - button [ref=f1e664] [cursor=pointer] \ No newline at end of file diff --git a/.playwright-cli/page-2026-07-11T11-16-46-711Z.yml b/.playwright-cli/page-2026-07-11T11-16-46-711Z.yml new file mode 100644 index 00000000..70c58513 --- /dev/null +++ b/.playwright-cli/page-2026-07-11T11-16-46-711Z.yml @@ -0,0 +1,247 @@ +- generic [ref=f1e4]: + - complementary [ref=f1e5]: + - generic [ref=f1e6]: + - button "112233" [ref=f1e12] + - generic [ref=f1e14]: + - link "搜索……" [ref=f1e15] [cursor=pointer]: + - /url: /app/accounts/1/search + - button [ref=f1e19] [cursor=pointer] + - navigation [ref=f1e21]: + - list [ref=f1e22]: + - listitem [ref=f1e23] [cursor=pointer]: + - button "我的收件箱" [ref=f1e24] + - listitem [ref=f1e29] [cursor=pointer]: + - button "会话" [ref=f1e30] + - listitem [ref=f1e73] [cursor=pointer]: + - button "联系人" [ref=f1e74] + - listitem [ref=f1e79] [cursor=pointer]: + - button "报告" [ref=f1e80] + - listitem [ref=f1e85] [cursor=pointer]: + - button "活动" [ref=f1e86] + - listitem [ref=f1e91] [cursor=pointer]: + - button "帮助中心" [ref=f1e92] + - listitem [ref=f1e97] [cursor=pointer]: + - button "设置" [ref=f1e98] + - list [ref=f1e159]: + - listitem [ref=f1e160]: + - link "账户设置" [ref=f1e161]: + - /url: /app/accounts/1/settings/general + - listitem [ref=f1e165]: + - link "客服代理" [ref=f1e166]: + - /url: /app/accounts/1/settings/agents/list + - listitem [ref=f1e170]: + - link "团队" [ref=f1e171]: + - /url: /app/accounts/1/settings/teams/list + - listitem [ref=f1e175]: + - link "客服分配" [ref=f1e176]: + - /url: /app/accounts/1/settings/assignment-policy/index + - listitem [ref=f1e180]: + - link "收件箱" [ref=f1e181]: + - /url: /app/accounts/1/settings/inboxes/list + - listitem [ref=f1e185]: + - link "标签" [ref=f1e186]: + - /url: /app/accounts/1/settings/labels/list + - listitem [ref=f1e190]: + - link "自定义属性" [ref=f1e191]: + - /url: /app/accounts/1/settings/custom-attributes/list + - listitem [ref=f1e195]: + - link "自动化" [ref=f1e196]: + - /url: /app/accounts/1/settings/automation/list + - listitem [ref=f1e200]: + - link "机器人" [ref=f1e201]: + - /url: /app/accounts/1/settings/agent-bots + - listitem [ref=f1e205]: + - link "宏" [ref=f1e206]: + - /url: /app/accounts/1/settings/macros + - listitem [ref=f1e210]: + - link "预设回复" [ref=f1e211]: + - /url: /app/accounts/1/settings/canned-response/list + - listitem [ref=f1e215]: + - link "集成方式" [ref=f1e216]: + - /url: /app/accounts/1/settings/integrations + - listitem [ref=f1e220]: + - link "会话工作流" [ref=f1e221]: + - /url: /app/accounts/1/settings/conversation-workflow + - button "Super Admin1 admin@gochat.local" [ref=f1e106] [cursor=pointer]: + - img [ref=f1e109]: + - generic [ref=f1e110]: SA + - generic [ref=f1e111]: + - generic [ref=f1e112]: Super Admin1 + - generic [ref=f1e113]: admin@gochat.local + - main [ref=f1e116]: + - generic [ref=f1e323]: + - heading "后退 收件箱" [level=1] [ref=f1e325]: + - button "后退" [ref=f1e326] [cursor=pointer] + - generic [ref=f1e328]: 收件箱 + - generic [ref=f1e329]: + - generic [ref=f1e331]: + - heading "Test Website Inbox1" [level=2] [ref=f1e332] + - list [ref=f1e334]: + - listitem [ref=f1e335]: + - generic [ref=f1e336] [cursor=pointer]: 设置 + - listitem [ref=f1e337]: + - generic [ref=f1e338] [cursor=pointer]: 协作者 + - listitem [ref=f1e339]: + - generic [ref=f1e340] [cursor=pointer]: 工作时间 + - listitem [ref=f1e341]: + - generic [ref=f1e342] [cursor=pointer]: 客户满意度 + - listitem [ref=f1e343]: + - generic [ref=f1e344] [cursor=pointer]: 预聊天表单 + - listitem [ref=f1e345]: + - generic [ref=f1e346] [cursor=pointer]: 配置 + - listitem [ref=f1e347]: + - generic [ref=f1e348] [cursor=pointer]: 机器人配置 + - generic [ref=f1e351]: + - generic [ref=f1e352]: + - generic [ref=f1e353]: 频道头像 + - generic [ref=f1e359]: + - generic [ref=f1e360]: 网站名称 + - 'textbox "输入您的网站名称 (e.g: Acme Inc)" [ref=f1e365]': Test Website Inbox1 + - generic [ref=f1e367]: + - generic [ref=f1e368]: 网站域名 + - 'textbox "输入您的网站域名(e.g: acme.com)" [ref=f1e373]': http://localhost:3036 + - generic [ref=f1e374]: + - generic [ref=f1e375]: + - generic [ref=f1e376]: 帮助中心 + - combobox [ref=f1e379]: + - option "选择帮助中心" [disabled] + - option "啥都没有" + - option "Smoke Help Center" [selected] + - paragraph [ref=f1e381]: 将帮助中心附加到收件箱 + - generic [ref=f1e382]: + - generic [ref=f1e383]: + - generic [ref=f1e384]: 发件人姓名 + - button "配置您的业务名称" [ref=f1e386] [cursor=pointer] + - generic [ref=f1e388]: + - generic [ref=f1e390] [cursor=pointer]: + - generic [ref=f1e391]: + - heading "友好的" [level=3] [ref=f1e393] + - radio "友好的 在发件人姓名中添加回复的代理名称,使其更加友好。 S Smith 发自 Chatwoot " [ref=f1e394] + - paragraph [ref=f1e395]: 在发件人姓名中添加回复的代理名称,使其更加友好。 + - generic [ref=f1e396]: + - img [ref=f1e398]: + - generic [ref=f1e399]: S + - generic [ref=f1e400]: + - generic [ref=f1e401]: + - generic [ref=f1e402]: Smith + - generic [ref=f1e403]: 发自 + - generic [ref=f1e404]: Chatwoot + - generic [ref=f1e405]: + - generic [ref=f1e407] [cursor=pointer]: + - generic [ref=f1e408]: + - heading "专业的" [level=3] [ref=f1e410] + - radio "专业的 仅在电子邮件标题中使用配置的业务名称作为发件人姓名。 C Chatwoot " [checked] [ref=f1e411] + - paragraph [ref=f1e412]: 仅在电子邮件标题中使用配置的业务名称作为发件人姓名。 + - generic [ref=f1e413]: + - img [ref=f1e415]: + - generic [ref=f1e416]: C + - generic [ref=f1e417]: + - generic [ref=f1e418]: Chatwoot + - generic [ref=f1e420]: + - generic [ref=f1e421] [cursor=pointer]: Widget features + - generic [ref=f1e423]: + - generic [ref=f1e425]: + - generic [ref=f1e426]: 欢迎标题: + - textbox "你好!" [ref=f1e431]: "111" + - generic [ref=f1e433]: + - generic [ref=f1e434]: 欢迎标签行 + - generic [ref=f1e437]: + - generic [ref=f1e440]: + - toolbar [ref=f1e441]: + - button "Toggle strong style" [ref=f1e443] [cursor=pointer] + - button "Toggle emphasis" [ref=f1e447] [cursor=pointer] + - button "Add or remove link" [disabled] [ref=f1e451] + - button "Undo last change" [disabled] [ref=f1e455] + - button "Redo last undone change" [disabled] [ref=f1e459] + - paragraph [ref=f1e463]: "22222" + - generic [ref=f1e464]: 5 / 255 + - generic [ref=f1e467]: + - generic [ref=f1e468]: 窗口小部件颜色 + - button "#1f93ff" [ref=f1e473] [cursor=pointer] + - generic [ref=f1e479]: + - generic [ref=f1e480]: Bubble + - generic [ref=f1e482]: + - generic [ref=f1e483]: + - generic [ref=f1e484]: "Position:" + - combobox [ref=f1e486]: + - option "左侧" + - option "右侧" [selected] + - generic [ref=f1e488]: + - generic [ref=f1e489]: "类型:" + - combobox [ref=f1e491]: + - option "标准" [selected] + - option "扩展气泡" + - generic [ref=f1e493]: + - generic [ref=f1e494]: Launcher Title + - textbox "联系我们" [ref=f1e499] + - generic [ref=f1e500]: + - generic [ref=f1e501]: + - generic [ref=f1e502]: 设置回复时间 + - combobox [ref=f1e505]: + - option "几分钟前" [selected] + - option "几个小时前" + - option "一天内" + - paragraph [ref=f1e507]: 此回复时间将会显示在实时聊天窗口上 + - generic [ref=f1e509]: + - generic [ref=f1e510]: 特性 + - generic [ref=f1e512]: + - generic [ref=f1e513]: + - checkbox [ref=f1e514] + - generic [ref=f1e515]: 在小部件上显示文件选择器 + - generic [ref=f1e516]: + - checkbox [ref=f1e517] + - generic [ref=f1e518]: 在小部件上显示表情选择器 + - generic [ref=f1e519]: + - checkbox [ref=f1e520] + - generic [ref=f1e521]: 允许用户从小部件结束对话 + - generic [ref=f1e522]: + - checkbox [ref=f1e523] + - generic [ref=f1e524]: 使用收件箱名称和头像作为机器人 + - generic [ref=f1e525] [cursor=pointer]: Channel Preferences + - generic [ref=f1e527]: + - generic [ref=f1e528]: + - generic [ref=f1e529]: + - generic [ref=f1e530]: + - generic [ref=f1e531]: 开启频道问候功能 + - switch "切换开关" [checked] [ref=f1e532] [cursor=pointer] + - generic [ref=f1e536]: 当客户开始对话并发送第一条消息时,自动发送问候消息。 + - generic [ref=f1e542]: + - toolbar [ref=f1e543]: + - button "Toggle strong style" [ref=f1e545] [cursor=pointer] + - button "Toggle emphasis" [ref=f1e549] [cursor=pointer] + - button "Toggle code font" [ref=f1e553] [cursor=pointer] + - button "Add or remove link" [disabled] [ref=f1e557] + - button "Toggle strikethrough" [ref=f1e561] [cursor=pointer] + - button "Wrap in bullet list" [ref=f1e565] [cursor=pointer] + - button "Wrap in ordered list" [ref=f1e569] [cursor=pointer] + - button "Undo last change" [disabled] [ref=f1e573] + - button "Redo last undone change" [disabled] [ref=f1e577] + - paragraph [ref=f1e581]: Hello from GoChat + - generic [ref=f1e583]: + - generic [ref=f1e584]: + - generic [ref=f1e585]: 启用电子邮件收集框 + - switch "切换开关" [checked] [ref=f1e586] [cursor=pointer] + - generic [ref=f1e590]: 在新对话中启用或禁用电子邮件收集框 + - generic [ref=f1e592]: + - generic [ref=f1e593]: + - generic [ref=f1e594]: 允许在对话解决后发送消息 + - switch "切换开关" [checked] [ref=f1e595] [cursor=pointer] + - generic [ref=f1e599]: 允许最终用户在对话解决后发送消息。 + - generic [ref=f1e601]: + - generic [ref=f1e602]: + - generic [ref=f1e603]: 通过电子邮件启用对话连续性 + - switch "切换开关" [checked] [ref=f1e604] [cursor=pointer] + - generic [ref=f1e608]: 如果有联系人的电子邮件地址,对话将会继续在电子邮件中进行。 + - button "更新" [ref=f1e610] [cursor=pointer] + - generic [ref=f1e614]: + - generic [ref=f1e616]: + - button "预览" [ref=f1e617] [cursor=pointer] + - button "脚本" [active] [ref=f1e619] [cursor=pointer] + - generic [ref=f1e667]: + - generic [ref=f1e668]: + - button "在 CodePen 中打开" [ref=f1e670] [cursor=pointer] + - button "复制" [ref=f1e672] [cursor=pointer] + - code [ref=f1e675]: + - generic [ref=f1e676]: \ No newline at end of file diff --git a/.playwright-cli/page-2026-07-11T11-17-17-226Z.yml b/.playwright-cli/page-2026-07-11T11-17-17-226Z.yml new file mode 100644 index 00000000..7fc633f4 --- /dev/null +++ b/.playwright-cli/page-2026-07-11T11-17-17-226Z.yml @@ -0,0 +1,249 @@ +- generic [ref=f1e3]: + - generic [ref=f1e4]: + - complementary [ref=f1e5]: + - generic [ref=f1e6]: + - button "112233" [ref=f1e12] + - generic [ref=f1e14]: + - link "搜索……" [ref=f1e15] [cursor=pointer]: + - /url: /app/accounts/1/search + - button [ref=f1e19] [cursor=pointer] + - navigation [ref=f1e21]: + - list [ref=f1e22]: + - listitem [ref=f1e23] [cursor=pointer]: + - button "我的收件箱" [ref=f1e24] + - listitem [ref=f1e29] [cursor=pointer]: + - button "会话" [ref=f1e30] + - listitem [ref=f1e73] [cursor=pointer]: + - button "联系人" [ref=f1e74] + - listitem [ref=f1e79] [cursor=pointer]: + - button "报告" [ref=f1e80] + - listitem [ref=f1e85] [cursor=pointer]: + - button "活动" [ref=f1e86] + - listitem [ref=f1e91] [cursor=pointer]: + - button "帮助中心" [ref=f1e92] + - listitem [ref=f1e97] [cursor=pointer]: + - button "设置" [ref=f1e98] + - list [ref=f1e159]: + - listitem [ref=f1e160]: + - link "账户设置" [ref=f1e161]: + - /url: /app/accounts/1/settings/general + - listitem [ref=f1e165]: + - link "客服代理" [ref=f1e166]: + - /url: /app/accounts/1/settings/agents/list + - listitem [ref=f1e170]: + - link "团队" [ref=f1e171]: + - /url: /app/accounts/1/settings/teams/list + - listitem [ref=f1e175]: + - link "客服分配" [ref=f1e176]: + - /url: /app/accounts/1/settings/assignment-policy/index + - listitem [ref=f1e180]: + - link "收件箱" [ref=f1e181]: + - /url: /app/accounts/1/settings/inboxes/list + - listitem [ref=f1e185]: + - link "标签" [ref=f1e186]: + - /url: /app/accounts/1/settings/labels/list + - listitem [ref=f1e190]: + - link "自定义属性" [ref=f1e191]: + - /url: /app/accounts/1/settings/custom-attributes/list + - listitem [ref=f1e195]: + - link "自动化" [ref=f1e196]: + - /url: /app/accounts/1/settings/automation/list + - listitem [ref=f1e200]: + - link "机器人" [ref=f1e201]: + - /url: /app/accounts/1/settings/agent-bots + - listitem [ref=f1e205]: + - link "宏" [ref=f1e206]: + - /url: /app/accounts/1/settings/macros + - listitem [ref=f1e210]: + - link "预设回复" [ref=f1e211]: + - /url: /app/accounts/1/settings/canned-response/list + - listitem [ref=f1e215]: + - link "集成方式" [ref=f1e216]: + - /url: /app/accounts/1/settings/integrations + - listitem [ref=f1e220]: + - link "会话工作流" [ref=f1e221]: + - /url: /app/accounts/1/settings/conversation-workflow + - button "Super Admin1 admin@gochat.local" [ref=f1e106] [cursor=pointer]: + - img [ref=f1e109]: + - generic [ref=f1e110]: SA + - generic [ref=f1e111]: + - generic [ref=f1e112]: Super Admin1 + - generic [ref=f1e113]: admin@gochat.local + - main [ref=f1e116]: + - generic [ref=f1e323]: + - heading "后退 收件箱" [level=1] [ref=f1e325]: + - button "后退" [ref=f1e326] [cursor=pointer] + - generic [ref=f1e328]: 收件箱 + - generic [ref=f1e329]: + - generic [ref=f1e331]: + - heading "Test Website Inbox1" [level=2] [ref=f1e332] + - list [ref=f1e334]: + - listitem [ref=f1e335]: + - generic [ref=f1e336] [cursor=pointer]: 设置 + - listitem [ref=f1e337]: + - generic [ref=f1e338] [cursor=pointer]: 协作者 + - listitem [ref=f1e339]: + - generic [ref=f1e340] [cursor=pointer]: 工作时间 + - listitem [ref=f1e341]: + - generic [ref=f1e342] [cursor=pointer]: 客户满意度 + - listitem [ref=f1e343]: + - generic [ref=f1e344] [cursor=pointer]: 预聊天表单 + - listitem [ref=f1e345]: + - generic [ref=f1e346] [cursor=pointer]: 配置 + - listitem [ref=f1e347]: + - generic [ref=f1e348] [cursor=pointer]: 机器人配置 + - generic [ref=f1e351]: + - generic [ref=f1e352]: + - generic [ref=f1e353]: 频道头像 + - generic [ref=f1e359]: + - generic [ref=f1e360]: 网站名称 + - 'textbox "输入您的网站名称 (e.g: Acme Inc)" [ref=f1e365]': Test Website Inbox1 + - generic [ref=f1e367]: + - generic [ref=f1e368]: 网站域名 + - 'textbox "输入您的网站域名(e.g: acme.com)" [ref=f1e373]': http://localhost:3036 + - generic [ref=f1e374]: + - generic [ref=f1e375]: + - generic [ref=f1e376]: 帮助中心 + - combobox [ref=f1e379]: + - option "选择帮助中心" [disabled] + - option "啥都没有" + - option "Smoke Help Center" [selected] + - paragraph [ref=f1e381]: 将帮助中心附加到收件箱 + - generic [ref=f1e382]: + - generic [ref=f1e383]: + - generic [ref=f1e384]: 发件人姓名 + - button "配置您的业务名称" [ref=f1e386] [cursor=pointer] + - generic [ref=f1e388]: + - generic [ref=f1e390] [cursor=pointer]: + - generic [ref=f1e391]: + - heading "友好的" [level=3] [ref=f1e393] + - radio "友好的 在发件人姓名中添加回复的代理名称,使其更加友好。 S Smith 发自 Chatwoot " [ref=f1e394] + - paragraph [ref=f1e395]: 在发件人姓名中添加回复的代理名称,使其更加友好。 + - generic [ref=f1e396]: + - img [ref=f1e398]: + - generic [ref=f1e399]: S + - generic [ref=f1e400]: + - generic [ref=f1e401]: + - generic [ref=f1e402]: Smith + - generic [ref=f1e403]: 发自 + - generic [ref=f1e404]: Chatwoot + - generic [ref=f1e405]: + - generic [ref=f1e407] [cursor=pointer]: + - generic [ref=f1e408]: + - heading "专业的" [level=3] [ref=f1e410] + - radio "专业的 仅在电子邮件标题中使用配置的业务名称作为发件人姓名。 C Chatwoot " [checked] [ref=f1e411] + - paragraph [ref=f1e412]: 仅在电子邮件标题中使用配置的业务名称作为发件人姓名。 + - generic [ref=f1e413]: + - img [ref=f1e415]: + - generic [ref=f1e416]: C + - generic [ref=f1e417]: + - generic [ref=f1e418]: Chatwoot + - generic [ref=f1e420]: + - generic [ref=f1e421] [cursor=pointer]: Widget features + - generic [ref=f1e423]: + - generic [ref=f1e425]: + - generic [ref=f1e426]: 欢迎标题: + - textbox "你好!" [ref=f1e431]: "111" + - generic [ref=f1e433]: + - generic [ref=f1e434]: 欢迎标签行 + - generic [ref=f1e437]: + - generic [ref=f1e440]: + - toolbar [ref=f1e441]: + - button "Toggle strong style" [ref=f1e443] [cursor=pointer] + - button "Toggle emphasis" [ref=f1e447] [cursor=pointer] + - button "Add or remove link" [disabled] [ref=f1e451] + - button "Undo last change" [disabled] [ref=f1e455] + - button "Redo last undone change" [disabled] [ref=f1e459] + - paragraph [ref=f1e463]: "22222" + - generic [ref=f1e464]: 5 / 255 + - generic [ref=f1e467]: + - generic [ref=f1e468]: 窗口小部件颜色 + - button "#1f93ff" [ref=f1e473] [cursor=pointer] + - generic [ref=f1e479]: + - generic [ref=f1e480]: Bubble + - generic [ref=f1e482]: + - generic [ref=f1e483]: + - generic [ref=f1e484]: "Position:" + - combobox [ref=f1e486]: + - option "左侧" + - option "右侧" [selected] + - generic [ref=f1e488]: + - generic [ref=f1e489]: "类型:" + - combobox [ref=f1e491]: + - option "标准" [selected] + - option "扩展气泡" + - generic [ref=f1e493]: + - generic [ref=f1e494]: Launcher Title + - textbox "联系我们" [ref=f1e499] + - generic [ref=f1e500]: + - generic [ref=f1e501]: + - generic [ref=f1e502]: 设置回复时间 + - combobox [ref=f1e505]: + - option "几分钟前" [selected] + - option "几个小时前" + - option "一天内" + - paragraph [ref=f1e507]: 此回复时间将会显示在实时聊天窗口上 + - generic [ref=f1e509]: + - generic [ref=f1e510]: 特性 + - generic [ref=f1e512]: + - generic [ref=f1e513]: + - checkbox [ref=f1e514] + - generic [ref=f1e515]: 在小部件上显示文件选择器 + - generic [ref=f1e516]: + - checkbox [ref=f1e517] + - generic [ref=f1e518]: 在小部件上显示表情选择器 + - generic [ref=f1e519]: + - checkbox [ref=f1e520] + - generic [ref=f1e521]: 允许用户从小部件结束对话 + - generic [ref=f1e522]: + - checkbox [ref=f1e523] + - generic [ref=f1e524]: 使用收件箱名称和头像作为机器人 + - generic [ref=f1e525] [cursor=pointer]: Channel Preferences + - generic [ref=f1e527]: + - generic [ref=f1e528]: + - generic [ref=f1e529]: + - generic [ref=f1e530]: + - generic [ref=f1e531]: 开启频道问候功能 + - switch "切换开关" [checked] [ref=f1e532] [cursor=pointer] + - generic [ref=f1e536]: 当客户开始对话并发送第一条消息时,自动发送问候消息。 + - generic [ref=f1e542]: + - toolbar [ref=f1e543]: + - button "Toggle strong style" [ref=f1e545] [cursor=pointer] + - button "Toggle emphasis" [ref=f1e549] [cursor=pointer] + - button "Toggle code font" [ref=f1e553] [cursor=pointer] + - button "Add or remove link" [disabled] [ref=f1e557] + - button "Toggle strikethrough" [ref=f1e561] [cursor=pointer] + - button "Wrap in bullet list" [ref=f1e565] [cursor=pointer] + - button "Wrap in ordered list" [ref=f1e569] [cursor=pointer] + - button "Undo last change" [disabled] [ref=f1e573] + - button "Redo last undone change" [disabled] [ref=f1e577] + - paragraph [ref=f1e581]: Hello from GoChat + - generic [ref=f1e583]: + - generic [ref=f1e584]: + - generic [ref=f1e585]: 启用电子邮件收集框 + - switch "切换开关" [checked] [ref=f1e586] [cursor=pointer] + - generic [ref=f1e590]: 在新对话中启用或禁用电子邮件收集框 + - generic [ref=f1e592]: + - generic [ref=f1e593]: + - generic [ref=f1e594]: 允许在对话解决后发送消息 + - switch "切换开关" [checked] [ref=f1e595] [cursor=pointer] + - generic [ref=f1e599]: 允许最终用户在对话解决后发送消息。 + - generic [ref=f1e601]: + - generic [ref=f1e602]: + - generic [ref=f1e603]: 通过电子邮件启用对话连续性 + - switch "切换开关" [checked] [ref=f1e604] [cursor=pointer] + - generic [ref=f1e608]: 如果有联系人的电子邮件地址,对话将会继续在电子邮件中进行。 + - button "更新" [ref=f1e610] [cursor=pointer] + - generic [ref=f1e614]: + - generic [ref=f1e616]: + - button "预览" [ref=f1e617] [cursor=pointer] + - button "脚本" [ref=f1e619] [cursor=pointer] + - generic [ref=f1e667]: + - generic [ref=f1e668]: + - button "在 CodePen 中打开" [ref=f1e670] [cursor=pointer] + - button "复制" [active] [ref=f1e672] [cursor=pointer] + - code [ref=f1e675]: + - generic [ref=f1e676]: + - generic [ref=f1e679]: 已复制到剪贴板 \ No newline at end of file diff --git a/.playwright-cli/page-2026-07-11T11-17-40-156Z.yml b/.playwright-cli/page-2026-07-11T11-17-40-156Z.yml new file mode 100644 index 00000000..94a22ad6 --- /dev/null +++ b/.playwright-cli/page-2026-07-11T11-17-40-156Z.yml @@ -0,0 +1,252 @@ +- generic [ref=f1e3]: + - generic [ref=f1e4]: + - complementary [ref=f1e5]: + - generic [ref=f1e6]: + - button "112233" [ref=f1e12] + - generic [ref=f1e14]: + - link "搜索……" [ref=f1e15] [cursor=pointer]: + - /url: /app/accounts/1/search + - button [ref=f1e19] [cursor=pointer] + - navigation [ref=f1e21]: + - list [ref=f1e22]: + - listitem [ref=f1e23] [cursor=pointer]: + - button "我的收件箱" [ref=f1e24] + - listitem [ref=f1e29] [cursor=pointer]: + - button "会话" [ref=f1e30] + - listitem [ref=f1e73] [cursor=pointer]: + - button "联系人" [ref=f1e74] + - listitem [ref=f1e79] [cursor=pointer]: + - button "报告" [ref=f1e80] + - listitem [ref=f1e85] [cursor=pointer]: + - button "活动" [ref=f1e86] + - listitem [ref=f1e91] [cursor=pointer]: + - button "帮助中心" [ref=f1e92] + - listitem [ref=f1e97] [cursor=pointer]: + - button "设置" [ref=f1e98] + - list [ref=f1e159]: + - listitem [ref=f1e160]: + - link "账户设置" [ref=f1e161]: + - /url: /app/accounts/1/settings/general + - listitem [ref=f1e165]: + - link "客服代理" [ref=f1e166]: + - /url: /app/accounts/1/settings/agents/list + - listitem [ref=f1e170]: + - link "团队" [ref=f1e171]: + - /url: /app/accounts/1/settings/teams/list + - listitem [ref=f1e175]: + - link "客服分配" [ref=f1e176]: + - /url: /app/accounts/1/settings/assignment-policy/index + - listitem [ref=f1e180]: + - link "收件箱" [ref=f1e181]: + - /url: /app/accounts/1/settings/inboxes/list + - listitem [ref=f1e185]: + - link "标签" [ref=f1e186]: + - /url: /app/accounts/1/settings/labels/list + - listitem [ref=f1e190]: + - link "自定义属性" [ref=f1e191]: + - /url: /app/accounts/1/settings/custom-attributes/list + - listitem [ref=f1e195]: + - link "自动化" [ref=f1e196]: + - /url: /app/accounts/1/settings/automation/list + - listitem [ref=f1e200]: + - link "机器人" [ref=f1e201]: + - /url: /app/accounts/1/settings/agent-bots + - listitem [ref=f1e205]: + - link "宏" [ref=f1e206]: + - /url: /app/accounts/1/settings/macros + - listitem [ref=f1e210]: + - link "预设回复" [ref=f1e211]: + - /url: /app/accounts/1/settings/canned-response/list + - listitem [ref=f1e215]: + - link "集成方式" [ref=f1e216]: + - /url: /app/accounts/1/settings/integrations + - listitem [ref=f1e220]: + - link "会话工作流" [ref=f1e221]: + - /url: /app/accounts/1/settings/conversation-workflow + - button "Super Admin1 admin@gochat.local" [ref=f1e106] [cursor=pointer]: + - img [ref=f1e109]: + - generic [ref=f1e110]: SA + - generic [ref=f1e111]: + - generic [ref=f1e112]: Super Admin1 + - generic [ref=f1e113]: admin@gochat.local + - main [ref=f1e116]: + - generic [ref=f1e323]: + - heading "后退 收件箱" [level=1] [ref=f1e325]: + - button "后退" [ref=f1e326] [cursor=pointer] + - generic [ref=f1e328]: 收件箱 + - generic [ref=f1e329]: + - generic [ref=f1e331]: + - heading "Test Website Inbox1" [level=2] [ref=f1e332] + - list [ref=f1e334]: + - listitem [ref=f1e335]: + - generic [ref=f1e336] [cursor=pointer]: 设置 + - listitem [ref=f1e337]: + - generic [ref=f1e338] [cursor=pointer]: 协作者 + - listitem [ref=f1e339]: + - generic [ref=f1e340] [cursor=pointer]: 工作时间 + - listitem [ref=f1e341]: + - generic [ref=f1e342] [cursor=pointer]: 客户满意度 + - listitem [ref=f1e343]: + - generic [ref=f1e344] [cursor=pointer]: 预聊天表单 + - listitem [ref=f1e345]: + - generic [ref=f1e346] [cursor=pointer]: 配置 + - listitem [ref=f1e347]: + - generic [ref=f1e348] [cursor=pointer]: 机器人配置 + - generic [ref=f1e351]: + - generic [ref=f1e352]: + - generic [ref=f1e353]: 频道头像 + - generic [ref=f1e359]: + - generic [ref=f1e360]: 网站名称 + - 'textbox "输入您的网站名称 (e.g: Acme Inc)" [ref=f1e365]': Test Website Inbox1 + - generic [ref=f1e367]: + - generic [ref=f1e368]: 网站域名 + - 'textbox "输入您的网站域名(e.g: acme.com)" [ref=f1e373]': http://localhost:3036 + - generic [ref=f1e374]: + - generic [ref=f1e375]: + - generic [ref=f1e376]: 帮助中心 + - combobox [ref=f1e379]: + - option "选择帮助中心" [disabled] + - option "啥都没有" + - option "Smoke Help Center" [selected] + - paragraph [ref=f1e381]: 将帮助中心附加到收件箱 + - generic [ref=f1e382]: + - generic [ref=f1e383]: + - generic [ref=f1e384]: 发件人姓名 + - button "配置您的业务名称" [ref=f1e386] [cursor=pointer] + - generic [ref=f1e388]: + - generic [ref=f1e390] [cursor=pointer]: + - generic [ref=f1e391]: + - heading "友好的" [level=3] [ref=f1e393] + - radio "友好的 在发件人姓名中添加回复的代理名称,使其更加友好。 S Smith 发自 Chatwoot " [ref=f1e394] + - paragraph [ref=f1e395]: 在发件人姓名中添加回复的代理名称,使其更加友好。 + - generic [ref=f1e396]: + - img [ref=f1e398]: + - generic [ref=f1e399]: S + - generic [ref=f1e400]: + - generic [ref=f1e401]: + - generic [ref=f1e402]: Smith + - generic [ref=f1e403]: 发自 + - generic [ref=f1e404]: Chatwoot + - generic [ref=f1e405]: + - generic [ref=f1e407] [cursor=pointer]: + - generic [ref=f1e408]: + - heading "专业的" [level=3] [ref=f1e410] + - radio "专业的 仅在电子邮件标题中使用配置的业务名称作为发件人姓名。 C Chatwoot " [checked] [ref=f1e411] + - paragraph [ref=f1e412]: 仅在电子邮件标题中使用配置的业务名称作为发件人姓名。 + - generic [ref=f1e413]: + - img [ref=f1e415]: + - generic [ref=f1e416]: C + - generic [ref=f1e417]: + - generic [ref=f1e418]: Chatwoot + - generic [ref=f1e420]: + - generic [ref=f1e421] [cursor=pointer]: Widget features + - generic [ref=f1e423]: + - generic [ref=f1e425]: + - generic [ref=f1e426]: 欢迎标题: + - textbox "你好!" [ref=f1e431]: "111" + - generic [ref=f1e433]: + - generic [ref=f1e434]: 欢迎标签行 + - generic [ref=f1e437]: + - generic [ref=f1e440]: + - toolbar [ref=f1e441]: + - button "Toggle strong style" [ref=f1e443] [cursor=pointer] + - button "Toggle emphasis" [ref=f1e447] [cursor=pointer] + - button "Add or remove link" [disabled] [ref=f1e451] + - button "Undo last change" [disabled] [ref=f1e455] + - button "Redo last undone change" [disabled] [ref=f1e459] + - paragraph [ref=f1e463]: "22222" + - generic [ref=f1e464]: 5 / 255 + - generic [ref=f1e467]: + - generic [ref=f1e468]: 窗口小部件颜色 + - button "#1f93ff" [ref=f1e473] [cursor=pointer] + - generic [ref=f1e479]: + - generic [ref=f1e480]: Bubble + - generic [ref=f1e482]: + - generic [ref=f1e483]: + - generic [ref=f1e484]: "Position:" + - combobox [ref=f1e486]: + - option "左侧" + - option "右侧" [selected] + - generic [ref=f1e488]: + - generic [ref=f1e489]: "类型:" + - combobox [ref=f1e491]: + - option "标准" [selected] + - option "扩展气泡" + - generic [ref=f1e493]: + - generic [ref=f1e494]: Launcher Title + - textbox "联系我们" [ref=f1e499] + - generic [ref=f1e500]: + - generic [ref=f1e501]: + - generic [ref=f1e502]: 设置回复时间 + - combobox [ref=f1e505]: + - option "几分钟前" [selected] + - option "几个小时前" + - option "一天内" + - paragraph [ref=f1e507]: 此回复时间将会显示在实时聊天窗口上 + - generic [ref=f1e509]: + - generic [ref=f1e510]: 特性 + - generic [ref=f1e512]: + - generic [ref=f1e513]: + - checkbox [ref=f1e514] + - generic [ref=f1e515]: 在小部件上显示文件选择器 + - generic [ref=f1e516]: + - checkbox [ref=f1e517] + - generic [ref=f1e518]: 在小部件上显示表情选择器 + - generic [ref=f1e519]: + - checkbox [ref=f1e520] + - generic [ref=f1e521]: 允许用户从小部件结束对话 + - generic [ref=f1e522]: + - checkbox [ref=f1e523] + - generic [ref=f1e524]: 使用收件箱名称和头像作为机器人 + - generic [ref=f1e525] [cursor=pointer]: Channel Preferences + - generic [ref=f1e527]: + - generic [ref=f1e528]: + - generic [ref=f1e529]: + - generic [ref=f1e530]: + - generic [ref=f1e531]: 开启频道问候功能 + - switch "切换开关" [checked] [ref=f1e532] [cursor=pointer] + - generic [ref=f1e536]: 当客户开始对话并发送第一条消息时,自动发送问候消息。 + - generic [ref=f1e542]: + - toolbar [ref=f1e543]: + - button "Toggle strong style" [ref=f1e545] [cursor=pointer] + - button "Toggle emphasis" [ref=f1e549] [cursor=pointer] + - button "Toggle code font" [ref=f1e553] [cursor=pointer] + - button "Add or remove link" [disabled] [ref=f1e557] + - button "Toggle strikethrough" [ref=f1e561] [cursor=pointer] + - button "Wrap in bullet list" [ref=f1e565] [cursor=pointer] + - button "Wrap in ordered list" [ref=f1e569] [cursor=pointer] + - button "Undo last change" [disabled] [ref=f1e573] + - button "Redo last undone change" [disabled] [ref=f1e577] + - paragraph [ref=f1e581]: Hello from GoChat + - generic [ref=f1e583]: + - generic [ref=f1e584]: + - generic [ref=f1e585]: 启用电子邮件收集框 + - switch "切换开关" [checked] [ref=f1e586] [cursor=pointer] + - generic [ref=f1e590]: 在新对话中启用或禁用电子邮件收集框 + - generic [ref=f1e592]: + - generic [ref=f1e593]: + - generic [ref=f1e594]: 允许在对话解决后发送消息 + - switch "切换开关" [checked] [ref=f1e595] [cursor=pointer] + - generic [ref=f1e599]: 允许最终用户在对话解决后发送消息。 + - generic [ref=f1e601]: + - generic [ref=f1e602]: + - generic [ref=f1e603]: 通过电子邮件启用对话连续性 + - switch "切换开关" [checked] [ref=f1e604] [cursor=pointer] + - generic [ref=f1e608]: 如果有联系人的电子邮件地址,对话将会继续在电子邮件中进行。 + - button "更新" [ref=f1e610] [cursor=pointer] + - generic [ref=f1e614]: + - generic [ref=f1e616]: + - button "预览" [ref=f1e617] [cursor=pointer] + - button "脚本" [ref=f1e619] [cursor=pointer] + - generic [ref=f1e667]: + - generic [ref=f1e668]: + - button "在 CodePen 中打开" [ref=f1e670] [cursor=pointer] + - button "复制" [ref=f1e672] [cursor=pointer] + - code [ref=f1e675]: + - generic [ref=f1e676]: + - generic [ref=f1e679]: 已复制到剪贴板 + - generic [ref=f1e658]: + - generic [ref=f1e661]: 已重新连接 + - button [ref=f1e664] [cursor=pointer] \ No newline at end of file diff --git a/.playwright-cli/page-2026-07-11T11-18-05-134Z.png b/.playwright-cli/page-2026-07-11T11-18-05-134Z.png new file mode 100644 index 00000000..c8cf71c3 Binary files /dev/null and b/.playwright-cli/page-2026-07-11T11-18-05-134Z.png differ diff --git a/.playwright-cli/page-2026-07-11T11-18-22-034Z.yml b/.playwright-cli/page-2026-07-11T11-18-22-034Z.yml new file mode 100644 index 00000000..565e2ca2 --- /dev/null +++ b/.playwright-cli/page-2026-07-11T11-18-22-034Z.yml @@ -0,0 +1,249 @@ +- generic [ref=f1e3]: + - generic [ref=f1e4]: + - complementary [ref=f1e5]: + - generic [ref=f1e6]: + - button "112233" [ref=f1e12] + - generic [ref=f1e14]: + - link "搜索……" [ref=f1e15] [cursor=pointer]: + - /url: /app/accounts/1/search + - button [ref=f1e19] [cursor=pointer] + - navigation [ref=f1e21]: + - list [ref=f1e22]: + - listitem [ref=f1e23] [cursor=pointer]: + - button "我的收件箱" [ref=f1e24] + - listitem [ref=f1e29] [cursor=pointer]: + - button "会话" [ref=f1e30] + - listitem [ref=f1e73] [cursor=pointer]: + - button "联系人" [ref=f1e74] + - listitem [ref=f1e79] [cursor=pointer]: + - button "报告" [ref=f1e80] + - listitem [ref=f1e85] [cursor=pointer]: + - button "活动" [ref=f1e86] + - listitem [ref=f1e91] [cursor=pointer]: + - button "帮助中心" [ref=f1e92] + - listitem [ref=f1e97] [cursor=pointer]: + - button "设置" [ref=f1e98] + - list [ref=f1e159]: + - listitem [ref=f1e160]: + - link "账户设置" [ref=f1e161]: + - /url: /app/accounts/1/settings/general + - listitem [ref=f1e165]: + - link "客服代理" [ref=f1e166]: + - /url: /app/accounts/1/settings/agents/list + - listitem [ref=f1e170]: + - link "团队" [ref=f1e171]: + - /url: /app/accounts/1/settings/teams/list + - listitem [ref=f1e175]: + - link "客服分配" [ref=f1e176]: + - /url: /app/accounts/1/settings/assignment-policy/index + - listitem [ref=f1e180]: + - link "收件箱" [ref=f1e181]: + - /url: /app/accounts/1/settings/inboxes/list + - listitem [ref=f1e185]: + - link "标签" [ref=f1e186]: + - /url: /app/accounts/1/settings/labels/list + - listitem [ref=f1e190]: + - link "自定义属性" [ref=f1e191]: + - /url: /app/accounts/1/settings/custom-attributes/list + - listitem [ref=f1e195]: + - link "自动化" [ref=f1e196]: + - /url: /app/accounts/1/settings/automation/list + - listitem [ref=f1e200]: + - link "机器人" [ref=f1e201]: + - /url: /app/accounts/1/settings/agent-bots + - listitem [ref=f1e205]: + - link "宏" [ref=f1e206]: + - /url: /app/accounts/1/settings/macros + - listitem [ref=f1e210]: + - link "预设回复" [ref=f1e211]: + - /url: /app/accounts/1/settings/canned-response/list + - listitem [ref=f1e215]: + - link "集成方式" [ref=f1e216]: + - /url: /app/accounts/1/settings/integrations + - listitem [ref=f1e220]: + - link "会话工作流" [ref=f1e221]: + - /url: /app/accounts/1/settings/conversation-workflow + - button "Super Admin1 admin@gochat.local" [ref=f1e106] [cursor=pointer]: + - img [ref=f1e109]: + - generic [ref=f1e110]: SA + - generic [ref=f1e111]: + - generic [ref=f1e112]: Super Admin1 + - generic [ref=f1e113]: admin@gochat.local + - main [ref=f1e116]: + - generic [ref=f1e323]: + - heading "后退 收件箱" [level=1] [ref=f1e325]: + - button "后退" [ref=f1e326] [cursor=pointer] + - generic [ref=f1e328]: 收件箱 + - generic [ref=f1e329]: + - generic [ref=f1e331]: + - heading "Test Website Inbox1" [level=2] [ref=f1e332] + - list [ref=f1e334]: + - listitem [ref=f1e335]: + - generic [ref=f1e336] [cursor=pointer]: 设置 + - listitem [ref=f1e337]: + - generic [ref=f1e338] [cursor=pointer]: 协作者 + - listitem [ref=f1e339]: + - generic [ref=f1e340] [cursor=pointer]: 工作时间 + - listitem [ref=f1e341]: + - generic [ref=f1e342] [cursor=pointer]: 客户满意度 + - listitem [ref=f1e343]: + - generic [ref=f1e344] [cursor=pointer]: 预聊天表单 + - listitem [ref=f1e345]: + - generic [ref=f1e346] [cursor=pointer]: 配置 + - listitem [ref=f1e347]: + - generic [ref=f1e348] [cursor=pointer]: 机器人配置 + - generic [ref=f1e351]: + - generic [ref=f1e352]: + - generic [ref=f1e353]: 频道头像 + - generic [ref=f1e359]: + - generic [ref=f1e360]: 网站名称 + - 'textbox "输入您的网站名称 (e.g: Acme Inc)" [ref=f1e365]': Test Website Inbox1 + - generic [ref=f1e367]: + - generic [ref=f1e368]: 网站域名 + - 'textbox "输入您的网站域名(e.g: acme.com)" [ref=f1e373]': http://localhost:3036 + - generic [ref=f1e374]: + - generic [ref=f1e375]: + - generic [ref=f1e376]: 帮助中心 + - combobox [ref=f1e379]: + - option "选择帮助中心" [disabled] + - option "啥都没有" + - option "Smoke Help Center" [selected] + - paragraph [ref=f1e381]: 将帮助中心附加到收件箱 + - generic [ref=f1e382]: + - generic [ref=f1e383]: + - generic [ref=f1e384]: 发件人姓名 + - button "配置您的业务名称" [ref=f1e386] [cursor=pointer] + - generic [ref=f1e388]: + - generic [ref=f1e390] [cursor=pointer]: + - generic [ref=f1e391]: + - heading "友好的" [level=3] [ref=f1e393] + - radio "友好的 在发件人姓名中添加回复的代理名称,使其更加友好。 S Smith 发自 Chatwoot " [ref=f1e394] + - paragraph [ref=f1e395]: 在发件人姓名中添加回复的代理名称,使其更加友好。 + - generic [ref=f1e396]: + - img [ref=f1e398]: + - generic [ref=f1e399]: S + - generic [ref=f1e400]: + - generic [ref=f1e401]: + - generic [ref=f1e402]: Smith + - generic [ref=f1e403]: 发自 + - generic [ref=f1e404]: Chatwoot + - generic [ref=f1e405]: + - generic [ref=f1e407] [cursor=pointer]: + - generic [ref=f1e408]: + - heading "专业的" [level=3] [ref=f1e410] + - radio "专业的 仅在电子邮件标题中使用配置的业务名称作为发件人姓名。 C Chatwoot " [checked] [ref=f1e411] + - paragraph [ref=f1e412]: 仅在电子邮件标题中使用配置的业务名称作为发件人姓名。 + - generic [ref=f1e413]: + - img [ref=f1e415]: + - generic [ref=f1e416]: C + - generic [ref=f1e417]: + - generic [ref=f1e418]: Chatwoot + - generic [ref=f1e420]: + - generic [ref=f1e421] [cursor=pointer]: Widget features + - generic [ref=f1e423]: + - generic [ref=f1e425]: + - generic [ref=f1e426]: 欢迎标题: + - textbox "你好!" [ref=f1e431]: "111" + - generic [ref=f1e433]: + - generic [ref=f1e434]: 欢迎标签行 + - generic [ref=f1e437]: + - generic [ref=f1e440]: + - toolbar [ref=f1e441]: + - button "Toggle strong style" [ref=f1e443] [cursor=pointer] + - button "Toggle emphasis" [ref=f1e447] [cursor=pointer] + - button "Add or remove link" [disabled] [ref=f1e451] + - button "Undo last change" [disabled] [ref=f1e455] + - button "Redo last undone change" [disabled] [ref=f1e459] + - paragraph [ref=f1e463]: "22222" + - generic [ref=f1e464]: 5 / 255 + - generic [ref=f1e467]: + - generic [ref=f1e468]: 窗口小部件颜色 + - button "#1f93ff" [ref=f1e473] [cursor=pointer] + - generic [ref=f1e479]: + - generic [ref=f1e480]: Bubble + - generic [ref=f1e482]: + - generic [ref=f1e483]: + - generic [ref=f1e484]: "Position:" + - combobox [ref=f1e486]: + - option "左侧" + - option "右侧" [selected] + - generic [ref=f1e488]: + - generic [ref=f1e489]: "类型:" + - combobox [ref=f1e491]: + - option "标准" [selected] + - option "扩展气泡" + - generic [ref=f1e493]: + - generic [ref=f1e494]: Launcher Title + - textbox "联系我们" [ref=f1e499] + - generic [ref=f1e500]: + - generic [ref=f1e501]: + - generic [ref=f1e502]: 设置回复时间 + - combobox [ref=f1e505]: + - option "几分钟前" [selected] + - option "几个小时前" + - option "一天内" + - paragraph [ref=f1e507]: 此回复时间将会显示在实时聊天窗口上 + - generic [ref=f1e509]: + - generic [ref=f1e510]: 特性 + - generic [ref=f1e512]: + - generic [ref=f1e513]: + - checkbox [ref=f1e514] + - generic [ref=f1e515]: 在小部件上显示文件选择器 + - generic [ref=f1e516]: + - checkbox [ref=f1e517] + - generic [ref=f1e518]: 在小部件上显示表情选择器 + - generic [ref=f1e519]: + - checkbox [ref=f1e520] + - generic [ref=f1e521]: 允许用户从小部件结束对话 + - generic [ref=f1e522]: + - checkbox [ref=f1e523] + - generic [ref=f1e524]: 使用收件箱名称和头像作为机器人 + - generic [ref=f1e525] [cursor=pointer]: Channel Preferences + - generic [ref=f1e527]: + - generic [ref=f1e528]: + - generic [ref=f1e529]: + - generic [ref=f1e530]: + - generic [ref=f1e531]: 开启频道问候功能 + - switch "切换开关" [checked] [ref=f1e532] [cursor=pointer] + - generic [ref=f1e536]: 当客户开始对话并发送第一条消息时,自动发送问候消息。 + - generic [ref=f1e542]: + - toolbar [ref=f1e543]: + - button "Toggle strong style" [ref=f1e545] [cursor=pointer] + - button "Toggle emphasis" [ref=f1e549] [cursor=pointer] + - button "Toggle code font" [ref=f1e553] [cursor=pointer] + - button "Add or remove link" [disabled] [ref=f1e557] + - button "Toggle strikethrough" [ref=f1e561] [cursor=pointer] + - button "Wrap in bullet list" [ref=f1e565] [cursor=pointer] + - button "Wrap in ordered list" [ref=f1e569] [cursor=pointer] + - button "Undo last change" [disabled] [ref=f1e573] + - button "Redo last undone change" [disabled] [ref=f1e577] + - paragraph [ref=f1e581]: Hello from GoChat + - generic [ref=f1e583]: + - generic [ref=f1e584]: + - generic [ref=f1e585]: 启用电子邮件收集框 + - switch "切换开关" [checked] [ref=f1e586] [cursor=pointer] + - generic [ref=f1e590]: 在新对话中启用或禁用电子邮件收集框 + - generic [ref=f1e592]: + - generic [ref=f1e593]: + - generic [ref=f1e594]: 允许在对话解决后发送消息 + - switch "切换开关" [checked] [ref=f1e595] [cursor=pointer] + - generic [ref=f1e599]: 允许最终用户在对话解决后发送消息。 + - generic [ref=f1e601]: + - generic [ref=f1e602]: + - generic [ref=f1e603]: 通过电子邮件启用对话连续性 + - switch "切换开关" [checked] [ref=f1e604] [cursor=pointer] + - generic [ref=f1e608]: 如果有联系人的电子邮件地址,对话将会继续在电子邮件中进行。 + - button "更新" [ref=f1e610] [cursor=pointer] + - generic [ref=f1e614]: + - generic [ref=f1e616]: + - button "预览" [ref=f1e617] [cursor=pointer] + - button "脚本" [ref=f1e619] [cursor=pointer] + - generic [ref=f1e667]: + - generic [ref=f1e668]: + - button "在 CodePen 中打开" [ref=f1e670] [cursor=pointer] + - button "复制" [ref=f1e672] [cursor=pointer] + - code [ref=f1e675]: + - generic [ref=f1e676]: + - generic [ref=f1e679]: 已复制到剪贴板 \ No newline at end of file diff --git a/.playwright-cli/page-2026-07-11T11-18-54-159Z.yml b/.playwright-cli/page-2026-07-11T11-18-54-159Z.yml new file mode 100644 index 00000000..bbe31813 --- /dev/null +++ b/.playwright-cli/page-2026-07-11T11-18-54-159Z.yml @@ -0,0 +1,16 @@ +- main [ref=e4]: + - heading "登录到GoChat" [level=2] [ref=e6] + - generic [ref=e9]: + - generic [ref=e10]: + - generic [ref=e11]: Email + - textbox "实例 @companyname.com" [ref=e14] + - generic [ref=e15]: + - generic [ref=e16]: + - text: 密码 + - paragraph [ref=e17]: + - link "忘记密码了?" [ref=e18] [cursor=pointer]: + - /url: /app/auth/reset/password + - generic [ref=e20]: + - textbox "密码" [ref=e21] + - button "Show password" [ref=e22] [cursor=pointer] + - button "登录" [ref=e24] [cursor=pointer] \ No newline at end of file diff --git a/.playwright-cli/page-2026-07-11T11-19-03-846Z.yml b/.playwright-cli/page-2026-07-11T11-19-03-846Z.yml new file mode 100644 index 00000000..33e77831 --- /dev/null +++ b/.playwright-cli/page-2026-07-11T11-19-03-846Z.yml @@ -0,0 +1,35 @@ +- generic [ref=f1e4]: + - complementary [ref=f1e5]: + - generic [ref=f1e6]: + - button [ref=f1e12] + - generic [ref=f1e13]: + - link "搜索……" [ref=f1e14] [cursor=pointer]: + - /url: /app/accounts/1/search + - button [ref=f1e18] [cursor=pointer] + - navigation [ref=f1e20]: + - list [ref=f1e21]: + - listitem [ref=f1e22] [cursor=pointer]: + - button "我的收件箱" [ref=f1e23] + - listitem [ref=f1e28] [cursor=pointer]: + - button "会话" [ref=f1e29] + - listitem [ref=f1e34] [cursor=pointer]: + - button "报告" [ref=f1e35] + - listitem [ref=f1e40] [cursor=pointer]: + - button "设置" [ref=f1e41] + - list [ref=f1e47]: + - listitem [ref=f1e48]: + - link "账户设置" [ref=f1e49]: + - /url: /app/accounts/1/settings/general + - listitem [ref=f1e53]: + - link "会话工作流" [ref=f1e54]: + - /url: /app/accounts/1/settings/conversation-workflow + - button "Super Admin1 admin@gochat.local" [ref=f1e61] [cursor=pointer]: + - img [ref=f1e64]: + - generic [ref=f1e65]: SA + - generic [ref=f1e66]: + - generic [ref=f1e67]: Super Admin1 + - generic [ref=f1e68]: admin@gochat.local + - main [ref=f1e71]: + - heading "后退 收件箱" [level=1] [ref=f1e74]: + - button "后退" [ref=f1e75] [cursor=pointer] + - generic [ref=f1e77]: 收件箱 \ No newline at end of file diff --git a/.playwright-cli/page-2026-07-11T11-19-30-544Z.png b/.playwright-cli/page-2026-07-11T11-19-30-544Z.png new file mode 100644 index 00000000..7b1aef0b Binary files /dev/null and b/.playwright-cli/page-2026-07-11T11-19-30-544Z.png differ diff --git a/.playwright-cli/page-2026-07-11T11-20-18-454Z.yml b/.playwright-cli/page-2026-07-11T11-20-18-454Z.yml new file mode 100644 index 00000000..a1d3abbe --- /dev/null +++ b/.playwright-cli/page-2026-07-11T11-20-18-454Z.yml @@ -0,0 +1,247 @@ +- generic [ref=f1e4]: + - complementary [ref=f1e5]: + - generic [ref=f1e6]: + - button "112233" [ref=f1e81] + - generic [ref=f1e13]: + - link "搜索……" [ref=f1e14] [cursor=pointer]: + - /url: /app/accounts/1/search + - button [ref=f1e18] [cursor=pointer] + - navigation [ref=f1e20]: + - list [ref=f1e21]: + - listitem [ref=f1e22] [cursor=pointer]: + - button "我的收件箱" [ref=f1e23] + - listitem [ref=f1e28] [cursor=pointer]: + - button "会话" [ref=f1e29] + - listitem [ref=f1e83] [cursor=pointer]: + - button "联系人" [ref=f1e84] + - listitem [ref=f1e34] [cursor=pointer]: + - button "报告" [ref=f1e35] + - listitem [ref=f1e89] [cursor=pointer]: + - button "活动" [ref=f1e90] + - listitem [ref=f1e95] [cursor=pointer]: + - button "帮助中心" [ref=f1e96] + - listitem [ref=f1e40] [cursor=pointer]: + - button "设置" [ref=f1e41] + - list [ref=f1e47]: + - listitem [ref=f1e48]: + - link "账户设置" [ref=f1e49]: + - /url: /app/accounts/1/settings/general + - listitem [ref=f1e101]: + - link "客服代理" [ref=f1e102]: + - /url: /app/accounts/1/settings/agents/list + - listitem [ref=f1e106]: + - link "团队" [ref=f1e107]: + - /url: /app/accounts/1/settings/teams/list + - listitem [ref=f1e111]: + - link "客服分配" [ref=f1e112]: + - /url: /app/accounts/1/settings/assignment-policy/index + - listitem [ref=f1e116]: + - link "收件箱" [ref=f1e117]: + - /url: /app/accounts/1/settings/inboxes/list + - listitem [ref=f1e121]: + - link "标签" [ref=f1e122]: + - /url: /app/accounts/1/settings/labels/list + - listitem [ref=f1e126]: + - link "自定义属性" [ref=f1e127]: + - /url: /app/accounts/1/settings/custom-attributes/list + - listitem [ref=f1e131]: + - link "自动化" [ref=f1e132]: + - /url: /app/accounts/1/settings/automation/list + - listitem [ref=f1e136]: + - link "机器人" [ref=f1e137]: + - /url: /app/accounts/1/settings/agent-bots + - listitem [ref=f1e141]: + - link "宏" [ref=f1e142]: + - /url: /app/accounts/1/settings/macros + - listitem [ref=f1e146]: + - link "预设回复" [ref=f1e147]: + - /url: /app/accounts/1/settings/canned-response/list + - listitem [ref=f1e151]: + - link "集成方式" [ref=f1e152]: + - /url: /app/accounts/1/settings/integrations + - listitem [ref=f1e53]: + - link "会话工作流" [ref=f1e54]: + - /url: /app/accounts/1/settings/conversation-workflow + - button "Super Admin1 admin@gochat.local" [ref=f1e61] [cursor=pointer]: + - img [ref=f1e64]: + - generic [ref=f1e65]: SA + - generic [ref=f1e66]: + - generic [ref=f1e67]: Super Admin1 + - generic [ref=f1e68]: admin@gochat.local + - main [ref=f1e71]: + - generic [ref=f1e72]: + - heading "后退 收件箱" [level=1] [ref=f1e74]: + - button "后退" [ref=f1e75] [cursor=pointer] + - generic [ref=f1e77]: 收件箱 + - generic [ref=f1e156]: + - generic [ref=f1e158]: + - heading "Test Website Inbox1" [level=2] [ref=f1e159] + - list [ref=f1e161]: + - listitem [ref=f1e162]: + - generic [ref=f1e163] [cursor=pointer]: 设置 + - listitem [ref=f1e164]: + - generic [ref=f1e165] [cursor=pointer]: 协作者 + - listitem [ref=f1e166]: + - generic [ref=f1e167] [cursor=pointer]: 工作时间 + - listitem [ref=f1e168]: + - generic [ref=f1e169] [cursor=pointer]: 客户满意度 + - listitem [ref=f1e170]: + - generic [ref=f1e171] [cursor=pointer]: 预聊天表单 + - listitem [ref=f1e172]: + - generic [ref=f1e173] [cursor=pointer]: 配置 + - listitem [ref=f1e174]: + - generic [ref=f1e175] [cursor=pointer]: 机器人配置 + - generic [ref=f1e178]: + - generic [ref=f1e179]: + - generic [ref=f1e180]: 频道头像 + - generic [ref=f1e186]: + - generic [ref=f1e187]: 网站名称 + - 'textbox "输入您的网站名称 (e.g: Acme Inc)" [ref=f1e192]': Test Website Inbox1 + - generic [ref=f1e194]: + - generic [ref=f1e195]: 网站域名 + - 'textbox "输入您的网站域名(e.g: acme.com)" [ref=f1e200]': http://localhost:3036 + - generic [ref=f1e201]: + - generic [ref=f1e202]: + - generic [ref=f1e203]: 帮助中心 + - combobox [ref=f1e206]: + - option "选择帮助中心" [disabled] + - option "啥都没有" + - option "Smoke Help Center" [selected] + - paragraph [ref=f1e208]: 将帮助中心附加到收件箱 + - generic [ref=f1e209]: + - generic [ref=f1e210]: + - generic [ref=f1e211]: 发件人姓名 + - button "配置您的业务名称" [ref=f1e213] [cursor=pointer] + - generic [ref=f1e215]: + - generic [ref=f1e217] [cursor=pointer]: + - generic [ref=f1e218]: + - heading "友好的" [level=3] [ref=f1e220] + - radio "友好的 在发件人姓名中添加回复的代理名称,使其更加友好。 S Smith 发自 Chatwoot " [ref=f1e221] + - paragraph [ref=f1e222]: 在发件人姓名中添加回复的代理名称,使其更加友好。 + - generic [ref=f1e223]: + - img [ref=f1e225]: + - generic [ref=f1e226]: S + - generic [ref=f1e227]: + - generic [ref=f1e228]: + - generic [ref=f1e229]: Smith + - generic [ref=f1e230]: 发自 + - generic [ref=f1e231]: Chatwoot + - generic [ref=f1e232]: + - generic [ref=f1e234] [cursor=pointer]: + - generic [ref=f1e235]: + - heading "专业的" [level=3] [ref=f1e237] + - radio "专业的 仅在电子邮件标题中使用配置的业务名称作为发件人姓名。 C Chatwoot " [checked] [ref=f1e238] + - paragraph [ref=f1e239]: 仅在电子邮件标题中使用配置的业务名称作为发件人姓名。 + - generic [ref=f1e240]: + - img [ref=f1e242]: + - generic [ref=f1e243]: C + - generic [ref=f1e244]: + - generic [ref=f1e245]: Chatwoot + - generic [ref=f1e247]: + - generic [ref=f1e248] [cursor=pointer]: Widget features + - generic [ref=f1e250]: + - generic [ref=f1e252]: + - generic [ref=f1e253]: 欢迎标题: + - textbox "你好!" [ref=f1e258]: "111" + - generic [ref=f1e260]: + - generic [ref=f1e261]: 欢迎标签行 + - generic [ref=f1e264]: + - generic [ref=f1e267]: + - toolbar [ref=f1e268]: + - button "Toggle strong style" [ref=f1e270] [cursor=pointer] + - button "Toggle emphasis" [ref=f1e274] [cursor=pointer] + - button "Add or remove link" [disabled] [ref=f1e278] + - button "Undo last change" [disabled] [ref=f1e282] + - button "Redo last undone change" [disabled] [ref=f1e286] + - paragraph [ref=f1e290]: "22222" + - generic [ref=f1e291]: 5 / 255 + - generic [ref=f1e294]: + - generic [ref=f1e295]: 窗口小部件颜色 + - button "#1f93ff" [ref=f1e300] [cursor=pointer] + - generic [ref=f1e306]: + - generic [ref=f1e307]: Bubble + - generic [ref=f1e309]: + - generic [ref=f1e310]: + - generic [ref=f1e311]: "Position:" + - combobox [ref=f1e313]: + - option "左侧" + - option "右侧" [selected] + - generic [ref=f1e315]: + - generic [ref=f1e316]: "类型:" + - combobox [ref=f1e318]: + - option "标准" [selected] + - option "扩展气泡" + - generic [ref=f1e320]: + - generic [ref=f1e321]: Launcher Title + - textbox "联系我们" [ref=f1e326] + - generic [ref=f1e327]: + - generic [ref=f1e328]: + - generic [ref=f1e329]: 设置回复时间 + - combobox [ref=f1e332]: + - option "几分钟前" [selected] + - option "几个小时前" + - option "一天内" + - paragraph [ref=f1e334]: 此回复时间将会显示在实时聊天窗口上 + - generic [ref=f1e336]: + - generic [ref=f1e337]: 特性 + - generic [ref=f1e339]: + - generic [ref=f1e340]: + - checkbox [ref=f1e341] + - generic [ref=f1e342]: 在小部件上显示文件选择器 + - generic [ref=f1e343]: + - checkbox [ref=f1e344] + - generic [ref=f1e345]: 在小部件上显示表情选择器 + - generic [ref=f1e346]: + - checkbox [ref=f1e347] + - generic [ref=f1e348]: 允许用户从小部件结束对话 + - generic [ref=f1e349]: + - checkbox [ref=f1e350] + - generic [ref=f1e351]: 使用收件箱名称和头像作为机器人 + - generic [ref=f1e352] [cursor=pointer]: Channel Preferences + - generic [ref=f1e354]: + - generic [ref=f1e355]: + - generic [ref=f1e356]: + - generic [ref=f1e357]: + - generic [ref=f1e358]: 开启频道问候功能 + - switch "切换开关" [checked] [ref=f1e359] [cursor=pointer] + - generic [ref=f1e363]: 当客户开始对话并发送第一条消息时,自动发送问候消息。 + - generic [ref=f1e369]: + - toolbar [ref=f1e370]: + - button "Toggle strong style" [ref=f1e372] [cursor=pointer] + - button "Toggle emphasis" [ref=f1e376] [cursor=pointer] + - button "Toggle code font" [ref=f1e380] [cursor=pointer] + - button "Add or remove link" [disabled] [ref=f1e384] + - button "Toggle strikethrough" [ref=f1e388] [cursor=pointer] + - button "Wrap in bullet list" [ref=f1e392] [cursor=pointer] + - button "Wrap in ordered list" [ref=f1e396] [cursor=pointer] + - button "Undo last change" [disabled] [ref=f1e400] + - button "Redo last undone change" [disabled] [ref=f1e404] + - paragraph [ref=f1e408]: Hello from GoChat + - generic [ref=f1e410]: + - generic [ref=f1e411]: + - generic [ref=f1e412]: 启用电子邮件收集框 + - switch "切换开关" [checked] [ref=f1e413] [cursor=pointer] + - generic [ref=f1e417]: 在新对话中启用或禁用电子邮件收集框 + - generic [ref=f1e419]: + - generic [ref=f1e420]: + - generic [ref=f1e421]: 允许在对话解决后发送消息 + - switch "切换开关" [checked] [ref=f1e422] [cursor=pointer] + - generic [ref=f1e426]: 允许最终用户在对话解决后发送消息。 + - generic [ref=f1e428]: + - generic [ref=f1e429]: + - generic [ref=f1e430]: 通过电子邮件启用对话连续性 + - switch "切换开关" [checked] [ref=f1e431] [cursor=pointer] + - generic [ref=f1e435]: 如果有联系人的电子邮件地址,对话将会继续在电子邮件中进行。 + - button "更新" [ref=f1e437] [cursor=pointer] + - generic [ref=f1e441]: + - generic [ref=f1e443]: + - button "预览" [ref=f1e444] [cursor=pointer] + - button "脚本" [active] [ref=f1e446] [cursor=pointer] + - generic [ref=f1e485]: + - generic [ref=f1e486]: + - button "在 CodePen 中打开" [ref=f1e488] [cursor=pointer] + - button "复制" [ref=f1e490] [cursor=pointer] + - code [ref=f1e493]: + - generic [ref=f1e494]: \ No newline at end of file diff --git a/.playwright-cli/page-2026-07-11T11-20-41-066Z.yml b/.playwright-cli/page-2026-07-11T11-20-41-066Z.yml new file mode 100644 index 00000000..2c13dce0 --- /dev/null +++ b/.playwright-cli/page-2026-07-11T11-20-41-066Z.yml @@ -0,0 +1,249 @@ +- generic [ref=f1e3]: + - generic [ref=f1e4]: + - complementary [ref=f1e5]: + - generic [ref=f1e6]: + - button "112233" [ref=f1e81] + - generic [ref=f1e13]: + - link "搜索……" [ref=f1e14] [cursor=pointer]: + - /url: /app/accounts/1/search + - button [ref=f1e18] [cursor=pointer] + - navigation [ref=f1e20]: + - list [ref=f1e21]: + - listitem [ref=f1e22] [cursor=pointer]: + - button "我的收件箱" [ref=f1e23] + - listitem [ref=f1e28] [cursor=pointer]: + - button "会话" [ref=f1e29] + - listitem [ref=f1e83] [cursor=pointer]: + - button "联系人" [ref=f1e84] + - listitem [ref=f1e34] [cursor=pointer]: + - button "报告" [ref=f1e35] + - listitem [ref=f1e89] [cursor=pointer]: + - button "活动" [ref=f1e90] + - listitem [ref=f1e95] [cursor=pointer]: + - button "帮助中心" [ref=f1e96] + - listitem [ref=f1e40] [cursor=pointer]: + - button "设置" [ref=f1e41] + - list [ref=f1e47]: + - listitem [ref=f1e48]: + - link "账户设置" [ref=f1e49]: + - /url: /app/accounts/1/settings/general + - listitem [ref=f1e101]: + - link "客服代理" [ref=f1e102]: + - /url: /app/accounts/1/settings/agents/list + - listitem [ref=f1e106]: + - link "团队" [ref=f1e107]: + - /url: /app/accounts/1/settings/teams/list + - listitem [ref=f1e111]: + - link "客服分配" [ref=f1e112]: + - /url: /app/accounts/1/settings/assignment-policy/index + - listitem [ref=f1e116]: + - link "收件箱" [ref=f1e117]: + - /url: /app/accounts/1/settings/inboxes/list + - listitem [ref=f1e121]: + - link "标签" [ref=f1e122]: + - /url: /app/accounts/1/settings/labels/list + - listitem [ref=f1e126]: + - link "自定义属性" [ref=f1e127]: + - /url: /app/accounts/1/settings/custom-attributes/list + - listitem [ref=f1e131]: + - link "自动化" [ref=f1e132]: + - /url: /app/accounts/1/settings/automation/list + - listitem [ref=f1e136]: + - link "机器人" [ref=f1e137]: + - /url: /app/accounts/1/settings/agent-bots + - listitem [ref=f1e141]: + - link "宏" [ref=f1e142]: + - /url: /app/accounts/1/settings/macros + - listitem [ref=f1e146]: + - link "预设回复" [ref=f1e147]: + - /url: /app/accounts/1/settings/canned-response/list + - listitem [ref=f1e151]: + - link "集成方式" [ref=f1e152]: + - /url: /app/accounts/1/settings/integrations + - listitem [ref=f1e53]: + - link "会话工作流" [ref=f1e54]: + - /url: /app/accounts/1/settings/conversation-workflow + - button "Super Admin1 admin@gochat.local" [ref=f1e61] [cursor=pointer]: + - img [ref=f1e64]: + - generic [ref=f1e65]: SA + - generic [ref=f1e66]: + - generic [ref=f1e67]: Super Admin1 + - generic [ref=f1e68]: admin@gochat.local + - main [ref=f1e71]: + - generic [ref=f1e72]: + - heading "后退 收件箱" [level=1] [ref=f1e74]: + - button "后退" [ref=f1e75] [cursor=pointer] + - generic [ref=f1e77]: 收件箱 + - generic [ref=f1e156]: + - generic [ref=f1e158]: + - heading "Test Website Inbox1" [level=2] [ref=f1e159] + - list [ref=f1e161]: + - listitem [ref=f1e162]: + - generic [ref=f1e163] [cursor=pointer]: 设置 + - listitem [ref=f1e164]: + - generic [ref=f1e165] [cursor=pointer]: 协作者 + - listitem [ref=f1e166]: + - generic [ref=f1e167] [cursor=pointer]: 工作时间 + - listitem [ref=f1e168]: + - generic [ref=f1e169] [cursor=pointer]: 客户满意度 + - listitem [ref=f1e170]: + - generic [ref=f1e171] [cursor=pointer]: 预聊天表单 + - listitem [ref=f1e172]: + - generic [ref=f1e173] [cursor=pointer]: 配置 + - listitem [ref=f1e174]: + - generic [ref=f1e175] [cursor=pointer]: 机器人配置 + - generic [ref=f1e178]: + - generic [ref=f1e179]: + - generic [ref=f1e180]: 频道头像 + - generic [ref=f1e186]: + - generic [ref=f1e187]: 网站名称 + - 'textbox "输入您的网站名称 (e.g: Acme Inc)" [ref=f1e192]': Test Website Inbox1 + - generic [ref=f1e194]: + - generic [ref=f1e195]: 网站域名 + - 'textbox "输入您的网站域名(e.g: acme.com)" [ref=f1e200]': http://localhost:3036 + - generic [ref=f1e201]: + - generic [ref=f1e202]: + - generic [ref=f1e203]: 帮助中心 + - combobox [ref=f1e206]: + - option "选择帮助中心" [disabled] + - option "啥都没有" + - option "Smoke Help Center" [selected] + - paragraph [ref=f1e208]: 将帮助中心附加到收件箱 + - generic [ref=f1e209]: + - generic [ref=f1e210]: + - generic [ref=f1e211]: 发件人姓名 + - button "配置您的业务名称" [ref=f1e213] [cursor=pointer] + - generic [ref=f1e215]: + - generic [ref=f1e217] [cursor=pointer]: + - generic [ref=f1e218]: + - heading "友好的" [level=3] [ref=f1e220] + - radio "友好的 在发件人姓名中添加回复的代理名称,使其更加友好。 S Smith 发自 Chatwoot " [ref=f1e221] + - paragraph [ref=f1e222]: 在发件人姓名中添加回复的代理名称,使其更加友好。 + - generic [ref=f1e223]: + - img [ref=f1e225]: + - generic [ref=f1e226]: S + - generic [ref=f1e227]: + - generic [ref=f1e228]: + - generic [ref=f1e229]: Smith + - generic [ref=f1e230]: 发自 + - generic [ref=f1e231]: Chatwoot + - generic [ref=f1e232]: + - generic [ref=f1e234] [cursor=pointer]: + - generic [ref=f1e235]: + - heading "专业的" [level=3] [ref=f1e237] + - radio "专业的 仅在电子邮件标题中使用配置的业务名称作为发件人姓名。 C Chatwoot " [checked] [ref=f1e238] + - paragraph [ref=f1e239]: 仅在电子邮件标题中使用配置的业务名称作为发件人姓名。 + - generic [ref=f1e240]: + - img [ref=f1e242]: + - generic [ref=f1e243]: C + - generic [ref=f1e244]: + - generic [ref=f1e245]: Chatwoot + - generic [ref=f1e247]: + - generic [ref=f1e248] [cursor=pointer]: Widget features + - generic [ref=f1e250]: + - generic [ref=f1e252]: + - generic [ref=f1e253]: 欢迎标题: + - textbox "你好!" [ref=f1e258]: "111" + - generic [ref=f1e260]: + - generic [ref=f1e261]: 欢迎标签行 + - generic [ref=f1e264]: + - generic [ref=f1e267]: + - toolbar [ref=f1e268]: + - button "Toggle strong style" [ref=f1e270] [cursor=pointer] + - button "Toggle emphasis" [ref=f1e274] [cursor=pointer] + - button "Add or remove link" [disabled] [ref=f1e278] + - button "Undo last change" [disabled] [ref=f1e282] + - button "Redo last undone change" [disabled] [ref=f1e286] + - paragraph [ref=f1e290]: "22222" + - generic [ref=f1e291]: 5 / 255 + - generic [ref=f1e294]: + - generic [ref=f1e295]: 窗口小部件颜色 + - button "#1f93ff" [ref=f1e300] [cursor=pointer] + - generic [ref=f1e306]: + - generic [ref=f1e307]: Bubble + - generic [ref=f1e309]: + - generic [ref=f1e310]: + - generic [ref=f1e311]: "Position:" + - combobox [ref=f1e313]: + - option "左侧" + - option "右侧" [selected] + - generic [ref=f1e315]: + - generic [ref=f1e316]: "类型:" + - combobox [ref=f1e318]: + - option "标准" [selected] + - option "扩展气泡" + - generic [ref=f1e320]: + - generic [ref=f1e321]: Launcher Title + - textbox "联系我们" [ref=f1e326] + - generic [ref=f1e327]: + - generic [ref=f1e328]: + - generic [ref=f1e329]: 设置回复时间 + - combobox [ref=f1e332]: + - option "几分钟前" [selected] + - option "几个小时前" + - option "一天内" + - paragraph [ref=f1e334]: 此回复时间将会显示在实时聊天窗口上 + - generic [ref=f1e336]: + - generic [ref=f1e337]: 特性 + - generic [ref=f1e339]: + - generic [ref=f1e340]: + - checkbox [ref=f1e341] + - generic [ref=f1e342]: 在小部件上显示文件选择器 + - generic [ref=f1e343]: + - checkbox [ref=f1e344] + - generic [ref=f1e345]: 在小部件上显示表情选择器 + - generic [ref=f1e346]: + - checkbox [ref=f1e347] + - generic [ref=f1e348]: 允许用户从小部件结束对话 + - generic [ref=f1e349]: + - checkbox [ref=f1e350] + - generic [ref=f1e351]: 使用收件箱名称和头像作为机器人 + - generic [ref=f1e352] [cursor=pointer]: Channel Preferences + - generic [ref=f1e354]: + - generic [ref=f1e355]: + - generic [ref=f1e356]: + - generic [ref=f1e357]: + - generic [ref=f1e358]: 开启频道问候功能 + - switch "切换开关" [checked] [ref=f1e359] [cursor=pointer] + - generic [ref=f1e363]: 当客户开始对话并发送第一条消息时,自动发送问候消息。 + - generic [ref=f1e369]: + - toolbar [ref=f1e370]: + - button "Toggle strong style" [ref=f1e372] [cursor=pointer] + - button "Toggle emphasis" [ref=f1e376] [cursor=pointer] + - button "Toggle code font" [ref=f1e380] [cursor=pointer] + - button "Add or remove link" [disabled] [ref=f1e384] + - button "Toggle strikethrough" [ref=f1e388] [cursor=pointer] + - button "Wrap in bullet list" [ref=f1e392] [cursor=pointer] + - button "Wrap in ordered list" [ref=f1e396] [cursor=pointer] + - button "Undo last change" [disabled] [ref=f1e400] + - button "Redo last undone change" [disabled] [ref=f1e404] + - paragraph [ref=f1e408]: Hello from GoChat + - generic [ref=f1e410]: + - generic [ref=f1e411]: + - generic [ref=f1e412]: 启用电子邮件收集框 + - switch "切换开关" [checked] [ref=f1e413] [cursor=pointer] + - generic [ref=f1e417]: 在新对话中启用或禁用电子邮件收集框 + - generic [ref=f1e419]: + - generic [ref=f1e420]: + - generic [ref=f1e421]: 允许在对话解决后发送消息 + - switch "切换开关" [checked] [ref=f1e422] [cursor=pointer] + - generic [ref=f1e426]: 允许最终用户在对话解决后发送消息。 + - generic [ref=f1e428]: + - generic [ref=f1e429]: + - generic [ref=f1e430]: 通过电子邮件启用对话连续性 + - switch "切换开关" [checked] [ref=f1e431] [cursor=pointer] + - generic [ref=f1e435]: 如果有联系人的电子邮件地址,对话将会继续在电子邮件中进行。 + - button "更新" [ref=f1e437] [cursor=pointer] + - generic [ref=f1e441]: + - generic [ref=f1e443]: + - button "预览" [ref=f1e444] [cursor=pointer] + - button "脚本" [ref=f1e446] [cursor=pointer] + - generic [ref=f1e485]: + - generic [ref=f1e486]: + - button "在 CodePen 中打开" [ref=f1e488] [cursor=pointer] + - button "复制" [ref=f1e490] [cursor=pointer] + - code [ref=f1e493]: + - generic [ref=f1e494]: + - generic [ref=f1e497]: 已复制到剪贴板 \ No newline at end of file diff --git a/.playwright-cli/page-2026-07-11T11-20-54-509Z.png b/.playwright-cli/page-2026-07-11T11-20-54-509Z.png new file mode 100644 index 00000000..039fa1f2 Binary files /dev/null and b/.playwright-cli/page-2026-07-11T11-20-54-509Z.png differ diff --git a/backend/internal/app/bootstrap.go b/backend/internal/app/bootstrap.go index 6081d540..b094f737 100644 --- a/backend/internal/app/bootstrap.go +++ b/backend/internal/app/bootstrap.go @@ -692,7 +692,6 @@ func Bootstrap(env string) (*App, error) { // Team + Profile services (P5 — Teams + Team Members + User Profiles) teamService := service.NewTeamService(teamRepo, teamMemberRepo, db) agentService := service.NewAgentService(agentRepo, db) - agentService.SetConfirmationMailer(service.NewEnvProfileConfirmationMailer()) profileService := service.NewProfileService(userRepo, accountUserRepo, accessTokenRepo, installationConfigRepo) profileService.SetConfirmationMailer(service.NewEnvProfileConfirmationMailer()) @@ -828,7 +827,7 @@ func Bootstrap(env string) (*App, error) { EmailChannel: v1.NewEmailChannelHandler(emailChannelSvc, inboxService, emailChannelRepo), EmailWebhook: emailWebhookHandler, Message: v1.NewMessageHandler(messageService), - Profile: v1.NewProfileHandler(profileService), + Profile: v1.NewProfileHandler(profileService, uploadService), Notification: v1.NewNotificationHandler(notificationService).WithEventPublisher(eventPublisher), PlatformApp: v1.NewPlatformAppHandler(platformAppService), Team: v1.NewTeamHandler(teamService), @@ -953,6 +952,7 @@ func Bootstrap(env string) (*App, error) { engine.Use(middleware.RateLimit(cfg, rdb)) // rate limiting (ref: Chatwoot rack-attack) engine.Use(corsMiddleware) // CORS with configurable whitelist engine.Use(middleware.SecurityHeaders(middleware.DefaultSecurityHeadersConfig())) // security headers (ref: P14 deliverable #11) + engine.StaticFS("/uploads", gin.Dir(cfg.Storage.LocalPath, false)) // CSRF protection — double-submit cookie pattern (ref: OWASP CSRF Prevention) // Applies globally; safe methods (GET/HEAD/OPTIONS) auto-set token cookie, @@ -1036,7 +1036,6 @@ func (a *hubTypingAdapter) SetTypingOff(ctx context.Context, accountID, conversa return nil } - // isFakeChannelEnabled reports whether the fake test channel should be wired. // It is disabled in production environments to prevent test endpoints from // being exposed in deployed images. diff --git a/backend/internal/handler/api/v1/account_handler.go b/backend/internal/handler/api/v1/account_handler.go index 8cc19d70..705ab673 100644 --- a/backend/internal/handler/api/v1/account_handler.go +++ b/backend/internal/handler/api/v1/account_handler.go @@ -453,9 +453,9 @@ func serializeAccountSettings(account *model.Account) map[string]any { "auto_resolve_after": account.AutoResolveDuration, "auto_resolve_duration": account.AutoResolveDuration, "auto_resolve_message": "", - "auto_resolve_ignore_waiting": false, + "auto_resolve_ignore_waiting": account.AutoResolveIgnoreWaiting, "audio_transcriptions": account.AudioTranscriptions, - "auto_resolve_label": "", + "auto_resolve_label": account.AutoResolveLabel, "reporting_timezone": account.ReportingTimezone, } return settings diff --git a/backend/internal/handler/api/v1/account_handler_test.go b/backend/internal/handler/api/v1/account_handler_test.go index 6134c0fa..3158526d 100644 --- a/backend/internal/handler/api/v1/account_handler_test.go +++ b/backend/internal/handler/api/v1/account_handler_test.go @@ -343,6 +343,41 @@ func (s *AccountHandlerTestSuite) TestPatchUpdate_AudioTranscriptions() { assert.False(s.T(), stored.AudioTranscriptions) } +func (s *AccountHandlerTestSuite) TestPatchUpdate_AutoResolvePreferences() { + acc := s.seedAccount("Auto Resolve Preferences") + + body := `{"auto_resolve_after":15,"auto_resolve_ignore_waiting":true,"auto_resolve_label":"vip"}` + w := httptest.NewRecorder() + req, _ := http.NewRequest("PATCH", "/api/v1/accounts/"+strconv.FormatUint(uint64(acc.ID), 10), bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + resp := s.unmarshalResponse(w) + settings := resp["settings"].(map[string]interface{}) + assert.Equal(s.T(), float64(15), settings["auto_resolve_after"]) + assert.Equal(s.T(), true, settings["auto_resolve_ignore_waiting"]) + assert.Equal(s.T(), "vip", settings["auto_resolve_label"]) + + var stored model.Account + require.NoError(s.T(), s.db.First(&stored, acc.ID).Error) + assert.Equal(s.T(), 15, stored.AutoResolveDuration) + assert.True(s.T(), stored.AutoResolveIgnoreWaiting) + assert.Equal(s.T(), "vip", stored.AutoResolveLabel) + + body = `{"auto_resolve_after":0,"auto_resolve_ignore_waiting":false,"auto_resolve_label":""}` + w = httptest.NewRecorder() + req, _ = http.NewRequest("PATCH", "/api/v1/accounts/"+strconv.FormatUint(uint64(acc.ID), 10), bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + require.NoError(s.T(), s.db.First(&stored, acc.ID).Error) + assert.Zero(s.T(), stored.AutoResolveDuration) + assert.False(s.T(), stored.AutoResolveIgnoreWaiting) + assert.Empty(s.T(), stored.AutoResolveLabel) +} + func (s *AccountHandlerTestSuite) TestPatchUpdate_ChatwootGeneralSettingsPayload() { acc := s.seedAccount("Test Account") diff --git a/backend/internal/handler/api/v1/agent_handler.go b/backend/internal/handler/api/v1/agent_handler.go index 3d5588fc..d802fc4a 100644 --- a/backend/internal/handler/api/v1/agent_handler.go +++ b/backend/internal/handler/api/v1/agent_handler.go @@ -120,6 +120,7 @@ func (h *AgentHandler) Create(c *gin.Context) { return } + c.Header("Cache-Control", "no-store") c.JSON(http.StatusOK, serializeAgentDetail(agent, accountID)) } @@ -188,6 +189,30 @@ func (h *AgentHandler) Delete(c *gin.Context) { c.Status(http.StatusOK) } +// ResetPassword generates a new temporary password for an email agent. +// POST /api/v1/accounts/:account_id/agents/:agent_id/reset_password +func (h *AgentHandler) ResetPassword(c *gin.Context) { + accountID := getAccountID(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") + return + } + id, err := strconv.ParseUint(c.Param("agent_id"), 10, 32) + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid agent id") + return + } + + temporaryPassword, svcErr := h.svc.ResetPassword(c.Request.Context(), uint(id), accountID) + if svcErr != nil { + applogger.L().Errorf("Reset password for agent %d in account %d: %v", id, accountID, svcErr) + handleServiceError(c, svcErr) + return + } + c.Header("Cache-Control", "no-store") + c.JSON(http.StatusOK, gin.H{"temporary_password": temporaryPassword}) +} + // BulkCreate adds multiple agents to the account by email. // POST /api/v1/accounts/:account_id/agents/bulk_create // Reference: Chatwoot agents_controller.rb#bulk_create @@ -222,14 +247,15 @@ func (h *AgentHandler) BulkCreate(c *gin.Context) { return } - _, svcErr := h.svc.BulkCreate(c.Request.Context(), accountID, userID, req) + agents, svcErr := h.svc.BulkCreate(c.Request.Context(), accountID, userID, req) if svcErr != nil { applogger.L().Errorf("BulkCreate agents for account %d: %v", accountID, svcErr) handleServiceError(c, svcErr) return } - c.Status(http.StatusOK) + c.Header("Cache-Control", "no-store") + c.JSON(http.StatusOK, serializeAgentDetails(agents, accountID)) } func serializeAgentDetails(agents []repository.AgentDetail, accountID uint) []map[string]any { @@ -244,7 +270,11 @@ func serializeAgentDetail(agent *repository.AgentDetail, accountID uint) map[str if agent == nil { return map[string]any{} } - return serializeAgentUser(&agent.User, accountID, agent.Role, agent.Availability, agent.AutoOffline, agent.CustomRoleID) + payload := serializeAgentUser(&agent.User, accountID, agent.Role, agent.Availability, agent.AutoOffline, agent.CustomRoleID) + if agent.TemporaryPassword != "" { + payload["temporary_password"] = agent.TemporaryPassword + } + return payload } func serializeAgentUser(user *model.User, accountID uint, role string, availability string, autoOffline bool, customRoleID uint) map[string]any { diff --git a/backend/internal/handler/api/v1/agent_handler_test.go b/backend/internal/handler/api/v1/agent_handler_test.go index a64e52e6..fd8fbf6a 100644 --- a/backend/internal/handler/api/v1/agent_handler_test.go +++ b/backend/internal/handler/api/v1/agent_handler_test.go @@ -13,6 +13,7 @@ import ( "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" + pkgcrypto "github.com/gochat/gochat/pkg/crypto" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "gorm.io/driver/sqlite" @@ -24,7 +25,6 @@ type AgentHandlerTestSuite struct { suite.Suite db *gorm.DB handler *AgentHandler - mailer *fakeProfileConfirmationMailer account *model.Account user *model.User } @@ -40,8 +40,6 @@ func (s *AgentHandlerTestSuite) SetupSuite() { agentRepo := repository.NewAgentRepo(db) svc := service.NewAgentService(agentRepo, db) - s.mailer = &fakeProfileConfirmationMailer{} - svc.SetConfirmationMailer(s.mailer) s.handler = NewAgentHandler(svc) s.account = &model.Account{Name: "test-agent-account"} @@ -61,7 +59,6 @@ func (s *AgentHandlerTestSuite) SetupTest() { // Don't delete users — we need the inviter user to persist // Only delete agent users (not the inviter) s.db.Exec("DELETE FROM users WHERE id != ?", s.user.ID) - s.mailer.Reset() } func (s *AgentHandlerTestSuite) TearDownSuite() { @@ -186,10 +183,20 @@ func (s *AgentHandlerTestSuite) TestCreateAgent() { assert.NotContains(s.T(), data, "invited_by") assert.NotContains(s.T(), data, "inviter_id") assert.NotContains(s.T(), data, "account_user_id") + temporaryPassword, ok := data["temporary_password"].(string) + s.Require().True(ok) + assert.Len(s.T(), temporaryPassword, 16) + assert.Equal(s.T(), "no-store", w.Header().Get("Cache-Control")) var membership model.AccountUser s.Require().NoError(s.db.Where("account_id = ? AND user_id = ?", s.account.ID, uint(data["id"].(float64))).First(&membership).Error) assert.Equal(s.T(), s.user.ID, membership.InvitedBy) + + var createdUser model.User + s.Require().NoError(s.db.First(&createdUser, uint(data["id"].(float64))).Error) + assert.NotNil(s.T(), createdUser.ConfirmedAt) + assert.True(s.T(), pkgcrypto.CheckPassword(temporaryPassword, createdUser.PasswordDigest)) + assert.True(s.T(), pkgcrypto.CheckPassword(temporaryPassword, createdUser.Password)) } func (s *AgentHandlerTestSuite) TestCreateAgentChatwootFrontendPayload() { @@ -218,27 +225,63 @@ func (s *AgentHandlerTestSuite) TestCreateAgentChatwootFrontendPayload() { assert.NotContains(s.T(), data, "data") } -func (s *AgentHandlerTestSuite) TestCreateAgentSendsWorkspaceInvitation() { - req := service.CreateAgentRequest{Email: "invite-mail@test.com", Name: "Invite Mail", Role: "agent"} +func (s *AgentHandlerTestSuite) TestCreateAgentDoesNotRequireInvitationEmail() { + req := service.CreateAgentRequest{Email: "direct-login@test.com", Name: "Direct Login", Role: "agent"} w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents", req, s.account.ID, s.user.ID) s.handler.Create(c) assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String()) - s.Require().Len(s.mailer.calls, 1) - mail := s.mailer.calls[0] - assert.Equal(s.T(), "invitation", mail.Kind) - assert.Equal(s.T(), "invite-mail@test.com", mail.ToEmail) - assert.Equal(s.T(), "You're invited to join test-agent-account", mail.Heading) - assert.Equal(s.T(), "Inviter Admin invited you to join the test-agent-account workspace on Chatwoot.", mail.IntroText) - assert.Equal(s.T(), "Accept invitation", mail.ActionText) - assert.Contains(s.T(), mail.ActionURL, "/app/auth/password/edit?reset_password_token=") - assert.NotEmpty(s.T(), mail.ResetPasswordToken) + var data map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &data)) + temporaryPassword, ok := data["temporary_password"].(string) + s.Require().True(ok) + assert.Regexp(s.T(), `[A-Z]`, temporaryPassword) + assert.Regexp(s.T(), `[a-z]`, temporaryPassword) + assert.Regexp(s.T(), `[0-9]`, temporaryPassword) + assert.Regexp(s.T(), `[!@#$%]`, temporaryPassword) - var invited model.User - s.Require().NoError(s.db.Where("email = ?", "invite-mail@test.com").First(&invited).Error) - assert.NotEmpty(s.T(), invited.ResetPasswordToken) - assert.NotEqual(s.T(), mail.ResetPasswordToken, invited.ResetPasswordToken) - assert.NotNil(s.T(), invited.ResetPasswordSentAt) + var created model.User + s.Require().NoError(s.db.Where("email = ?", "direct-login@test.com").First(&created).Error) + assert.Empty(s.T(), created.ResetPasswordToken) + assert.Nil(s.T(), created.ResetPasswordSentAt) + assert.NotNil(s.T(), created.ConfirmedAt) + assert.True(s.T(), pkgcrypto.CheckPassword(temporaryPassword, created.PasswordDigest)) +} + +func (s *AgentHandlerTestSuite) TestResetPasswordReturnsNewTemporaryPassword() { + req := service.CreateAgentRequest{Email: "reset-agent@test.com", Name: "Reset Agent", Role: "agent"} + createResponse, createContext := s.makeRequest("POST", "/api/v1/accounts/1/agents", req, s.account.ID, s.user.ID) + s.handler.Create(createContext) + s.Require().Equal(http.StatusOK, createResponse.Code) + var created map[string]interface{} + s.Require().NoError(json.Unmarshal(createResponse.Body.Bytes(), &created)) + agentID := uint(created["id"].(float64)) + initialPassword := created["temporary_password"].(string) + + w, c := s.makeRequest("POST", fmt.Sprintf("/api/v1/accounts/1/agents/%d/reset_password", agentID), nil, s.account.ID, s.user.ID) + s.handler.ResetPassword(c) + + assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String()) + assert.Equal(s.T(), "no-store", w.Header().Get("Cache-Control")) + var data map[string]string + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &data)) + newPassword := data["temporary_password"] + assert.Len(s.T(), newPassword, 16) + assert.NotEqual(s.T(), initialPassword, newPassword) + + var user model.User + s.Require().NoError(s.db.First(&user, agentID).Error) + assert.True(s.T(), pkgcrypto.CheckPassword(newPassword, user.PasswordDigest)) + assert.False(s.T(), pkgcrypto.CheckPassword(initialPassword, user.PasswordDigest)) +} + +func (s *AgentHandlerTestSuite) TestResetPasswordIsScopedToAccount() { + otherAccount := &model.Account{Name: "other-reset-account"} + s.Require().NoError(s.db.Create(otherAccount).Error) + + w, c := s.makeRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/agents/%d/reset_password", otherAccount.ID, s.user.ID), nil, otherAccount.ID, s.user.ID) + s.handler.ResetPassword(c) + assert.Equal(s.T(), http.StatusNotFound, w.Code, w.Body.String()) } func (s *AgentHandlerTestSuite) TestCreateAgentDefaultsBlankNameFromEmail() { @@ -467,7 +510,12 @@ func (s *AgentHandlerTestSuite) TestBulkCreate() { s.handler.BulkCreate(c) assert.Equal(s.T(), http.StatusOK, w.Code) - assert.Empty(s.T(), w.Body.String()) + var data []map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &data)) + s.Require().Len(data, 3) + for _, agent := range data { + assert.NotEmpty(s.T(), agent["temporary_password"]) + } } func (s *AgentHandlerTestSuite) TestBulkCreateValidation() { @@ -477,7 +525,7 @@ func (s *AgentHandlerTestSuite) TestBulkCreateValidation() { w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents/bulk_create", req, s.account.ID, s.user.ID) s.handler.BulkCreate(c) assert.Equal(s.T(), http.StatusOK, w.Code) - assert.Empty(s.T(), w.Body.String()) + assert.JSONEq(s.T(), `[]`, w.Body.String()) } func (s *AgentHandlerTestSuite) TestBulkCreateSkipsInvalidEmailsAndClearsOnboardingStep() { @@ -489,7 +537,10 @@ func (s *AgentHandlerTestSuite) TestBulkCreateSkipsInvalidEmailsAndClearsOnboard s.handler.BulkCreate(c) assert.Equal(s.T(), http.StatusOK, w.Code) - assert.Empty(s.T(), w.Body.String()) + var data []map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &data)) + s.Require().Len(data, 1) + assert.NotEmpty(s.T(), data[0]["temporary_password"]) var validUser model.User s.Require().NoError(s.db.Where("email = ?", "valid-bulk@test.com").First(&validUser).Error) @@ -521,7 +572,11 @@ func (s *AgentHandlerTestSuite) TestBulkCreateSkipsDuplicates() { s.handler.BulkCreate(c2) assert.Equal(s.T(), http.StatusOK, w2.Code) - assert.Empty(s.T(), w2.Body.String()) + var data []map[string]interface{} + s.Require().NoError(json.Unmarshal(w2.Body.Bytes(), &data)) + s.Require().Len(data, 1) + assert.Equal(s.T(), "new@test.com", data[0]["email"]) + assert.NotEmpty(s.T(), data[0]["temporary_password"]) } func (s *AgentHandlerTestSuite) TestListAfterCreate() { diff --git a/backend/internal/handler/api/v1/article_handler.go b/backend/internal/handler/api/v1/article_handler.go index b18c6dc5..085de374 100644 --- a/backend/internal/handler/api/v1/article_handler.go +++ b/backend/internal/handler/api/v1/article_handler.go @@ -755,7 +755,7 @@ func (h *ArticleHandler) articleSearchParams(c *gin.Context, portalID uint, offs Query: c.Query("query"), CategorySlug: c.Query("category_slug"), Locale: c.Query("locale"), - Status: c.Query("status"), + Status: normalizeArticleStatusFilter(c.Query("status")), SortBy: c.Query("sort_by"), Offset: offset, Limit: limit, @@ -775,6 +775,19 @@ func (h *ArticleHandler) articleSearchParams(c *gin.Context, portalID uint, offs return params, true } +func normalizeArticleStatusFilter(status string) string { + switch strings.TrimSpace(status) { + case "0": + return string(model.ArticleStatusDraft) + case "1": + return string(model.ArticleStatusPublished) + case "2": + return string(model.ArticleStatusArchived) + default: + return status + } +} + func articleIDParam(c *gin.Context) (uint, error) { if id, err := parseUintAnyParam(c, "article_id", "id"); err == nil && id != 0 { return id, nil diff --git a/backend/internal/handler/api/v1/article_handler_test.go b/backend/internal/handler/api/v1/article_handler_test.go index 31ed384b..b1a001dc 100644 --- a/backend/internal/handler/api/v1/article_handler_test.go +++ b/backend/internal/handler/api/v1/article_handler_test.go @@ -698,6 +698,63 @@ func (s *ArticleHandlerTestSuite) TestPatch_RawPayloadClearsDescription() { assert.Equal(s.T(), "", payload["description"]) } +func (s *ArticleHandlerTestSuite) TestPatch_AcceptsChatwootNumericStatus() { + article := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, Title: "numeric-status-article", Slug: "numeric-status-article", Status: "draft"} + s.Require().NoError(s.db.Create(article).Error) + + r := gin.New() + r.PATCH("/api/v1/accounts/:account_id/portals/:portal_id/articles/:article_id", s.handler.Update) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles/%d", s.account.ID, article.ID), bytes.NewBufferString(`{"status":1}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var resp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + payload := resp["payload"].(map[string]interface{}) + assert.Equal(s.T(), "published", payload["status"]) + + var updated model.Article + s.Require().NoError(s.db.First(&updated, article.ID).Error) + assert.Equal(s.T(), "published", updated.Status) +} + +func (s *ArticleHandlerTestSuite) TestList_AcceptsChatwootNumericStatusFilters() { + for index, status := range []model.ArticleStatus{ + model.ArticleStatusDraft, + model.ArticleStatusPublished, + model.ArticleStatusArchived, + } { + article := &model.Article{ + AccountID: s.account.ID, + PortalID: s.portal.ID, + Title: fmt.Sprintf("numeric-filter-%d", index), + Slug: fmt.Sprintf("numeric-filter-%d", index), + Status: string(status), + Locale: "en", + } + s.Require().NoError(s.db.Create(article).Error) + } + + r := gin.New() + r.GET("/api/v1/accounts/:account_id/portals/:portal_id/articles", s.handler.List) + + for statusCode, expectedStatus := range []string{"draft", "published", "archived"} { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles?locale=en&status=%d", s.account.ID, statusCode), nil) + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var resp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + payload := resp["payload"].([]interface{}) + s.Require().Len(payload, 1) + assert.Equal(s.T(), expectedStatus, payload[0].(map[string]interface{})["status"]) + } +} + func (s *ArticleHandlerTestSuite) TestDelete_ReturnsEmptyOK() { article := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, Title: "delete-article", Slug: "delete-article", Status: "draft"} s.Require().NoError(s.db.Create(article).Error) diff --git a/backend/internal/handler/api/v1/assignment_policy_handler_test.go b/backend/internal/handler/api/v1/assignment_policy_handler_test.go index 1693fa42..28b7a1d3 100644 --- a/backend/internal/handler/api/v1/assignment_policy_handler_test.go +++ b/backend/internal/handler/api/v1/assignment_policy_handler_test.go @@ -135,7 +135,7 @@ func (s *AssignmentPolicyHandlerTestSuite) TestGetAccountPolicy_Success() { var resp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(s.T(), "Default", resp["name"]) - assert.Equal(s.T(), float64(1), resp["conversation_priority"]) + assert.Equal(s.T(), "longest_waiting", resp["conversation_priority"]) assert.Equal(s.T(), true, resp["enabled"]) } @@ -172,7 +172,7 @@ func (s *AssignmentPolicyHandlerTestSuite) TestGetAccountPolicy_Unauthorized() { // ==================== CreateAccountPolicy ==================== func (s *AssignmentPolicyHandlerTestSuite) TestCreateAccountPolicy_Success() { - body := `{"name":"Default","assignment_order":0,"conversation_priority":1,"fair_distribution_limit":5,"fair_distribution_window":300,"enabled":true}` + body := `{"name":"Default","assignment_order":"round_robin","conversation_priority":"longest_waiting","fair_distribution_limit":5,"fair_distribution_window":300,"enabled":true}` w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/1/assignment_policy", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") @@ -183,10 +183,29 @@ func (s *AssignmentPolicyHandlerTestSuite) TestCreateAccountPolicy_Success() { var resp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(s.T(), "Default", resp["name"]) + assert.Equal(s.T(), "round_robin", resp["assignment_order"]) + assert.Equal(s.T(), "longest_waiting", resp["conversation_priority"]) assert.Equal(s.T(), float64(5), resp["fair_distribution_limit"]) assert.Equal(s.T(), float64(300), resp["fair_distribution_window"]) } +func (s *AssignmentPolicyHandlerTestSuite) TestCreateAccountPolicy_AcceptsFrontendEnumPayload() { + body := `{"name":"test","description":"123123","enabled":true,"assignment_order":"balanced","conversation_priority":"earliest_created","fair_distribution_limit":100,"fair_distribution_window":3600}` + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/v1/accounts/1/assignment_policies", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + + var resp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(s.T(), "balanced", resp["assignment_order"]) + assert.Equal(s.T(), "earliest_created", resp["conversation_priority"]) + assert.Equal(s.T(), float64(100), resp["fair_distribution_limit"]) + assert.Equal(s.T(), float64(3600), resp["fair_distribution_window"]) +} + func (s *AssignmentPolicyHandlerTestSuite) TestCreateAccountPolicy_InvalidJSON() { body := `{invalid json` w := httptest.NewRecorder() @@ -245,7 +264,7 @@ func (s *AssignmentPolicyHandlerTestSuite) TestUpdateAccountPolicy_Success() { } s.Require().NoError(s.db.Create(policy).Error) - body := `{"name":"Priority","fair_distribution_limit":10}` + body := `{"name":"Priority","assignment_order":"balanced","conversation_priority":"longest_waiting","fair_distribution_limit":10}` w := httptest.NewRecorder() req, _ := http.NewRequest("PUT", "/api/v1/accounts/1/assignment_policy/"+uintToStr(policy.ID), bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") @@ -256,6 +275,8 @@ func (s *AssignmentPolicyHandlerTestSuite) TestUpdateAccountPolicy_Success() { var resp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(s.T(), "Priority", resp["name"]) + assert.Equal(s.T(), "balanced", resp["assignment_order"]) + assert.Equal(s.T(), "longest_waiting", resp["conversation_priority"]) assert.Equal(s.T(), float64(10), resp["fair_distribution_limit"]) } diff --git a/backend/internal/handler/api/v1/auth_handler.go b/backend/internal/handler/api/v1/auth_handler.go index 0adb4f1c..4df56dec 100644 --- a/backend/internal/handler/api/v1/auth_handler.go +++ b/backend/internal/handler/api/v1/auth_handler.go @@ -61,13 +61,6 @@ type LoginMFAResquest struct { TOTPCode string `json:"totp_code" binding:"required"` } -// RegisterRequest is the JSON body for register endpoint. -type RegisterRequest struct { - Name string `json:"name" binding:"required,min=2"` - Email string `json:"email" binding:"required,email"` - Password string `json:"password" binding:"required,min=6"` -} - // RefreshRequest is the JSON body for refresh endpoint. type RefreshRequest struct { RefreshToken string `json:"refresh_token" binding:"required"` @@ -251,31 +244,6 @@ func (h *AuthHandler) LoginMFA(c *gin.Context) { }) } -// Register creates a new user account. -// POST /api/v1/auth/register -func (h *AuthHandler) Register(c *gin.Context) { - var req RegisterRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) - return - } - - user, err := h.authService.Register(c.Request.Context(), &service.RegisterInput{ - Name: req.Name, - Email: req.Email, - Password: req.Password, - }) - if err != nil { - response.AbortWithStatusError(c, http.StatusConflict, response.ErrConflict, err.Error()) - return - } - - response.Created(c, gin.H{ - "user": user, - "message": "Registration successful. Please verify your email.", - }) -} - // Refresh rotates a refresh token and returns new JWT pair. // POST /api/v1/auth/refresh // Implements refresh token rotation per P2E §1.4 security requirement. @@ -521,7 +489,6 @@ func RegisterAuthRoutes(rg *gin.RouterGroup, handler *AuthHandler) { // Core auth endpoints authGroup.POST("/login", handler.Login) authGroup.POST("/login/mfa", handler.LoginMFA) - authGroup.POST("/register", handler.Register) authGroup.POST("/refresh", handler.Refresh) authGroup.DELETE("/logout", handler.Logout) diff --git a/backend/internal/handler/api/v1/auth_handler_test.go b/backend/internal/handler/api/v1/auth_handler_test.go index edd92ae6..94f09edb 100644 --- a/backend/internal/handler/api/v1/auth_handler_test.go +++ b/backend/internal/handler/api/v1/auth_handler_test.go @@ -65,6 +65,15 @@ func setupChatwootAuthTest(t *testing.T) (*gin.Engine, *gorm.DB, *model.User) { return router, db, user } +func TestRegisterAuthRoutesDoesNotExposeSelfRegistration(t *testing.T) { + router := gin.New() + RegisterAuthRoutes(router.Group("/api/v1"), &AuthHandler{}) + + for _, route := range router.Routes() { + require.NotEqual(t, "/api/v1/auth/register", route.Path) + } +} + func TestChatwootAuthSignInReturnsDeviseHeadersAndUserPayload(t *testing.T) { router, _, _ := setupChatwootAuthTest(t) body, _ := json.Marshal(map[string]string{"email": " AUTH@example.com ", "password": "password123"}) diff --git a/backend/internal/handler/api/v1/inbox_handler_parity_test.go b/backend/internal/handler/api/v1/inbox_handler_parity_test.go index 586bc035..6783dc73 100644 --- a/backend/internal/handler/api/v1/inbox_handler_parity_test.go +++ b/backend/internal/handler/api/v1/inbox_handler_parity_test.go @@ -285,6 +285,29 @@ func TestSerializeInboxComputesCallbackWebhookURL(t *testing.T) { require.Nil(t, serializeInbox(website, nil, false)["callback_webhook_url"]) } +func TestSerializeInboxComputesWebWidgetScript(t *testing.T) { + t.Setenv("FRONTEND_URL", "https://app.example.test/") + + website := &model.Inbox{ + AccountID: 1, + Name: "Website", + ChannelType: "web_widget", + ChannelConfig: `{"website_token":"website-token"}`, + } + + script, ok := serializeInbox(website, nil, false)["web_widget_script"].(string) + require.True(t, ok) + require.Contains(t, script, `var BASE_URL="https://app.example.test"`) + require.Contains(t, script, `g.src=BASE_URL+"/sdk/js/sdk.js"`) + require.Contains(t, script, `websiteToken: "website-token"`) + + missingToken := &model.Inbox{AccountID: 1, Name: "Website", ChannelType: "web_widget"} + require.Nil(t, serializeInbox(missingToken, nil, false)["web_widget_script"]) + + email := &model.Inbox{AccountID: 1, Name: "Email", ChannelType: "email"} + require.Nil(t, serializeInbox(email, nil, false)["web_widget_script"]) +} + func TestInboxHandler_SensitiveFieldsRequireAdministratorRole(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/handler/api/v1/inbox_member_handler.go b/backend/internal/handler/api/v1/inbox_member_handler.go index 8b46df1f..2a067458 100644 --- a/backend/internal/handler/api/v1/inbox_member_handler.go +++ b/backend/internal/handler/api/v1/inbox_member_handler.go @@ -209,14 +209,19 @@ func (h *InboxMemberHandler) updateAccountScoped(c *gin.Context) { // DestroyAccountScoped removes selected users from an inbox using Chatwoot's account-level route. // DELETE /api/v1/accounts/:account_id/inbox_members func (h *InboxMemberHandler) DestroyAccountScoped(c *gin.Context) { - var req struct { - InboxID uint `json:"inbox_id" binding:"required"` - UserIDs []uint `json:"user_ids" binding:"required"` - } + var req service.UpdateMultipleRequest if bindErr := c.ShouldBindJSON(&req); bindErr != nil { c.JSON(http.StatusBadRequest, gin.H{"error": bindErr.Error()}) return } + if req.InboxID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "inbox_id is required"}) + return + } + if req.UserIDs == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "user_ids is required"}) + return + } for _, userID := range req.UserIDs { if svcErr := h.svc.RemoveMember(c.Request.Context(), req.InboxID, userID); svcErr != nil { diff --git a/backend/internal/handler/api/v1/inbox_member_handler_test.go b/backend/internal/handler/api/v1/inbox_member_handler_test.go index 1df30ea7..b197c8c9 100644 --- a/backend/internal/handler/api/v1/inbox_member_handler_test.go +++ b/backend/internal/handler/api/v1/inbox_member_handler_test.go @@ -136,7 +136,7 @@ func (s *InboxMemberHandlerTestSuite) TestAccountScopedInboxMembers_ChatwootPayl s.Require().Len(payload, 2) update := httptest.NewRecorder() - body = fmt.Sprintf(`{"inbox_id":%d,"user_ids":[%d]}`, s.inbox.ID, users[2].ID) + body = fmt.Sprintf(`{"inbox_id":"%d","user_ids":[%d]}`, s.inbox.ID, users[2].ID) req, _ = http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/inbox_members", s.account.ID), bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(update, req) @@ -146,7 +146,7 @@ func (s *InboxMemberHandlerTestSuite) TestAccountScopedInboxMembers_ChatwootPayl s.Require().Equal(float64(users[2].ID), payload[0]["id"]) destroy := httptest.NewRecorder() - body = fmt.Sprintf(`{"inbox_id":%d,"user_ids":[%d]}`, s.inbox.ID, users[2].ID) + body = fmt.Sprintf(`{"inbox_id":"%d","user_ids":[%d]}`, s.inbox.ID, users[2].ID) req, _ = http.NewRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/inbox_members", s.account.ID), bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(destroy, req) diff --git a/backend/internal/handler/api/v1/inbox_serializer.go b/backend/internal/handler/api/v1/inbox_serializer.go index 116bf9b1..f9b39355 100644 --- a/backend/internal/handler/api/v1/inbox_serializer.go +++ b/backend/internal/handler/api/v1/inbox_serializer.go @@ -2,6 +2,7 @@ package v1 import ( "encoding/json" + "fmt" "os" "strings" @@ -47,7 +48,7 @@ func serializeInbox(inbox *model.Inbox, db *gorm.DB, isAdmin bool) map[string]an "hmac_mandatory": configValue(config, "hmac_mandatory"), "welcome_title": configValue(config, "welcome_title"), "welcome_tagline": configValue(config, "welcome_tagline"), - "web_widget_script": configValue(config, "web_widget_script"), + "web_widget_script": webWidgetScript(inbox, config), "website_token": configValue(config, "website_token"), "selected_feature_flags": configValue(config, "selected_feature_flags"), "reply_time": configValue(config, "reply_time"), @@ -230,6 +231,36 @@ func callbackWebhookURL(inbox *model.Inbox, config map[string]any) any { } } +func webWidgetScript(inbox *model.Inbox, config map[string]any) any { + if chatwootChannelType(inbox.ChannelType) != "Channel::WebWidget" { + return nil + } + + websiteToken := strings.TrimSpace(inboxConfigString(config, "website_token")) + if websiteToken == "" { + return nil + } + + baseURL := strings.TrimRight(os.Getenv("FRONTEND_URL"), "/") + return fmt.Sprintf(` + + `, baseURL, websiteToken) +} + func parseInboxChannelConfig(raw string) map[string]any { if raw == "" { return map[string]any{} diff --git a/backend/internal/handler/api/v1/profile_handler.go b/backend/internal/handler/api/v1/profile_handler.go index 7afd0f64..5f99d116 100644 --- a/backend/internal/handler/api/v1/profile_handler.go +++ b/backend/internal/handler/api/v1/profile_handler.go @@ -2,6 +2,7 @@ package v1 import ( "encoding/json" + "mime/multipart" "net/http" "strings" @@ -15,12 +16,17 @@ import ( // ProfileHandler handles Profile get/update + avatar + availability + auto_offline + set_active_account + resend_confirmation + reset_access_token. // Reference: Chatwoot app/controllers/api/v1/profile_controller.rb type ProfileHandler struct { - svc *service.ProfileService + svc *service.ProfileService + uploadSvc *service.UploadService } // NewProfileHandler creates a new Profile handler. -func NewProfileHandler(svc *service.ProfileService) *ProfileHandler { - return &ProfileHandler{svc: svc} +func NewProfileHandler(svc *service.ProfileService, uploadSvc ...*service.UploadService) *ProfileHandler { + handler := &ProfileHandler{svc: svc} + if len(uploadSvc) > 0 { + handler.uploadSvc = uploadSvc[0] + } + return handler } // Get returns the current user's profile. @@ -52,13 +58,25 @@ func (h *ProfileHandler) Update(c *gin.Context) { return } - req, err := bindProfileUpdate(c) + req, avatar, err := bindProfileUpdate(c) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error()) return } accountID := c.GetUint("account_id") + if avatar != nil { + if h.uploadSvc == nil { + response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "profile avatar storage is not configured") + return + } + upload, uploadErr := h.uploadSvc.ProfileAvatarUpload(c.Request.Context(), accountID, avatar) + if uploadErr != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, uploadErr.Error()) + return + } + req.Profile.AvatarURL = upload.FileURL + } user, svcErr := h.svc.Update(c.Request.Context(), userID, accountID, req.Profile) if svcErr != nil { applogger.L().Errorf("Update profile for user %d: %v", userID, svcErr) @@ -69,22 +87,22 @@ func (h *ProfileHandler) Update(c *gin.Context) { c.JSON(http.StatusOK, user) } -func bindProfileUpdate(c *gin.Context) (service.ProfileUpdatePayload, error) { +func bindProfileUpdate(c *gin.Context) (service.ProfileUpdatePayload, *multipart.FileHeader, error) { contentType := c.GetHeader("Content-Type") if strings.Contains(contentType, "multipart/form-data") || strings.Contains(contentType, "application/x-www-form-urlencoded") { return bindProfileUpdateForm(c) } var req service.ProfileUpdatePayload if err := c.ShouldBindJSON(&req); err != nil { - return req, err + return req, nil, err } - return req, nil + return req, nil, nil } -func bindProfileUpdateForm(c *gin.Context) (service.ProfileUpdatePayload, error) { +func bindProfileUpdateForm(c *gin.Context) (service.ProfileUpdatePayload, *multipart.FileHeader, error) { var req service.ProfileUpdatePayload if err := c.Request.ParseMultipartForm(32 << 20); err != nil && !strings.Contains(err.Error(), "request Content-Type isn't multipart/form-data") { - return req, err + return req, nil, err } form := c.Request.Form profile := &req.Profile @@ -109,9 +127,7 @@ func bindProfileUpdateForm(c *gin.Context) (service.ProfileUpdatePayload, error) if value := form.Get("profile[avatar_url]"); value != "" { profile.AvatarURL = value } - if file, err := c.FormFile("profile[avatar]"); err == nil && file != nil { - profile.AvatarURL = file.Filename - } + avatar, _ := c.FormFile("profile[avatar]") uiSettings := map[string]any{} for key, values := range form { if !strings.HasPrefix(key, "profile[ui_settings][") || len(values) == 0 { @@ -129,7 +145,7 @@ func bindProfileUpdateForm(c *gin.Context) (service.ProfileUpdatePayload, error) if len(uiSettings) > 0 { profile.UISettings = uiSettings } - return req, nil + return req, avatar, nil } // UpdateAvatar updates the current user's avatar. diff --git a/backend/internal/handler/api/v1/profile_handler_test.go b/backend/internal/handler/api/v1/profile_handler_test.go index f04b2178..d8b8fd8d 100644 --- a/backend/internal/handler/api/v1/profile_handler_test.go +++ b/backend/internal/handler/api/v1/profile_handler_test.go @@ -21,6 +21,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/logger" + "github.com/gochat/gochat/internal/config" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" @@ -103,9 +104,13 @@ func (s *ProfileHandlerTestSuite) SetupSuite() { accessTokenRepo := repository.NewAccessTokenRepo(db) installationConfigRepo := repository.NewInstallationConfigRepo(db) profileSvc := service.NewProfileService(userRepo, accountUserRepo, accessTokenRepo, installationConfigRepo) + uploadCfg := &config.Config{} + uploadCfg.Storage.LocalPath = s.T().TempDir() + uploadCfg.Storage.MaxFileSize = 20 << 20 + uploadSvc := service.NewUploadService(nil, uploadCfg) s.mailer = &fakeProfileConfirmationMailer{} profileSvc.SetConfirmationMailer(s.mailer) - s.handler = NewProfileHandler(profileSvc) + s.handler = NewProfileHandler(profileSvc, uploadSvc) // Build router with profile routes and auth middleware s.router = s.buildRouter() @@ -1058,7 +1063,14 @@ func (s *ProfileHandlerTestSuite) TestUpdate_MultipartFormProfileParity() { assert.Equal(s.T(), "Multipart User", payload["name"]) assert.Equal(s.T(), "Multipart Display", payload["display_name"]) assert.Equal(s.T(), "Sent from multipart", payload["message_signature"]) - assert.Equal(s.T(), "avatar.png", payload["avatar_url"]) + avatarURL := payload["avatar_url"].(string) + assert.Contains(s.T(), avatarURL, "/uploads/account/") + assert.Contains(s.T(), avatarURL, ".png") + + var persisted model.User + s.Require().NoError(s.db.First(&persisted, s.userID).Error) + assert.Equal(s.T(), "Multipart Display", persisted.DisplayName) + assert.Equal(s.T(), avatarURL, persisted.AvatarURL) uiSettings := payload["ui_settings"].(map[string]interface{}) assert.Equal(s.T(), "enter", uiSettings["editor_message_key"]) } diff --git a/backend/internal/middleware/account_scope.go b/backend/internal/middleware/account_scope.go index 2424ddfa..6c762382 100644 --- a/backend/internal/middleware/account_scope.go +++ b/backend/internal/middleware/account_scope.go @@ -56,25 +56,31 @@ func AccountScope() gin.HandlerFunc { // Step 3: Override JWT claims account_id if header was provided c.Set("account_id", accountID) - // Step 4: Get role from JWT claims + // Step 4: Get role from JWT claims. AuthMiddleware stores the current + // keys as "role" and "claims"; retain the legacy names for callers that + // still construct middleware contexts directly. roleStr := "agent" // default fallback - role, roleExists := c.Get("user_role") - if roleExists { - if s, ok := role.(string); ok { - roleStr = s + for _, key := range []string{"role", "user_role"} { + if role, roleExists := c.Get(key); roleExists { + if s, ok := role.(string); ok && s != "" { + roleStr = s + break + } } } // Step 5: Get custom_role_id from JWT claims customRoleID := uint(0) - claims, claimsExists := c.Get("auth_claims") - if claimsExists { - if jwtClaims, ok := claims.(*auth.Claims); ok { - if jwtClaims.CustomRoleID != 0 { - customRoleID = jwtClaims.CustomRoleID - } - if jwtClaims.Role != "" { - roleStr = jwtClaims.Role + for _, key := range []string{"claims", "auth_claims"} { + if claims, claimsExists := c.Get(key); claimsExists { + if jwtClaims, ok := claims.(*auth.Claims); ok { + if jwtClaims.CustomRoleID != 0 { + customRoleID = jwtClaims.CustomRoleID + } + if jwtClaims.Role != "" { + roleStr = jwtClaims.Role + } + break } } } diff --git a/backend/internal/middleware/account_scope_test.go b/backend/internal/middleware/account_scope_test.go index 88fa6394..057ad06f 100644 --- a/backend/internal/middleware/account_scope_test.go +++ b/backend/internal/middleware/account_scope_test.go @@ -6,7 +6,11 @@ import ( "testing" "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/auth" + "github.com/gochat/gochat/internal/config" + "github.com/gochat/gochat/internal/model" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestAccountScope_NoUserID(t *testing.T) { @@ -135,3 +139,34 @@ func TestAccountScope_RouteAccountMatchesContext(t *testing.T) { r.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) } + +func TestAccountScope_UsesAuthMiddlewareRoleForAdministratorRoute(t *testing.T) { + gin.SetMode(gin.TestMode) + cfg := &config.JWTConfig{ + Secret: "account-scope-role-test-secret", + ExpiryHours: 1, + RefreshExpiryHours: 1, + } + jwtService := auth.NewJWTService(cfg) + tokens, err := jwtService.GenerateTokenPair(&model.User{ + Base: model.Base{ID: 1}, + Provider: "email", + }, 1, "administrator") + require.NoError(t, err) + + r := gin.New() + r.Use(AuthMiddlewareWithService(jwtService)) + r.Use(AccountScope()) + r.POST( + "/api/v1/accounts/:account_id/agents", + RoleCheck("administrator"), + func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }, + ) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/agents", nil) + req.Header.Set("access-token", tokens.AccessToken) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code, w.Body.String()) +} diff --git a/backend/internal/model/account.go b/backend/internal/model/account.go index 6fb68ca8..c6ac353b 100644 --- a/backend/internal/model/account.go +++ b/backend/internal/model/account.go @@ -9,23 +9,25 @@ import ( // Account represents a tenant/organization in the system. type Account struct { Base - Name string `gorm:"size:255;not null" json:"name"` - Domain string `gorm:"size:255" json:"domain,omitempty"` - Locale string `gorm:"size:10;default:zh_CN" json:"locale"` - Timezone string `gorm:"size:50;default:UTC" json:"timezone"` - ReportingTimezone string `gorm:"size:100" json:"reporting_timezone,omitempty"` - Active bool `gorm:"default:true" json:"active"` - Status string `gorm:"size:50;default:active" json:"status"` - OnboardingStep string `gorm:"size:100" json:"onboarding_step,omitempty"` - CustomAttributes datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"custom_attributes,omitempty"` - FeatureFlags string `gorm:"type:text" json:"feature_flags,omitempty"` // JSON-encoded feature flags - AutoResolveDuration int `gorm:"default:0" json:"auto_resolve_duration,omitempty"` // days - AudioTranscriptions bool `gorm:"default:false" json:"audio_transcriptions"` - Limits datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"limits,omitempty"` // Chatwoot account limits, e.g. emails - AgentLimit int `gorm:"default:0" json:"agent_limit,omitempty"` // max agents allowed (0 = unlimited), Chatwoot usage_limits[:agents] - InboxLimit int `gorm:"default:0" json:"inbox_limit,omitempty"` // max inboxes allowed (0 = unlimited), Chatwoot usage_limits[:inboxes] - CaptainModels datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"captain_models,omitempty"` - CaptainFeatures datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"captain_features,omitempty"` + Name string `gorm:"size:255;not null" json:"name"` + Domain string `gorm:"size:255" json:"domain,omitempty"` + Locale string `gorm:"size:10;default:zh_CN" json:"locale"` + Timezone string `gorm:"size:50;default:UTC" json:"timezone"` + ReportingTimezone string `gorm:"size:100" json:"reporting_timezone,omitempty"` + Active bool `gorm:"default:true" json:"active"` + Status string `gorm:"size:50;default:active" json:"status"` + OnboardingStep string `gorm:"size:100" json:"onboarding_step,omitempty"` + CustomAttributes datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"custom_attributes,omitempty"` + FeatureFlags string `gorm:"type:text" json:"feature_flags,omitempty"` // JSON-encoded feature flags + AutoResolveDuration int `gorm:"default:0" json:"auto_resolve_duration,omitempty"` // minutes + AutoResolveIgnoreWaiting bool `gorm:"default:false" json:"auto_resolve_ignore_waiting"` + AutoResolveLabel string `gorm:"size:255" json:"auto_resolve_label,omitempty"` + AudioTranscriptions bool `gorm:"default:false" json:"audio_transcriptions"` + Limits datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"limits,omitempty"` // Chatwoot account limits, e.g. emails + AgentLimit int `gorm:"default:0" json:"agent_limit,omitempty"` // max agents allowed (0 = unlimited), Chatwoot usage_limits[:agents] + InboxLimit int `gorm:"default:0" json:"inbox_limit,omitempty"` // max inboxes allowed (0 = unlimited), Chatwoot usage_limits[:inboxes] + CaptainModels datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"captain_models,omitempty"` + CaptainFeatures datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"captain_features,omitempty"` // Chatwoot: store_accessor :settings, :keep_pending_on_bot_failure // When true, agent bot webhook failures do NOT reopen pending conversations. KeepPendingOnBotFailure bool `gorm:"default:false" json:"keep_pending_on_bot_failure,omitempty"` diff --git a/backend/internal/model/article_status.go b/backend/internal/model/article_status.go new file mode 100644 index 00000000..6313232b --- /dev/null +++ b/backend/internal/model/article_status.go @@ -0,0 +1,34 @@ +package model + +import ( + "encoding/json" + "fmt" +) + +// UnmarshalJSON accepts both GoChat's descriptive article statuses and the +// integer enum values sent by the Chatwoot frontend. +func (s *ArticleStatus) UnmarshalJSON(data []byte) error { + var status string + if err := json.Unmarshal(data, &status); err == nil { + *s = ArticleStatus(status) + return nil + } + + var statusCode int + if err := json.Unmarshal(data, &statusCode); err != nil { + return fmt.Errorf("article status must be draft, published, archived, 0, 1, or 2") + } + + switch statusCode { + case 0: + *s = ArticleStatusDraft + case 1: + *s = ArticleStatusPublished + case 2: + *s = ArticleStatusArchived + default: + return fmt.Errorf("invalid article status code %d", statusCode) + } + + return nil +} diff --git a/backend/internal/repository/agent_repo.go b/backend/internal/repository/agent_repo.go index aa7ef93c..f537d016 100644 --- a/backend/internal/repository/agent_repo.go +++ b/backend/internal/repository/agent_repo.go @@ -3,6 +3,7 @@ package repository import ( "context" "errors" + "time" "gorm.io/gorm" @@ -30,13 +31,14 @@ func NewAgentRepo(db *gorm.DB) *AgentRepo { // Reference: Chatwoot renders User objects with included account_users. type AgentDetail struct { model.User - Role string `json:"role"` - Availability string `json:"availability"` - AutoOffline bool `json:"auto_offline"` - InvitedBy uint `json:"invited_by"` - AccountUserID uint `json:"account_user_id"` - CustomRoleID uint `json:"custom_role_id,omitempty"` - IsNewUser bool `json:"-"` + Role string `json:"role"` + Availability string `json:"availability"` + AutoOffline bool `json:"auto_offline"` + InvitedBy uint `json:"invited_by"` + AccountUserID uint `json:"account_user_id"` + CustomRoleID uint `json:"custom_role_id,omitempty"` + IsNewUser bool `json:"-"` + TemporaryPassword string `json:"-"` } // ListByAccount retrieves all agents (users) for an account with pagination. @@ -143,7 +145,7 @@ func (r *AgentRepo) FindAgentByID(ctx context.Context, userID, accountID uint) ( // CreateAgent adds a user to an account (creates AccountUser). // Reference: Chatwoot agents_controller.rb#create → AgentBuilder.new.perform // If the user does not exist, creates the user first, then creates the AccountUser. -func (r *AgentRepo) CreateAgent(ctx context.Context, accountID uint, inviterID uint, name, email, role, availability string, autoOffline bool, customRoleID uint) (*AgentDetail, error) { +func (r *AgentRepo) CreateAgent(ctx context.Context, accountID uint, inviterID uint, name, email, role, availability string, autoOffline bool, customRoleID uint, passwordDigest string, confirmedAt time.Time) (*AgentDetail, error) { // Find or create the user var user model.User isNewUser := false @@ -157,10 +159,14 @@ func (r *AgentRepo) CreateAgent(ctx context.Context, accountID uint, inviterID u } } user = model.User{ - Name: name, - Email: email, - Provider: "email", - Active: true, + AccountID: accountID, + Name: name, + Email: email, + Password: passwordDigest, + PasswordDigest: passwordDigest, + Provider: "email", + Active: true, + ConfirmedAt: &confirmedAt, } if err := r.db.WithContext(ctx).Create(&user).Error; err != nil { return nil, err @@ -285,31 +291,6 @@ func (r *AgentRepo) DeleteAgent(ctx context.Context, userID, accountID uint) err return nil } -// BulkCreateAgents adds multiple agents to an account by email. -// Reference: Chatwoot agents_controller.rb#bulk_create -// For each email: find or create user, then create AccountUser. -// Silently skips emails that fail (duplicate, invalid, etc.) — matches Chatwoot behavior. -func (r *AgentRepo) BulkCreateAgents(ctx context.Context, accountID uint, inviterID uint, emails []string) ([]AgentDetail, error) { - results := make([]AgentDetail, 0, len(emails)) - - for _, email := range emails { - name := email // Default name to email; Chatwoot uses email.split('@').first - atIdx := indexOfAt(email) - if atIdx > 0 { - name = email[:atIdx] - } - - detail, err := r.CreateAgent(ctx, accountID, inviterID, name, email, "agent", "offline", false, 0) - if err != nil { - // Silently skip — Chatwoot rescues ActiveRecord::RecordInvalid and logs - continue - } - results = append(results, *detail) - } - - return results, nil -} - // indexOfAt returns the index of '@' in the email string. func indexOfAt(email string) int { for i, c := range email { diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 8657866f..fe3bc7ac 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -728,16 +728,17 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { // Chatwoot: GET /api/v1/accounts/:account_id/agents (index) accounts.GET("/:account_id/agents", h.Agent.List) // Chatwoot: POST /api/v1/accounts/:account_id/agents (create) - accounts.POST("/:account_id/agents", h.Agent.Create) + accounts.POST("/:account_id/agents", middleware.RoleCheck("administrator"), h.Agent.Create) // Chatwoot: PATCH/PUT /api/v1/accounts/:account_id/agents/:id (update) - accounts.PATCH("/:account_id/agents/:agent_id", h.Agent.Update) - accounts.PUT("/:account_id/agents/:agent_id", h.Agent.Update) + accounts.PATCH("/:account_id/agents/:agent_id", middleware.RoleCheck("administrator"), h.Agent.Update) + accounts.PUT("/:account_id/agents/:agent_id", middleware.RoleCheck("administrator"), h.Agent.Update) // Chatwoot: DELETE /api/v1/accounts/:account_id/agents/:id (destroy) - accounts.DELETE("/:account_id/agents/:agent_id", h.Agent.Delete) + accounts.DELETE("/:account_id/agents/:agent_id", middleware.RoleCheck("administrator"), h.Agent.Delete) + accounts.POST("/:account_id/agents/:agent_id/reset_password", middleware.RoleCheck("administrator"), h.Agent.ResetPassword) // Chatwoot: GET /api/v1/accounts/:account_id/agents/:id (show) — was missing accounts.GET("/:account_id/agents/:agent_id", h.Agent.Get) // Chatwoot: POST /api/v1/accounts/:account_id/agents/bulk_create - accounts.POST("/:account_id/agents/bulk_create", h.Agent.BulkCreate) + accounts.POST("/:account_id/agents/bulk_create", middleware.RoleCheck("administrator"), h.Agent.BulkCreate) // Bulk assign/unassign conversations to agents (ref: Chatwoot agents_controller.rb #bulk_assign, #bulk_unassign) accounts.POST("/:account_id/agents/bulk_assign", h.AgentBulk.BulkAssign) accounts.POST("/:account_id/agents/bulk_unassign", h.AgentBulk.BulkUnassign) @@ -1160,6 +1161,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { labels.POST("/", h.Label.CreateTag) labels.GET("/:tag_id", h.Label.GetTag) labels.PUT("/:tag_id", h.Label.UpdateTag) + labels.PATCH("/:tag_id", h.Label.UpdateTag) labels.DELETE("/:tag_id", h.Label.DeleteTag) labels.GET("/:tag_id/conversations", h.Label.GetConversationsByTag) } @@ -1358,6 +1360,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { assistants.GET("/tools", h.CaptainAssistant.Tools) assistants.GET("/:assistant_id", h.CaptainAssistant.Get) assistants.PUT("/:assistant_id", h.CaptainAssistant.Update) + assistants.PATCH("/:assistant_id", h.CaptainAssistant.Update) assistants.DELETE("/:assistant_id", h.CaptainAssistant.Delete) // Inbox bindings @@ -1503,6 +1506,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { assistantResponses.GET("/", h.CaptainAssistantResponse.List) assistantResponses.GET("/:response_id", h.CaptainAssistantResponse.Get) assistantResponses.PUT("/:response_id", h.CaptainAssistantResponse.Update) + assistantResponses.PATCH("/:response_id", h.CaptainAssistantResponse.Update) assistantResponses.DELETE("/:response_id", h.CaptainAssistantResponse.Delete) // Extended: ProcessResponse (AI generation + optional store) assistantResponses.POST("/process", h.CaptainAssistantResponse.ProcessResponse) @@ -1699,6 +1703,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { automationRules.POST("/", h.AutomationRule.Create) automationRules.GET("/:automation_id", h.AutomationRule.Get) automationRules.PUT("/:automation_id", h.AutomationRule.Update) + automationRules.PATCH("/:automation_id", h.AutomationRule.Update) automationRules.DELETE("/:automation_id", h.AutomationRule.Delete) automationRules.POST("/:automation_id/clone", h.AutomationRule.Clone) automationRules.POST("/:automation_id/toggle_active", h.AutomationRule.ToggleActive) @@ -1714,6 +1719,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { macros.POST("/", h.Macro.Create) macros.GET("/:macro_id", h.Macro.Get) macros.PUT("/:macro_id", h.Macro.Update) + macros.PATCH("/:macro_id", h.Macro.Update) macros.DELETE("/:macro_id", h.Macro.Delete) macros.POST("/:macro_id/execute", h.Macro.Execute) macros.POST("/:macro_id/clone", h.Macro.Clone) diff --git a/backend/internal/router/router_test.go b/backend/internal/router/router_test.go index 9215e28f..4c817c1e 100644 --- a/backend/internal/router/router_test.go +++ b/backend/internal/router/router_test.go @@ -65,6 +65,11 @@ func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) { "GET /app/*params", "GET /api/v1/accounts/:account_id/captain/assistants/tools", "GET /api/v1/accounts/:account_id/captain/assistants/:assistant_id", + "PATCH /api/v1/accounts/:account_id/captain/assistants/:assistant_id", + "PATCH /api/v1/accounts/:account_id/captain/assistant_responses/:response_id", + "PATCH /api/v1/accounts/:account_id/automation_rules/:automation_id", + "PATCH /api/v1/accounts/:account_id/labels/:tag_id", + "PATCH /api/v1/accounts/:account_id/macros/:macro_id", "GET /api/v1/widget/conversations", "GET /api/v1/widget/conversations/toggle_status", "PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id", diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index 64687bde..24ac1559 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -84,18 +84,18 @@ func (s *AccountService) Create(ctx context.Context, userID uint, req CreateAcco // UpdateAccountRequest is the DTO for updating an account. type UpdateAccountRequest struct { - Name string `json:"name,omitempty" validate:"omitempty,min=2"` - Locale string `json:"locale,omitempty" validate:"omitempty,max=10"` - Domain string `json:"domain,omitempty" validate:"omitempty,min=3"` - SupportEmail string `json:"support_email,omitempty"` - FeatureFlags string `json:"feature_flags,omitempty"` - Status string `json:"status,omitempty" validate:"omitempty,oneof=active inactive"` - AutoResolveDuration int `json:"auto_resolve_duration,omitempty" validate:"omitempty,gte=0"` - AutoResolveAfter int `json:"auto_resolve_after,omitempty" validate:"omitempty,gte=0"` - AutoResolveMessage string `json:"auto_resolve_message,omitempty"` - AutoResolveIgnoreWaiting *bool `json:"auto_resolve_ignore_waiting,omitempty"` - AudioTranscriptions *bool `json:"audio_transcriptions,omitempty"` - AutoResolveLabel string `json:"auto_resolve_label,omitempty"` + Name string `json:"name,omitempty" validate:"omitempty,min=2"` + Locale string `json:"locale,omitempty" validate:"omitempty,max=10"` + Domain string `json:"domain,omitempty" validate:"omitempty,min=3"` + SupportEmail string `json:"support_email,omitempty"` + FeatureFlags string `json:"feature_flags,omitempty"` + Status string `json:"status,omitempty" validate:"omitempty,oneof=active inactive"` + AutoResolveDuration *int `json:"auto_resolve_duration,omitempty" validate:"omitempty,gte=0"` + AutoResolveAfter *int `json:"auto_resolve_after,omitempty" validate:"omitempty,gte=0"` + AutoResolveMessage string `json:"auto_resolve_message,omitempty"` + AutoResolveIgnoreWaiting *bool `json:"auto_resolve_ignore_waiting,omitempty"` + AudioTranscriptions *bool `json:"audio_transcriptions,omitempty"` + AutoResolveLabel *string `json:"auto_resolve_label,omitempty"` } // UpdateAccountOnboardingRequest matches Chatwoot's account onboarding payload. @@ -137,10 +137,16 @@ func (s *AccountService) Update(ctx context.Context, id uint, req UpdateAccountR if req.Status != "" { account.Status = req.Status } - if req.AutoResolveAfter > 0 { - account.AutoResolveDuration = req.AutoResolveAfter - } else if req.AutoResolveDuration > 0 { - account.AutoResolveDuration = req.AutoResolveDuration + if req.AutoResolveAfter != nil { + account.AutoResolveDuration = *req.AutoResolveAfter + } else if req.AutoResolveDuration != nil { + account.AutoResolveDuration = *req.AutoResolveDuration + } + if req.AutoResolveIgnoreWaiting != nil { + account.AutoResolveIgnoreWaiting = *req.AutoResolveIgnoreWaiting + } + if req.AutoResolveLabel != nil { + account.AutoResolveLabel = *req.AutoResolveLabel } if req.AudioTranscriptions != nil { account.AudioTranscriptions = *req.AudioTranscriptions diff --git a/backend/internal/service/agent_service.go b/backend/internal/service/agent_service.go index a4280c54..e85ee7d6 100644 --- a/backend/internal/service/agent_service.go +++ b/backend/internal/service/agent_service.go @@ -2,15 +2,18 @@ package service import ( "context" + "crypto/rand" "encoding/json" "errors" "fmt" + "math/big" "net/mail" "strings" "time" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" + pkgcrypto "github.com/gochat/gochat/pkg/crypto" applogger "github.com/gochat/gochat/pkg/logger" pkgvalidator "github.com/gochat/gochat/pkg/validator" "gorm.io/gorm" @@ -20,9 +23,8 @@ import ( // Reference: Chatwoot app/controllers/api/v1/accounts/agents_controller.rb // An "agent" in Chatwoot is a User with an AccountUser association in a specific account. type AgentService struct { - agentRepo *repository.AgentRepo - db *gorm.DB - confirmationMailer ProfileConfirmationMailer + agentRepo *repository.AgentRepo + db *gorm.DB } var ErrAgentNameBlank = errors.New("agent name cannot be blank") @@ -32,10 +34,6 @@ func NewAgentService(agentRepo *repository.AgentRepo, db *gorm.DB) *AgentService return &AgentService{agentRepo: agentRepo, db: db} } -func (s *AgentService) SetConfirmationMailer(mailer ProfileConfirmationMailer) { - s.confirmationMailer = mailer -} - func (s *AgentService) DB() *gorm.DB { if s == nil { return nil @@ -128,7 +126,16 @@ func (s *AgentService) Create(ctx context.Context, accountID uint, inviterID uin if req.CustomRoleID != nil { customRoleID = *req.CustomRoleID } - detail, err := s.agentRepo.CreateAgent(ctx, accountID, inviterID, name, req.Email, role, availability, req.AutoOffline, customRoleID) + temporaryPassword, err := generateTemporaryPassword() + if err != nil { + return nil, fmt.Errorf("generate temporary password: %w", err) + } + passwordDigest, err := pkgcrypto.HashPassword(temporaryPassword) + if err != nil { + return nil, fmt.Errorf("hash temporary password: %w", err) + } + confirmedAt := time.Now().UTC() + detail, err := s.agentRepo.CreateAgent(ctx, accountID, inviterID, name, req.Email, role, availability, req.AutoOffline, customRoleID, passwordDigest, confirmedAt) if err != nil { if errors.Is(err, repository.ErrAlreadyMember) { return nil, repository.ErrAlreadyMember @@ -137,66 +144,12 @@ func (s *AgentService) Create(ctx context.Context, accountID uint, inviterID uin return nil, err } if detail.IsNewUser { - if err := s.sendAgentInvitationConfirmation(ctx, accountID, inviterID, detail); err != nil { - return nil, err - } + detail.TemporaryPassword = temporaryPassword } return detail, nil } -func (s *AgentService) sendAgentInvitationConfirmation(ctx context.Context, accountID, inviterID uint, detail *repository.AgentDetail) error { - if s == nil || s.db == nil || detail == nil || detail.ConfirmedAt != nil { - return nil - } - resetPasswordToken, err := generateAuthToken() - if err != nil { - return fmt.Errorf("generate invitation reset token: %w", err) - } - now := time.Now().UTC() - digestedResetToken := digestAuthToken(resetPasswordToken) - if err := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", detail.ID).Updates(map[string]interface{}{ - "reset_password_token": digestedResetToken, - "reset_password_sent_at": now, - }).Error; err != nil { - return fmt.Errorf("persist invitation reset token: %w", err) - } - detail.ResetPasswordToken = digestedResetToken - detail.ResetPasswordSentAt = &now - - var account model.Account - if err := s.db.WithContext(ctx).First(&account, accountID).Error; err != nil { - return fmt.Errorf("load invitation account: %w", err) - } - var inviter *model.User - if inviterID != 0 { - var inviterUser model.User - if err := s.db.WithContext(ctx).First(&inviterUser, inviterID).Error; err != nil { - return fmt.Errorf("load invitation inviter: %w", err) - } - inviter = &inviterUser - } - - if s.confirmationMailer == nil { - applogger.L().Infof("confirmation mailer not configured for invited user %d (%s)", detail.ID, detail.Email) - return nil - } - brandName := s.confirmationBrandName(ctx) - req := buildProfileConfirmationMailRequest(&detail.User, &account, inviter, brandName, envConfirmationFrontendURL(), "", resetPasswordToken) - return s.confirmationMailer.SendConfirmationInstructions(ctx, req) -} - -func (s *AgentService) confirmationBrandName(ctx context.Context) string { - if s == nil || s.db == nil { - return "Chatwoot" - } - var cfg model.InstallationConfig - if err := s.db.WithContext(ctx).Where("name = ?", "BRAND_NAME").First(&cfg).Error; err != nil { - return "Chatwoot" - } - return cfg.Value -} - // Update modifies an agent's details (name on User, role/availability on AccountUser). func (s *AgentService) Update(ctx context.Context, userID, accountID uint, req UpdateAgentRequest) (*repository.AgentDetail, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { @@ -214,6 +167,39 @@ func (s *AgentService) Delete(ctx context.Context, userID, accountID uint) error return s.agentRepo.DeleteAgent(ctx, userID, accountID) } +// ResetPassword assigns a new one-time-visible random password to an email agent. +// The plaintext password is returned to the administrator and is never persisted. +func (s *AgentService) ResetPassword(ctx context.Context, userID, accountID uint) (string, error) { + detail, err := s.agentRepo.FindAgentByID(ctx, userID, accountID) + if err != nil { + return "", err + } + if detail.Provider != "" && detail.Provider != "email" { + return "", fmt.Errorf("password reset is only allowed for email users") + } + + temporaryPassword, err := generateTemporaryPassword() + if err != nil { + return "", fmt.Errorf("generate temporary password: %w", err) + } + passwordDigest, err := pkgcrypto.HashPassword(temporaryPassword) + if err != nil { + return "", fmt.Errorf("hash temporary password: %w", err) + } + now := time.Now().UTC() + if err := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{ + "password": passwordDigest, + "password_digest": passwordDigest, + "confirmed_at": now, + "confirmation_token": "", + "reset_password_token": "", + "reset_password_sent_at": nil, + }).Error; err != nil { + return "", err + } + return temporaryPassword, nil +} + // BulkCreate adds multiple agents to an account by email. // Reference: Chatwoot agents_controller.rb#bulk_create — iterates emails, creates AgentBuilder for each. // Silently skips emails that fail (duplicate, etc.). @@ -225,9 +211,13 @@ func (s *AgentService) BulkCreate(ctx context.Context, accountID uint, inviterID } } - created, err := s.agentRepo.BulkCreateAgents(ctx, accountID, inviterID, emails) - if err != nil { - return nil, err + created := make([]repository.AgentDetail, 0, len(emails)) + for _, email := range emails { + detail, err := s.Create(ctx, accountID, inviterID, CreateAgentRequest{Email: email}) + if err != nil { + continue + } + created = append(created, *detail) } if s.db != nil { if err := s.db.WithContext(ctx).Model(&model.Account{}).Where("id = ?", accountID).Update("onboarding_step", "").Error; err != nil { @@ -237,6 +227,49 @@ func (s *AgentService) BulkCreate(ctx context.Context, accountID uint, inviterID return created, nil } +const temporaryPasswordLength = 16 + +func generateTemporaryPassword() (string, error) { + groups := []string{ + "ABCDEFGHJKLMNPQRSTUVWXYZ", + "abcdefghijkmnopqrstuvwxyz", + "23456789", + "!@#$%", + } + all := strings.Join(groups, "") + password := make([]byte, 0, temporaryPasswordLength) + for _, group := range groups { + char, err := randomPasswordCharacter(group) + if err != nil { + return "", err + } + password = append(password, char) + } + for len(password) < temporaryPasswordLength { + char, err := randomPasswordCharacter(all) + if err != nil { + return "", err + } + password = append(password, char) + } + for i := len(password) - 1; i > 0; i-- { + j, err := rand.Int(rand.Reader, big.NewInt(int64(i+1))) + if err != nil { + return "", err + } + password[i], password[j.Int64()] = password[j.Int64()], password[i] + } + return string(password), nil +} + +func randomPasswordCharacter(alphabet string) (byte, error) { + index, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet)))) + if err != nil { + return 0, err + } + return alphabet[index.Int64()], nil +} + func isValidBulkAgentEmail(email string) bool { email = strings.TrimSpace(email) if email == "" { diff --git a/backend/internal/service/assignment_policy_service.go b/backend/internal/service/assignment_policy_service.go index df12b806..576cc143 100644 --- a/backend/internal/service/assignment_policy_service.go +++ b/backend/internal/service/assignment_policy_service.go @@ -24,6 +24,14 @@ type AssignmentPolicyService struct { type AssignmentPolicyPayload map[string]any +const ( + assignmentOrderRoundRobin = "round_robin" + assignmentOrderBalanced = "balanced" + + conversationPriorityEarliestCreated = "earliest_created" + conversationPriorityLongestWaiting = "longest_waiting" +) + // NewAssignmentPolicyService creates a new AssignmentPolicy service. func NewAssignmentPolicyService( policyRepo *repository.AssignmentPolicyRepo, @@ -37,8 +45,8 @@ func NewAssignmentPolicyService( type CreatePolicyRequest struct { Name string `json:"name" validate:"required"` Description string `json:"description,omitempty"` - AssignmentOrder int `json:"assignment_order,omitempty"` - ConversationPriority int `json:"conversation_priority,omitempty"` + AssignmentOrder string `json:"assignment_order,omitempty" validate:"omitempty,oneof=round_robin balanced"` + ConversationPriority string `json:"conversation_priority,omitempty" validate:"omitempty,oneof=earliest_created longest_waiting"` FairDistributionLimit int `json:"fair_distribution_limit,omitempty"` FairDistributionWindow int `json:"fair_distribution_window,omitempty"` Enabled *bool `json:"enabled,omitempty"` @@ -46,13 +54,13 @@ type CreatePolicyRequest struct { // UpdatePolicyRequest is the DTO for updating an assignment policy. type UpdatePolicyRequest struct { - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - AssignmentOrder *int `json:"assignment_order,omitempty"` - ConversationPriority *int `json:"conversation_priority,omitempty"` - FairDistributionLimit *int `json:"fair_distribution_limit,omitempty"` - FairDistributionWindow *int `json:"fair_distribution_window,omitempty"` - Enabled *bool `json:"enabled,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + AssignmentOrder *string `json:"assignment_order,omitempty" validate:"omitempty,oneof=round_robin balanced"` + ConversationPriority *string `json:"conversation_priority,omitempty" validate:"omitempty,oneof=earliest_created longest_waiting"` + FairDistributionLimit *int `json:"fair_distribution_limit,omitempty"` + FairDistributionWindow *int `json:"fair_distribution_window,omitempty"` + Enabled *bool `json:"enabled,omitempty"` } // CreateInboxPolicyRequest is the DTO for creating an inbox assignment policy. @@ -110,6 +118,9 @@ func (s *AssignmentPolicyService) CreateAccountPolicy(ctx context.Context, accou return nil, err } + assignmentOrder := assignmentOrderToValue(req.AssignmentOrder) + conversationPriority := conversationPriorityToValue(req.ConversationPriority) + enabled := true if req.Enabled != nil { enabled = *req.Enabled @@ -129,8 +140,8 @@ func (s *AssignmentPolicyService) CreateAccountPolicy(ctx context.Context, accou AccountID: accountID, Name: req.Name, Description: req.Description, - AssignmentOrder: req.AssignmentOrder, - ConversationPriority: req.ConversationPriority, + AssignmentOrder: assignmentOrder, + ConversationPriority: conversationPriority, FairDistributionLimit: limit, FairDistributionWindow: window, Enabled: enabled, @@ -159,10 +170,10 @@ func (s *AssignmentPolicyService) UpdateAccountPolicy(ctx context.Context, id, a } policy.Description = req.Description if req.AssignmentOrder != nil { - policy.AssignmentOrder = *req.AssignmentOrder + policy.AssignmentOrder = assignmentOrderToValue(*req.AssignmentOrder) } if req.ConversationPriority != nil { - policy.ConversationPriority = *req.ConversationPriority + policy.ConversationPriority = conversationPriorityToValue(*req.ConversationPriority) } if req.FairDistributionLimit != nil { policy.FairDistributionLimit = *req.FairDistributionLimit @@ -256,8 +267,8 @@ func (s *AssignmentPolicyService) SerializePolicy(ctx context.Context, policy *m "id": policy.ID, "name": policy.Name, "description": policy.Description, - "assignment_order": policy.AssignmentOrder, - "conversation_priority": policy.ConversationPriority, + "assignment_order": assignmentOrderFromValue(policy.AssignmentOrder), + "conversation_priority": conversationPriorityFromValue(policy.ConversationPriority), "fair_distribution_limit": policy.FairDistributionLimit, "fair_distribution_window": policy.FairDistributionWindow, "enabled": policy.Enabled, @@ -267,6 +278,34 @@ func (s *AssignmentPolicyService) SerializePolicy(ctx context.Context, policy *m }, nil } +func assignmentOrderToValue(value string) int { + if value == assignmentOrderBalanced { + return 1 + } + return 0 +} + +func assignmentOrderFromValue(value int) string { + if value == 1 { + return assignmentOrderBalanced + } + return assignmentOrderRoundRobin +} + +func conversationPriorityToValue(value string) int { + if value == conversationPriorityLongestWaiting { + return 1 + } + return 0 +} + +func conversationPriorityFromValue(value int) string { + if value == 1 { + return conversationPriorityLongestWaiting + } + return conversationPriorityEarliestCreated +} + func unixSeconds(value time.Time) int64 { if value.IsZero() { return 0 diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 25356407..3dfd45fc 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -213,50 +213,6 @@ func (s *AuthService) LoginWithMFA(ctx context.Context, userID uint, totpCode st }, nil } -// RegisterInput holds registration request parameters. -type RegisterInput struct { - Name string - Email string - Password string -} - -// Register creates a new user account with email/password. -// Flow: hash password → create user → send confirmation email → return user. -func (s *AuthService) Register(ctx context.Context, input *RegisterInput) (*model.User, error) { - // Check for duplicate email - var existing model.User - if err := s.db.Where("email = ?", input.Email).First(&existing).Error; err == nil { - return nil, fmt.Errorf("email already registered") - } - - // Hash password - passwordDigest, err := pkgcrypto.HashPassword(input.Password) - if err != nil { - return nil, fmt.Errorf("failed to hash password: %w", err) - } - - // Create user - user := &model.User{ - Name: input.Name, - Email: input.Email, - PasswordDigest: passwordDigest, - Provider: "email", - } - - if err := s.db.Create(user).Error; err != nil { - return nil, fmt.Errorf("failed to create user: %w", err) - } - - // Production note: confirmation email should be sent via async worker queue. - // Current implementation logs the action; wire email worker when infrastructure is ready. - // For now, auto-confirm for development convenience - now := time.Now() - user.ConfirmedAt = &now - s.db.Save(user) - - return user, nil -} - // --- Token Refresh / Rotation --- // RefreshInput holds refresh token request parameters. diff --git a/backend/internal/service/conversation_maintenance_worker.go b/backend/internal/service/conversation_maintenance_worker.go index 2975655b..355ce928 100644 --- a/backend/internal/service/conversation_maintenance_worker.go +++ b/backend/internal/service/conversation_maintenance_worker.go @@ -328,24 +328,60 @@ func (r *conversationMaintenanceRunner) performResolutionForAccount(ctx context. } cutoff := r.now().Add(-time.Duration(account.AutoResolveDuration) * time.Minute).Unix() now := r.now() - var conversationIDs []uint - if err := r.db.WithContext(ctx).Model(&model.Conversation{}). + query := r.db.WithContext(ctx).Model(&model.Conversation{}). Where("account_id = ? AND status = ? AND contact_id <> 0", account.ID, string(model.ConversationStatusOpen)). - Where("last_activity_at IS NOT NULL AND last_activity_at < ?", cutoff). - Limit(conversationResolutionLimit). - Pluck("id", &conversationIDs).Error; err != nil { + Where("last_activity_at IS NOT NULL AND last_activity_at < ?", cutoff) + if account.AutoResolveIgnoreWaiting { + query = query.Where("waiting_since IS NULL") + } + var conversations []model.Conversation + if err := query.Limit(conversationResolutionLimit).Find(&conversations).Error; err != nil { return err } - if len(conversationIDs) == 0 { + if len(conversations) == 0 { return nil } - updates := map[string]any{ - "status": string(model.ConversationStatusResolved), - "resolved_at": now, + conversationIDs := make([]uint, 0, len(conversations)) + for i := range conversations { + conversationIDs = append(conversationIDs, conversations[i].ID) } - if err := r.db.WithContext(ctx).Model(&model.Conversation{}). - Where("id IN ?", conversationIDs). - Updates(updates).Error; err != nil { + + var autoResolveTag *model.Tag + if label := strings.TrimSpace(account.AutoResolveLabel); label != "" { + var tag model.Tag + if err := r.db.WithContext(ctx).Where("account_id = ? AND name = ?", account.ID, label).First(&tag).Error; err == nil { + autoResolveTag = &tag + } else if err != gorm.ErrRecordNotFound { + return err + } + } + + if err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for i := range conversations { + conversation := &conversations[i] + updates := map[string]any{ + "status": string(model.ConversationStatusResolved), + "resolved_at": now, + } + if autoResolveTag != nil { + updates["labels"] = mergeConversationLabels(conversation.Labels, []string{autoResolveTag.Name}, nil) + } + if err := tx.Model(&model.Conversation{}).Where("id = ?", conversation.ID).Updates(updates).Error; err != nil { + return err + } + if autoResolveTag != nil { + conversationLabel := model.ConversationLabel{ + ConversationID: conversation.ID, + TagID: autoResolveTag.ID, + AccountID: account.ID, + } + if err := tx.Where("conversation_id = ? AND tag_id = ?", conversation.ID, autoResolveTag.ID).FirstOrCreate(&conversationLabel).Error; err != nil { + return err + } + } + } + return nil + }); err != nil { return err } return r.indexConversationSearchDocumentsByIDs(ctx, conversationIDs) diff --git a/backend/internal/service/conversation_maintenance_worker_test.go b/backend/internal/service/conversation_maintenance_worker_test.go index 8a2b7df8..7ea5ffaf 100644 --- a/backend/internal/service/conversation_maintenance_worker_test.go +++ b/backend/internal/service/conversation_maintenance_worker_test.go @@ -200,6 +200,71 @@ func TestConversationMaintenanceJobsProcessCampaignSnoozeAndResolution(t *testin } } +func TestConversationMaintenanceJobsResolutionHonorsPreferences(t *testing.T) { + now := time.Date(2026, 6, 5, 20, 10, 0, 0, time.UTC) + db := setupServiceTestDB(t) + wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return now })) + registerConversationMaintenanceJobsWithNow(wp, db, func() time.Time { return now }) + + account := createTestAccount(t, db) + account.AutoResolveDuration = 30 + account.AutoResolveIgnoreWaiting = true + account.AutoResolveLabel = "vip" + if err := db.Save(account).Error; err != nil { + t.Fatalf("save auto resolve preferences: %v", err) + } + tag := model.Tag{AccountID: account.ID, Name: "vip"} + if err := db.Create(&tag).Error; err != nil { + t.Fatalf("create auto resolve label: %v", err) + } + inbox := createTestInbox(t, db, account.ID, "web_widget") + contact := createTestContact(t, db, account.ID) + oldActivity := now.Add(-45 * time.Minute).Unix() + + eligible := createTestConversation(t, db, account.ID, inbox.ID, contact.ID) + if err := db.Model(eligible).Updates(map[string]any{"last_activity_at": oldActivity, "labels": "existing"}).Error; err != nil { + t.Fatalf("prepare eligible conversation: %v", err) + } + waiting := createTestConversation(t, db, account.ID, inbox.ID, contact.ID) + waitingSince := now.Add(-40 * time.Minute).Unix() + if err := db.Model(waiting).Updates(map[string]any{"last_activity_at": oldActivity, "waiting_since": waitingSince}).Error; err != nil { + t.Fatalf("prepare waiting conversation: %v", err) + } + + if _, err := wp.Enqueue(context.Background(), TaskTypeConversationResolutionForAccount, conversationResolutionJob{AccountID: account.ID}, worker.WithQueue("low")); err != nil { + t.Fatalf("enqueue preference-aware resolution: %v", err) + } + processRequiredJob(t, wp, "preference-aware resolution") + + var resolved model.Conversation + if err := db.First(&resolved, eligible.ID).Error; err != nil { + t.Fatalf("load eligible conversation: %v", err) + } + if resolved.Status != string(model.ConversationStatusResolved) || resolved.ResolvedAt == nil { + t.Fatalf("expected eligible conversation resolved, got status=%s resolved_at=%v", resolved.Status, resolved.ResolvedAt) + } + if resolved.Labels != "existing,vip" { + t.Fatalf("expected auto resolve label to be merged, got %q", resolved.Labels) + } + var labelCount int64 + if err := db.Model(&model.ConversationLabel{}). + Where("account_id = ? AND conversation_id = ? AND tag_id = ?", account.ID, eligible.ID, tag.ID). + Count(&labelCount).Error; err != nil { + t.Fatalf("count conversation label: %v", err) + } + if labelCount != 1 { + t.Fatalf("expected one persisted conversation label, got %d", labelCount) + } + + var skipped model.Conversation + if err := db.First(&skipped, waiting.ID).Error; err != nil { + t.Fatalf("load waiting conversation: %v", err) + } + if skipped.Status != string(model.ConversationStatusOpen) || skipped.ResolvedAt != nil { + t.Fatalf("expected waiting conversation to remain open, got status=%s resolved_at=%v", skipped.Status, skipped.ResolvedAt) + } +} + func TestConversationMaintenanceJobsCampaignTriggerDispatchesEvents(t *testing.T) { now := time.Date(2026, 6, 5, 20, 15, 0, 0, time.UTC) db := setupServiceTestDB(t) diff --git a/backend/internal/service/inbox_member_service.go b/backend/internal/service/inbox_member_service.go index 0e423072..0764cd0d 100644 --- a/backend/internal/service/inbox_member_service.go +++ b/backend/internal/service/inbox_member_service.go @@ -2,6 +2,10 @@ package service import ( "context" + "encoding/json" + "fmt" + "strconv" + "strings" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" @@ -113,6 +117,38 @@ type UpdateMultipleRequest struct { UserIDs []uint `json:"user_ids"` } +// UnmarshalJSON accepts both the numeric inbox_id used by the API and the +// numeric string produced by Vue Router route params in the Chatwoot frontend. +func (r *UpdateMultipleRequest) UnmarshalJSON(data []byte) error { + var raw struct { + InboxID json.RawMessage `json:"inbox_id"` + UserIDs []uint `json:"user_ids"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + r.UserIDs = raw.UserIDs + if len(raw.InboxID) == 0 || string(raw.InboxID) == "null" { + return nil + } + + value := strings.TrimSpace(string(raw.InboxID)) + if strings.HasPrefix(value, "\"") { + if err := json.Unmarshal(raw.InboxID, &value); err != nil { + return fmt.Errorf("invalid inbox_id: %w", err) + } + value = strings.TrimSpace(value) + } + + parsed, err := strconv.ParseUint(value, 10, 32) + if err != nil { + return fmt.Errorf("invalid inbox_id: %w", err) + } + r.InboxID = uint(parsed) + return nil +} + // AddMembers adds the requested users without removing existing members. // Reference: Chatwoot inbox_members#create - add_members(agents_to_be_added_ids). func (s *InboxMemberService) AddMembers(ctx context.Context, req UpdateMultipleRequest) ([]model.InboxMember, error) { diff --git a/backend/internal/service/inbox_service.go b/backend/internal/service/inbox_service.go index 74f7555a..974cbaba 100644 --- a/backend/internal/service/inbox_service.go +++ b/backend/internal/service/inbox_service.go @@ -2127,12 +2127,14 @@ func generateRandomHex(n int) (string, error) { } // isFakeChannelAllowed reports whether the fake test channel is allowed. -// It is disabled in production environments. +// It is disabled in production environments unless explicitly enabled for +// QA/UAT with GOCHAT_ALLOW_FAKE_CHANNEL. func isFakeChannelAllowed() bool { env := strings.ToLower(strings.TrimSpace(os.Getenv("GOCHAT_ENV"))) switch env { case "production", "prod": - return false + value := strings.TrimSpace(os.Getenv("GOCHAT_ALLOW_FAKE_CHANNEL")) + return strings.EqualFold(value, "true") || value == "1" default: return true } diff --git a/backend/internal/service/inbox_service_test.go b/backend/internal/service/inbox_service_test.go index 2174860c..04430a08 100644 --- a/backend/internal/service/inbox_service_test.go +++ b/backend/internal/service/inbox_service_test.go @@ -658,3 +658,23 @@ func TestInboxService_DisableWhatsAppCalling_PersistsFalseAndIgnoresWebhookFailu providerConfig := parseJSONMap(updated.ProviderConfig) assert.Equal(t, false, providerConfig["calling_enabled"]) } + +func TestIsFakeChannelAllowed(t *testing.T) { + t.Run("non-production environments allow fake channels", func(t *testing.T) { + t.Setenv("GOCHAT_ENV", "development") + t.Setenv("GOCHAT_ALLOW_FAKE_CHANNEL", "") + assert.True(t, isFakeChannelAllowed()) + }) + + t.Run("production disables fake channels by default", func(t *testing.T) { + t.Setenv("GOCHAT_ENV", "prod") + t.Setenv("GOCHAT_ALLOW_FAKE_CHANNEL", "") + assert.False(t, isFakeChannelAllowed()) + }) + + t.Run("production can explicitly enable fake channels", func(t *testing.T) { + t.Setenv("GOCHAT_ENV", "production") + t.Setenv("GOCHAT_ALLOW_FAKE_CHANNEL", "true") + assert.True(t, isFakeChannelAllowed()) + }) +} diff --git a/backend/internal/service/service_test_helper.go b/backend/internal/service/service_test_helper.go index 0d2a2967..7722bff8 100644 --- a/backend/internal/service/service_test_helper.go +++ b/backend/internal/service/service_test_helper.go @@ -67,6 +67,7 @@ func setupServiceTestDB(t *testing.T) *gorm.DB { &model.Company{}, &model.CompanyNote{}, &model.Tag{}, + &model.ConversationLabel{}, &model.ContactLabel{}, &model.ContactExport{}, &model.CsatTemplate{}, diff --git a/backend/internal/service/upload_service.go b/backend/internal/service/upload_service.go index 87866803..baf46860 100644 --- a/backend/internal/service/upload_service.go +++ b/backend/internal/service/upload_service.go @@ -137,6 +137,56 @@ func (s *UploadService) AccountUpload(ctx context.Context, accountID uint, req A return s.processUpload(ctx, accountID, req.FileHeader, model.DirectUploadSourceAccount) } +// ProfileAvatarUpload stores a dashboard user's avatar as a durable account +// file. Profile avatars intentionally bypass the direct_uploads staging table: +// they are referenced immediately from users.avatar_url and do not expire. +func (s *UploadService) ProfileAvatarUpload(ctx context.Context, accountID uint, fileHeader *multipart.FileHeader) (*UploadResponse, error) { + if accountID == 0 { + return nil, errors.New("account_id is required") + } + if fileHeader == nil { + return nil, errors.New("avatar file is required") + } + + mimeType := fileHeader.Header.Get("Content-Type") + if mimeType == "" || mimeType == "application/octet-stream" { + mimeType = detectUploadMIMEFromFilename(fileHeader.Filename) + } + if !strings.HasPrefix(mimeType, "image/") { + return nil, fmt.Errorf("avatar must be an image, got %s", mimeType) + } + if !isUploadMIMEAllowed("image", mimeType) { + return nil, fmt.Errorf("MIME type %s is not allowed for avatar", mimeType) + } + maxSize := int64(s.cfg.Storage.MaxFileSize) + if maxSize <= 0 { + maxSize = 20 << 20 + } + if fileHeader.Size > maxSize { + return nil, fmt.Errorf("avatar file size %d exceeds maximum %d", fileHeader.Size, maxSize) + } + + src, err := fileHeader.Open() + if err != nil { + return nil, fmt.Errorf("failed to open avatar file: %w", err) + } + defer src.Close() + + fileURL, thumbURL, err := s.saveUploadReader(accountID, model.DirectUploadSourceAccount, filepath.Ext(fileHeader.Filename), mimeType, src) + if err != nil { + return nil, fmt.Errorf("failed to save avatar file: %w", err) + } + return &UploadResponse{ + OriginalName: fileHeader.Filename, + FileType: "image", + MimeType: mimeType, + FileSize: fileHeader.Size, + FileURL: fileURL, + ThumbURL: thumbURL, + Status: string(model.DirectUploadStatusCompleted), + }, nil +} + func (s *UploadService) AccountUploadFromURL(ctx context.Context, accountID uint, externalURL string) (*UploadResponse, error) { if accountID == 0 { return nil, errors.New("account_id is required") diff --git a/backend/migrations/000055_add_auto_resolve_preferences_to_accounts.down.sql b/backend/migrations/000055_add_auto_resolve_preferences_to_accounts.down.sql new file mode 100644 index 00000000..20477b49 --- /dev/null +++ b/backend/migrations/000055_add_auto_resolve_preferences_to_accounts.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE accounts +DROP COLUMN IF EXISTS auto_resolve_label; + +ALTER TABLE accounts +DROP COLUMN IF EXISTS auto_resolve_ignore_waiting; diff --git a/backend/migrations/000055_add_auto_resolve_preferences_to_accounts.up.sql b/backend/migrations/000055_add_auto_resolve_preferences_to_accounts.up.sql new file mode 100644 index 00000000..ef9d1c53 --- /dev/null +++ b/backend/migrations/000055_add_auto_resolve_preferences_to_accounts.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE accounts +ADD COLUMN IF NOT EXISTS auto_resolve_ignore_waiting BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE accounts +ADD COLUMN IF NOT EXISTS auto_resolve_label VARCHAR(255) NOT NULL DEFAULT ''; diff --git a/deploy/quickstart/.env.example b/deploy/quickstart/.env.example index b98f3bc0..d8dfc8ac 100644 --- a/deploy/quickstart/.env.example +++ b/deploy/quickstart/.env.example @@ -19,6 +19,7 @@ REDIS_PASSWORD= # GoChat runtime GOCHAT_ENV=development +GOCHAT_ALLOW_FAKE_CHANNEL=false GOCHAT_SERVER_MODE=debug GOCHAT_JWT_SECRET=gochat_quickstart_change_me_minimum_32_chars GOCHAT_CAPTAIN_ENABLED=false diff --git a/frontend/.playwright-cli/page-2026-07-11T10-48-06-813Z.yml b/frontend/.playwright-cli/page-2026-07-11T10-48-06-813Z.yml new file mode 100644 index 00000000..6b5b667b --- /dev/null +++ b/frontend/.playwright-cli/page-2026-07-11T10-48-06-813Z.yml @@ -0,0 +1,88 @@ +- generic [ref=f2e4]: + - complementary [ref=f2e5]: + - generic [ref=f2e6]: + - button "112233" [ref=f2e12] + - generic [ref=f2e14]: + - link "搜索……" [ref=f2e15] [cursor=pointer]: + - /url: /app/accounts/1/search + - button [ref=f2e19] [cursor=pointer] + - navigation [ref=f2e21]: + - list [ref=f2e22]: + - listitem [ref=f2e23] [cursor=pointer]: + - button "我的收件箱" [ref=f2e24] + - listitem [ref=f2e29] [cursor=pointer]: + - button "会话" [ref=f2e30] + - listitem [ref=f2e35] [cursor=pointer]: + - button "联系人" [ref=f2e36] + - listitem [ref=f2e41] [cursor=pointer]: + - button "报告" [ref=f2e42] + - listitem [ref=f2e47] [cursor=pointer]: + - button "活动" [ref=f2e48] + - listitem [ref=f2e53] [cursor=pointer]: + - button "帮助中心" [ref=f2e54] + - list [ref=f2e60]: + - listitem [ref=f2e61]: + - link "文章" [ref=f2e62]: + - /url: /app/accounts/1/portals/portals_articles_index + - listitem [ref=f2e64]: + - link "类别" [ref=f2e65]: + - /url: /app/accounts/1/portals/portals_categories_index + - listitem [ref=f2e67]: + - link "语言" [ref=f2e68]: + - /url: /app/accounts/1/portals/portals_locales_index + - listitem [ref=f2e70]: + - link "设置" [ref=f2e71]: + - /url: /app/accounts/1/portals/portals_settings_index + - listitem [ref=f2e73] [cursor=pointer]: + - button "设置" [ref=f2e74] + - button "Super Admin1 admin@gochat.local" [ref=f2e82] [cursor=pointer]: + - img [ref=f2e85]: + - generic [ref=f2e86]: SA + - generic [ref=f2e87]: + - generic [ref=f2e88]: Super Admin1 + - generic [ref=f2e89]: admin@gochat.local + - main [ref=f2e92]: + - generic [ref=f2e96]: + - generic [ref=f2e98]: + - generic [ref=f2e99]: + - generic [ref=f2e100]: Smoke Help Center + - button [ref=f2e103] [cursor=pointer] + - generic [ref=f2e106]: + - generic [ref=f2e107]: + - button "所有文章 (3)" [ref=f2e108] [cursor=pointer] + - button "我的文章 (3)" [ref=f2e110] [cursor=pointer] + - button "草稿" [ref=f2e112] [cursor=pointer] + - button "已归档 (1)" [active] [ref=f2e114] [cursor=pointer] + - generic [ref=f2e115]: + - generic [ref=f2e116]: + - button "English" [ref=f2e119] [cursor=pointer] + - button "所有类别" [ref=f2e124] [cursor=pointer] + - button "新建文章" [ref=f2e127] [cursor=pointer] + - main [ref=f2e130]: + - list [ref=f2e218]: + - listitem [ref=f2e219]: + - generic [ref=f2e221]: + - generic [ref=f2e222]: + - generic [ref=f2e223]: "13123" + - generic [ref=f2e225]: + - generic [ref=f2e226]: 已归档 + - button [ref=f2e228] [cursor=pointer] + - generic [ref=f2e230]: + - generic [ref=f2e231]: + - generic [ref=f2e232]: + - img [ref=f2e234]: + - generic [ref=f2e235]: SA + - generic [ref=f2e236]: Super Admin1 + - generic [ref=f2e237]: Smoke Guides + - generic [ref=f2e238]: 6 次浏览 + - generic [ref=f2e241]: 17 minutes ago + - generic [ref=f2e243]: + - generic [ref=f2e244]: 显示第 1 - 1 项,共 1 项 + - generic [ref=f2e246]: + - button [disabled] [ref=f2e247] + - button [disabled] [ref=f2e249] + - generic [ref=f2e251]: + - generic [ref=f2e252]: "1" + - generic [ref=f2e253]: 第 页,共 1 页 + - button [disabled] [ref=f2e254] + - button [disabled] [ref=f2e256] \ No newline at end of file diff --git a/frontend/app/javascript/dashboard/api/agents.js b/frontend/app/javascript/dashboard/api/agents.js index cfc6b36f..4b97c611 100644 --- a/frontend/app/javascript/dashboard/api/agents.js +++ b/frontend/app/javascript/dashboard/api/agents.js @@ -12,6 +12,10 @@ class Agents extends ApiClient { emails, }); } + + resetPassword(agentId) { + return axios.post(`${this.url}/${agentId}/reset_password`); + } } export default new Agents(); diff --git a/frontend/app/javascript/dashboard/api/specs/agents.spec.js b/frontend/app/javascript/dashboard/api/specs/agents.spec.js index 0df0fd8d..2d046fcc 100644 --- a/frontend/app/javascript/dashboard/api/specs/agents.spec.js +++ b/frontend/app/javascript/dashboard/api/specs/agents.spec.js @@ -9,6 +9,7 @@ describe('#AgentAPI', () => { expect(agents).toHaveProperty('create'); expect(agents).toHaveProperty('update'); expect(agents).toHaveProperty('delete'); + expect(agents).toHaveProperty('resetPassword'); }); describe('API calls', () => { @@ -34,5 +35,12 @@ describe('#AgentAPI', () => { } ); }); + + it('#resetPassword', () => { + agents.resetPassword(42); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/agents/42/reset_password' + ); + }); }); }); diff --git a/frontend/app/javascript/dashboard/components/Code.vue b/frontend/app/javascript/dashboard/components/Code.vue index e45cacae..02e97388 100644 --- a/frontend/app/javascript/dashboard/components/Code.vue +++ b/frontend/app/javascript/dashboard/components/Code.vue @@ -54,8 +54,12 @@ const codepenScriptValue = computed(() => { const onCopy = async e => { e.preventDefault(); - await copyTextToClipboard(scrubbedScript.value); - useAlert(t('COMPONENTS.CODE.COPY_SUCCESSFUL')); + try { + await copyTextToClipboard(scrubbedScript.value); + useAlert(t('COMPONENTS.CODE.COPY_SUCCESSFUL')); + } catch { + useAlert(t('COMPONENTS.CODE.COPY_FAILED')); + } }; diff --git a/frontend/app/javascript/dashboard/components/Modal.vue b/frontend/app/javascript/dashboard/components/Modal.vue index c8a23bf9..a6854036 100644 --- a/frontend/app/javascript/dashboard/components/Modal.vue +++ b/frontend/app/javascript/dashboard/components/Modal.vue @@ -1,13 +1,14 @@