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

408 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>