| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553 |
- import { expect, test, type Page } from '@playwright/test';
- const chat = {
- chat_id: '-1001234567890',
- title: '产品运营测试群',
- username: 'product_ops_test',
- type: 'supergroup',
- accessible: true,
- member_count: 286,
- bot_status: 'administrator',
- bot_privileges: ['can_change_info', 'can_delete_messages', 'can_restrict_members'],
- };
- const recentMember = {
- user: {
- id: '10001',
- username: 'alice',
- first_name: '小明',
- last_name: '同学',
- is_bot: false,
- is_deleted: false,
- },
- status: 'recent',
- privileges: [],
- last_seen_at: '2026-07-24T08:00:00Z',
- };
- const permissions = [
- 'chat.profile',
- 'chat.members',
- 'chat.permissions',
- 'chat.announcements',
- 'chat.invites',
- 'chat.rules',
- 'chat.notes',
- 'automation.manage',
- 'points.manage',
- 'giveaways.manage',
- 'karma.manage',
- 'teacher_directory.manage',
- 'business_assistant.manage',
- 'media.upload',
- ];
- const bot = {
- bot_id: 'primary',
- label: 'Test Bot',
- enabled: true,
- bot_token_configured: true,
- sudo_users_id: ['10001'],
- log_group_id: '0',
- gban_log_group_id: '0',
- message_dump_chat: '-1001234567890',
- role_ids: ['full_access'],
- permissions,
- identity: { id: '999', username: 'test_bot', name: 'Test Bot', can_connect_to_business: true },
- business_assistant: {
- base_url: 'https://models.example.com/v1',
- api_key_configured: true,
- model: 'support-model',
- timeout_seconds: 30,
- max_output_tokens: 600,
- configured: true,
- },
- ready_to_connect: true,
- runtime: { state: 'running' },
- };
- function envelope(data: unknown) {
- return JSON.stringify({ data });
- }
- function luminance(color: string) {
- const channels = color.match(/[\d.]+/g)?.slice(0, 3).map(Number) || [];
- if (channels.length !== 3) throw new Error(`无法解析颜色:${color}`);
- const linear = channels.map((value) => {
- const normalized = value / 255;
- return normalized <= 0.04045
- ? normalized / 12.92
- : ((normalized + 0.055) / 1.055) ** 2.4;
- });
- return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
- }
- function contrastRatio(foreground: string, background: string) {
- const lighter = Math.max(luminance(foreground), luminance(background));
- const darker = Math.min(luminance(foreground), luminance(background));
- return (lighter + 0.05) / (darker + 0.05);
- }
- async function expectReadablePair(
- page: Page,
- foregroundSelector: string,
- backgroundSelector: string,
- minimum = 4.5,
- ) {
- const colors = await page.locator(foregroundSelector).first().evaluate(
- (foreground, selector) => {
- const background = document.querySelector(selector);
- if (!background) throw new Error(`找不到背景元素:${selector}`);
- return {
- foreground: getComputedStyle(foreground).color,
- background: getComputedStyle(background).backgroundColor,
- };
- },
- backgroundSelector,
- );
- expect(contrastRatio(colors.foreground, colors.background)).toBeGreaterThanOrEqual(minimum);
- }
- async function selectChatWorkspace(page: Page, name: string) {
- if ((page.viewportSize()?.width || 0) <= 575) {
- await page.evaluate(() => window.scrollTo(0, 0));
- await page.locator('.workspace-tab-select').click();
- await page.getByText(name, { exact: true }).last().click();
- return;
- }
- await page.getByRole('tab', { name }).click();
- }
- test.beforeEach(async ({ page }) => {
- await page.route('**/api/admin/v1/**', async (route) => {
- const url = new URL(route.request().url());
- const path = url.pathname;
- let data: unknown = {};
- if (path.endsWith('/auth/login') || path.endsWith('/auth/me')) {
- data = { username: 'admin', must_change_password: false, csrf_token: 'csrf-test' };
- } else if (path.endsWith('/bots')) {
- data = { items: [bot] };
- } else if (path.endsWith('/settings/telegram')) {
- data = {
- api_id: 12345,
- api_hash_configured: true,
- api_ready: true,
- permission_catalog: permissions.map((key) => ({
- key,
- name: key,
- group: '职责权限',
- description: key,
- })),
- roles: [
- {
- role_id: 'full_access',
- name: '全部职责',
- description: '拥有全部机器人职责',
- permissions,
- builtin: true,
- },
- ],
- bots: [bot],
- };
- } else if (path.endsWith('/dashboard')) {
- data = {
- counts: { chats: 3, giveaways: 8, point_accounts: 126, point_transactions: 804, audit_events: 19 },
- recent_giveaways: [
- {
- giveaway_id: 'a1b2c3',
- chat_id: chat.chat_id,
- title: '七月活跃抽奖',
- status: 'running',
- prizes: [{ name: '一等奖', count: 1, points_reward: 50 }],
- minimum_points: 20,
- entry_cost: 5,
- participation_reward: 2,
- ends_at: '2026-07-25T08:00:00Z',
- created_at: '2026-07-24T08:00:00Z',
- },
- ],
- top_accounts: [{ chat_id: chat.chat_id, user_id: '10001', username: 'alice', display_name: '爱丽丝 示例', balance: 180, lifetime_earned: 220, lifetime_spent: 40 }],
- 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' }],
- };
- } else if (path.endsWith('/chats')) {
- data = { items: [chat], total: 1, page: 1, page_size: 20 };
- } else if (path === `/api/admin/v1/chats/${chat.chat_id}`) {
- data = {
- ...chat,
- description: '用于后台验收的测试群',
- permissions: {},
- automation: {
- chat_id: chat.chat_id,
- auto_replies: [],
- blacklist_words: ['广告'],
- risk_rules: [
- {
- rule_id: 'ads',
- name: '广告拦截',
- enabled: true,
- keywords: ['广告'],
- match_images: true,
- match_links: true,
- actions: ['delete', 'warn'],
- duration_seconds: 3600,
- },
- ],
- identity_monitor: { enabled: true, notify_in_chat: false },
- welcome: { enabled: false, text: '', media: null },
- captcha_enabled: false,
- chatbot_enabled: false,
- antiflood_enabled: true,
- },
- };
- } else if (path.endsWith('/admins')) {
- data = { items: [] };
- } else if (path.endsWith('/members/recent')) {
- data = { items: [recentMember] };
- } else if (path.endsWith('/members/identity-changes')) {
- data = {
- items: [{
- change_id: 'change-1',
- chat_id: chat.chat_id,
- user_id: '10001',
- before: { username: 'alice', display_name: '小明 同学' },
- after: { username: 'alice_new', display_name: '小明 新昵称' },
- changed_fields: ['username', 'display_name'],
- observed_at: '2026-07-24T08:30:00Z',
- }],
- total: 1,
- page: 1,
- page_size: 20,
- };
- } else if (path.endsWith('/members/search')) {
- data = { items: [{ ...recentMember, status: 'member' }] };
- } else if (path.endsWith('/giveaways')) {
- data = { items: [], total: 0, page: 1, page_size: 20 };
- } else if (path.endsWith('/points/accounts') || path.endsWith('/points/transactions')) {
- data = { items: [], total: 0, page: 1, page_size: 20 };
- } else if (path.endsWith('/audit-logs')) {
- data = { items: [], total: 0, page: 1, page_size: 20 };
- } else if (path.endsWith('/directory/applications')) {
- data = {
- items: [{
- user_id: '20001',
- username: 'teacher_demo',
- display_name: '示例技师',
- application_status: 'pending',
- listed: false,
- online: false,
- applied_at: '2026-07-24T08:00:00Z',
- location: {
- source: 'telegram_location',
- updated_at: '2026-07-24T08:00:00Z',
- },
- }],
- total: 1,
- page: 1,
- page_size: 20,
- };
- } else if (
- path.endsWith('/directory/profiles') ||
- path.endsWith('/directory/locations') ||
- path.endsWith('/directory/events')
- ) {
- data = { items: [], total: 0, page: 1, page_size: 20 };
- } else if (path.endsWith('/directory/settings')) {
- data = {
- online_duration_hours: 24,
- nearby_radius_km: 20,
- nearby_radius_options_km: [5, 10, 20, 50],
- };
- } else if (path.endsWith('/directory/fulfillment-metrics')) {
- data = {
- counts: {},
- valid_requests: 3,
- quoted_requests: 2,
- confirmed_requests: 1,
- request_submission_rate: 1,
- quote_rate: 0.6667,
- customer_confirmation_rate: 0.5,
- match_success_rate: 0.3333,
- fulfillment_rate: 1,
- cancellation_rate: 0,
- cancellation_distribution: { customer: 0, technician: 0 },
- scope_note: '仅统计平台服务单;直接 Telegram 私聊成交不在统计范围。',
- };
- } else if (path.endsWith('/directory/service-settings')) {
- data = {
- customer_max_open_orders: 3,
- customer_daily_request_limit: 10,
- review_qr_expiry_hours: 24,
- review_forum_chat_id: '-1001234567890',
- review_forum_username: 'public_reviews',
- };
- } else if (path.endsWith('/directory/package-templates')) {
- data = { items: [], total: 0 };
- } else if (
- path.endsWith('/directory/service-orders') ||
- path.endsWith('/directory/reviews') ||
- path.endsWith('/directory/review-qrs') ||
- path.endsWith('/directory/leaderboard') ||
- path.endsWith('/directory/customer-blocks') ||
- path.endsWith('/directory/reports')
- ) {
- data = { items: [], total: 0, page: 1, page_size: 20 };
- } else if (path.endsWith('/directory/review-template')) {
- data = {
- template_id: 'review1',
- version: 1,
- status: 'active',
- published_at: '2026-07-26T00:00:00Z',
- questions: [
- {
- question_id: 'effect',
- type: 'single_choice',
- label: '服务效果',
- required: true,
- weight: 100,
- options: [
- { value: 'good', label: '满意', score: 5 },
- { value: 'bad', label: '不满意', score: 1 },
- ],
- },
- ],
- };
- } else if (path.endsWith('/business-assistant/status')) {
- data = {
- polling_state: 'running',
- business_mode_supported: true,
- webhook_conflict: false,
- webhook_url: '',
- model_configured: true,
- usage: { ai_calls: 8, customers: 3, conversations: 3, handoffs: 1, human_paused: 1 },
- };
- } else if (path.endsWith('/business-assistant/connections')) {
- data = {
- items: [{
- connection_id: 'conn-a',
- user: { id: '900', username: 'owner', first_name: '店主' },
- user_chat_id: '900',
- rights: { can_reply: true, can_read_messages: true },
- is_enabled: true,
- last_event_at: '2026-08-14T08:00:00Z',
- }],
- total: 1,
- page: 1,
- page_size: 100,
- };
- } else if (path.endsWith('/business-assistant/settings')) {
- data = {
- connection_id: 'conn-a',
- assistant_enabled: true,
- system_prompt: '只依据知识库回答。',
- language: 'zh-CN',
- tone: 'professional',
- account_daily_limit: 200,
- customer_daily_limit: 20,
- human_pause_hours: 24,
- notification_destination: 'both',
- ops_group_id: '-100123',
- timezone: 'Asia/Shanghai',
- digest_enabled: false,
- digest_time: '09:00',
- handoff_message: '请稍候,已转人工。',
- unsupported_message: '该消息需要人工处理。',
- };
- } else if (path.endsWith('/business-assistant/knowledge-sources')) {
- data = {
- items: [{
- source_id: 'source-1',
- connection_id: 'conn-a',
- source_type: 'channel',
- chat_id: '-100100',
- title: '官方公告频道',
- publication_mode: 'auto',
- author_policy: 'admins_or_allowlist',
- allowed_user_ids: [],
- include_linked_chat: true,
- linked_chat_id: '-100101',
- linked_chat_title: '官方讨论群',
- backfill_days: 30,
- backfill_limit: 100,
- enabled: true,
- sync_status: 'completed',
- last_sync_processed: 12,
- }],
- total: 1,
- page: 1,
- page_size: 20,
- };
- } else if (path.endsWith('/business-assistant/knowledge-candidates')) {
- data = {
- items: [{
- candidate_id: 'candidate-1',
- connection_id: 'conn-a',
- source_id: 'source-1',
- event_id: 'event-1',
- question: '配送范围?',
- aliases: [],
- keywords: ['配送'],
- answer: '仅支持市区配送。',
- tags: ['配送'],
- confidence: 0.92,
- status: 'pending',
- source_snapshot: {
- title: '官方公告频道',
- source_type: 'channel',
- chat_id: '-100100',
- message_id: '8',
- author_id: '900',
- },
- }],
- total: 1,
- page: 1,
- page_size: 20,
- };
- } else if (path.endsWith('/business-assistant/knowledge')) {
- data = { items: [], total: 0, page: 1, page_size: 20 };
- } else if (path.endsWith('/business-assistant/conversations')) {
- data = { items: [], total: 0, page: 1, page_size: 20 };
- } else if (path.endsWith('/business-assistant/usage')) {
- data = { ai_calls: 8, customers: 3, conversations: 3, handoffs: 1, human_paused: 1 };
- } else if (path.endsWith('/settings')) {
- data = {
- web_address: 'http://127.0.0.1:8088/admin',
- bind_host: '0.0.0.0',
- upload_limit_mb: 20,
- supervisor_mode: true,
- bot: { connected: false, id: '', username: '', name: '' },
- };
- }
- await route.fulfill({ status: 200, contentType: 'application/json', body: envelope(data) });
- });
- });
- test('登录与主要管理视图在不同视口无页面级横向滚动', async ({ page }, testInfo) => {
- await page.goto('/admin/login');
- await page.getByLabel('密码').fill('qwe0.123456');
- await page.getByRole('button', { name: /登\s*录/ }).click();
- await expect(page.getByRole('heading', { name: '概览' })).toBeVisible();
- if ((page.viewportSize()?.width || 0) >= 992) {
- const giveawayNavigationColor = await page
- .locator('.ant-menu-item')
- .filter({ hasText: '抽奖管理' })
- .evaluate((element) => getComputedStyle(element).color);
- expect(giveawayNavigationColor).toBe('rgb(182, 197, 213)');
- }
- const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
- expect(overflow).toBeLessThanOrEqual(1);
- await page.screenshot({ path: testInfo.outputPath('dashboard.png'), fullPage: true });
- await page.goto('/admin/chats');
- await expect(page.getByText('产品运营测试群')).toBeVisible();
- expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
- await page.goto(`/admin/chats/${chat.chat_id}`);
- await selectChatWorkspace(page, '自动化与风控');
- await expect(page.getByRole('heading', { name: '内容风控' })).toBeVisible();
- await expect(page.getByText('广告拦截')).toBeVisible();
- await page.getByRole('button', { name: '编辑 广告拦截' }).click();
- await expect(page.getByText('编辑风控规则')).toBeVisible();
- await expect(page.getByLabel('封禁时长')).toHaveValue('60');
- expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
- await page.getByRole('button', { name: '关闭' }).click();
- await selectChatWorkspace(page, '成员与管理员');
- await expect(page.getByText('小明 同学', { exact: true }).first()).toBeVisible();
- await expect(page.getByText('小明 新昵称', { exact: true }).first()).toBeVisible();
- expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
- await page.goto('/admin/giveaways');
- await expect(page.getByRole('heading', { name: '抽奖管理' })).toBeVisible();
- expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
- for (const [path, heading] of [
- ['/admin/points', '积分管理'],
- ['/admin/directory', '技师与附近'],
- ['/admin/services', '技师咨询与评价'],
- ['/admin/business-assistant', '智能接待'],
- ['/admin/audit', '操作日志'],
- ['/admin/settings', '系统设置'],
- ] as const) {
- await page.goto(path);
- await expect(page.getByRole('heading', { name: heading })).toBeVisible();
- if (path === '/admin/settings') {
- await expect(page.getByText('全部职责').first()).toBeVisible();
- await page.screenshot({ path: testInfo.outputPath('settings.png'), fullPage: true });
- }
- if (path === '/admin/directory') {
- await expect(page.getByText('示例技师')).toBeVisible();
- await expect(page.getByText('位置属于敏感信息')).toBeVisible();
- await page.screenshot({ path: testInfo.outputPath('directory.png'), fullPage: true });
- }
- if (path === '/admin/services') {
- await expect(page.getByText('咨询平台模式')).toBeVisible();
- await page.screenshot({ path: testInfo.outputPath('services.png'), fullPage: true });
- }
- if (path === '/admin/business-assistant') {
- if ((page.viewportSize()?.width || 0) >= 992) {
- const selectedNavigationColor = await page
- .locator('.ant-menu-item-selected')
- .evaluate((element) => getComputedStyle(element).color);
- expect(selectedNavigationColor).toBe('rgb(255, 255, 255)');
- }
- await expect(page.getByText('允许回复')).toBeVisible();
- await expect(page.getByText('允许标记已读')).toBeVisible();
- const reminderAlert = page
- .locator('.ant-alert-info')
- .filter({ hasText: '第一期按每条用户消息提醒' });
- const reminderColors = await reminderAlert.evaluate((element) => ({
- background: getComputedStyle(element).backgroundColor,
- message: getComputedStyle(element.querySelector('.ant-alert-message') as Element).color,
- description: getComputedStyle(element.querySelector('.ant-alert-description') as Element).color,
- }));
- expect(reminderColors).toEqual({
- background: 'rgb(237, 249, 254)',
- message: 'rgb(7, 94, 135)',
- description: 'rgb(49, 89, 109)',
- });
- await page.getByRole('tab', { name: '知识来源' }).click();
- await expect(page.getByText('官方公告频道')).toBeVisible();
- await expect(page.getByText('讨论群:官方讨论群')).toBeVisible();
- await page.getByRole('tab', { name: '知识候选' }).click();
- await expect(page.getByText('配送范围?')).toBeVisible();
- await expect(page.getByRole('button', { name: '批准' })).toBeVisible();
- await page.screenshot({ path: testInfo.outputPath('business-assistant.png'), fullPage: true });
- }
- expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
- }
- });
- test('深色导航不污染下拉、状态标签与业务弹层的浅色可读性', async ({ page }) => {
- await page.goto('/admin/chats');
- await expect(page.getByRole('heading', { name: '群组管理' })).toBeVisible();
- await page.locator('.bot-selector .ant-select-selector').click();
- await expect(page.locator('.ant-select-dropdown')).toBeVisible();
- await expect(page.locator('.bot-selector .ant-select-selection-item')).toHaveCSS('color', 'rgb(31, 52, 71)');
- await expect(page.locator('.bot-selector .ant-select-arrow')).toHaveCSS('color', 'rgb(64, 84, 105)');
- await expect(page.locator('.ant-select-dropdown')).toHaveCSS('background-color', 'rgb(255, 255, 255)');
- await expectReadablePair(
- page,
- '.ant-select-dropdown .ant-select-item-option-content',
- '.ant-select-dropdown',
- );
- await page.keyboard.press('Escape');
- await expect(page.locator('.ant-table-tbody .ant-tag')).toHaveCSS('background-color', 'rgb(239, 250, 245)');
- await expectReadablePair(page, '.ant-table-tbody .ant-tag', '.ant-table-tbody .ant-tag');
- await page.goto('/admin/giveaways');
- await page.getByRole('button', { name: '创建抽奖' }).click();
- await expect(page.locator('.ant-modal-content')).toHaveCSS('background-color', 'rgb(255, 255, 255)');
- await expectReadablePair(page, '.ant-modal-title', '.ant-modal-content');
- await expectReadablePair(page, '.ant-modal .ant-form-item-label label', '.ant-modal-content');
- await page.locator('.ant-modal-close').click();
- await page.goto('/admin/points');
- await page.getByRole('button', { name: '手工调整' }).click();
- await expect(page.locator('.ant-modal-content')).toHaveCSS('background-color', 'rgb(255, 255, 255)');
- await expectReadablePair(page, '.ant-modal-title', '.ant-modal-content');
- await expectReadablePair(page, '.ant-modal .ant-form-item-label label', '.ant-modal-content');
- });
|