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:
Chengdong Zhang
2026-09-22 18:22:50 +08:00
parent 4d2ee3b0bf
commit 0d0e10b97b
101 changed files with 2833 additions and 7207 deletions
+1 -1
View File
@@ -7,6 +7,6 @@
".": "./src/index.ts",
"./locales": "./src/locales/index.ts",
"./types": "./src/types.ts",
"./ui.css": "./src/ui.css"
"./theme.css": "./src/theme.css"
}
}
+60
View File
@@ -0,0 +1,60 @@
@import "tailwindcss";
/* Kit components live outside each app's Vite root; scan them for utilities. */
@source "../../ui";
/*
* Accent presets: utilities reference var(--primary*) at runtime via
* `@theme inline`, so html[data-accent] swaps the palette without a rebuild.
*/
@theme inline {
--color-primary: var(--primary);
--color-primary-hover: var(--primary-hover);
--color-primary-soft: var(--primary-soft);
}
@theme {
--color-bg: #f5f5f5;
--color-surface: #ffffff;
--color-border: #e2e5ea;
--color-text: #1c2330;
--color-muted: #66707f;
--color-danger: #d64545;
--color-success: #1f9d63;
--color-warning: #c77d0a;
--radius-sm: 4px;
--radius-md: 8px;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
/* Spec-locked mall sizes: max-w-mall, h-hero */
--container-mall: 1200px;
--spacing-hero: 450px;
}
:root,
html[data-accent="red"] {
--primary: #ca151e;
--primary-hover: #a81219;
--primary-soft: #fdecec;
}
html[data-accent="blue"] {
--primary: #2f6fed;
--primary-hover: #2058c8;
--primary-soft: #e7effd;
}
html[data-accent="teal"] {
--primary: #0f8a72;
--primary-hover: #0b6b59;
--primary-soft: #e0f4ef;
}
html[data-accent="violet"] {
--primary: #7c3aed;
--primary-hover: #6425c9;
--primary-soft: #f0e9fd;
}
-159
View File
@@ -1,159 +0,0 @@
:root {
--bg: #f6f7f9;
--surface: #ffffff;
--border: #e2e5ea;
--text: #1c2330;
--muted: #66707f;
--primary: #2f6fed;
--primary-dark: #2058c8;
--danger: #d64545;
--success: #1f9d63;
--warning: #c77d0a;
--radius: 8px;
--shadow: 0 1px 3px rgba(16, 24, 40, 0.08);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Inter", -apple-system, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.55;
}
a { color: var(--primary); text-decoration: none; }
a:hover { text-decoration: underline; }
.container { max-width: 1120px; margin: 0 auto; padding: 0 16px; }
.topnav {
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
}
.topnav-inner {
display: flex;
align-items: center;
gap: 20px;
height: 56px;
}
.topnav .brand { font-weight: 700; font-size: 17px; color: var(--text); }
.topnav nav { display: flex; gap: 14px; flex: 1; }
.topnav nav a { color: var(--muted); font-weight: 500; }
.topnav nav a.router-link-active { color: var(--primary); }
.topnav .spacer { flex: 1; }
select, input, textarea {
font: inherit;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--text);
width: 100%;
}
select { width: auto; }
input:focus, textarea:focus, select:focus { outline: 2px solid rgba(47, 111, 237, 0.3); border-color: var(--primary); }
label { display: block; font-weight: 500; margin-bottom: 4px; color: var(--text); }
.field { margin-bottom: 14px; }
.field-row { display: flex; gap: 12px; }
.field-row .field { flex: 1; }
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-weight: 500;
cursor: pointer;
font-size: 14px;
}
.btn:hover { background: #f0f2f5; }
.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; }
.btn.primary:hover { background: var(--primary-dark); }
.btn.danger { color: var(--danger); border-color: var(--danger); }
.btn.danger:hover { background: #fdf0f0; }
.btn.sm { padding: 4px 10px; font-size: 13px; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 20px;
}
.card + .card { margin-top: 16px; }
.page { padding: 24px 0 48px; }
.page-title { font-size: 20px; font-weight: 700; margin: 0 0 16px; }
.page-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.page-head .page-title { margin: 0; }
table.table { width: 100%; border-collapse: collapse; background: var(--surface); }
.table th, .table td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--border); }
.table th { color: var(--muted); font-weight: 600; font-size: 13px; }
.table tr:last-child td { border-bottom: none; }
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: #eef1f5;
color: var(--muted);
}
.badge.green { background: #e4f6ec; color: var(--success); }
.badge.blue { background: #e7effd; color: var(--primary); }
.badge.orange { background: #fdf1e0; color: var(--warning); }
.badge.red { background: #fbe6e6; color: var(--danger); }
.grid { display: grid; gap: 16px; }
.grid.products { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
.product-card { overflow: hidden; padding: 0; display: flex; flex-direction: column; }
.product-card img { width: 100%; aspect-ratio: 1; object-fit: cover; background: #eef1f5; }
.product-card .body { padding: 12px; }
.product-card .name { font-weight: 600; margin-bottom: 4px; }
.product-card .price { color: var(--primary); font-weight: 700; }
.muted { color: var(--muted); }
.row { display: flex; align-items: center; gap: 12px; }
.between { justify-content: space-between; }
.mt { margin-top: 16px; }
.mb { margin-bottom: 16px; }
.error-text { color: var(--danger); margin: 8px 0; }
.form-narrow { max-width: 420px; margin: 40px auto; }
.auth-layout { display: flex; min-height: 100vh; }
.auth-layout aside {
width: 220px;
background: var(--surface);
border-right: 1px solid var(--border);
padding: 16px 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.auth-layout aside .brand { font-weight: 700; font-size: 16px; padding: 8px 10px 16px; }
.auth-layout aside a {
display: block;
padding: 8px 10px;
border-radius: var(--radius);
color: var(--muted);
font-weight: 500;
}
.auth-layout aside a:hover { background: #f0f2f5; text-decoration: none; }
.auth-layout aside a.router-link-active { background: #e7effd; color: var(--primary); }
.auth-layout main { flex: 1; padding: 24px; overflow: auto; }
.auth-layout .main-head { display: flex; justify-content: flex-end; gap: 10px; margin-bottom: 12px; }
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@vmall/ui",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./nuxt": "./src/nuxt.ts"
},
"dependencies": {
"@nuxt/kit": "^3.15.4"
},
"peerDependencies": {
"vue": "^3.5.13"
}
}
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { ACCENTS, useAccent, type Accent } from "../composables/useAccent";
const { accent, setAccent } = useAccent();
const swatches: Record<Accent, string> = {
red: "#ca151e",
blue: "#2f6fed",
teal: "#0f8a72",
violet: "#7c3aed",
};
</script>
<template>
<div class="flex items-center gap-1.5" role="radiogroup" aria-label="Accent preset">
<button
v-for="name in ACCENTS"
:key="name"
type="button"
role="radio"
:aria-checked="accent === name"
:title="name"
:style="{ backgroundColor: swatches[name] }"
:class="[
'h-5 w-5 cursor-pointer rounded-full border-2 transition-transform hover:scale-110',
accent === name ? 'border-text' : 'border-transparent',
]"
@click="setAccent(name)"
/>
</div>
</template>
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{ tone?: "green" | "blue" | "orange" | "red" | "gray" }>(),
{ tone: "gray" },
);
const tones = {
green: "bg-success/10 text-success",
blue: "bg-primary-soft text-primary",
orange: "bg-warning/10 text-warning",
red: "bg-danger/10 text-danger",
gray: "bg-bg text-muted",
} as const;
</script>
<template>
<span
:class="[
'inline-block rounded px-2 py-0.5 text-xs font-medium',
tones[props.tone],
]"
>
<slot />
</span>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{
variant?: "default" | "primary" | "danger";
size?: "md" | "sm";
type?: "button" | "submit" | "reset";
disabled?: boolean;
}>(),
{ variant: "default", size: "md", type: "button", disabled: false },
);
const base =
"inline-flex cursor-pointer items-center justify-center gap-1 rounded-md border text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50";
const variants = {
default: "border-border bg-surface text-text hover:bg-bg",
primary: "border-primary bg-primary text-white hover:bg-primary-hover",
danger: "border-danger text-danger hover:bg-danger/10",
} as const;
const sizes = { md: "px-4 py-2", sm: "px-2.5 py-1 text-[13px]" } as const;
</script>
<template>
<button
:type="props.type"
:disabled="props.disabled"
:class="[base, variants[props.variant], sizes[props.size]]"
>
<slot />
</button>
</template>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{ padded?: boolean }>(), { padded: true });
</script>
<template>
<div
:class="[
'rounded-lg border border-border bg-surface shadow-sm',
props.padded ? 'p-5' : '',
]"
>
<slot />
</div>
</template>
+11
View File
@@ -0,0 +1,11 @@
<script setup lang="ts">
defineProps<{ label?: string; error?: string }>();
</script>
<template>
<div class="mb-3.5">
<label v-if="label" class="mb-1 block text-sm font-medium text-text">{{ label }}</label>
<slot />
<p v-if="error" class="mt-1 text-sm text-danger">{{ error }}</p>
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
withDefaults(
defineProps<{ type?: string; placeholder?: string; disabled?: boolean }>(),
{ type: "text", disabled: false },
);
const model = defineModel<string | number>();
</script>
<template>
<input
v-model="model"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-text focus:border-primary focus:outline-2 focus:outline-primary/30 disabled:opacity-50"
/>
</template>
+13
View File
@@ -0,0 +1,13 @@
<script setup lang="ts">
defineProps<{ title?: string }>();
</script>
<template>
<div class="py-6 pb-12">
<div v-if="title || $slots.actions" class="mb-4 flex items-center justify-between">
<h1 class="text-xl font-bold text-text">{{ title }}</h1>
<slot name="actions" />
</div>
<slot />
</div>
</template>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
defineProps<{ title?: string }>();
</script>
<template>
<div class="rounded-lg border border-border bg-surface shadow-sm">
<div
v-if="title || $slots.actions"
class="flex items-center justify-between border-b border-border px-5 py-3"
>
<h3 class="text-sm font-semibold text-text">{{ title }}</h3>
<slot name="actions" />
</div>
<div class="p-5"><slot /></div>
</div>
</template>
+9
View File
@@ -0,0 +1,9 @@
<template>
<div class="overflow-x-auto rounded-lg border border-border bg-surface shadow-sm">
<table
class="w-full border-collapse [&_td]:border-b [&_td]:border-border [&_td]:px-3 [&_td]:py-2.5 [&_td]:text-left [&_td]:text-sm [&_th]:border-b [&_th]:border-border [&_th]:px-3 [&_th]:py-2.5 [&_th]:text-left [&_th]:text-[13px] [&_th]:font-semibold [&_th]:text-muted [&_tr:last-child_td]:border-b-0"
>
<slot />
</table>
</div>
</template>
+47
View File
@@ -0,0 +1,47 @@
import { onMounted } from "vue";
import { useHead, useState } from "#app";
export const ACCENTS = ["red", "blue", "teal", "violet"] as const;
export type Accent = (typeof ACCENTS)[number];
const STORAGE_KEY = "vmall-accent";
const valid = (value: string | null): value is Accent =>
ACCENTS.includes(value as Accent);
/**
* Console accent preset state. `boot()` belongs in the console shell setup:
* it injects a pre-paint inline script that restores the stored preset and
* syncs reactive state on mount. Invalid stored values fall back to `red`.
*/
export function useAccent() {
const accent = useState<Accent>("vmall-accent", () => "red");
const setAccent = (value: string) => {
const next: Accent = valid(value) ? value : "red";
accent.value = next;
if (import.meta.client) {
localStorage.setItem(STORAGE_KEY, next);
document.documentElement.dataset.accent = next;
}
};
const boot = () => {
useHead({
script: [
{
key: "vmall-accent-boot",
innerHTML: `try{var a=localStorage.getItem("${STORAGE_KEY}");document.documentElement.dataset.accent=${JSON.stringify(ACCENTS)}.includes(a)?a:"red";}catch(e){document.documentElement.dataset.accent="red";}`,
},
],
});
onMounted(() => {
const stored = localStorage.getItem(STORAGE_KEY);
const next: Accent = valid(stored) ? stored : "red";
accent.value = next;
document.documentElement.dataset.accent = next;
});
};
return { accent, setAccent, boot };
}
+10
View File
@@ -0,0 +1,10 @@
export { default as VBtn } from "./components/VBtn.vue";
export { default as VBadge } from "./components/VBadge.vue";
export { default as VField } from "./components/VField.vue";
export { default as VInput } from "./components/VInput.vue";
export { default as VCard } from "./components/VCard.vue";
export { default as VPanel } from "./components/VPanel.vue";
export { default as VTable } from "./components/VTable.vue";
export { default as VPage } from "./components/VPage.vue";
export { default as VAccentSwatch } from "./components/VAccentSwatch.vue";
export { useAccent, ACCENTS, type Accent } from "./composables/useAccent";
+11
View File
@@ -0,0 +1,11 @@
import { defineNuxtModule, addComponentsDir, addImportsDir } from "@nuxt/kit";
import { fileURLToPath } from "node:url";
export default defineNuxtModule({
meta: { name: "@vmall/ui", configKey: "vmallUi" },
setup() {
const resolve = (path: string) => fileURLToPath(new URL(path, import.meta.url));
addComponentsDir({ path: resolve("./components"), prefix: "" });
addImportsDir(resolve("./composables"));
},
});