This commit is contained in:
2026-06-02 09:55:06 +08:00
parent ecc6b8ab8a
commit 8cca6f9d32
28 changed files with 1951 additions and 2658 deletions
+194
View File
@@ -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",
+2
View File
@@ -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"
+4
View File
@@ -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
}
}
+6 -3
View File
@@ -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 @@
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};
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
View File
@@ -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">