feat:新增产品管理模块

master
黄伟杰 2 weeks ago
parent 493fd0125f
commit 3f496dea47

@ -0,0 +1,48 @@
import request from '@/utils/request'
export function getProductCategoryList(params) {
return request({
url: '/admin-api/erp/product-category/list',
method: 'get',
params
})
}
export function getProductCategorySimpleList() {
return request({
url: '/admin-api/erp/product-category/simple-list',
method: 'get'
})
}
export function getProductCategory(id) {
return request({
url: '/admin-api/erp/product-category/get',
method: 'get',
params: { id }
})
}
export function createProductCategory(data) {
return request({
url: '/admin-api/erp/product-category/create',
method: 'post',
data
})
}
export function updateProductCategory(data) {
return request({
url: '/admin-api/erp/product-category/update',
method: 'put',
data
})
}
export function deleteProductCategory(id) {
return request({
url: '/admin-api/erp/product-category/delete',
method: 'delete',
params: { id }
})
}

@ -0,0 +1,70 @@
import request from '@/utils/request'
export function getProductPage(params) {
return request({
url: '/admin-api/erp/product/page',
method: 'get',
params
})
}
export function getProductSimpleList(params) {
return request({
url: '/admin-api/erp/product/simple-list-all',
method: 'get',
params
})
}
export function getMesProductSimpleList() {
return request({
url: '/admin-api/erp/product/simple-list-product',
method: 'get'
})
}
export function getItemSimpleList() {
return request({
url: '/admin-api/erp/product/simple-list-item',
method: 'get'
})
}
export function getProduct(id) {
return request({
url: '/admin-api/erp/product/get',
method: 'get',
params: { id }
})
}
export function createProduct(data) {
return request({
url: '/admin-api/erp/product/create',
method: 'post',
data
})
}
export function updateProduct(data) {
return request({
url: '/admin-api/erp/product/update',
method: 'put',
data
})
}
export function deleteProduct(id) {
return request({
url: '/admin-api/erp/product/delete',
method: 'delete',
params: { id }
})
}
export function getProductUnitSimpleList() {
return request({
url: '/admin-api/erp/product-unit/simple-list',
method: 'get'
})
}

@ -0,0 +1,49 @@
import request from '@/utils/request'
export function getBomPage(params) {
return request({
url: '/admin-api/mes/bom/page',
method: 'get',
params
})
}
export function getBom(id) {
return request({
url: '/admin-api/mes/bom/get',
method: 'get',
params: { id }
})
}
export function createBom(data) {
return request({
url: '/admin-api/mes/bom/create',
method: 'post',
data
})
}
export function updateBom(data) {
return request({
url: '/admin-api/mes/bom/update',
method: 'put',
data
})
}
export function deleteBom(id) {
return request({
url: '/admin-api/mes/bom/delete',
method: 'delete',
params: { id }
})
}
export function getBomDetailListByBomId(bomId) {
return request({
url: '/admin-api/mes/bom/bom-detail/list-by-bom-id',
method: 'get',
params: { bomId }
})
}

@ -542,5 +542,117 @@ export default {
},
avatar: {
chooseAvatar: 'Choose Avatar'
},
materialCategory: {
moduleName: 'Product Category',
subTitle: 'Product material category management',
detailTitle: 'Category Detail',
basicInfo: 'Basic Info',
code: 'Category Code',
name: 'Category Name',
parentName: 'Parent Category',
rootCategory: 'None (Top Level)',
sort: 'Sort',
status: 'Status',
statusEnable: 'Enable',
statusDisable: 'Disable',
createTime: 'Created At',
searchPlaceholder: 'Enter category code/name',
createTitle: 'Create Category',
editTitle: 'Edit Category',
empty: 'No category data',
loadEditFailed: 'Failed to load edit data',
noId: 'Missing category ID',
loadFailed: 'Failed to load category detail',
placeholderCode: 'Enter category code',
placeholderName: 'Enter category name',
placeholderParent: 'Select parent category',
placeholderSort: 'Enter sort order',
placeholderStatus: 'Select status',
validatorCodeRequired: 'Category code is required',
validatorNameRequired: 'Category name is required',
validatorSortRequired: 'Sort order is required',
validatorStatusRequired: 'Status is required',
confirmDeleteContent: 'Confirm delete category [{name}]?'
},
materialInfo: {
moduleName: 'Product Material',
subTitle: 'Product material information management',
detailTitle: 'Material Detail',
basicInfo: 'Basic Info',
barCode: 'Barcode',
name: 'Material Name',
category: 'Category',
unit: 'Unit',
standard: 'Specification',
expiryDay: 'Shelf Life (Days)',
status: 'Status',
statusEnable: 'Enable',
statusDisable: 'Disable',
remark: 'Remark',
createTime: 'Created At',
autoCode: 'Auto Generate',
searchPlaceholder: 'Enter material name/barcode',
createTitle: 'Create Material',
editTitle: 'Edit Material',
empty: 'No material data',
loadEditFailed: 'Failed to load edit data',
noId: 'Missing material ID',
loadFailed: 'Failed to load material detail',
placeholderBarCode: 'Enter barcode',
placeholderName: 'Enter material name',
placeholderCategory: 'Select category',
placeholderUnit: 'Select unit',
placeholderStandard: 'Enter specification',
placeholderExpiryDay: 'Enter shelf life days',
placeholderStatus: 'Select status',
placeholderRemark: 'Enter remark',
validatorBarCodeRequired: 'Barcode is required',
validatorNameRequired: 'Material name is required',
validatorCategoryRequired: 'Category is required',
validatorUnitRequired: 'Unit is required',
confirmDeleteContent: 'Confirm delete material [{name}]?'
},
productBom: {
moduleName: 'Product BOM',
subTitle: 'Product BOM management',
detailTitle: 'BOM Detail',
basicInfo: 'Basic Info',
code: 'BOM Code',
version: 'Version',
product: 'Product',
unit: 'Unit',
yieldRate: 'Yield Rate',
isEnable: 'Enabled',
enableYes: 'Yes',
enableNo: 'No',
remark: 'Remark',
createTime: 'Created At',
searchPlaceholder: 'Enter BOM code',
createTitle: 'Create BOM',
editTitle: 'Edit BOM',
empty: 'No BOM data',
loadEditFailed: 'Failed to load edit data',
noId: 'Missing BOM ID',
loadFailed: 'Failed to load BOM detail',
placeholderCode: 'Enter BOM code',
placeholderVersion: 'Enter version',
placeholderProduct: 'Select product',
placeholderUnit: 'Select unit',
placeholderYieldRate: 'Enter yield rate',
placeholderEnable: 'Select enabled status',
placeholderRemark: 'Enter remark',
validatorCodeRequired: 'BOM code is required',
validatorVersionRequired: 'Version is required',
validatorProductRequired: 'Product is required',
validatorUnitRequired: 'Unit is required',
validatorEnableRequired: 'Enabled status is required',
confirmDeleteContent: 'Confirm delete BOM [{code}]?',
detailTab: 'BOM Details',
detailUsageNumber: 'Usage Qty',
detailUnit: 'Unit',
detailLossRate: 'Loss Rate',
detailRemark: 'Remark',
detailEmpty: 'No BOM detail data'
}
}

@ -542,5 +542,117 @@ export default {
},
avatar: {
chooseAvatar: '选择头像'
},
materialCategory: {
moduleName: '产品物料分类',
subTitle: '产品物料分类管理',
detailTitle: '分类详情',
basicInfo: '基础信息',
code: '分类编码',
name: '分类名称',
parentName: '上级分类',
rootCategory: '无(顶级分类)',
sort: '排序',
status: '状态',
statusEnable: '启用',
statusDisable: '禁用',
createTime: '创建时间',
searchPlaceholder: '请输入分类编码/名称',
createTitle: '新增分类',
editTitle: '编辑分类',
empty: '暂无分类数据',
loadEditFailed: '加载编辑数据失败',
noId: '缺少分类ID',
loadFailed: '加载分类详情失败',
placeholderCode: '请输入分类编码',
placeholderName: '请输入分类名称',
placeholderParent: '请选择上级分类',
placeholderSort: '请输入排序',
placeholderStatus: '请选择状态',
validatorCodeRequired: '分类编码不能为空',
validatorNameRequired: '分类名称不能为空',
validatorSortRequired: '排序不能为空',
validatorStatusRequired: '状态不能为空',
confirmDeleteContent: '确认删除分类【{name}】吗?'
},
materialInfo: {
moduleName: '产品物料信息',
subTitle: '产品物料信息管理',
detailTitle: '物料详情',
basicInfo: '基础信息',
barCode: '物料条码',
name: '物料名称',
category: '物料分类',
unit: '单位',
standard: '规格',
expiryDay: '保质期天数',
status: '状态',
statusEnable: '启用',
statusDisable: '禁用',
remark: '备注',
createTime: '创建时间',
autoCode: '自动生成',
searchPlaceholder: '请输入物料名称/条码',
createTitle: '新增物料',
editTitle: '编辑物料',
empty: '暂无物料数据',
loadEditFailed: '加载编辑数据失败',
noId: '缺少物料ID',
loadFailed: '加载物料详情失败',
placeholderBarCode: '请输入物料条码',
placeholderName: '请输入物料名称',
placeholderCategory: '请选择物料分类',
placeholderUnit: '请选择单位',
placeholderStandard: '请输入规格',
placeholderExpiryDay: '请输入保质期天数',
placeholderStatus: '请选择状态',
placeholderRemark: '请输入备注',
validatorBarCodeRequired: '物料条码不能为空',
validatorNameRequired: '物料名称不能为空',
validatorCategoryRequired: '物料分类不能为空',
validatorUnitRequired: '单位不能为空',
confirmDeleteContent: '确认删除物料【{name}】吗?'
},
productBom: {
moduleName: '产品BOM',
subTitle: '产品BOM管理',
detailTitle: 'BOM详情',
basicInfo: '基础信息',
code: 'BOM编码',
version: '版本',
product: '产品',
unit: '单位',
yieldRate: '良品率',
isEnable: '是否启用',
enableYes: '是',
enableNo: '否',
remark: '备注',
createTime: '创建时间',
searchPlaceholder: '请输入BOM编码',
createTitle: '新增BOM',
editTitle: '编辑BOM',
empty: '暂无BOM数据',
loadEditFailed: '加载编辑数据失败',
noId: '缺少BOM ID',
loadFailed: '加载BOM详情失败',
placeholderCode: '请输入BOM编码',
placeholderVersion: '请输入版本',
placeholderProduct: '请选择产品',
placeholderUnit: '请选择单位',
placeholderYieldRate: '请输入良品率',
placeholderEnable: '请选择是否启用',
placeholderRemark: '请输入备注',
validatorCodeRequired: 'BOM编码不能为空',
validatorVersionRequired: '版本不能为空',
validatorProductRequired: '产品不能为空',
validatorUnitRequired: '单位不能为空',
validatorEnableRequired: '是否启用不能为空',
confirmDeleteContent: '确认删除BOM【{code}】吗?',
detailTab: 'BOM明细',
detailUsageNumber: '用量',
detailUnit: '单位',
detailLossRate: '损耗率',
detailRemark: '备注',
detailEmpty: '暂无BOM明细数据'
}
}

@ -386,6 +386,13 @@
"navigationStyle": "custom"
}
},
{
"path": "materialCategory/detail",
"style": {
"navigationBarTitleText": "分类详情",
"navigationStyle": "custom"
}
},
{
"path": "materialInfo/index",
"style": {
@ -393,6 +400,13 @@
"navigationStyle": "custom"
}
},
{
"path": "materialInfo/detail",
"style": {
"navigationBarTitleText": "物料详情",
"navigationStyle": "custom"
}
},
{
"path": "productBom/index",
"style": {
@ -400,6 +414,13 @@
"navigationStyle": "custom"
}
},
{
"path": "productBom/detail",
"style": {
"navigationBarTitleText": "BOM详情",
"navigationStyle": "custom"
}
},
{
"path": "equipmentCategory/index",
"style": {

@ -0,0 +1,155 @@
<template>
<view class="page-container">
<view class="fixed-header">
<AppTitleHeader :title="t('materialCategory.detailTitle')" />
</view>
<scroll-view scroll-y class="detail-scroll">
<view class="content-section">
<!-- 基础信息 -->
<view class="info-card">
<view class="card-title">{{ t('materialCategory.basicInfo') }}</view>
<view class="info-list">
<view class="info-row">
<text class="info-label">{{ t('materialCategory.code') }}</text>
<text class="info-value">{{ fieldValue('code') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('materialCategory.name') }}</text>
<text class="info-value">{{ fieldValue('name') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('materialCategory.parentName') }}</text>
<text class="info-value">{{ parentNameText }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('materialCategory.sort') }}</text>
<text class="info-value">{{ fieldValue('sort') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('materialCategory.status') }}</text>
<text :class="['info-value', String(detailData?.status) === '0' ? 'text-success' : 'text-danger']">{{ statusText }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('materialCategory.createTime') }}</text>
<text class="info-value">{{ formatDateTime(detailData?.createTime) }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useI18n } from 'vue-i18n'
import AppTitleHeader from '@/components/common/AppTitleHeader.vue'
import { getProductCategory, getProductCategoryList } from '@/api/erp/productCategory'
import { DICT_TYPE, getDictLabel, initAllDict } from '@/utils/dict'
const { t } = useI18n()
const detailId = ref(undefined)
const detailData = ref(null)
const allCategories = ref([])
const parentNameText = computed(() => {
if (!detailData.value?.parentId || detailData.value.parentId === 0) return t('materialCategory.rootCategory')
const parent = allCategories.value.find(item => item.id === detailData.value.parentId)
return parent ? (parent.name || '-') : '-'
})
const statusText = computed(() => {
return getDictLabel(DICT_TYPE.COMMON_STATUS, detailData.value?.status, textValue(detailData.value?.status))
})
onLoad(async (query) => {
const id = query?.id ? decodeURIComponent(String(query.id)) : ''
detailId.value = id || undefined
await initAllDict()
await fetchDetail()
})
async function fetchDetail() {
if (!detailId.value) {
uni.showToast({ title: t('materialCategory.noId'), icon: 'none' })
return
}
try {
const res = await getProductCategory(detailId.value)
detailData.value = normalizeDetail(res)
const listRes = await getProductCategoryList()
allCategories.value = normalizeListData(listRes)
} catch (e) {
uni.showToast({ title: t('materialCategory.loadFailed'), icon: 'none' })
}
}
function normalizeDetail(res) {
const root = res && res.data !== undefined ? res.data : res
if (root?.data && typeof root.data === 'object') return root.data
if (root && typeof root === 'object') return root
return {}
}
function normalizeListData(res) {
const root = res && res.data !== undefined ? res.data : res
if (Array.isArray(root)) return root
if (root?.data && Array.isArray(root.data)) return root.data
return []
}
function fieldValue(field) {
return textValue(detailData.value ? detailData.value[field] : undefined)
}
function textValue(value) {
if (value === 0) return '0'
if (value === false) return t('functionCommon.no')
if (value === true) return t('functionCommon.yes')
if (value === null || value === undefined) return '-'
const text = String(value).trim()
return text || '-'
}
function formatDateTime(value) {
if (!value) return '-'
if (Array.isArray(value) && value.length >= 3) {
const [y, m, d, hh = 0, mm = 0, ss = 0] = value
const pad = (n) => String(n).padStart(2, '0')
return `${y}-${pad(m)}-${pad(d)} ${pad(hh)}:${pad(mm)}:${pad(ss)}`
}
const text = String(value).trim()
if (!text) return '-'
const numeric = Number(text)
if (Number.isFinite(numeric)) {
const timestamp = text.length === 10 ? numeric * 1000 : numeric
const date = new Date(timestamp)
if (!Number.isNaN(date.getTime())) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`
}
}
const date = new Date(text)
if (!Number.isNaN(date.getTime())) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`
}
return text
}
</script>
<style lang="scss" scoped>
.page-container { min-height: 100vh; background-color: #f0f2f5; }
.fixed-header { position: sticky; top: 0; z-index: 10; }
.detail-scroll { height: calc(100vh - 120rpx); }
.content-section { padding: 0 24rpx 24rpx; }
.info-card { margin-top: 20rpx; background: #ffffff; border-radius: 20rpx; padding: 28rpx; box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05); }
.card-title { font-size: 32rpx; color: #1a3a5c; font-weight: 700; margin-bottom: 18rpx; }
.info-list { background: #ffffff; }
.info-row { display: flex; justify-content: space-between; align-items: flex-start; padding: 18rpx 0; border-bottom: 1rpx solid #edf0f3; }
.info-row:last-child { border-bottom: none; }
.info-label { font-size: 27rpx; color: #8a9099; width: 220rpx; }
.info-value { flex: 1; text-align: right; font-size: 28rpx; color: #30363d; line-height: 1.45; }
.text-success { color: #52c41a; }
.text-danger { color: #ff4d4f; }
</style>

@ -1,125 +1,111 @@
<template>
<view class="page-container">
<view class="header-section">
<view class="back-btn" @click="goBack">
<text class="back-icon"></text>
<text class="back-text">返回</text>
</view>
<text class="header-title">产品物料分类</text>
</view>
<view class="search-section">
<view class="search-wrapper">
<view class="search-icon">
<text class="iconfont icon-search"></text>
</view>
<input
v-model="searchKeyword"
class="search-input"
type="text"
placeholder="请输入分类编码或名称"
placeholder-class="input-placeholder"
@input="handleSearch"
/>
<view v-if="searchKeyword" class="clear-btn" @click="clearSearch">
<text class="clear-icon">×</text>
<AppTitleHeader :title="t('materialCategory.moduleName')" :subTitle="t('materialCategory.subTitle')" :showSubTitle="true" />
<!-- 搜索区域 -->
<view class="search-card">
<view class="search-row">
<view class="search-input-wrap">
<text class="iconfont icon-search search-icon"></text>
<input v-model="searchKeyword" class="search-input" :placeholder="t('materialCategory.searchPlaceholder')" @confirm="handleSearch" />
</view>
<view class="search-btn" @click="handleSearch">{{ t('functionCommon.search') }}</view>
</view>
</view>
<scroll-view scroll-y class="content-scroll">
<view class="category-list">
<view
v-for="(item, index) in categoryList"
:key="index"
class="category-card"
>
<!-- 列表区域 -->
<scroll-view scroll-y class="list-scroll" :scroll-top="scrollTop" @scroll="onScroll" @scrolltolower="loadMore" :lower-threshold="80">
<view class="list-wrap">
<view v-for="item in filteredList" :key="item.id" class="type-card" @click="openDetail(item)">
<view class="card-header">
<view class="header-left">
<text class="category-name">{{ item.name }}</text>
<text class="category-code">编码: {{ item.code }}</text>
</view>
<view class="card-actions">
<view class="action-btn edit-btn" @click.stop="handleEdit(item)">
<text class="action-icon"></text>
</view>
<view class="action-btn delete-btn" @click.stop="handleDelete(item)">
<text class="action-icon">🗑</text>
</view>
<text class="type-name">{{ textValue(item.name) }}</text>
<text class="type-code">{{ t('materialCategory.code') }}: {{ textValue(item.code) }}</text>
</view>
</view>
<view class="card-body">
<view class="card-row">
<text class="card-label">状态</text>
<text class="card-value" :class="item.status === '启用' ? 'status-active' : 'status-inactive'">
{{ item.status }}
</text>
<view class="row">
<text class="label">{{ t('materialCategory.parentName') }}</text>
<text class="value">{{ getParentName(item.parentId) }}</text>
</view>
<view class="row">
<text class="label">{{ t('materialCategory.sort') }}</text>
<text class="value">{{ textValue(item.sort) }}</text>
</view>
<view class="row">
<text class="label">{{ t('materialCategory.status') }}</text>
<text :class="['value', String(item.status) === '0' ? 'text-success' : 'text-danger']">{{ statusLabel(item.status) }}</text>
</view>
</view>
<view class="card-actions">
<view class="action-btn edit-btn" @click.stop="openEdit(item)">
<uni-icons type="compose" size="18" color="#ffffff"></uni-icons>
</view>
<view class="action-btn delete-btn" @click.stop="confirmDelete(item)">
<uni-icons type="trash" size="18" color="#ffffff"></uni-icons>
</view>
</view>
</view>
<view v-if="loading" class="hint">{{ t('functionCommon.loading') }}</view>
<view v-else-if="!filteredList.length" class="hint">{{ t('materialCategory.empty') }}</view>
</view>
</scroll-view>
<view class="add-btn" @click="showAddForm">
<!-- 返回顶部 -->
<view v-if="showGoTop" class="go-top-btn" @click="goTop">
<uni-icons type="arrow-up" size="20" color="#1a3a5c"></uni-icons>
</view>
<!-- 新增按钮 -->
<view class="add-btn" @click="openCreate">
<text class="add-icon">+</text>
</view>
<uni-popup ref="addPopup" type="center" background-color="#fff">
<!-- 新增/编辑弹框 -->
<uni-popup ref="formPopupRef" type="center" background-color="#fff">
<view class="popup-content">
<view class="popup-header">
<text class="popup-title">新增分类</text>
<view class="popup-close" @click="hideAddForm">
<text class="popup-title">{{ formMode === 'create' ? t('materialCategory.createTitle') : t('materialCategory.editTitle') }}</text>
<view class="popup-close" @click="closeForm">
<text class="close-icon">×</text>
</view>
</view>
<view class="form-content">
<view class="form-item">
<text class="form-label">分类编码 <text class="required">*</text></text>
<input
v-model="formData.code"
class="form-input"
type="text"
placeholder="请输入分类编码"
/>
</view>
<view class="form-item">
<text class="form-label">分类名称 <text class="required">*</text></text>
<input
v-model="formData.name"
class="form-input"
type="text"
placeholder="请输入分类名称"
/>
</view>
<view class="form-item">
<text class="form-label">状态 <text class="required">*</text></text>
<view class="status-selector">
<view
class="status-option"
:class="{ active: formData.status === '启用' }"
@click="selectStatus('启用')"
>
<view class="status-dot" :class="{ active: formData.status === '启用' }"></view>
<text>启用</text>
</view>
<view
class="status-option"
:class="{ active: formData.status === '禁用' }"
@click="selectStatus('禁用')"
>
<view class="status-dot" :class="{ active: formData.status === '禁用' }"></view>
<text>禁用</text>
</view>
<scroll-view scroll-y class="form-scroll">
<view class="form-content">
<view class="form-item">
<text class="form-label">{{ t('materialCategory.parentName') }} <text class="required-star">*</text></text>
<picker mode="selector" :range="parentCategoryOptions" range-key="label" @change="onParentChange">
<view :class="['form-picker', formData.parentId === undefined ? 'placeholder' : '']">
{{ formData.parentId !== undefined ? getParentName(formData.parentId) : t('materialCategory.placeholderParent') }}
</view>
</picker>
</view>
<view class="form-item">
<text class="form-label">{{ t('materialCategory.code') }} <text class="required-star">*</text></text>
<input v-model="formData.code" class="form-input" :placeholder="t('materialCategory.placeholderCode')" :disabled="formMode === 'update'" />
</view>
<view class="form-item">
<text class="form-label">{{ t('materialCategory.name') }} <text class="required-star">*</text></text>
<input v-model="formData.name" class="form-input" :placeholder="t('materialCategory.placeholderName')" />
</view>
<view class="form-item">
<text class="form-label">{{ t('materialCategory.sort') }} <text class="required-star">*</text></text>
<input v-model="formData.sort" class="form-input" type="number" :placeholder="t('materialCategory.placeholderSort')" />
</view>
<view class="form-item">
<text class="form-label">{{ t('materialCategory.status') }} <text class="required-star">*</text></text>
<picker mode="selector" :range="statusOptions" range-key="label" @change="onStatusChange">
<view :class="['form-picker', formData.status === undefined ? 'placeholder' : '']">
{{ formData.status !== undefined ? statusLabel(formData.status) : t('materialCategory.placeholderStatus') }}
</view>
</picker>
</view>
</view>
</view>
</scroll-view>
<view class="form-footer">
<view class="footer-btn cancel-btn" @click="hideAddForm">
<text class="btn-text">取消</text>
</view>
<view class="footer-btn confirm-btn" @click="handleSave">
<text class="btn-text">保存</text>
</view>
<view class="footer-btn cancel-btn" @click="closeForm"><text class="btn-text">{{ t('functionCommon.cancel') }}</text></view>
<view class="footer-btn confirm-btn" @click="submitForm"><text class="btn-text">{{ t('functionCommon.save') }}</text></view>
</view>
</view>
</uni-popup>
@ -127,482 +113,308 @@
</template>
<script setup>
import { ref, reactive } from 'vue';
const addPopup = ref(null);
const searchKeyword = ref('');
import { ref, reactive, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useI18n } from 'vue-i18n'
import AppTitleHeader from '@/components/common/AppTitleHeader.vue'
import { getProductCategoryList, getProductCategorySimpleList, createProductCategory, updateProductCategory, deleteProductCategory } from '@/api/erp/productCategory'
import { DICT_TYPE, getDictLabel, initAllDict } from '@/utils/dict'
const { t } = useI18n()
const formPopupRef = ref(null)
const searchKeyword = ref('')
const list = ref([])
const allCategories = ref([])
const loading = ref(false)
const scrollTop = ref(0)
const showGoTop = ref(false)
const formMode = ref('create')
const formData = reactive({
id: undefined,
parentId: undefined,
code: '',
name: '',
status: '启用'
});
const categoryList = reactive([
{ code: 'CAT001', name: '原材料', status: '启用' },
{ code: 'CAT002', name: '半成品', status: '启用' },
{ code: 'CAT003', name: '成品', status: '启用' },
{ code: 'CAT004', name: '辅料', status: '启用' },
{ code: 'CAT005', name: '包材', status: '启用' },
{ code: 'CAT006', name: '废料', status: '禁用' }
]);
function goBack() {
uni.navigateBack();
}
function handleSearch() {
console.log('搜索:', searchKeyword.value);
}
function clearSearch() {
searchKeyword.value = '';
}
function handleEdit(item) {
uni.showToast({
title: `编辑${item.name}`,
icon: 'none'
});
sort: 0,
status: 0
})
const statusOptions = computed(() => [
{ label: t('materialCategory.statusEnable'), value: 0 },
{ label: t('materialCategory.statusDisable'), value: 1 }
])
const parentCategoryOptions = computed(() => {
const root = [{ label: t('materialCategory.rootCategory'), value: 0 }]
allCategories.value.forEach(item => {
root.push({ label: item.name || '', value: item.id })
})
return root
})
const filteredList = computed(() => {
if (!searchKeyword.value.trim()) return list.value
const keyword = searchKeyword.value.trim().toLowerCase()
return list.value.filter(item =>
(item.name && item.name.toLowerCase().includes(keyword)) ||
(item.code && item.code.toLowerCase().includes(keyword))
)
})
onLoad(async () => {
await initAllDict()
await fetchList()
})
async function fetchList() {
loading.value = true
try {
const res = await getProductCategoryList()
const data = normalizeData(res)
allCategories.value = Array.isArray(data) ? data : []
list.value = flattenTree(buildTree(allCategories.value))
} catch (e) {
uni.showToast({ title: t('functionCommon.loadFailed'), icon: 'none' })
} finally {
loading.value = false
}
}
function handleDelete(item) {
uni.showModal({
title: '确认删除',
content: `确定要删除"${item.name}"吗?`,
success: () => {
uni.showToast({
title: '删除成功',
icon: 'success'
});
function buildTree(data) {
const map = {}
const roots = []
data.forEach(item => { map[item.id] = { ...item, children: [] } })
data.forEach(item => {
if (item.parentId && map[item.parentId]) {
map[item.parentId].children.push(map[item.id])
} else {
roots.push(map[item.id])
}
});
}
function showAddForm() {
addPopup.value.open();
}
function hideAddForm() {
addPopup.value.close();
resetForm();
}
function resetForm() {
formData.code = '';
formData.name = '';
formData.status = '启用';
}
function selectStatus(status) {
formData.status = status;
}
function handleSave() {
if (!formData.code || !formData.name) {
uni.showToast({
title: '请填写必填项',
icon: 'none'
});
return;
}
uni.showToast({
title: '保存成功',
icon: 'success'
});
hideAddForm();
resetForm();
})
return roots
}
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background-color: #f0f2f5;
function flattenTree(nodes, depth = 0) {
const result = []
nodes.forEach(node => {
result.push({ ...node, _depth: depth })
if (node.children && node.children.length) {
result.push(...flattenTree(node.children, depth + 1))
}
})
return result
}
.header-section {
background: linear-gradient(135deg, #1a3a5c 0%, #2d5a87 100%);
padding: 60rpx 40rpx 40rpx 30rpx;
display: flex;
justify-content: center;
align-items: center;
position: relative;
function normalizeData(res) {
const root = res && res.data !== undefined ? res.data : res
if (Array.isArray(root)) return root
if (root?.data && Array.isArray(root.data)) return root.data
return []
}
.back-btn {
display: flex;
align-items: center;
position: absolute;
left: 30rpx;
top: 50%;
transform: translateY(-50%);
&:active {
opacity: 0.7;
}
.back-icon {
font-size: 48rpx;
color: #ffffff;
margin-right: 8rpx;
}
.back-text {
font-size: 28rpx;
color: #ffffff;
}
function getParentName(parentId) {
if (!parentId || parentId === 0) return t('materialCategory.rootCategory')
const parent = allCategories.value.find(item => item.id === parentId)
return parent ? (parent.name || '-') : '-'
}
.header-title {
font-size: 34rpx;
font-weight: bold;
color: #ffffff;
function statusLabel(value) {
return getDictLabel(DICT_TYPE.COMMON_STATUS, value, textValue(value))
}
.search-section {
background: #ffffff;
padding: 24rpx 30rpx;
margin-bottom: 20rpx;
function textValue(value) {
if (value === 0) return '0'
if (value === false) return t('functionCommon.no')
if (value === true) return t('functionCommon.yes')
if (value === null || value === undefined) return '-'
const text = String(value).trim()
return text || '-'
}
.search-wrapper {
display: flex;
align-items: center;
background: #f5f7fa;
border-radius: 48rpx;
padding: 0 24rpx;
}
function handleSearch() {}
.search-icon {
margin-right: 20rpx;
.iconfont {
font-size: 36rpx;
color: #666666;
}
}
function loadMore() {}
.search-input {
flex: 1;
height: 72rpx;
font-size: 28rpx;
color: #333333;
background: transparent;
function onScroll(e) {
showGoTop.value = (e?.detail?.scrollTop || 0) > 600
}
.input-placeholder {
color: #999999;
function goTop() {
scrollTop.value = 0
}
.clear-btn {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
&:active {
opacity: 0.7;
}
.clear-icon {
font-size: 36rpx;
color: #999999;
function openDetail(item) {
if (!item?.id) {
uni.showToast({ title: t('functionCommon.noIdView'), icon: 'none' })
return
}
uni.navigateTo({
url: `/pages_function/pages/materialCategory/detail?id=${encodeURIComponent(String(item.id))}`
})
}
.content-scroll {
flex: 1;
height: calc(100vh - 260rpx);
}
.category-list {
display: flex;
flex-direction: column;
align-items: center;
padding: 0 24rpx;
}
.category-card {
width: 100%;
background: #ffffff;
border-radius: 20rpx;
padding: 28rpx;
margin-bottom: 20rpx;
box-sizing: border-box;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
padding-bottom: 20rpx;
border-bottom: 2rpx solid #f0f2f5;
}
.header-left {
display: flex;
flex-direction: column;
}
.category-name {
font-size: 32rpx;
font-weight: 600;
color: #1a3a5c;
margin-bottom: 8rpx;
function openCreate() {
formMode.value = 'create'
resetForm()
formPopupRef.value?.open()
}
.category-code {
font-size: 24rpx;
color: #999999;
}
.card-actions {
display: flex;
gap: 16rpx;
}
.action-btn {
width: 64rpx;
height: 64rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
&:active {
opacity: 0.7;
transform: scale(0.95);
async function openEdit(item) {
if (!item?.id) {
uni.showToast({ title: t('functionCommon.noIdEdit'), icon: 'none' })
return
}
.action-icon {
font-size: 32rpx;
color: #ffffff;
try {
const { getProductCategory } = await import('@/api/erp/productCategory')
const res = await getProductCategory(item.id)
const detail = normalizeDetail(res)
formMode.value = 'update'
formData.id = detail?.id
formData.parentId = detail?.parentId ?? 0
formData.code = String(detail?.code ?? '')
formData.name = String(detail?.name ?? '')
formData.sort = detail?.sort ?? 0
formData.status = detail?.status ?? 0
formPopupRef.value?.open()
} catch (e) {
uni.showToast({ title: t('materialCategory.loadEditFailed'), icon: 'none' })
}
}
.edit-btn {
background: #1a3a5c;
}
.delete-btn {
background: #ff4d4f;
}
.card-body {
display: flex;
flex-direction: column;
}
.card-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
&:last-child {
margin-bottom: 0;
function confirmDelete(item) {
if (!item?.id) {
uni.showToast({ title: t('functionCommon.noIdDelete'), icon: 'none' })
return
}
uni.showModal({
title: t('functionCommon.confirmDelete'),
content: t('materialCategory.confirmDeleteContent', { name: textValue(item?.name) }),
success: async (res) => {
if (!res.confirm) return
try {
await deleteProductCategory(item.id)
uni.showToast({ title: t('functionCommon.deleteSuccess'), icon: 'success' })
await fetchList()
} catch (e) {
uni.showToast({ title: t('functionCommon.deleteFailed'), icon: 'none' })
}
}
})
}
.card-label {
font-size: 26rpx;
color: #999999;
}
.card-value {
font-size: 28rpx;
color: #333333;
flex: 1;
text-align: right;
function onParentChange(e) {
const idx = e.detail.value
formData.parentId = parentCategoryOptions.value[idx]?.value ?? 0
}
.status-active {
color: #18bc37;
function onStatusChange(e) {
const idx = e.detail.value
formData.status = statusOptions.value[idx]?.value ?? 0
}
.status-inactive {
color: #999999;
function closeForm() {
formPopupRef.value?.close()
}
.add-btn {
position: fixed;
bottom: 120rpx;
right: 30rpx;
width: 100rpx;
height: 100rpx;
background: linear-gradient(135deg, #1a3a5c 0%, #2d5a87 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(26, 58, 92, 0.3);
z-index: 999;
&:active {
transform: scale(0.95);
async function submitForm() {
if (!formData.code.trim()) {
uni.showToast({ title: t('materialCategory.validatorCodeRequired'), icon: 'none' })
return
}
.add-icon {
font-size: 60rpx;
color: #ffffff;
font-weight: bold;
if (!formData.name.trim()) {
uni.showToast({ title: t('materialCategory.validatorNameRequired'), icon: 'none' })
return
}
}
.popup-content {
width: 600rpx;
background: #ffffff;
border-radius: 20rpx;
overflow: hidden;
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 32rpx;
background: linear-gradient(135deg, #1a3a5c 0%, #2d5a87 100%);
}
.popup-title {
font-size: 32rpx;
font-weight: 600;
color: #ffffff;
}
.popup-close {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
&:active {
opacity: 0.7;
if (formData.sort === undefined || formData.sort === null || String(formData.sort).trim() === '') {
uni.showToast({ title: t('materialCategory.validatorSortRequired'), icon: 'none' })
return
}
.close-icon {
font-size: 36rpx;
color: #ffffff;
const payload = {
id: formMode.value === 'update' ? formData.id : undefined,
parentId: formData.parentId ?? 0,
code: formData.code.trim(),
name: formData.name.trim(),
sort: Number(formData.sort),
status: formData.status
}
}
.form-content {
padding: 32rpx;
}
.form-item {
margin-bottom: 24rpx;
}
.form-label {
display: block;
font-size: 28rpx;
color: #333333;
margin-bottom: 12rpx;
}
.required {
color: #ff4d4f;
margin-left: 4rpx;
}
.form-input {
width: 100%;
height: 80rpx;
padding: 0 24rpx;
font-size: 28rpx;
background: #f5f7fa;
border-radius: 12rpx;
border: 2rpx solid #e8eaed;
box-sizing: border-box;
}
.status-selector {
display: flex;
gap: 20rpx;
}
.status-option {
flex: 1;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fa;
border-radius: 12rpx;
border: 2rpx solid #e8eaed;
gap: 12rpx;
&.active {
background: rgba(26, 58, 92, 0.1);
border-color: #1a3a5c;
text {
color: #1a3a5c;
try {
if (formMode.value === 'create') {
await createProductCategory(payload)
uni.showToast({ title: t('functionCommon.createSuccess'), icon: 'success' })
} else {
await updateProductCategory(payload)
uni.showToast({ title: t('functionCommon.updateSuccess'), icon: 'success' })
}
}
text {
font-size: 28rpx;
color: #666666;
}
}
.status-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: #999999;
&.active {
background: #18bc37;
closeForm()
await fetchList()
} catch (e) {
uni.showToast({ title: t('functionCommon.saveFailed'), icon: 'none' })
}
}
.form-footer {
display: flex;
gap: 16rpx;
margin-top: 32rpx;
}
.footer-btn {
flex: 1;
height: 88rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
&:active {
opacity: 0.8;
}
.btn-text {
font-size: 28rpx;
font-weight: 500;
}
function resetForm() {
formData.id = undefined
formData.parentId = 0
formData.code = ''
formData.name = ''
formData.sort = 0
formData.status = 0
}
.cancel-btn {
background: #f5f7fa;
.btn-text {
color: #666666;
}
function normalizeDetail(res) {
const root = res && res.data !== undefined ? res.data : res
if (root?.data && typeof root.data === 'object') return root.data
if (root && typeof root === 'object') return root
return {}
}
</script>
.confirm-btn {
background: linear-gradient(135deg, #1a3a5c 0%, #2d5a87 100%);
.btn-text {
color: #ffffff;
}
}
<style lang="scss" scoped>
.page-container { min-height: 100vh; background-color: #f0f2f5; }
.search-card { background: #ffffff; margin: 20rpx 24rpx; border-radius: 18rpx; padding: 20rpx; box-shadow: 0 4rpx 18rpx rgba(0, 0, 0, 0.04); }
.search-row { display: flex; align-items: center; gap: 16rpx; }
.search-input-wrap { flex: 1; display: flex; align-items: center; background: #f5f7fa; border-radius: 44rpx; padding: 0 20rpx; }
.search-icon { margin-right: 12rpx; }
.search-input { flex: 1; height: 72rpx; font-size: 28rpx; color: #333; background: transparent; }
.search-btn { min-width: 120rpx; height: 72rpx; background: linear-gradient(135deg, #1a3a5c 0%, #2d5a87 100%); border-radius: 14rpx; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 28rpx; font-weight: 600; }
.list-scroll { height: calc(100vh - 300rpx); }
.list-wrap { padding: 0 24rpx 30rpx; }
.type-card { background: #ffffff; border-radius: 18rpx; padding: 24rpx; margin-bottom: 18rpx; box-shadow: 0 4rpx 18rpx rgba(0, 0, 0, 0.05); }
.card-header { display: flex; justify-content: space-between; align-items: center; padding-bottom: 18rpx; border-bottom: 1rpx solid #edf0f3; }
.header-left { display: flex; flex-direction: column; }
.type-name { font-size: 32rpx; font-weight: 600; color: #1a3a5c; margin-bottom: 8rpx; }
.type-code { font-size: 24rpx; color: #8a9099; }
.card-body { padding-top: 16rpx; }
.row { display: flex; justify-content: space-between; align-items: center; margin-top: 12rpx; }
.label { font-size: 26rpx; color: #8a9099; }
.value { font-size: 27rpx; color: #30363d; max-width: 62%; text-align: right; }
.text-success { color: #52c41a; }
.text-danger { color: #ff4d4f; }
.card-actions { margin-top: 24rpx; display: flex; justify-content: flex-end; gap: 14rpx; }
.action-btn { width: 60rpx; height: 60rpx; border-radius: 12rpx; display: flex; align-items: center; justify-content: center; }
.edit-btn { background: #1a3a5c; }
.delete-btn { background: #ff4d4f; }
.hint { text-align: center; color: #909399; padding: 24rpx 0; }
.go-top-btn { position: fixed; right: 28rpx; bottom: 254rpx; width: 88rpx; height: 88rpx; border-radius: 44rpx; background: rgba(26, 58, 92, 0.9); display: flex; align-items: center; justify-content: center; z-index: 99; box-shadow: 0 6rpx 16rpx rgba(26, 58, 92, 0.25); }
.add-btn { position: fixed; right: 28rpx; bottom: 140rpx; width: 96rpx; height: 96rpx; border-radius: 48rpx; background: linear-gradient(135deg, #1a3a5c 0%, #2d5a87 100%); display: flex; align-items: center; justify-content: center; box-shadow: 0 8rpx 24rpx rgba(26, 58, 92, 0.32); z-index: 99; }
.add-icon { color: #ffffff; font-size: 56rpx; line-height: 1; }
.popup-content { width: 680rpx; max-height: 80vh; border-radius: 20rpx; overflow: hidden; }
.popup-header { padding: 24rpx; display: flex; align-items: center; justify-content: space-between; border-bottom: 1rpx solid #edf0f3; position: relative; }
.popup-title { font-size: 32rpx; color: #1a3a5c; font-weight: 700; }
.popup-close { position: absolute; right: 24rpx; top: 20rpx; width: 48rpx; height: 48rpx; display: flex; align-items: center; justify-content: center; }
.close-icon { font-size: 42rpx; color: #9aa0a6; line-height: 1; }
.form-scroll { max-height: 56vh; }
.form-content { padding: 22rpx 28rpx; }
.form-item { margin-bottom: 18rpx; }
.form-label { display: block; font-size: 25rpx; color: #5f6b7a; margin-bottom: 10rpx; }
.required-star { color: #e34d59; }
.form-input { height: 76rpx; padding: 0 20rpx; border-radius: 12rpx; background: #f5f7fa; font-size: 28rpx; color: #30363d; }
.form-picker { height: 76rpx; padding: 0 20rpx; border-radius: 12rpx; background: #f5f7fa; font-size: 28rpx; color: #30363d; display: flex; align-items: center; }
.form-picker.placeholder { color: #999; }
.form-footer { height: 88rpx; display: flex; border-top: 1rpx solid #edf0f3; }
.footer-btn { flex: 1; display: flex; align-items: center; justify-content: center; }
.btn-text { font-size: 28rpx; }
.cancel-btn { background: #edf0f4; }
.cancel-btn .btn-text { color: #586070; }
.confirm-btn { background: #1a3a5c; }
.confirm-btn .btn-text { color: #fff; }
</style>

@ -0,0 +1,153 @@
<template>
<view class="page-container">
<view class="fixed-header">
<AppTitleHeader :title="t('materialInfo.detailTitle')" />
</view>
<scroll-view scroll-y class="detail-scroll">
<view class="content-section">
<!-- 基础信息 -->
<view class="info-card">
<view class="card-title">{{ t('materialInfo.basicInfo') }}</view>
<view class="info-list">
<view class="info-row">
<text class="info-label">{{ t('materialInfo.barCode') }}</text>
<text class="info-value">{{ fieldValue('barCode') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('materialInfo.name') }}</text>
<text class="info-value">{{ fieldValue('name') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('materialInfo.category') }}</text>
<text class="info-value">{{ fieldValue('subCategoryName') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('materialInfo.unit') }}</text>
<text class="info-value">{{ fieldValue('unitName') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('materialInfo.standard') }}</text>
<text class="info-value">{{ fieldValue('standard') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('materialInfo.expiryDay') }}</text>
<text class="info-value">{{ fieldValue('expiryDay') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('materialInfo.status') }}</text>
<text :class="['info-value', String(detailData?.status) === '0' ? 'text-success' : 'text-danger']">{{ statusText }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('materialInfo.createTime') }}</text>
<text class="info-value">{{ formatDateTime(detailData?.createTime) }}</text>
</view>
<view class="info-row remark-row">
<text class="info-label">{{ t('materialInfo.remark') }}</text>
<text class="info-value remark-value">{{ fieldValue('remark') }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useI18n } from 'vue-i18n'
import AppTitleHeader from '@/components/common/AppTitleHeader.vue'
import { getProduct } from '@/api/erp/productInfo'
import { DICT_TYPE, getDictLabel, initAllDict } from '@/utils/dict'
const { t } = useI18n()
const detailId = ref(undefined)
const detailData = ref(null)
const statusText = computed(() => {
return getDictLabel(DICT_TYPE.COMMON_STATUS, detailData.value?.status, textValue(detailData.value?.status))
})
onLoad(async (query) => {
const id = query?.id ? decodeURIComponent(String(query.id)) : ''
detailId.value = id || undefined
await initAllDict()
await fetchDetail()
})
async function fetchDetail() {
if (!detailId.value) {
uni.showToast({ title: t('materialInfo.noId'), icon: 'none' })
return
}
try {
const res = await getProduct(detailId.value)
detailData.value = normalizeDetail(res)
} catch (e) {
uni.showToast({ title: t('materialInfo.loadFailed'), icon: 'none' })
}
}
function normalizeDetail(res) {
const root = res && res.data !== undefined ? res.data : res
if (root?.data && typeof root.data === 'object') return root.data
if (root && typeof root === 'object') return root
return {}
}
function fieldValue(field) {
return textValue(detailData.value ? detailData.value[field] : undefined)
}
function textValue(value) {
if (value === 0) return '0'
if (value === false) return t('functionCommon.no')
if (value === true) return t('functionCommon.yes')
if (value === null || value === undefined) return '-'
const text = String(value).trim()
return text || '-'
}
function formatDateTime(value) {
if (!value) return '-'
if (Array.isArray(value) && value.length >= 3) {
const [y, m, d, hh = 0, mm = 0, ss = 0] = value
const pad = (n) => String(n).padStart(2, '0')
return `${y}-${pad(m)}-${pad(d)} ${pad(hh)}:${pad(mm)}:${pad(ss)}`
}
const text = String(value).trim()
if (!text) return '-'
const numeric = Number(text)
if (Number.isFinite(numeric)) {
const timestamp = text.length === 10 ? numeric * 1000 : numeric
const date = new Date(timestamp)
if (!Number.isNaN(date.getTime())) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`
}
}
const date = new Date(text)
if (!Number.isNaN(date.getTime())) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`
}
return text
}
</script>
<style lang="scss" scoped>
.page-container { min-height: 100vh; background-color: #f0f2f5; }
.fixed-header { position: sticky; top: 0; z-index: 10; }
.detail-scroll { height: calc(100vh - 120rpx); }
.content-section { padding: 0 24rpx 24rpx; }
.info-card { margin-top: 20rpx; background: #ffffff; border-radius: 20rpx; padding: 28rpx; box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05); }
.card-title { font-size: 32rpx; color: #1a3a5c; font-weight: 700; margin-bottom: 18rpx; }
.info-list { background: #ffffff; }
.info-row { display: flex; justify-content: space-between; align-items: flex-start; padding: 18rpx 0; border-bottom: 1rpx solid #edf0f3; }
.info-row:last-child { border-bottom: none; }
.info-label { font-size: 27rpx; color: #8a9099; width: 220rpx; }
.info-value { flex: 1; text-align: right; font-size: 28rpx; color: #30363d; line-height: 1.45; }
.remark-row { border-bottom: none; }
.remark-value { white-space: pre-wrap; }
.text-success { color: #52c41a; }
.text-danger { color: #ff4d4f; }
</style>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,209 @@
<template>
<view class="page-container">
<view class="fixed-header">
<AppTitleHeader :title="t('productBom.detailTitle')" />
</view>
<scroll-view scroll-y class="detail-scroll">
<view class="content-section">
<!-- 基础信息 -->
<view class="info-card">
<view class="card-title">{{ t('productBom.basicInfo') }}</view>
<view class="info-list">
<view class="info-row">
<text class="info-label">{{ t('productBom.code') }}</text>
<text class="info-value">{{ fieldValue('code') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('productBom.version') }}</text>
<text class="info-value">{{ fieldValue('version') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('productBom.product') }}</text>
<text class="info-value">{{ fieldValue('productName') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('productBom.unit') }}</text>
<text class="info-value">{{ fieldValue('unitName') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('productBom.yieldRate') }}</text>
<text class="info-value">{{ fieldValue('yieldRate') }}%</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('productBom.isEnable') }}</text>
<text :class="['info-value', String(detailData?.isEnable) === 'true' || String(detailData?.isEnable) === '1' ? 'text-success' : 'text-danger']">{{ enableText }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('productBom.createTime') }}</text>
<text class="info-value">{{ formatDateTime(detailData?.createTime) }}</text>
</view>
<view class="info-row remark-row">
<text class="info-label">{{ t('productBom.remark') }}</text>
<text class="info-value remark-value">{{ fieldValue('remark') }}</text>
</view>
</view>
</view>
<!-- BOM明细Tab -->
<view class="info-card">
<view class="card-title">{{ t('productBom.detailTab') }}</view>
<view v-if="bomDetailList.length" class="detail-list">
<view v-for="(item, index) in bomDetailList" :key="item.id || index" class="detail-item">
<view class="detail-item-header">
<text class="detail-index">{{ index + 1 }}</text>
<text class="detail-product-name">{{ textValue(item.productName) }}</text>
</view>
<view class="detail-item-body">
<view class="detail-row">
<text class="detail-label">{{ t('productBom.detailUsageNumber') }}</text>
<text class="detail-value">{{ textValue(item.usageNumber) }}</text>
</view>
<view class="detail-row">
<text class="detail-label">{{ t('productBom.detailUnit') }}</text>
<text class="detail-value">{{ textValue(item.unitName) }}</text>
</view>
<view class="detail-row">
<text class="detail-label">{{ t('productBom.detailLossRate') }}</text>
<text class="detail-value">{{ textValue(item.yieldRate) }}%</text>
</view>
<view v-if="item.remark" class="detail-row">
<text class="detail-label">{{ t('productBom.detailRemark') }}</text>
<text class="detail-value">{{ textValue(item.remark) }}</text>
</view>
</view>
</view>
</view>
<view v-else class="detail-empty">{{ t('productBom.detailEmpty') }}</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useI18n } from 'vue-i18n'
import AppTitleHeader from '@/components/common/AppTitleHeader.vue'
import { getBom, getBomDetailListByBomId } from '@/api/mes/productBom'
import { DICT_TYPE, getDictLabel, initAllDict } from '@/utils/dict'
const { t } = useI18n()
const detailId = ref(undefined)
const detailData = ref(null)
const bomDetailList = ref([])
const enableText = computed(() => {
return getDictLabel(DICT_TYPE.INFRA_BOOLEAN_STRING, detailData.value?.isEnable, textValue(detailData.value?.isEnable))
})
onLoad(async (query) => {
const id = query?.id ? decodeURIComponent(String(query.id)) : ''
detailId.value = id || undefined
await initAllDict()
await fetchDetail()
})
async function fetchDetail() {
if (!detailId.value) {
uni.showToast({ title: t('productBom.noId'), icon: 'none' })
return
}
try {
const res = await getBom(detailId.value)
detailData.value = normalizeDetail(res)
await fetchBomDetails()
} catch (e) {
uni.showToast({ title: t('productBom.loadFailed'), icon: 'none' })
}
}
async function fetchBomDetails() {
try {
const res = await getBomDetailListByBomId(detailId.value)
bomDetailList.value = normalizeListData(res)
} catch (e) {
bomDetailList.value = []
}
}
function normalizeDetail(res) {
const root = res && res.data !== undefined ? res.data : res
if (root?.data && typeof root.data === 'object') return root.data
if (root && typeof root === 'object') return root
return {}
}
function normalizeListData(res) {
const root = res && res.data !== undefined ? res.data : res
if (Array.isArray(root)) return root
if (root?.data && Array.isArray(root.data)) return root.data
return []
}
function fieldValue(field) {
return textValue(detailData.value ? detailData.value[field] : undefined)
}
function textValue(value) {
if (value === 0) return '0'
if (value === false) return t('functionCommon.no')
if (value === true) return t('functionCommon.yes')
if (value === null || value === undefined) return '-'
const text = String(value).trim()
return text || '-'
}
function formatDateTime(value) {
if (!value) return '-'
if (Array.isArray(value) && value.length >= 3) {
const [y, m, d, hh = 0, mm = 0, ss = 0] = value
const pad = (n) => String(n).padStart(2, '0')
return `${y}-${pad(m)}-${pad(d)} ${pad(hh)}:${pad(mm)}:${pad(ss)}`
}
const text = String(value).trim()
if (!text) return '-'
const numeric = Number(text)
if (Number.isFinite(numeric)) {
const timestamp = text.length === 10 ? numeric * 1000 : numeric
const date = new Date(timestamp)
if (!Number.isNaN(date.getTime())) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`
}
}
const date = new Date(text)
if (!Number.isNaN(date.getTime())) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`
}
return text
}
</script>
<style lang="scss" scoped>
.page-container { min-height: 100vh; background-color: #f0f2f5; }
.fixed-header { position: sticky; top: 0; z-index: 10; }
.detail-scroll { height: calc(100vh - 120rpx); }
.content-section { padding: 0 24rpx 24rpx; }
.info-card { margin-top: 20rpx; background: #ffffff; border-radius: 20rpx; padding: 28rpx; box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05); }
.card-title { font-size: 32rpx; color: #1a3a5c; font-weight: 700; margin-bottom: 18rpx; }
.info-list { background: #ffffff; }
.info-row { display: flex; justify-content: space-between; align-items: flex-start; padding: 18rpx 0; border-bottom: 1rpx solid #edf0f3; }
.info-row:last-child { border-bottom: none; }
.info-label { font-size: 27rpx; color: #8a9099; width: 220rpx; }
.info-value { flex: 1; text-align: right; font-size: 28rpx; color: #30363d; line-height: 1.45; }
.remark-row { border-bottom: none; }
.remark-value { white-space: pre-wrap; }
.text-success { color: #52c41a; }
.text-danger { color: #ff4d4f; }
.detail-list { margin-top: 10rpx; }
.detail-item { background: #f8f9fb; border-radius: 14rpx; padding: 20rpx; margin-bottom: 14rpx; }
.detail-item-header { display: flex; align-items: center; gap: 14rpx; margin-bottom: 12rpx; }
.detail-index { width: 44rpx; height: 44rpx; border-radius: 22rpx; background: #1a3a5c; color: #fff; font-size: 24rpx; display: flex; align-items: center; justify-content: center; }
.detail-product-name { font-size: 28rpx; font-weight: 600; color: #30363d; }
.detail-item-body { padding-left: 58rpx; }
.detail-row { display: flex; justify-content: space-between; align-items: center; margin-top: 8rpx; }
.detail-label { font-size: 24rpx; color: #8a9099; }
.detail-value { font-size: 25rpx; color: #30363d; }
.detail-empty { text-align: center; color: #909399; padding: 24rpx 0; font-size: 26rpx; }
</style>

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save