前端验证 / verify (push) Successful in 2m7s
- 云产品选择器新增地区标签页、分组数量及全部选项,自动跟随当前线路所属地区 - 将线路和型号改为紧凑选择按钮,精简型号名称的公共前缀并保留完整名称提示 - 物理服务器目录改为规格列表,支持行内展开、收起线路详情及进入定制页 - 新增商品起价升降序排序,将无有效价格的商品置于末尾 - 将规格筛选改为下拉选项,移动端支持展开更多筛选并显示已选条件数量 - 统一云服务器与物理服务器的横向配置表单,优化磁盘、带宽、镜像选择及移动端布局 - 限制桌面端配置汇总栏高度,使清单内部滚动并保持价格和购买按钮可见 - 统一产品分类标签页与胶囊按钮样式,补充查看全部产品入口 - 将产品选择器和物理服务器目录重构为 script setup、声明式模板及 scoped 样式,删除旧样式文件与相关引用
408 lines
12 KiB
Vue
408 lines
12 KiB
Vue
<script setup>
|
||
/**
|
||
* 云商品分组与分页选择器:groups/products/库存文案由父级提供,不直接请求接口。
|
||
* 分组按名称归入地区标签页;分组、商品、重试和分页分别发出意图事件;disabled 将购买提交期间的选择锁定。
|
||
*/
|
||
import { computed, ref, useId, watch } from "vue";
|
||
import { NuxtLink } from "#components";
|
||
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>
|