feat(items): implement initial item management system

This commit introduces the foundational structure and core features for the item management application.

Key features implemented:
- **Item Listing:** A main page displaying all items in a searchable UTable.
- **Item Creation:** An "Add Item" modal with a UForm for input and validation using Zod.
- **State Management:** A `useItemsStore` composable built with `@vueuse/core`'s `useLocalStorage` to persist item data
in the browser.
- **UI Framework:** Integrated Nuxt UI for components like tables, modals, forms, and the overall layout.
- **Application Shell:** Established a default layout with a header, navigation menu, and placeholders for Export and
History pages.
- **Project Setup:** Configured pnpm workspace, Tailwind CSS, and VSCode editor settings for an improved development
experience.
This commit is contained in:
xiaomai
2025-10-14 09:36:59 +08:00
parent c0ba7ac0ff
commit e05c41eb07
13 changed files with 493 additions and 4 deletions

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
shamefully-hoist=true

12
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,12 @@
{
"files.associations": {
"*.css": "tailwindcss"
},
"editor.quickSuggestions": {
"strings": "on"
},
"tailwindCSS.classAttributes": ["class", "ui"],
"tailwindCSS.experimental.classRegex": [
["ui:\\s*{([^)]*)\\s*}", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
]
}

View File

@@ -1,6 +1,7 @@
<template> <template>
<div> <UApp>
<NuxtRouteAnnouncer /> <NuxtLayout>
<NuxtWelcome /> <NuxtPage />
</div> </NuxtLayout>
</UApp>
</template> </template>

10
app/assets/css/main.css Normal file
View File

@@ -0,0 +1,10 @@
@import "tailwindcss";
@import "@nuxt/ui";
.router-link-exact-active {
@apply text-blue-600 bg-blue-50;
}
.router-link-active {
@apply text-blue-600 bg-blue-50;
}

View File

@@ -0,0 +1,123 @@
<template>
<UModal v-model:open="open" title="新增物品" description="新增一件新的物品">
<UButton label="新增" icon="lucide:plus" />
<template #body>
<UForm
ref="itemForm"
:schema="itemSchema"
:state="itemState"
@submit="onSubmit"
class="space-y-4"
>
<UFormField label="标品名称" required name="name">
<UInput
v-model="itemState.name"
placeholder="请输入物品名称"
class="w-full"
/>
</UFormField>
<UFormField label="图片 URL">
<UInput
v-model="itemState.imageUrl"
placeholder="https://img.tootaio.com/i/2025/09/26/gxuf17.jpeg"
class="w-full"
/>
<template #hint>
<div class="text-xs text-gray-500">请粘贴您的图床图片链接</div>
</template>
</UFormField>
<UFormField label="描述">
<UTextarea
v-model="itemState.description"
placeholder="请输入物品描述(可选)"
class="w-full"
/>
</UFormField>
<UFormField label="标签">
<UInput
v-model="itemState.tags"
placeholder="Johnnie Walker,Whiskey"
class="w-full"
/>
<template #hint>
<div class="text-xs text-gray-500">用英文逗号隔开</div>
</template>
</UFormField>
</UForm>
</template>
<template #footer>
<div class="flex justify-end w-full gap-2">
<UButton
label="取消"
color="neutral"
variant="subtle"
@click="open = false"
/>
<UButton
label="创建"
color="primary"
variant="solid"
@click="itemForm.submit()"
/>
</div>
</template>
</UModal>
</template>
<script lang="ts" setup>
import type { FormSubmitEvent } from "@nuxt/ui";
import * as z from "zod";
const itemForm = ref();
const open = ref<boolean>(false);
const toast = useToast();
const { stats, isNameAvailable, addItem } = useItemsStore();
const itemSchema = z.object({
name: z
.string()
.min(1, "名称不能为空")
.refine(isNameAvailable, "该名称已被占用,请更换一个"),
imageUrl: z.string().nullable().optional(),
description: z.string().nullable().optional(),
tags: z.string().nullable().optional(),
});
type ItemSchema = z.output<typeof itemSchema>;
const itemState = reactive<ItemSchema>({
name: "",
imageUrl: null,
description: null,
tags: null,
});
const onSubmit = async (event: FormSubmitEvent<ItemSchema>) => {
const item: Item = {
id: stats.latestId.value,
name: event.data.name,
imageUrl: event.data.imageUrl,
description: event.data.description,
tags: event.data.tags?.split(",").map((tag) => tag.trim()),
createdAt: new Date(),
updatedAt: new Date(),
};
// TODO: Check is add or edit
addItem(item);
toast.add({
title: "成功",
description: `${event.data.name} 已被添加到数据库中`,
color: "success",
});
open.value = false;
};
</script>
<style></style>

61
app/composables/items.ts Normal file
View File

@@ -0,0 +1,61 @@
import { useLocalStorage, createSharedComposable } from "@vueuse/core";
// 类型定义
export type Item = {
id: number;
name: string;
imageUrl?: string | null;
description?: string | null;
tags?: string[] | null;
createdAt: Date;
updatedAt: Date;
};
const _useItems = () => {
const items = useLocalStorage<Item[]>("item-database", [], {
serializer: {
read: (v) => {
const parsed = JSON.parse(v);
return parsed.map((item: any) => ({
...item,
createdAt: new Date(item.createdAt),
updatedAt: new Date(item.updatedAt),
}));
},
write: (v) => JSON.stringify(v),
},
});
const stats = {
totalItems: computed(() => items.value.length),
addedThisMonth: computed(() => {
const now = new Date();
const thisMonth = now.getMonth();
const thisYear = now.getFullYear();
let count = 0;
for (const item of items.value) {
const date = item.createdAt;
if (date.getMonth() === thisMonth && date.getFullYear() === thisYear) {
count++;
}
}
return count;
}),
latestId: computed(
() => (items.value[items.value.length - 1]?.id ?? 0) + 1
),
};
const isNameAvailable = (name: string) =>
!items.value.some((item) => item.name === name);
const addItem = (item: Item) => items.value.push(item);
const removeItem = (id: number) =>
(items.value = items.value.filter((i) => i.id !== id));
return { items, stats, isNameAvailable, addItem, removeItem };
};
export const useItemsStore = createSharedComposable(_useItems);

54
app/layouts/default.vue Normal file
View File

@@ -0,0 +1,54 @@
<template>
<div>
<!-- 导航栏 -->
<UHeader>
<template #title>物品数据库</template>
<UNavigationMenu :items="items" />
<template #right>
<UColorModeButton />
<UTooltip text="Open on GitHub" :kbds="['meta', 'G']">
<UButton
color="neutral"
variant="ghost"
to="https://github.com/nuxt/ui"
target="_blank"
icon="i-simple-icons-github"
aria-label="GitHub"
/>
</UTooltip>
</template>
</UHeader>
<!-- 页面内容插槽 -->
<slot />
</div>
</template>
<script setup lang="ts">
import type { NavigationMenuItem } from "@nuxt/ui";
const route = useRoute();
const items = computed<NavigationMenuItem[]>(() => [
{
label: "物品管理",
to: "/",
icon: "marketeq:home-alt-3",
},
{
label: "导出配置",
to: "/export",
active: route.path.startsWith("/export"),
icon: "marketeq:export-2",
},
{
label: "历史记录",
to: "/history",
active: route.path.startsWith("/history"),
icon: "marketeq:chart-line-alt-1",
},
]);
</script>

View File

@@ -0,0 +1,13 @@
<template>
<div>
</div>
</template>
<script lang="ts" setup>
</script>
<style>
</style>

View File

@@ -0,0 +1,13 @@
<template>
<div>
</div>
</template>
<script lang="ts" setup>
</script>
<style>
</style>

195
app/pages/index.vue Normal file
View File

@@ -0,0 +1,195 @@
<template>
<div>
<UPageHero
title="智能物品管理系统"
description="专业的物品数据库管理工具支持图片记录、CSV导出和历史价格追踪"
headline="New release"
/>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- 统计 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div
v-for="stat in statistics"
:key="stat.label"
class="bg-white rounded-xl p-6 shadow-sm border border-gray-100"
>
<div class="flex items-center">
<div
:class="`p-3 rounded-lg flex bg-${stat.color}-100 items-center`"
>
<Icon
class="w-6 h-6"
:name="stat.icon"
:style="`color: ${stat.color}`"
/>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">{{ stat.label }}</p>
<p class="text-2xl font-bold text-gray-900">{{ stat.value }}</p>
</div>
</div>
</div>
</div>
<!-- 列表 -->
<div class="flex flex-col flex-1 w-full">
<div
class="flex justify-between items-center gap-2 overflow-x-auto px-4 py-3.5 border-b border-accented"
>
<div>
<UInput
v-model="globalFilter"
class="max-w-sm"
placeholder="Search..."
icon="lucide:search"
/>
</div>
<div>
<ItemAddModal />
</div>
</div>
<UTable
v-model:global-filter="globalFilter"
:data="items"
:columns="itemColumns"
>
<template #name-cell="{ row }">
<div class="flex items-center gap-3">
<UAvatar
:src="row.original.imageUrl"
size="lg"
:alt="row.original.name"
/>
<div>
<p class="font-medium text-highlighted">
{{ row.original.name }}
</p>
</div>
</div>
</template>
<template #tags-cell="{ row }">
<div class="flex gap-2">
<UBadge v-for="tag in row.original.tags" variant="subtle">{{
tag
}}</UBadge>
</div>
</template>
<template #actions-cell="{ row }">
<div class="text-right">
<UDropdownMenu
:items="getRowItems(row)"
:content="{ align: 'end' }"
aria-label="Actions dropdown"
>
<UButton
icon="i-lucide-ellipsis-vertical"
color="neutral"
variant="ghost"
class="ml-auto"
aria-label="Actions dropdown"
/>
</UDropdownMenu>
</div>
</template>
</UTable>
</div>
</main>
</div>
</template>
<script lang="ts" setup>
import type { TableColumn, DropdownMenuItem } from "@nuxt/ui";
import type { Row } from "@tanstack/vue-table";
import { useClipboard } from "@vueuse/core";
const UBadge = resolveComponent("UBadge");
const UAvatar = resolveComponent("UAvatar");
const toast = useToast();
const { copy } = useClipboard();
const { items, stats } = useItemsStore();
// 统计
const statistics = [
{
icon: "lucide:box",
label: "总物品数",
value: stats.totalItems,
color: "blue",
},
{
icon: "lucide:package-plus",
label: "本月新增",
value: stats.addedThisMonth,
color: "green",
},
{
icon: "lucide:history",
label: "历史记录",
value: computed(() => 0),
color: "orange",
},
{
icon: "lucide:image",
label: "图片总数",
value: computed(() => 0),
color: "purple",
},
];
const globalFilter = ref<string>("");
const itemColumns: TableColumn<Item>[] = [
{
accessorKey: "id",
header: "#",
cell: ({ row }) => `#${row.getValue("id")}`,
},
{
accessorKey: "name",
header: "标品名称",
},
{
accessorKey: "tags",
header: "标签",
},
{
id: "actions",
header: "", // 不需要标题
},
];
function getRowItems(row: Row<Item>): DropdownMenuItem[] {
return [
{ type: "label", label: "操作" },
{
label: "编辑",
color: "primary",
icon: "lucide:pencil-line",
onSelect() {
copy(row.original.id.toString());
toast.add({
title: "Payment ID copied to clipboard!",
color: "success",
icon: "i-lucide-circle-check",
});
},
},
{ type: "separator" },
{ label: "删除", color: "error", icon: "lucide:trash-2" },
] as const;
}
// Define Shortcut
defineShortcuts({
meta_i: () => {
console.log("Shortcut works!");
},
});
</script>
<style></style>

View File

@@ -2,5 +2,6 @@
export default defineNuxtConfig({ export default defineNuxtConfig({
compatibilityDate: '2025-07-15', compatibilityDate: '2025-07-15',
devtools: { enabled: true }, devtools: { enabled: true },
css: ['~/assets/css/main.css'],
modules: ['@nuxt/ui'] modules: ['@nuxt/ui']
}) })

5
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,5 @@
onlyBuiltDependencies:
- '@parcel/watcher'
- '@tailwindcss/oxide'
- esbuild
- vue-demi

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB