diff --git a/app/assets/styles/ProductDescription.Ctb7jDuO.css b/app/assets/styles/ProductDescription.Ctb7jDuO.css index c720ec9..98b3ffe 100644 --- a/app/assets/styles/ProductDescription.Ctb7jDuO.css +++ b/app/assets/styles/ProductDescription.Ctb7jDuO.css @@ -10,18 +10,19 @@ line-height: 1.8; } .description-panel[data-v-8a6cfb44] { - background: #fff; - border-top: 1px solid #e2e9f3; - border-bottom: 1px solid #dfe6f0; + /* 面板与文字使用同一主题协议,紧凑预览和完整说明都不能固定为浅色配色。 */ + background: var(--p-content-background); + border-top: 1px solid var(--p-content-border-color); + border-bottom: 1px solid var(--p-content-border-color); padding: 0; } .description-header[data-v-8a6cfb44] { - background: #f5f8fe; - border-bottom: 1px solid #dfe6f0; + background: var(--p-content-hover-background); + border-bottom: 1px solid var(--p-content-border-color); padding: 8px clamp(20px, 8.6%, 110px); } .description-header h2[data-v-8a6cfb44] { - color: #657286; + color: var(--p-text-muted-color); margin: 0; font-size: 21px; font-weight: 500; @@ -42,7 +43,7 @@ display: grid; } .description-field dt[data-v-8a6cfb44] { - color: #708096; + color: var(--p-text-muted-color); align-items: center; gap: 16px; min-width: 0; @@ -56,7 +57,7 @@ font-size: 18px; } .description-field dd[data-v-8a6cfb44] { - color: #394658; + color: var(--p-text-color); white-space: pre-line; min-width: 0; margin: 0; diff --git a/app/assets/styles/WithdrawDialog.ks0wdKEK.css b/app/assets/styles/WithdrawDialog.ks0wdKEK.css index fad3aa4..6e6fbab 100644 --- a/app/assets/styles/WithdrawDialog.ks0wdKEK.css +++ b/app/assets/styles/WithdrawDialog.ks0wdKEK.css @@ -89,13 +89,13 @@ .calc-row.total > b[data-v-e4be3b52] { color: var(--p-text-color); } -/* 应用深色主题覆盖,保持在基础颜色之后;主题切换只改变外观,不改变业务状态。 */ -.app-dark .notice { +/* 深色提示仅属于本弹窗;首页公告也使用 notice-title,不能让覆盖跨越组件作用域。 */ +.app-dark .notice[data-v-e4be3b52] { background: #172554; border-color: #1e3a8a; } /* 应用深色主题覆盖,保持在基础颜色之后;主题切换只改变外观,不改变业务状态。 */ -.app-dark .notice ul, -.app-dark .notice-title { +.app-dark .notice ul[data-v-e4be3b52], +.app-dark .notice-title[data-v-e4be3b52] { color: #bfdbfe; } diff --git a/app/components/UploadField.vue b/app/components/UploadField.vue index 4315ce6..7cd845b 100644 --- a/app/components/UploadField.vue +++ b/app/components/UploadField.vue @@ -4,6 +4,7 @@ import "~/assets/styles/UploadField.BbTCTv6C.css"; import { toDisplayString } from "vue"; import { mergeModels } from "vue"; import { useModel } from "vue"; +import { useId } from "vue"; import { ref } from "vue"; import { createCommentVNode } from "vue"; import { createElementBlock } from "vue"; @@ -18,13 +19,10 @@ import { t as _plugin_vue_export_helper_t } from "~/lib/_plugin-vue_export-helpe var m = { class: `field`, }, - h = { - key: 0, - }, g = { class: `row`, }, - _ = [`accept`, `disabled`], + _ = [`id`, `accept`, `disabled`], v = { key: 0, class: `state`, @@ -62,6 +60,8 @@ var m = { let p = useModel(t, `modelValue`), S = u, C = useApi(), + // 每个实例有独立关联 ID,多张证件同时上传时标签仍指向各自的原生输入框。 + L = useId(), w = ref(!1), T = ref(``), E = ref(``); @@ -90,13 +90,20 @@ var m = { createElementBlock(`div`, m, [ t.label ? (openBlock(), - createElementBlock(`label`, h, toDisplayString(t.label), 1)) + createElementBlock( + `label`, + { key: 0, for: L }, + toDisplayString(t.label), + 9, + [`for`], + )) : createCommentVNode(``, !0), createElementVNode(`div`, g, [ createElementVNode( `input`, { type: `file`, + id: L, accept: t.accept, disabled: unref(w), onChange: D, @@ -147,6 +154,7 @@ var m = { { type: `button`, class: `rm`, + "aria-label": `移除${t.label || `已上传文件`}`, onClick: O, }, [ @@ -155,12 +163,15 @@ var m = { `i`, { class: `pi pi-times`, + "aria-hidden": `true`, }, null, -1, ), ]), ], + 8, + [`aria-label`], ), ], 64, diff --git a/tests/review-style-fields.e2e.test.mjs b/tests/review-style-fields.e2e.test.mjs new file mode 100644 index 0000000..14912a8 --- /dev/null +++ b/tests/review-style-fields.e2e.test.mjs @@ -0,0 +1,354 @@ +/** 主题和字段回归经真实商品、首页、实名认证与实例入口验证;只替换外部 HTTP 响应。 */ +import assert from "node:assert/strict"; +import test from "node:test"; +import { backendOrigin, login, scenario } from "./helpers/console-browser.mjs"; + +async function boundary(page, responses) { + const requests = []; + await page + .context() + .route(`${backendOrigin}/console/v1/**`, async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname.replace( + /^\/console\/v1/, + "", + ); + if (!Object.hasOwn(responses, path)) return route.fallback(); + requests.push({ + path, + authenticated: Boolean(request.headers().authorization), + multipart: + request + .headers() + ["content-type"]?.startsWith("multipart/form-data; boundary=") === + true, + }); + await route.fulfill({ + json: { status: 200, msg: "", data: responses[path] }, + headers: { "Access-Control-Allow-Origin": "*" }, + }); + }); + return requests; +} + +async function textPresentation(locator) { + return locator.evaluate((element) => { + const style = getComputedStyle(element); + const rgb = (value) => value.match(/[\d.]+/g).map(Number); + let background = [255, 255, 255]; + const layers = []; + for (let owner = element; owner; owner = owner.parentElement) + layers.push(rgb(getComputedStyle(owner).backgroundColor)); + for (const layer of layers.reverse()) { + const alpha = layer[3] ?? 1; + background = background.map( + (channel, index) => channel * (1 - alpha) + layer[index] * alpha, + ); + } + const foreground = rgb(style.color); + const luminance = (color) => + color + .slice(0, 3) + .map((channel) => { + const value = channel / 255; + return value <= 0.04045 + ? value / 12.92 + : ((value + 0.055) / 1.055) ** 2.4; + }) + .reduce( + (total, value, index) => + total + value * [0.2126, 0.7152, 0.0722][index], + 0, + ); + const light = luminance(foreground); + const dark = luminance(background); + const tokenColor = new Option().style; + tokenColor.color = style.getPropertyValue("--p-text-color"); + return { + color: style.color, + textColor: tokenColor.color, + background: background.map(Math.round), + contrast: (Math.max(light, dark) + 0.05) / (Math.min(light, dark) + 0.05), + }; + }); +} + +test( + "dark structured catalog descriptions keep readable field text", + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + await page.emulateMedia({ colorScheme: "dark" }); + await boundary(page, { + "/product/group/first": { list: [{ id: 1, name: "云产品" }] }, + "/product/group/second": { + list: [{ id: 2, parent_id: 1, name: "测试分类", description: "" }], + }, + "/product": { + list: [ + { + id: 123, + name: "布局验收商品", + module: "idcsmart_common", + res_module: "idcsmart_common", + pay_type: "recurring_prepayment", + price: "10.00", + billing_cycle: "monthly", + stock_control: 0, + qty: 10, + order_allowed: true, + description: "配置:4GB 内存\n线路:BGP", + }, + ], + count: 1, + }, + }); + await login(page, "/products?g=1&sg=2"); + const value = page + .locator(".description-field dd") + .filter({ hasText: "4GB" }); + const label = page + .locator(".description-field dt") + .filter({ hasText: "配置" }); + await value.waitFor(); + await label.waitFor(); + assert.ok( + (await textPresentation(value)).contrast >= 4.5, + "Structured description values must remain readable in dark mode", + ); + assert.ok( + (await textPresentation(label)).contrast >= 4.5, + "Structured description labels must remain readable in dark mode", + ); + return { structuredDescriptionReadable: true }; + }), +); + +test( + "dark withdrawal notices do not recolor the home announcement title", + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + await page.emulateMedia({ colorScheme: "dark" }); + await boundary(page, { + "/announcement": { + list: [{ id: 1, title: "本地公告", create_time: 1700000000 }], + count: 1, + }, + "/withdraw/rule/credit": { + status: 1, + method: [{ id: 0, name: "账户余额", no_account: true }], + min: "1.00", + max: "100.00", + withdraw_fee_type: "fixed", + withdraw_fee: "0.00", + }, + }); + await login(page, "/"); + const announcement = page + .locator(".home-row-title") + .filter({ hasText: "本地公告" }); + await announcement.waitFor(); + const home = await textPresentation(announcement); + assert.equal( + home.color, + home.textColor, + "The announcement title owns its normal text color", + ); + await page.getByRole("button", { name: "提现", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "提现", exact: true }); + const notice = dialog.locator(".notice-title"); + await notice.waitFor(); + const ownNotice = await textPresentation(notice); + assert.ok( + ownNotice.contrast >= 4.5, + "Withdrawal instructions must remain readable", + ); + assert.notEqual( + ownNotice.color, + home.color, + "The withdrawal notice retains its own emphasis color", + ); + assert.equal((await textPresentation(announcement)).color, home.color); + return { + homeAnnouncementColorOwned: true, + withdrawalNoticeReadable: true, + }; + }), +); + +test( + "real-name upload fields expose distinct labels and named removal actions", + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + const requests = await boundary(page, { + "/certification/info": { + certification_open: 1, + certification_company_open: 1, + certification_upload: 1, + certification_company_need_person: 0, + is_certification: 0, + person: { status: 0 }, + company: { status: 0 }, + }, + "/upload": { save_name: "identity-front.png" }, + }); + await login(page, "/account/realname/personal"); + const front = page.getByLabel("身份证正面照 *", { exact: true }); + const back = page.getByLabel("身份证反面照 *", { exact: true }); + await front.waitFor(); + await back.waitFor(); + assert.notEqual( + await front.getAttribute("id"), + await back.getAttribute("id"), + ); + const file = { + name: "identity-front.png", + mimeType: "image/png", + buffer: Buffer.from("local-e2e-upload-not-identity-data"), + }; + await front.setInputFiles(file); + const remove = page.getByRole("button", { + name: "移除身份证正面照 *", + exact: true, + }); + await remove.waitFor(); + assert.deepEqual( + requests.filter((request) => request.path === "/upload"), + [{ path: "/upload", authenticated: true, multipart: true }], + ); + await remove.click(); + assert.equal(await remove.count(), 0); + await front.setInputFiles(file); + await remove.waitFor(); + return { + separateFileInputNames: true, + namedRemoval: true, + sameFileReselection: true, + authenticatedMultipartUpload: true, + }; + }), +); + +test( + "normal kky upgrade plans and disks fit the narrow dialog container", + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + await boundary(page, { + "/host/5": { + host: { + id: 5, + product_id: 123, + module: "kky_ecs", + name: "本地弹性云", + status: "Active", + billing_cycle: "monthly", + upgrade_allowed: true, + renew_amount: "10.00", + }, + }, + "/kky_ecs/5": { + cpu: 2, + memory: 4, + bw: 10, + protect: 0, + ip: "192.0.2.5", + username: "root", + port: 22, + image: { name: "Ubuntu 24.04" }, + }, + "/kky_ecs/5/status": { status: "on", rescue: false }, + "/kky_ecs/5/traffic": { + mode: "bandwidth", + monthly_quota: 100, + traffic_show: 0, + }, + "/kky_ecs/disk/5": { + list: [ + { + id: "data-1", + name: "数据盘", + size: 40, + type2: "data", + fs_type: "SSD", + }, + ], + }, + "/kky_ecs/monitor/5": { + sample_info: [], + cpu: [], + memory: [], + bandwidth: [], + disk: [], + }, + "/host/5/renew/auto": { status: 0 }, + "/refund": { allow_refund: 0, reasons: [] }, + "/refund/host/5/refund": { refund: null }, + "/promo_code/host/5/promo_code": { promo_code: [] }, + "/kky_ecs/upgrade/5/ins_upgrade_config": [ + { + cpu_num: 4, + mem_list: [ + { id: 1, memory: 8, is_sell_out: 0, description: "常规升级规格" }, + ], + }, + ], + "/kky_ecs/upgrade/5/add_disk_config": { + min: 10, + max: 1000, + step: 10, + type: ["SSD"], + remaining_num: 0, + }, + }); + await login(page, "/servers/server?id=5"); + await page.getByRole("tab", { name: "计费", exact: true }).click(); + await page.getByRole("button", { name: "升降级", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "升降级", exact: true }); + await dialog.locator(".plans .plan").waitFor(); + const widths = []; + const assertFits = async (gridName, cardName) => { + const measured = await dialog + .locator(gridName) + .evaluate((grid, childSelector) => { + const bounds = grid.getBoundingClientRect(); + const card = grid + .querySelector(childSelector) + .getBoundingClientRect(); + return { + width: bounds.width, + scroll: grid.scrollWidth, + client: grid.clientWidth, + rightOverflow: card.right - bounds.right, + }; + }, cardName); + assert.ok(measured.width > 0); + assert.ok( + measured.rightOverflow <= 1 && measured.scroll <= measured.client + 1, + `${gridName} must shrink to its actual dialog container: ${JSON.stringify(measured)}`, + ); + widths.push({ + viewport: page.viewportSize().width, + grid: gridName, + width: Math.round(measured.width), + fits: true, + }); + }; + for (const width of [375, 320]) { + await page.setViewportSize({ width, height: 900 }); + await assertFits(".plans", ".plan"); + } + await dialog.getByRole("button", { name: /硬盘/ }).click(); + await dialog + .getByRole("button", { name: "扩容已有盘", exact: true }) + .click(); + await dialog.locator(".disks .disk").waitFor(); + for (const width of [375, 320]) { + await page.setViewportSize({ width, height: 900 }); + await assertFits(".disks", ".disk"); + } + return { grids: widths }; + }), +);