feat(ui): adopt Tailwind v4 design system and archive change
- Add tailwindcss v4 + @tailwindcss/vite to mall, shop-admin, admin - Add @vmall/shared/theme.css tokens with html[data-accent] presets - Add @vmall/ui kit (VBtn/VBadge/VField/VInput/VCard/VPanel/VTable/VPage, VAccentSwatch, useAccent) as a Nuxt module - Convert all three apps to kit + utilities; delete ui.css/mall.css and every <style scoped>; consoles get accent presets, mall locked to red - Fix VCard boolean prop default (padding) and PDP/store stale useAsyncData keys on param navigation - Archive adopt-tailwind-design-system; new frontend-ui capability spec
This commit is contained in:
@@ -3,33 +3,11 @@ defineProps<{ items: { label: string; to?: string }[] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="crumbs w1200" aria-label="breadcrumb">
|
||||
<nav aria-label="breadcrumb" class="mx-auto my-5 max-w-mall px-4 text-[13px] text-muted">
|
||||
<template v-for="(item, i) in items" :key="i">
|
||||
<NuxtLink v-if="item.to" :to="item.to">{{ item.label }}</NuxtLink>
|
||||
<span v-else class="here">{{ item.label }}</span>
|
||||
<span v-if="i < items.length - 1" class="sep">/</span>
|
||||
<NuxtLink v-if="item.to" :to="item.to" class="no-underline transition-colors hover:text-primary">{{ item.label }}</NuxtLink>
|
||||
<span v-else class="text-text">{{ item.label }}</span>
|
||||
<span v-if="i < items.length - 1" class="mx-2 text-muted/60">/</span>
|
||||
</template>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.crumbs {
|
||||
margin: 20px auto;
|
||||
font-size: 13px;
|
||||
color: var(--mall-muted);
|
||||
}
|
||||
a {
|
||||
color: var(--mall-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
color: var(--mall-red);
|
||||
}
|
||||
.sep {
|
||||
margin: 0 8px;
|
||||
color: var(--mall-faint);
|
||||
}
|
||||
.here {
|
||||
color: var(--mall-ink);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,6 +10,7 @@ let timer: ReturnType<typeof setInterval> | null = null;
|
||||
function go(i: number): void {
|
||||
index.value = (i + props.images.length) % props.images.length;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.images.length > 1) {
|
||||
timer = setInterval(() => {
|
||||
@@ -17,66 +18,32 @@ onMounted(() => {
|
||||
}, props.interval);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="carousel" :style="{ height: `${height}px` }">
|
||||
<div class="relative w-full overflow-hidden" :style="{ height: `${height}px` }">
|
||||
<NuxtLink
|
||||
v-for="(img, i) in images"
|
||||
v-show="i === index"
|
||||
:key="img.image"
|
||||
:to="img.url || '#'"
|
||||
class="slide"
|
||||
class="block h-full w-full bg-cover bg-center"
|
||||
:style="{ backgroundImage: `url(${img.image})` }"
|
||||
/>
|
||||
<div v-if="images.length > 1" class="dots">
|
||||
<div v-if="images.length > 1" class="absolute bottom-[14px] left-0 right-0 flex justify-center gap-2">
|
||||
<button
|
||||
v-for="(_, i) in images"
|
||||
:key="i"
|
||||
type="button"
|
||||
:class="{ on: i === index }"
|
||||
class="h-2.5 w-2.5 rounded-full bg-white/55 p-0 transition-colors"
|
||||
:class="i === index ? 'bg-primary' : 'hover:bg-white/80'"
|
||||
:aria-label="`slide ${i + 1}`"
|
||||
@click="go(i)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.carousel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
.slide {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
}
|
||||
.dots {
|
||||
position: absolute;
|
||||
bottom: 14px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.dots button {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: 0;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.dots button.on {
|
||||
background: var(--mall-red);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,20 +3,8 @@ withDefaults(defineProps<{ text?: string }>(), { text: "" });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="empty">
|
||||
<div class="icon">∅</div>
|
||||
<p>{{ text || $t("common.empty") }}</p>
|
||||
<div class="py-[60px] text-center text-muted/70">
|
||||
<div class="mb-2.5 text-[40px] leading-none">∅</div>
|
||||
<p class="m-0">{{ text || $t("common.empty") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty {
|
||||
padding: 60px 0;
|
||||
text-align: center;
|
||||
color: var(--mall-faint);
|
||||
}
|
||||
.icon {
|
||||
font-size: 40px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,61 +5,24 @@ const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="mask" @click.self="emit('close')">
|
||||
<div class="dialog">
|
||||
<header v-if="title" class="head">
|
||||
<div v-if="open" class="fixed inset-0 z-[900] flex items-center justify-center bg-black/40" @click.self="emit('close')">
|
||||
<div class="min-w-[420px] max-w-[720px] rounded-md bg-surface shadow-lg">
|
||||
<header v-if="title" class="flex items-center justify-between border-b border-border px-5 py-3.5 text-[15px]">
|
||||
<span>{{ title }}</span>
|
||||
<button type="button" class="x" :aria-label="$t('common.cancel')" @click="emit('close')">×</button>
|
||||
<button
|
||||
type="button"
|
||||
class="border-0 bg-transparent text-[20px] text-muted transition-colors hover:text-primary"
|
||||
:aria-label="$t('common.cancel')"
|
||||
@click="emit('close')"
|
||||
>×</button>
|
||||
</header>
|
||||
<div class="body">
|
||||
<div class="p-5">
|
||||
<slot />
|
||||
</div>
|
||||
<footer v-if="$slots.footer" class="foot">
|
||||
<footer v-if="$slots.footer" class="border-t border-border px-5 py-3 text-right">
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 900;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.dialog {
|
||||
background: #fff;
|
||||
min-width: 420px;
|
||||
max-width: 720px;
|
||||
border-radius: 4px;
|
||||
box-shadow: var(--mall-shadow);
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--mall-line);
|
||||
font-size: 15px;
|
||||
}
|
||||
.x {
|
||||
border: 0;
|
||||
background: none;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
color: var(--mall-muted);
|
||||
}
|
||||
.body {
|
||||
padding: 20px;
|
||||
}
|
||||
.foot {
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--mall-line);
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,42 +18,26 @@ function go(p: number): void {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav v-if="pages > 1" class="pager">
|
||||
<button type="button" :disabled="page <= 1" @click="go(page - 1)">{{ $t("common.prev") }}</button>
|
||||
<nav v-if="pages > 1" class="my-[30px] flex justify-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="h-[34px] min-w-[34px] rounded-sm border border-border bg-surface px-2 text-[13px] transition-colors disabled:cursor-not-allowed disabled:opacity-40"
|
||||
:disabled="page <= 1"
|
||||
@click="go(page - 1)"
|
||||
>{{ $t("common.prev") }}</button>
|
||||
<button
|
||||
v-for="p in window_"
|
||||
:key="p"
|
||||
type="button"
|
||||
:class="{ on: p === page }"
|
||||
class="h-[34px] min-w-[34px] rounded-sm border border-border px-2 text-[13px] transition-colors"
|
||||
:class="p === page ? 'border-primary bg-primary text-white' : 'bg-surface hover:border-primary hover:text-primary'"
|
||||
@click="go(p)"
|
||||
>{{ p }}</button>
|
||||
<button type="button" :disabled="page >= pages" @click="go(page + 1)">{{ $t("common.next") }}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-[34px] min-w-[34px] rounded-sm border border-border bg-surface px-2 text-[13px] transition-colors disabled:cursor-not-allowed disabled:opacity-40"
|
||||
:disabled="page >= pages"
|
||||
@click="go(page + 1)"
|
||||
>{{ $t("common.next") }}</button>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pager {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin: 30px 0;
|
||||
}
|
||||
button {
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid var(--mall-line-dark);
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--mall-radius);
|
||||
}
|
||||
button.on {
|
||||
background: var(--mall-red);
|
||||
border-color: var(--mall-red);
|
||||
color: #fff;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,78 +12,36 @@ const sold = computed(() => props.product.sold_count);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink :to="`/goods/${product.slug}`" class="card hover-lift">
|
||||
<div class="img">
|
||||
<img :src="product.images[0] || '/mock/product-1.svg'" :alt="pick(product.name, locale)" loading="lazy" />
|
||||
</div>
|
||||
<div class="body">
|
||||
<p class="name">{{ pick(product.name, locale) }}</p>
|
||||
<p class="sub">{{ pick(product.description, locale) }}</p>
|
||||
<p class="price-row">
|
||||
<span v-if="sku" class="price"><PriceText :amount-minor="sku.price_minor" :currency="sku.currency" /></span>
|
||||
<s v-if="sku" class="market"><PriceText :amount-minor="sku.price_minor + 10000" :currency="sku.currency" /></s>
|
||||
</p>
|
||||
<p class="meta">{{ $t("product.salesCount", { n: sold }) }}</p>
|
||||
</div>
|
||||
<NuxtLink
|
||||
:to="`/goods/${product.slug}`"
|
||||
class="group block h-full no-underline text-inherit transition-shadow duration-200 hover:-translate-y-0.5 hover:shadow-lg"
|
||||
>
|
||||
<VCard :padded="false" class="h-full overflow-hidden border border-border bg-surface">
|
||||
<div class="flex items-center justify-center p-4">
|
||||
<img
|
||||
:src="product.images[0] || '/mock/product-1.svg'"
|
||||
:alt="pick(product.name, locale)"
|
||||
loading="lazy"
|
||||
class="h-[180px] w-[180px] object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div class="px-[14px] pb-[14px]">
|
||||
<p class="m-0 h-[37px] overflow-hidden text-[13px] leading-[1.4] transition-colors group-hover:text-primary">
|
||||
{{ pick(product.name, locale) }}
|
||||
</p>
|
||||
<p class="m-0 mb-2 mt-1 overflow-hidden text-ellipsis whitespace-nowrap text-[12px] text-muted/70">
|
||||
{{ pick(product.description, locale) }}
|
||||
</p>
|
||||
<p class="m-0">
|
||||
<span v-if="sku" class="mr-2 text-[16px] font-bold text-primary">
|
||||
<PriceText :amount-minor="sku.price_minor" :currency="sku.currency" />
|
||||
</span>
|
||||
<s v-if="sku" class="text-[12px] text-muted/60">
|
||||
<PriceText :amount-minor="sku.price_minor + 10000" :currency="sku.currency" />
|
||||
</s>
|
||||
</p>
|
||||
<p class="m-0 mt-1.5 text-[12px] text-muted/70">{{ $t("product.salesCount", { n: sold }) }}</p>
|
||||
</div>
|
||||
</VCard>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
display: block;
|
||||
background: #fff;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border: 1px solid var(--mall-line);
|
||||
}
|
||||
.img {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
.img img {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.body {
|
||||
padding: 0 14px 14px;
|
||||
}
|
||||
.name {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
height: 37px;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
}
|
||||
.card:hover .name {
|
||||
color: var(--mall-red);
|
||||
}
|
||||
.sub {
|
||||
font-size: 12px;
|
||||
color: var(--mall-faint);
|
||||
margin: 4px 0 8px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.price-row {
|
||||
margin: 0;
|
||||
}
|
||||
.price {
|
||||
color: var(--mall-red);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.market {
|
||||
color: var(--mall-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
.meta {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--mall-faint);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,49 +9,26 @@ function set(v: number): void {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stepper">
|
||||
<button type="button" :disabled="modelValue <= 1" @click="set(modelValue - 1)">−</button>
|
||||
<div class="inline-flex overflow-hidden rounded-md border border-border">
|
||||
<button
|
||||
type="button"
|
||||
class="h-8 w-[30px] border-0 bg-bg text-sm transition-colors hover:text-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
:disabled="modelValue <= 1"
|
||||
@click="set(modelValue - 1)"
|
||||
>−</button>
|
||||
<input
|
||||
:value="modelValue"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="max"
|
||||
class="h-8 w-12 [appearance:textfield] border-x border-border bg-surface text-center text-[13px] text-text outline-none focus:border-primary"
|
||||
@change="set(Number(($event.target as HTMLInputElement).value))"
|
||||
/>
|
||||
<button type="button" :disabled="modelValue >= max" @click="set(modelValue + 1)">+</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-8 w-[30px] border-0 bg-bg text-sm transition-colors hover:text-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
:disabled="modelValue >= max"
|
||||
@click="set(modelValue + 1)"
|
||||
>+</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stepper {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--mall-line-dark);
|
||||
border-radius: var(--mall-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
button {
|
||||
width: 30px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
background: #fafafa;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
input {
|
||||
width: 48px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-left: 1px solid var(--mall-line);
|
||||
border-right: 1px solid var(--mall-line);
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
input::-webkit-inner-spin-button {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,16 +4,7 @@ const stars = computed(() => [1, 2, 3, 4, 5].map((i) => i <= Math.round(props.va
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="stars" :style="{ fontSize: `${size}px` }">
|
||||
<span v-for="(on, i) in stars" :key="i" :class="{ on }">★</span>
|
||||
<span :style="{ fontSize: `${size}px` }">
|
||||
<span v-for="(on, i) in stars" :key="i" :class="on ? 'text-primary' : 'text-[#ddd]'">★</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stars span {
|
||||
color: #ddd;
|
||||
}
|
||||
.stars span.on {
|
||||
color: var(--mall-red);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,48 +3,18 @@ defineProps<{ steps: string[]; active: number }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ol class="steps">
|
||||
<li v-for="(s, i) in steps" :key="s" :class="{ done: i < active, on: i === active }">
|
||||
<span class="n">{{ i + 1 }}</span>
|
||||
<span class="label">{{ s }}</span>
|
||||
<ol class="mb-5 flex list-none border border-border bg-bg py-3.5">
|
||||
<li
|
||||
v-for="(s, i) in steps"
|
||||
:key="s"
|
||||
class="flex-1 text-center text-[13px] text-muted/70"
|
||||
:class="i === active ? 'font-semibold text-primary' : ''"
|
||||
>
|
||||
<span
|
||||
class="mr-2 inline-block h-[22px] w-[22px] rounded-full text-center text-[12px] leading-[22px] text-white"
|
||||
:class="i < active ? 'bg-success' : i === active ? 'bg-primary' : 'bg-[#ddd]'"
|
||||
>{{ i + 1 }}</span>
|
||||
<span>{{ s }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.steps {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
margin: 0 0 20px;
|
||||
padding: 14px 0;
|
||||
background: #fafafa;
|
||||
border: 1px solid var(--mall-line);
|
||||
}
|
||||
li {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
color: var(--mall-faint);
|
||||
font-size: 13px;
|
||||
}
|
||||
.n {
|
||||
display: inline-block;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
line-height: 22px;
|
||||
border-radius: 50%;
|
||||
background: #ddd;
|
||||
color: #fff;
|
||||
margin-right: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
li.on {
|
||||
color: var(--mall-red);
|
||||
font-weight: 600;
|
||||
}
|
||||
li.on .n {
|
||||
background: var(--mall-red);
|
||||
}
|
||||
li.done .n {
|
||||
background: var(--mall-green);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,42 +4,19 @@ const emit = defineEmits<{ "update:modelValue": [key: string] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tabs">
|
||||
<div class="tab-head">
|
||||
<div>
|
||||
<div class="flex border-b border-border">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
:class="{ on: tab.key === modelValue }"
|
||||
class="border-0 border-b-2 border-transparent bg-transparent px-6 py-3 text-sm text-muted transition-colors"
|
||||
:class="tab.key === modelValue ? 'border-primary font-semibold text-primary' : 'hover:text-primary'"
|
||||
@click="emit('update:modelValue', tab.key)"
|
||||
>{{ tab.label }}</button>
|
||||
</div>
|
||||
<div class="tab-body">
|
||||
<div class="py-5">
|
||||
<slot :active="modelValue" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tab-head {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--mall-line);
|
||||
}
|
||||
.tab-head button {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 12px 24px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
color: var(--mall-muted);
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
.tab-head button.on {
|
||||
color: var(--mall-red);
|
||||
border-bottom-color: var(--mall-red);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tab-body {
|
||||
padding: 20px 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user