Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.
 
 
 
 

376 rindas
16 KiB

  1. import type { Page, Route } from '@playwright/test'
  2. import { captureScreenshot, expect, test } from './fixtures'
  3. const DEFAULT_SYSTEM_NAME = '某类装备维修实训数字车间'
  4. const PNG_BYTES = Buffer.from(
  5. 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
  6. 'base64',
  7. )
  8. type BrandAsset = 'logo' | 'favicon'
  9. interface ConfigurationState {
  10. systemName: string
  11. systemNameConfigured: boolean
  12. systemNameDataVersion: number
  13. logoConfigured: boolean
  14. logoUrl: string | null
  15. logoMediaType: string | null
  16. logoOriginalName: string | null
  17. logoSize: number | null
  18. logoSha256: string | null
  19. logoDataVersion: number
  20. faviconConfigured: boolean
  21. faviconUrl: string | null
  22. faviconMediaType: string | null
  23. faviconOriginalName: string | null
  24. faviconSize: number | null
  25. faviconSha256: string | null
  26. faviconDataVersion: number
  27. }
  28. const initialConfiguration = (): ConfigurationState => ({
  29. systemName: DEFAULT_SYSTEM_NAME,
  30. systemNameConfigured: false,
  31. systemNameDataVersion: 0,
  32. logoConfigured: false,
  33. logoUrl: null,
  34. logoMediaType: null,
  35. logoOriginalName: null,
  36. logoSize: null,
  37. logoSha256: null,
  38. logoDataVersion: 0,
  39. faviconConfigured: false,
  40. faviconUrl: null,
  41. faviconMediaType: null,
  42. faviconOriginalName: null,
  43. faviconSize: null,
  44. faviconSha256: null,
  45. faviconDataVersion: 0,
  46. })
  47. const profile = (canUpdate: boolean) => ({
  48. user: {
  49. id: '1',
  50. username: 'admin',
  51. displayName: '系统管理员',
  52. departmentId: '10',
  53. departmentName: '信息中心',
  54. mustChangePassword: false,
  55. version: 1,
  56. },
  57. activeRoleId: '100',
  58. activeRole: { id: '100', code: 'admin', name: '管理员' },
  59. roles: [{
  60. id: '100', code: 'admin', name: '管理员', shortName: '管', status: 1,
  61. builtIn: 1, isSuperAdmin: 1, dataScopeCode: 'ALL',
  62. }],
  63. permissions: ['system.config', ...(canUpdate ? ['system.config.update'] : [])],
  64. authorizationMode: 'SINGLE_ACTIVE',
  65. loginTime: 1786387200,
  66. })
  67. const loginContext = {
  68. defaultRoleCode: 'admin',
  69. roles: [{ code: 'admin', label: '管理员', accountLabel: '登录账号', organizationMode: 'NONE', organizationLabels: [] }],
  70. departments: [],
  71. }
  72. const envelope = (data: unknown, message = '成功', code: number | string = 200) => JSON.stringify({
  73. code,
  74. message,
  75. data,
  76. timestamp: '2026-08-11T12:00:00+08:00',
  77. requestId: 'ute2e-system-config',
  78. })
  79. const fulfillJson = (route: Route, data: unknown, status = 200, message = '成功', code: number | string = status) => route.fulfill({
  80. status,
  81. contentType: 'application/json',
  82. body: envelope(data, message, code),
  83. })
  84. class SystemConfigurationMock {
  85. readonly state = initialConfiguration()
  86. readonly mutations: string[] = []
  87. readonly canUpdate: boolean
  88. conflictNextNameSave = false
  89. managementReadCount = 0
  90. constructor(canUpdate = true) {
  91. this.canUpdate = canUpdate
  92. }
  93. async install(page: Page, authenticated = true) {
  94. if (authenticated) {
  95. await page.addInitScript(() => {
  96. sessionStorage.setItem('unreal-tran:web:access-token:v1', 'ute2e-system-config-token')
  97. })
  98. }
  99. await page.route('**/api/auth/v1/**', (route) => this.handle(route))
  100. }
  101. private publicState() {
  102. return {
  103. systemName: this.state.systemName,
  104. logoConfigured: this.state.logoConfigured,
  105. logoUrl: this.state.logoUrl,
  106. logoMediaType: this.state.logoMediaType,
  107. faviconConfigured: this.state.faviconConfigured,
  108. faviconUrl: this.state.faviconUrl,
  109. faviconMediaType: this.state.faviconMediaType,
  110. }
  111. }
  112. private configureAsset(asset: BrandAsset) {
  113. const nextVersion = this.state[`${asset}DataVersion`] + 1
  114. this.state[`${asset}Configured`] = true
  115. this.state[`${asset}Url`] = `/api/auth/v1/system-config/assets/${asset}?v=${nextVersion}`
  116. this.state[`${asset}MediaType`] = 'image/png'
  117. this.state[`${asset}OriginalName`] = asset === 'logo' ? 'brand-logo.png' : 'site-icon.png'
  118. this.state[`${asset}Size`] = PNG_BYTES.length
  119. this.state[`${asset}Sha256`] = `mock-${asset}-sha256`
  120. this.state[`${asset}DataVersion`] = nextVersion
  121. }
  122. private removeAsset(asset: BrandAsset) {
  123. this.state[`${asset}Configured`] = false
  124. this.state[`${asset}Url`] = null
  125. this.state[`${asset}MediaType`] = null
  126. this.state[`${asset}OriginalName`] = null
  127. this.state[`${asset}Size`] = null
  128. this.state[`${asset}Sha256`] = null
  129. this.state[`${asset}DataVersion`] += 1
  130. }
  131. private async handle(route: Route) {
  132. const request = route.request()
  133. const url = new URL(request.url())
  134. const path = url.pathname
  135. const method = request.method()
  136. if (method === 'GET' && path === '/api/auth/v1/system-config/public') {
  137. return fulfillJson(route, this.publicState())
  138. }
  139. if (method === 'GET' && path === '/api/auth/v1/auth/login-context') {
  140. return fulfillJson(route, loginContext)
  141. }
  142. if (method === 'GET' && path === '/api/auth/v1/auth/me') {
  143. return fulfillJson(route, profile(this.canUpdate))
  144. }
  145. if (method === 'GET' && path === '/api/auth/v1/menus/navigation') {
  146. return fulfillJson(route, [])
  147. }
  148. if (method === 'GET' && path === '/api/auth/v1/system-config') {
  149. this.managementReadCount += 1
  150. return fulfillJson(route, this.state)
  151. }
  152. if (method === 'PUT' && path === '/api/auth/v1/system-config/name') {
  153. this.mutations.push('name')
  154. if (this.conflictNextNameSave) {
  155. this.conflictNextNameSave = false
  156. this.state.systemName = '其他管理员刚保存的名称'
  157. this.state.systemNameConfigured = true
  158. this.state.systemNameDataVersion += 1
  159. return fulfillJson(route, null, 409, '系统名称版本冲突', 'CONFIG_VERSION_CONFLICT')
  160. }
  161. const body = request.postDataJSON() as { systemName?: string }
  162. const nextName = String(body.systemName ?? '').trim()
  163. this.state.systemName = nextName || DEFAULT_SYSTEM_NAME
  164. this.state.systemNameConfigured = Boolean(nextName)
  165. this.state.systemNameDataVersion += 1
  166. return fulfillJson(route, this.state)
  167. }
  168. if (method === 'POST' && /^\/api\/auth\/v1\/system-config\/(logo|favicon)$/.test(path)) {
  169. const asset = path.endsWith('/logo') ? 'logo' : 'favicon'
  170. this.mutations.push(`upload-${asset}`)
  171. this.configureAsset(asset)
  172. return fulfillJson(route, this.state)
  173. }
  174. if (method === 'DELETE' && /^\/api\/auth\/v1\/system-config\/(logo|favicon)$/.test(path)) {
  175. const asset = path.endsWith('/logo') ? 'logo' : 'favicon'
  176. this.mutations.push(`remove-${asset}`)
  177. this.removeAsset(asset)
  178. return fulfillJson(route, this.state)
  179. }
  180. if (method === 'GET' && /^\/api\/auth\/v1\/system-config\/assets\/(logo|favicon)$/.test(path)) {
  181. return route.fulfill({
  182. status: 200,
  183. contentType: 'image/png',
  184. headers: { 'Cache-Control': 'public, max-age=31536000, immutable' },
  185. body: PNG_BYTES,
  186. })
  187. }
  188. return fulfillJson(route, {})
  189. }
  190. }
  191. const configuredBrandImage = (page: Page) => page.locator('.platform-brand .brand-mark.configured img')
  192. const faviconLink = (page: Page) => page.locator('link[rel~="icon"]')
  193. test.describe('系统配置(状态化 Mock,不访问真实 API/数据库)', () => {
  194. test('公开配置为空时登录页使用当前内置品牌', async ({ page }) => {
  195. const mock = new SystemConfigurationMock()
  196. await mock.install(page, false)
  197. await page.goto('/login')
  198. await expect(page.locator('.login-brand')).toContainText(DEFAULT_SYSTEM_NAME)
  199. await expect(page).toHaveTitle(`登录 - ${DEFAULT_SYSTEM_NAME}`)
  200. await expect(faviconLink(page)).toHaveAttribute('href', '/favicon.svg')
  201. await expect(faviconLink(page)).toHaveAttribute('type', 'image/svg+xml')
  202. expect(mock.mutations).toEqual([])
  203. })
  204. test('已配置的平台 LOGO 保持透明且不显示边框', async ({ page }) => {
  205. const mock = new SystemConfigurationMock()
  206. Object.assign(mock.state, {
  207. logoConfigured: true,
  208. logoUrl: '/api/auth/v1/system-config/assets/logo?v=1',
  209. logoMediaType: 'image/png',
  210. logoOriginalName: 'brand-logo.png',
  211. logoSize: PNG_BYTES.length,
  212. logoSha256: 'mock-logo-sha256',
  213. logoDataVersion: 1,
  214. })
  215. await mock.install(page)
  216. await page.goto('/system/config')
  217. await expect(page.getByTestId('system-config-page')).toHaveAttribute('data-ready', 'true')
  218. const configuredBrandMark = configuredBrandImage(page).locator('..')
  219. await expect(configuredBrandMark).toHaveCSS('border-top-width', '0px')
  220. await expect(configuredBrandMark).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)')
  221. await expect(configuredBrandMark).toHaveCSS('box-shadow', 'none')
  222. })
  223. test('管理员可从菜单进入并保存、清空名称以及上传、移除 PNG 品牌资源', async ({ page }, testInfo) => {
  224. const mock = new SystemConfigurationMock()
  225. await mock.install(page)
  226. await page.setViewportSize({ width: 1920, height: 1080 })
  227. await page.goto('/system/config')
  228. await expect(page.getByTestId('system-config-page')).toHaveAttribute('data-ready', 'true')
  229. await expect(page).toHaveURL(/\/system\/config(?:\?|$)/)
  230. await expect(page.locator('.current-group-navigation')).toContainText('系统配置')
  231. await expect(page.getByRole('link', { name: '系统配置' })).toHaveClass(/active/)
  232. await expect(page.getByTestId('system-config-form')).toBeVisible()
  233. await expect(page.locator('.config-navigation')).toHaveCount(0)
  234. await expect(page.locator('.system-config-form-card')).toHaveCount(1)
  235. await expect(page.locator('.system-config-form-row')).toHaveCount(3)
  236. const pageBox = await page.getByTestId('system-config-page').boundingBox()
  237. const formBox = await page.getByTestId('system-config-form').boundingBox()
  238. expect(pageBox).not.toBeNull()
  239. expect(formBox).not.toBeNull()
  240. expect(formBox!.width / pageBox!.width).toBeGreaterThan(0.95)
  241. const nameInput = page.getByTestId('system-config-name')
  242. await nameInput.fill('装备维修教学测试平台')
  243. await page.getByTestId('system-config-save-name').click()
  244. await expect(page.locator('.platform-brand')).toContainText('装备维修教学测试平台')
  245. await expect(page).toHaveTitle('系统配置 - 装备维修教学测试平台')
  246. await page.getByTestId('system-config-clear-name').click()
  247. const restoreNameDialog = page.locator('.el-message-box:visible')
  248. await restoreNameDialog.getByRole('button', { name: '恢复默认' }).click()
  249. await expect(page.locator('.platform-brand')).toContainText(DEFAULT_SYSTEM_NAME)
  250. await expect(page).toHaveTitle(`系统配置 - ${DEFAULT_SYSTEM_NAME}`)
  251. const logoCard = page.getByTestId('system-config-logo-card')
  252. await logoCard.locator('input[type="file"]').setInputFiles({
  253. name: 'brand-logo.png',
  254. mimeType: 'image/png',
  255. buffer: PNG_BYTES,
  256. })
  257. const logoDialog = page.getByTestId('system-config-upload-dialog')
  258. await expect(logoDialog).toBeVisible()
  259. await logoDialog.getByRole('button', { name: '确认上传' }).click()
  260. await expect(logoCard).toContainText('brand-logo.png')
  261. await expect(configuredBrandImage(page)).toHaveAttribute('src', /\/api\/auth\/v1\/system-config\/assets\/logo\?v=1$/)
  262. await page.getByTestId('system-config-remove-logo').click()
  263. await page.locator('.el-message-box:visible').getByRole('button', { name: '确认移除' }).click()
  264. await expect(configuredBrandImage(page)).toHaveCount(0)
  265. await expect(logoCard).toContainText('使用系统内置 LOGO')
  266. const faviconCard = page.getByTestId('system-config-favicon-card')
  267. await faviconCard.locator('input[type="file"]').setInputFiles({
  268. name: 'site-icon.png',
  269. mimeType: 'image/png',
  270. buffer: PNG_BYTES,
  271. })
  272. const faviconDialog = page.getByTestId('system-config-upload-dialog')
  273. await faviconDialog.getByRole('button', { name: '确认上传' }).click()
  274. await expect(faviconCard).toContainText('site-icon.png')
  275. await expect(faviconLink(page)).toHaveAttribute('type', 'image/png')
  276. await expect(faviconLink(page)).toHaveAttribute('href', /\/api\/auth\/v1\/system-config\/assets\/favicon\?v=1$/)
  277. await page.getByTestId('system-config-remove-favicon').click()
  278. await page.locator('.el-message-box:visible').getByRole('button', { name: '确认移除' }).click()
  279. await expect(faviconLink(page)).toHaveAttribute('type', 'image/svg+xml')
  280. await expect(faviconLink(page)).toHaveAttribute('href', '/favicon.svg')
  281. await expect(faviconCard).toContainText('使用系统内置网站图标')
  282. expect(mock.mutations).toEqual([
  283. 'name', 'name', 'upload-logo', 'remove-logo', 'upload-favicon', 'remove-favicon',
  284. ])
  285. await expect(page.locator('.el-message')).toHaveCount(0, { timeout: 10_000 })
  286. await captureScreenshot(page, testInfo, 'system-config-defaults-restored')
  287. })
  288. test('保存遇到 409 时刷新服务端配置并提示重新确认', async ({ page }) => {
  289. const mock = new SystemConfigurationMock()
  290. mock.conflictNextNameSave = true
  291. await mock.install(page)
  292. await page.goto('/system/config')
  293. await expect(page.getByTestId('system-config-page')).toHaveAttribute('data-ready', 'true')
  294. const readsBeforeSave = mock.managementReadCount
  295. await page.getByTestId('system-config-name').fill('本次输入会冲突')
  296. await page.getByTestId('system-config-save-name').click()
  297. await expect(page.getByText('配置已被其他管理员修改,页面已刷新,请确认后重新操作', { exact: true })).toBeVisible()
  298. await expect(page.getByTestId('system-config-name')).toHaveValue('其他管理员刚保存的名称')
  299. await expect(page.locator('.platform-brand')).toContainText('其他管理员刚保存的名称')
  300. await expect(page).toHaveTitle('系统配置 - 其他管理员刚保存的名称')
  301. expect(mock.managementReadCount).toBeGreaterThan(readsBeforeSave)
  302. })
  303. test('只有查看权限时页面为只读且不暴露更新操作', async ({ page }, testInfo) => {
  304. const mock = new SystemConfigurationMock(false)
  305. await mock.install(page)
  306. await page.goto('/system/config')
  307. await expect(page.getByTestId('system-config-page')).toHaveAttribute('data-ready', 'true')
  308. await expect(page.getByRole('link', { name: '系统配置' })).toBeVisible()
  309. await expect(page.getByTestId('system-config-name')).toBeDisabled()
  310. await expect(page.getByTestId('system-config-save-name')).toHaveCount(0)
  311. await expect(page.getByTestId('system-config-clear-name')).toHaveCount(0)
  312. await expect(page.getByTestId('system-config-upload-logo')).toHaveCount(0)
  313. await expect(page.getByTestId('system-config-upload-favicon')).toHaveCount(0)
  314. expect(mock.mutations).toEqual([])
  315. await captureScreenshot(page, testInfo, 'system-config-read-only')
  316. })
  317. test('简化表单在移动端暗色模式下无横向溢出', async ({ page }, testInfo) => {
  318. const mock = new SystemConfigurationMock()
  319. await mock.install(page)
  320. await page.addInitScript(() => {
  321. localStorage.setItem('unreal-tran:web:preferences:v1', JSON.stringify({
  322. navigationMode: 'top-side',
  323. themeMode: 'dark',
  324. sidebarCollapsed: false,
  325. }))
  326. })
  327. await page.setViewportSize({ width: 375, height: 812 })
  328. await page.goto('/system/config')
  329. await expect(page.getByTestId('system-config-page')).toHaveAttribute('data-ready', 'true')
  330. await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
  331. await expect(page.getByTestId('system-config-form')).toBeVisible()
  332. await expect(page.locator('.config-navigation')).toHaveCount(0)
  333. expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
  334. await captureScreenshot(page, testInfo, 'system-config-mobile-dark')
  335. })
  336. })