admin.spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. import { expect, test, type Page } from '@playwright/test';
  2. const chat = {
  3. chat_id: '-1001234567890',
  4. title: '产品运营测试群',
  5. username: 'product_ops_test',
  6. type: 'supergroup',
  7. accessible: true,
  8. member_count: 286,
  9. bot_status: 'administrator',
  10. bot_privileges: ['can_change_info', 'can_delete_messages', 'can_restrict_members'],
  11. };
  12. const recentMember = {
  13. user: {
  14. id: '10001',
  15. username: 'alice',
  16. first_name: '小明',
  17. last_name: '同学',
  18. is_bot: false,
  19. is_deleted: false,
  20. },
  21. status: 'recent',
  22. privileges: [],
  23. last_seen_at: '2026-07-24T08:00:00Z',
  24. };
  25. const permissions = [
  26. 'chat.profile',
  27. 'chat.members',
  28. 'chat.permissions',
  29. 'chat.announcements',
  30. 'chat.invites',
  31. 'chat.rules',
  32. 'chat.notes',
  33. 'automation.manage',
  34. 'points.manage',
  35. 'giveaways.manage',
  36. 'karma.manage',
  37. 'teacher_directory.manage',
  38. 'business_assistant.manage',
  39. 'media.upload',
  40. ];
  41. const bot = {
  42. bot_id: 'primary',
  43. label: 'Test Bot',
  44. enabled: true,
  45. bot_token_configured: true,
  46. sudo_users_id: ['10001'],
  47. log_group_id: '0',
  48. gban_log_group_id: '0',
  49. message_dump_chat: '-1001234567890',
  50. role_ids: ['full_access'],
  51. permissions,
  52. identity: { id: '999', username: 'test_bot', name: 'Test Bot', can_connect_to_business: true },
  53. business_assistant: {
  54. base_url: 'https://models.example.com/v1',
  55. api_key_configured: true,
  56. model: 'support-model',
  57. timeout_seconds: 30,
  58. max_output_tokens: 600,
  59. configured: true,
  60. },
  61. ready_to_connect: true,
  62. runtime: { state: 'running' },
  63. };
  64. function envelope(data: unknown) {
  65. return JSON.stringify({ data });
  66. }
  67. async function selectChatWorkspace(page: Page, name: string) {
  68. if ((page.viewportSize()?.width || 0) <= 575) {
  69. await page.evaluate(() => window.scrollTo(0, 0));
  70. await page.locator('.workspace-tab-select').click();
  71. await page.getByText(name, { exact: true }).last().click();
  72. return;
  73. }
  74. await page.getByRole('tab', { name }).click();
  75. }
  76. test.beforeEach(async ({ page }) => {
  77. await page.route('**/api/admin/v1/**', async (route) => {
  78. const url = new URL(route.request().url());
  79. const path = url.pathname;
  80. let data: unknown = {};
  81. if (path.endsWith('/auth/login') || path.endsWith('/auth/me')) {
  82. data = { username: 'admin', must_change_password: false, csrf_token: 'csrf-test' };
  83. } else if (path.endsWith('/bots')) {
  84. data = { items: [bot] };
  85. } else if (path.endsWith('/settings/telegram')) {
  86. data = {
  87. api_id: 12345,
  88. api_hash_configured: true,
  89. api_ready: true,
  90. permission_catalog: permissions.map((key) => ({
  91. key,
  92. name: key,
  93. group: '职责权限',
  94. description: key,
  95. })),
  96. roles: [
  97. {
  98. role_id: 'full_access',
  99. name: '全部职责',
  100. description: '拥有全部机器人职责',
  101. permissions,
  102. builtin: true,
  103. },
  104. ],
  105. bots: [bot],
  106. };
  107. } else if (path.endsWith('/dashboard')) {
  108. data = {
  109. counts: { chats: 3, giveaways: 8, point_accounts: 126, point_transactions: 804, audit_events: 19 },
  110. recent_giveaways: [
  111. {
  112. giveaway_id: 'a1b2c3',
  113. chat_id: chat.chat_id,
  114. title: '七月活跃抽奖',
  115. status: 'running',
  116. prizes: [{ name: '一等奖', count: 1, points_reward: 50 }],
  117. minimum_points: 20,
  118. entry_cost: 5,
  119. participation_reward: 2,
  120. ends_at: '2026-07-25T08:00:00Z',
  121. created_at: '2026-07-24T08:00:00Z',
  122. },
  123. ],
  124. top_accounts: [{ chat_id: chat.chat_id, user_id: '10001', username: 'alice', display_name: '爱丽丝 示例', balance: 180, lifetime_earned: 220, lifetime_spent: 40 }],
  125. recent_transactions: [{ transaction_id: 'tx1', chat_id: chat.chat_id, user_id: '10001', username: 'alice', display_name: '爱丽丝 示例', delta: 10, balance_after: 180, source: 'checkin', created_at: '2026-07-24T08:00:00Z' }],
  126. };
  127. } else if (path.endsWith('/chats')) {
  128. data = { items: [chat], total: 1, page: 1, page_size: 20 };
  129. } else if (path === `/api/admin/v1/chats/${chat.chat_id}`) {
  130. data = {
  131. ...chat,
  132. description: '用于后台验收的测试群',
  133. permissions: {},
  134. automation: {
  135. chat_id: chat.chat_id,
  136. auto_replies: [],
  137. blacklist_words: ['广告'],
  138. risk_rules: [
  139. {
  140. rule_id: 'ads',
  141. name: '广告拦截',
  142. enabled: true,
  143. keywords: ['广告'],
  144. match_images: true,
  145. match_links: true,
  146. actions: ['delete', 'warn'],
  147. duration_seconds: 3600,
  148. },
  149. ],
  150. identity_monitor: { enabled: true, notify_in_chat: false },
  151. welcome: { enabled: false, text: '', media: null },
  152. captcha_enabled: false,
  153. chatbot_enabled: false,
  154. antiflood_enabled: true,
  155. },
  156. };
  157. } else if (path.endsWith('/admins')) {
  158. data = { items: [] };
  159. } else if (path.endsWith('/members/recent')) {
  160. data = { items: [recentMember] };
  161. } else if (path.endsWith('/members/identity-changes')) {
  162. data = {
  163. items: [{
  164. change_id: 'change-1',
  165. chat_id: chat.chat_id,
  166. user_id: '10001',
  167. before: { username: 'alice', display_name: '小明 同学' },
  168. after: { username: 'alice_new', display_name: '小明 新昵称' },
  169. changed_fields: ['username', 'display_name'],
  170. observed_at: '2026-07-24T08:30:00Z',
  171. }],
  172. total: 1,
  173. page: 1,
  174. page_size: 20,
  175. };
  176. } else if (path.endsWith('/members/search')) {
  177. data = { items: [{ ...recentMember, status: 'member' }] };
  178. } else if (path.endsWith('/giveaways')) {
  179. data = { items: [], total: 0, page: 1, page_size: 20 };
  180. } else if (path.endsWith('/points/accounts') || path.endsWith('/points/transactions')) {
  181. data = { items: [], total: 0, page: 1, page_size: 20 };
  182. } else if (path.endsWith('/audit-logs')) {
  183. data = { items: [], total: 0, page: 1, page_size: 20 };
  184. } else if (path.endsWith('/directory/applications')) {
  185. data = {
  186. items: [{
  187. user_id: '20001',
  188. username: 'teacher_demo',
  189. display_name: '示例技师',
  190. application_status: 'pending',
  191. listed: false,
  192. online: false,
  193. applied_at: '2026-07-24T08:00:00Z',
  194. location: {
  195. source: 'telegram_location',
  196. updated_at: '2026-07-24T08:00:00Z',
  197. },
  198. }],
  199. total: 1,
  200. page: 1,
  201. page_size: 20,
  202. };
  203. } else if (
  204. path.endsWith('/directory/profiles') ||
  205. path.endsWith('/directory/locations') ||
  206. path.endsWith('/directory/events')
  207. ) {
  208. data = { items: [], total: 0, page: 1, page_size: 20 };
  209. } else if (path.endsWith('/directory/settings')) {
  210. data = {
  211. online_duration_hours: 24,
  212. nearby_radius_km: 20,
  213. nearby_radius_options_km: [5, 10, 20, 50],
  214. };
  215. } else if (path.endsWith('/directory/fulfillment-metrics')) {
  216. data = {
  217. counts: {},
  218. valid_requests: 3,
  219. quoted_requests: 2,
  220. confirmed_requests: 1,
  221. request_submission_rate: 1,
  222. quote_rate: 0.6667,
  223. customer_confirmation_rate: 0.5,
  224. match_success_rate: 0.3333,
  225. fulfillment_rate: 1,
  226. cancellation_rate: 0,
  227. cancellation_distribution: { customer: 0, technician: 0 },
  228. scope_note: '仅统计平台服务单;直接 Telegram 私聊成交不在统计范围。',
  229. };
  230. } else if (path.endsWith('/directory/service-settings')) {
  231. data = {
  232. customer_max_open_orders: 3,
  233. customer_daily_request_limit: 10,
  234. review_qr_expiry_hours: 24,
  235. review_forum_chat_id: '-1001234567890',
  236. review_forum_username: 'public_reviews',
  237. };
  238. } else if (path.endsWith('/directory/package-templates')) {
  239. data = { items: [], total: 0 };
  240. } else if (
  241. path.endsWith('/directory/service-orders') ||
  242. path.endsWith('/directory/reviews') ||
  243. path.endsWith('/directory/review-qrs') ||
  244. path.endsWith('/directory/leaderboard') ||
  245. path.endsWith('/directory/customer-blocks') ||
  246. path.endsWith('/directory/reports')
  247. ) {
  248. data = { items: [], total: 0, page: 1, page_size: 20 };
  249. } else if (path.endsWith('/directory/review-template')) {
  250. data = {
  251. template_id: 'review1',
  252. version: 1,
  253. status: 'active',
  254. published_at: '2026-07-26T00:00:00Z',
  255. questions: [
  256. {
  257. question_id: 'effect',
  258. type: 'single_choice',
  259. label: '服务效果',
  260. required: true,
  261. weight: 100,
  262. options: [
  263. { value: 'good', label: '满意', score: 5 },
  264. { value: 'bad', label: '不满意', score: 1 },
  265. ],
  266. },
  267. ],
  268. };
  269. } else if (path.endsWith('/business-assistant/status')) {
  270. data = {
  271. polling_state: 'running',
  272. business_mode_supported: true,
  273. webhook_conflict: false,
  274. webhook_url: '',
  275. model_configured: true,
  276. usage: { ai_calls: 8, customers: 3, conversations: 3, handoffs: 1, human_paused: 1 },
  277. };
  278. } else if (path.endsWith('/business-assistant/connections')) {
  279. data = {
  280. items: [{
  281. connection_id: 'conn-a',
  282. user: { id: '900', username: 'owner', first_name: '店主' },
  283. user_chat_id: '900',
  284. rights: { can_reply: true, can_read_messages: true },
  285. is_enabled: true,
  286. last_event_at: '2026-08-14T08:00:00Z',
  287. }],
  288. total: 1,
  289. page: 1,
  290. page_size: 100,
  291. };
  292. } else if (path.endsWith('/business-assistant/settings')) {
  293. data = {
  294. connection_id: 'conn-a',
  295. assistant_enabled: true,
  296. system_prompt: '只依据知识库回答。',
  297. language: 'zh-CN',
  298. tone: 'professional',
  299. account_daily_limit: 200,
  300. customer_daily_limit: 20,
  301. human_pause_hours: 24,
  302. notification_destination: 'both',
  303. ops_group_id: '-100123',
  304. timezone: 'Asia/Shanghai',
  305. digest_enabled: false,
  306. digest_time: '09:00',
  307. handoff_message: '请稍候,已转人工。',
  308. unsupported_message: '该消息需要人工处理。',
  309. };
  310. } else if (path.endsWith('/business-assistant/knowledge-sources')) {
  311. data = {
  312. items: [{
  313. source_id: 'source-1',
  314. connection_id: 'conn-a',
  315. source_type: 'channel',
  316. chat_id: '-100100',
  317. title: '官方公告频道',
  318. publication_mode: 'auto',
  319. author_policy: 'admins_or_allowlist',
  320. allowed_user_ids: [],
  321. include_linked_chat: true,
  322. linked_chat_id: '-100101',
  323. linked_chat_title: '官方讨论群',
  324. backfill_days: 30,
  325. backfill_limit: 100,
  326. enabled: true,
  327. sync_status: 'completed',
  328. last_sync_processed: 12,
  329. }],
  330. total: 1,
  331. page: 1,
  332. page_size: 20,
  333. };
  334. } else if (path.endsWith('/business-assistant/knowledge-candidates')) {
  335. data = {
  336. items: [{
  337. candidate_id: 'candidate-1',
  338. connection_id: 'conn-a',
  339. source_id: 'source-1',
  340. event_id: 'event-1',
  341. question: '配送范围?',
  342. aliases: [],
  343. keywords: ['配送'],
  344. answer: '仅支持市区配送。',
  345. tags: ['配送'],
  346. confidence: 0.92,
  347. status: 'pending',
  348. source_snapshot: {
  349. title: '官方公告频道',
  350. source_type: 'channel',
  351. chat_id: '-100100',
  352. message_id: '8',
  353. author_id: '900',
  354. },
  355. }],
  356. total: 1,
  357. page: 1,
  358. page_size: 20,
  359. };
  360. } else if (path.endsWith('/business-assistant/knowledge')) {
  361. data = { items: [], total: 0, page: 1, page_size: 20 };
  362. } else if (path.endsWith('/business-assistant/conversations')) {
  363. data = { items: [], total: 0, page: 1, page_size: 20 };
  364. } else if (path.endsWith('/business-assistant/usage')) {
  365. data = { ai_calls: 8, customers: 3, conversations: 3, handoffs: 1, human_paused: 1 };
  366. } else if (path.endsWith('/settings')) {
  367. data = {
  368. web_address: 'http://127.0.0.1:8088/admin',
  369. bind_host: '0.0.0.0',
  370. upload_limit_mb: 20,
  371. supervisor_mode: true,
  372. bot: { connected: false, id: '', username: '', name: '' },
  373. };
  374. }
  375. await route.fulfill({ status: 200, contentType: 'application/json', body: envelope(data) });
  376. });
  377. });
  378. test('登录与主要管理视图在不同视口无页面级横向滚动', async ({ page }, testInfo) => {
  379. await page.goto('/admin/login');
  380. await page.getByLabel('密码').fill('qwe0.123456');
  381. await page.getByRole('button', { name: /登\s*录/ }).click();
  382. await expect(page.getByRole('heading', { name: '概览' })).toBeVisible();
  383. const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
  384. expect(overflow).toBeLessThanOrEqual(1);
  385. await page.screenshot({ path: testInfo.outputPath('dashboard.png'), fullPage: true });
  386. await page.goto('/admin/chats');
  387. await expect(page.getByText('产品运营测试群')).toBeVisible();
  388. expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
  389. await page.goto(`/admin/chats/${chat.chat_id}`);
  390. await selectChatWorkspace(page, '自动化与风控');
  391. await expect(page.getByRole('heading', { name: '内容风控' })).toBeVisible();
  392. await expect(page.getByText('广告拦截')).toBeVisible();
  393. await page.getByRole('button', { name: '编辑 广告拦截' }).click();
  394. await expect(page.getByText('编辑风控规则')).toBeVisible();
  395. await expect(page.getByLabel('封禁时长')).toHaveValue('60');
  396. expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
  397. await page.getByRole('button', { name: '关闭' }).click();
  398. await selectChatWorkspace(page, '成员与管理员');
  399. await expect(page.getByText('小明 同学', { exact: true }).first()).toBeVisible();
  400. await expect(page.getByText('小明 新昵称', { exact: true }).first()).toBeVisible();
  401. expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
  402. await page.goto('/admin/giveaways');
  403. await expect(page.getByRole('heading', { name: '抽奖管理' })).toBeVisible();
  404. expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
  405. for (const [path, heading] of [
  406. ['/admin/points', '积分管理'],
  407. ['/admin/directory', '技师与附近'],
  408. ['/admin/services', '技师咨询与评价'],
  409. ['/admin/business-assistant', '智能接待'],
  410. ['/admin/audit', '操作日志'],
  411. ['/admin/settings', '系统设置'],
  412. ] as const) {
  413. await page.goto(path);
  414. await expect(page.getByRole('heading', { name: heading })).toBeVisible();
  415. if (path === '/admin/settings') {
  416. await expect(page.getByText('全部职责').first()).toBeVisible();
  417. await page.screenshot({ path: testInfo.outputPath('settings.png'), fullPage: true });
  418. }
  419. if (path === '/admin/directory') {
  420. await expect(page.getByText('示例技师')).toBeVisible();
  421. await expect(page.getByText('位置属于敏感信息')).toBeVisible();
  422. await page.screenshot({ path: testInfo.outputPath('directory.png'), fullPage: true });
  423. }
  424. if (path === '/admin/services') {
  425. await expect(page.getByText('咨询平台模式')).toBeVisible();
  426. await page.screenshot({ path: testInfo.outputPath('services.png'), fullPage: true });
  427. }
  428. if (path === '/admin/business-assistant') {
  429. await expect(page.getByText('允许回复')).toBeVisible();
  430. await expect(page.getByText('允许标记已读')).toBeVisible();
  431. await page.getByRole('tab', { name: '知识来源' }).click();
  432. await expect(page.getByText('官方公告频道')).toBeVisible();
  433. await expect(page.getByText('讨论群:官方讨论群')).toBeVisible();
  434. await page.getByRole('tab', { name: '知识候选' }).click();
  435. await expect(page.getByText('配送范围?')).toBeVisible();
  436. await expect(page.getByRole('button', { name: '批准' })).toBeVisible();
  437. await page.screenshot({ path: testInfo.outputPath('business-assistant.png'), fullPage: true });
  438. }
  439. expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
  440. }
  441. });