前端验证 / verify (push) Successful in 2m7s
- 云产品选择器新增地区标签页、分组数量及全部选项,自动跟随当前线路所属地区 - 将线路和型号改为紧凑选择按钮,精简型号名称的公共前缀并保留完整名称提示 - 物理服务器目录改为规格列表,支持行内展开、收起线路详情及进入定制页 - 新增商品起价升降序排序,将无有效价格的商品置于末尾 - 将规格筛选改为下拉选项,移动端支持展开更多筛选并显示已选条件数量 - 统一云服务器与物理服务器的横向配置表单,优化磁盘、带宽、镜像选择及移动端布局 - 限制桌面端配置汇总栏高度,使清单内部滚动并保持价格和购买按钮可见 - 统一产品分类标签页与胶囊按钮样式,补充查看全部产品入口 - 将产品选择器和物理服务器目录重构为 script setup、声明式模板及 scoped 样式,删除旧样式文件与相关引用
1117 lines
30 KiB
Vue
1117 lines
30 KiB
Vue
<script setup>
|
|
/**
|
|
* 物理服务器选型:汇总可售物理商品、机型与地区线路,支持规格/价格筛选与排序,展开机型后选择线路进入独立定制页。
|
|
*/
|
|
import {
|
|
computed,
|
|
onBeforeUnmount,
|
|
onMounted,
|
|
reactive,
|
|
ref,
|
|
useId,
|
|
} from "vue";
|
|
import { NuxtLink } from "#components";
|
|
import Button from "openvue/button";
|
|
import InputNumber from "openvue/inputnumber";
|
|
import Message from "openvue/message";
|
|
import Select from "openvue/select";
|
|
import Skeleton from "openvue/skeleton";
|
|
import { useApi } from "~/lib/auth.js";
|
|
import { n as displayProductName } from "~/lib/product-name.js";
|
|
import { productStock } from "~/lib/product-inventory.js";
|
|
import { resourceOptionValue } from "~/lib/resource-option-value.js";
|
|
import { productId } from "~/lib/product-id.js";
|
|
|
|
// 只识别本地或资源模块映射到 mf_dcim 的物理商品;未知模块不强行套用配置器。
|
|
function dcimModule(product) {
|
|
const module = product.module?.trim() || "";
|
|
if (module === "mf_dcim" || module === "remf_dcim") return module;
|
|
return !module && product.res_module?.trim() === "mf_dcim"
|
|
? "remf_dcim"
|
|
: undefined;
|
|
}
|
|
|
|
const api = useApi();
|
|
const fieldId = useId();
|
|
const loading = ref(true);
|
|
const error = ref("");
|
|
const partialError = ref("");
|
|
const entries = ref([]);
|
|
const selectedKey = ref("");
|
|
const lineDetails = ref({});
|
|
const lineLoading = ref(false);
|
|
const lineError = ref("");
|
|
const moreFiltersOpen = ref(false);
|
|
const sortBy = ref("default");
|
|
const filters = reactive({
|
|
minPrice: null,
|
|
maxPrice: null,
|
|
region: "",
|
|
line: "",
|
|
cpu: "",
|
|
memory: "",
|
|
disk: "",
|
|
});
|
|
let listGeneration = 0;
|
|
let lineGeneration = 0;
|
|
let disposed = false;
|
|
|
|
const sortOptions = [
|
|
{ label: "默认排序", value: "default" },
|
|
{ label: "价格从低到高", value: "price-asc" },
|
|
{ label: "价格从高到低", value: "price-desc" },
|
|
];
|
|
const specFilters = [
|
|
{ key: "cpu", label: "处理器" },
|
|
{ key: "memory", label: "内存" },
|
|
{ key: "disk", label: "硬盘" },
|
|
];
|
|
|
|
const keyOf = (entry) => `${entry.product.id}:${entry.model.id}`;
|
|
const unique = (values) => [...new Set(values.filter(Boolean))];
|
|
|
|
function priceOf(entry) {
|
|
const raw = entry.product.price;
|
|
if (raw === undefined || raw === null || String(raw).trim() === "")
|
|
return null;
|
|
const value = Number(raw);
|
|
return Number.isFinite(value) ? value : null;
|
|
}
|
|
|
|
const selected = computed(() =>
|
|
entries.value.find((entry) => keyOf(entry) === selectedKey.value),
|
|
);
|
|
const options = computed(() => ({
|
|
region: unique(
|
|
entries.value.flatMap((e) => e.lines.map((line) => line.location)),
|
|
),
|
|
line: unique(entries.value.flatMap((e) => e.lines.map((line) => line.name))),
|
|
cpu: unique(entries.value.map((e) => e.model.cpu)),
|
|
memory: unique(entries.value.map((e) => e.model.memory)),
|
|
disk: unique(entries.value.map((e) => e.model.disk)),
|
|
}));
|
|
const selectOptions = computed(() =>
|
|
Object.fromEntries(
|
|
specFilters.map(({ key, label }) => [
|
|
key,
|
|
[
|
|
{ label: `全部${label}`, value: "" },
|
|
...options.value[key].map((value) => ({ label: value, value })),
|
|
],
|
|
]),
|
|
),
|
|
);
|
|
|
|
function matchesLine(line) {
|
|
return (
|
|
(!filters.region || line.location === filters.region) &&
|
|
(!filters.line || line.name === filters.line)
|
|
);
|
|
}
|
|
|
|
const filtered = computed(() => {
|
|
const list = entries.value.filter((entry) => {
|
|
const price = priceOf(entry);
|
|
return (
|
|
(filters.minPrice === null ||
|
|
(price !== null && price >= filters.minPrice)) &&
|
|
(filters.maxPrice === null ||
|
|
(price !== null && price <= filters.maxPrice)) &&
|
|
((!filters.region && !filters.line) || entry.lines.some(matchesLine)) &&
|
|
(!filters.cpu || entry.model.cpu === filters.cpu) &&
|
|
(!filters.memory || entry.model.memory === filters.memory) &&
|
|
(!filters.disk || entry.model.disk === filters.disk)
|
|
);
|
|
});
|
|
if (sortBy.value === "default") return list;
|
|
const direction = sortBy.value === "price-asc" ? 1 : -1;
|
|
// 无有效价格的商品始终排在末尾,避免被当作 0 元置顶。
|
|
return [...list].sort((a, b) => {
|
|
const pa = priceOf(a);
|
|
const pb = priceOf(b);
|
|
if (pa === null) return pb === null ? 0 : 1;
|
|
if (pb === null) return -1;
|
|
return (pa - pb) * direction;
|
|
});
|
|
});
|
|
const visibleLines = computed(() =>
|
|
(selected.value?.lines ?? []).filter(matchesLine),
|
|
);
|
|
const extraFilterCount = computed(
|
|
() =>
|
|
specFilters.filter(({ key }) => filters[key]).length +
|
|
(filters.minPrice !== null || filters.maxPrice !== null ? 1 : 0),
|
|
);
|
|
const hasActiveFilters = computed(
|
|
() => Boolean(filters.region || filters.line) || extraFilterCount.value > 0,
|
|
);
|
|
|
|
onBeforeUnmount(() => {
|
|
disposed = true;
|
|
listGeneration++;
|
|
lineGeneration++;
|
|
});
|
|
|
|
function resetFilters() {
|
|
Object.assign(filters, {
|
|
minPrice: null,
|
|
maxPrice: null,
|
|
region: "",
|
|
line: "",
|
|
cpu: "",
|
|
memory: "",
|
|
disk: "",
|
|
});
|
|
}
|
|
|
|
// 商品库存与自动同步的机型库存都满足时才允许选购。
|
|
function inStock(entry) {
|
|
const stock = productStock(entry.product, entry.model);
|
|
return stock === null || stock > 0;
|
|
}
|
|
|
|
function stockLabel(entry) {
|
|
if (!entry.lines.length) return "暂无可选线路";
|
|
if (!inStock(entry)) return "库存不足";
|
|
const stock = productStock(entry.product, entry.model);
|
|
return stock === null ? "" : `剩余 ${stock} 台`;
|
|
}
|
|
|
|
function locationSummary(entry) {
|
|
const locations = unique(entry.lines.map((line) => line.location));
|
|
if (!locations.length) return "";
|
|
return locations.length > 1
|
|
? `${locations[0]} 等 ${locations.length} 个地区`
|
|
: locations[0];
|
|
}
|
|
|
|
// 分页收集商品后并行加载物理模块配置;单个商品失败保留其余可用项并提示部分加载失败。
|
|
async function load() {
|
|
const generation = ++listGeneration;
|
|
lineGeneration++;
|
|
selectedKey.value = "";
|
|
lineDetails.value = {};
|
|
loading.value = true;
|
|
error.value = "";
|
|
partialError.value = "";
|
|
try {
|
|
const products = [];
|
|
for (let page = 1; ; page++) {
|
|
const result = await api.get("/product", { page, limit: 50 });
|
|
if (disposed || generation !== listGeneration) return;
|
|
const list = result.list ?? [];
|
|
products.push(...list);
|
|
if (
|
|
!list.length ||
|
|
products.length >= Number(result.count ?? products.length)
|
|
)
|
|
break;
|
|
}
|
|
const candidates = products.flatMap((product) => {
|
|
const module = dcimModule(product);
|
|
return product.hidden !== 1 && productId(product.id) !== null && module
|
|
? [{ product: { ...product, module }, module }]
|
|
: [];
|
|
});
|
|
const pages = await Promise.allSettled(
|
|
candidates.map(async ({ product, module }) => ({
|
|
product,
|
|
page: await api.get(`/product/${product.id}/${module}/order_page`),
|
|
})),
|
|
);
|
|
if (disposed || generation !== listGeneration) return;
|
|
entries.value = pages.flatMap((settled) => {
|
|
if (settled.status !== "fulfilled") return [];
|
|
const { product, page } = settled.value;
|
|
const lines = (page.data_center ?? []).flatMap((center) =>
|
|
(center.city ?? []).flatMap((city) =>
|
|
(city.area ?? []).flatMap((area) =>
|
|
(area.line ?? []).map((line) => ({
|
|
...line,
|
|
location: [city.name, area.name].filter(Boolean).join(" / "),
|
|
country: center.name,
|
|
})),
|
|
),
|
|
),
|
|
);
|
|
return (page.model_config ?? []).map((model) => ({
|
|
product,
|
|
model,
|
|
lines,
|
|
}));
|
|
});
|
|
if (pages.some((settled) => settled.status === "rejected"))
|
|
partialError.value =
|
|
"部分物理服务器配置暂时无法加载,请重试以获取完整列表。";
|
|
} catch (err) {
|
|
if (!disposed && generation === listGeneration) {
|
|
entries.value = [];
|
|
error.value =
|
|
err instanceof Error ? err.message : "加载物理服务器商品失败";
|
|
}
|
|
} finally {
|
|
if (!disposed && generation === listGeneration) loading.value = false;
|
|
}
|
|
}
|
|
|
|
// 按当前机型读取各线路详情,快速改选时只接纳最新选择的结果。
|
|
async function select(entry) {
|
|
selectedKey.value = keyOf(entry);
|
|
const generation = ++lineGeneration;
|
|
lineDetails.value = {};
|
|
lineError.value = "";
|
|
lineLoading.value = true;
|
|
const module = dcimModule(entry.product) || "mf_dcim";
|
|
const results = await Promise.allSettled(
|
|
entry.lines.map(async (line) => ({
|
|
id: line.id,
|
|
detail: await api.get(
|
|
`/product/${entry.product.id}/${module}/line/${line.id}`,
|
|
),
|
|
})),
|
|
);
|
|
if (disposed || generation !== lineGeneration) return;
|
|
for (const result of results)
|
|
if (result.status === "fulfilled")
|
|
lineDetails.value[result.value.id] = result.value.detail;
|
|
if (results.some((result) => result.status === "rejected"))
|
|
lineError.value = "部分线路详情加载失败,可重试或进入配置页面重新加载。";
|
|
lineLoading.value = false;
|
|
}
|
|
|
|
function closeSelection() {
|
|
lineGeneration++;
|
|
selectedKey.value = "";
|
|
lineDetails.value = {};
|
|
lineLoading.value = false;
|
|
lineError.value = "";
|
|
}
|
|
|
|
function toggle(entry) {
|
|
if (selectedKey.value === keyOf(entry)) closeSelection();
|
|
else select(entry);
|
|
}
|
|
|
|
// 只有明确有效的商品价格才显示金额,缺失时提示配置后确认。
|
|
function formatPrice(raw) {
|
|
return raw !== undefined &&
|
|
String(raw).trim() !== "" &&
|
|
Number.isFinite(Number(raw))
|
|
? `¥ ${Number(raw).toFixed(2)}`
|
|
: "价格待配置";
|
|
}
|
|
|
|
function lineBillType(line) {
|
|
return lineDetails.value[line.id]?.bill_type || line.bill_type;
|
|
}
|
|
|
|
function lineNetwork(line) {
|
|
const detail = lineDetails.value[line.id];
|
|
return detail?.bill_type === "flow"
|
|
? {
|
|
label: "流量",
|
|
value: `${resourceOptionValue(detail?.flow?.[0]) ?? "—"} GB`,
|
|
}
|
|
: {
|
|
label: "带宽",
|
|
value: `${resourceOptionValue(detail?.bw?.[0]) ?? "—"} Mbps`,
|
|
};
|
|
}
|
|
|
|
// 定制链接携带当前商品、机型及所选线路 ID,最终仍由配置页加载并验证商品。
|
|
function customizeLink(lineId) {
|
|
return `/products/dedicated-server/customize?id=${selected.value?.product.id}&model_id=${selected.value?.model.id}&line_id=${lineId}`;
|
|
}
|
|
|
|
onMounted(load);
|
|
</script>
|
|
|
|
<template>
|
|
<div class="dedicated-catalog">
|
|
<header class="catalog-head">
|
|
<h1>物理服务器</h1>
|
|
<NuxtLink to="/products">查看全部产品</NuxtLink>
|
|
</header>
|
|
|
|
<section class="catalog-filters" aria-label="筛选">
|
|
<div
|
|
v-for="group in [
|
|
{ key: 'region', label: '地区' },
|
|
{ key: 'line', label: '线路' },
|
|
]"
|
|
:key="group.key"
|
|
class="filter-row"
|
|
>
|
|
<span class="filter-label">{{ group.label }}</span>
|
|
<div class="filter-chips" role="group" :aria-label="group.label">
|
|
<button
|
|
type="button"
|
|
:class="{ active: !filters[group.key] }"
|
|
:aria-pressed="!filters[group.key]"
|
|
@click="filters[group.key] = ''"
|
|
>
|
|
全部
|
|
</button>
|
|
<button
|
|
v-for="value in options[group.key]"
|
|
:key="value"
|
|
type="button"
|
|
:class="{ active: filters[group.key] === value }"
|
|
:aria-pressed="filters[group.key] === value"
|
|
@click="filters[group.key] = value"
|
|
>
|
|
{{ value }}
|
|
</button>
|
|
<span
|
|
v-if="!loading && !options[group.key].length"
|
|
class="filter-empty"
|
|
>
|
|
暂无可选项
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="filter-row filter-more" :class="{ open: moreFiltersOpen }">
|
|
<span class="filter-label">规格</span>
|
|
<div class="filter-fields">
|
|
<Select
|
|
v-for="spec in specFilters"
|
|
:key="spec.key"
|
|
v-model="filters[spec.key]"
|
|
:options="selectOptions[spec.key]"
|
|
option-label="label"
|
|
option-value="value"
|
|
:placeholder="`全部${spec.label}`"
|
|
:aria-label="spec.label"
|
|
class="filter-select"
|
|
/>
|
|
<div class="price-range">
|
|
<InputNumber
|
|
v-model="filters.minPrice"
|
|
:input-id="`${fieldId}-min-price`"
|
|
aria-label="最低商品起价"
|
|
placeholder="最低价"
|
|
:min="0"
|
|
:min-fraction-digits="0"
|
|
:max-fraction-digits="2"
|
|
/>
|
|
<span aria-hidden="true">—</span>
|
|
<InputNumber
|
|
v-model="filters.maxPrice"
|
|
:input-id="`${fieldId}-max-price`"
|
|
aria-label="最高商品起价"
|
|
placeholder="最高价"
|
|
:min="0"
|
|
:max-fraction-digits="2"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="catalog-toolbar">
|
|
<h2>{{ loading ? "正在加载产品" : `${filtered.length} 款产品` }}</h2>
|
|
<Button
|
|
class="more-toggle"
|
|
:label="
|
|
extraFilterCount ? `更多筛选 (${extraFilterCount})` : '更多筛选'
|
|
"
|
|
icon="pi pi-sliders-h"
|
|
severity="secondary"
|
|
outlined
|
|
size="small"
|
|
:aria-expanded="moreFiltersOpen"
|
|
@click="moreFiltersOpen = !moreFiltersOpen"
|
|
/>
|
|
<Button
|
|
v-if="hasActiveFilters"
|
|
label="清除筛选"
|
|
severity="secondary"
|
|
text
|
|
size="small"
|
|
@click="resetFilters"
|
|
/>
|
|
<Select
|
|
v-model="sortBy"
|
|
:options="sortOptions"
|
|
option-label="label"
|
|
option-value="value"
|
|
aria-label="排序"
|
|
size="small"
|
|
class="sort-select"
|
|
/>
|
|
</div>
|
|
|
|
<section class="catalog-products">
|
|
<div v-if="loading" class="catalog-skeleton">
|
|
<Skeleton v-for="n in 5" :key="n" height="72px" />
|
|
</div>
|
|
|
|
<div v-else-if="error" class="catalog-state">
|
|
<Message severity="error" :closable="false">{{ error }}</Message>
|
|
<Button label="重新加载商品" icon="pi pi-refresh" @click="load" />
|
|
</div>
|
|
|
|
<template v-else>
|
|
<Message v-if="partialError" severity="warn" :closable="false">
|
|
{{ partialError }}
|
|
<Button label="重试" text @click="load" />
|
|
</Message>
|
|
|
|
<div v-if="filtered.length" class="hardware-list">
|
|
<div class="hardware-columns" aria-hidden="true">
|
|
<span>机型</span>
|
|
<span>处理器</span>
|
|
<span>内存</span>
|
|
<span>硬盘</span>
|
|
<span class="col-price">起价</span>
|
|
<span />
|
|
</div>
|
|
|
|
<article
|
|
v-for="entry in filtered"
|
|
:key="keyOf(entry)"
|
|
class="hardware-card"
|
|
:class="{
|
|
selected: selectedKey === keyOf(entry),
|
|
unavailable: !inStock(entry) || !entry.lines.length,
|
|
}"
|
|
>
|
|
<div class="hardware-row">
|
|
<div class="hw-name">
|
|
<strong class="hw-product">{{
|
|
displayProductName(entry.product.name)
|
|
}}</strong>
|
|
<h3>{{ entry.model.name }}</h3>
|
|
<div class="hw-meta">
|
|
<span v-if="locationSummary(entry)">
|
|
<i class="pi pi-map-marker" aria-hidden="true" />{{
|
|
locationSummary(entry)
|
|
}}
|
|
</span>
|
|
<span
|
|
v-if="stockLabel(entry)"
|
|
class="hw-stock"
|
|
:class="{ warn: !inStock(entry) || !entry.lines.length }"
|
|
>{{ stockLabel(entry) }}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
<div class="hw-spec hw-cpu">
|
|
<span class="hw-label">处理器</span>
|
|
<strong>{{ entry.model.cpu || "未提供处理器信息" }}</strong>
|
|
<small v-if="entry.model.cpu_param">{{
|
|
entry.model.cpu_param
|
|
}}</small>
|
|
</div>
|
|
<div class="hw-spec hw-mem">
|
|
<span class="hw-label">内存</span>
|
|
<strong>{{ entry.model.memory || "未提供内存信息" }}</strong>
|
|
</div>
|
|
<div class="hw-spec hw-disk">
|
|
<span class="hw-label">硬盘</span>
|
|
<strong>{{ entry.model.disk || "未提供硬盘信息" }}</strong>
|
|
<small v-if="entry.model.gpu">{{ entry.model.gpu }}</small>
|
|
</div>
|
|
<div class="hw-price">
|
|
<strong>{{ formatPrice(entry.product.price) }}</strong>
|
|
<small v-if="entry.product.cycle"
|
|
>/ {{ entry.product.cycle }}起</small
|
|
>
|
|
</div>
|
|
<div class="hw-action">
|
|
<Button
|
|
:label="
|
|
!inStock(entry)
|
|
? '库存不足'
|
|
: selectedKey === keyOf(entry)
|
|
? '收起线路'
|
|
: '选择线路'
|
|
"
|
|
:icon="
|
|
inStock(entry) && entry.lines.length
|
|
? selectedKey === keyOf(entry)
|
|
? 'pi pi-chevron-up'
|
|
: 'pi pi-chevron-down'
|
|
: undefined
|
|
"
|
|
icon-pos="right"
|
|
:severity="
|
|
selectedKey === keyOf(entry) ? 'secondary' : undefined
|
|
"
|
|
:outlined="selectedKey === keyOf(entry)"
|
|
:disabled="!inStock(entry) || !entry.lines.length"
|
|
:aria-expanded="selectedKey === keyOf(entry)"
|
|
@click="toggle(entry)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<section
|
|
v-if="selectedKey === keyOf(entry)"
|
|
class="catalog-lines"
|
|
aria-label="可选线路"
|
|
>
|
|
<Message v-if="lineError" severity="warn" :closable="false">
|
|
{{ lineError }}
|
|
<Button label="重试线路" text @click="select(entry)" />
|
|
</Message>
|
|
<article
|
|
v-for="line in visibleLines"
|
|
:key="line.id"
|
|
class="line-card"
|
|
>
|
|
<div class="line-tags">
|
|
<span class="line-location">{{ line.location }}</span>
|
|
<span class="line-name">{{ line.name }}</span>
|
|
</div>
|
|
<Skeleton
|
|
v-if="lineLoading"
|
|
height="40px"
|
|
class="line-specs-skeleton"
|
|
/>
|
|
<dl v-else class="line-specs">
|
|
<div>
|
|
<dt>计费方式</dt>
|
|
<dd>
|
|
{{
|
|
lineBillType(line) === "flow" ? "流量计费" : "固定带宽"
|
|
}}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{{ lineNetwork(line).label }}</dt>
|
|
<dd>{{ lineNetwork(line).value }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>防护</dt>
|
|
<dd>
|
|
{{
|
|
lineDetails[line.id]?.defence?.[0]?.desc || "按配置提供"
|
|
}}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
<div class="line-action">
|
|
<Button as-child v-slot="slotProps">
|
|
<NuxtLink
|
|
:to="customizeLink(line.id)"
|
|
:class="[slotProps.class, 'line-order-link']"
|
|
>
|
|
<i
|
|
class="pi pi-shopping-cart"
|
|
aria-hidden="true"
|
|
/>立即订购
|
|
</NuxtLink>
|
|
</Button>
|
|
</div>
|
|
</article>
|
|
<p v-if="!visibleLines.length" class="line-empty">
|
|
当前筛选下没有可选线路。
|
|
</p>
|
|
<p class="line-note">最终费用以配置报价为准</p>
|
|
</section>
|
|
</article>
|
|
</div>
|
|
|
|
<div v-else class="catalog-state">
|
|
<i class="pi pi-server" aria-hidden="true" />
|
|
<h3>
|
|
{{
|
|
entries.length
|
|
? "没有符合筛选条件的物理服务器"
|
|
: partialError
|
|
? "暂时无法展示物理服务器配置"
|
|
: "暂无可订购的物理服务器"
|
|
}}
|
|
</h3>
|
|
<p>
|
|
{{
|
|
entries.length
|
|
? "请调整筛选条件后再试。"
|
|
: "当前商品目录未提供可售型号,可稍后刷新或查看其他产品。"
|
|
}}
|
|
</p>
|
|
<Button
|
|
v-if="entries.length"
|
|
label="清除筛选"
|
|
severity="secondary"
|
|
outlined
|
|
@click="resetFilters"
|
|
/>
|
|
<NuxtLink v-else to="/products">浏览产品</NuxtLink>
|
|
</div>
|
|
</template>
|
|
</section>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.dedicated-catalog {
|
|
width: 100%;
|
|
max-width: 1280px;
|
|
margin: 0 auto;
|
|
padding-bottom: 28px;
|
|
}
|
|
.catalog-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 14px;
|
|
margin-bottom: 20px;
|
|
}
|
|
.catalog-head h1 {
|
|
margin: 0;
|
|
font-size: 1.375rem;
|
|
font-weight: 400;
|
|
}
|
|
.catalog-head a {
|
|
color: var(--p-text-muted-color);
|
|
font-size: 0.875rem;
|
|
text-decoration: none;
|
|
}
|
|
.catalog-head a:hover {
|
|
color: var(--p-primary-color);
|
|
}
|
|
|
|
.catalog-filters {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
.filter-row {
|
|
display: grid;
|
|
grid-template-columns: 88px minmax(0, 1fr);
|
|
align-items: start;
|
|
gap: 8px 16px;
|
|
}
|
|
.filter-label {
|
|
color: var(--p-text-muted-color);
|
|
font-size: 0.9375rem;
|
|
line-height: 34px;
|
|
}
|
|
/* 统一选择控件:与云服务器选择器、商品目录分类使用同一套胶囊按钮规格。 */
|
|
.filter-chips {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
}
|
|
.filter-chips button {
|
|
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;
|
|
cursor: pointer;
|
|
}
|
|
.filter-chips button:hover {
|
|
border-color: var(--p-primary-color);
|
|
}
|
|
.filter-chips button.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;
|
|
}
|
|
.filter-chips button:focus-visible {
|
|
outline: 2px solid var(--p-primary-color);
|
|
outline-offset: 2px;
|
|
}
|
|
.filter-empty {
|
|
color: var(--p-text-muted-color);
|
|
font-size: 0.875rem;
|
|
line-height: 34px;
|
|
}
|
|
.filter-fields {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr)) minmax(0, 1.3fr);
|
|
gap: 10px;
|
|
}
|
|
.filter-select {
|
|
min-width: 0;
|
|
}
|
|
.price-range {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
min-width: 0;
|
|
}
|
|
.price-range :deep(.p-inputnumber) {
|
|
flex: 1;
|
|
width: 0;
|
|
}
|
|
.price-range :deep(input) {
|
|
width: 100%;
|
|
min-width: 0;
|
|
}
|
|
|
|
.catalog-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin: 20px 0 12px;
|
|
}
|
|
.catalog-toolbar h2 {
|
|
margin: 0 auto 0 0;
|
|
font-size: 1rem;
|
|
font-weight: 500;
|
|
}
|
|
.more-toggle {
|
|
display: none;
|
|
}
|
|
.sort-select {
|
|
width: 148px;
|
|
}
|
|
|
|
.catalog-skeleton {
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
.hardware-list {
|
|
border: 1px solid var(--p-content-border-color);
|
|
background: var(--p-content-background);
|
|
border-radius: var(--radius-md);
|
|
overflow: hidden;
|
|
}
|
|
.hardware-columns,
|
|
.hardware-row {
|
|
display: grid;
|
|
grid-template-columns:
|
|
minmax(0, 1.5fr) minmax(0, 1.35fr) minmax(0, 0.9fr) minmax(0, 0.9fr)
|
|
120px 124px;
|
|
align-items: center;
|
|
gap: 16px;
|
|
padding: 0 16px;
|
|
}
|
|
.hardware-columns {
|
|
color: var(--p-text-muted-color);
|
|
background: var(--p-content-hover-background);
|
|
border-bottom: 1px solid var(--p-content-border-color);
|
|
font-size: 0.8125rem;
|
|
min-height: 38px;
|
|
}
|
|
.col-price {
|
|
text-align: right;
|
|
}
|
|
.hardware-card {
|
|
border-bottom: 1px solid var(--p-content-border-color);
|
|
transition: background-color 0.15s;
|
|
}
|
|
.hardware-card:last-child {
|
|
border-bottom: 0;
|
|
}
|
|
.hardware-card:hover {
|
|
background: var(--p-content-hover-background);
|
|
}
|
|
.hardware-card.selected {
|
|
background: var(--p-highlight-background);
|
|
box-shadow: inset 3px 0 0 var(--p-primary-color);
|
|
}
|
|
.hardware-row {
|
|
min-height: 76px;
|
|
padding-top: 12px;
|
|
padding-bottom: 12px;
|
|
}
|
|
.hw-name,
|
|
.hw-spec {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
min-width: 0;
|
|
}
|
|
.hw-product {
|
|
font-size: 0.9375rem;
|
|
font-weight: 600;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.hw-name h3 {
|
|
margin: 0;
|
|
color: var(--p-text-muted-color);
|
|
font-size: 0.8125rem;
|
|
font-weight: 400;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.hw-meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 4px 10px;
|
|
margin-top: 2px;
|
|
color: var(--p-text-muted-color);
|
|
font-size: 0.75rem;
|
|
}
|
|
.hw-meta i {
|
|
margin-right: 3px;
|
|
font-size: 0.7rem;
|
|
}
|
|
.hw-stock {
|
|
color: var(--p-primary-color);
|
|
}
|
|
.hw-stock.warn {
|
|
color: var(--p-red-500, #dc2626);
|
|
}
|
|
.hw-label {
|
|
display: none;
|
|
color: var(--p-text-muted-color);
|
|
font-size: 0.75rem;
|
|
}
|
|
.hw-spec strong {
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.hw-spec small {
|
|
color: var(--p-text-muted-color);
|
|
font-size: 0.75rem;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.hw-price {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-end;
|
|
text-align: right;
|
|
}
|
|
.hw-price strong {
|
|
color: var(--p-primary-color);
|
|
font-size: 1.125rem;
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
}
|
|
.hw-price small {
|
|
color: var(--p-text-muted-color);
|
|
font-size: 0.75rem;
|
|
}
|
|
.hw-action {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
}
|
|
.hw-action :deep(.p-button) {
|
|
width: 100%;
|
|
white-space: nowrap;
|
|
}
|
|
.hardware-card.unavailable .hw-product,
|
|
.hardware-card.unavailable .hw-spec strong,
|
|
.hardware-card.unavailable .hw-price strong {
|
|
color: var(--p-text-muted-color);
|
|
}
|
|
|
|
.catalog-lines {
|
|
padding: 0 16px 14px;
|
|
}
|
|
.line-card {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1.5fr) minmax(0, 3fr) 124px;
|
|
align-items: center;
|
|
gap: 16px;
|
|
padding: 12px 14px;
|
|
border: 1px solid var(--p-content-border-color);
|
|
background: var(--p-content-background);
|
|
border-radius: var(--radius-md);
|
|
}
|
|
.line-card + .line-card {
|
|
margin-top: 8px;
|
|
}
|
|
.line-tags {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
min-width: 0;
|
|
}
|
|
.line-location {
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.line-name {
|
|
color: var(--p-text-muted-color);
|
|
font-size: 0.8125rem;
|
|
}
|
|
.line-specs {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 12px;
|
|
margin: 0;
|
|
}
|
|
.line-specs dt {
|
|
color: var(--p-text-muted-color);
|
|
font-size: 0.75rem;
|
|
}
|
|
.line-specs dd {
|
|
margin: 0;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.line-action {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
}
|
|
.line-order-link {
|
|
display: inline-flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
gap: 7px;
|
|
width: 100%;
|
|
white-space: nowrap;
|
|
text-decoration: none;
|
|
}
|
|
.line-empty,
|
|
.line-note {
|
|
margin: 10px 0 0;
|
|
color: var(--p-text-muted-color);
|
|
font-size: 0.8125rem;
|
|
}
|
|
.catalog-lines :deep(.p-message) {
|
|
margin-bottom: 8px;
|
|
}
|
|
.catalog-products > :deep(.p-message) {
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.catalog-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 14px;
|
|
min-height: 260px;
|
|
padding: 70px 21px;
|
|
text-align: center;
|
|
border: 1px solid var(--p-content-border-color);
|
|
background: var(--p-content-background);
|
|
border-radius: var(--radius-md);
|
|
}
|
|
.catalog-state > i {
|
|
color: var(--p-text-muted-color);
|
|
font-size: 2rem;
|
|
}
|
|
.catalog-state h3 {
|
|
margin: 0;
|
|
font-size: 1.125rem;
|
|
}
|
|
.catalog-state p {
|
|
margin: 0;
|
|
color: var(--p-text-muted-color);
|
|
font-size: 0.9375rem;
|
|
}
|
|
|
|
@media (width < 1024px) {
|
|
.hardware-columns {
|
|
display: none;
|
|
}
|
|
.hardware-row {
|
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
|
|
grid-template-areas:
|
|
"name name name"
|
|
"cpu mem disk"
|
|
"price price action";
|
|
gap: 10px 16px;
|
|
padding: 14px 16px;
|
|
}
|
|
.hw-name {
|
|
grid-area: name;
|
|
}
|
|
.hw-cpu {
|
|
grid-area: cpu;
|
|
}
|
|
.hw-mem {
|
|
grid-area: mem;
|
|
}
|
|
.hw-disk {
|
|
grid-area: disk;
|
|
}
|
|
.hw-price {
|
|
grid-area: price;
|
|
flex-direction: row;
|
|
align-items: baseline;
|
|
gap: 4px;
|
|
}
|
|
.hw-action {
|
|
grid-area: action;
|
|
}
|
|
.hw-label {
|
|
display: block;
|
|
}
|
|
.line-card {
|
|
grid-template-columns: minmax(0, 1fr) 124px;
|
|
}
|
|
.line-specs {
|
|
grid-column: 1 / -1;
|
|
grid-row: 2;
|
|
}
|
|
}
|
|
|
|
@media (width < 768px) {
|
|
.filter-row {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
gap: 6px;
|
|
}
|
|
.filter-label {
|
|
line-height: 1.5;
|
|
}
|
|
.filter-chips {
|
|
flex-wrap: nowrap;
|
|
overflow-x: auto;
|
|
scrollbar-width: none;
|
|
margin-right: -16px;
|
|
padding-right: 16px;
|
|
}
|
|
.filter-chips::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
.filter-chips button {
|
|
flex: none;
|
|
white-space: nowrap;
|
|
}
|
|
.filter-more {
|
|
display: none;
|
|
}
|
|
.filter-more.open {
|
|
display: grid;
|
|
}
|
|
.filter-fields {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
.more-toggle {
|
|
display: inline-flex;
|
|
}
|
|
.sort-select {
|
|
width: 128px;
|
|
}
|
|
.catalog-toolbar {
|
|
flex-wrap: wrap;
|
|
}
|
|
.hardware-row {
|
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
grid-template-areas:
|
|
"name name"
|
|
"cpu cpu"
|
|
"mem disk"
|
|
"price action";
|
|
gap: 8px 12px;
|
|
padding: 12px;
|
|
}
|
|
.hw-cpu {
|
|
flex-flow: row wrap;
|
|
align-items: baseline;
|
|
column-gap: 6px;
|
|
}
|
|
.hw-cpu .hw-label {
|
|
flex-basis: 100%;
|
|
}
|
|
.line-card {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
.line-specs {
|
|
grid-row: auto;
|
|
}
|
|
.catalog-state {
|
|
padding: 42px 14px;
|
|
}
|
|
}
|
|
</style>
|