chore: migrate to pnpm workspaces monorepo with Turborepo

- Restructure directories: apps/api, apps/admin, apps/website
- Add root pnpm-workspace.yaml, turbo.json, .prettierrc, .gitignore
- Rename packages to @inkreach/api, @inkreach/admin, @inkreach/website
- Add shared packages: packages/tsconfig, packages/shared-types
- Add pnpm.onlyBuiltDependencies for native builds
- Update docs: README.md, structs.md
- All three projects build successfully
This commit is contained in:
yeuimu
2026-07-11 16:54:05 +08:00
parent 69945b8749
commit 7e04877bb6
155 changed files with 20134 additions and 14393 deletions
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE=/api
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>inkreach-official-admin</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@inkreach/admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@vueuse/core": "^14.3.0",
"axios": "^1.18.0",
"dayjs": "^1.11.21",
"element-plus": "^2.14.2",
"pinia": "^3.0.4",
"vue": "^3.5.34",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^24.12.3",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/tsconfig": "^0.9.1",
"sass": "^1.101.0",
"typescript": "~6.0.2",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^32.1.0",
"vite": "^8.0.12",
"vue-tsc": "^3.2.8"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
// Root component renders router-managed views.
</script>
<template>
<router-view />
</template>
<style>
#app {
width: 100%;
height: 100%;
}
</style>
+19
View File
@@ -0,0 +1,19 @@
import request from './request'
import type { LoginRequest, LoginResponse, User } from '@/types'
export const authApi = {
// Login
login: (data: LoginRequest) => {
return request.post<any, LoginResponse>('/auth/login', data)
},
// Get current user
getCurrentUser: () => {
return request.get<any, User>('/auth/me')
},
// Logout
logout: () => {
return request.post('/auth/logout')
},
}
+41
View File
@@ -0,0 +1,41 @@
import request from './request'
import type {
Category,
CategoryTree,
CreateCategoryRequest,
UpdateCategoryRequest,
CategoryFilter,
PaginatedResult,
} from '@/types'
export const categoriesApi = {
// Get categories list
getCategoriesList: (params: CategoryFilter) => {
return request.get<any, PaginatedResult<Category>>('/categories', { params })
},
// Get category tree
getCategoryTree: () => {
return request.get<any, CategoryTree[]>('/categories')
},
// Get category by id
getCategoryById: (id: string) => {
return request.get<any, Category>(`/categories/${id}`)
},
// Create category
createCategory: (data: CreateCategoryRequest) => {
return request.post<any, Category>('/categories', data)
},
// Update category
updateCategory: (id: string, data: UpdateCategoryRequest) => {
return request.patch<any, Category>(`/categories/${id}`, data)
},
// Delete category
deleteCategory: (id: string) => {
return request.delete(`/categories/${id}`)
},
}
+35
View File
@@ -0,0 +1,35 @@
import request from './request'
import type {
Country,
CreateCountryRequest,
UpdateCountryRequest,
CountryFilter,
PaginatedResult,
} from '@/types'
export const countriesApi = {
// Get countries list
getCountriesList: (params: CountryFilter) => {
return request.get<any, PaginatedResult<Country>>('/countries', { params })
},
// Get country by id
getCountryById: (id: string) => {
return request.get<any, Country>(`/countries/${id}`)
},
// Create country
createCountry: (data: CreateCountryRequest) => {
return request.post<any, Country>('/countries', data)
},
// Update country
updateCountry: (id: string, data: UpdateCountryRequest) => {
return request.patch<any, Country>(`/countries/${id}`, data)
},
// Delete country
deleteCountry: (id: string) => {
return request.delete(`/countries/${id}`)
},
}
+47
View File
@@ -0,0 +1,47 @@
import request from './request'
import type {
Good,
CreateGoodRequest,
UpdateGoodRequest,
BatchCreateGoodsRequest,
UpdatePriorityRequest,
GoodsFilter,
PaginatedResult,
} from '@/types'
export const goodsApi = {
// Get goods list
getGoodsList: (params: GoodsFilter) => {
return request.get<any, PaginatedResult<Good>>('/goods', { params })
},
// Get good by id
getGoodById: (id: string) => {
return request.get<any, Good>(`/goods/${id}`)
},
// Create good
createGood: (data: CreateGoodRequest) => {
return request.post<any, Good>('/goods', data)
},
// Update good
updateGood: (id: string, data: UpdateGoodRequest) => {
return request.patch<any, Good>(`/goods/${id}`, data)
},
// Delete good
deleteGood: (id: string) => {
return request.delete(`/goods/${id}`)
},
// Batch create goods
batchCreateGoods: (data: BatchCreateGoodsRequest) => {
return request.post<any, Good[]>('/goods/batch', data)
},
// Update priority
updatePriority: (data: UpdatePriorityRequest) => {
return request.patch<any, Good[]>('/goods/priority', data)
},
}
+12
View File
@@ -0,0 +1,12 @@
import request from './request'
import type { OriginGood, OriginGoodsTreeResponse, PaginatedResult } from '@/types'
export const originGoodsApi = {
getTree: () => {
return request.get<any, OriginGoodsTreeResponse>('/origin-goods/tree')
},
getOriginGoodsList: (params: { page?: number; pageSize?: number; keyword?: string }) => {
return request.get<any, PaginatedResult<OriginGood>>('/origin-goods', { params })
},
}
+35
View File
@@ -0,0 +1,35 @@
import request from './request'
import type {
Position,
CreatePositionRequest,
UpdatePositionRequest,
PositionFilter,
PaginatedResult,
} from '@/types'
export const positionsApi = {
// Get positions list
getPositionsList: (params: PositionFilter) => {
return request.get<any, PaginatedResult<Position>>('/positions', { params })
},
// Get position by id
getPositionById: (id: string) => {
return request.get<any, Position>(`/positions/${id}`)
},
// Create position
createPosition: (data: CreatePositionRequest) => {
return request.post<any, Position>('/positions', data)
},
// Update position
updatePosition: (id: string, data: UpdatePositionRequest) => {
return request.patch<any, Position>(`/positions/${id}`, data)
},
// Delete position
deletePosition: (id: string) => {
return request.delete(`/positions/${id}`)
},
}
+72
View File
@@ -0,0 +1,72 @@
import axios from 'axios'
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
const request: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE || '/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor
request.interceptors.request.use(
(config: AxiosRequestConfig) => {
const token = localStorage.getItem('token')
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error: AxiosError) => {
return Promise.reject(error)
}
)
// Response interceptor
request.interceptors.response.use(
(response: AxiosResponse) => {
// Backend wraps everything in { data, success } — unwrap to data
const body = response.data
if (body && typeof body === 'object' && 'success' in body && 'data' in body) {
return body.data
}
return body
},
(error: AxiosError) => {
if (error.response) {
const { status, data } = error.response
switch (status) {
case 401:
ElMessage.error('Unauthorized, please login')
localStorage.removeItem('token')
localStorage.removeItem('user')
router.push('/login')
break
case 403:
ElMessage.error('Forbidden')
break
case 404:
ElMessage.error('Resource not found')
break
case 500:
ElMessage.error('Server error')
break
default:
const errorMessage = (data as any)?.message || 'Request failed'
ElMessage.error(errorMessage)
}
} else if (error.request) {
ElMessage.error('Network error')
} else {
ElMessage.error('Request failed')
}
return Promise.reject(error)
}
)
export default request
+14
View File
@@ -0,0 +1,14 @@
import request from './request'
import type { SyncLog } from '@/types'
export const syncApi = {
syncProducts: () => {
return request.post<any, SyncLog>('/sync/products')
},
getSyncStatus: (limit?: number) => {
return request.get<any, SyncLog[]>('/sync/status', {
params: limit ? { limit } : undefined,
})
},
}
+33
View File
@@ -0,0 +1,33 @@
import request from './request'
import type {
TagGroup,
CreateTagGroupRequest,
UpdateTagGroupRequest,
ReorderTagGroupsRequest,
} from '@/types'
export const tagGroupsApi = {
getTagGroupsList: () => {
return request.get<any, TagGroup[]>('/tag-groups')
},
getTagGroupById: (id: string) => {
return request.get<any, TagGroup>(`/tag-groups/${id}`)
},
createTagGroup: (data: CreateTagGroupRequest) => {
return request.post<any, TagGroup>('/tag-groups', data)
},
updateTagGroup: (id: string, data: UpdateTagGroupRequest) => {
return request.patch<any, TagGroup>(`/tag-groups/${id}`, data)
},
deleteTagGroup: (id: string) => {
return request.delete(`/tag-groups/${id}`)
},
reorderTagGroups: (data: ReorderTagGroupsRequest) => {
return request.patch('/tag-groups/sort', data)
},
}
+41
View File
@@ -0,0 +1,41 @@
import request from './request'
import type {
Tag,
CreateTagRequest,
UpdateTagRequest,
TagFilter,
ReorderTagsRequest,
PaginatedResult,
} from '@/types'
export const tagsApi = {
// Get tags list
getTagsList: (params: TagFilter) => {
return request.get<any, PaginatedResult<Tag>>('/tags', { params })
},
// Get tag by id
getTagById: (id: string) => {
return request.get<any, Tag>(`/tags/${id}`)
},
// Create tag
createTag: (data: CreateTagRequest) => {
return request.post<any, Tag>('/tags', data)
},
// Update tag
updateTag: (id: string, data: UpdateTagRequest) => {
return request.patch<any, Tag>(`/tags/${id}`, data)
},
// Delete tag
deleteTag: (id: string) => {
return request.delete(`/tags/${id}`)
},
// Reorder tags (and/or move across groups)
reorderTags: (data: ReorderTagsRequest) => {
return request.patch('/tags/sort', data)
},
}
+90
View File
@@ -0,0 +1,90 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue').EffectScope
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
const computed: typeof import('vue').computed
const createApp: typeof import('vue').createApp
const createPinia: typeof import('pinia').createPinia
const customRef: typeof import('vue').customRef
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
const defineComponent: typeof import('vue').defineComponent
const defineStore: typeof import('pinia').defineStore
const effectScope: typeof import('vue').effectScope
const getActivePinia: typeof import('pinia').getActivePinia
const getCurrentInstance: typeof import('vue').getCurrentInstance
const getCurrentScope: typeof import('vue').getCurrentScope
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
const h: typeof import('vue').h
const inject: typeof import('vue').inject
const isProxy: typeof import('vue').isProxy
const isReactive: typeof import('vue').isReactive
const isReadonly: typeof import('vue').isReadonly
const isRef: typeof import('vue').isRef
const isShallow: typeof import('vue').isShallow
const mapActions: typeof import('pinia').mapActions
const mapGetters: typeof import('pinia').mapGetters
const mapState: typeof import('pinia').mapState
const mapStores: typeof import('pinia').mapStores
const mapWritableState: typeof import('pinia').mapWritableState
const markRaw: typeof import('vue').markRaw
const nextTick: typeof import('vue').nextTick
const onActivated: typeof import('vue').onActivated
const onBeforeMount: typeof import('vue').onBeforeMount
const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
const onDeactivated: typeof import('vue').onDeactivated
const onErrorCaptured: typeof import('vue').onErrorCaptured
const onMounted: typeof import('vue').onMounted
const onRenderTracked: typeof import('vue').onRenderTracked
const onRenderTriggered: typeof import('vue').onRenderTriggered
const onScopeDispose: typeof import('vue').onScopeDispose
const onServerPrefetch: typeof import('vue').onServerPrefetch
const onUnmounted: typeof import('vue').onUnmounted
const onUpdated: typeof import('vue').onUpdated
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
const provide: typeof import('vue').provide
const reactive: typeof import('vue').reactive
const readonly: typeof import('vue').readonly
const ref: typeof import('vue').ref
const resolveComponent: typeof import('vue').resolveComponent
const setActivePinia: typeof import('pinia').setActivePinia
const setMapStoreSuffix: typeof import('pinia').setMapStoreSuffix
const shallowReactive: typeof import('vue').shallowReactive
const shallowReadonly: typeof import('vue').shallowReadonly
const shallowRef: typeof import('vue').shallowRef
const storeToRefs: typeof import('pinia').storeToRefs
const toRaw: typeof import('vue').toRaw
const toRef: typeof import('vue').toRef
const toRefs: typeof import('vue').toRefs
const toValue: typeof import('vue').toValue
const triggerRef: typeof import('vue').triggerRef
const unref: typeof import('vue').unref
const useAttrs: typeof import('vue').useAttrs
const useCssModule: typeof import('vue').useCssModule
const useCssVars: typeof import('vue').useCssVars
const useId: typeof import('vue').useId
const useLink: typeof import('vue-router').useLink
const useModel: typeof import('vue').useModel
const useRoute: typeof import('vue-router').useRoute
const useRouter: typeof import('vue-router').useRouter
const useSlots: typeof import('vue').useSlots
const useTemplateRef: typeof import('vue').useTemplateRef
const watch: typeof import('vue').watch
const watchEffect: typeof import('vue').watchEffect
const watchPostEffect: typeof import('vue').watchPostEffect
const watchSyncEffect: typeof import('vue').watchSyncEffect
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
+56
View File
@@ -0,0 +1,56 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ElAside: typeof import('element-plus/es')['ElAside']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCascader: typeof import('element-plus/es')['ElCascader']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElImage: typeof import('element-plus/es')['ElImage']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTree: typeof import('element-plus/es')['ElTree']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}
+296
View File
@@ -0,0 +1,296 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import {
Goods,
Expand,
Fold,
ArrowDown,
User,
SwitchButton,
} from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const appStore = useAppStore()
interface MenuItem {
index: string
title: string
icon: string
}
const menuItems = ref<MenuItem[]>([
{ index: '/goods', title: '商品管理', icon: 'Goods' },
])
const iconMap: Record<string, unknown> = {
Goods,
}
const activeMenu = computed(() => route.path)
const collapsed = computed(() => appStore.sidebarCollapsed)
const username = computed(() => authStore.user?.username || 'Admin')
const breadcrumb = computed(() => {
const meta = route.meta as { title?: string } | undefined
return meta?.title || '商品管理'
})
function toggleSidebar() {
appStore.toggleSidebar()
}
async function handleLogout() {
try {
await ElMessageBox.confirm('确定退出登录吗?', '退出登录', {
confirmButtonText: '退出登录',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
await authStore.logout()
router.push('/login')
}
function handleCommand(command: string) {
if (command === 'logout') {
handleLogout()
}
}
</script>
<template>
<el-container class="layout-root">
<!-- Sidebar -->
<el-aside :width="collapsed ? '64px' : '240px'" class="layout-aside">
<div class="brand" :class="{ collapsed }">
<div class="brand-mark">IR</div>
<div v-if="!collapsed" class="brand-text">
<div class="brand-name">Inkreach</div>
<div class="brand-tag">官网后台</div>
</div>
</div>
<el-menu
:default-active="activeMenu"
:collapse="collapsed"
:collapse-transition="false"
background-color="#1f1f24"
text-color="#d4d4d8"
active-text-color="#ff6800"
router
>
<el-menu-item v-for="item in menuItems" :key="item.index" :index="item.index">
<el-icon>
<component :is="iconMap[item.icon]" />
</el-icon>
<template #title>{{ item.title }}</template>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<!-- Header -->
<el-header class="layout-header" height="56px">
<div class="header-left">
<el-button
text
class="collapse-btn"
:title="collapsed ? '展开侧边栏' : '折叠侧边栏'"
@click="toggleSidebar"
>
<el-icon :size="20">
<component :is="collapsed ? Expand : Fold" />
</el-icon>
</el-button>
<el-breadcrumb separator="/" class="breadcrumb">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>{{ breadcrumb }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<el-dropdown trigger="click" @command="handleCommand">
<span class="user-trigger">
<el-icon><User /></el-icon>
<span class="user-name">{{ username }}</span>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">
<el-icon><SwitchButton /></el-icon>
<span>退出登录</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<!-- Main content -->
<el-main class="layout-main">
<router-view v-slot="{ Component }">
<transition name="fade-slide" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</el-main>
</el-container>
</el-container>
</template>
<style scoped>
.layout-root {
height: 100vh;
}
.layout-aside {
background: #1f1f24;
transition: width 0.2s ease;
overflow: hidden;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
height: 56px;
padding: 0 16px;
border-bottom: 1px solid #2b2b33;
background: #16161a;
overflow: hidden;
}
.brand.collapsed {
justify-content: center;
padding: 0;
}
.brand-mark {
flex: 0 0 auto;
width: 32px;
height: 32px;
border-radius: 6px;
background: var(--brand-color);
color: #fff;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
letter-spacing: 0.5px;
}
.brand-text {
display: flex;
flex-direction: column;
line-height: 1.2;
white-space: nowrap;
}
.brand-name {
color: #fff;
font-weight: 600;
font-size: 15px;
}
.brand-tag {
color: #9ca3af;
font-size: 11px;
}
.layout-aside :deep(.el-menu) {
border-right: none;
}
.layout-aside :deep(.el-menu-item.is-active) {
background: rgba(255, 104, 0, 0.12) !important;
border-right: 3px solid var(--brand-color);
}
.layout-aside :deep(.el-menu-item:hover) {
background: rgba(255, 255, 255, 0.04) !important;
}
.layout-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
background: #ffffff;
border-bottom: 1px solid #e5e7eb;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.collapse-btn {
font-size: 18px;
}
.breadcrumb {
font-size: 14px;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.user-trigger {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
padding: 6px 10px;
border-radius: 6px;
color: #374151;
font-size: 14px;
transition: background 0.15s ease;
}
.user-trigger:hover {
background: #f3f4f6;
}
.user-name {
font-weight: 500;
}
.layout-main {
background: #f5f7fa;
padding: 16px;
overflow: auto;
}
/* View transition */
.fade-slide-enter-active,
.fade-slide-leave-active {
transition:
opacity 0.18s ease,
transform 0.18s ease;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateY(8px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>
+15
View File
@@ -0,0 +1,15 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import './style.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')
+55
View File
@@ -0,0 +1,55 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/LoginView.vue'),
meta: { title: '登录', public: true },
},
{
path: '/',
component: () => import('@/layouts/DefaultLayout.vue'),
redirect: '/goods',
children: [
{
path: 'goods',
name: 'Goods',
component: () => import('@/views/product-management/ProductManagementView.vue'),
meta: { title: '商品管理' },
},
],
},
{
path: '/:pathMatch(.*)*',
redirect: '/goods',
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to) => {
const authStore = useAuthStore()
const isPublic = to.meta?.public === true
if (!authStore.isLoggedIn && !isPublic) {
return { path: '/login', replace: true }
}
if (authStore.isLoggedIn && to.path === '/login') {
return { path: '/', replace: true }
}
return true
})
router.afterEach((to) => {
const title = (to.meta?.title as string) || 'Inkreach 官网后台'
document.title = `${title} | Inkreach 官网后台`
})
export default router
+26
View File
@@ -0,0 +1,26 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
const SIDEBAR_KEY = 'app:sidebarCollapsed'
export const useAppStore = defineStore('app', () => {
const sidebarCollapsed = ref<boolean>(
localStorage.getItem(SIDEBAR_KEY) === 'true'
)
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value
localStorage.setItem(SIDEBAR_KEY, String(sidebarCollapsed.value))
}
function setSidebarCollapsed(value: boolean) {
sidebarCollapsed.value = value
localStorage.setItem(SIDEBAR_KEY, String(value))
}
return {
sidebarCollapsed,
toggleSidebar,
setSidebarCollapsed,
}
})
+76
View File
@@ -0,0 +1,76 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { LoginRequest, User } from '@/types'
import { authApi } from '@/api/auth'
export const useAuthStore = defineStore('auth', () => {
// Token persisted to localStorage
const token = ref<string>(localStorage.getItem('token') || '')
// User persisted to localStorage (parsed if available)
const user = ref<User | null>(loadUser())
const isLoggedIn = computed(() => !!token.value)
function loadUser(): User | null {
const raw = localStorage.getItem('user')
if (!raw) return null
try {
return JSON.parse(raw) as User
} catch {
return null
}
}
function setToken(newToken: string) {
token.value = newToken
if (newToken) {
localStorage.setItem('token', newToken)
} else {
localStorage.removeItem('token')
}
}
function setUser(newUser: User | null) {
user.value = newUser
if (newUser) {
localStorage.setItem('user', JSON.stringify(newUser))
} else {
localStorage.removeItem('user')
}
}
async function login(payload: LoginRequest) {
const res = await authApi.login(payload) as any;
const accessToken: string = res.accessToken ?? res.data?.accessToken ?? '';
const userData: User | null = res.user ?? res.data?.user ?? null;
if (accessToken) setToken(accessToken);
if (userData) setUser(userData);
return res;
}
async function fetchCurrentUser() {
const current = await authApi.getCurrentUser()
setUser(current)
return current
}
async function logout() {
try {
await authApi.logout()
} catch {
// Ignore network errors during logout
}
setToken('')
setUser(null)
}
return {
token,
user,
isLoggedIn,
login,
fetchCurrentUser,
logout,
}
})
+100
View File
@@ -0,0 +1,100 @@
:root {
--brand-color: #ff6800;
--brand-color-hover: #ff7d1f;
--brand-color-active: #e65a00;
--sidebar-width: 240px;
--sidebar-width-collapsed: 64px;
--header-height: 56px;
font-family:
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
'PingFang SC',
'Hiragino Sans GB',
'Microsoft YaHei',
sans-serif;
color: #1f2937;
background-color: #f5f7fa;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
}
a {
color: inherit;
text-decoration: none;
}
/* Element Plus brand color overrides */
:root {
--el-color-primary: var(--brand-color);
--el-color-primary-light-3: #ff8f3d;
--el-color-primary-light-5: #ffb073;
--el-color-primary-light-7: #ffd1a8;
--el-color-primary-light-8: #ffe0c2;
--el-color-primary-light-9: #fff0e0;
--el-color-primary-dark-2: #e65a00;
}
/* Brand-colored buttons */
.el-button--primary {
--el-button-bg-color: var(--brand-color);
--el-button-border-color: var(--brand-color);
--el-button-hover-bg-color: var(--brand-color-hover);
--el-button-hover-border-color: var(--brand-color-hover);
--el-button-active-bg-color: var(--brand-color-active);
--el-button-active-border-color: var(--brand-color-active);
}
/* Page wrapper helper */
.page-container {
padding: 16px;
}
.page-card {
background: #ffffff;
border-radius: 6px;
padding: 16px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
/* Compact filter bar */
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin-bottom: 16px;
}
.filter-bar .el-input,
.filter-bar .el-select,
.filter-bar .el-cascader {
width: 200px;
}
/* Action cell padding */
.table-actions .el-button + .el-button {
margin-left: 8px;
}
/* Brand-colored dialog titles */
.brand-title {
color: var(--brand-color);
font-weight: 600;
}
+315
View File
@@ -0,0 +1,315 @@
// Common types
export interface PaginatedResult<T> {
items: T[]
total: number
page: number
pageSize: number
}
// Auth types
export interface LoginRequest {
username: string
password: string
}
export interface LoginResponse {
accessToken: string
user: User
}
export interface User {
id: string
username: string
email?: string
}
// Good types
export interface Good {
id: string
goodName: string
goodImage: string | null
goodPriority: number
originGoodId: string
originGood?: OriginGood
countryId: string
country?: Country
categoryId: string
category?: Category
tagId?: string
tag?: Tag
tags: Tag[]
positionId?: string
position?: Position
createdAt: string
updatedAt: string
}
export interface CreateGoodRequest {
goodName: string
goodImage?: string
originGoodId: number
countryId: number
categoryId: number
tagIds?: number[]
positionId?: number
goodPriority?: number
}
export interface UpdateGoodRequest {
goodName?: string
goodImage?: string | null
originGoodId?: number
countryId?: number
categoryId?: number
tagIds?: number[]
positionId?: number | null
goodPriority?: number
}
export interface BatchCreateItem {
originGoodId: number
priority?: number
}
export interface BatchCreateGoodsRequest {
items: BatchCreateItem[]
countryId: number
categoryId: number
tagIds?: number[]
positionId?: number
defaultPriority?: number
}
export interface UpdatePriorityRequest {
goodIds: string[]
priority: number
}
// Country types
export interface Country {
id: string
countryName: string
countryIcon?: string | null
createdAt: string
updatedAt: string
}
export interface CreateCountryRequest {
countryName: string
countryIcon?: string
}
export interface UpdateCountryRequest {
countryName?: string
countryIcon?: string | null
}
// Category types
export interface Category {
id: string
categoryName: string
categoryIcon?: string | null
sdsCategoryId?: string | null
parentCategoryId?: string | null
parent?: Category
children?: Category[]
_count?: {
children: number
}
createdAt: string
updatedAt: string
}
export interface CreateCategoryRequest {
categoryName: string
categoryIcon?: string
parentCategoryId?: number
}
export interface UpdateCategoryRequest {
categoryName?: string
categoryIcon?: string | null
parentCategoryId?: number | null
}
export interface CategoryTree extends Category {
children?: CategoryTree[]
}
// Tag types
export interface Tag {
id: string
tagName: string
tagColor?: string | null
tagFontColor?: string | null
timing?: string | null
tagGroupId?: string | null
sortOrder?: number
tagGroup?: TagGroup | null
createdAt: string
updatedAt: string
}
export interface CreateTagRequest {
tagName: string
tagColor?: string
tagFontColor?: string
timing?: string
tagGroupId?: number
sortOrder?: number
}
export interface UpdateTagRequest {
tagName?: string
tagColor?: string | null
tagFontColor?: string | null
timing?: string | null
tagGroupId?: number | null
sortOrder?: number
}
// Tag Group types
export interface TagGroup {
id: string
groupName: string
groupIcon?: string | null
groupColor?: string | null
sortOrder: number
createdAt: string
updatedAt: string
_count?: { tags: number }
}
export interface CreateTagGroupRequest {
groupName: string
groupIcon?: string
groupColor?: string
sortOrder?: number
}
export interface UpdateTagGroupRequest {
groupName?: string
groupIcon?: string | null
groupColor?: string | null
sortOrder?: number
}
export interface ReorderTagGroupsRequest {
items: Array<{ id: number; sortOrder: number }>
}
export interface ReorderTagsRequest {
items: Array<{ id: number; tagGroupId?: number | null; sortOrder: number }>
}
// Position types
export interface Position {
id: string
indexVal: number
countryId?: string
country?: Country
categoryId?: string
category?: Category
createdAt: string
updatedAt: string
}
export interface CreatePositionRequest {
indexVal: number
countryId?: number
categoryId?: number
}
export interface UpdatePositionRequest {
indexVal?: number
countryId?: number | null
categoryId?: number | null
}
// Origin Good types
export interface OriginGood {
id: string
goodName: string | null
goodImage: string | null
goodPrice: string | null
sdsGoodId: string
sdsCategoryId: string | null
createdAt: string
updatedAt: string
}
// Origin Goods Tree types
export interface OriginGoodsTreeNode {
id: string
goodName: string
goodImage: string | null
goodPrice: string | null
sdsGoodId: string
configuredCount: number
configuredCountries: string[]
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]
}
export interface OriginGoodsTreeCategoryNode {
categoryId: string
categoryName: string
sdsCategoryId: string | null
configuredCount: number
totalCount: number
children: OriginGoodsTreeCategoryNode[]
originGoods: OriginGoodsTreeNode[]
}
export interface OriginGoodsTreeResponse {
tree: OriginGoodsTreeCategoryNode[]
totalOriginGoods: number
configuredCount: number
}
// Sync types
export interface SyncLog {
id: string
type: 'CATEGORY' | 'PRODUCT'
status: 'SUCCESS' | 'FAILED'
message?: string
startTime: string
endTime?: string
errorCount?: number
createdAt: string
}
// Filter types
export interface GoodsFilter {
countryId?: string
categoryId?: string
tagId?: string
keyword?: string
page?: number
pageSize?: number
}
export interface CategoryFilter {
parentCategoryId?: string
categoryName?: string
page?: number
pageSize?: number
}
export interface CountryFilter {
countryName?: string
page?: number
pageSize?: number
}
export interface TagFilter {
tagName?: string
page?: number
pageSize?: number
}
export interface PositionFilter {
countryId?: string
categoryId?: string
page?: number
pageSize?: number
}
@@ -0,0 +1,246 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Edit, Delete, Refresh } from '@element-plus/icons-vue'
import type {
Category,
CategoryTree,
CreateCategoryRequest,
UpdateCategoryRequest,
} from '@/types'
import { categoriesApi } from '@/api/categories'
const loading = ref(false)
const tree = ref<CategoryTree[]>([])
async function fetchTree() {
loading.value = true
try {
tree.value = await categoriesApi.getCategoryTree()
} finally {
loading.value = false
}
}
// ---------- Cascader ----------
interface CascaderNode {
value: string
label: string
children?: CascaderNode[]
}
const cascaderOptions = ref<CascaderNode[]>([])
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
return nodes.map((n) => ({
value: n.id,
label: n.categoryName,
children: n.children?.length ? buildCascader(n.children) : undefined,
}))
}
function rebuildCascader() {
cascaderOptions.value = buildCascader(tree.value)
}
// ---------- Dialog ----------
const dialogRef = ref()
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const dialogLoading = ref(false)
const dialogForm = reactive<CreateCategoryRequest & { id?: string }>({
categoryName: '',
categoryIcon: '',
parentCategoryId: '',
})
const dialogRules = {
categoryName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
}
async function openAddDialog() {
dialogMode.value = 'create'
Object.assign(dialogForm, { id: undefined, categoryName: '', categoryIcon: '', parentCategoryId: '' })
rebuildCascader()
dialogVisible.value = true
}
async function openEditDialog(c: Category) {
dialogMode.value = 'edit'
Object.assign(dialogForm, {
id: c.id,
categoryName: c.categoryName,
categoryIcon: c.categoryIcon || '',
parentCategoryId: c.parentCategoryId || '',
})
rebuildCascader()
dialogVisible.value = true
}
async function handleSubmit() {
if (!dialogRef.value) return
await dialogRef.value.validate(async (valid: boolean) => {
if (!valid) return
dialogLoading.value = true
try {
const payload: CreateCategoryRequest = {
categoryName: dialogForm.categoryName,
categoryIcon: dialogForm.categoryIcon || undefined,
parentCategoryId: dialogForm.parentCategoryId || undefined,
}
if (dialogMode.value === 'create') {
await categoriesApi.createCategory(payload)
ElMessage.success('分类创建成功')
} else {
await categoriesApi.updateCategory(dialogForm.id!, payload as UpdateCategoryRequest)
ElMessage.success('分类更新成功')
}
dialogVisible.value = false
fetchTree()
} finally {
dialogLoading.value = false
}
})
}
async function handleDelete(c: Category) {
try {
await ElMessageBox.confirm(`确定删除「${c.categoryName}」吗?`, '确认', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return
}
await categoriesApi.deleteCategory(c.id)
ElMessage.success('删除成功')
fetchTree()
}
onMounted(fetchTree)
</script>
<template>
<div class="page-container">
<div class="page-card">
<div class="toolbar">
<el-button type="primary" @click="openAddDialog">
<el-icon><Plus /></el-icon>
<span>新增分类</span>
</el-button>
<el-button @click="fetchTree">
<el-icon><Refresh /></el-icon>
<span>刷新</span>
</el-button>
</div>
<el-table
v-loading="loading"
:data="tree"
border
row-key="id"
:tree-props="{ children: 'children' }"
default-expand-all
>
<el-table-column prop="categoryName" label="名称" min-width="220" />
<el-table-column label="图标" width="100">
<template #default="{ row }: { row: CategoryTree }">
<el-image
v-if="row.categoryIcon"
:src="row.categoryIcon"
:preview-src-list="[row.categoryIcon]"
fit="cover"
style="width: 32px; height: 32px; border-radius: 4px;"
/>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="父级">
<template #default="{ row }: { row: CategoryTree }">
{{ row.parent?.categoryName || '-' }}
</template>
</el-table-column>
<el-table-column label="子级数" width="100">
<template #default="{ row }: { row: CategoryTree }">
{{ row._count?.children ?? row.children?.length ?? 0 }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }: { row: CategoryTree }">
<div class="table-actions">
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
<el-icon><Edit /></el-icon>
<span>编辑</span>
</el-button>
<el-button size="small" type="danger" plain @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
<span>删除</span>
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无分类" />
</template>
</el-table>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogMode === 'create' ? '新增分类' : '编辑分类'"
width="520px"
destroy-on-close
>
<el-form
ref="dialogRef"
:model="dialogForm"
:rules="dialogRules"
label-width="100px"
>
<el-form-item label="名称" prop="categoryName">
<el-input v-model="dialogForm.categoryName" placeholder="请输入分类名称" />
</el-form-item>
<el-form-item label="父级分类">
<el-cascader
v-model="dialogForm.parentCategoryId"
:options="cascaderOptions"
:props="{
checkStrictly: true,
value: 'value',
label: 'label',
children: 'children',
emitPath: false,
}"
placeholder="顶级(可选)"
clearable
style="width: 100%"
/>
</el-form-item>
<el-form-item label="图标 URL">
<el-input v-model="dialogForm.categoryIcon" placeholder="https://..." />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
{{ dialogMode === 'create' ? '创建' : '保存' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.pagination {
margin-top: 16px;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,231 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
import type {
Country,
CreateCountryRequest,
UpdateCountryRequest,
CountryFilter,
} from '@/types'
import { countriesApi } from '@/api/countries'
const loading = ref(false)
const list = ref<Country[]>([])
const total = ref(0)
const filter = reactive<Required<CountryFilter>>({
countryName: '',
page: 1,
pageSize: 10,
})
async function fetchList() {
loading.value = true
try {
const res = await countriesApi.getCountriesList(filter)
const data = await countriesApi.getCountriesList(filter) as any
const arr = Array.isArray(data) ? data : (data.items ?? [])
list.value = arr
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
} finally {
loading.value = false
}
}
function handleSearch() {
filter.page = 1
fetchList()
}
function handleReset() {
filter.countryName = ''
filter.page = 1
fetchList()
}
const dialogRef = ref()
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const dialogLoading = ref(false)
const dialogForm = reactive<CreateCountryRequest & { id?: string }>({
countryName: '',
countryIcon: '',
})
const dialogRules = {
countryName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
}
function openAddDialog() {
dialogMode.value = 'create'
Object.assign(dialogForm, { id: undefined, countryName: '', countryIcon: '' })
dialogVisible.value = true
}
function openEditDialog(c: Country) {
dialogMode.value = 'edit'
Object.assign(dialogForm, { id: c.id, countryName: c.countryName, countryIcon: c.countryIcon || '' })
dialogVisible.value = true
}
async function handleSubmit() {
if (!dialogRef.value) return
await dialogRef.value.validate(async (valid: boolean) => {
if (!valid) return
dialogLoading.value = true
try {
const payload: CreateCountryRequest = {
countryName: dialogForm.countryName,
countryIcon: dialogForm.countryIcon || undefined,
}
if (dialogMode.value === 'create') {
await countriesApi.createCountry(payload)
ElMessage.success('国家创建成功')
} else {
await countriesApi.updateCountry(dialogForm.id!, payload as UpdateCountryRequest)
ElMessage.success('国家更新成功')
}
dialogVisible.value = false
fetchList()
} finally {
dialogLoading.value = false
}
})
}
async function handleDelete(c: Country) {
try {
await ElMessageBox.confirm(`确定删除「${c.countryName}」吗?`, '确认', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return
}
await countriesApi.deleteCountry(c.id)
ElMessage.success('删除成功')
fetchList()
}
onMounted(fetchList)
</script>
<template>
<div class="page-container">
<div class="page-card">
<div class="filter-bar">
<el-input
v-model="filter.countryName"
placeholder="按名称搜索"
clearable
@keyup.enter="handleSearch"
@clear="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
<span>搜索</span>
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
<span>重置</span>
</el-button>
<div class="filter-spacer" />
<el-button type="primary" @click="openAddDialog">
<el-icon><Plus /></el-icon>
<span>新增国家</span>
</el-button>
</div>
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column label="图标" width="80">
<template #default="{ row }: { row: Country }">
<el-image
v-if="row.countryIcon"
:src="row.countryIcon"
:preview-src-list="[row.countryIcon]"
fit="cover"
style="width: 32px; height: 32px; border-radius: 4px;"
/>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="countryName" label="名称" min-width="200" />
<el-table-column label="创建时间" width="180">
<template #default="{ row }: { row: Country }">
{{ new Date(row.createdAt).toLocaleString() }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }: { row: Country }">
<div class="table-actions">
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
<el-icon><Edit /></el-icon>
<span>编辑</span>
</el-button>
<el-button size="small" type="danger" plain @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
<span>删除</span>
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无国家" />
</template>
</el-table>
<el-pagination
class="pagination"
v-model:current-page="filter.page"
v-model:page-size="filter.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="(p: number) => { filter.page = p; fetchList() }"
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
/>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogMode === 'create' ? '新增国家' : '编辑国家'"
width="480px"
destroy-on-close
>
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
<el-form-item label="名称" prop="countryName">
<el-input v-model="dialogForm.countryName" placeholder="请输入国家名称" />
</el-form-item>
<el-form-item label="图标 URL">
<el-input v-model="dialogForm.countryIcon" placeholder="https://..." />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
{{ dialogMode === 'create' ? '创建' : '保存' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.filter-spacer {
flex: 1;
}
.pagination {
margin-top: 16px;
justify-content: flex-end;
}
</style>
File diff suppressed because it is too large Load Diff
+181
View File
@@ -0,0 +1,181 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const form = reactive({
username: '',
password: '',
})
const rules: FormRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 64, message: '长度 2-64', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 4, max: 64, message: '长度 4-64', trigger: 'blur' },
],
}
async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
loading.value = true
try {
await authStore.login({ username: form.username, password: form.password })
ElMessage.success('登录成功')
router.push('/')
} catch (err) {
// Error toast is shown by axios response interceptor
console.error('Login failed', err)
} finally {
loading.value = false
}
})
}
</script>
<template>
<div class="login-page">
<div class="login-bg" />
<div class="login-card">
<div class="login-brand">
<div class="brand-logo">Inkreach</div>
<div class="brand-subtitle">官网后台</div>
</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
size="large"
label-position="top"
@submit.prevent="handleSubmit"
>
<el-form-item label="用户名" prop="username">
<el-input
v-model="form.username"
placeholder="请输入用户名"
clearable
autocomplete="username"
>
<template #prefix>
<el-icon><User /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码"
show-password
autocomplete="current-password"
@keyup.enter="handleSubmit"
>
<template #prefix>
<el-icon><Lock /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-button
type="primary"
class="login-button"
:loading="loading"
native-type="submit"
@click="handleSubmit"
>
登录
</el-button>
</el-form-item>
</el-form>
<div class="login-footer">
<span>© {{ new Date().getFullYear() }} Inkreach</span>
</div>
</div>
</div>
</template>
<style scoped>
.login-page {
position: relative;
width: 100%;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.login-bg {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 20% 20%, rgba(255, 104, 0, 0.18), transparent 60%),
radial-gradient(circle at 80% 80%, rgba(255, 141, 31, 0.15), transparent 60%),
linear-gradient(135deg, #1f1f24 0%, #2b2b33 100%);
z-index: 0;
}
.login-card {
position: relative;
z-index: 1;
width: 420px;
max-width: calc(100vw - 32px);
padding: 40px 36px 28px;
background: rgba(255, 255, 255, 0.97);
border-radius: 12px;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.18);
}
.login-brand {
text-align: center;
margin-bottom: 24px;
}
.brand-logo {
font-size: 28px;
font-weight: 700;
color: var(--brand-color);
letter-spacing: 0.5px;
}
.brand-subtitle {
font-size: 13px;
color: #6b7280;
margin-top: 6px;
}
.login-button {
width: 100%;
height: 44px;
font-weight: 600;
font-size: 15px;
}
.login-footer {
text-align: center;
color: #9ca3af;
font-size: 12px;
margin-top: 8px;
}
:deep(.el-form-item__label) {
font-weight: 500;
color: #374151;
}
</style>
@@ -0,0 +1,317 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
import type {
Position,
CreatePositionRequest,
UpdatePositionRequest,
PositionFilter,
Country,
Category,
CategoryTree,
} from '@/types'
import { positionsApi } from '@/api/positions'
import { countriesApi } from '@/api/countries'
import { categoriesApi } from '@/api/categories'
const loading = ref(false)
const list = ref<Position[]>([])
const total = ref(0)
const countries = ref<Country[]>([])
const categoriesTree = ref<CategoryTree[]>([])
const filter = reactive<Required<PositionFilter>>({
countryId: '',
categoryId: '',
page: 1,
pageSize: 10,
})
async function loadLookups() {
const [c, ct] = await Promise.all([
countriesApi.getCountriesList({ page: 1, pageSize: 500 }),
categoriesApi.getCategoryTree(),
])
countries.value = c.items
categoriesTree.value = ct
}
async function fetchList() {
loading.value = true
try {
const res = await positionsApi.getPositionsList(filter)
const data = await positionsApi.getPositionsList(filter) as any
const arr = Array.isArray(data) ? data : (data.items ?? [])
list.value = arr
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
} finally {
loading.value = false
}
}
function handleSearch() {
filter.page = 1
fetchList()
}
function handleReset() {
filter.countryId = ''
filter.categoryId = ''
filter.page = 1
fetchList()
}
// ---------- Cascader ----------
interface CascaderNode {
value: string
label: string
children?: CascaderNode[]
}
const cascaderOptions = ref<CascaderNode[]>([])
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
return nodes.map((n) => ({
value: n.id,
label: n.categoryName,
children: n.children?.length ? buildCascader(n.children) : undefined,
}))
}
// ---------- Dialog ----------
const dialogRef = ref()
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const dialogLoading = ref(false)
const dialogForm = reactive<CreatePositionRequest & { id?: string }>({
indexVal: 0,
countryId: '',
categoryId: '',
})
const dialogRules = {
indexVal: [{ required: true, message: '排序值是必填项', trigger: 'blur' }],
}
function rebuildCascader() {
cascaderOptions.value = buildCascader(categoriesTree.value)
}
function openAddDialog() {
dialogMode.value = 'create'
Object.assign(dialogForm, { id: undefined, indexVal: 0, countryId: '', categoryId: '' })
rebuildCascader()
dialogVisible.value = true
}
function openEditDialog(p: Position) {
dialogMode.value = 'edit'
Object.assign(dialogForm, {
id: p.id,
indexVal: p.indexVal,
countryId: p.countryId || '',
categoryId: p.categoryId || '',
})
rebuildCascader()
dialogVisible.value = true
}
async function handleSubmit() {
if (!dialogRef.value) return
await dialogRef.value.validate(async (valid: boolean) => {
if (!valid) return
dialogLoading.value = true
try {
const payload: CreatePositionRequest = {
indexVal: Number(dialogForm.indexVal),
countryId: dialogForm.countryId || undefined,
categoryId: dialogForm.categoryId || undefined,
}
if (dialogMode.value === 'create') {
await positionsApi.createPosition(payload)
ElMessage.success('位置创建成功')
} else {
await positionsApi.updatePosition(dialogForm.id!, payload as UpdatePositionRequest)
ElMessage.success('位置更新成功')
}
dialogVisible.value = false
fetchList()
} finally {
dialogLoading.value = false
}
})
}
async function handleDelete(p: Position) {
try {
await ElMessageBox.confirm(`确定删除位置 #${p.indexVal} 吗?`, '确认', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return
}
await positionsApi.deletePosition(p.id)
ElMessage.success('删除成功')
fetchList()
}
function getCategoryName(p: Position): string {
if (!p.category) return p.categoryId || '-'
return p.category.parent ? `${p.category.parent.categoryName} / ${p.category.categoryName}` : p.category.categoryName
}
onMounted(async () => {
await loadLookups()
await fetchList()
})
</script>
<template>
<div class="page-container">
<div class="page-card">
<div class="filter-bar">
<el-select
v-model="filter.countryId"
placeholder="请选择国家"
clearable
@change="handleSearch"
>
<el-option
v-for="c in countries"
:key="c.id"
:label="c.countryName"
:value="c.id"
/>
</el-select>
<el-cascader
v-model="filter.categoryId"
:options="cascaderOptions"
:props="{ checkStrictly: true, value: 'value', label: 'label', children: 'children', emitPath: false }"
placeholder="请选择分类"
clearable
@change="handleSearch"
/>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
<span>搜索</span>
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
<span>重置</span>
</el-button>
<div class="filter-spacer" />
<el-button type="primary" @click="openAddDialog">
<el-icon><Plus /></el-icon>
<span>新增位置</span>
</el-button>
</div>
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column label="排序值" prop="indexVal" width="100" sortable />
<el-table-column label="国家" min-width="160">
<template #default="{ row }: { row: Position }">
{{ row.country?.countryName || '-' }}
</template>
</el-table-column>
<el-table-column label="分类" min-width="200">
<template #default="{ row }: { row: Position }">
{{ getCategoryName(row) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }: { row: Position }">
<div class="table-actions">
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
<el-icon><Edit /></el-icon>
<span>编辑</span>
</el-button>
<el-button size="small" type="danger" plain @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
<span>删除</span>
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无位置" />
</template>
</el-table>
<el-pagination
class="pagination"
v-model:current-page="filter.page"
v-model:page-size="filter.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="(p: number) => { filter.page = p; fetchList() }"
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
/>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogMode === 'create' ? '新增位置' : '编辑位置'"
width="520px"
destroy-on-close
>
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
<el-form-item label="排序值" prop="indexVal">
<el-input-number v-model="dialogForm.indexVal" :min="0" :max="9999" />
</el-form-item>
<el-form-item label="国家">
<el-select v-model="dialogForm.countryId" placeholder="可选" clearable style="width: 100%">
<el-option
v-for="c in countries"
:key="c.id"
:label="c.countryName"
:value="c.id"
/>
</el-select>
</el-form-item>
<el-form-item label="分类">
<el-cascader
v-model="dialogForm.categoryId"
:options="cascaderOptions"
:props="{
checkStrictly: true,
value: 'value',
label: 'label',
children: 'children',
emitPath: false,
}"
placeholder="可选"
clearable
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
{{ dialogMode === 'create' ? '创建' : '保存' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.filter-spacer {
flex: 1;
}
.pagination {
margin-top: 16px;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { markRaw, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Goods, Refresh } from '@element-plus/icons-vue'
import GoodsView from '@/views/goods/GoodsView.vue'
import SyncView from '@/views/sync/SyncView.vue'
const route = useRoute()
const router = useRouter()
const tabs = [
{ name: 'goods', label: '商品配置', icon: markRaw(Goods), comp: markRaw(GoodsView) },
{ name: 'sync', label: '数据同步', icon: markRaw(Refresh), comp: markRaw(SyncView) },
]
const activeTab = ref((route.query.tab as string) || 'goods')
watch(activeTab, (val) => {
router.replace({ query: { ...route.query, tab: val } })
})
</script>
<template>
<div class="pm-wrapper">
<el-tabs v-model="activeTab" class="pm-tabs" type="card">
<el-tab-pane
v-for="t in tabs"
:key="t.name"
:name="t.name"
>
<template #label>
<span class="pm-tab-label">
<el-icon class="pm-tab-icon"><component :is="t.icon" /></el-icon>
{{ t.label }}
</span>
</template>
<component :is="t.comp" />
</el-tab-pane>
</el-tabs>
</div>
</template>
<style scoped>
.pm-wrapper { height: 100%; display: flex; flex-direction: column; }
.pm-tabs { height: 100%; display: flex; flex-direction: column; }
.pm-tabs :deep(.el-tabs__header) { margin-bottom: 0; flex-shrink: 0; }
.pm-tabs :deep(.el-tabs__content) { flex: 1; min-height: 0; overflow: hidden; }
.pm-tabs :deep(.el-tab-pane) { height: 100%; }
.pm-tab-label {
display: inline-flex;
align-items: center;
gap: 4px;
}
.pm-tab-icon { font-size: 14px; }
</style>
+160
View File
@@ -0,0 +1,160 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, Box } from '@element-plus/icons-vue'
import type { SyncLog } from '@/types'
import { syncApi } from '@/api/sync'
const logs = ref<SyncLog[]>([])
const loading = ref(false)
const syncing = ref(false)
let timer: ReturnType<typeof setInterval> | null = null
async function refreshLogs() {
loading.value = true
try {
const data = await syncApi.getSyncStatus(50) as any
logs.value = Array.isArray(data) ? data : []
} catch (err) {
console.warn('Failed to fetch sync status', err)
} finally {
loading.value = false
}
}
async function handleSync() {
try {
await ElMessageBox.confirm(
'确定立即执行产品同步吗?此操作可能需要一些时间。',
'确认',
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
)
} catch { return }
syncing.value = true
try {
await syncApi.syncProducts()
ElMessage.success('产品同步完成')
await refreshLogs()
} catch {
ElMessage.error('产品同步失败')
} finally {
syncing.value = false
}
}
function formatDate(s?: string): string {
if (!s) return '-'
return new Date(s).toLocaleString()
}
onMounted(() => {
refreshLogs()
timer = setInterval(refreshLogs, 30_000)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
</script>
<template>
<div class="page-container">
<div class="sync-cards">
<el-card class="sync-card">
<template #header>
<div class="sync-card-header">
<div class="sync-card-title">
<el-icon><Box /></el-icon>
<span>产品同步</span>
</div>
</div>
</template>
<p class="sync-card-desc">
从上游拉取最新产品和原产品并与本地数据库进行同步
</p>
<el-button type="primary" :loading="syncing" @click="handleSync">
<el-icon><Refresh /></el-icon>
<span>执行产品同步</span>
</el-button>
</el-card>
</div>
<div class="page-card logs-card">
<div class="logs-toolbar">
<h3 class="logs-title">同步日志</h3>
<el-button @click="refreshLogs">
<el-icon><Refresh /></el-icon>
<span>刷新</span>
</el-button>
</div>
<el-table v-loading="loading" :data="logs" border stripe>
<el-table-column label="状态" width="100">
<template #default="{ row }: { row: SyncLog }">
<el-tag :type="row.status === 'SUCCESS' ? 'success' : 'danger'" size="small">
{{ row.status === 'SUCCESS' ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="时间" width="200">
<template #default="{ row }: { row: SyncLog }">
{{ formatDate(row.startTime) }}
</template>
</el-table-column>
<el-table-column prop="message" label="信息" min-width="200" show-overflow-tooltip />
<template #empty>
<el-empty description="暂无同步记录" />
</template>
</el-table>
</div>
</div>
</template>
<style scoped>
.sync-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.sync-card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.sync-card-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 15px;
}
.sync-card-title :deep(.el-icon) {
color: var(--brand-color);
}
.sync-card-desc {
color: #6b7280;
font-size: 13px;
margin: 0 0 16px;
min-height: 40px;
}
.logs-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.logs-title {
margin: 0;
font-size: 16px;
font-weight: 600;
}
</style>
+324
View File
@@ -0,0 +1,324 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
import type {
Tag,
TagGroup,
CreateTagRequest,
UpdateTagRequest,
TagFilter,
} from '@/types'
import { tagsApi } from '@/api/tags'
import { tagGroupsApi } from '@/api/tag-groups'
const loading = ref(false)
const list = ref<Tag[]>([])
const total = ref(0)
const allGroups = ref<TagGroup[]>([])
const filter = reactive<Required<TagFilter>>({
tagName: '',
page: 1,
pageSize: 10,
})
async function fetchList() {
loading.value = true
try {
const res = await tagsApi.getTagsList(filter)
const data = await tagsApi.getTagsList(filter) as any
const arr = Array.isArray(data) ? data : (data.items ?? [])
list.value = arr
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
} finally {
loading.value = false
}
}
async function loadGroups() {
allGroups.value = await tagGroupsApi.getTagGroupsList()
}
function handleSearch() {
filter.page = 1
fetchList()
}
function handleReset() {
filter.tagName = ''
filter.page = 1
fetchList()
}
const dialogRef = ref()
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const dialogLoading = ref(false)
const dialogForm = reactive<CreateTagRequest & { id?: string; tagGroupId?: number | null }>({
id: undefined,
tagName: '',
tagColor: '#ff6800',
tagFontColor: '#ffffff',
timing: '',
tagGroupId: null,
})
const dialogRules = {
tagName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
}
function openAddDialog() {
dialogMode.value = 'create'
Object.assign(dialogForm, { id: undefined, tagName: '', tagColor: '#ff6800', tagFontColor: '#ffffff', timing: '', tagGroupId: null })
dialogVisible.value = true
}
function openEditDialog(t: Tag) {
dialogMode.value = 'edit'
Object.assign(dialogForm, {
id: t.id,
tagName: t.tagName,
tagColor: t.tagColor || '#ff6800',
tagFontColor: t.tagFontColor || '#ffffff',
timing: t.timing || '',
tagGroupId: t.tagGroupId ? Number(t.tagGroupId) : null,
})
dialogVisible.value = true
}
async function handleSubmit() {
if (!dialogRef.value) return
await dialogRef.value.validate(async (valid: boolean) => {
if (!valid) return
dialogLoading.value = true
try {
const payload: CreateTagRequest = {
tagName: dialogForm.tagName,
tagColor: dialogForm.tagColor || undefined,
tagFontColor: dialogForm.tagFontColor || undefined,
timing: dialogForm.timing || undefined,
tagGroupId: dialogForm.tagGroupId ?? undefined,
}
if (dialogMode.value === 'create') {
await tagsApi.createTag(payload)
ElMessage.success('标签创建成功')
} else {
await tagsApi.updateTag(dialogForm.id!, payload as UpdateTagRequest)
ElMessage.success('标签更新成功')
}
dialogVisible.value = false
fetchList()
} finally {
dialogLoading.value = false
}
})
}
async function handleDelete(t: Tag) {
try {
await ElMessageBox.confirm(`确定删除「${t.tagName}」吗?`, '确认', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return
}
await tagsApi.deleteTag(t.id)
ElMessage.success('删除成功')
fetchList()
}
onMounted(() => {
fetchList()
loadGroups()
})
</script>
<template>
<div class="page-container">
<div class="page-card">
<div class="filter-bar">
<el-input
v-model="filter.tagName"
placeholder="按名称搜索"
clearable
@keyup.enter="handleSearch"
@clear="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
<span>搜索</span>
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
<span>重置</span>
</el-button>
<div class="filter-spacer" />
<el-button type="primary" @click="openAddDialog">
<el-icon><Plus /></el-icon>
<span>新增标签</span>
</el-button>
</div>
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column label="预览" width="120">
<template #default="{ row }: { row: Tag }">
<span
v-if="row.tagColor"
class="tag-preview"
:style="{ background: row.tagColor, color: row.tagFontColor || '#fff' }"
>{{ row.tagName }}</span>
<el-tag v-else effect="plain">{{ row.tagName }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="tagName" label="名称" min-width="200" />
<el-table-column label="背景色" width="140">
<template #default="{ row }: { row: Tag }">
<div class="color-cell">
<span class="color-swatch" :style="{ background: row.tagColor || '#d1d5db' }" />
<span class="color-hex">{{ row.tagColor || '-' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="字体色" width="140">
<template #default="{ row }: { row: Tag }">
<div class="color-cell">
<span class="color-swatch" :style="{ background: row.tagFontColor || '#d1d5db' }" />
<span class="color-hex">{{ row.tagFontColor || '-' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="所属分组" min-width="140">
<template #default="{ row }: { row: Tag }">
<span v-if="row.tagGroup">{{ row.tagGroup.groupName }}</span>
<span v-else style="color: #999;">未分组</span>
</template>
</el-table-column>
<el-table-column prop="timing" label="定时" min-width="120" />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }: { row: Tag }">
<div class="table-actions">
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
<el-icon><Edit /></el-icon>
<span>编辑</span>
</el-button>
<el-button size="small" type="danger" plain @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
<span>删除</span>
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无标签" />
</template>
</el-table>
<el-pagination
class="pagination"
v-model:current-page="filter.page"
v-model:page-size="filter.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="(p: number) => { filter.page = p; fetchList() }"
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
/>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogMode === 'create' ? '新增标签' : '编辑标签'"
width="480px"
destroy-on-close
>
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
<el-form-item label="名称" prop="tagName">
<el-input v-model="dialogForm.tagName" placeholder="请输入标签名称" />
</el-form-item>
<el-form-item label="背景色">
<el-color-picker v-model="dialogForm.tagColor" />
<span class="color-readout">{{ dialogForm.tagColor }}</span>
</el-form-item>
<el-form-item label="字体色">
<el-color-picker v-model="dialogForm.tagFontColor" />
<span class="color-readout">{{ dialogForm.tagFontColor }}</span>
</el-form-item>
<el-form-item label="所属分组">
<el-select v-model="dialogForm.tagGroupId" clearable placeholder="未分组" style="width: 100%">
<el-option
v-for="g in allGroups"
:key="g.id"
:label="g.groupName"
:value="Number(g.id)"
/>
</el-select>
</el-form-item>
<el-form-item label="定时">
<el-input v-model="dialogForm.timing" placeholder="例如 9:00-12:00" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
{{ dialogMode === 'create' ? '创建' : '保存' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.filter-spacer {
flex: 1;
}
.pagination {
margin-top: 16px;
justify-content: flex-end;
}
.color-cell {
display: flex;
align-items: center;
gap: 8px;
}
.color-swatch {
display: inline-block;
width: 20px;
height: 20px;
border-radius: 4px;
border: 1px solid #e5e7eb;
}
.color-hex {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
color: #6b7280;
}
.color-readout {
margin-left: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
color: #6b7280;
}
.tag-preview {
display: inline-block;
padding: 2px 12px;
border-radius: 4px;
font-size: 13px;
white-space: nowrap;
}
</style>
+27
View File
@@ -0,0 +1,27 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client", "node"],
/* Path aliases */
"ignoreDeprecations": "6.0",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"src/auto-imports.d.ts",
"src/components.d.ts"
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+37
View File
@@ -0,0 +1,37 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import { fileURLToPath, URL } from 'node:url'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
imports: ['vue', 'vue-router', 'pinia'],
dts: 'src/auto-imports.d.ts',
}),
Components({
resolvers: [ElementPlusResolver()],
dts: 'src/components.d.ts',
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
})
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
coverage
*.log
.DS_Store
.env
+25
View File
@@ -0,0 +1,25 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
};
+45
View File
@@ -0,0 +1,45 @@
# compiled output
/dist
/node_modules
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# Environment variables
.env
.env.local
.env.*.local
# Prisma
# prisma/migrations (keep migrations in VCS)
# TypeScript
*.tsbuildinfo
+7
View File
@@ -0,0 +1,7 @@
{
"singleQuote": true,
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"printWidth": 100
}
+15
View File
@@ -0,0 +1,15 @@
module.exports = {
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
testEnvironment: 'node',
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
collectCoverageFrom: ['**/*.(t|j)s'],
coverageDirectory: '../coverage',
coveragePathIgnorePatterns: ['/node_modules/', '/test/'],
};
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"webpack": false
}
}
+70
View File
@@ -0,0 +1,70 @@
{
"name": "@inkreach/api",
"private": true,
"version": "1.0.0",
"description": "NestJS backend for InkReach Product Center",
"main": "dist/main.js",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio"
},
"dependencies": {
"@nestjs/axios": "^3.0.1",
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/schedule": "^4.0.0",
"@nestjs/swagger": "^7.1.17",
"@prisma/client": "^5.8.0",
"axios": "^1.6.5",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.2.1",
"@nestjs/schematics": "^10.0.3",
"@nestjs/testing": "^10.3.0",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.11",
"@types/node": "^20.10.6",
"@types/passport-jwt": "^4.0.0",
"@types/supertest": "^6.0.2",
"@typescript-eslint/eslint-plugin": "^6.17.0",
"@typescript-eslint/parser": "^6.17.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.2",
"jest": "^29.7.0",
"prettier": "^3.1.1",
"prisma": "^5.8.0",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.1",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.3.3"
}
}
@@ -0,0 +1,187 @@
-- CreateEnum
CREATE TYPE "SyncType" AS ENUM ('CATEGORIES', 'PRODUCTS');
-- CreateEnum
CREATE TYPE "SyncStatus" AS ENUM ('RUNNING', 'SUCCESS', 'FAILED');
-- CreateTable
CREATE TABLE "origin_goods" (
"origin_good_id" BIGSERIAL NOT NULL,
"sds_good_id" TEXT NOT NULL,
"sds_category_id" TEXT,
"good_name" TEXT,
"good_image" TEXT,
"good_price" DECIMAL(12,2),
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "origin_goods_pkey" PRIMARY KEY ("origin_good_id")
);
-- CreateTable
CREATE TABLE "countries" (
"country_id" BIGSERIAL NOT NULL,
"country_name" TEXT NOT NULL,
"country_icon" TEXT,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "countries_pkey" PRIMARY KEY ("country_id")
);
-- CreateTable
CREATE TABLE "categories" (
"category_id" BIGSERIAL NOT NULL,
"parent_category_id" BIGINT,
"category_name" TEXT NOT NULL,
"category_icon" TEXT,
"sds_category_id" TEXT,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "categories_pkey" PRIMARY KEY ("category_id")
);
-- CreateTable
CREATE TABLE "tags" (
"tag_id" BIGSERIAL NOT NULL,
"tag_name" TEXT NOT NULL,
"tag_color" TEXT,
"timing" TEXT,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tags_pkey" PRIMARY KEY ("tag_id")
);
-- CreateTable
CREATE TABLE "positions" (
"position_id" BIGSERIAL NOT NULL,
"index_val" INTEGER NOT NULL,
"country_id" BIGINT,
"category_id" BIGINT,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "positions_pkey" PRIMARY KEY ("position_id")
);
-- CreateTable
CREATE TABLE "goods" (
"good_id" BIGSERIAL NOT NULL,
"origin_good_id" BIGINT NOT NULL,
"country_id" BIGINT NOT NULL,
"category_id" BIGINT NOT NULL,
"tag_id" BIGINT,
"position_id" BIGINT,
"good_name" TEXT NOT NULL,
"good_priority" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "goods_pkey" PRIMARY KEY ("good_id")
);
-- CreateTable
CREATE TABLE "users" (
"id" BIGSERIAL NOT NULL,
"username" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sync_logs" (
"id" BIGSERIAL NOT NULL,
"type" "SyncType" NOT NULL,
"status" "SyncStatus" NOT NULL,
"message" TEXT,
"started_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"finished_at" TIMESTAMPTZ(6),
CONSTRAINT "sync_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "origin_goods_sds_good_id_key" ON "origin_goods"("sds_good_id");
-- CreateIndex
CREATE INDEX "origin_goods_sds_category_id_idx" ON "origin_goods"("sds_category_id");
-- CreateIndex
CREATE UNIQUE INDEX "countries_country_name_key" ON "countries"("country_name");
-- CreateIndex
CREATE UNIQUE INDEX "categories_sds_category_id_key" ON "categories"("sds_category_id");
-- CreateIndex
CREATE INDEX "categories_parent_category_id_idx" ON "categories"("parent_category_id");
-- CreateIndex
CREATE UNIQUE INDEX "tags_tag_name_key" ON "tags"("tag_name");
-- CreateIndex
CREATE INDEX "positions_country_id_idx" ON "positions"("country_id");
-- CreateIndex
CREATE INDEX "positions_category_id_idx" ON "positions"("category_id");
-- CreateIndex
CREATE INDEX "positions_country_id_category_id_idx" ON "positions"("country_id", "category_id");
-- CreateIndex
CREATE INDEX "goods_origin_good_id_idx" ON "goods"("origin_good_id");
-- CreateIndex
CREATE INDEX "goods_country_id_idx" ON "goods"("country_id");
-- CreateIndex
CREATE INDEX "goods_category_id_idx" ON "goods"("category_id");
-- CreateIndex
CREATE INDEX "goods_tag_id_idx" ON "goods"("tag_id");
-- CreateIndex
CREATE INDEX "goods_position_id_idx" ON "goods"("position_id");
-- CreateIndex
CREATE INDEX "goods_good_priority_idx" ON "goods"("good_priority" DESC);
-- CreateIndex
CREATE INDEX "goods_country_id_good_priority_idx" ON "goods"("country_id", "good_priority" DESC);
-- CreateIndex
CREATE INDEX "goods_category_id_country_id_idx" ON "goods"("category_id", "country_id");
-- CreateIndex
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
-- CreateIndex
CREATE INDEX "sync_logs_type_started_at_idx" ON "sync_logs"("type", "started_at");
-- AddForeignKey
ALTER TABLE "categories" ADD CONSTRAINT "categories_parent_category_id_fkey" FOREIGN KEY ("parent_category_id") REFERENCES "categories"("category_id") ON DELETE RESTRICT ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "positions" ADD CONSTRAINT "positions_country_id_fkey" FOREIGN KEY ("country_id") REFERENCES "countries"("country_id") ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "positions" ADD CONSTRAINT "positions_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("category_id") ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "goods" ADD CONSTRAINT "goods_origin_good_id_fkey" FOREIGN KEY ("origin_good_id") REFERENCES "origin_goods"("origin_good_id") ON DELETE RESTRICT ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "goods" ADD CONSTRAINT "goods_country_id_fkey" FOREIGN KEY ("country_id") REFERENCES "countries"("country_id") ON DELETE RESTRICT ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "goods" ADD CONSTRAINT "goods_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("category_id") ON DELETE RESTRICT ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "goods" ADD CONSTRAINT "goods_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "tags"("tag_id") ON DELETE SET NULL ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "goods" ADD CONSTRAINT "goods_position_id_fkey" FOREIGN KEY ("position_id") REFERENCES "positions"("position_id") ON DELETE SET NULL ON UPDATE NO ACTION;
@@ -0,0 +1,53 @@
-- AlterTable
ALTER TABLE "tags" ADD COLUMN "tag_group_id" BIGINT,
ADD COLUMN "sort_order" INTEGER NOT NULL DEFAULT 0;
-- CreateTable
CREATE TABLE "tag_groups" (
"tag_group_id" BIGSERIAL NOT NULL,
"group_name" TEXT NOT NULL,
"group_icon" TEXT,
"group_color" TEXT,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tag_groups_pkey" PRIMARY KEY ("tag_group_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "tag_groups_group_name_key" ON "tag_groups"("group_name");
-- CreateIndex
CREATE INDEX "tags_tag_group_id_idx" ON "tags"("tag_group_id");
-- CreateIndex
CREATE INDEX "tags_tag_group_id_sort_order_idx" ON "tags"("tag_group_id", "sort_order");
-- AddForeignKey
ALTER TABLE "tags" ADD CONSTRAINT "tags_tag_group_id_fkey" FOREIGN KEY ("tag_group_id") REFERENCES "tag_groups"("tag_group_id") ON DELETE SET NULL ON UPDATE NO ACTION;
-- Seed: insert 3 default tag groups
INSERT INTO "tag_groups" ("group_name", "sort_order", "created_at", "updated_at") VALUES
('物流渠道', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('印刷位置', 2, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('印刷工艺', 3, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
-- Seed: assign existing tags to groups
UPDATE "tags" SET "tag_group_id" = (SELECT "tag_group_id" FROM "tag_groups" WHERE "group_name" = '物流渠道')
WHERE "tag_name" IN ('包邮', '不包邮');
UPDATE "tags" SET "tag_group_id" = (SELECT "tag_group_id" FROM "tag_groups" WHERE "group_name" = '印刷位置')
WHERE "tag_name" IN ('单面印', '双面印');
UPDATE "tags" SET "tag_group_id" = (SELECT "tag_group_id" FROM "tag_groups" WHERE "group_name" = '印刷工艺')
WHERE "tag_name" IN ('烫画', '直喷', '不打印');
-- Seed: assign sortOrder within each group by tag_id
WITH ordered AS (
SELECT "tag_id",
ROW_NUMBER() OVER (PARTITION BY "tag_group_id" ORDER BY "tag_id") AS rn
FROM "tags"
WHERE "tag_group_id" IS NOT NULL
)
UPDATE "tags" SET "sort_order" = ordered.rn
FROM ordered
WHERE "tags"."tag_id" = ordered."tag_id";
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
+201
View File
@@ -0,0 +1,201 @@
// InkReach Product Center Prisma Schema
// Generated based on docs/dev/database-table-design.md
// All TIMESTAMPTZ columns use DateTime @db.Timestamptz(6)
// All BIGINT columns use BigInt
// snake_case table & column names via @@map / @map
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ---------- Origin Goods ----------
model OriginGood {
id BigInt @id @default(autoincrement()) @map("origin_good_id")
sdsGoodId String @unique @map("sds_good_id")
// Cached SDS product metadata (filled during sync)
sdsCategoryId String? @map("sds_category_id")
goodName String? @map("good_name")
goodImage String? @map("good_image")
goodPrice Decimal? @map("good_price") @db.Decimal(12, 2)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
goods Good[]
@@index([sdsCategoryId])
@@map("origin_goods")
}
// ---------- Countries ----------
model Country {
id BigInt @id @default(autoincrement()) @map("country_id")
countryName String @unique @map("country_name")
countryIcon String? @map("country_icon")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
goods Good[]
positions Position[]
@@map("countries")
}
// ---------- Categories (self-referential tree) ----------
model Category {
id BigInt @id @default(autoincrement()) @map("category_id")
parentCategoryId BigInt? @map("parent_category_id")
categoryName String @map("category_name")
categoryIcon String? @map("category_icon")
sdsCategoryId String? @unique @map("sds_category_id")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
parent Category? @relation("CategoryToCategory", fields: [parentCategoryId], references: [id], onDelete: Restrict, onUpdate: NoAction)
children Category[] @relation("CategoryToCategory")
goods Good[]
positions Position[]
@@index([parentCategoryId])
@@map("categories")
}
// ---------- Tag Groups ----------
model TagGroup {
id BigInt @id @default(autoincrement()) @map("tag_group_id")
groupName String @unique @map("group_name")
groupIcon String? @map("group_icon")
groupColor String? @map("group_color")
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
tags Tag[]
@@map("tag_groups")
}
// ---------- Tags ----------
model Tag {
id BigInt @id @default(autoincrement()) @map("tag_id")
tagName String @unique @map("tag_name")
tagColor String? @map("tag_color")
tagFontColor String? @map("tag_font_color")
timing String?
tagGroupId BigInt? @map("tag_group_id")
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
goods Good[]
goodTags GoodTag[]
tagGroup TagGroup? @relation(fields: [tagGroupId], references: [id], onDelete: SetNull, onUpdate: NoAction)
@@index([tagGroupId])
@@index([tagGroupId, sortOrder])
@@map("tags")
}
// ---------- Positions ----------
model Position {
id BigInt @id @default(autoincrement()) @map("position_id")
indexVal Int @map("index_val")
countryId BigInt? @map("country_id")
categoryId BigInt? @map("category_id")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
country Country? @relation(fields: [countryId], references: [id], onDelete: Cascade, onUpdate: NoAction)
category Category? @relation(fields: [categoryId], references: [id], onDelete: Cascade, onUpdate: NoAction)
goods Good[]
@@index([countryId])
@@index([categoryId])
@@index([countryId, categoryId])
@@map("positions")
}
// ---------- Goods ----------
model Good {
id BigInt @id @default(autoincrement()) @map("good_id")
originGoodId BigInt @map("origin_good_id")
countryId BigInt @map("country_id")
categoryId BigInt @map("category_id")
tagId BigInt? @map("tag_id")
positionId BigInt? @map("position_id")
goodName String @map("good_name")
goodImage String? @map("good_image")
goodPriority Int @default(0) @map("good_priority")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Restrict, onUpdate: NoAction)
country Country @relation(fields: [countryId], references: [id], onDelete: Restrict, onUpdate: NoAction)
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict, onUpdate: NoAction)
tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull, onUpdate: NoAction)
position Position? @relation(fields: [positionId], references: [id], onDelete: SetNull, onUpdate: NoAction)
goodTags GoodTag[]
@@index([originGoodId])
@@index([countryId])
@@index([categoryId])
@@index([tagId])
@@index([positionId])
@@index([goodPriority(sort: Desc)])
@@index([countryId, goodPriority(sort: Desc)])
@@index([categoryId, countryId])
@@map("goods")
}
// ---------- Good-Tag Junction (M:N) ----------
model GoodTag {
goodId BigInt @map("good_id")
tagId BigInt @map("tag_id")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
good Good @relation(fields: [goodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@id([goodId, tagId])
@@index([tagId])
@@map("good_tags")
}
// ---------- Users (admin authentication) ----------
model User {
id BigInt @id @default(autoincrement())
username String @unique
passwordHash String @map("password_hash")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
@@map("users")
}
// ---------- Sync Logs ----------
enum SyncType {
CATEGORIES
PRODUCTS
}
enum SyncStatus {
RUNNING
SUCCESS
FAILED
}
model SyncLog {
id BigInt @id @default(autoincrement())
type SyncType
status SyncStatus
message String?
startedAt DateTime @default(now()) @map("started_at") @db.Timestamptz(6)
finishedAt DateTime? @map("finished_at") @db.Timestamptz(6)
@@index([type, startedAt])
@@map("sync_logs")
}
+33
View File
@@ -0,0 +1,33 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { CountriesModule } from './countries/countries.module';
import { CategoriesModule } from './categories/categories.module';
import { TagsModule } from './tags/tags.module';
import { TagGroupsModule } from './tag-groups/tag-groups.module';
import { PositionsModule } from './positions/positions.module';
import { OriginGoodsModule } from './origin-goods/origin-goods.module';
import { GoodsModule } from './goods/goods.module';
import { SyncModule } from './sync/sync.module';
import { PublicModule } from './public/public.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
PrismaModule,
AuthModule,
CountriesModule,
CategoriesModule,
TagsModule,
TagGroupsModule,
PositionsModule,
OriginGoodsModule,
GoodsModule,
SyncModule,
PublicModule,
],
})
export class AppModule {}
+37
View File
@@ -0,0 +1,37 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import {
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import {
LoginResponseDto,
UserPublicDto,
} from './dto/auth-response.dto';
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Register a new admin user' })
@ApiResponse({ status: 201, type: UserPublicDto })
@ApiResponse({ status: 409, description: 'Username already exists' })
register(@Body() dto: RegisterDto): Promise<UserPublicDto> {
return this.authService.register(dto) as unknown as Promise<UserPublicDto>;
}
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Login and obtain a JWT' })
@ApiResponse({ status: 200, type: LoginResponseDto })
@ApiResponse({ status: 401, description: 'Invalid credentials' })
login(@Body() dto: LoginDto): Promise<LoginResponseDto> {
return this.authService.login(dto) as unknown as Promise<LoginResponseDto>;
}
}
+31
View File
@@ -0,0 +1,31 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => {
const secret = config.get<string>('JWT_SECRET');
if (!secret) {
throw new Error('JWT_SECRET must be configured');
}
return {
secret,
signOptions: { expiresIn: '7d' },
};
},
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtModule],
})
export class AuthModule {}
+102
View File
@@ -0,0 +1,102 @@
import { Test } from '@nestjs/testing';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule } from '@nestjs/config';
import { ConflictException, UnauthorizedException } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { AuthService } from './auth.service';
import { PrismaService } from '../prisma/prisma.service';
describe('AuthService', () => {
let service: AuthService;
let prisma: PrismaService;
const createdUsernames: string[] = [];
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
JwtModule.register({
secret: 'test-secret',
signOptions: { expiresIn: '1h' },
}),
],
providers: [AuthService, PrismaService],
}).compile();
service = moduleRef.get(AuthService);
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
});
afterAll(async () => {
// Cleanup created test users
if (createdUsernames.length) {
await prisma.user.deleteMany({
where: { username: { in: createdUsernames } },
});
}
await prisma.onModuleDestroy();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('register', () => {
it('creates a new user and stores a hashed password', async () => {
const username = `test_reg_${Date.now()}`;
createdUsernames.push(username);
const user = await service.register({ username, password: 'plain-pwd' });
expect(user.username).toBe(username);
expect(user.id).toBeTruthy();
const stored = await prisma.user.findUnique({ where: { username } });
expect(stored).not.toBeNull();
expect(stored?.passwordHash).not.toBe('plain-pwd');
const matches = await bcrypt.compare('plain-pwd', stored!.passwordHash);
expect(matches).toBe(true);
});
it('throws ConflictException for duplicate usernames', async () => {
const username = `test_dup_${Date.now()}`;
createdUsernames.push(username);
await service.register({ username, password: 'pwd1234' });
await expect(
service.register({ username, password: 'pwd5678' }),
).rejects.toBeInstanceOf(ConflictException);
});
});
describe('login', () => {
it('returns an access token for valid credentials', async () => {
const username = `test_login_${Date.now()}`;
createdUsernames.push(username);
await service.register({ username, password: 'correct-pwd' });
const result = await service.login({ username, password: 'correct-pwd' });
expect(result.accessToken).toEqual(expect.any(String));
const parts = result.accessToken.split('.');
expect(parts.length).toBe(3);
expect(result.user.username).toBe(username);
});
it('throws UnauthorizedException for wrong password', async () => {
const username = `test_wrong_${Date.now()}`;
createdUsernames.push(username);
await service.register({ username, password: 'right-pwd' });
await expect(
service.login({ username, password: 'wrong-pwd' }),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('throws UnauthorizedException for unknown user', async () => {
await expect(
service.login({ username: 'no-such-user-xyz', password: 'whatever' }),
).rejects.toBeInstanceOf(UnauthorizedException);
});
});
});
+87
View File
@@ -0,0 +1,87 @@
import {
ConflictException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../prisma/prisma.service';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import type { JwtPayload } from './strategies/jwt.strategy';
export interface PublicUser {
id: string;
username: string;
createdAt: string;
}
export interface LoginResult {
accessToken: string;
user: PublicUser;
}
const BCRYPT_ROUNDS = 10;
const TOKEN_EXPIRES_IN = '7d';
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
) {}
/**
* Registers a brand-new admin user. Throws {@link ConflictException}
* if the username is already taken.
*/
async register(dto: RegisterDto): Promise<PublicUser> {
const existing = await this.prisma.user.findUnique({
where: { username: dto.username },
});
if (existing) {
throw new ConflictException('Username already exists');
}
const passwordHash = await bcrypt.hash(dto.password, BCRYPT_ROUNDS);
const created = await this.prisma.user.create({
data: { username: dto.username, passwordHash },
});
return this.toPublic(created);
}
/**
* Verifies credentials and returns a signed JWT.
*/
async login(dto: LoginDto): Promise<LoginResult> {
const user = await this.prisma.user.findUnique({
where: { username: dto.username },
});
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
const ok = await bcrypt.compare(dto.password, user.passwordHash);
if (!ok) {
throw new UnauthorizedException('Invalid credentials');
}
const payload: JwtPayload = {
sub: user.id.toString(),
username: user.username,
};
const accessToken = await this.jwt.signAsync(payload, {
expiresIn: TOKEN_EXPIRES_IN,
});
return { accessToken, user: this.toPublic(user) };
}
private toPublic(user: {
id: bigint;
username: string;
createdAt: Date;
}): PublicUser {
return {
id: user.id.toString(),
username: user.username,
createdAt: user.createdAt.toISOString(),
};
}
}
@@ -0,0 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
export class UserPublicDto {
@ApiProperty({ description: 'User ID (bigint serialized as string)' })
id!: string;
@ApiProperty()
username!: string;
@ApiProperty()
createdAt!: string;
}
export class LoginResponseDto {
@ApiProperty()
accessToken!: string;
@ApiProperty({ type: UserPublicDto })
user!: UserPublicDto;
}
+14
View File
@@ -0,0 +1,14 @@
import { IsNotEmpty, IsString } from 'class-validator';
/**
* Payload accepted by `POST /auth/login`.
*/
export class LoginDto {
@IsString()
@IsNotEmpty()
username!: string;
@IsString()
@IsNotEmpty()
password!: string;
}
+16
View File
@@ -0,0 +1,16 @@
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
/**
* Payload accepted by `POST /auth/register`.
*
* Username must be unique (enforced by DB); password is hashed with bcrypt.
*/
export class RegisterDto {
@IsString()
@IsNotEmpty()
username!: string;
@IsString()
@MinLength(6)
password!: string;
}
@@ -0,0 +1,10 @@
import { AuthGuard } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
/**
* Default guard for every admin route. Delegates to passport-jwt.
*
* Apply with `@UseGuards(JwtAuthGuard)`.
*/
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
@@ -0,0 +1,40 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
/**
* Shape of the JWT we issue.
*
* `sub` is the user ID as a string (bigints are serialized to strings in JSON).
*/
export interface JwtPayload {
sub: string;
username: string;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
const secret = config.get<string>('JWT_SECRET');
if (!secret) {
throw new Error('JWT_SECRET is not configured');
}
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: secret,
});
}
/**
* Runs on every authenticated request. The returned object becomes
* `request.user` for downstream controllers.
*/
validate(payload: JwtPayload): { id: bigint; username: string } {
if (!payload?.sub || !payload.username) {
throw new UnauthorizedException('Invalid token payload');
}
return { id: BigInt(payload.sub), username: payload.username };
}
}
@@ -0,0 +1,67 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CategoriesService } from './categories.service';
import { CreateCategoryDto } from './dto/create-category.dto';
import { UpdateCategoryDto } from './dto/update-category.dto';
@ApiTags('categories')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('categories')
export class CategoriesController {
constructor(private readonly service: CategoriesService) {}
@Get()
@ApiOperation({ summary: 'Return categories as a tree' })
findAll() {
return this.service.findAll();
}
@Get('flat')
@ApiOperation({ summary: 'Return categories as a flat list' })
findFlat() {
return this.service.findFlat();
}
@Get(':id')
@ApiOperation({ summary: 'Get one category' })
findOne(@Param('id', ParseIntPipe) id: string) {
return this.service.findOne(BigInt(id));
}
@Post()
@ApiOperation({ summary: 'Create a category' })
create(@Body() dto: CreateCategoryDto) {
return this.service.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a category' })
update(
@Param('id', ParseIntPipe) id: string,
@Body() dto: UpdateCategoryDto,
) {
return this.service.update(BigInt(id), dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a category' })
remove(@Param('id', ParseIntPipe) id: string) {
return this.service.remove(BigInt(id));
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CategoriesController } from './categories.controller';
import { CategoriesService } from './categories.service';
@Module({
controllers: [CategoriesController],
providers: [CategoriesService],
exports: [CategoriesService],
})
export class CategoriesModule {}
@@ -0,0 +1,103 @@
import { Test } from '@nestjs/testing';
import {
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { CategoriesService } from './categories.service';
import { PrismaService } from '../prisma/prisma.service';
describe('CategoriesService', () => {
let service: CategoriesService;
let prisma: PrismaService;
const createdNames: string[] = [];
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [CategoriesService, PrismaService],
}).compile();
service = moduleRef.get(CategoriesService);
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
});
afterAll(async () => {
if (createdNames.length) {
await prisma.category.deleteMany({
where: { categoryName: { in: createdNames } },
});
}
await prisma.onModuleDestroy();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('creates root + child + grandchild and assembles them into a tree', async () => {
const stamp = Date.now();
const rootName = `Root ${stamp}`;
const childName = `Child ${stamp}`;
const leafName = `Leaf ${stamp}`;
createdNames.push(rootName, childName, leafName);
const root = await service.create({ categoryName: rootName });
const child = await service.create({
categoryName: childName,
parentCategoryId: Number(root.id),
});
const leaf = await service.create({
categoryName: leafName,
parentCategoryId: Number(child.id),
});
const tree = await service.findAll();
const findNode = (
list: Array<{ id: string; children: Array<{ id: string }> }>,
id: string,
): { id: string; children: Array<{ id: string }> } | undefined => {
for (const n of list) {
if (n.id === id) return n;
const inner = findNode(n.children as any, id);
if (inner) return inner;
}
return undefined;
};
const rootNode = findNode(tree as any, root.id.toString());
expect(rootNode).toBeDefined();
const childNode = findNode(rootNode!.children as any, child.id.toString());
expect(childNode).toBeDefined();
const leafNode = findNode(childNode!.children as any, leaf.id.toString());
expect(leafNode).toBeDefined();
});
it('rejects deletion when children exist', async () => {
const stamp = Date.now();
const parent = await service.create({ categoryName: `Parent ${stamp}` });
createdNames.push(parent.categoryName);
const child = await service.create({
categoryName: `Child of ${stamp}`,
parentCategoryId: Number(parent.id),
});
createdNames.push(child.categoryName);
await expect(service.remove(parent.id)).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('throws NotFoundException for unknown id', async () => {
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('returns a flat list and a single node', async () => {
const stamp = Date.now();
const c = await service.create({ categoryName: `Flat ${stamp}` });
createdNames.push(c.categoryName);
const flat = await service.findFlat();
expect(flat.some((row) => row.id === c.id)).toBe(true);
const single = await service.findOne(c.id);
expect(single.categoryName).toBe(c.categoryName);
});
});
@@ -0,0 +1,137 @@
import { Category as PrismaCategory, Prisma } from '@prisma/client';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateCategoryDto } from './dto/create-category.dto';
import { UpdateCategoryDto } from './dto/update-category.dto';
import { CategoryNodeDto } from './dto/category-node.dto';
@Injectable()
export class CategoriesService {
constructor(private readonly prisma: PrismaService) {}
async findAll(): Promise<CategoryNodeDto[]> {
const all = await this.prisma.category.findMany({
orderBy: [{ id: 'asc' }],
});
return this.buildTree(all);
}
async findFlat() {
return this.prisma.category.findMany({
orderBy: [{ id: 'asc' }],
});
}
async findOne(id: bigint) {
const c = await this.prisma.category.findUnique({ where: { id } });
if (!c) throw new NotFoundException(`Category ${id} not found`);
return c;
}
async create(dto: CreateCategoryDto) {
if (dto.parentCategoryId !== undefined && dto.parentCategoryId !== null) {
// Validate the parent exists to produce a clean 404 instead of FK error.
await this.findOne(BigInt(dto.parentCategoryId));
}
return this.prisma.category.create({
data: {
categoryName: dto.categoryName,
categoryIcon: dto.categoryIcon ?? null,
parentCategoryId:
dto.parentCategoryId === undefined || dto.parentCategoryId === null
? null
: BigInt(dto.parentCategoryId),
},
});
}
async update(id: bigint, dto: UpdateCategoryDto) {
await this.findOne(id);
if (dto.parentCategoryId !== undefined && dto.parentCategoryId !== null) {
// Prevent self-parenting & cycles.
if (BigInt(dto.parentCategoryId) === id) {
throw new BadRequestException('A category cannot be its own parent');
}
await this.findOne(BigInt(dto.parentCategoryId));
}
const data: Prisma.CategoryUpdateInput = {};
if (dto.categoryName !== undefined) data.categoryName = dto.categoryName;
if (dto.categoryIcon !== undefined) data.categoryIcon = dto.categoryIcon;
if (dto.parentCategoryId !== undefined) {
data.parent = dto.parentCategoryId === null
? { disconnect: true }
: { connect: { id: BigInt(dto.parentCategoryId) } };
}
return this.prisma.category.update({ where: { id }, data });
}
async remove(id: bigint) {
await this.findOne(id);
const childCount = await this.prisma.category.count({
where: { parentCategoryId: id },
});
if (childCount > 0) {
throw new BadRequestException(
'Category has children and cannot be deleted',
);
}
try {
return await this.prisma.category.delete({ where: { id } });
} catch (err) {
if (this.isForeignKeyViolation(err)) {
throw new BadRequestException(
'Category is referenced by goods or positions and cannot be deleted',
);
}
throw err;
}
}
private isForeignKeyViolation(err: unknown): boolean {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
return err.code === 'P2003';
}
if (err instanceof Prisma.PrismaClientUnknownRequestError) {
const msg = err.message ?? '';
return (
msg.includes('foreign key constraint') ||
msg.includes('RESTRICT') ||
msg.includes('violates')
);
}
return false;
}
/**
* Builds a tree in-memory from a flat list. Top-level nodes have
* `parentCategoryId = null`.
*/
private buildTree(
rows: PrismaCategory[],
): CategoryNodeDto[] {
const byId = new Map<bigint, CategoryNodeDto>();
for (const row of rows) {
byId.set(row.id, CategoryNodeDto.from(row, []));
}
const roots: CategoryNodeDto[] = [];
for (const row of rows) {
const node = byId.get(row.id)!;
if (row.parentCategoryId === null) {
roots.push(node);
} else {
const parent = byId.get(row.parentCategoryId);
if (parent) {
parent.children.push(node);
} else {
// Orphan (parent row missing) — surface as a root.
roots.push(node);
}
}
}
return roots;
}
}
@@ -0,0 +1,35 @@
import { ApiProperty } from '@nestjs/swagger';
import type { Category as PrismaCategory } from '@prisma/client';
export class CategoryNodeDto {
@ApiProperty({ description: 'Category ID (bigint serialized as string)' })
id!: string;
@ApiProperty()
categoryName!: string;
@ApiProperty({ nullable: true })
categoryIcon!: string | null;
@ApiProperty({ nullable: true })
sdsCategoryId!: string | null;
@ApiProperty({ nullable: true, description: 'Parent category ID' })
parentCategoryId!: string | null;
@ApiProperty({ type: [CategoryNodeDto] })
children!: CategoryNodeDto[];
static from(category: PrismaCategory, children: CategoryNodeDto[] = []): CategoryNodeDto {
return {
id: category.id.toString(),
categoryName: category.categoryName,
categoryIcon: category.categoryIcon,
sdsCategoryId: category.sdsCategoryId,
parentCategoryId: category.parentCategoryId
? category.parentCategoryId.toString()
: null,
children,
};
}
}
@@ -0,0 +1,26 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Min,
} from 'class-validator';
export class CreateCategoryDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
categoryName!: string;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
categoryIcon?: string;
@ApiProperty({ required: false, nullable: true, description: 'Parent category ID' })
@IsOptional()
@IsInt()
@Min(1)
parentCategoryId?: number;
}
@@ -0,0 +1,25 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsInt,
IsOptional,
IsString,
Min,
} from 'class-validator';
export class UpdateCategoryDto {
@ApiProperty({ required: false })
@IsOptional()
@IsString()
categoryName?: string;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
categoryIcon?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsInt()
@Min(1)
parentCategoryId?: number | null;
}
@@ -0,0 +1,13 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
/**
* Pulls the authenticated user out of the request, as populated by
* the JWT strategy.
*/
export const CurrentUser = createParamDecorator(
(data: keyof { id: bigint; username: string } | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest<{ user?: { id: bigint; username: string } }>();
const user = request.user;
return data ? user?.[data] : user;
},
);
@@ -0,0 +1,73 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
/**
* Global HTTP exception filter.
*
* Normalizes the error envelope to:
* ```json
* {
* "statusCode": 400,
* "message": "...",
* "error": "...",
* "timestamp": "ISO-8601",
* "path": "..."
* }
* ```
*/
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
let message: string | string[] = 'Internal server error';
let error = 'InternalServerError';
if (exception instanceof HttpException) {
const resp = exception.getResponse();
if (typeof resp === 'string') {
message = resp;
} else if (typeof resp === 'object' && resp !== null) {
const obj = resp as Record<string, unknown>;
message = (obj.message as string | string[]) ?? exception.message;
error = (obj.error as string) ?? exception.name;
} else {
message = exception.message;
}
} else if (exception instanceof Error) {
message = exception.message;
error = exception.name;
}
if (status >= 500) {
this.logger.error(
`${request.method} ${request.url} -> ${status} ${message}`,
exception instanceof Error ? exception.stack : undefined,
);
}
response.status(status).json({
statusCode: status,
message,
error,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
@@ -0,0 +1,19 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
/**
* Wraps every successful response into `{ data, success: true }`.
*
* Exceptions still flow through the global filter and are not wrapped.
*/
@Injectable()
export class TransformInterceptor implements NestInterceptor {
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
return next.handle().pipe(map((data) => ({ data, success: true })));
}
}
@@ -0,0 +1,61 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CountriesService } from './countries.service';
import { CreateCountryDto } from './dto/create-country.dto';
import { UpdateCountryDto } from './dto/update-country.dto';
@ApiTags('countries')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('countries')
export class CountriesController {
constructor(private readonly service: CountriesService) {}
@Get()
@ApiOperation({ summary: 'List all countries' })
findAll() {
return this.service.findAll();
}
@Get(':id')
@ApiOperation({ summary: 'Get one country' })
findOne(@Param('id', ParseIntPipe) id: string) {
return this.service.findOne(BigInt(id));
}
@Post()
@ApiOperation({ summary: 'Create a country' })
create(@Body() dto: CreateCountryDto) {
return this.service.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a country' })
update(
@Param('id', ParseIntPipe) id: string,
@Body() dto: UpdateCountryDto,
) {
return this.service.update(BigInt(id), dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a country' })
remove(@Param('id', ParseIntPipe) id: string) {
return this.service.remove(BigInt(id));
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CountriesController } from './countries.controller';
import { CountriesService } from './countries.service';
@Module({
controllers: [CountriesController],
providers: [CountriesService],
exports: [CountriesService],
})
export class CountriesModule {}
@@ -0,0 +1,115 @@
import { Test } from '@nestjs/testing';
import {
BadRequestException,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import { CountriesService } from './countries.service';
import { PrismaService } from '../prisma/prisma.service';
describe('CountriesService', () => {
let service: CountriesService;
let prisma: PrismaService;
const created: string[] = [];
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [CountriesService, PrismaService],
}).compile();
service = moduleRef.get(CountriesService);
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
});
afterAll(async () => {
if (created.length) {
// Delete goods/origin_goods first so we can remove the countries.
await prisma.good.deleteMany({
where: { country: { countryName: { in: created } } },
});
await prisma.position.deleteMany({
where: { country: { countryName: { in: created } } },
});
await prisma.country.deleteMany({
where: { countryName: { in: created } },
});
}
await prisma.onModuleDestroy();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('creates and reads back a country', async () => {
const name = `Test Country ${Date.now()}`;
created.push(name);
const createdRow = await service.create({ countryName: name });
expect(createdRow.countryName).toBe(name);
const fetched = await service.findOne(createdRow.id);
expect(fetched.countryName).toBe(name);
});
it('rejects duplicate names with ConflictException', async () => {
const name = `Dup Country ${Date.now()}`;
created.push(name);
await service.create({ countryName: name });
await expect(service.create({ countryName: name })).rejects.toBeInstanceOf(
ConflictException,
);
});
it('throws NotFoundException for unknown id', async () => {
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('throws BadRequestException when deleting a country referenced by goods', async () => {
const name = `Ref Country ${Date.now()}`;
created.push(name);
const country = await service.create({ countryName: name });
// Need a category + origin good to satisfy the foreign keys before
// we can attach a good that references the country.
const category = await prisma.category.create({
data: { categoryName: `Cat ${Date.now()}` },
});
const originGood = await prisma.originGood.create({
data: { sdsGoodId: `sds-${Date.now()}-${Math.random()}` },
});
await prisma.good.create({
data: {
originGoodId: originGood.id,
countryId: country.id,
categoryId: category.id,
goodName: 'sample',
},
});
await expect(service.remove(country.id)).rejects.toBeInstanceOf(
BadRequestException,
);
// cleanup
await prisma.good.deleteMany({ where: { countryId: country.id } });
await prisma.originGood.delete({ where: { id: originGood.id } });
await prisma.category.delete({ where: { id: category.id } });
});
it('updates fields and deletes when unreferenced', async () => {
const name = `Upd Country ${Date.now()}`;
created.push(name);
const c = await service.create({ countryName: name });
const updated = await service.update(c.id, { countryName: `${name}-v2` });
expect(updated.countryName).toBe(`${name}-v2`);
created[created.indexOf(name)] = `${name}-v2`;
await service.remove(c.id);
const idx = created.indexOf(`${name}-v2`);
if (idx !== -1) created.splice(idx, 1);
});
});
+100
View File
@@ -0,0 +1,100 @@
import { Prisma } from '@prisma/client';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateCountryDto } from './dto/create-country.dto';
import { UpdateCountryDto } from './dto/update-country.dto';
@Injectable()
export class CountriesService {
constructor(private readonly prisma: PrismaService) {}
findAll() {
return this.prisma.country.findMany({ orderBy: { id: 'asc' } });
}
async findOne(id: bigint) {
const country = await this.prisma.country.findUnique({ where: { id } });
if (!country) {
throw new NotFoundException(`Country ${id} not found`);
}
return country;
}
async create(dto: CreateCountryDto) {
try {
return await this.prisma.country.create({
data: {
countryName: dto.countryName,
countryIcon: dto.countryIcon ?? null,
},
});
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === 'P2002'
) {
throw new ConflictException('Country name already exists');
}
throw err;
}
}
async update(id: bigint, dto: UpdateCountryDto) {
await this.findOne(id);
try {
return await this.prisma.country.update({
where: { id },
data: {
countryName: dto.countryName,
countryIcon: dto.countryIcon === undefined ? undefined : dto.countryIcon,
},
});
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === 'P2002'
) {
throw new ConflictException('Country name already exists');
}
throw err;
}
}
async remove(id: bigint) {
await this.findOne(id);
try {
return await this.prisma.country.delete({ where: { id } });
} catch (err) {
if (this.isForeignKeyViolation(err)) {
throw new BadRequestException(
'Country is referenced by goods or positions and cannot be deleted',
);
}
throw err;
}
}
private isForeignKeyViolation(err: unknown): boolean {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
// P2003 = FK constraint violation
return err.code === 'P2003';
}
// Fallback: Prisma sometimes surfaces FK violations as UnknownRequestError
// when the constraint check happens server-side before the typed error
// is mapped (e.g. cascading RESTRICT).
if (err instanceof Prisma.PrismaClientUnknownRequestError) {
const msg = err.message ?? '';
return (
msg.includes('foreign key constraint') ||
msg.includes('RESTRICT') ||
msg.includes('violates')
);
}
return false;
}
}
@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateCountryDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
countryName!: string;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
countryIcon?: string;
}
@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
export class UpdateCountryDto {
@ApiProperty({ required: false })
@IsOptional()
@IsString()
countryName?: string;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
countryIcon?: string | null;
}
@@ -0,0 +1,61 @@
import { ApiProperty } from '@nestjs/swagger';
import {
ArrayMinSize,
IsArray,
IsInt,
IsOptional,
Min,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
export class BatchCreateItemDto {
@ApiProperty()
@IsInt()
@Min(1)
originGoodId!: number;
@ApiProperty({ required: false })
@IsOptional()
@IsInt()
@Min(0)
priority?: number;
}
export class BatchCreateGoodDto {
@ApiProperty({ type: [BatchCreateItemDto] })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => BatchCreateItemDto)
items!: BatchCreateItemDto[];
@ApiProperty()
@IsInt()
@Min(1)
countryId!: number;
@ApiProperty()
@IsInt()
@Min(1)
categoryId!: number;
@ApiProperty({ required: false, nullable: true, type: [Number] })
@IsOptional()
@IsArray()
@IsInt({ each: true })
@Min(1, { each: true })
tagIds?: number[];
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsInt()
@Min(1)
positionId?: number;
@ApiProperty({ required: false, default: 0 })
@IsOptional()
@IsInt()
@Min(0)
defaultPriority?: number;
}
@@ -0,0 +1,30 @@
import { ApiProperty } from '@nestjs/swagger';
import {
ArrayMinSize,
IsArray,
IsInt,
Min,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
export class PriorityItemDto {
@ApiProperty()
@IsInt()
@Min(1)
id!: number;
@ApiProperty()
@IsInt()
@Min(0)
priority!: number;
}
export class BatchPriorityDto {
@ApiProperty({ type: [PriorityItemDto] })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => PriorityItemDto)
items!: PriorityItemDto[];
}
+57
View File
@@ -0,0 +1,57 @@
import { ApiProperty } from '@nestjs/swagger';
import {
ArrayMinSize,
IsArray,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Min,
} from 'class-validator';
export class CreateGoodDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
goodName!: string;
@ApiProperty()
@IsInt()
@Min(1)
originGoodId!: number;
@ApiProperty()
@IsInt()
@Min(1)
countryId!: number;
@ApiProperty()
@IsInt()
@Min(1)
categoryId!: number;
@ApiProperty({ required: false, nullable: true, type: [Number] })
@IsOptional()
@IsArray()
@ArrayMinSize(1)
@IsInt({ each: true })
@Min(1, { each: true })
tagIds?: number[];
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsInt()
@Min(1)
positionId?: number;
@ApiProperty({ required: false, default: 0 })
@IsOptional()
@IsInt()
@Min(0)
goodPriority?: number;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
goodImage?: string;
}
+151
View File
@@ -0,0 +1,151 @@
import { ApiProperty } from '@nestjs/swagger';
import type { Good as PrismaGood } from '@prisma/client';
export interface GoodRelations {
country?: { id: bigint; countryName: string; countryIcon: string | null } | null;
category?: { id: bigint; categoryName: string; categoryIcon: string | null } | null;
tag?: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } | null;
position?: { id: bigint; indexVal: number } | null;
originGood?: {
id: bigint;
sdsGoodId: string;
goodName: string | null;
goodImage: string | null;
goodPrice: unknown;
} | null;
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } }[];
}
export class GoodDto {
@ApiProperty()
id!: string;
@ApiProperty()
goodName!: string;
@ApiProperty({ nullable: true })
goodImage!: string | null;
@ApiProperty()
goodPriority!: number;
@ApiProperty()
countryId!: string;
@ApiProperty()
categoryId!: string;
@ApiProperty({ nullable: true })
tagId!: string | null;
@ApiProperty({ nullable: true })
positionId!: string | null;
@ApiProperty()
originGoodId!: string;
@ApiProperty()
createdAt!: string;
@ApiProperty()
updatedAt!: string;
@ApiProperty({ required: false, nullable: true })
country?: { id: string; countryName: string; countryIcon: string | null } | null;
@ApiProperty({ required: false, nullable: true })
category?: { id: string; categoryName: string; categoryIcon: string | null } | null;
@ApiProperty({ required: false, nullable: true })
tag?: { id: string; tagName: string; tagColor: string | null; tagFontColor: string | null } | null;
@ApiProperty({ required: false, type: Array })
tags!: Array<{ id: string; tagName: string; tagColor: string | null; tagFontColor: string | null }>;
@ApiProperty({ required: false, nullable: true })
position?: { id: string; indexVal: number } | null;
@ApiProperty({ required: false, nullable: true })
originGood?: {
id: string;
sdsGoodId: string;
goodName: string | null;
goodImage: string | null;
goodPrice: string | null;
} | null;
static from(
good: PrismaGood,
rel: GoodRelations = {},
): GoodDto {
return {
id: good.id.toString(),
goodName: good.goodName,
goodImage: good.goodImage,
goodPriority: good.goodPriority,
countryId: good.countryId.toString(),
categoryId: good.categoryId.toString(),
tagId: good.tagId === null || good.tagId === undefined ? null : good.tagId.toString(),
positionId: good.positionId === null || good.positionId === undefined ? null : good.positionId.toString(),
originGoodId: good.originGoodId.toString(),
createdAt: good.createdAt.toISOString(),
updatedAt: good.updatedAt.toISOString(),
country: rel.country
? {
id: rel.country.id.toString(),
countryName: rel.country.countryName,
countryIcon: rel.country.countryIcon,
}
: null,
category: rel.category
? {
id: rel.category.id.toString(),
categoryName: rel.category.categoryName,
categoryIcon: rel.category.categoryIcon,
}
: null,
tag: rel.tag
? {
id: rel.tag.id.toString(),
tagName: rel.tag.tagName,
tagColor: rel.tag.tagColor,
tagFontColor: rel.tag.tagFontColor,
}
: null,
tags: rel.goodTags
? rel.goodTags.map((gt) => ({
id: gt.tag.id.toString(),
tagName: gt.tag.tagName,
tagColor: gt.tag.tagColor,
tagFontColor: gt.tag.tagFontColor,
}))
: [],
position: rel.position
? {
id: rel.position.id.toString(),
indexVal: rel.position.indexVal,
}
: null,
originGood: rel.originGood
? {
id: rel.originGood.id.toString(),
sdsGoodId: rel.originGood.sdsGoodId,
goodName: rel.originGood.goodName,
goodImage: rel.originGood.goodImage,
goodPrice:
rel.originGood.goodPrice === null ||
rel.originGood.goodPrice === undefined
? null
: (rel.originGood.goodPrice as { toString(): string }).toString(),
}
: null,
};
}
}
export interface PaginatedGoods {
items: GoodDto[];
total: number;
page: number;
pageSize: number;
}
+58
View File
@@ -0,0 +1,58 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsInt,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
export class QueryGoodDto {
@ApiProperty({ required: false, default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page: number = 1;
@ApiProperty({ required: false, default: 20 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(200)
pageSize: number = 20;
@ApiProperty({ required: false })
@IsOptional()
@Type(() => Number)
@IsInt()
countryId?: number;
@ApiProperty({
required: false,
description: 'Includes all descendants of this category recursively',
})
@IsOptional()
@Type(() => Number)
@IsInt()
categoryId?: number;
@ApiProperty({ required: false })
@IsOptional()
@Type(() => Number)
@IsInt()
tagId?: number;
@ApiProperty({ required: false })
@IsOptional()
@Type(() => Number)
@IsInt()
positionId?: number;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
keyword?: string;
}
+57
View File
@@ -0,0 +1,57 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsArray,
IsInt,
IsOptional,
IsString,
Min,
} from 'class-validator';
export class UpdateGoodDto {
@ApiProperty({ required: false })
@IsOptional()
@IsString()
goodName?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsInt()
@Min(1)
originGoodId?: number;
@ApiProperty({ required: false })
@IsOptional()
@IsInt()
@Min(1)
countryId?: number;
@ApiProperty({ required: false })
@IsOptional()
@IsInt()
@Min(1)
categoryId?: number;
@ApiProperty({ required: false, nullable: true, type: [Number] })
@IsOptional()
@IsArray()
@IsInt({ each: true })
@Min(1, { each: true })
tagIds?: number[];
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsInt()
@Min(1)
positionId?: number | null;
@ApiProperty({ required: false })
@IsOptional()
@IsInt()
@Min(0)
goodPriority?: number;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
goodImage?: string | null;
}
+77
View File
@@ -0,0 +1,77 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { GoodsService } from './goods.service';
import { CreateGoodDto } from './dto/create-good.dto';
import { UpdateGoodDto } from './dto/update-good.dto';
import { QueryGoodDto } from './dto/query-good.dto';
import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
import { BatchPriorityDto } from './dto/batch-priority.dto';
@ApiTags('goods')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('goods')
export class GoodsController {
constructor(private readonly service: GoodsService) {}
@Get()
@ApiOperation({ summary: 'List goods with filters & pagination' })
findAll(@Query() query: QueryGoodDto) {
return this.service.findAll(query);
}
@Get(':id')
@ApiOperation({ summary: 'Get one good with relations' })
findOne(@Param('id', ParseIntPipe) id: string) {
return this.service.findOne(BigInt(id));
}
@Post()
@ApiOperation({ summary: 'Create a good' })
create(@Body() dto: CreateGoodDto) {
return this.service.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a good' })
update(
@Param('id', ParseIntPipe) id: string,
@Body() dto: UpdateGoodDto,
) {
return this.service.update(BigInt(id), dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a good' })
remove(@Param('id', ParseIntPipe) id: string) {
return this.service.remove(BigInt(id));
}
@Patch('batch-priority')
@ApiOperation({ summary: 'Batch update good priorities (transaction)' })
batchPriority(@Body() dto: BatchPriorityDto) {
return this.service.batchUpdatePriority(dto);
}
@Post('batch')
@ApiOperation({ summary: 'Batch create goods from origin goods (transaction)' })
batchCreate(@Body() dto: BatchCreateGoodDto) {
return this.service.batchCreate(dto);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { GoodsController } from './goods.controller';
import { GoodsService } from './goods.service';
@Module({
controllers: [GoodsController],
providers: [GoodsService],
exports: [GoodsService],
})
export class GoodsModule {}
+216
View File
@@ -0,0 +1,216 @@
import { Test } from '@nestjs/testing';
import {
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { GoodsService } from './goods.service';
import { PrismaService } from '../prisma/prisma.service';
describe('GoodsService', () => {
let service: GoodsService;
let prisma: PrismaService;
const stamp = Date.now();
// Fixtures
let countryId: bigint;
let country2Id: bigint;
let categoryId: bigint;
let childCategoryId: bigint;
let tagId: bigint;
let positionId: bigint;
let originGoodIds: bigint[] = [];
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [GoodsService, PrismaService],
}).compile();
service = moduleRef.get(GoodsService);
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
const country = await prisma.country.create({
data: { countryName: `Goods Country ${stamp}` },
});
countryId = country.id;
const country2 = await prisma.country.create({
data: { countryName: `Goods Country 2 ${stamp}` },
});
country2Id = country2.id;
const cat = await prisma.category.create({
data: { categoryName: `Goods Cat ${stamp}` },
});
categoryId = cat.id;
const child = await prisma.category.create({
data: { categoryName: `Goods Child ${stamp}`, parentCategoryId: cat.id },
});
childCategoryId = child.id;
const tag = await prisma.tag.create({
data: { tagName: `Goods Tag ${stamp}`, tagColor: '#00FF00' },
});
tagId = tag.id;
const pos = await prisma.position.create({
data: { indexVal: 1, countryId, categoryId },
});
positionId = pos.id;
const originGoods = await Promise.all(
Array.from({ length: 5 }).map((_, i) =>
prisma.originGood.create({
data: {
sdsGoodId: `sds-goods-${stamp}-${i}`,
goodName: `Goods Origin ${stamp} ${i}`,
},
}),
),
);
originGoodIds = originGoods.map((og) => og.id);
});
afterAll(async () => {
// Wipe all goods first so origin_goods/category can be removed.
await prisma.good.deleteMany({
where: { goodName: { contains: `Goods Test ${stamp}` } },
});
await prisma.good.deleteMany({
where: { goodName: { contains: `Origin ${stamp}` } },
});
await prisma.originGood.deleteMany({
where: { id: { in: originGoodIds } },
});
await prisma.position.delete({ where: { id: positionId } });
await prisma.tag.delete({ where: { id: tagId } });
await prisma.category.delete({ where: { id: childCategoryId } });
await prisma.category.delete({ where: { id: categoryId } });
await prisma.country.delete({ where: { id: countryId } });
await prisma.country.delete({ where: { id: country2Id } });
await prisma.onModuleDestroy();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('creates and reads back a good', async () => {
const created = await service.create({
goodName: `Goods Test ${stamp} basic`,
originGoodId: Number(originGoodIds[0]),
countryId: Number(countryId),
categoryId: Number(categoryId),
tagIds: [Number(tagId)],
positionId: Number(positionId),
goodPriority: 3,
});
expect(created.id).toBeTruthy();
expect(created.country?.countryName).toBeTruthy();
expect(created.tags.some((t) => t.tagColor === '#00FF00')).toBe(true);
const fetched = await service.findOne(BigInt(created.id));
expect(fetched.goodName).toBe(`Goods Test ${stamp} basic`);
});
it('filters by countryId, tagId, positionId and keyword', async () => {
const result = await service.findAll({
page: 1,
pageSize: 20,
countryId: Number(countryId),
tagId: Number(tagId),
keyword: `Goods Test ${stamp}`,
});
expect(result.items.length).toBeGreaterThan(0);
expect(result.items.every((g) => g.countryId === countryId.toString())).toBe(true);
expect(
result.items.every((g) => g.tags.some((t) => t.id === tagId.toString())),
).toBe(true);
});
it('categoryId filter includes descendants recursively', async () => {
const inChild = await service.create({
goodName: `Goods Test ${stamp} child`,
originGoodId: Number(originGoodIds[1]),
countryId: Number(countryId),
categoryId: Number(childCategoryId),
});
const result = await service.findAll({
page: 1,
pageSize: 20,
categoryId: Number(categoryId),
keyword: `Goods Test ${stamp}`,
});
const ids = result.items.map((g) => g.id);
expect(ids).toContain(inChild.id);
});
it('batch update priority is atomic', async () => {
const created = await service.create({
goodName: `Goods Test ${stamp} prio`,
originGoodId: Number(originGoodIds[2]),
countryId: Number(countryId),
categoryId: Number(categoryId),
});
const result = await service.batchUpdatePriority({
items: [{ id: Number(created.id), priority: 42 }],
});
expect(result.count).toBe(1);
const after = await service.findOne(BigInt(created.id));
expect(after.goodPriority).toBe(42);
});
it('batch create creates all rows or none', async () => {
// Count of goods whose originGoodId is one of the two fixture ids,
// so we are independent of goodName (which batch derives from origin).
const before = await service.findAll({
page: 1,
pageSize: 100,
keyword: `Goods Origin ${stamp}`,
});
const created = await service.batchCreate({
countryId: Number(countryId),
categoryId: Number(categoryId),
defaultPriority: 1,
items: [
{ originGoodId: Number(originGoodIds[3]) },
{ originGoodId: Number(originGoodIds[4]) },
],
});
expect(created.length).toBe(2);
const after = await service.findAll({
page: 1,
pageSize: 100,
keyword: `Goods Origin ${stamp}`,
});
expect(after.total).toBe(before.total + 2);
});
it('batch create rolls back on failure', async () => {
const before = await service.findAll({
page: 1,
pageSize: 100,
keyword: `Goods Origin ${stamp}`,
});
await expect(
service.batchCreate({
countryId: Number(countryId),
categoryId: Number(categoryId),
items: [
{ originGoodId: Number(originGoodIds[0]) },
{ originGoodId: 99999999 }, // missing -> failure
],
}),
).rejects.toBeInstanceOf(BadRequestException);
const after = await service.findAll({
page: 1,
pageSize: 100,
keyword: `Goods Origin ${stamp}`,
});
expect(after.total).toBe(before.total);
});
it('throws NotFoundException for unknown id', async () => {
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
+316
View File
@@ -0,0 +1,316 @@
import { Prisma } from '@prisma/client';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateGoodDto } from './dto/create-good.dto';
import { UpdateGoodDto } from './dto/update-good.dto';
import { QueryGoodDto } from './dto/query-good.dto';
import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
import { BatchPriorityDto } from './dto/batch-priority.dto';
import { GoodDto, PaginatedGoods } from './dto/good.dto';
const GOOD_INCLUDE = {
country: true,
category: true,
tag: true,
position: true,
originGood: true,
goodTags: { include: { tag: true } },
} satisfies Prisma.GoodInclude;
@Injectable()
export class GoodsService {
constructor(private readonly prisma: PrismaService) {}
async findAll(query: QueryGoodDto): Promise<PaginatedGoods> {
const { page, pageSize, countryId, categoryId, tagId, positionId, keyword } = query;
const where: Prisma.GoodWhereInput = {};
if (countryId !== undefined) where.countryId = BigInt(countryId);
if (tagId !== undefined) where.goodTags = { some: { tagId: BigInt(tagId) } };
if (positionId !== undefined) where.positionId = BigInt(positionId);
if (keyword) {
where.goodName = { contains: keyword, mode: 'insensitive' };
}
if (categoryId !== undefined) {
const ids = await this.collectCategoryDescendants(BigInt(categoryId));
where.categoryId = { in: ids };
}
const [total, rows] = await this.prisma.$transaction([
this.prisma.good.count({ where }),
this.prisma.good.findMany({
where,
include: GOOD_INCLUDE,
orderBy: [{ goodPriority: 'desc' }, { createdAt: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
]);
return {
items: rows.map((g) => GoodDto.from(g, {
country: g.country,
category: g.category,
tag: g.tag,
position: g.position,
originGood: g.originGood,
goodTags: g.goodTags,
})),
total,
page,
pageSize,
};
}
async findOne(id: bigint): Promise<GoodDto> {
const good = await this.prisma.good.findUnique({
where: { id },
include: GOOD_INCLUDE,
});
if (!good) throw new NotFoundException(`Good ${id} not found`);
return GoodDto.from(good, {
country: good.country,
category: good.category,
tag: good.tag,
position: good.position,
originGood: good.originGood,
goodTags: good.goodTags,
});
}
async create(dto: CreateGoodDto): Promise<GoodDto> {
await this.ensureReferences(dto);
return this.prisma.$transaction(async (tx) => {
const created = await tx.good.create({
data: {
goodName: dto.goodName,
goodImage: dto.goodImage,
originGoodId: BigInt(dto.originGoodId),
countryId: BigInt(dto.countryId),
categoryId: BigInt(dto.categoryId),
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
goodPriority: dto.goodPriority ?? 0,
},
});
if (dto.tagIds && dto.tagIds.length > 0) {
await tx.goodTag.createMany({
data: dto.tagIds.map((tagId) => ({
goodId: created.id,
tagId: BigInt(tagId),
})),
});
}
const result = await tx.good.findUniqueOrThrow({
where: { id: created.id },
include: GOOD_INCLUDE,
});
return GoodDto.from(result, {
country: result.country,
category: result.category,
tag: result.tag,
position: result.position,
originGood: result.originGood,
goodTags: result.goodTags,
});
});
}
async update(id: bigint, dto: UpdateGoodDto): Promise<GoodDto> {
await this.findOne(id);
const data: Prisma.GoodUpdateInput = {};
if (dto.goodName !== undefined) data.goodName = dto.goodName;
if (dto.originGoodId !== undefined) {
await this.ensureOriginGood(dto.originGoodId);
data.originGood = { connect: { id: BigInt(dto.originGoodId) } };
}
if (dto.countryId !== undefined) {
await this.ensureCountry(dto.countryId);
data.country = { connect: { id: BigInt(dto.countryId) } };
}
if (dto.categoryId !== undefined) {
await this.ensureCategory(dto.categoryId);
data.category = { connect: { id: BigInt(dto.categoryId) } };
}
if (dto.positionId !== undefined) {
data.position =
dto.positionId === null
? { disconnect: true }
: { connect: { id: BigInt(dto.positionId) } };
}
if (dto.goodPriority !== undefined) data.goodPriority = dto.goodPriority;
if (dto.goodImage !== undefined) data.goodImage = dto.goodImage;
if (dto.tagIds !== undefined) {
for (const tagId of dto.tagIds) {
await this.ensureTag(tagId);
}
}
return this.prisma.$transaction(async (tx) => {
if (dto.tagIds !== undefined) {
await tx.goodTag.deleteMany({ where: { goodId: id } });
if (dto.tagIds.length > 0) {
await tx.goodTag.createMany({
data: dto.tagIds.map((tagId) => ({
goodId: id,
tagId: BigInt(tagId),
})),
});
}
}
const updated = await tx.good.update({
where: { id },
data,
include: GOOD_INCLUDE,
});
return GoodDto.from(updated, {
country: updated.country,
category: updated.category,
tag: updated.tag,
position: updated.position,
originGood: updated.originGood,
goodTags: updated.goodTags,
});
});
}
async remove(id: bigint): Promise<{ id: string }> {
await this.findOne(id);
await this.prisma.good.delete({ where: { id } });
return { id: id.toString() };
}
/**
* Updates priorities in a single transaction; either all rows update
* or none do.
*/
async batchUpdatePriority(dto: BatchPriorityDto): Promise<{ count: number }> {
return this.prisma.$transaction(async (tx) => {
for (const item of dto.items) {
await tx.good.update({
where: { id: BigInt(item.id) },
data: { goodPriority: item.priority },
});
}
return { count: dto.items.length };
});
}
/**
* Creates multiple goods atomically, sharing countryId/categoryId/tagIds/positionId
* and a default priority that may be overridden per item.
*/
async batchCreate(dto: BatchCreateGoodDto): Promise<GoodDto[]> {
const defaultPriority = dto.defaultPriority ?? 0;
if (dto.tagIds && dto.tagIds.length > 0) {
for (const tagId of dto.tagIds) {
await this.ensureTag(tagId);
}
}
return this.prisma.$transaction(async (tx) => {
const created: GoodDto[] = [];
for (const item of dto.items) {
const og = await tx.originGood.findUnique({
where: { id: BigInt(item.originGoodId) },
});
if (!og) {
throw new BadRequestException(
`Origin good ${item.originGoodId} not found`,
);
}
const row = await tx.good.create({
data: {
goodName: og.goodName ?? `Origin Good ${og.sdsGoodId}`,
goodImage: og.goodImage,
originGoodId: og.id,
countryId: BigInt(dto.countryId),
categoryId: BigInt(dto.categoryId),
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
goodPriority: item.priority ?? defaultPriority,
},
});
if (dto.tagIds && dto.tagIds.length > 0) {
await tx.goodTag.createMany({
data: dto.tagIds.map((tagId) => ({
goodId: row.id,
tagId: BigInt(tagId),
})),
});
}
const result = await tx.good.findUniqueOrThrow({
where: { id: row.id },
include: GOOD_INCLUDE,
});
created.push(GoodDto.from(result, {
country: result.country,
category: result.category,
tag: result.tag,
position: result.position,
originGood: result.originGood,
goodTags: result.goodTags,
}));
}
return created;
});
}
/**
* Walk the category tree and return the requested id + all of its
* descendants. We use a level-by-level BFS to keep the queries small
* for the typical tree sizes we expect.
*/
private async collectCategoryDescendants(rootId: bigint): Promise<bigint[]> {
const ids: bigint[] = [rootId];
let frontier: bigint[] = [rootId];
while (frontier.length > 0) {
const children = await this.prisma.category.findMany({
where: { parentCategoryId: { in: frontier } },
select: { id: true },
});
if (children.length === 0) break;
const childIds = children.map((c) => c.id);
ids.push(...childIds);
frontier = childIds;
}
return ids;
}
private async ensureOriginGood(id: number) {
const og = await this.prisma.originGood.findUnique({
where: { id: BigInt(id) },
});
if (!og) throw new BadRequestException(`Origin good ${id} not found`);
}
private async ensureCountry(id: number) {
const c = await this.prisma.country.findUnique({ where: { id: BigInt(id) } });
if (!c) throw new BadRequestException(`Country ${id} not found`);
}
private async ensureCategory(id: number) {
const c = await this.prisma.category.findUnique({ where: { id: BigInt(id) } });
if (!c) throw new BadRequestException(`Category ${id} not found`);
}
private async ensureTag(id: number) {
const t = await this.prisma.tag.findUnique({ where: { id: BigInt(id) } });
if (!t) throw new BadRequestException(`Tag ${id} not found`);
}
private async ensureReferences(dto: CreateGoodDto) {
await this.ensureOriginGood(dto.originGoodId);
await this.ensureCountry(dto.countryId);
await this.ensureCategory(dto.categoryId);
if (dto.tagIds && dto.tagIds.length > 0) {
for (const tagId of dto.tagIds) {
await this.ensureTag(tagId);
}
}
if (dto.positionId !== undefined) {
const p = await this.prisma.position.findUnique({ where: { id: BigInt(dto.positionId) } });
if (!p) throw new BadRequestException(`Position ${dto.positionId} not found`);
}
}
}
+72
View File
@@ -0,0 +1,72 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { json } from 'express';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bodyParser: false });
// Replace Express's JSON parser with one that stringifies BigInt.
// Express's default `json()` throws "Do not know how to serialize a BigInt".
app.use(
json({
limit: '2mb',
reviver: (_key: string, value: unknown) => {
// If a numeric string would overflow the JS number range we keep
// it as a string; the JWT strategy / services expect bigints.
if (typeof value === 'string' && /^-?\d{16,}$/.test(value)) {
// Leave as string — services parse with BigInt().
return value;
}
return value;
},
}),
);
// CORS
app.enableCors({
origin: ['http://localhost:5173', 'http://localhost:3000'],
credentials: true,
});
// Global pipes
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
// Global filters and interceptors
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor());
// Swagger
const config = new DocumentBuilder()
.setTitle('InkReach Product Center API')
.setDescription('Backend API for InkReach Product Center')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
const port = process.env.PORT ?? 3001;
await app.listen(port);
console.log(`🚀 Application is running on: http://localhost:${port}`);
console.log(`📚 Swagger documentation: http://localhost:${port}/api/docs`);
}
// Make JSON.stringify aware of BigInt so outgoing responses containing
// primary keys (`BigInt` columns) don't blow up. BigInts are serialized
// as their decimal string — clients should parse them with BigInt().
(BigInt.prototype as unknown as { toJSON: () => string }).toJSON = function () {
return this.toString();
};
bootstrap();
@@ -0,0 +1,31 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsInt,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
export class QueryOriginGoodDto {
@ApiProperty({ required: false, default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page: number = 1;
@ApiProperty({ required: false, default: 20 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(200)
pageSize: number = 20;
@ApiProperty({ required: false, description: 'Search by goodName' })
@IsOptional()
@IsString()
keyword?: string;
}
@@ -0,0 +1,27 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OriginGoodsService } from './origin-goods.service';
import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
@ApiTags('origin-goods')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('origin-goods')
export class OriginGoodsController {
constructor(private readonly service: OriginGoodsService) {}
@Get('tree')
@ApiOperation({
summary: 'Origin goods grouped by SDS category with config status',
})
getTree() {
return this.service.getTree();
}
@Get()
@ApiOperation({ summary: 'Paginated list of origin goods (read-only)' })
findAll(@Query() query: QueryOriginGoodDto) {
return this.service.findAll(query);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { OriginGoodsController } from './origin-goods.controller';
import { OriginGoodsService } from './origin-goods.service';
@Module({
controllers: [OriginGoodsController],
providers: [OriginGoodsService],
})
export class OriginGoodsModule {}
@@ -0,0 +1,80 @@
import { Test } from '@nestjs/testing';
import { OriginGoodsService } from './origin-goods.service';
import { PrismaService } from '../prisma/prisma.service';
describe('OriginGoodsService', () => {
let service: OriginGoodsService;
let prisma: PrismaService;
const stamp = Date.now();
const createdSds: string[] = [];
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [OriginGoodsService, PrismaService],
}).compile();
service = moduleRef.get(OriginGoodsService);
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
// Seed 25 rows with sequential goodNames so we can paginate/filter.
const rows = Array.from({ length: 25 }).map((_, i) => ({
sdsGoodId: `sds-${stamp}-${i}`,
goodName: `Origin Good ${stamp} ${i.toString().padStart(2, '0')}`,
sdsCategoryId: `cat-${stamp}-${i % 3}`,
}));
await prisma.originGood.createMany({ data: rows });
createdSds.push(...rows.map((r) => r.sdsGoodId));
});
afterAll(async () => {
if (createdSds.length) {
await prisma.originGood.deleteMany({
where: { sdsGoodId: { in: createdSds } },
});
}
await prisma.onModuleDestroy();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('returns paginated results', async () => {
const page1 = await service.findAll({
page: 1,
pageSize: 10,
keyword: `Origin Good ${stamp}`,
});
expect(page1.total).toBe(25);
expect(page1.items.length).toBe(10);
expect(page1.page).toBe(1);
expect(page1.pageSize).toBe(10);
const page3 = await service.findAll({
page: 3,
pageSize: 10,
keyword: `Origin Good ${stamp}`,
});
expect(page3.items.length).toBe(5);
});
it('searches by keyword (case insensitive)', async () => {
const result = await service.findAll({
page: 1,
pageSize: 5,
keyword: `origin good ${stamp} 05`,
});
expect(result.items.length).toBe(1);
expect(result.items[0].goodName).toContain('05');
});
it('returns empty page when no matches', async () => {
const result = await service.findAll({
page: 1,
pageSize: 5,
keyword: 'definitely-does-not-exist',
});
expect(result.total).toBe(0);
expect(result.items.length).toBe(0);
});
});
@@ -0,0 +1,272 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
export interface PaginatedOriginGoods {
items: Array<{
id: string;
sdsGoodId: string;
goodName: string | null;
goodImage: string | null;
goodPrice: string | null;
sdsCategoryId: string | null;
createdAt: string;
updatedAt: string;
}>;
total: number;
page: number;
pageSize: number;
}
/**
* A single origin-good node inside the tree, augmented with configuration status
* (how many `goods` rows reference it and which countries it has been configured for).
*/
export interface OriginGoodsTreeNode {
id: string;
goodName: string;
goodImage: string | null;
goodPrice: string | null;
sdsGoodId: string;
configuredCount: number;
configuredCountries: string[];
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[];
}
/** A category node in the hierarchical tree, with origin goods as leaves. */
export interface OriginGoodsTreeCategoryNode {
categoryId: string;
categoryName: string;
sdsCategoryId: string | null;
configuredCount: number;
totalCount: number;
children: OriginGoodsTreeCategoryNode[];
originGoods: OriginGoodsTreeNode[];
}
/** Top-level tree response returned by `OriginGoodsService.getTree()`. */
export interface OriginGoodsTreeResponse {
tree: OriginGoodsTreeCategoryNode[];
totalOriginGoods: number;
configuredCount: number;
}
@Injectable()
export class OriginGoodsService {
constructor(private readonly prisma: PrismaService) {}
async findAll(query: QueryOriginGoodDto): Promise<PaginatedOriginGoods> {
const { page, pageSize, keyword } = query;
const where: Prisma.OriginGoodWhereInput = keyword
? { goodName: { contains: keyword, mode: 'insensitive' } }
: {};
const [total, rows] = await this.prisma.$transaction([
this.prisma.originGood.count({ where }),
this.prisma.originGood.findMany({
where,
orderBy: { id: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
]);
return {
items: rows.map((r) => ({
id: r.id.toString(),
sdsGoodId: r.sdsGoodId,
goodName: r.goodName,
goodImage: r.goodImage,
goodPrice: r.goodPrice === null || r.goodPrice === undefined ? null : r.goodPrice.toString(),
sdsCategoryId: r.sdsCategoryId,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
})),
total,
page,
pageSize,
};
}
/**
* Builds a hierarchical tree using the `categories` table parent-child
* structure, placing each origin-good as a leaf under the category whose
* `sdsCategoryId` matches the origin-good's `sdsCategoryId`.
*
* Origin-goods whose `sdsCategoryId` doesn't map to any category are placed
* under a synthetic "未分类" root node.
*/
async getTree(): Promise<OriginGoodsTreeResponse> {
const [allCategories, allOriginGoods, configCounts, goodsWithCountries, goodsWithTags] =
await Promise.all([
this.prisma.category.findMany({
where: { sdsCategoryId: { not: null } },
orderBy: { categoryName: 'asc' },
select: {
id: true,
categoryName: true,
sdsCategoryId: true,
parentCategoryId: true,
},
}),
this.prisma.originGood.findMany({ orderBy: { goodName: 'asc' } }),
this.prisma.good.groupBy({
by: ['originGoodId'],
_count: { _all: true },
}),
this.prisma.good.findMany({
select: {
originGoodId: true,
country: { select: { countryName: true } },
},
distinct: ['originGoodId', 'countryId'],
}),
this.prisma.goodTag.findMany({
select: {
good: { select: { originGoodId: true } },
tag: {
select: {
tagName: true,
tagColor: true,
tagFontColor: true,
tagGroupId: true,
sortOrder: true,
tagGroup: { select: { groupName: true } },
},
},
},
}),
]);
const countMap = new Map<string, number>();
configCounts.forEach((c) =>
countMap.set(c.originGoodId.toString(), c._count._all),
);
const countryMap = new Map<string, string[]>();
goodsWithCountries.forEach((g) => {
const key = g.originGoodId.toString();
const name = g.country?.countryName;
if (!name) return;
const arr = countryMap.get(key);
if (arr) arr.push(name);
else countryMap.set(key, [name]);
});
const tagMap = new Map<string, { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]>();
goodsWithTags.forEach((gt) => {
const key = gt.good.originGoodId.toString();
const tagInfo = {
tagName: gt.tag.tagName,
tagColor: gt.tag.tagColor,
tagFontColor: gt.tag.tagFontColor,
tagGroupId: gt.tag.tagGroupId?.toString() ?? null,
tagGroupName: gt.tag.tagGroup?.groupName ?? null,
sortOrder: gt.tag.sortOrder,
};
const arr = tagMap.get(key);
if (arr) {
if (!arr.some((t) => t.tagName === tagInfo.tagName)) arr.push(tagInfo);
} else {
tagMap.set(key, [tagInfo]);
}
});
const sdsToCategory = new Map<
string,
(typeof allCategories)[number]
>();
for (const c of allCategories) {
if (c.sdsCategoryId) sdsToCategory.set(c.sdsCategoryId, c);
}
const ogToCategory = new Map<string, string>();
for (const og of allOriginGoods) {
if (og.sdsCategoryId && sdsToCategory.has(og.sdsCategoryId)) {
ogToCategory.set(og.id.toString(), sdsToCategory.get(og.sdsCategoryId)!.id.toString());
}
}
const buildNode = (
cat: (typeof allCategories)[number],
): OriginGoodsTreeCategoryNode => {
const childrenCats = allCategories.filter(
(c) => c.parentCategoryId !== null && c.parentCategoryId === cat.id,
);
const childNodes = childrenCats.map(buildNode);
const ogsForThisCat = allOriginGoods.filter(
(og) => ogToCategory.get(og.id.toString()) === cat.id.toString(),
);
const ogNodes: OriginGoodsTreeNode[] = ogsForThisCat.map((og) => ({
id: og.id.toString(),
goodName: og.goodName ?? `SDS-${og.sdsGoodId}`,
goodImage: og.goodImage,
goodPrice: og.goodPrice?.toString() ?? null,
sdsGoodId: og.sdsGoodId,
configuredCount: countMap.get(og.id.toString()) ?? 0,
configuredCountries: countryMap.get(og.id.toString()) ?? [],
configuredTags: tagMap.get(og.id.toString()) ?? [],
}));
const childTotal = childNodes.reduce((s, n) => s + n.totalCount, 0);
const childConfigured = childNodes.reduce(
(s, n) => s + n.configuredCount,
0,
);
const ogConfigured = ogNodes.filter((o) => o.configuredCount > 0).length;
return {
categoryId: cat.id.toString(),
categoryName: cat.categoryName,
sdsCategoryId: cat.sdsCategoryId,
configuredCount: childConfigured + ogConfigured,
totalCount: childTotal + ogNodes.length,
children: childNodes,
originGoods: ogNodes,
};
};
const roots = allCategories.filter((c) => c.parentCategoryId === null);
const tree = roots.map(buildNode);
const unmapped = allOriginGoods.filter(
(og) => !ogToCategory.has(og.id.toString()),
);
if (unmapped.length > 0) {
tree.push({
categoryId: 'uncategorized',
categoryName: '未分类',
sdsCategoryId: null,
configuredCount: unmapped.filter(
(og) => (countMap.get(og.id.toString()) ?? 0) > 0,
).length,
totalCount: unmapped.length,
children: [],
originGoods: unmapped.map((og) => ({
id: og.id.toString(),
goodName: og.goodName ?? `SDS-${og.sdsGoodId}`,
goodImage: og.goodImage,
goodPrice: og.goodPrice?.toString() ?? null,
sdsGoodId: og.sdsGoodId,
configuredCount: countMap.get(og.id.toString()) ?? 0,
configuredCountries: countryMap.get(og.id.toString()) ?? [],
configuredTags: tagMap.get(og.id.toString()) ?? [],
})),
});
}
tree.sort((a, b) => a.categoryName.localeCompare(b.categoryName, 'zh'));
const totalConfigured = allOriginGoods.filter(
(og) => (countMap.get(og.id.toString()) ?? 0) > 0,
).length;
return {
tree,
totalOriginGoods: allOriginGoods.length,
configuredCount: totalConfigured,
};
}
}
@@ -0,0 +1,25 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsInt,
IsOptional,
Min,
} from 'class-validator';
export class CreatePositionDto {
@ApiProperty({ description: 'Sort order / weight' })
@IsInt()
@Min(0)
indexVal!: number;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsInt()
@Min(1)
countryId?: number;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsInt()
@Min(1)
categoryId?: number;
}
@@ -0,0 +1,26 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsInt,
IsOptional,
Min,
} from 'class-validator';
export class UpdatePositionDto {
@ApiProperty({ required: false })
@IsOptional()
@IsInt()
@Min(0)
indexVal?: number;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsInt()
@Min(1)
countryId?: number | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsInt()
@Min(1)
categoryId?: number | null;
}
@@ -0,0 +1,71 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { PositionsService } from './positions.service';
import { CreatePositionDto } from './dto/create-position.dto';
import { UpdatePositionDto } from './dto/update-position.dto';
@ApiTags('positions')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('positions')
export class PositionsController {
constructor(private readonly service: PositionsService) {}
@Get()
@ApiOperation({ summary: 'List positions (optionally filtered)' })
@ApiQuery({ name: 'countryId', required: false, type: Number })
@ApiQuery({ name: 'categoryId', required: false, type: Number })
findAll(
@Query('countryId') countryId?: string,
@Query('categoryId') categoryId?: string,
) {
return this.service.findAll({
countryId: countryId ? BigInt(countryId) : undefined,
categoryId: categoryId ? BigInt(categoryId) : undefined,
});
}
@Get(':id')
@ApiOperation({ summary: 'Get one position' })
findOne(@Param('id', ParseIntPipe) id: string) {
return this.service.findOne(BigInt(id));
}
@Post()
@ApiOperation({ summary: 'Create a position' })
create(@Body() dto: CreatePositionDto) {
return this.service.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a position' })
update(
@Param('id', ParseIntPipe) id: string,
@Body() dto: UpdatePositionDto,
) {
return this.service.update(BigInt(id), dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a position' })
remove(@Param('id', ParseIntPipe) id: string) {
return this.service.remove(BigInt(id));
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PositionsController } from './positions.controller';
import { PositionsService } from './positions.service';
@Module({
controllers: [PositionsController],
providers: [PositionsService],
exports: [PositionsService],
})
export class PositionsModule {}
@@ -0,0 +1,97 @@
import { Test } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { PositionsService } from './positions.service';
import { PrismaService } from '../prisma/prisma.service';
describe('PositionsService', () => {
let service: PositionsService;
let prisma: PrismaService;
let countryId: bigint;
let categoryId: bigint;
const createdIds: bigint[] = [];
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [PositionsService, PrismaService],
}).compile();
service = moduleRef.get(PositionsService);
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
const stamp = Date.now();
const c = await prisma.country.create({
data: { countryName: `Pos Country ${stamp}` },
});
countryId = c.id;
const cat = await prisma.category.create({
data: { categoryName: `Pos Cat ${stamp}` },
});
categoryId = cat.id;
});
afterAll(async () => {
if (createdIds.length) {
await prisma.position.deleteMany({ where: { id: { in: createdIds } } });
}
await prisma.country.delete({ where: { id: countryId } });
await prisma.category.delete({ where: { id: categoryId } });
await prisma.onModuleDestroy();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('creates a position linked to country + category and returns joined names', async () => {
const created = await service.create({
indexVal: 1,
countryId: Number(countryId),
categoryId: Number(categoryId),
});
createdIds.push(created.id);
expect(created.country?.countryName).toBeTruthy();
expect(created.category?.categoryName).toBeTruthy();
const fetched = await service.findOne(created.id);
expect(fetched.country?.id).toBe(countryId);
expect(fetched.category?.id).toBe(categoryId);
});
it('filters by countryId and categoryId', async () => {
const list = await service.findAll({
countryId,
categoryId,
});
expect(list.length).toBeGreaterThan(0);
expect(list.every((p) => p.countryId === countryId)).toBe(true);
expect(list.every((p) => p.categoryId === categoryId)).toBe(true);
});
it('updates indexVal and disconnects country', async () => {
const created = await service.create({
indexVal: 5,
countryId: Number(countryId),
});
createdIds.push(created.id);
const updated = await service.update(created.id, {
indexVal: 9,
countryId: null,
});
expect(updated.indexVal).toBe(9);
expect(updated.countryId).toBeNull();
});
it('throws NotFoundException for unknown country during create', async () => {
await expect(
service.create({ indexVal: 1, countryId: 99999999 }),
).rejects.toBeInstanceOf(NotFoundException);
});
it('deletes a position', async () => {
const created = await service.create({ indexVal: 7 });
await service.remove(created.id);
const list = await service.findAll();
expect(list.find((p) => p.id === created.id)).toBeUndefined();
});
});
@@ -0,0 +1,93 @@
import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreatePositionDto } from './dto/create-position.dto';
import { UpdatePositionDto } from './dto/update-position.dto';
@Injectable()
export class PositionsService {
constructor(private readonly prisma: PrismaService) {}
findAll(filters?: { countryId?: bigint; categoryId?: bigint }) {
const where: { countryId?: bigint; categoryId?: bigint } = {};
if (filters?.countryId !== undefined) where.countryId = filters.countryId;
if (filters?.categoryId !== undefined) where.categoryId = filters.categoryId;
return this.prisma.position.findMany({
where,
include: { country: true, category: true },
orderBy: { indexVal: 'asc' },
});
}
async findOne(id: bigint) {
const p = await this.prisma.position.findUnique({
where: { id },
include: { country: true, category: true },
});
if (!p) throw new NotFoundException(`Position ${id} not found`);
return p;
}
async create(dto: CreatePositionDto) {
if (dto.countryId !== undefined) {
await this.ensureCountry(dto.countryId);
}
if (dto.categoryId !== undefined) {
await this.ensureCategory(dto.categoryId);
}
return this.prisma.position.create({
data: {
indexVal: dto.indexVal,
countryId: dto.countryId === undefined ? null : BigInt(dto.countryId),
categoryId: dto.categoryId === undefined ? null : BigInt(dto.categoryId),
},
include: { country: true, category: true },
});
}
async update(id: bigint, dto: UpdatePositionDto) {
await this.findOne(id);
if (dto.countryId !== undefined && dto.countryId !== null) {
await this.ensureCountry(dto.countryId);
}
if (dto.categoryId !== undefined && dto.categoryId !== null) {
await this.ensureCategory(dto.categoryId);
}
return this.prisma.position.update({
where: { id },
data: {
indexVal: dto.indexVal,
country:
dto.countryId === undefined
? undefined
: dto.countryId === null
? { disconnect: true }
: { connect: { id: BigInt(dto.countryId) } },
category:
dto.categoryId === undefined
? undefined
: dto.categoryId === null
? { disconnect: true }
: { connect: { id: BigInt(dto.categoryId) } },
},
include: { country: true, category: true },
});
}
async remove(id: bigint) {
await this.findOne(id);
return this.prisma.position.delete({ where: { id } });
}
private async ensureCountry(id: number) {
const c = await this.prisma.country.findUnique({ where: { id: BigInt(id) } });
if (!c) throw new NotFoundException(`Country ${id} not found`);
}
private async ensureCategory(id: number) {
const c = await this.prisma.category.findUnique({ where: { id: BigInt(id) } });
if (!c) throw new NotFoundException(`Category ${id} not found`);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
/**
* Global Prisma module exporting {@link PrismaService}.
*
* Marked `@Global()` so feature modules do not need to import it again.
*/
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

Some files were not shown because too many files have changed in this diff Show More