x
This commit is contained in:
Generated
+194
@@ -8,8 +8,10 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"air-datepicker": "^3.6.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"element-plus": "^2.14.1",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.34",
|
||||
"vue-router": "^4.6.4"
|
||||
@@ -69,6 +71,24 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ctrl/tinycolor": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz",
|
||||
"integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@element-plus/icons-vue": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
|
||||
"integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"vue": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
@@ -103,6 +123,31 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.5",
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
@@ -144,6 +189,17 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"name": "@sxzz/popperjs-es",
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz",
|
||||
"integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
|
||||
@@ -437,6 +493,21 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
|
||||
"integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash-es": {
|
||||
"version": "4.17.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.12.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz",
|
||||
@@ -447,6 +518,12 @@
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "6.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz",
|
||||
@@ -661,6 +738,44 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/core": {
|
||||
"version": "14.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz",
|
||||
"integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": "^0.0.21",
|
||||
"@vueuse/metadata": "14.3.0",
|
||||
"@vueuse/shared": "14.3.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/metadata": {
|
||||
"version": "14.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz",
|
||||
"integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/shared": {
|
||||
"version": "14.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz",
|
||||
"integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/air-datepicker": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/air-datepicker/-/air-datepicker-3.6.0.tgz",
|
||||
@@ -674,6 +789,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/async-validator": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
|
||||
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/birpc": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
|
||||
@@ -716,6 +837,12 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.21",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
|
||||
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -726,6 +853,32 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/element-plus": {
|
||||
"version": "2.14.1",
|
||||
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.1.tgz",
|
||||
"integrity": "sha512-UFnm1+BckNi+azkKJ7L32q1uXs9ekr99Z9pWTQPeDR05jqEWUwQq51ro4kZMVrANbjknX3Z7ukCZwTi2T6Tr9A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ctrl/tinycolor": "^4.2.0",
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@floating-ui/dom": "^1.7.6",
|
||||
"@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@vueuse/core": "14.3.0",
|
||||
"async-validator": "^4.2.5",
|
||||
"dayjs": "^1.11.20",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"lodash-unified": "^1.0.3",
|
||||
"memoize-one": "^6.0.0",
|
||||
"normalize-wheel-es": "^1.2.0",
|
||||
"vue-component-type-helpers": "^3.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.3.7"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
@@ -1068,6 +1221,29 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-unified": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz",
|
||||
"integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/lodash-es": "*",
|
||||
"lodash": "*",
|
||||
"lodash-es": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -1077,6 +1253,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/memoize-one": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mitt": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||
@@ -1108,6 +1290,12 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-wheel-es": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
|
||||
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/path-browserify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||
@@ -1411,6 +1599,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-component-type-helpers": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.3.tgz",
|
||||
"integrity": "sha512-x4nsFpy5Pe8fqPzp/5vkTPeTTDBpAx4WVtV47Ejt0+2FQrq4pRRsJs7JmYRqMFzTu/LW+pCWEjQ3YVCkPV7f9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "4.6.4",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"air-datepicker": "^3.6.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"element-plus": "^2.14.1",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.34",
|
||||
"vue-router": "^4.6.4"
|
||||
|
||||
@@ -3,8 +3,12 @@ import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import './assets/styles/main.css'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
app.mount('#app')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useInventory } from '@/modules/inventory/composables/useInventory'
|
||||
import { apiRequest } from '@/shared/api'
|
||||
import { addNotification, handleApiError } from '@/shared/notification'
|
||||
|
||||
const { state, loadCustomers } = useInventory()
|
||||
|
||||
const showModal = ref(false)
|
||||
const editingItem = ref<any>(null)
|
||||
const form = reactive({
|
||||
name: '',
|
||||
contact_person: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: ''
|
||||
})
|
||||
|
||||
function openCreateCustomer() {
|
||||
editingItem.value = null
|
||||
form.name = ''
|
||||
form.contact_person = ''
|
||||
form.phone = ''
|
||||
form.email = ''
|
||||
form.address = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function editCustomer(item: any) {
|
||||
editingItem.value = item
|
||||
form.name = item.name ?? ''
|
||||
form.contact_person = item.contact_person ?? ''
|
||||
form.phone = item.phone ?? ''
|
||||
form.email = item.email ?? ''
|
||||
form.address = item.address ?? ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
contact_person: form.contact_person || null,
|
||||
phone: form.phone || null,
|
||||
email: form.email || null,
|
||||
address: form.address || null
|
||||
}
|
||||
if (editingItem.value) {
|
||||
await apiRequest(`/api/customers/${editingItem.value.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
addNotification('客户更新成功', 'success')
|
||||
} else {
|
||||
await apiRequest('/api/customers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
addNotification('客户已创建', 'success')
|
||||
}
|
||||
showModal.value = false
|
||||
loadCustomers()
|
||||
} catch (e) {
|
||||
handleApiError(e, '保存客户')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCustomer(id: number) {
|
||||
if (!confirm('确定要删除这个客户吗?')) return
|
||||
try {
|
||||
await apiRequest(`/api/customers/${id}`, { method: 'DELETE' })
|
||||
addNotification('客户已删除', 'success')
|
||||
loadCustomers()
|
||||
} catch (e) {
|
||||
handleApiError(e, '删除客户')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="customers-tab">
|
||||
<div class="table-header">
|
||||
<el-button type="primary" @click="openCreateCustomer">+ 新增客户</el-button>
|
||||
</div>
|
||||
<el-table :data="state.customers" style="width: 100%" v-loading="state.loading" empty-text="暂无客户数据">
|
||||
<el-table-column prop="code" label="编码" />
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="contact_person" label="联系人">
|
||||
<template #default="{ row }">
|
||||
{{ row.contact_person || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="phone" label="电话">
|
||||
<template #default="{ row }">
|
||||
{{ row.phone || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="email" label="邮箱">
|
||||
<template #default="{ row }">
|
||||
{{ row.email || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="editCustomer(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteCustomer(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="showModal" :title="editingItem ? '编辑客户' : '新增客户'" width="520px" :close-on-click-modal="false">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="名称" required>
|
||||
<el-input v-model="form.name" placeholder="请输入客户名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系人">
|
||||
<el-input v-model="form.contact_person" placeholder="请输入联系人" />
|
||||
</el-form-item>
|
||||
<el-form-item label="电话">
|
||||
<el-input v-model="form.phone" placeholder="请输入电话" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="form.email" type="email" placeholder="请输入邮箱" />
|
||||
</el-form-item>
|
||||
<el-form-item label="地址">
|
||||
<el-input v-model="form.address" placeholder="请输入地址" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showModal = false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.customers-tab {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { useInventory } from '@/modules/inventory/composables/useInventory'
|
||||
const { state, formatCurrency } = useInventory()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8" v-for="card in [
|
||||
{ icon: '📦', value: state.dashboard?.product_count || 0, label: '成品数量' },
|
||||
{ icon: '📊', value: state.dashboard?.total_stock || 0, label: '物料库存总量' },
|
||||
{ icon: '💰', value: formatCurrency(state.dashboard?.total_value || 0), label: '库存价值' },
|
||||
{ icon: '🏭', value: state.dashboard?.supplier_count || 0, label: '供应商' },
|
||||
{ icon: '👥', value: state.dashboard?.customer_count || 0, label: '客户' },
|
||||
{ icon: '🏪', value: state.dashboard?.warehouse_count || 0, label: '仓库' }
|
||||
]" :key="card.label" style="margin-bottom: 16px">
|
||||
<el-card shadow="hover">
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<span style="font-size: 32px;">{{ card.icon }}</span>
|
||||
<div>
|
||||
<div style="font-size: 24px; font-weight: 700;">{{ card.value }}</div>
|
||||
<div style="color: var(--text-tertiary); font-size: 13px;">{{ card.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { useInventory } from '@/modules/inventory/composables/useInventory'
|
||||
|
||||
const { state, menuGroups, openGroups, toggleGroup, handleMenuClick } = useInventory()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-menu
|
||||
:default-active="state.activeTab"
|
||||
class="inventory-sidebar"
|
||||
@select="(key: string) => handleMenuClick(key)"
|
||||
>
|
||||
<div v-for="group in menuGroups" :key="group.key" class="sidebar-group">
|
||||
<div class="sidebar-group-title" @click="toggleGroup(group.key)">
|
||||
<span>{{ group.title }}</span>
|
||||
<el-icon><component :is="openGroups[group.key] ? 'ArrowDown' : 'ArrowRight'" /></el-icon>
|
||||
</div>
|
||||
<template v-if="openGroups[group.key]">
|
||||
<el-menu-item
|
||||
v-for="item in group.items"
|
||||
:key="group.key + '-' + item.key"
|
||||
:index="item.key"
|
||||
>
|
||||
{{ item.label }}
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</div>
|
||||
</el-menu>
|
||||
</template>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useInventory } from '@/modules/inventory/composables/useInventory'
|
||||
import { apiRequest } from '@/shared/api'
|
||||
import { addNotification, handleApiError } from '@/shared/notification'
|
||||
|
||||
const { state, loadInventory, ensureStockBaseData } = useInventory()
|
||||
|
||||
const showModal = ref(false)
|
||||
const editingItem = ref<any>(null)
|
||||
const form = reactive({
|
||||
product_id: null as number | null,
|
||||
warehouse_id: null as number | null,
|
||||
quantity: 0,
|
||||
locked_quantity: 0,
|
||||
batch_no: '',
|
||||
location: ''
|
||||
})
|
||||
|
||||
function openCreate() {
|
||||
ensureStockBaseData()
|
||||
editingItem.value = null
|
||||
form.product_id = null
|
||||
form.warehouse_id = null
|
||||
form.quantity = 0
|
||||
form.locked_quantity = 0
|
||||
form.batch_no = ''
|
||||
form.location = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: any) {
|
||||
ensureStockBaseData()
|
||||
editingItem.value = item
|
||||
form.product_id = item.product_id ?? null
|
||||
form.warehouse_id = item.warehouse_id ?? null
|
||||
form.quantity = item.quantity ?? 0
|
||||
form.locked_quantity = item.locked_quantity ?? 0
|
||||
form.batch_no = item.batch_no ?? ''
|
||||
form.location = item.location ?? ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
const payload = {
|
||||
product_id: form.product_id,
|
||||
warehouse_id: form.warehouse_id,
|
||||
quantity: form.quantity,
|
||||
locked_quantity: form.locked_quantity,
|
||||
batch_no: form.batch_no || null,
|
||||
location: form.location || null
|
||||
}
|
||||
if (editingItem.value) {
|
||||
await apiRequest(`/api/inventory/${editingItem.value.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
addNotification('物料库存更新成功', 'success')
|
||||
} else {
|
||||
await apiRequest('/api/inventory', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
addNotification('物料库存已创建', 'success')
|
||||
}
|
||||
showModal.value = false
|
||||
loadInventory()
|
||||
} catch (e) {
|
||||
handleApiError(e, '保存物料库存')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteItem(id: number) {
|
||||
if (!confirm('确定要删除这个物料库存记录吗?')) return
|
||||
try {
|
||||
await apiRequest(`/api/inventory/${id}`, { method: 'DELETE' })
|
||||
addNotification('物料库存已删除', 'success')
|
||||
loadInventory()
|
||||
} catch (e) {
|
||||
handleApiError(e, '删除物料库存')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inventory-tab">
|
||||
<div class="table-header">
|
||||
<el-button type="primary" @click="openCreate">+ 新增物料库存</el-button>
|
||||
</div>
|
||||
<el-table :data="state.inventory" style="width: 100%" v-loading="state.loading" empty-text="暂无库存数据">
|
||||
<el-table-column prop="product_sku" label="SKU" />
|
||||
<el-table-column prop="product_name" label="物料" />
|
||||
<el-table-column prop="warehouse_name" label="仓库" />
|
||||
<el-table-column prop="quantity" label="数量" />
|
||||
<el-table-column prop="available_quantity" label="可用" />
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteItem(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="showModal" :title="editingItem ? '编辑物料库存' : '新增物料库存'" width="520px" :close-on-click-modal="false">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="物料" required>
|
||||
<el-select v-model="form.product_id" placeholder="请选择物料" style="width: 100%">
|
||||
<el-option
|
||||
v-for="m in state.materials"
|
||||
:key="m.id"
|
||||
:label="m.name + (m.sku ? ' (' + m.sku + ')' : '')"
|
||||
:value="m.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="仓库" required>
|
||||
<el-select v-model="form.warehouse_id" placeholder="请选择仓库" style="width: 100%">
|
||||
<el-option
|
||||
v-for="w in state.warehouses"
|
||||
:key="w.id"
|
||||
:label="w.name"
|
||||
:value="w.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="数量" required>
|
||||
<el-input-number v-model="form.quantity" :min="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="锁定数量">
|
||||
<el-input-number v-model="form.locked_quantity" :min="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="批次号">
|
||||
<el-input v-model="form.batch_no" placeholder="请输入批次号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="库位">
|
||||
<el-input v-model="form.location" placeholder="请输入库位" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showModal = false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inventory-tab {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,314 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useInventory } from '../composables/useInventory'
|
||||
import { apiRequest } from '@/shared/api'
|
||||
import { addNotification, handleApiError } from '@/shared/notification'
|
||||
|
||||
const {
|
||||
state,
|
||||
formatCurrency,
|
||||
loadMaterials,
|
||||
loadSuppliers,
|
||||
loadPurchaseOrders
|
||||
} = useInventory()
|
||||
|
||||
const showModal = ref(false)
|
||||
const editingItem = ref<any>(null)
|
||||
const form = ref<any>({})
|
||||
const showRestockModal = ref(false)
|
||||
const restockItems = ref<any[]>([])
|
||||
const restockSupplierId = ref<number | null>(null)
|
||||
|
||||
function openCreateMaterial() {
|
||||
editingItem.value = null
|
||||
form.value = {
|
||||
item_type: 'material',
|
||||
unit: 'kg',
|
||||
min_stock: 0,
|
||||
max_stock: 1000,
|
||||
cost_price: 0,
|
||||
sale_price: 0,
|
||||
sku: '',
|
||||
name: '',
|
||||
category: ''
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function editMaterial(product: any) {
|
||||
editingItem.value = product
|
||||
form.value = { ...product }
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function saveProduct() {
|
||||
if (!form.value.sku) {
|
||||
addNotification('请输入SKU', 'warning')
|
||||
return
|
||||
}
|
||||
if (!form.value.name) {
|
||||
addNotification('请输入名称', 'warning')
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (editingItem.value) {
|
||||
await apiRequest(`/api/products/${editingItem.value.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(form.value)
|
||||
})
|
||||
addNotification('物料更新成功', 'success')
|
||||
} else {
|
||||
await apiRequest('/api/products', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(form.value)
|
||||
})
|
||||
addNotification('物料创建成功', 'success')
|
||||
}
|
||||
showModal.value = false
|
||||
editingItem.value = null
|
||||
form.value = {}
|
||||
loadMaterials()
|
||||
} catch (e) {
|
||||
handleApiError(e, '保存物料')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMaterial(id: number) {
|
||||
if (!confirm('确定要删除这个物料吗?')) return
|
||||
try {
|
||||
await apiRequest(`/api/products/${id}`, { method: 'DELETE' })
|
||||
addNotification('物料已删除', 'success')
|
||||
loadMaterials()
|
||||
} catch (e) {
|
||||
handleApiError(e, '删除物料')
|
||||
}
|
||||
}
|
||||
|
||||
async function openRestockModal() {
|
||||
await loadMaterials()
|
||||
await loadSuppliers()
|
||||
restockItems.value = []
|
||||
showRestockModal.value = true
|
||||
}
|
||||
|
||||
function addRestockItem() {
|
||||
restockItems.value.push({
|
||||
material_id: state.materials[0]?.id || null,
|
||||
quantity: 1,
|
||||
unit_price: 0,
|
||||
remark: ''
|
||||
})
|
||||
}
|
||||
|
||||
function removeRestockItem(index: number) {
|
||||
restockItems.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const restockTotalAmount = computed(() => {
|
||||
return restockItems.value.reduce((sum: number, item: any) => {
|
||||
return sum + (item.unit_price || 0) * (item.quantity || 0)
|
||||
}, 0)
|
||||
})
|
||||
|
||||
async function saveRestock() {
|
||||
if (!restockItems.value || restockItems.value.length === 0) {
|
||||
addNotification('请至少添加一个补货物料', 'warning')
|
||||
return
|
||||
}
|
||||
for (const [i, item] of restockItems.value.entries()) {
|
||||
const idx = i + 1
|
||||
if (!item.material_id) {
|
||||
addNotification(`第 ${idx} 行:请选择物料`, 'warning')
|
||||
return
|
||||
}
|
||||
if (!Number.isFinite(item.quantity) || item.quantity <= 0) {
|
||||
addNotification(`第 ${idx} 行:数量必须大于 0`, 'warning')
|
||||
return
|
||||
}
|
||||
if (!Number.isFinite(item.unit_price) || item.unit_price < 0) {
|
||||
addNotification(`第 ${idx} 行:单价必须大于等于 0`, 'warning')
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
const payload = {
|
||||
supplier_id: restockSupplierId.value || state.suppliers[0]?.id || null,
|
||||
expected_date: new Date().toISOString().split('T')[0],
|
||||
remark: '物料补货',
|
||||
items: restockItems.value.map((item: any) => ({
|
||||
product_id: item.material_id,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unit_price,
|
||||
remark: item.remark
|
||||
}))
|
||||
}
|
||||
if (!payload.supplier_id) {
|
||||
addNotification('请先添加供应商', 'warning')
|
||||
return
|
||||
}
|
||||
await apiRequest('/api/purchase-orders', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
addNotification('采购订单创建成功', 'success')
|
||||
showRestockModal.value = false
|
||||
restockItems.value = []
|
||||
loadPurchaseOrders()
|
||||
} catch (e) {
|
||||
handleApiError(e, '保存补货订单')
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editingItem.value = null
|
||||
form.value = {}
|
||||
}
|
||||
|
||||
function closeRestockModal() {
|
||||
showRestockModal.value = false
|
||||
restockItems.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div style="margin-bottom: 12px; display: flex; gap: 8px;">
|
||||
<el-button type="primary" @click="openCreateMaterial">新增物料</el-button>
|
||||
<el-button type="success" @click="openRestockModal">物料补货</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="state.materials" v-loading="state.loading" stripe>
|
||||
<el-table-column prop="sku" label="SKU" />
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="category" label="分类">
|
||||
<template #default="{ row }">{{ row.category || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="unit" label="单位" />
|
||||
<el-table-column label="成本价">
|
||||
<template #default="{ row }">{{ formatCurrency(row.cost_price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="min_stock" label="最低库存">
|
||||
<template #default="{ row }">{{ row.min_stock ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" size="small" @click="editMaterial(row)">编辑</el-button>
|
||||
<el-button type="danger" size="small" @click="deleteMaterial(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-empty v-if="!state.loading && state.materials.length === 0" description="暂无物料数据" />
|
||||
</el-table>
|
||||
|
||||
<el-dialog
|
||||
v-model="showModal"
|
||||
:title="(editingItem ? '编辑' : '新增') + '物料'"
|
||||
width="560px"
|
||||
@closed="closeModal"
|
||||
>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="类型">
|
||||
<el-input model-value="物料(纳入库存)" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="SKU" required>
|
||||
<el-input v-model="form.sku" placeholder="产品编码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名称" required>
|
||||
<el-input v-model="form.name" placeholder="产品名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-input v-model="form.category" placeholder="产品分类" />
|
||||
</el-form-item>
|
||||
<el-form-item label="单位">
|
||||
<el-input v-model="form.unit" placeholder="kg" />
|
||||
</el-form-item>
|
||||
<el-form-item label="成本价">
|
||||
<el-input-number v-model="form.cost_price" :precision="2" :min="0" :step="0.01" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最低库存">
|
||||
<el-input-number v-model="form.min_stock" :min="0" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最高库存">
|
||||
<el-input-number v-model="form.max_stock" :min="0" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showModal = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveProduct">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="showRestockModal"
|
||||
title="物料补货"
|
||||
width="900px"
|
||||
@closed="closeRestockModal"
|
||||
>
|
||||
<el-form label-width="100px" style="margin-bottom: 16px;">
|
||||
<el-form-item label="供应商">
|
||||
<el-select v-model="restockSupplierId" placeholder="请选择供应商" style="width:100%">
|
||||
<el-option
|
||||
v-for="supplier in state.suppliers"
|
||||
:key="supplier.id"
|
||||
:label="supplier.name"
|
||||
:value="supplier.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
||||
<span style="font-weight: 600;">补货物料明细</span>
|
||||
<el-button type="primary" size="small" @click="addRestockItem">+ 添加物料</el-button>
|
||||
</div>
|
||||
<el-table :data="restockItems" border size="small">
|
||||
<el-table-column label="物料" min-width="200">
|
||||
<template #default="{ row: line }">
|
||||
<el-select v-model="line.material_id" placeholder="请选择物料" style="width:100%">
|
||||
<el-option
|
||||
v-for="material in state.materials"
|
||||
:key="material.id"
|
||||
:label="`${material.sku} - ${material.name}`"
|
||||
:value="material.id"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数量" width="120">
|
||||
<template #default="{ row: line }">
|
||||
<el-input-number v-model="line.quantity" :min="1" size="small" style="width:100%" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单价" width="120">
|
||||
<template #default="{ row: line }">
|
||||
<el-input-number v-model="line.unit_price" :min="0" :precision="2" :step="0.01" size="small" style="width:100%" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="总价" width="120">
|
||||
<template #default="{ row: line }">
|
||||
{{ formatCurrency((line.unit_price || 0) * (line.quantity || 0)) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" width="150">
|
||||
<template #default="{ row: line }">
|
||||
<el-input v-model="line.remark" size="small" placeholder="备注" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ $index }">
|
||||
<el-button type="danger" size="small" @click="removeRestockItem($index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div style="text-align: right; margin-top: 8px; font-weight: 600;">
|
||||
补货总金额:{{ formatCurrency(restockTotalAmount) }}
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showRestockModal = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveRestock">创建采购订单</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { useInventory } from '@/modules/inventory/composables/useInventory'
|
||||
|
||||
const { state, getMovementTypeLabel, getMovementBadgeClass, formatDateTime } = useInventory()
|
||||
|
||||
const getMovementTagType = (movementType: string): 'success' | 'danger' | 'warning' | 'info' | '' => {
|
||||
const badgeClass = getMovementBadgeClass(movementType)
|
||||
if (badgeClass === 'badge-success') return 'success'
|
||||
if (badgeClass === 'badge-error') return 'danger'
|
||||
if (badgeClass === 'badge-warning') return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="movements-tab">
|
||||
<el-table :data="state.movements" style="width: 100%" v-loading="state.loading" empty-text="暂无库存变动记录">
|
||||
<el-table-column label="物料">
|
||||
<template #default="{ row }">
|
||||
{{ row.product_name }}{{ row.product_sku ? ' (' + row.product_sku + ')' : '' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getMovementTagType(row.movement_type)" size="small">
|
||||
{{ getMovementTypeLabel(row.movement_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="quantity" label="数量" width="80" />
|
||||
<el-table-column prop="before_quantity" label="变动前" width="80" />
|
||||
<el-table-column prop="after_quantity" label="变动后" width="80" />
|
||||
<el-table-column label="时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.movements-tab {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,226 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useInventory } from '../composables/useInventory'
|
||||
import { apiRequest } from '@/shared/api'
|
||||
import { addNotification, handleApiError } from '@/shared/notification'
|
||||
|
||||
const {
|
||||
state,
|
||||
formatCurrency,
|
||||
loadFinishedProducts,
|
||||
loadMaterials
|
||||
} = useInventory()
|
||||
|
||||
const showModal = ref(false)
|
||||
const editingItem = ref<any>(null)
|
||||
const form = ref<any>({})
|
||||
const bomItems = ref<any[]>([])
|
||||
|
||||
function openCreateProduct() {
|
||||
editingItem.value = null
|
||||
form.value = {
|
||||
item_type: 'finished',
|
||||
unit: '件',
|
||||
min_stock: 0,
|
||||
max_stock: 1000,
|
||||
cost_price: 0,
|
||||
sale_price: 0,
|
||||
sku: '',
|
||||
name: '',
|
||||
category: ''
|
||||
}
|
||||
bomItems.value = []
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function editProduct(product: any) {
|
||||
editingItem.value = product
|
||||
form.value = { ...product }
|
||||
await loadMaterials()
|
||||
try {
|
||||
const bom = await apiRequest(`/api/products/${product.id}/materials`)
|
||||
bomItems.value = (bom.items || []).map((bomItem: any) => ({
|
||||
material_id: bomItem.material_id,
|
||||
quantity: bomItem.quantity
|
||||
}))
|
||||
} catch (e) {
|
||||
handleApiError(e, '加载BOM')
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function addBomItem() {
|
||||
bomItems.value.push({
|
||||
material_id: state.materials[0]?.id || null,
|
||||
quantity: 1
|
||||
})
|
||||
}
|
||||
|
||||
function removeBomItem(index: number) {
|
||||
bomItems.value.splice(index, 1)
|
||||
}
|
||||
|
||||
async function saveProduct() {
|
||||
if (!form.value.sku) {
|
||||
addNotification('请输入SKU', 'warning')
|
||||
return
|
||||
}
|
||||
if (!form.value.name) {
|
||||
addNotification('请输入名称', 'warning')
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (editingItem.value) {
|
||||
await apiRequest(`/api/products/${editingItem.value.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(form.value)
|
||||
})
|
||||
if (form.value.item_type === 'finished' && bomItems.value.length > 0) {
|
||||
await apiRequest(`/api/products/${editingItem.value.id}/materials`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ items: bomItems.value })
|
||||
})
|
||||
}
|
||||
addNotification('成品更新成功', 'success')
|
||||
} else {
|
||||
await apiRequest('/api/products', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(form.value)
|
||||
})
|
||||
addNotification('成品创建成功', 'success')
|
||||
}
|
||||
showModal.value = false
|
||||
editingItem.value = null
|
||||
form.value = {}
|
||||
bomItems.value = []
|
||||
loadFinishedProducts()
|
||||
loadMaterials()
|
||||
} catch (e) {
|
||||
handleApiError(e, '保存成品')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProduct(id: number) {
|
||||
if (!confirm('确定要删除这个成品吗?')) return
|
||||
try {
|
||||
await apiRequest(`/api/products/${id}`, { method: 'DELETE' })
|
||||
addNotification('成品已删除', 'success')
|
||||
loadFinishedProducts()
|
||||
loadMaterials()
|
||||
} catch (e) {
|
||||
handleApiError(e, '删除成品')
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editingItem.value = null
|
||||
form.value = {}
|
||||
bomItems.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div style="margin-bottom: 12px;">
|
||||
<el-button type="primary" @click="openCreateProduct">新增成品</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="state.finishedProducts" v-loading="state.loading" stripe>
|
||||
<el-table-column prop="sku" label="SKU" />
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="category" label="分类">
|
||||
<template #default="{ row }">{{ row.category || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="unit" label="单位" />
|
||||
<el-table-column label="成本价">
|
||||
<template #default="{ row }">{{ formatCurrency(row.cost_price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="销售价">
|
||||
<template #default="{ row }">{{ formatCurrency(row.sale_price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="基础物料成本">
|
||||
<template #default="{ row }">{{ formatCurrency(row.material_cost || 0) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" size="small" @click="editProduct(row)">编辑</el-button>
|
||||
<el-button type="danger" size="small" @click="deleteProduct(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-empty v-if="!state.loading && state.finishedProducts.length === 0" description="暂无成品数据" />
|
||||
</el-table>
|
||||
|
||||
<el-dialog
|
||||
v-model="showModal"
|
||||
:title="(editingItem ? '编辑' : '新增') + '成品'"
|
||||
width="560px"
|
||||
@closed="closeModal"
|
||||
>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="类型">
|
||||
<el-input model-value="成品(按单生产,不做库存)" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="SKU" required>
|
||||
<el-input v-model="form.sku" placeholder="产品编码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名称" required>
|
||||
<el-input v-model="form.name" placeholder="产品名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-input v-model="form.category" placeholder="产品分类" />
|
||||
</el-form-item>
|
||||
<el-form-item label="单位">
|
||||
<el-input v-model="form.unit" placeholder="件" />
|
||||
</el-form-item>
|
||||
<el-form-item label="成本价">
|
||||
<el-input-number v-model="form.cost_price" :precision="2" :min="0" :step="0.01" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="销售价">
|
||||
<el-input-number v-model="form.sale_price" :precision="2" :min="0" :step="0.01" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="说明">
|
||||
<el-input model-value="成品不做库存,成本由下方BOM定义物料构成后自动计算" disabled />
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="editingItem">
|
||||
<el-divider content-position="left">BOM物料配置</el-divider>
|
||||
<div style="padding: 0 12px 12px 12px;">
|
||||
<el-button type="primary" size="small" @click="addBomItem" style="margin-bottom:8px">+ 添加物料</el-button>
|
||||
<el-table :data="bomItems" size="small" border>
|
||||
<el-table-column label="物料">
|
||||
<template #default="{ row: bomItem }">
|
||||
<el-select v-model="bomItem.material_id" placeholder="选择物料" style="width:100%">
|
||||
<el-option
|
||||
v-for="material in state.materials"
|
||||
:key="material.id"
|
||||
:label="`${material.sku} - ${material.name}`"
|
||||
:value="material.id"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数量" width="120">
|
||||
<template #default="{ row: bomItem }">
|
||||
<el-input-number v-model="bomItem.quantity" :min="0.0001" :step="0.0001" size="small" style="width:100%" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ $index }">
|
||||
<el-button type="danger" size="small" @click="removeBomItem($index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showModal = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveProduct">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useInventory } from '@/modules/inventory/composables/useInventory'
|
||||
import { apiRequest } from '@/shared/api'
|
||||
import { addNotification, handleApiError } from '@/shared/notification'
|
||||
|
||||
const { state, loadSuppliers } = useInventory()
|
||||
|
||||
const showModal = ref(false)
|
||||
const editingItem = ref<any>(null)
|
||||
const form = reactive({
|
||||
name: '',
|
||||
contact_person: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: ''
|
||||
})
|
||||
|
||||
function openCreateSupplier() {
|
||||
editingItem.value = null
|
||||
form.name = ''
|
||||
form.contact_person = ''
|
||||
form.phone = ''
|
||||
form.email = ''
|
||||
form.address = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function editSupplier(item: any) {
|
||||
editingItem.value = item
|
||||
form.name = item.name ?? ''
|
||||
form.contact_person = item.contact_person ?? ''
|
||||
form.phone = item.phone ?? ''
|
||||
form.email = item.email ?? ''
|
||||
form.address = item.address ?? ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
contact_person: form.contact_person || null,
|
||||
phone: form.phone || null,
|
||||
email: form.email || null,
|
||||
address: form.address || null
|
||||
}
|
||||
if (editingItem.value) {
|
||||
await apiRequest(`/api/suppliers/${editingItem.value.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
addNotification('供应商更新成功', 'success')
|
||||
} else {
|
||||
await apiRequest('/api/suppliers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
addNotification('供应商已创建', 'success')
|
||||
}
|
||||
showModal.value = false
|
||||
loadSuppliers()
|
||||
} catch (e) {
|
||||
handleApiError(e, '保存供应商')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSupplier(id: number) {
|
||||
if (!confirm('确定要删除这个供应商吗?')) return
|
||||
try {
|
||||
await apiRequest(`/api/suppliers/${id}`, { method: 'DELETE' })
|
||||
addNotification('供应商已删除', 'success')
|
||||
loadSuppliers()
|
||||
} catch (e) {
|
||||
handleApiError(e, '删除供应商')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="suppliers-tab">
|
||||
<div class="table-header">
|
||||
<el-button type="primary" @click="openCreateSupplier">+ 新增供应商</el-button>
|
||||
</div>
|
||||
<el-table :data="state.suppliers" style="width: 100%" v-loading="state.loading" empty-text="暂无供应商数据">
|
||||
<el-table-column prop="code" label="编码" />
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="contact_person" label="联系人">
|
||||
<template #default="{ row }">
|
||||
{{ row.contact_person || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="phone" label="电话">
|
||||
<template #default="{ row }">
|
||||
{{ row.phone || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="email" label="邮箱">
|
||||
<template #default="{ row }">
|
||||
{{ row.email || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="editSupplier(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteSupplier(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="showModal" :title="editingItem ? '编辑供应商' : '新增供应商'" width="520px" :close-on-click-modal="false">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="名称" required>
|
||||
<el-input v-model="form.name" placeholder="请输入供应商名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系人">
|
||||
<el-input v-model="form.contact_person" placeholder="请输入联系人" />
|
||||
</el-form-item>
|
||||
<el-form-item label="电话">
|
||||
<el-input v-model="form.phone" placeholder="请输入电话" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="form.email" type="email" placeholder="请输入邮箱" />
|
||||
</el-form-item>
|
||||
<el-form-item label="地址">
|
||||
<el-input v-model="form.address" placeholder="请输入地址" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showModal = false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.suppliers-tab {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,509 @@
|
||||
import { reactive, ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { apiRequest } from '@/shared/api'
|
||||
import { addNotification, handleApiError } from '@/shared/notification'
|
||||
import { formatCurrency, formatNumber, formatDateTime, formatDate } from '@/shared/utils'
|
||||
|
||||
declare const AirDatepicker: any
|
||||
|
||||
const state = reactive({
|
||||
activeTab: 'dashboard' as string,
|
||||
backendDbReady: true,
|
||||
backendDbMessage: '' as string,
|
||||
productCategory: 'finished' as string,
|
||||
dashboard: null as any,
|
||||
financeSummary: null as any,
|
||||
financePeriod: {
|
||||
year: new Date().getFullYear(),
|
||||
quarter: '' as string
|
||||
},
|
||||
financeTransactions: [] as any[],
|
||||
receivables: [] as any[],
|
||||
payables: [] as any[],
|
||||
customerFinanceStatement: [] as any[],
|
||||
supplierFinanceStatement: [] as any[],
|
||||
customerProductStatement: [] as any[],
|
||||
supplierProductStatement: [] as any[],
|
||||
products: [] as any[],
|
||||
materials: [] as any[],
|
||||
finishedProducts: [] as any[],
|
||||
purchaseOrders: [] as any[],
|
||||
purchaseWarehouseId: null as number | null,
|
||||
purchaseReceiveItems: [] as any[],
|
||||
productionOrders: [] as any[],
|
||||
productionPlan: null as any,
|
||||
productionWarehouseId: null as number | null,
|
||||
suppliers: [] as any[],
|
||||
customers: [] as any[],
|
||||
warehouses: [] as any[],
|
||||
inventory: [] as any[],
|
||||
movements: [] as any[],
|
||||
loading: false,
|
||||
showModal: false,
|
||||
modalType: '' as string,
|
||||
editingItem: null as any,
|
||||
productBomItems: [] as any[],
|
||||
materialConsumptionItems: [] as any[],
|
||||
showMaterialConsumptionModal: false,
|
||||
consumedMaterials: [] as any[],
|
||||
restockItems: [] as any[],
|
||||
showRestockModal: false,
|
||||
form: {} as any
|
||||
})
|
||||
|
||||
let deliveryPicker: any = null
|
||||
let expectedPicker: any = null
|
||||
|
||||
const deliveryDateInput = ref<HTMLElement | null>(null)
|
||||
const expectedDateInput = ref<HTMLElement | null>(null)
|
||||
const deliveryDateNativeInput = ref<HTMLElement | null>(null)
|
||||
const expectedDateNativeInput = ref<HTMLElement | null>(null)
|
||||
|
||||
export function useInventory() {
|
||||
const parseDateTimeLocal = (text: string | null | undefined): Date | null => {
|
||||
if (!text) return null
|
||||
const raw = String(text).trim()
|
||||
const normalized = raw.replace('T', ' ').slice(0, 16)
|
||||
const m = normalized.match(/^(\d{4})-(\d{2})-(\d{2})\s(\d{2}):(\d{2})$/)
|
||||
if (!m) return null
|
||||
const year = Number(m[1])
|
||||
const month = Number(m[2])
|
||||
const day = Number(m[3])
|
||||
const hour = Number(m[4])
|
||||
const minute = Number(m[5])
|
||||
if (!Number.isFinite(year + month + day + hour + minute)) return null
|
||||
return new Date(year, month - 1, day, hour, minute, 0)
|
||||
}
|
||||
|
||||
const toPickerValue = (value: any): string => {
|
||||
if (!value) return ''
|
||||
const raw = String(value).trim()
|
||||
if (/^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}/.test(raw)) return raw.slice(0, 16)
|
||||
if (raw.includes('T')) return raw.replace('T', ' ').slice(0, 16)
|
||||
const dt = new Date(raw)
|
||||
if (!Number.isFinite(dt.getTime())) return ''
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`
|
||||
}
|
||||
|
||||
const toApiDateTime = (value: any): string | null => {
|
||||
if (!value) return null
|
||||
const text = String(value).trim()
|
||||
if (text.includes('T')) return text.split('T')[0]
|
||||
if (text.includes(' ')) return text.split(' ')[0]
|
||||
if (text.length === 10) return text
|
||||
if (value instanceof Date) {
|
||||
const year = value.getFullYear()
|
||||
const month = String(value.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(value.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
const toNativeValue = (value: any): string => {
|
||||
if (!value) return ''
|
||||
const text = String(value).trim()
|
||||
if (text.length === 10 && !text.includes('T') && !text.includes(' ')) return text
|
||||
const isoText = text.replace(' ', 'T')
|
||||
return isoText.length >= 16 ? isoText.slice(0, 16) : isoText
|
||||
}
|
||||
|
||||
const fromNativeValue = (value: any): string => {
|
||||
if (!value) return ''
|
||||
const text = String(value).trim()
|
||||
if (text.length === 10 && !text.includes('T') && !text.includes(' ')) return text
|
||||
return text.replace('T', ' ').slice(0, 16)
|
||||
}
|
||||
|
||||
const destroyPickers = () => {
|
||||
if (deliveryPicker) {
|
||||
deliveryPicker.destroy()
|
||||
deliveryPicker = null
|
||||
}
|
||||
if (expectedPicker) {
|
||||
expectedPicker.destroy()
|
||||
expectedPicker = null
|
||||
}
|
||||
}
|
||||
|
||||
const initPickers = () => {
|
||||
destroyPickers()
|
||||
if (typeof AirDatepicker !== 'function') return
|
||||
|
||||
if (state.modalType === 'salesOrder' && deliveryDateInput.value) {
|
||||
deliveryPicker = new AirDatepicker(deliveryDateInput.value, {
|
||||
timepicker: false,
|
||||
autoClose: true,
|
||||
zIndex: 2005,
|
||||
dateFormat: 'yyyy-MM-dd',
|
||||
onSelect: ({ formattedDate }: any) => {
|
||||
state.form.delivery_date = formattedDate || ''
|
||||
state.form.delivery_date_native = toNativeValue(formattedDate || '')
|
||||
}
|
||||
})
|
||||
const initial = parseDateTimeLocal(state.form.delivery_date)
|
||||
if (initial) deliveryPicker.selectDate(initial, { silent: true })
|
||||
}
|
||||
|
||||
if (state.modalType === 'purchaseOrder' && expectedDateInput.value) {
|
||||
expectedPicker = new AirDatepicker(expectedDateInput.value, {
|
||||
timepicker: false,
|
||||
autoClose: true,
|
||||
zIndex: 2005,
|
||||
dateFormat: 'yyyy-MM-dd',
|
||||
onSelect: ({ formattedDate }: any) => {
|
||||
state.form.expected_date = formattedDate || ''
|
||||
state.form.expected_date_native = toNativeValue(formattedDate || '')
|
||||
}
|
||||
})
|
||||
const initial = parseDateTimeLocal(state.form.expected_date)
|
||||
if (initial) expectedPicker.selectDate(initial, { silent: true })
|
||||
}
|
||||
}
|
||||
|
||||
const openDateTimePicker = (pickerKind: string) => {
|
||||
if (pickerKind === 'delivery' && deliveryPicker) {
|
||||
deliveryPicker.show()
|
||||
return
|
||||
}
|
||||
if (pickerKind === 'expected' && expectedPicker) {
|
||||
expectedPicker.show()
|
||||
return
|
||||
}
|
||||
|
||||
const nativeInput = pickerKind === 'delivery' ? deliveryDateNativeInput.value : expectedDateNativeInput.value
|
||||
if (!nativeInput) return
|
||||
if (typeof (nativeInput as any).showPicker === 'function') {
|
||||
(nativeInput as any).showPicker()
|
||||
return
|
||||
}
|
||||
nativeInput.focus()
|
||||
nativeInput.click()
|
||||
}
|
||||
|
||||
const getMovementTypeLabel = (movementType: string): string => {
|
||||
const movementLabelMap: Record<string, string> = {
|
||||
in: '其他入库',
|
||||
out: '其他出库',
|
||||
adjust: '库存调整',
|
||||
purchase_in: '采购入库',
|
||||
return_from_production: '生产退料入库',
|
||||
outsource_return: '外协回库',
|
||||
finish_in: '完工入库',
|
||||
issue_to_production: '生产领料出库',
|
||||
outsource_send: '外协发料出库',
|
||||
shipment_out: '销售出库',
|
||||
scrap_out: '报废出库'
|
||||
}
|
||||
return movementLabelMap[movementType] || movementType
|
||||
}
|
||||
|
||||
const getMovementBadgeClass = (movementType: string): string => {
|
||||
if (['purchase_in', 'return_from_production', 'outsource_return', 'finish_in', 'in'].includes(movementType)) {
|
||||
return 'badge-success'
|
||||
}
|
||||
if (['issue_to_production', 'outsource_send', 'shipment_out', 'scrap_out', 'out'].includes(movementType)) {
|
||||
return 'badge-error'
|
||||
}
|
||||
return 'badge-warning'
|
||||
}
|
||||
|
||||
const getPurchaseOrderStatusLabel = (status: string): string => {
|
||||
const statusMap: Record<string, string> = {
|
||||
draft: '已下单',
|
||||
pending: '已下单',
|
||||
received: '已收货',
|
||||
paid: '已付款'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
const isPurchaseOrderLocked = (status: string): boolean => {
|
||||
return ['received', 'paid'].includes(status)
|
||||
}
|
||||
|
||||
const getSalesOrderStatusLabel = (status: string): string => {
|
||||
const statusMap: Record<string, string> = {
|
||||
manufacturing: '制造中',
|
||||
delivered: '已交付',
|
||||
paid: '已收款'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
const checkBackendHealth = async () => {
|
||||
try {
|
||||
const resp = await fetch('/health', { method: 'GET' })
|
||||
if (!resp.ok) {
|
||||
state.backendDbReady = false
|
||||
state.backendDbMessage = '后端服务异常,暂无法加载业务数据'
|
||||
return
|
||||
}
|
||||
const health = await resp.json().catch(() => null)
|
||||
if (health && health.database_connected === false) {
|
||||
state.backendDbReady = false
|
||||
state.backendDbMessage = '数据库未连接,当前仅可浏览界面,业务数据暂不可用'
|
||||
return
|
||||
}
|
||||
state.backendDbReady = true
|
||||
state.backendDbMessage = ''
|
||||
} catch {
|
||||
state.backendDbReady = false
|
||||
state.backendDbMessage = '无法连接后端服务'
|
||||
}
|
||||
}
|
||||
|
||||
const loadDashboard = async () => {
|
||||
state.loading = true
|
||||
try { state.dashboard = await apiRequest('/api/dashboard') }
|
||||
catch (e) { handleApiError(e, '加载仪表盘') }
|
||||
finally { state.loading = false }
|
||||
}
|
||||
|
||||
const loadFinishedProducts = async () => {
|
||||
state.loading = true
|
||||
try { state.finishedProducts = await apiRequest('/api/products?item_type=finished&limit=100') }
|
||||
catch (e) { handleApiError(e, '加载成品') }
|
||||
finally { state.loading = false }
|
||||
}
|
||||
|
||||
const loadProducts = async () => { await loadFinishedProducts() }
|
||||
|
||||
const loadMaterials = async () => {
|
||||
state.loading = true
|
||||
try { state.materials = await apiRequest('/api/products?item_type=material&limit=100') }
|
||||
catch (e) { handleApiError(e, '加载物料') }
|
||||
finally { state.loading = false }
|
||||
}
|
||||
|
||||
const loadWarehouses = async () => {
|
||||
state.loading = true
|
||||
try { state.warehouses = await apiRequest('/api/warehouses') }
|
||||
catch (e) { handleApiError(e, '加载仓库') }
|
||||
finally { state.loading = false }
|
||||
}
|
||||
|
||||
const ensureStockBaseData = async () => {
|
||||
if (!state.materials.length) await loadMaterials()
|
||||
if (!state.warehouses.length) await loadWarehouses()
|
||||
if (!state.warehouses.length) {
|
||||
try {
|
||||
await apiRequest('/api/warehouses', { method: 'POST', body: JSON.stringify({ name: '默认仓库' }) })
|
||||
await loadWarehouses()
|
||||
addNotification('已自动创建默认仓库', 'success')
|
||||
} catch (e) { handleApiError(e, '自动创建默认仓库') }
|
||||
}
|
||||
}
|
||||
|
||||
const loadSuppliers = async () => {
|
||||
state.loading = true
|
||||
try { state.suppliers = await apiRequest('/api/suppliers') }
|
||||
catch (e) { handleApiError(e, '加载供应商') }
|
||||
finally { state.loading = false }
|
||||
}
|
||||
|
||||
const loadProductionOrders = async () => {
|
||||
state.loading = true
|
||||
try {
|
||||
const [orders, warehouses] = await Promise.all([
|
||||
apiRequest('/api/sales-orders?limit=100'),
|
||||
apiRequest('/api/warehouses')
|
||||
])
|
||||
state.productionOrders = orders?.items || []
|
||||
state.warehouses = warehouses || []
|
||||
if (!state.productionWarehouseId) {
|
||||
state.productionWarehouseId = state.warehouses.find((w: any) => w.is_default)?.id || state.warehouses[0]?.id || null
|
||||
}
|
||||
} catch (e) { handleApiError(e, '加载按单生产数据') }
|
||||
finally { state.loading = false }
|
||||
}
|
||||
|
||||
const loadPurchaseOrders = async () => {
|
||||
state.loading = true
|
||||
try {
|
||||
const [orders, warehouses] = await Promise.all([
|
||||
apiRequest('/api/purchase-orders?limit=100'),
|
||||
apiRequest('/api/warehouses')
|
||||
])
|
||||
state.purchaseOrders = orders?.items || []
|
||||
state.warehouses = warehouses || []
|
||||
if (!state.purchaseWarehouseId) {
|
||||
state.purchaseWarehouseId = state.warehouses.find((w: any) => w.is_default)?.id || state.warehouses[0]?.id || null
|
||||
}
|
||||
} catch (e) { handleApiError(e, '加载采购订单') }
|
||||
finally { state.loading = false }
|
||||
}
|
||||
|
||||
const loadCustomers = async () => {
|
||||
state.loading = true
|
||||
try { state.customers = await apiRequest('/api/customers') }
|
||||
catch (e) { handleApiError(e, '加载客户') }
|
||||
finally { state.loading = false }
|
||||
}
|
||||
|
||||
const loadInventory = async () => {
|
||||
state.loading = true
|
||||
try { state.inventory = (await apiRequest('/api/inventory'))?.items || [] }
|
||||
catch (e) { handleApiError(e, '加载库存') }
|
||||
finally { state.loading = false }
|
||||
}
|
||||
|
||||
const loadMovements = async () => {
|
||||
state.loading = true
|
||||
try { state.movements = (await apiRequest('/api/stock-movements'))?.items || [] }
|
||||
catch (e) { handleApiError(e, '加载变动记录') }
|
||||
finally { state.loading = false }
|
||||
}
|
||||
|
||||
const loadFinance = async () => {
|
||||
state.loading = true
|
||||
try {
|
||||
const selectedYear = Number(state.financePeriod.year) || new Date().getFullYear()
|
||||
const selectedQuarter = state.financePeriod.quarter ? Number(state.financePeriod.quarter) : null
|
||||
const periodQuery = selectedQuarter
|
||||
? `year=${selectedYear}&quarter=${selectedQuarter}`
|
||||
: `year=${selectedYear}`
|
||||
const [summary, transactions, receivables, payables, customerStatement, supplierStatement, customerProductStatement, supplierProductStatement] = await Promise.all([
|
||||
apiRequest(`/api/finance/summary?${periodQuery}`),
|
||||
apiRequest(`/api/finance/transactions?status=confirmed&limit=20&${periodQuery}`),
|
||||
apiRequest(`/api/finance/receivables?limit=20&${periodQuery}`),
|
||||
apiRequest(`/api/finance/payables?limit=20&${periodQuery}`),
|
||||
apiRequest(`/api/finance/partner-statement/customer?${periodQuery}`),
|
||||
apiRequest(`/api/finance/partner-statement/supplier?${periodQuery}`),
|
||||
apiRequest(`/api/finance/partner-product-statement/customer?${periodQuery}`),
|
||||
apiRequest(`/api/finance/partner-product-statement/supplier?${periodQuery}`)
|
||||
])
|
||||
state.financeSummary = summary
|
||||
state.financeTransactions = transactions
|
||||
state.receivables = receivables
|
||||
state.payables = payables
|
||||
state.customerFinanceStatement = customerStatement.items || []
|
||||
state.supplierFinanceStatement = supplierStatement.items || []
|
||||
state.customerProductStatement = customerProductStatement.items || []
|
||||
state.supplierProductStatement = supplierProductStatement.items || []
|
||||
} catch (e) { handleApiError(e, '加载财务数据') }
|
||||
finally { state.loading = false }
|
||||
}
|
||||
|
||||
const refreshFinanceByPeriod = () => {
|
||||
if (state.activeTab === 'finance') loadFinance()
|
||||
}
|
||||
|
||||
const switchTab = (tab: string) => {
|
||||
state.activeTab = tab
|
||||
if (!state.backendDbReady) return
|
||||
switch (tab) {
|
||||
case 'dashboard': loadDashboard(); break
|
||||
case 'sales_orders': loadProductionOrders(); break
|
||||
case 'products': loadFinishedProducts(); break
|
||||
case 'materials': loadMaterials(); break
|
||||
case 'purchases': loadPurchaseOrders(); break
|
||||
case 'inventory': loadInventory(); break
|
||||
case 'customers': loadCustomers(); break
|
||||
case 'suppliers': loadSuppliers(); break
|
||||
case 'finance': loadFinance(); break
|
||||
case 'movements': loadMovements(); break
|
||||
}
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
state.showModal = false
|
||||
state.modalType = ''
|
||||
state.editingItem = null
|
||||
state.productBomItems = []
|
||||
state.purchaseReceiveItems = []
|
||||
state.form = {}
|
||||
destroyPickers()
|
||||
}
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
const prefix = state.editingItem ? '编辑' : '新增'
|
||||
const typeMap: Record<string, string> = {
|
||||
product: state.form.item_type === 'finished' ? '成品' : '物料',
|
||||
inventoryItem: '物料库存',
|
||||
salesOrder: '销售订单',
|
||||
purchaseOrder: '采购订单',
|
||||
purchaseReceive: '采购到货入库',
|
||||
supplier: '供应商',
|
||||
customer: '客户'
|
||||
}
|
||||
return prefix + (typeMap[state.modalType] || '')
|
||||
})
|
||||
|
||||
const menuGroups = [
|
||||
{ key: 'overview', title: '概览', items: [{ key: 'dashboard', label: '仪表盘' }] },
|
||||
{ key: 'sales', title: '销售', items: [{ key: 'sales_orders', label: '销售订单管理' }] },
|
||||
{ key: 'purchase', title: '采购', items: [{ key: 'purchases', label: '采购订单管理' }] },
|
||||
{ key: 'product', title: '产品', items: [{ key: 'products', label: '成品管理' }, { key: 'materials', label: '物料管理' }] },
|
||||
{ key: 'partner', title: '往来单位', items: [{ key: 'customers', label: '客户管理' }, { key: 'suppliers', label: '供应商管理' }] },
|
||||
{ key: 'warehouse', title: '仓库', items: [{ key: 'inventory', label: '库存管理' }, { key: 'movements', label: '库存变动记录' }] },
|
||||
{ key: 'finance', title: '财务', items: [{ key: 'finance', label: '财务概览' }] }
|
||||
]
|
||||
|
||||
const openGroups = reactive<Record<string, boolean>>(
|
||||
Object.fromEntries(menuGroups.map(g => [g.key, true]))
|
||||
)
|
||||
|
||||
const toggleGroup = (groupKey: string) => { openGroups[groupKey] = !openGroups[groupKey] }
|
||||
|
||||
const activeMenu = computed(() => {
|
||||
for (const group of menuGroups) {
|
||||
const item = group.items.find(i => i.key === state.activeTab)
|
||||
if (item) return { group, item }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const handleMenuClick = (itemKey: string) => { switchTab(itemKey) }
|
||||
|
||||
const switchProductCategory = (category: string) => { state.productCategory = category }
|
||||
|
||||
return {
|
||||
state,
|
||||
deliveryDateInput,
|
||||
expectedDateInput,
|
||||
deliveryDateNativeInput,
|
||||
expectedDateNativeInput,
|
||||
menuGroups,
|
||||
openGroups,
|
||||
activeMenu,
|
||||
modalTitle,
|
||||
parseDateTimeLocal,
|
||||
toPickerValue,
|
||||
toApiDateTime,
|
||||
toNativeValue,
|
||||
fromNativeValue,
|
||||
destroyPickers,
|
||||
initPickers,
|
||||
openDateTimePicker,
|
||||
getMovementTypeLabel,
|
||||
getMovementBadgeClass,
|
||||
getPurchaseOrderStatusLabel,
|
||||
isPurchaseOrderLocked,
|
||||
getSalesOrderStatusLabel,
|
||||
checkBackendHealth,
|
||||
loadDashboard,
|
||||
loadFinishedProducts,
|
||||
loadProducts,
|
||||
loadMaterials,
|
||||
loadWarehouses,
|
||||
ensureStockBaseData,
|
||||
loadSuppliers,
|
||||
loadProductionOrders,
|
||||
loadPurchaseOrders,
|
||||
loadCustomers,
|
||||
loadInventory,
|
||||
loadMovements,
|
||||
loadFinance,
|
||||
refreshFinanceByPeriod,
|
||||
switchTab,
|
||||
closeModal,
|
||||
toggleGroup,
|
||||
handleMenuClick,
|
||||
switchProductCategory,
|
||||
formatCurrency,
|
||||
formatNumber,
|
||||
formatDateTime,
|
||||
formatDate
|
||||
}
|
||||
}
|
||||
@@ -152,10 +152,13 @@ async def _apply_order_items(
|
||||
if product.item_type != "material":
|
||||
raise HTTPException(status_code=400, detail=f"采购单仅允许物料: {product.name}")
|
||||
|
||||
# 使用物料的成本价格作为单价,忽略前端提交的单价
|
||||
unit_price = product.cost_price or 0
|
||||
# 优先使用前端传入的单价,否则使用物料成本价
|
||||
if item_data.unit_price and item_data.unit_price > 0:
|
||||
unit_price = float(item_data.unit_price)
|
||||
else:
|
||||
unit_price = product.cost_price or 0
|
||||
if unit_price <= 0:
|
||||
raise HTTPException(status_code=400, detail=f"物料 {product.name} 未设置成本价格,请在物料管理界面设置")
|
||||
raise HTTPException(status_code=400, detail=f"物料 {product.name} 未设置价格,请在物料管理界面设置成本价或在采购明细中填写单价")
|
||||
|
||||
try:
|
||||
item = PurchaseOrderItem(
|
||||
|
||||
@@ -7,7 +7,7 @@ from decimal import Decimal
|
||||
class PurchaseOrderItemCreate(BaseModel):
|
||||
product_id: int
|
||||
quantity: int = Field(..., gt=0, description="采购数量")
|
||||
unit_price: Optional[Decimal] = Field(None, description="单价(可选,后端自动使用物料成本价格)")
|
||||
unit_price: Optional[Decimal] = Field(None, description="单价(可选,不填则使用物料成本价格)")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{E as e,_ as t,t as n,y as r}from"./index-SE6ZBOjs.js";var i={};function a(n,i){return e(),r(`div`,null,[...i[0]||=[t(`div`,{class:`page-header`},[t(`h1`,{class:`page-title`},`设计体系`),t(`p`,{class:`page-subtitle`},`迁移中...`)],-1)]])}var o=n(i,[[`render`,a]]);export{o as default};
|
||||
import{E as e,_ as t,t as n,y as r}from"./index-C_7kTAur.js";var i={};function a(n,i){return e(),r(`div`,null,[...i[0]||=[t(`div`,{class:`page-header`},[t(`h1`,{class:`page-title`},`设计体系`),t(`p`,{class:`page-subtitle`},`迁移中...`)],-1)]])}var o=n(i,[[`render`,a]]);export{o as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{E as e,O as t,P as n,S as r,_ as i,a,f as o,k as s,l as c,m as l,n as u,o as d,t as f,v as p,y as m}from"./index-SE6ZBOjs.js";var h={class:`login-container`},g={class:`login-card`},_={class:`form-group`},v={class:`form-group`},y={key:0,class:`form-error`},b=[`disabled`],x=f(r({__name:`LoginView`,setup(r){let f=c(),x=s({username:``,password:``,error:``,loading:!1}),S=async()=>{x.error=``,x.loading=!0;try{let e=await d(`/api/auth/login/json`,{method:`POST`,body:JSON.stringify({username:x.username,password:x.password})});a(e.access_token,e.user),u(`登录成功`,`success`),f.push(`/`)}catch(e){x.error=e.message||`登录失败`}finally{x.loading=!1}};return(r,a)=>(e(),m(`div`,h,[i(`div`,g,[a[4]||=i(`div`,{class:`login-header`},[i(`div`,{class:`login-logo`},`G`),i(`h1`,{class:`login-title`},`Gemold`),i(`p`,{class:`login-subtitle`},`模具制造管理系统`)],-1),i(`form`,{onSubmit:l(S,[`prevent`])},[i(`div`,_,[a[2]||=i(`label`,{class:`form-label`},`用户名`,-1),t(i(`input`,{"onUpdate:modelValue":a[0]||=e=>x.username=e,type:`text`,class:`form-input`,placeholder:`请输入用户名`,required:``},null,512),[[o,x.username]])]),i(`div`,v,[a[3]||=i(`label`,{class:`form-label`},`密码`,-1),t(i(`input`,{"onUpdate:modelValue":a[1]||=e=>x.password=e,type:`password`,class:`form-input`,placeholder:`请输入密码`,required:``},null,512),[[o,x.password]])]),x.error?(e(),m(`div`,y,n(x.error),1)):p(``,!0),i(`button`,{type:`submit`,class:`btn btn-primary w-full mt-4`,disabled:x.loading},n(x.loading?`登录中...`:`登录`),9,b)],32)])]))}}),[[`__scopeId`,`data-v-01da5b35`]]);export{x as default};
|
||||
import{E as e,O as t,P as n,S as r,_ as i,a,f as o,k as s,l as c,m as l,n as u,o as d,t as f,v as p,y as m}from"./index-C_7kTAur.js";var h={class:`login-container`},g={class:`login-card`},_={class:`form-group`},v={class:`form-group`},y={key:0,class:`form-error`},b=[`disabled`],x=f(r({__name:`LoginView`,setup(r){let f=c(),x=s({username:``,password:``,error:``,loading:!1}),S=async()=>{x.error=``,x.loading=!0;try{let e=await d(`/api/auth/login/json`,{method:`POST`,body:JSON.stringify({username:x.username,password:x.password})});a(e.access_token,e.user),u(`登录成功`,`success`),f.push(`/`)}catch(e){x.error=e.message||`登录失败`}finally{x.loading=!1}};return(r,a)=>(e(),m(`div`,h,[i(`div`,g,[a[4]||=i(`div`,{class:`login-header`},[i(`div`,{class:`login-logo`},`G`),i(`h1`,{class:`login-title`},`Gemold`),i(`p`,{class:`login-subtitle`},`模具制造管理系统`)],-1),i(`form`,{onSubmit:l(S,[`prevent`])},[i(`div`,_,[a[2]||=i(`label`,{class:`form-label`},`用户名`,-1),t(i(`input`,{"onUpdate:modelValue":a[0]||=e=>x.username=e,type:`text`,class:`form-input`,placeholder:`请输入用户名`,required:``},null,512),[[o,x.username]])]),i(`div`,v,[a[3]||=i(`label`,{class:`form-label`},`密码`,-1),t(i(`input`,{"onUpdate:modelValue":a[1]||=e=>x.password=e,type:`password`,class:`form-input`,placeholder:`请输入密码`,required:``},null,512),[[o,x.password]])]),x.error?(e(),m(`div`,y,n(x.error),1)):p(``,!0),i(`button`,{type:`submit`,class:`btn btn-primary w-full mt-4`,disabled:x.loading},n(x.loading?`登录中...`:`登录`),9,b)],32)])]))}}),[[`__scopeId`,`data-v-01da5b35`]]);export{x as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{E as e,_ as t,t as n,y as r}from"./index-SE6ZBOjs.js";var i={};function a(n,i){return e(),r(`div`,null,[...i[0]||=[t(`div`,{class:`page-header`},[t(`h1`,{class:`page-title`},`灰度发布与回滚`),t(`p`,{class:`page-subtitle`},`迁移中...`)],-1)]])}var o=n(i,[[`render`,a]]);export{o as default};
|
||||
import{E as e,_ as t,t as n,y as r}from"./index-C_7kTAur.js";var i={};function a(n,i){return e(),r(`div`,null,[...i[0]||=[t(`div`,{class:`page-header`},[t(`h1`,{class:`page-title`},`灰度发布与回滚`),t(`p`,{class:`page-subtitle`},`迁移中...`)],-1)]])}var o=n(i,[[`render`,a]]);export{o as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -61,8 +61,8 @@
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/static/assets/index-SE6ZBOjs.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/static/assets/index-BBu9hxSF.css">
|
||||
<script type="module" crossorigin src="/static/assets/index-C_7kTAur.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/static/assets/index-O0fr6NFx.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
|
||||
Reference in New Issue
Block a user