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:
12
.vscode/settings.json
vendored
Normal file
12
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"files.associations": {
|
||||||
|
"*.css": "tailwindcss"
|
||||||
|
},
|
||||||
|
"editor.quickSuggestions": {
|
||||||
|
"strings": "on"
|
||||||
|
},
|
||||||
|
"tailwindCSS.classAttributes": ["class", "ui"],
|
||||||
|
"tailwindCSS.experimental.classRegex": [
|
||||||
|
["ui:\\s*{([^)]*)\\s*}", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
10
app/assets/css/main.css
Normal 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;
|
||||||
|
}
|
||||||
123
app/components/item/AddModal.vue
Normal file
123
app/components/item/AddModal.vue
Normal 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
61
app/composables/items.ts
Normal 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
54
app/layouts/default.vue
Normal 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>
|
||||||
13
app/pages/export/index.vue
Normal file
13
app/pages/export/index.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
|
||||||
|
</style>
|
||||||
13
app/pages/history/index.vue
Normal file
13
app/pages/history/index.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
|
||||||
|
</style>
|
||||||
195
app/pages/index.vue
Normal file
195
app/pages/index.vue
Normal 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>
|
||||||
@@ -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
5
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
onlyBuiltDependencies:
|
||||||
|
- '@parcel/watcher'
|
||||||
|
- '@tailwindcss/oxide'
|
||||||
|
- esbuild
|
||||||
|
- vue-demi
|
||||||
BIN
public/resources/database-icon.png
Normal file
BIN
public/resources/database-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 344 KiB |
Reference in New Issue
Block a user