admin.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  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. 'media.upload',
  39. ];
  40. const bot = {
  41. bot_id: 'primary',
  42. label: 'Test Bot',
  43. enabled: true,
  44. bot_token_configured: true,
  45. sudo_users_id: ['10001'],
  46. log_group_id: '0',
  47. gban_log_group_id: '0',
  48. message_dump_chat: '-1001234567890',
  49. role_ids: ['full_access'],
  50. permissions,
  51. identity: { id: '999', username: 'test_bot', name: 'Test Bot' },
  52. ready_to_connect: true,
  53. runtime: { state: 'running' },
  54. };
  55. function envelope(data: unknown) {
  56. return JSON.stringify({ data });
  57. }
  58. async function selectChatWorkspace(page: Page, name: string) {
  59. if ((page.viewportSize()?.width || 0) <= 575) {
  60. await page.evaluate(() => window.scrollTo(0, 0));
  61. await page.locator('.workspace-tab-select').click();
  62. await page.getByText(name, { exact: true }).last().click();
  63. return;
  64. }
  65. await page.getByRole('tab', { name }).click();
  66. }
  67. test.beforeEach(async ({ page }) => {
  68. await page.route('**/api/admin/v1/**', async (route) => {
  69. const url = new URL(route.request().url());
  70. const path = url.pathname;
  71. let data: unknown = {};
  72. if (path.endsWith('/auth/login') || path.endsWith('/auth/me')) {
  73. data = { username: 'admin', must_change_password: false, csrf_token: 'csrf-test' };
  74. } else if (path.endsWith('/bots')) {
  75. data = { items: [bot] };
  76. } else if (path.endsWith('/settings/telegram')) {
  77. data = {
  78. api_id: 12345,
  79. api_hash_configured: true,
  80. api_ready: true,
  81. permission_catalog: permissions.map((key) => ({
  82. key,
  83. name: key,
  84. group: '职责权限',
  85. description: key,
  86. })),
  87. roles: [
  88. {
  89. role_id: 'full_access',
  90. name: '全部职责',
  91. description: '拥有全部机器人职责',
  92. permissions,
  93. builtin: true,
  94. },
  95. ],
  96. bots: [bot],
  97. };
  98. } else if (path.endsWith('/dashboard')) {
  99. data = {
  100. counts: { chats: 3, giveaways: 8, point_accounts: 126, point_transactions: 804, audit_events: 19 },
  101. recent_giveaways: [
  102. {
  103. giveaway_id: 'a1b2c3',
  104. chat_id: chat.chat_id,
  105. title: '七月活跃抽奖',
  106. status: 'running',
  107. prizes: [{ name: '一等奖', count: 1, points_reward: 50 }],
  108. minimum_points: 20,
  109. entry_cost: 5,
  110. participation_reward: 2,
  111. ends_at: '2026-07-25T08:00:00Z',
  112. created_at: '2026-07-24T08:00:00Z',
  113. },
  114. ],
  115. top_accounts: [{ chat_id: chat.chat_id, user_id: '10001', username: 'alice', display_name: '爱丽丝 示例', balance: 180, lifetime_earned: 220, lifetime_spent: 40 }],
  116. 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' }],
  117. };
  118. } else if (path.endsWith('/chats')) {
  119. data = { items: [chat], total: 1, page: 1, page_size: 20 };
  120. } else if (path === `/api/admin/v1/chats/${chat.chat_id}`) {
  121. data = {
  122. ...chat,
  123. description: '用于后台验收的测试群',
  124. permissions: {},
  125. automation: {
  126. chat_id: chat.chat_id,
  127. auto_replies: [],
  128. blacklist_words: ['广告'],
  129. risk_rules: [
  130. {
  131. rule_id: 'ads',
  132. name: '广告拦截',
  133. enabled: true,
  134. keywords: ['广告'],
  135. match_images: true,
  136. match_links: true,
  137. actions: ['delete', 'warn'],
  138. duration_seconds: 3600,
  139. },
  140. ],
  141. identity_monitor: { enabled: true, notify_in_chat: false },
  142. welcome: { enabled: false, text: '', media: null },
  143. captcha_enabled: false,
  144. chatbot_enabled: false,
  145. antiflood_enabled: true,
  146. },
  147. };
  148. } else if (path.endsWith('/admins')) {
  149. data = { items: [] };
  150. } else if (path.endsWith('/members/recent')) {
  151. data = { items: [recentMember] };
  152. } else if (path.endsWith('/members/identity-changes')) {
  153. data = {
  154. items: [{
  155. change_id: 'change-1',
  156. chat_id: chat.chat_id,
  157. user_id: '10001',
  158. before: { username: 'alice', display_name: '小明 同学' },
  159. after: { username: 'alice_new', display_name: '小明 新昵称' },
  160. changed_fields: ['username', 'display_name'],
  161. observed_at: '2026-07-24T08:30:00Z',
  162. }],
  163. total: 1,
  164. page: 1,
  165. page_size: 20,
  166. };
  167. } else if (path.endsWith('/members/search')) {
  168. data = { items: [{ ...recentMember, status: 'member' }] };
  169. } else if (path.endsWith('/giveaways')) {
  170. data = { items: [], total: 0, page: 1, page_size: 20 };
  171. } else if (path.endsWith('/points/accounts') || path.endsWith('/points/transactions')) {
  172. data = { items: [], total: 0, page: 1, page_size: 20 };
  173. } else if (path.endsWith('/audit-logs')) {
  174. data = { items: [], total: 0, page: 1, page_size: 20 };
  175. } else if (path.endsWith('/directory/applications')) {
  176. data = {
  177. items: [{
  178. user_id: '20001',
  179. username: 'teacher_demo',
  180. display_name: '示例技师',
  181. application_status: 'pending',
  182. listed: false,
  183. online: false,
  184. applied_at: '2026-07-24T08:00:00Z',
  185. location: {
  186. source: 'telegram_location',
  187. updated_at: '2026-07-24T08:00:00Z',
  188. },
  189. }],
  190. total: 1,
  191. page: 1,
  192. page_size: 20,
  193. };
  194. } else if (
  195. path.endsWith('/directory/profiles') ||
  196. path.endsWith('/directory/locations') ||
  197. path.endsWith('/directory/events')
  198. ) {
  199. data = { items: [], total: 0, page: 1, page_size: 20 };
  200. } else if (path.endsWith('/directory/settings')) {
  201. data = {
  202. online_duration_hours: 24,
  203. nearby_radius_km: 20,
  204. nearby_radius_options_km: [5, 10, 20, 50],
  205. };
  206. } else if (path.endsWith('/directory/fulfillment-metrics')) {
  207. data = {
  208. counts: {},
  209. valid_requests: 3,
  210. quoted_requests: 2,
  211. confirmed_requests: 1,
  212. request_submission_rate: 1,
  213. quote_rate: 0.6667,
  214. customer_confirmation_rate: 0.5,
  215. match_success_rate: 0.3333,
  216. fulfillment_rate: 1,
  217. cancellation_rate: 0,
  218. cancellation_distribution: { customer: 0, technician: 0 },
  219. scope_note: '仅统计平台服务单;直接 Telegram 私聊成交不在统计范围。',
  220. };
  221. } else if (path.endsWith('/directory/service-settings')) {
  222. data = {
  223. customer_max_open_orders: 3,
  224. customer_daily_request_limit: 10,
  225. review_qr_expiry_hours: 24,
  226. review_forum_chat_id: '-1001234567890',
  227. review_forum_username: 'public_reviews',
  228. };
  229. } else if (path.endsWith('/directory/package-templates')) {
  230. data = { items: [], total: 0 };
  231. } else if (
  232. path.endsWith('/directory/service-orders') ||
  233. path.endsWith('/directory/reviews') ||
  234. path.endsWith('/directory/review-qrs') ||
  235. path.endsWith('/directory/leaderboard') ||
  236. path.endsWith('/directory/customer-blocks') ||
  237. path.endsWith('/directory/reports')
  238. ) {
  239. data = { items: [], total: 0, page: 1, page_size: 20 };
  240. } else if (path.endsWith('/directory/review-template')) {
  241. data = {
  242. template_id: 'review1',
  243. version: 1,
  244. status: 'active',
  245. published_at: '2026-07-26T00:00:00Z',
  246. questions: [
  247. {
  248. question_id: 'effect',
  249. type: 'single_choice',
  250. label: '服务效果',
  251. required: true,
  252. weight: 100,
  253. options: [
  254. { value: 'good', label: '满意', score: 5 },
  255. { value: 'bad', label: '不满意', score: 1 },
  256. ],
  257. },
  258. ],
  259. };
  260. } else if (path.endsWith('/settings')) {
  261. data = {
  262. web_address: 'http://127.0.0.1:8088/admin',
  263. bind_host: '0.0.0.0',
  264. upload_limit_mb: 20,
  265. supervisor_mode: true,
  266. bot: { connected: false, id: '', username: '', name: '' },
  267. };
  268. }
  269. await route.fulfill({ status: 200, contentType: 'application/json', body: envelope(data) });
  270. });
  271. });
  272. test('登录与主要管理视图在不同视口无页面级横向滚动', async ({ page }, testInfo) => {
  273. await page.goto('/admin/login');
  274. await page.getByLabel('密码').fill('qwe0.123456');
  275. await page.getByRole('button', { name: /登\s*录/ }).click();
  276. await expect(page.getByRole('heading', { name: '概览' })).toBeVisible();
  277. const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
  278. expect(overflow).toBeLessThanOrEqual(1);
  279. await page.screenshot({ path: testInfo.outputPath('dashboard.png'), fullPage: true });
  280. await page.goto('/admin/chats');
  281. await expect(page.getByText('产品运营测试群')).toBeVisible();
  282. expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
  283. await page.goto(`/admin/chats/${chat.chat_id}`);
  284. await selectChatWorkspace(page, '自动化与风控');
  285. await expect(page.getByRole('heading', { name: '内容风控' })).toBeVisible();
  286. await expect(page.getByText('广告拦截')).toBeVisible();
  287. await page.getByRole('button', { name: '编辑 广告拦截' }).click();
  288. await expect(page.getByText('编辑风控规则')).toBeVisible();
  289. await expect(page.getByLabel('封禁时长')).toHaveValue('60');
  290. expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
  291. await page.getByRole('button', { name: '关闭' }).click();
  292. await selectChatWorkspace(page, '成员与管理员');
  293. await expect(page.getByText('小明 同学', { exact: true }).first()).toBeVisible();
  294. await expect(page.getByText('小明 新昵称', { exact: true }).first()).toBeVisible();
  295. expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
  296. await page.goto('/admin/giveaways');
  297. await expect(page.getByRole('heading', { name: '抽奖管理' })).toBeVisible();
  298. expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
  299. for (const [path, heading] of [
  300. ['/admin/points', '积分管理'],
  301. ['/admin/directory', '技师与附近'],
  302. ['/admin/services', '技师咨询与评价'],
  303. ['/admin/audit', '操作日志'],
  304. ['/admin/settings', '系统设置'],
  305. ] as const) {
  306. await page.goto(path);
  307. await expect(page.getByRole('heading', { name: heading })).toBeVisible();
  308. if (path === '/admin/settings') {
  309. await expect(page.getByText('全部职责').first()).toBeVisible();
  310. await page.screenshot({ path: testInfo.outputPath('settings.png'), fullPage: true });
  311. }
  312. if (path === '/admin/directory') {
  313. await expect(page.getByText('示例技师')).toBeVisible();
  314. await expect(page.getByText('位置属于敏感信息')).toBeVisible();
  315. await page.screenshot({ path: testInfo.outputPath('directory.png'), fullPage: true });
  316. }
  317. if (path === '/admin/services') {
  318. await expect(page.getByText('咨询平台模式')).toBeVisible();
  319. await page.screenshot({ path: testInfo.outputPath('services.png'), fullPage: true });
  320. }
  321. expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
  322. }
  323. });