From 08ffc5189473aaa77f5afbcd8f794ed767dd12a8 Mon Sep 17 00:00:00 2001 From: CieanYau Date: Fri, 6 Nov 2026 04:43:57 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=98=E7=BD=91=E9=80=89?= =?UTF-8?q?=E8=B4=AD=E5=92=A8=E8=AF=A2=E5=85=A5=E5=8F=A3=E4=B8=8E=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E5=A4=B1=E8=B4=A5=E9=99=8D=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/components/OfficialImage.vue | 25 ++ app/layouts/official-site.vue | 101 ++++---- app/lib/useOfficialSiteContent.js | 58 ++++- app/pages/official-site.vue | 84 ++++--- tests/review-online-official.e2e.test.mjs | 284 ++++++++++++++++++++++ 5 files changed, 463 insertions(+), 89 deletions(-) create mode 100644 app/components/OfficialImage.vue create mode 100644 tests/review-online-official.e2e.test.mjs diff --git a/app/components/OfficialImage.vue b/app/components/OfficialImage.vue new file mode 100644 index 0000000..d2eea2d --- /dev/null +++ b/app/components/OfficialImage.vue @@ -0,0 +1,25 @@ + + + diff --git a/app/layouts/official-site.vue b/app/layouts/official-site.vue index e19fc3d..9119453 100644 --- a/app/layouts/official-site.vue +++ b/app/layouts/official-site.vue @@ -22,6 +22,7 @@ import { createVNode } from "vue"; import { openBlock } from "vue"; import { normalizeStyle } from "vue"; import { NuxtLink } from "#components"; +import OfficialImage from "~/components/OfficialImage.vue"; import { i as useOfficialSiteContent_i } from "~/lib/useOfficialSiteContent.js"; import { n as useOfficialSiteContent_n } from "~/lib/useOfficialSiteContent.js"; import { r as useOfficialSiteContent_r } from "~/lib/useOfficialSiteContent.js"; @@ -32,7 +33,6 @@ var S = { C = { class: `official-header-inner`, }, - w = [`src`, `alt`], T = { key: 1, class: `official-brand-mark`, @@ -69,7 +69,6 @@ var S = { I = { class: `official-footer-brand`, }, - te = [`src`, `alt`], L = { key: 1, class: `official-brand-mark`, @@ -94,7 +93,6 @@ var S = { key: 3, class: `official-footer-qr`, }, - K = [`src`, `alt`], q = { class: `official-footer-bottom`, }, @@ -149,28 +147,26 @@ var S = { }, { default: withCtx(() => [ - unref($) - ? (openBlock(), - createElementBlock( - `img`, - { - key: 0, - class: `official-brand-image`, - src: unref($), - alt: unref(Y).brand.name, - }, - null, - 8, - w, - )) - : (openBlock(), - createElementBlock(`span`, T, [ - ...(i[3] ||= [ - createElementVNode(`span`, null, null, -1), - createElementVNode(`span`, null, null, -1), - createElementVNode(`span`, null, null, -1), + createVNode( + OfficialImage, + { + class: `official-brand-image`, + src: unref($), + alt: unref(Y).brand.name, + }, + { + default: withCtx(() => [ + createElementVNode(`span`, T, [ + createElementVNode(`span`), + createElementVNode(`span`), + createElementVNode(`span`), ]), - ])), + ]), + _: 1, + }, + 8, + [`src`, `alt`], + ), createElementVNode(`span`, E, [ createElementVNode( `strong`, @@ -337,28 +333,26 @@ var S = { }, { default: withCtx(() => [ - unref($) - ? (openBlock(), - createElementBlock( - `img`, - { - key: 0, - class: `official-brand-image`, - src: unref($), - alt: unref(Y).brand.name, - }, - null, - 8, - te, - )) - : (openBlock(), - createElementBlock(`span`, L, [ - ...(i[5] ||= [ - createElementVNode(`span`, null, null, -1), - createElementVNode(`span`, null, null, -1), - createElementVNode(`span`, null, null, -1), + createVNode( + OfficialImage, + { + class: `official-brand-image`, + src: unref($), + alt: unref(Y).brand.name, + }, + { + default: withCtx(() => [ + createElementVNode(`span`, L, [ + createElementVNode(`span`), + createElementVNode(`span`), + createElementVNode(`span`), ]), - ])), + ]), + _: 1, + }, + 8, + [`src`, `alt`], + ), createElementVNode(`span`, R, [ createElementVNode( `strong`, @@ -523,17 +517,26 @@ var S = { unref(Y).footer.qrImage ? (openBlock(), createElementBlock(`div`, G, [ - createElementVNode( - `img`, + createVNode( + OfficialImage, { src: unref(useOfficialSiteContent_i)( unref(Y).footer.qrImage, ), alt: unref(Y).footer.qrCaption, }, - null, + { + default: withCtx(() => [ + createElementVNode( + `span`, + null, + `二维码暂时无法显示,请使用工单咨询`, + ), + ]), + _: 1, + }, 8, - K, + [`src`, `alt`], ), createElementVNode( `span`, diff --git a/app/lib/useOfficialSiteContent.js b/app/lib/useOfficialSiteContent.js index 1cf0135..0c33cc3 100644 --- a/app/lib/useOfficialSiteContent.js +++ b/app/lib/useOfficialSiteContent.js @@ -25,7 +25,7 @@ var n = { }, { label: `订购产品`, - href: `/cart`, + href: `/products`, is_show: !0, }, { @@ -68,7 +68,7 @@ var n = { }, ], buttonLabel: `浏览产品服务`, - buttonHref: `/login?redirect=%2Fproducts`, + buttonHref: `/products`, secondaryLabel: `了解基础设施`, secondaryHref: `#infrastructure`, metric: { @@ -124,7 +124,7 @@ var n = { }, ], buttonLabel: `查看云服务器`, - href: `/login?redirect=%2Fcloud-servers`, + href: `/products/cloud-server`, is_show: !0, }, { @@ -149,7 +149,7 @@ var n = { }, ], buttonLabel: `查看物理服务器`, - href: `/login?redirect=%2Fdedicated-servers`, + href: `/products/dedicated-server`, is_show: !0, }, { @@ -173,8 +173,8 @@ var n = { is_show: !0, }, ], - buttonLabel: `了解机柜托管`, - href: `/login?redirect=%2Fcolocations`, + buttonLabel: `登录咨询机柜托管`, + href: `/tickets/create`, is_show: !0, }, ], @@ -251,7 +251,7 @@ var n = { title: `为你的下一个项目选好基础设施`, description: `浏览可用产品方案,或进入客户中心配置服务、管理实例与订单。`, buttonLabel: `前往产品目录`, - buttonHref: `/login?redirect=%2Fproducts`, + buttonHref: `/products`, secondaryLabel: `登录客户中心`, secondaryHref: `/login`, }, @@ -263,17 +263,17 @@ var n = { links: [ { label: `云服务器`, - href: `/login?redirect=%2Fcloud-servers`, + href: `/products/cloud-server`, is_show: !0, }, { label: `物理服务器`, - href: `/login?redirect=%2Fdedicated-servers`, + href: `/products/dedicated-server`, is_show: !0, }, { label: `机柜托管`, - href: `/login?redirect=%2Fcolocations`, + href: `/tickets/create`, is_show: !0, }, ], @@ -359,8 +359,44 @@ function i(e, t) { function a() { return JSON.parse(JSON.stringify(n)); } +// 只迁移已确认的站内旧购买目的地;CMS 自定义外链、文案及其余账号入口保持原样。 +const purchaseDestinations = new Map([ + ["/products", "/products"], + ["/cloud-servers", "/products/cloud-server"], + ["/dedicated-servers", "/products/dedicated-server"], + ["/colocations", "/tickets/create"], +]); +function purchaseHref(value) { + if (typeof value !== "string" || !/^\/(?!\/)/.test(value)) return value; + try { + const url = new URL(value, window.location.origin); + const target = + url.pathname === "/login" + ? new URL( + url.searchParams.get("redirect") || "/", + window.location.origin, + ) + : url; + if (target.origin !== window.location.origin) return value; + const destination = purchaseDestinations.get(target.pathname); + return destination ? destination + target.search + target.hash : value; + } catch { + return value; + } +} +// 在 CMS 入模处处理所有链接字段,头尾、商品卡及主按钮不会各自保留不同的旧规则。 +function normalizeLinks(value) { + if (Array.isArray(value)) return value.map(normalizeLinks); + if (!r(value)) return value; + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key, + /href$/i.test(key) ? purchaseHref(entry) : normalizeLinks(entry), + ]), + ); +} function o(e) { - return i(n, e); + return normalizeLinks(i(n, e)); } // 链接允许站内路径、锚点、HTTP(S)/mailto/tel;未知协议和反斜杠回落为不可跳转的 #。 function s(e) { diff --git a/app/pages/official-site.vue b/app/pages/official-site.vue index da60945..c8c655d 100644 --- a/app/pages/official-site.vue +++ b/app/pages/official-site.vue @@ -25,6 +25,7 @@ import { normalizeStyle } from "vue"; import { useHead } from "#app"; import { NuxtLink } from "#components"; import { Icon } from "#components"; +import OfficialImage from "~/components/OfficialImage.vue"; import { i as useOfficialSiteContent_i } from "~/lib/useOfficialSiteContent.js"; import { n as useOfficialSiteContent_n } from "~/lib/useOfficialSiteContent.js"; import { r as useOfficialSiteContent_r } from "~/lib/useOfficialSiteContent.js"; @@ -162,7 +163,6 @@ var x = { he = { class: `official-review-person`, }, - ge = [`src`, `alt`], _e = { key: 1, class: `official-review-avatar`, @@ -639,6 +639,13 @@ var x = { 1032, [`to`], ), + n.href === `/tickets/create` + ? createElementVNode( + `p`, + null, + `机柜托管及定制方案请登录后提交工单咨询`, + ) + : createCommentVNode(``, !0), ], 4, ) @@ -922,35 +929,33 @@ var x = { 1, ), createElementVNode(`div`, he, [ - unref( - useOfficialSiteContent_i, - )(t.avatar) - ? (openBlock(), - createElementBlock( - `img`, - { - key: 0, - src: unref( - useOfficialSiteContent_i, - )(t.avatar), - alt: t.author, - }, - null, - 8, - ge, - )) - : (openBlock(), - createElementBlock( - `span`, - _e, - toDisplayString( - t.author.slice( - 0, - 1, - ) || `客`, + createVNode( + OfficialImage, + { + src: unref( + useOfficialSiteContent_i, + )(t.avatar), + alt: t.author, + }, + { + default: withCtx(() => [ + createElementVNode( + `span`, + _e, + toDisplayString( + t.author.slice( + 0, + 1, + ) || `客`, + ), + 1, ), - 1, - )), + ]), + _: 2, + }, + 1032, + [`src`, `alt`], + ), createElementVNode( `div`, null, @@ -1011,6 +1016,27 @@ var x = { ), 1, ), + !unref(X).footer.phone && + !unref(X).footer.email && + !unref(X).footer.address + ? createElementVNode(`p`, null, [ + createTextVNode( + `机柜托管及定制方案:`, + ), + createVNode( + f, + { to: `/tickets/create` }, + { + default: withCtx(() => [ + createTextVNode( + `登录后提交工单咨询`, + ), + ]), + _: 1, + }, + ), + ]) + : createCommentVNode(``, !0), ]), createElementVNode(`div`, xe, [ createVNode( diff --git a/tests/review-online-official.e2e.test.mjs b/tests/review-online-official.e2e.test.mjs new file mode 100644 index 0000000..5659874 --- /dev/null +++ b/tests/review-online-official.e2e.test.mjs @@ -0,0 +1,284 @@ +/** 官网回归使用真实 CMS 信封和图片字节边界,不替换内部模型或手工触发图片事件。 */ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + appOrigin, + backendOrigin, + eventually, + loginAccount, + loginPassword, + scenario, +} from "./helpers/console-browser.mjs"; + +// 与线上 CMS 的字段形状一致:旧入口仍由 CMS 覆盖,不能只测试默认文案。 +function cmsContent() { + return { + header: { + navigation: [ + { label: "产品服务", href: "#products", is_show: true }, + { + label: "客户自定义方案", + href: "https://customer.example.invalid/plan", + is_show: true, + }, + ], + }, + hero: { + title: "为客户保留的自定义标题", + buttonLabel: "浏览产品服务", + buttonHref: "/login?redirect=%2Fproducts", + }, + products: { + items: [ + { + title: "客户自定义云方案", + buttonLabel: "查看云服务器", + href: "/login?redirect=%2Fcloud-servers", + features: [], + is_show: true, + }, + { + title: "物理服务器", + buttonLabel: "查看物理服务器", + href: "/login?redirect=%2Fdedicated-servers", + features: [], + is_show: true, + }, + { + title: "机柜托管", + buttonLabel: "了解机柜托管", + href: "/login?redirect=%2Fcolocations", + features: [], + is_show: true, + }, + ], + }, + footer: { + phone: "", + email: "", + address: "", + linkGroups: [ + { + title: "产品服务", + links: [ + { label: "云服务器", href: "/login?redirect=%2Fcloud-servers" }, + { label: "机柜托管", href: "/login?redirect=%2Fcolocations" }, + ], + }, + ], + }, + }; +} + +async function officialBoundary(page, content = {}) { + const imageErrors = []; + await page + .context() + .route(`${appOrigin}/official-test-images/**`, (route) => { + if (route.request().url().endsWith("valid-avatar.svg")) { + return route.fulfill({ + contentType: "image/svg+xml", + body: '', + }); + } + // HTTP 成功但图片不可解码,真实触发 img error;不放宽共享 console 错误门禁。 + imageErrors.push(new URL(route.request().url()).pathname); + return route.fulfill({ + contentType: "image/png", + body: "invalid PNG bytes", + }); + }); + await page.context().route(`${backendOrigin}/console/v1/**`, (route) => { + const path = new URL(route.request().url()).pathname.replace( + /^\/console\/v1/, + "", + ); + const reply = (data) => + route.fulfill({ json: { status: 200, msg: "", data } }); + if (path === "/official_site_cms/content") return reply({ content }); + if (path === "/product/group/first") { + return reply({ + list: [ + { id: 1, name: "云服务器" }, + { id: 2, name: "物理服务器" }, + ], + }); + } + if (path === "/product/group/second") { + return reply({ list: [{ id: 11, parent_id: 1, name: "公开云线路" }] }); + } + if (path === "/product") return reply({ list: [], count: 0 }); + if (path === "/ticket/config") return reply({ ticket_notice_open: 0 }); + if (["/ticket/type", "/ticket/department"].includes(path)) + return reply({ list: [] }); + return route.fallback(); + }); + return { imageErrors }; +} + +for (const [label, destination, content] of [ + ["浏览产品服务", "/products", {}], + ["浏览产品服务", "/products", cmsContent()], + ["查看云服务器", "/products/cloud-server", cmsContent()], + ["查看物理服务器", "/products/dedicated-server", cmsContent()], +]) { + test( + `official purchase CTA opens ${destination} anonymously with ${Object.keys(content).length ? "CMS" : "default"} content`, + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + await officialBoundary(page, content); + await page.goto(`${appOrigin}/official-site`); + if (content.hero) { + await page + .getByRole("heading", { name: content.hero.title, exact: true }) + .waitFor(); + assert.equal( + await page + .getByRole("link", { name: "客户自定义方案", exact: true }) + .getAttribute("href"), + "https://customer.example.invalid/plan", + ); + assert.equal( + await page + .getByRole("heading", { name: "客户自定义云方案", exact: true }) + .count(), + 1, + ); + } + await page.getByRole("link", { name: label, exact: true }).click(); + await eventually( + () => new URL(page.url()).pathname, + (path) => path === destination, + "Public purchase CTA must reach its product route without a login gate", + ); + await page.locator(".public-order-layout").waitFor(); + assert.equal(await page.locator("#account").count(), 0); + return { + anonymousDestination: destination, + customContentPreserved: !!content.hero, + }; + }), + ); +} + +test( + "official cabinet consultation reaches an implemented support flow", + { timeout: 30000 }, + (t) => + scenario(t, { loginMode: "password-only" }, async (page) => { + const content = cmsContent(); + content.contact = { is_show: false }; + content.sectionOrder = ["products", "infrastructure", "reviews"]; + await officialBoundary(page, content); + await page.goto(`${appOrigin}/official-site`); + await page + .getByRole("heading", { name: "为客户保留的自定义标题", exact: true }) + .waitFor(); + assert.equal( + await page.locator("#contact").count(), + 0, + "CMS contact visibility must not be overridden", + ); + await page + .getByText("机柜托管及定制方案请登录后提交工单咨询", { exact: true }) + .waitFor(); + await page + .getByRole("link", { name: "了解机柜托管", exact: true }) + .click(); + await page.getByRole("button", { name: "登录", exact: true }).waitFor(); + assert.equal( + new URL(page.url()).searchParams.get("redirect"), + "/tickets/create", + ); + await page.locator("#account").fill(loginAccount); + await page.locator("#password").fill(loginPassword); + await page.getByRole("button", { name: "登录", exact: true }).click(); + await page.waitForURL((url) => url.pathname === "/tickets/create"); + await page.locator(".ticket-create").waitFor(); + return { + consultationAvailable: true, + supportRouteOpened: "/tickets/create", + }; + }), +); + +test( + "official configurable images fall back after real image decoding failures", + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + const content = cmsContent(); + content.brand = { + name: "破碎工坊网络", + logoUrl: "/official-test-images/logo.png", + }; + content.footer.qrImage = "/official-test-images/qr.png"; + content.footer.qrCaption = "扫码咨询"; + content.reviews = { + is_show: true, + items: [ + { + author: "Sophon", + company: "", + quote: "保留原有客户评价", + avatar: "/official-test-images/avatar.png", + is_show: true, + }, + { + author: "Ramdisk", + company: "", + quote: "保留可用客户头像", + avatar: "/official-test-images/valid-avatar.svg", + is_show: true, + }, + ], + }; + const boundary = await officialBoundary(page, content); + await page.goto(`${appOrigin}/official-site`); + await page.getByText("保留原有客户评价", { exact: true }).waitFor(); + await eventually( + () => [...new Set(boundary.imageErrors)].sort(), + (paths) => paths.length === 3, + "The browser must request each configured bad image", + ); + await eventually( + () => page.locator(".official-brand-mark").count(), + (count) => count === 2, + "Both failed brand images must use the existing brand mark", + ); + assert.equal( + await page.locator(".official-review-avatar").textContent(), + "S", + ); + await page + .getByText("二维码暂时无法显示,请使用工单咨询", { exact: true }) + .waitFor(); + assert.equal( + await page.locator(".official-site img").count(), + 1, + "Only the successfully decoded original customer avatar should remain", + ); + assert.equal( + await page + .getByRole("img", { name: "Ramdisk", exact: true }) + .evaluate((image) => image.complete && image.naturalWidth > 0), + true, + ); + assert.equal(await page.getByText("Sophon", { exact: true }).count(), 1); + await page.setViewportSize({ width: 390, height: 844 }); + assert.equal( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + true, + "Image fallbacks and consultation text must fit the mobile viewport", + ); + return { + brandFallbacks: 2, + customerIdentityPreserved: true, + qrReplacedByConsultationText: true, + imageFailure: "HTTP 200 with invalid PNG bytes", + }; + }), +);