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",
+ };
+ }),
+);