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
+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"));
},
});