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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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"));
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user