admin.spec.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553
  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. function luminance(color: string) {
  68. const channels = color.match(/[\d.]+/g)?.slice(0, 3).map(Number) || [];
  69. if (channels.length !== 3) throw new Error(`无法解析颜色:${color}`);
  70. const linear = channels.map((value) => {
  71. const normalized = value / 255;
  72. return normalized <= 0.04045
  73. ? normalized / 12.92
  74. : ((normalized + 0.055) / 1.055) ** 2.4;
  75. });
  76. return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
  77. }
  78. function contrastRatio(foreground: string, background: string) {
  79. const lighter = Math.max(luminance(foreground), luminance(background));
  80. const darker = Math.min(luminance(foreground), luminance(background));
  81. return (lighter + 0.05) / (darker + 0.05);
  82. }
  83. async function expectReadablePair(
  84. page: Page,
  85. foregroundSelector: string,
  86. backgroundSelector: string,
  87. minimum = 4.5,
  88. ) {
  89. const colors = await page.locator(foregroundSelector).first().evaluate(
  90. (foreground, selector) => {
  91. const background = document.querySelector(selector);
  92. if (!background) throw new Error(`找不到背景元素:${selector}`);
  93. return {
  94. foreground: getComputedStyle(foreground).color,
  95. background: getComputedStyle(background).backgroundColor,
  96. };
  97. },
  98. backgroundSelector,
  99. );
  100. expect(contrastRatio(colors.foreground, colors.background)).toBeGreaterThanOrEqual(minimum);
  101. }
  102. async function selectChatWorkspace(page: Page, name: string) {
  103. if ((page.viewportSize()?.width || 0) <= 575) {
  104. await page.evaluate(() => window.scrollTo(0, 0));
  105. await page.locator('.workspace-tab-select').click();
  106. await page.getByText(name, { exact: true }).last().click();
  107. return;
  108. }
  109. await page.getByRole('tab', { name }).click();
  110. }
  111. test.beforeEach(async ({ page }) => {
  112. await page.route('**/api/admin/v1/**', async (route) => {
  113. const url = new URL(route.request().url());
  114. const path = url.pathname;
  115. let data: unknown = {};
  116. if (path.endsWith('/auth/login') || path.endsWith('/auth/me')) {
  117. data = { username: 'admin', must_change_password: false, csrf_token: 'csrf-test' };
  118. } else if (path.endsWith('/bots')) {
  119. data = { items: [bot] };
  120. } else if (path.endsWith('/settings/telegram')) {
  121. data = {
  122. api_id: 12345,
  123. api_hash_configured: true,
  124. api_ready: true,
  125. permission_catalog: permissions.map((key) => ({
  126. key,
  127. name: key,
  128. group: '职责权限',
  129. description: key,
  130. })),
  131. roles: [
  132. {
  133. role_id: 'full_access',
  134. name: '全部职责',
  135. description: '拥有全部机器人职责',
  136. permissions,
  137. builtin: true,
  138. },
  139. ],
  140. bots: [bot],
  141. };
  142. } else if (path.endsWith('/dashboard')) {
  143. data = {
  144. counts: { chats: 3, giveaways: 8, point_accounts: 126, point_transactions: 804, audit_events: 19 },
  145. recent_giveaways: [
  146. {
  147. giveaway_id: 'a1b2c3',
  148. chat_id: chat.chat_id,
  149. title: '七月活跃抽奖',
  150. status: 'running',
  151. prizes: [{ name: '一等奖', count: 1, points_reward: 50 }],
  152. minimum_points: 20,
  153. entry_cost: 5,
  154. participation_reward: 2,
  155. ends_at: '2026-07-25T08:00:00Z',
  156. created_at: '2026-07-24T08:00:00Z',
  157. },
  158. ],
  159. top_accounts: [{ chat_id: chat.chat_id, user_id: '10001', username: 'alice', display_name: '爱丽丝 示例', balance: 180, lifetime_earned: 220, lifetime_spent: 40 }],
  160. 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' }],
  161. };
  162. } else if (path.endsWith('/chats')) {
  163. data = { items: [chat], total: 1, page: 1, page_size: 20 };
  164. } else if (path === `/api/admin/v1/chats/${chat.chat_id}`) {
  165. data = {
  166. ...chat,
  167. description: '用于后台验收的测试群',
  168. permissions: {},
  169. automation: {
  170. chat_id: chat.chat_id,
  171. auto_replies: [],
  172. blacklist_words: ['广告'],
  173. risk_rules: [
  174. {
  175. rule_id: 'ads',
  176. name: '广告拦截',
  177. enabled: true,
  178. keywords: ['广告'],
  179. match_images: true,
  180. match_links: true,
  181. actions: ['delete', 'warn'],
  182. duration_seconds: 3600,
  183. },
  184. ],
  185. identity_monitor: { enabled: true, notify_in_chat: false },
  186. welcome: { enabled: false, text: '', media: null },
  187. captcha_enabled: false,
  188. chatbot_enabled: false,
  189. antiflood_enabled: true,
  190. },
  191. };
  192. } else if (path.endsWith('/admins')) {
  193. data = { items: [] };
  194. } else if (path.endsWith('/members/recent')) {
  195. data = { items: [recentMember] };
  196. } else if (path.endsWith('/members/identity-changes')) {
  197. data = {
  198. items: [{
  199. change_id: 'change-1',
  200. chat_id: chat.chat_id,
  201. user_id: '10001',
  202. before: { username: 'alice', display_name: '小明 同学' },
  203. after: { username: 'alice_new', display_name: '小明 新昵称' },
  204. changed_fields: ['username', 'display_name'],
  205. observed_at: '2026-07-24T08:30:00Z',
  206. }],
  207. total: 1,
  208. page: 1,
  209. page_size: 20,
  210. };
  211. } else if (path.endsWith('/members/search')) {
  212. data = { items: [{ ...recentMember, status: 'member' }] };
  213. } else if (path.endsWith('/giveaways')) {
  214. data = { items: [], total: 0, page: 1, page_size: 20 };
  215. } else if (path.endsWith('/points/accounts') || path.endsWith('/points/transactions')) {
  216. data = { items: [], total: 0, page: 1, page_size: 20 };
  217. } else if (path.endsWith('/audit-logs')) {
  218. data = { items: [], total: 0, page: 1, page_size: 20 };
  219. } else if (path.endsWith('/directory/applications')) {
  220. data = {
  221. items: [{
  222. user_id: '20001',
  223. username: 'teacher_demo',
  224. display_name: '示例技师',
  225. application_status: 'pending',
  226. listed: false,
  227. online: false,
  228. applied_at: '2026-07-24T08:00:00Z',
  229. location: {
  230. source: 'telegram_location',
  231. updated_at: '2026-07-24T08:00:00Z',
  232. },
  233. }],
  234. total: 1,
  235. page: 1,
  236. page_size: 20,
  237. };
  238. } else if (
  239. path.endsWith('/directory/profiles') ||
  240. path.endsWith('/directory/locations') ||
  241. path.endsWith('/directory/events')
  242. ) {
  243. data = { items: [], total: 0, page: 1, page_size: 20 };
  244. } else if (path.endsWith('/directory/settings')) {
  245. data = {
  246. online_duration_hours: 24,
  247. nearby_radius_km: 20,
  248. nearby_radius_options_km: [5, 10, 20, 50],
  249. };
  250. } else if (path.endsWith('/directory/fulfillment-metrics')) {
  251. data = {
  252. counts: {},
  253. valid_requests: 3,
  254. quoted_requests: 2,
  255. confirmed_requests: 1,
  256. request_submission_rate: 1,
  257. quote_rate: 0.6667,
  258. customer_confirmation_rate: 0.5,
  259. match_success_rate: 0.3333,
  260. fulfillment_rate: 1,
  261. cancellation_rate: 0,
  262. cancellation_distribution: { customer: 0, technician: 0 },
  263. scope_note: '仅统计平台服务单;直接 Telegram 私聊成交不在统计范围。',
  264. };
  265. } else if (path.endsWith('/directory/service-settings')) {
  266. data = {
  267. customer_max_open_orders: 3,
  268. customer_daily_request_limit: 10,
  269. review_qr_expiry_hours: 24,
  270. review_forum_chat_id: '-1001234567890',
  271. review_forum_username: 'public_reviews',
  272. };
  273. } else if (path.endsWith('/directory/package-templates')) {
  274. data = { items: [], total: 0 };
  275. } else if (
  276. path.endsWith('/directory/service-orders') ||
  277. path.endsWith('/directory/reviews') ||
  278. path.endsWith('/directory/review-qrs') ||
  279. path.endsWith('/directory/leaderboard') ||
  280. path.endsWith('/directory/customer-blocks') ||
  281. path.endsWith('/directory/reports')
  282. ) {
  283. data = { items: [], total: 0, page: 1, page_size: 20 };
  284. } else if (path.endsWith('/directory/review-template')) {
  285. data = {
  286. template_id: 'review1',
  287. version: 1,
  288. status: 'active',
  289. published_at: '2026-07-26T00:00:00Z',
  290. questions: [
  291. {
  292. question_id: 'effect',
  293. type: 'single_choice',
  294. label: '服务效果',
  295. required: true,
  296. weight: 100,
  297. options: [
  298. { value: 'good', label: '满意', score: 5 },
  299. { value: 'bad', label: '不满意', score: 1 },
  300. ],
  301. },
  302. ],
  303. };
  304. } else if (path.endsWith('/business-assistant/status')) {
  305. data = {
  306. polling_state: 'running',
  307. business_mode_supported: true,
  308. webhook_conflict: false,
  309. webhook_url: '',
  310. model_configured: true,
  311. usage: { ai_calls: 8, customers: 3, conversations: 3, handoffs: 1, human_paused: 1 },
  312. };
  313. } else if (path.endsWith('/business-assistant/connections')) {
  314. data = {
  315. items: [{
  316. connection_id: 'conn-a',
  317. user: { id: '900', username: 'owner', first_name: '店主' },
  318. user_chat_id: '900',
  319. rights: { can_reply: true, can_read_messages: true },
  320. is_enabled: true,
  321. last_event_at: '2026-08-14T08:00:00Z',
  322. }],
  323. total: 1,
  324. page: 1,
  325. page_size: 100,
  326. };
  327. } else if (path.endsWith('/business-assistant/settings')) {
  328. data = {
  329. connection_id: 'conn-a',
  330. assistant_enabled: true,
  331. system_prompt: '只依据知识库回答。',
  332. language: 'zh-CN',
  333. tone: 'professional',
  334. account_daily_limit: 200,
  335. customer_daily_limit: 20,
  336. human_pause_hours: 24,
  337. notification_destination: 'both',
  338. ops_group_id: '-100123',
  339. timezone: 'Asia/Shanghai',
  340. digest_enabled: false,
  341. digest_time: '09:00',
  342. handoff_message: '请稍候,已转人工。',
  343. unsupported_message: '该消息需要人工处理。',
  344. };
  345. } else if (path.endsWith('/business-assistant/knowledge-sources')) {
  346. data = {
  347. items: [{
  348. source_id: 'source-1',
  349. connection_id: 'conn-a',
  350. source_type: 'channel',
  351. chat_id: '-100100',
  352. title: '官方公告频道',
  353. publication_mode: 'auto',
  354. author_policy: 'admins_or_allowlist',
  355. allowed_user_ids: [],
  356. include_linked_chat: true,
  357. linked_chat_id: '-100101',
  358. linked_chat_title: '官方讨论群',
  359. backfill_days: 30,
  360. backfill_limit: 100,
  361. enabled: true,
  362. sync_status: 'completed',
  363. last_sync_processed: 12,
  364. }],
  365. total: 1,
  366. page: 1,
  367. page_size: 20,
  368. };
  369. } else if (path.endsWith('/business-assistant/knowledge-candidates')) {
  370. data = {
  371. items: [{
  372. candidate_id: 'candidate-1',
  373. connection_id: 'conn-a',
  374. source_id: 'source-1',
  375. event_id: 'event-1',
  376. question: '配送范围?',
  377. aliases: [],
  378. keywords: ['配送'],
  379. answer: '仅支持市区配送。',
  380. tags: ['配送'],
  381. confidence: 0.92,
  382. status: 'pending',
  383. source_snapshot: {
  384. title: '官方公告频道',
  385. source_type: 'channel',
  386. chat_id: '-100100',
  387. message_id: '8',
  388. author_id: '900',
  389. },
  390. }],
  391. total: 1,
  392. page: 1,
  393. page_size: 20,
  394. };
  395. } else if (path.endsWith('/business-assistant/knowledge')) {
  396. data = { items: [], total: 0, page: 1, page_size: 20 };
  397. } else if (path.endsWith('/business-assistant/conversations')) {
  398. data = { items: [], total: 0, page: 1, page_size: 20 };
  399. } else if (path.endsWith('/business-assistant/usage')) {
  400. data = { ai_calls: 8, customers: 3, conversations: 3, handoffs: 1, human_paused: 1 };
  401. } else if (path.endsWith('/settings')) {
  402. data = {
  403. web_address: 'http://127.0.0.1:8088/admin',
  404. bind_host: '0.0.0.0',
  405. upload_limit_mb: 20,
  406. supervisor_mode: true,
  407. bot: { connected: false, id: '', username: '', name: '' },
  408. };
  409. }
  410. await route.fulfill({ status: 200, contentType: 'application/json', body: envelope(data) });
  411. });
  412. });
  413. test('登录与主要管理视图在不同视口无页面级横向滚动', async ({ page }, testInfo) => {
  414. await page.goto('/admin/login');
  415. await page.getByLabel('密码').fill('qwe0.123456');
  416. await page.getByRole('button', { name: /登\s*录/ }).click();
  417. await expect(page.getByRole('heading', { name: '概览' })).toBeVisible();
  418. if ((page.viewportSize()?.width || 0) >= 992) {
  419. const giveawayNavigationColor = await page
  420. .locator('.ant-menu-item')
  421. .filter({ hasText: '抽奖管理' })
  422. .evaluate((element) => getComputedStyle(element).color);
  423. expect(giveawayNavigationColor).toBe('rgb(182, 197, 213)');
  424. }
  425. const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
  426. expect(overflow).toBeLessThanOrEqual(1);
  427. await page.screenshot({ path: testInfo.outputPath('dashboard.png'), fullPage: true });
  428. await page.goto('/admin/chats');
  429. await expect(page.getByText('产品运营测试群')).toBeVisible();
  430. expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
  431. await page.goto(`/admin/chats/${chat.chat_id}`);
  432. await selectChatWorkspace(page, '自动化与风控');
  433. await expect(page.getByRole('heading', { name: '内容风控' })).toBeVisible();
  434. await expect(page.getByText('广告拦截')).toBeVisible();
  435. await page.getByRole('button', { name: '编辑 广告拦截' }).click();
  436. await expect(page.getByText('编辑风控规则')).toBeVisible();
  437. await expect(page.getByLabel('封禁时长')).toHaveValue('60');
  438. expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
  439. await page.getByRole('button', { name: '关闭' }).click();
  440. await selectChatWorkspace(page, '成员与管理员');
  441. await expect(page.getByText('小明 同学', { exact: true }).first()).toBeVisible();
  442. await expect(page.getByText('小明 新昵称', { exact: true }).first()).toBeVisible();
  443. expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
  444. await page.goto('/admin/giveaways');
  445. await expect(page.getByRole('heading', { name: '抽奖管理' })).toBeVisible();
  446. expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
  447. for (const [path, heading] of [
  448. ['/admin/points', '积分管理'],
  449. ['/admin/directory', '技师与附近'],
  450. ['/admin/services', '技师咨询与评价'],
  451. ['/admin/business-assistant', '智能接待'],
  452. ['/admin/audit', '操作日志'],
  453. ['/admin/settings', '系统设置'],
  454. ] as const) {
  455. await page.goto(path);
  456. await expect(page.getByRole('heading', { name: heading })).toBeVisible();
  457. if (path === '/admin/settings') {
  458. await expect(page.getByText('全部职责').first()).toBeVisible();
  459. await page.screenshot({ path: testInfo.outputPath('settings.png'), fullPage: true });
  460. }
  461. if (path === '/admin/directory') {
  462. await expect(page.getByText('示例技师')).toBeVisible();
  463. await expect(page.getByText('位置属于敏感信息')).toBeVisible();
  464. await page.screenshot({ path: testInfo.outputPath('directory.png'), fullPage: true });
  465. }
  466. if (path === '/admin/services') {
  467. await expect(page.getByText('咨询平台模式')).toBeVisible();
  468. await page.screenshot({ path: testInfo.outputPath('services.png'), fullPage: true });
  469. }
  470. if (path === '/admin/business-assistant') {
  471. if ((page.viewportSize()?.width || 0) >= 992) {
  472. const selectedNavigationColor = await page
  473. .locator('.ant-menu-item-selected')
  474. .evaluate((element) => getComputedStyle(element).color);
  475. expect(selectedNavigationColor).toBe('rgb(255, 255, 255)');
  476. }
  477. await expect(page.getByText('允许回复')).toBeVisible();
  478. await expect(page.getByText('允许标记已读')).toBeVisible();
  479. const reminderAlert = page
  480. .locator('.ant-alert-info')
  481. .filter({ hasText: '第一期按每条用户消息提醒' });
  482. const reminderColors = await reminderAlert.evaluate((element) => ({
  483. background: getComputedStyle(element).backgroundColor,
  484. message: getComputedStyle(element.querySelector('.ant-alert-message') as Element).color,
  485. description: getComputedStyle(element.querySelector('.ant-alert-description') as Element).color,
  486. }));
  487. expect(reminderColors).toEqual({
  488. background: 'rgb(237, 249, 254)',
  489. message: 'rgb(7, 94, 135)',
  490. description: 'rgb(49, 89, 109)',
  491. });
  492. await page.getByRole('tab', { name: '知识来源' }).click();
  493. await expect(page.getByText('官方公告频道')).toBeVisible();
  494. await expect(page.getByText('讨论群:官方讨论群')).toBeVisible();
  495. await page.getByRole('tab', { name: '知识候选' }).click();
  496. await expect(page.getByText('配送范围?')).toBeVisible();
  497. await expect(page.getByRole('button', { name: '批准' })).toBeVisible();
  498. await page.screenshot({ path: testInfo.outputPath('business-assistant.png'), fullPage: true });
  499. }
  500. expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
  501. }
  502. });
  503. test('深色导航不污染下拉、状态标签与业务弹层的浅色可读性', async ({ page }) => {
  504. await page.goto('/admin/chats');
  505. await expect(page.getByRole('heading', { name: '群组管理' })).toBeVisible();
  506. await page.locator('.bot-selector .ant-select-selector').click();
  507. await expect(page.locator('.ant-select-dropdown')).toBeVisible();
  508. await expect(page.locator('.bot-selector .ant-select-selection-item')).toHaveCSS('color', 'rgb(31, 52, 71)');
  509. await expect(page.locator('.bot-selector .ant-select-arrow')).toHaveCSS('color', 'rgb(64, 84, 105)');
  510. await expect(page.locator('.ant-select-dropdown')).toHaveCSS('background-color', 'rgb(255, 255, 255)');
  511. await expectReadablePair(
  512. page,
  513. '.ant-select-dropdown .ant-select-item-option-content',
  514. '.ant-select-dropdown',
  515. );
  516. await page.keyboard.press('Escape');
  517. await expect(page.locator('.ant-table-tbody .ant-tag')).toHaveCSS('background-color', 'rgb(239, 250, 245)');
  518. await expectReadablePair(page, '.ant-table-tbody .ant-tag', '.ant-table-tbody .ant-tag');
  519. await page.goto('/admin/giveaways');
  520. await page.getByRole('button', { name: '创建抽奖' }).click();
  521. await expect(page.locator('.ant-modal-content')).toHaveCSS('background-color', 'rgb(255, 255, 255)');
  522. await expectReadablePair(page, '.ant-modal-title', '.ant-modal-content');
  523. await expectReadablePair(page, '.ant-modal .ant-form-item-label label', '.ant-modal-content');
  524. await page.locator('.ant-modal-close').click();
  525. await page.goto('/admin/points');
  526. await page.getByRole('button', { name: '手工调整' }).click();
  527. await expect(page.locator('.ant-modal-content')).toHaveCSS('background-color', 'rgb(255, 255, 255)');
  528. await expectReadablePair(page, '.ant-modal-title', '.ant-modal-content');
  529. await expectReadablePair(page, '.ant-modal .ant-form-item-label label', '.ant-modal-content');
  530. });