feat(products): 改版服务器选购界面并新增地区筛选与价格排序
前端验证 / verify (push) Successful in 2m7s

- 云产品选择器新增地区标签页、分组数量及全部选项,自动跟随当前线路所属地区
- 将线路和型号改为紧凑选择按钮,精简型号名称的公共前缀并保留完整名称提示
- 物理服务器目录改为规格列表,支持行内展开、收起线路详情及进入定制页
- 新增商品起价升降序排序,将无有效价格的商品置于末尾
- 将规格筛选改为下拉选项,移动端支持展开更多筛选并显示已选条件数量
- 统一云服务器与物理服务器的横向配置表单,优化磁盘、带宽、镜像选择及移动端布局
- 限制桌面端配置汇总栏高度,使清单内部滚动并保持价格和购买按钮可见
- 统一产品分类标签页与胶囊按钮样式,补充查看全部产品入口
- 将产品选择器和物理服务器目录重构为 script setup、声明式模板及 scoped 样式,删除旧样式文件与相关引用
This commit is contained in:
cneicy committed 2026-11-08 18:54:34 +08:00
1 parent 65c175c8fa
commit e3ad73d397
12 files changed
+2121 -2312

No files matched your search

+402 -448
View File
@@ -1,453 +1,407 @@
<script>
<script setup>
/**
* 云商品分组与分页选择器:groups/products/库存文案由父级提供,不直接请求接口。
* 分组、商品、重试和分页分别发出意图事件;disabled 将购买提交期间的选择锁定。
* 分组按名称归入地区标签页;分组、商品、重试和分页分别发出意图事件;disabled 将购买提交期间的选择锁定。
*/
import "~/assets/styles/ProductGroupLabel.B1kWpFA-.css";
import "~/assets/styles/CloudProductPicker.CAm9rmUl.css";
import { toDisplayString } from "vue";
import { renderList } from "vue";
import { useId } from "vue";
import { normalizeClass } from "vue";
import { createBlock } from "vue";
import { withCtx } from "vue";
import { createCommentVNode } from "vue";
import { createElementBlock } from "vue";
import { Fragment } from "vue";
import { createElementVNode } from "vue";
import { createTextVNode } from "vue";
import { unref } from "vue";
import { defineComponent } from "vue";
import { createVNode } from "vue";
import { openBlock } from "vue";
import { computed, ref, useId, watch } from "vue";
import { NuxtLink } from "#components";
import button from "openvue/button";
import { t as _plugin_vue_export_helper_t } from "~/lib/_plugin-vue_export-helper.js";
import message from "openvue/message";
import { n as product_name_n } from "~/lib/product-name.js";
import paginator from "openvue/paginator";
import skeleton from "openvue/skeleton";
import ProductGroupLabel from "~/components/ProductGroupLabel.vue";
var C = {
class: `cloud-product-picker`,
},
w = [`aria-labelledby`],
T = [`id`],
E = {
class: `zone-options`,
role: `group`,
"aria-label": `产品专区`,
},
D = [`aria-pressed`, `disabled`, `onClick`],
O = {
key: 2,
class: `catalog-empty`,
},
k = [`aria-labelledby`],
A = [`id`],
j = {
key: 2,
class: `catalog-empty`,
},
M = {
key: 3,
class: `product-options`,
role: `group`,
"aria-label": `选择云服务器产品`,
},
N = [`aria-pressed`, `disabled`, `onClick`],
P = {
key: 0,
},
F = {
key: 1,
},
I = Object.assign(
_plugin_vue_export_helper_t(
defineComponent({
__name: `CloudProductPicker`,
props: {
groups: {},
activeGroup: {},
products: {},
selectedId: {},
loading: {
type: Boolean,
},
productLoading: {
type: Boolean,
},
error: {},
errorSource: {},
total: {},
page: {},
disabled: {
type: Boolean,
},
soldOut: {
type: Function,
},
stockLabel: {
type: Function,
},
},
emits: [
`choose-group`,
`choose-product`,
`retry-groups`,
`retry-products`,
`paginate`,
],
setup(f, { emit: _ }) {
let I = _,
L = useId();
return (n, _) => {
let R = skeleton,
z = button,
B = message,
V = ProductGroupLabel,
H = NuxtLink,
U = paginator;
return (
openBlock(),
createElementBlock(`div`, C, [
createElementVNode(
`section`,
{
class: `selection-section`,
"aria-labelledby": `${unref(L)}-lines-title`,
},
[
createElementVNode(
`h3`,
{
id: `${unref(L)}-lines-title`,
},
`选择线路`,
8,
T,
),
f.loading
? (openBlock(),
createBlock(R, {
key: 0,
height: `110px`,
}))
: (openBlock(),
createElementBlock(
Fragment,
{
key: 1,
},
[
f.error && f.errorSource === `groups`
? (openBlock(),
createBlock(
B,
{
key: 0,
severity: `error`,
closable: !1,
},
{
default: withCtx(() => [
createTextVNode(
toDisplayString(f.error),
1,
),
createVNode(
z,
{
label: `重新加载线路`,
severity: `secondary`,
text: ``,
disabled: f.disabled,
onClick: (_[0] ||= (e) =>
I(`retry-groups`)),
},
null,
8,
[`disabled`],
),
]),
_: 1,
},
))
: f.groups.length
? (openBlock(),
createElementBlock(
Fragment,
{
key: 1,
},
[
(_[3] ||= createElementVNode(
`div`,
{
class: `selection-label`,
},
`产品专区`,
-1,
)),
createElementVNode(`div`, E, [
(openBlock(!0),
createElementBlock(
Fragment,
null,
renderList(
f.groups,
(e) => (
openBlock(),
createElementBlock(
`button`,
{
key: e.id,
type: `button`,
class: normalizeClass([
`zone-option`,
{
active:
f.activeGroup === e.id,
},
]),
"aria-pressed":
f.activeGroup === e.id,
disabled: f.disabled,
onClick: (t) =>
I(`choose-group`, e.id),
},
[
createVNode(
V,
{
name: e.name,
},
null,
8,
[`name`],
),
],
10,
D,
)
),
),
128,
)),
]),
],
64,
))
: (openBlock(),
createElementBlock(`div`, O, [
(_[5] ||= createElementVNode(
`i`,
{
class: `pi pi-box`,
"aria-hidden": `true`,
},
null,
-1,
)),
(_[6] ||= createElementVNode(
`p`,
null,
`暂未提供可选的云服务器线路`,
-1,
)),
createVNode(
H,
{
to: `/products`,
},
{
default: withCtx(() => [
...(_[4] ||= [
createTextVNode(`浏览产品`, -1),
]),
]),
_: 1,
},
),
])),
],
64,
)),
],
8,
w,
),
f.groups.length
? (openBlock(),
createElementBlock(
`section`,
{
key: 0,
class: `selection-section`,
"aria-labelledby": `${unref(L)}-products-title`,
},
[
createElementVNode(
`h3`,
{
id: `${unref(L)}-products-title`,
class: `products-title`,
},
`产品`,
8,
A,
),
f.productLoading && !f.products.length
? (openBlock(),
createBlock(R, {
key: 0,
height: `110px`,
}))
: f.error && f.errorSource === `products`
? (openBlock(),
createBlock(
B,
{
key: 1,
severity: `error`,
closable: !1,
},
{
default: withCtx(() => [
createTextVNode(
toDisplayString(f.error),
1,
),
createVNode(
z,
{
label: `重新加载商品`,
severity: `secondary`,
text: ``,
disabled: f.disabled,
onClick: (_[1] ||= (e) =>
I(`retry-products`)),
},
null,
8,
[`disabled`],
),
]),
_: 1,
},
))
: !f.products.length && !f.error
? (openBlock(),
createElementBlock(`div`, j, [
...(_[7] ||= [
createElementVNode(
`p`,
null,
`该专区暂无可售产品`,
-1,
),
]),
]))
: (openBlock(),
createElementBlock(`div`, M, [
(openBlock(!0),
createElementBlock(
Fragment,
null,
renderList(
f.products,
(t) => (
openBlock(),
createElementBlock(
`button`,
{
key: t.id,
type: `button`,
class: normalizeClass([
`product-option`,
{
active: f.selectedId === t.id,
},
]),
"aria-pressed":
f.selectedId === t.id,
disabled:
f.disabled || f.soldOut(t),
onClick: (e) =>
I(`choose-product`, t.id),
},
[
createElementVNode(
`strong`,
null,
toDisplayString(
unref(product_name_n)(t.name),
),
1,
),
f.soldOut(t)
? (openBlock(),
createElementBlock(
`span`,
P,
toDisplayString(
f.stockLabel(t),
),
1,
))
: (openBlock(),
createElementBlock(
`span`,
F,
toDisplayString(
f.selectedId === t.id
? `当前配置`
: `查看可选配置`,
),
1,
)),
],
10,
N,
)
),
),
128,
)),
])),
f.total > 24
? (openBlock(),
createBlock(
U,
{
key: 4,
first: (f.page - 1) * 24,
rows: 24,
totalRecords: f.total,
disabled: f.disabled,
onPage: (_[2] ||= (e) => I(`paginate`, e)),
},
null,
8,
[`first`, `totalRecords`, `disabled`],
))
: createCommentVNode(``, !0),
],
8,
k,
))
: createCommentVNode(``, !0),
])
);
};
},
}),
[[`__scopeId`, `data-v-a438862d`]],
),
{
__name: `CloudProductPicker`,
},
);
export {};
export default I;
import Button from "openvue/button";
import Message from "openvue/message";
import Paginator from "openvue/paginator";
import Skeleton from "openvue/skeleton";
import ProductGroupLabel, {
n as parseGroupLabel,
} from "~/components/ProductGroupLabel.vue";
import { n as displayProductName } from "~/lib/product-name.js";
const props = defineProps({
groups: { type: Array, default: () => [] },
activeGroup: {},
products: { type: Array, default: () => [] },
selectedId: {},
loading: Boolean,
productLoading: Boolean,
error: {},
errorSource: {},
total: {},
page: {},
disabled: Boolean,
soldOut: Function,
stockLabel: Function,
});
const emit = defineEmits([
"choose-group",
"choose-product",
"retry-groups",
"retry-products",
"paginate",
]);
const id = useId();
// 按名称关键字归类;港澳台与海外优先匹配,避免“香港BGP”之类被省份规则误判。
const REGIONS = [
[
"华东",
/上海|江苏|浙江|安徽|福建|江西|山东|华东|宿迁|金华|宁波|温州|绍兴|镇江|盐城|扬州|常州|无锡|徐州|泉州|厦门|福州|芜湖|九江|济南|杭州|南京|苏州/,
],
["华南", /广东|广西|海南|华南|深圳|广州|佛山|东莞|海口|南宁/],
["华中", /湖北|湖南|河南|华中|襄阳|十堰|武汉|郑州|新乡|长沙/],
["华北", /北京|天津|河北|山西|内蒙|华北|石家庄|太原/],
["西南", /四川|重庆|云南|贵州|西藏|西南|成都|昆明|德阳|贵阳/],
["西北", /陕西|甘肃|青海|宁夏|新疆|西北|西安|咸阳|兰州/],
["东北", /辽宁|吉林|黑龙江|东北|沈阳|鞍山|大连|长春|哈尔滨/],
];
const ABROAD = [
["港澳台", /香港|澳门|台湾/],
[
"海外",
/美国|日本|韩国|新加坡|越南|马来|泰国|菲律宾|印尼|印度|德国|英国|法国|荷兰|俄罗斯|欧洲|洛杉矶|硅谷|东京|首尔|海外/,
],
];
const REGION_ORDER = [...REGIONS, ...ABROAD]
.map(([name]) => name)
.concat("其他");
function regionOf(group) {
const name = parseGroupLabel(group.name).name;
for (const [region, pattern] of [...ABROAD, ...REGIONS])
if (pattern.test(name)) return region;
return "其他";
}
const regions = computed(() => {
const buckets = new Map();
for (const group of props.groups) {
const region = regionOf(group);
if (!buckets.has(region)) buckets.set(region, []);
buckets.get(region).push(group);
}
return REGION_ORDER.filter((name) => buckets.has(name)).map((name) => ({
name,
groups: buckets.get(name),
}));
});
const activeGroupRegion = computed(() => {
const group = props.groups.find((g) => g.id === props.activeGroup);
return group ? regionOf(group) : regions.value[0]?.name;
});
const chosenRegion = ref(null);
watch(
() => props.activeGroup,
() => (chosenRegion.value = null),
);
const currentRegion = computed(
() => chosenRegion.value ?? activeGroupRegion.value ?? "all",
);
const visibleGroups = computed(() =>
currentRegion.value === "all"
? props.groups
: (regions.value.find((r) => r.name === currentRegion.value)?.groups ??
props.groups),
);
// 同一分组下的商品名称通常共享“地区 | 运营商(”前缀,只显示差异部分,完整名称放在 title 中。
const productLabels = computed(() => {
const names = props.products.map((p) => displayProductName(p.name));
const plain = { labels: names };
if (names.length < 2) return plain;
let prefix = names[0];
for (const name of names)
while (!name.startsWith(prefix)) prefix = prefix.slice(0, -1);
const boundary = prefix.match(/^.*[((\s||·\-_/]/);
prefix = boundary ? boundary[0] : "";
if (prefix.length < 2 || names.some((n) => n.length <= prefix.length))
return plain;
const opensParen = /[((]$/.test(prefix);
return {
labels: names.map((name) => {
const rest = name.slice(prefix.length).trim();
return opensParen ? rest.replace(/[))]$/, "") : rest;
}),
};
});
</script>
<template>
<section class="cloud-product-picker" :aria-labelledby="`${id}-title`">
<h2 :id="`${id}-title`" class="section-title">选择产品</h2>
<div class="picker-row">
<span :id="`${id}-lines-title`" class="picker-label">线路</span>
<div class="picker-control">
<Skeleton v-if="loading" height="88px" />
<Message
v-else-if="error && errorSource === 'groups'"
severity="error"
:closable="false"
>
{{ error }}
<Button
label="重新加载线路"
severity="secondary"
text
:disabled="disabled"
@click="emit('retry-groups')"
/>
</Message>
<template v-else-if="groups.length">
<div
v-if="regions.length > 1"
class="choice-tabs"
role="group"
aria-label="地区"
>
<button
v-for="region in regions"
:key="region.name"
type="button"
class="choice-tab"
:class="{ active: currentRegion === region.name }"
:aria-pressed="currentRegion === region.name"
@click="chosenRegion = region.name"
>
{{ region.name }}<small>{{ region.groups.length }}</small>
</button>
<button
type="button"
class="choice-tab"
:class="{ active: currentRegion === 'all' }"
:aria-pressed="currentRegion === 'all'"
@click="chosenRegion = 'all'"
>
全部<small>{{ groups.length }}</small>
</button>
</div>
<div
class="choice-chips"
role="group"
:aria-labelledby="`${id}-lines-title`"
>
<button
v-for="group in visibleGroups"
:key="group.id"
type="button"
class="choice-chip"
:class="{ active: activeGroup === group.id }"
:aria-pressed="activeGroup === group.id"
:disabled="disabled"
@click="emit('choose-group', group.id)"
>
<ProductGroupLabel :name="group.name" />
</button>
</div>
</template>
<div v-else class="catalog-empty">
<i class="pi pi-box" aria-hidden="true" />
<p>暂未提供可选的云服务器线路</p>
<NuxtLink to="/products">浏览产品</NuxtLink>
</div>
</div>
</div>
<div v-if="groups.length" class="picker-row">
<span :id="`${id}-products-title`" class="picker-label">型号</span>
<div class="picker-control">
<Skeleton v-if="productLoading && !products.length" height="34px" />
<Message
v-else-if="error && errorSource === 'products'"
severity="error"
:closable="false"
>
{{ error }}
<Button
label="重新加载商品"
severity="secondary"
text
:disabled="disabled"
@click="emit('retry-products')"
/>
</Message>
<p v-else-if="!products.length && !error" class="picker-empty">
该专区暂无可售产品
</p>
<div
v-else
class="choice-chips"
role="group"
:aria-labelledby="`${id}-products-title`"
>
<button
v-for="(product, index) in products"
:key="product.id"
type="button"
class="choice-chip"
:class="{ active: selectedId === product.id }"
:aria-pressed="selectedId === product.id"
:title="displayProductName(product.name)"
:disabled="disabled || soldOut(product)"
@click="emit('choose-product', product.id)"
>
{{ productLabels.labels[index] }}
<small v-if="soldOut(product)">{{ stockLabel(product) }}</small>
</button>
</div>
<Paginator
v-if="total > 24"
:first="(page - 1) * 24"
:rows="24"
:total-records="total"
:disabled="disabled"
@page="emit('paginate', $event)"
/>
</div>
</div>
</section>
</template>
<style scoped>
.cloud-product-picker {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.cloud-product-picker > .section-title {
margin-bottom: 6px;
}
.picker-row {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
align-items: start;
gap: 8px 16px;
}
.picker-label {
color: var(--p-text-muted-color);
font-size: 0.9375rem;
line-height: 34px;
}
.picker-control {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.picker-empty {
margin: 0;
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 34px;
}
/* 统一选择控件:与物理服务器筛选、商品目录分类使用同一套标签页与胶囊按钮规格。 */
.choice-tabs {
display: flex;
gap: 2px;
overflow-x: auto;
scrollbar-width: none;
border-bottom: 1px solid var(--p-content-border-color);
}
.choice-tabs::-webkit-scrollbar {
display: none;
}
.choice-tab {
flex: none;
display: inline-flex;
align-items: baseline;
gap: 4px;
margin-bottom: -1px;
padding: 6px 12px 8px;
border: 0;
border-bottom: 2px solid transparent;
background: none;
color: var(--p-text-muted-color);
font: inherit;
font-size: 0.9375rem;
cursor: pointer;
}
.choice-tab small {
font-size: 0.75rem;
opacity: 0.6;
}
.choice-tab:hover {
color: var(--p-text-color);
}
.choice-tab.active {
border-bottom-color: var(--p-primary-color);
color: var(--p-primary-color);
font-weight: 500;
}
.choice-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.choice-chip {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
min-height: 34px;
padding: 5px 12px;
border: 1px solid var(--p-content-border-color);
border-radius: var(--radius-md);
background: var(--p-content-background);
color: var(--p-text-color);
font: inherit;
font-size: 0.875rem;
line-height: 1.4;
text-align: left;
overflow-wrap: anywhere;
cursor: pointer;
}
.choice-chip small {
color: var(--p-text-muted-color);
font-size: 0.75rem;
}
.choice-chip:hover:not(:disabled) {
border-color: var(--p-primary-color);
}
.choice-chip.active {
border-color: var(--p-primary-color);
box-shadow: inset 0 0 0 1px var(--p-primary-color);
background: var(--p-highlight-background);
color: var(--p-highlight-color);
font-weight: 500;
}
.choice-chip:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.choice-chip:focus-visible,
.choice-tab:focus-visible {
outline: 2px solid var(--p-primary-color);
outline-offset: 2px;
}
.choice-chip :deep(.product-group-badge) {
padding: 0;
border: 0;
background: none;
color: inherit;
opacity: 0.6;
font-size: 0.75rem;
font-weight: 400;
}
.catalog-empty {
padding: 28px;
border: 1px solid var(--p-content-border-color);
border-radius: var(--radius-md);
background: var(--p-content-background);
color: var(--p-text-muted-color);
text-align: center;
}
.catalog-empty i {
font-size: 1.75rem;
}
.catalog-empty a {
color: var(--p-primary-color);
text-decoration: none;
}
@media (width < 640px) {
.picker-row {
grid-template-columns: minmax(0, 1fr);
gap: 6px;
}
.picker-label {
line-height: 1.5;
}
}
</style>