Compare commits
3 Commits
f3c4dc8d39
...
f2f9409206
| Author | SHA1 | Date | |
|---|---|---|---|
| f2f9409206 | |||
| 6b4ebfa24f | |||
| 1edd471208 |
@ -25,10 +25,10 @@ BASE_DIR = get_project_root()
|
|||||||
UPLOAD_FOLDER = os.path.join(BASE_DIR, 'uploads')
|
UPLOAD_FOLDER = os.path.join(BASE_DIR, 'uploads')
|
||||||
|
|
||||||
# 允许上传的文件后缀
|
# 允许上传的文件后缀
|
||||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'pdf', 'doc', 'docx', 'xls', 'xlsx'}
|
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'zip', 'rar', '7z'}
|
||||||
|
|
||||||
# ★ 文件上传安全加固:限制最大文件大小 (10MB)
|
# ★ 文件上传安全加固:限制最大文件大小 (50MB,支持压缩包)
|
||||||
MAX_CONTENT_LENGTH = 10 * 1024 * 1024 # 10MB
|
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50MB
|
||||||
|
|
||||||
|
|
||||||
def allowed_file(filename):
|
def allowed_file(filename):
|
||||||
@ -68,7 +68,7 @@ def upload_file():
|
|||||||
if file_size > MAX_CONTENT_LENGTH:
|
if file_size > MAX_CONTENT_LENGTH:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"code": 400,
|
"code": 400,
|
||||||
"msg": f"文件大小超过限制 ({MAX_CONTENT_LENGTH // (1024*1024)}MB)"
|
"msg": f"文件大小超过限制(最大 50MB)"
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
if file and allowed_file(file.filename):
|
if file and allowed_file(file.filename):
|
||||||
|
|||||||
@ -3,6 +3,7 @@ from app.models.bom import BomTable
|
|||||||
from app.models.base import MaterialBase
|
from app.models.base import MaterialBase
|
||||||
from app.models.inbound.buy import StockBuy
|
from app.models.inbound.buy import StockBuy
|
||||||
from sqlalchemy import func, distinct, or_, case
|
from sqlalchemy import func, distinct, or_, case
|
||||||
|
from collections import defaultdict
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@ -64,6 +65,7 @@ class BomService:
|
|||||||
BomTable.parent_id,
|
BomTable.parent_id,
|
||||||
MaterialBase.name.label('parent_name'),
|
MaterialBase.name.label('parent_name'),
|
||||||
MaterialBase.spec_model.label('parent_spec'),
|
MaterialBase.spec_model.label('parent_spec'),
|
||||||
|
MaterialBase.category.label('parent_category'),
|
||||||
BomTable.is_enabled,
|
BomTable.is_enabled,
|
||||||
func.count(BomTable.child_id).label('child_count')
|
func.count(BomTable.child_id).label('child_count')
|
||||||
).join(
|
).join(
|
||||||
@ -72,7 +74,7 @@ class BomService:
|
|||||||
BomTable.bom_no == bom_no,
|
BomTable.bom_no == bom_no,
|
||||||
BomTable.version == version
|
BomTable.version == version
|
||||||
).group_by(
|
).group_by(
|
||||||
BomTable.parent_id, MaterialBase.name, MaterialBase.spec_model, BomTable.is_enabled
|
BomTable.parent_id, MaterialBase.name, MaterialBase.spec_model, MaterialBase.category, BomTable.is_enabled
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if summary:
|
if summary:
|
||||||
@ -82,12 +84,39 @@ class BomService:
|
|||||||
'parent_id': summary.parent_id,
|
'parent_id': summary.parent_id,
|
||||||
'parent_name': summary.parent_name,
|
'parent_name': summary.parent_name,
|
||||||
'parent_spec': summary.parent_spec or '',
|
'parent_spec': summary.parent_spec or '',
|
||||||
|
'parent_category': summary.parent_category or '',
|
||||||
'is_enabled': summary.is_enabled,
|
'is_enabled': summary.is_enabled,
|
||||||
'child_count': summary.child_count
|
'child_count': summary.child_count
|
||||||
})
|
})
|
||||||
|
|
||||||
results.sort(key=lambda x: (x['bom_no'], x['version']), reverse=True)
|
results.sort(key=lambda x: (x['bom_no'], x['version']), reverse=True)
|
||||||
return results
|
|
||||||
|
# 如果有关键词,过滤结果(keyword 匹配逻辑保持不变)
|
||||||
|
if keyword:
|
||||||
|
kw = f'%{keyword}%'
|
||||||
|
results = [
|
||||||
|
r for r in results
|
||||||
|
if kw in (r.get('parent_name') or '')
|
||||||
|
or kw in (r.get('parent_spec') or '')
|
||||||
|
or kw in (r.get('bom_no') or '')
|
||||||
|
or kw in (r.get('parent_category') or '')
|
||||||
|
]
|
||||||
|
|
||||||
|
# 按 parent_category 分组
|
||||||
|
grouped = defaultdict(list)
|
||||||
|
for item in results:
|
||||||
|
cat = item.get('parent_category') or '未分类'
|
||||||
|
grouped[cat].append(item)
|
||||||
|
|
||||||
|
grouped_list = []
|
||||||
|
for cat, items in sorted(grouped.items(), key=lambda x: x[0]):
|
||||||
|
grouped_list.append({
|
||||||
|
'category': cat,
|
||||||
|
'count': len(items),
|
||||||
|
'items': items
|
||||||
|
})
|
||||||
|
|
||||||
|
return grouped_list
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_bom_detail(bom_no, version=None):
|
def get_bom_detail(bom_no, version=None):
|
||||||
|
|||||||
@ -234,7 +234,7 @@ const handleLogout = () => {
|
|||||||
<footer v-if="!isLoginPage" class="app-footer">
|
<footer v-if="!isLoginPage" class="app-footer">
|
||||||
<span class="version-tag">
|
<span class="version-tag">
|
||||||
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
|
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
|
||||||
当前版本:V3.17(4.29部署)
|
当前版本:V3.18(4.29部署)
|
||||||
</span>
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
<el-input
|
<el-input
|
||||||
v-model="searchKeyword"
|
v-model="searchKeyword"
|
||||||
placeholder="搜索 编号/名称/规格/子件..."
|
placeholder="搜索 编号/名称/规格/子件..."
|
||||||
style="width: 300px; margin-right: 15px;"
|
style="width: 300px; margin-right: 10px;"
|
||||||
clearable
|
clearable
|
||||||
@clear="fetchBomList"
|
@clear="fetchBomList"
|
||||||
@keyup.enter="fetchBomList"
|
@keyup.enter="fetchBomList"
|
||||||
@ -17,36 +17,51 @@
|
|||||||
<el-button :icon="Search" @click="fetchBomList" />
|
<el-button :icon="Search" @click="fetchBomList" />
|
||||||
</template>
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
|
<el-button @click="activeCategories = bomGroups.map((g: any) => g.category)" size="small" style="margin-right: 6px;">全部展开</el-button>
|
||||||
|
<el-button @click="activeCategories = []" size="small" style="margin-right: 10px;">全部折叠</el-button>
|
||||||
<el-button v-if="userStore.hasPermission('bom_manage:operation')" type="primary" :icon="Plus" @click="handleCreate">新建 BOM</el-button>
|
<el-button v-if="userStore.hasPermission('bom_manage:operation')" type="primary" :icon="Plus" @click="handleCreate">新建 BOM</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="bomList" border style="width: 100%">
|
<div v-loading="loading">
|
||||||
<el-table-column v-if="hasColumnPermission('bom_no')" prop="bom_no" label="BOM编号" min-width="180" sortable />
|
<el-collapse v-model="activeCategories" class="bom-category-collapse">
|
||||||
<el-table-column v-if="hasColumnPermission('parent_name')" prop="parent_name" label="父件名称" min-width="150" />
|
<el-collapse-item
|
||||||
<el-table-column v-if="hasColumnPermission('parent_spec')" prop="parent_spec" label="父件规格" min-width="150" />
|
v-for="group in bomGroups"
|
||||||
<el-table-column v-if="hasColumnPermission('version')" prop="version" label="版本" width="100" align="center">
|
:key="group.category"
|
||||||
<template #default="{ row }">
|
:title="group.category + ' (' + group.count + ')'"
|
||||||
<el-tag>{{ row.version }}</el-tag>
|
:name="group.category"
|
||||||
</template>
|
>
|
||||||
</el-table-column>
|
<el-table :data="group.items" border style="width: 100%">
|
||||||
<el-table-column v-if="hasColumnPermission('status')" label="状态" width="100" align="center">
|
<el-table-column v-if="hasColumnPermission('bom_no')" prop="bom_no" label="BOM编号" min-width="180" sortable>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.is_enabled ? 'success' : 'danger'">
|
<span style="cursor: pointer; color: #409EFF;" @click="handleEdit(row)">{{ row.bom_no }}</span>
|
||||||
{{ row.is_enabled ? '启用' : '禁用' }}
|
</template>
|
||||||
</el-tag>
|
</el-table-column>
|
||||||
</template>
|
<el-table-column v-if="hasColumnPermission('parent_name')" prop="parent_name" label="父件名称" min-width="150" show-overflow-tooltip />
|
||||||
</el-table-column>
|
<el-table-column v-if="hasColumnPermission('parent_spec')" prop="parent_spec" label="父件规格" min-width="150" show-overflow-tooltip />
|
||||||
<el-table-column v-if="hasColumnPermission('child_count')" prop="child_count" label="子件数" width="80" align="center" />
|
<el-table-column v-if="hasColumnPermission('version')" label="版本" width="100" align="center">
|
||||||
<el-table-column v-if="userStore.hasPermission('bom_manage:operation')" label="操作" width="250" align="center" fixed="right">
|
<template #default="{ row }">
|
||||||
<template #default="{ row }">
|
<el-tag>{{ row.version }}</el-tag>
|
||||||
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
|
</template>
|
||||||
<el-button type="success" link @click="handleSaveAs(row)">另存为</el-button>
|
</el-table-column>
|
||||||
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
|
<el-table-column v-if="hasColumnPermission('status')" label="状态" width="100" align="center">
|
||||||
</template>
|
<template #default="{ row }">
|
||||||
</el-table-column>
|
<el-tag :type="row.is_enabled ? 'success' : 'danger'">{{ row.is_enabled ? '启用' : '禁用' }}</el-tag>
|
||||||
</el-table>
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column v-if="hasColumnPermission('child_count')" prop="child_count" label="子件数" width="80" align="center" />
|
||||||
|
<el-table-column v-if="userStore.hasPermission('bom_manage:operation')" label="操作" width="250" align="center" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
|
||||||
|
<el-button type="success" link @click="handleSaveAs(row)">另存为</el-button>
|
||||||
|
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-collapse-item>
|
||||||
|
</el-collapse>
|
||||||
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="850px" destroy-on-close :close-on-click-modal="false">
|
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="850px" destroy-on-close :close-on-click-modal="false">
|
||||||
@ -268,7 +283,8 @@ let originalVersion = ''
|
|||||||
let currentBomNo = ''
|
let currentBomNo = ''
|
||||||
let originalChildren: ChildRow[] = []
|
let originalChildren: ChildRow[] = []
|
||||||
|
|
||||||
const bomList = ref<BomItem[]>([])
|
const bomGroups = ref([]) // 分组结构: [{category, count, items[]}]
|
||||||
|
const activeCategories = ref([]) // 默认全部展开
|
||||||
const searchKeyword = ref('')
|
const searchKeyword = ref('')
|
||||||
const childSearchKeyword = ref('')
|
const childSearchKeyword = ref('')
|
||||||
|
|
||||||
@ -560,8 +576,9 @@ const pureBomNo = computed(() => form.bom_no)
|
|||||||
|
|
||||||
const versionOptions = computed(() => {
|
const versionOptions = computed(() => {
|
||||||
const ver = originalVersion || 'V1.0'
|
const ver = originalVersion || 'V1.0'
|
||||||
|
const allItems = bomGroups.value.flatMap((g: any) => g.items)
|
||||||
const occupiedVersions = new Set(
|
const occupiedVersions = new Set(
|
||||||
bomList.value.filter(item => item.bom_no === currentBomNo).map(item => item.version)
|
allItems.filter((item: any) => item.bom_no === currentBomNo).map((item: any) => item.version)
|
||||||
)
|
)
|
||||||
const getNextMinor = (baseMajor: number, baseMinor: number): string => {
|
const getNextMinor = (baseMajor: number, baseMinor: number): string => {
|
||||||
let minor = baseMinor + 1
|
let minor = baseMinor + 1
|
||||||
@ -597,7 +614,10 @@ const fetchBomList = async () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getBomList({ keyword: searchKeyword.value })
|
const res = await getBomList({ keyword: searchKeyword.value })
|
||||||
if (res.code === 200) bomList.value = res.data
|
if (res.code === 200) {
|
||||||
|
bomGroups.value = res.data
|
||||||
|
activeCategories.value = []
|
||||||
|
}
|
||||||
} finally { loading.value = false }
|
} finally { loading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -273,7 +273,8 @@
|
|||||||
<!-- 非图片文件 -->
|
<!-- 非图片文件 -->
|
||||||
<div v-for="(link, idx) in row.generalManual.filter(l => !isExternalLink(l) && !isImageFile(l))" :key="'file-' + idx">
|
<div v-for="(link, idx) in row.generalManual.filter(l => !isExternalLink(l) && !isImageFile(l))" :key="'file-' + idx">
|
||||||
<el-link @click.prevent="handleDownloadConfirm(link)" type="info" :underline="false">
|
<el-link @click.prevent="handleDownloadConfirm(link)" type="info" :underline="false">
|
||||||
<el-icon><Files /></el-icon>
|
<el-icon v-if="isCompressedFile(link)"><Zipper /></el-icon>
|
||||||
|
<el-icon v-else><Files /></el-icon>
|
||||||
{{ link.split('/').pop() }}
|
{{ link.split('/').pop() }}
|
||||||
</el-link>
|
</el-link>
|
||||||
</div>
|
</div>
|
||||||
@ -515,7 +516,7 @@
|
|||||||
<span class="el-upload-list__item-preview" @click="handlePreviewPicture(file)">
|
<span class="el-upload-list__item-preview" @click="handlePreviewPicture(file)">
|
||||||
<el-icon><ZoomIn /></el-icon>
|
<el-icon><ZoomIn /></el-icon>
|
||||||
</span>
|
</span>
|
||||||
<span class="el-upload-list__item-delete" @click="() => handleRemoveImage(file, 'generalManual')">
|
<span class="el-upload-list__item-delete" @click.stop.prevent="() => handleRemoveImage(file, 'generalManual')">
|
||||||
<el-icon><Delete /></el-icon>
|
<el-icon><Delete /></el-icon>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@ -1534,6 +1535,7 @@ const tableRowClassName = ({ row }: { row: MaterialBaseVO }) => {
|
|||||||
const getImageUrl = (url: string) => { return !url ? '' : (url.startsWith('http') ? url : url) }
|
const getImageUrl = (url: string) => { return !url ? '' : (url.startsWith('http') ? url : url) }
|
||||||
const isExternalLink = (str: string) => { return str && (str.startsWith('http://') || str.startsWith('https://')) && !str.includes('/api/v1/common/files') }
|
const isExternalLink = (str: string) => { return str && (str.startsWith('http://') || str.startsWith('https://')) && !str.includes('/api/v1/common/files') }
|
||||||
const isImageFile = (url: string) => { return /\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(url) }
|
const isImageFile = (url: string) => { return /\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(url) }
|
||||||
|
const isCompressedFile = (url: string) => { return /\.(zip|rar|7z)$/i.test(url) }
|
||||||
const getImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item) && isImageFile(item)) }
|
const getImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item) && isImageFile(item)) }
|
||||||
const getNonImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item) && !isImageFile(item)) }
|
const getNonImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item) && !isImageFile(item)) }
|
||||||
const truncateFileName = (name: string, maxLen = 12) => { return name.length > maxLen ? name.slice(0, maxLen - 3) + '...' : name }
|
const truncateFileName = (name: string, maxLen = 12) => { return name.length > maxLen ? name.slice(0, maxLen - 3) + '...' : name }
|
||||||
@ -1550,13 +1552,23 @@ const handleDownloadConfirm = (link: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const beforeAvatarUpload = (rawFile: any) => {
|
const beforeAvatarUpload = (rawFile: any) => {
|
||||||
const isTypeValid = ['image/jpeg', 'image/png', 'application/pdf'].includes(rawFile.type);
|
const isTypeValid = [
|
||||||
|
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp',
|
||||||
|
'application/pdf',
|
||||||
|
'application/zip', 'application/x-zip-compressed',
|
||||||
|
'application/x-rar-compressed',
|
||||||
|
'application/vnd.rar',
|
||||||
|
'application/x-7z-compressed',
|
||||||
|
'application/octet-stream' // 兼容某些浏览器对 .zip/.rar 的错误识别
|
||||||
|
].includes(rawFile.type);
|
||||||
if (!isTypeValid) {
|
if (!isTypeValid) {
|
||||||
ElMessage.error('仅支持 JPG/PNG/PDF');
|
ElMessage.error('仅支持 JPG/PNG/GIF/PDF/ZIP/RAR/7Z');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (rawFile.size / 1024 / 1024 > 10) {
|
const isCompressed = ['application/zip', 'application/x-zip-compressed', 'application/x-rar-compressed', 'application/vnd.rar', 'application/x-7z-compressed'].includes(rawFile.type);
|
||||||
ElMessage.error('文件不能超过 10MB');
|
const maxMB = 150;
|
||||||
|
if (rawFile.size / 1024 / 1024 > maxMB) {
|
||||||
|
ElMessage.error(`文件不能超过 ${maxMB}MB`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@ -1586,6 +1598,21 @@ const customUpload = async (options: any, targetField: 'generalImage' | 'general
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleRemoveImage = async (uploadFile: any, targetField: 'generalImage' | 'generalManual') => {
|
const handleRemoveImage = async (uploadFile: any, targetField: 'generalImage' | 'generalManual') => {
|
||||||
|
const fileName = uploadFile.name || uploadFile.url?.split('/').pop() || '此文件'
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确认要删除「${fileName}」吗?删除后不可恢复。`,
|
||||||
|
'删除确认',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确认删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return // 用户取消,不删除
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const urlToRemove = form.value[targetField].find(u => getImageUrl(u) === uploadFile.url) || uploadFile.url
|
const urlToRemove = form.value[targetField].find(u => getImageUrl(u) === uploadFile.url) || uploadFile.url
|
||||||
form.value[targetField] = form.value[targetField].filter(u => u !== urlToRemove)
|
form.value[targetField] = form.value[targetField].filter(u => u !== urlToRemove)
|
||||||
@ -1593,8 +1620,11 @@ const handleRemoveImage = async (uploadFile: any, targetField: 'generalImage' |
|
|||||||
const filename = urlToRemove.split('/').pop();
|
const filename = urlToRemove.split('/').pop();
|
||||||
if (filename) await deleteFile(filename)
|
if (filename) await deleteFile(filename)
|
||||||
}
|
}
|
||||||
ElMessage.success('已从列表移除')
|
ElMessage.success('已删除')
|
||||||
} catch (e) { console.error(e) }
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePreviewPicture = (uploadFile: any) => {
|
const handlePreviewPicture = (uploadFile: any) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user