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

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>