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>
|
||||
<div>
|
||||
<NuxtRouteAnnouncer />
|
||||
<NuxtWelcome />
|
||||
</div>
|
||||
<UApp>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</UApp>
|
||||
</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({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: true },
|
||||
css: ['~/assets/css/main.css'],
|
||||
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