init: Initialize the basic project.

This commit is contained in:
2026-01-06 09:58:29 +08:00
commit 1ab79d6f8f
1441 changed files with 129326 additions and 0 deletions

View File

@@ -0,0 +1,167 @@
<template>
<xn-form-container
:title="formData.projectId ? '编辑项目信息' : '增加项目信息'"
:width="700"
v-model:open="open"
:destroy-on-close="true"
@close="onClose"
>
<a-form ref="formRef" :model="formData" :rules="formRules" layout="horizontal">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="项目编码:" name="projectCode">
<a-input v-model:value="formData.projectCode" placeholder="请输入项目编码" allow-clear />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="项目名称:" name="projectName">
<a-input v-model:value="formData.projectName" placeholder="请输入项目名称" allow-clear />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="项目经理:" name="projectManage">
<a-select
v-model:value="formData.projectManage"
style="width: 100%"
placeholder="请选择项目经理"
:options="pmDatas"
></a-select>
<!-- <a-input v-model:value="formData.projectManage" placeholder="请输入项目经理" allow-clear />-->
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="产品需求:" name="requireManage">
<a-select
v-model:value="formData.requireManage"
style="width: 100%"
placeholder="请选择产品需求"
:options="prDatas"
></a-select>
<!-- <a-input v-model:value="formData.requireManage" placeholder="请输入产品需求" allow-clear />-->
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="开&nbsp;&nbsp;发&nbsp;&nbsp;组:" name="devGroups">
<a-select
v-model:value="formData.devGroups"
mode="multiple"
style="width: 100%"
placeholder="请选择开发组成员"
:options="devDatas"
></a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="开始时间:" name="startTime">
<a-date-picker
v-model:value="formData.startTime"
:format="dateFormat"
value-format="YYYY年MM月DD日"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="结束时间:" name="endTime">
<a-date-picker
v-model:value="formData.endTime"
:format="dateFormat"
value-format="YYYY年MM月DD日"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
<template #footer>
<a-button style="margin-right: 8px" @click="onClose">关闭</a-button>
<a-button type="primary" @click="onSubmit" :loading="submitLoading">保存</a-button>
</template>
</xn-form-container>
</template>
<script setup name="projectForm">
import { cloneDeep } from 'lodash-es'
import { required } from '@/utils/formRules'
import projectApi from '@/api/pmm/projectApi'
// 抽屉状态
const open = ref(false)
const emit = defineEmits({ successful: null })
const formRef = ref()
// 表单数据
const formData = ref({})
const submitLoading = ref(false)
const devDatas = ref([])
const pmDatas = ref([])
const prDatas = ref([])
const dateFormat = 'YYYY年MM月DD日'
// 打开抽屉
const onOpen = (record) => {
open.value = true
if (record) {
let recordData = cloneDeep(record)
formData.value = Object.assign({}, recordData)
}
}
// 关闭抽屉
const onClose = () => {
formRef.value.resetFields()
formData.value = {}
open.value = false
}
// 默认要校验的
const formRules = {
}
// 验证并提交数据
const onSubmit = () => {
formRef.value
.validate()
.then(() => {
submitLoading.value = true
const formDataParam = cloneDeep(formData.value)
console.log(formDataParam)
projectApi
.projectSubmitForm(formDataParam, formDataParam.projectId)
.then(() => {
onClose()
emit('successful')
})
.finally(() => {
submitLoading.value = false
})
})
.catch(() => {})
}
const loadDevUserData = () => {
const param = {
groupId: '1988222962091225089'
}
projectApi.devUsers(param).then((data) => {
devDatas.value = data
})
}
const loadPmUserData = () => {
const param = {
groupId: '1988526425308659714'
}
projectApi.devUsers(param).then((data) => {
pmDatas.value = data
})
}
const loadPrUserData = () => {
const param = {
groupId: '1988526867551879170'
}
projectApi.devUsers(param).then((data) => {
prDatas.value = data
})
}
loadDevUserData()
loadPmUserData()
loadPrUserData()
// 抛出函数
defineExpose({
onOpen
})
</script>

View File

@@ -0,0 +1,133 @@
<template>
<a-card :bordered="false">
<s-table
ref="tableRef"
:columns="columns"
:data="loadData"
:alert="options.alert.show"
bordered
:row-key="(record) => record.projectId"
:tool-config="toolConfig"
:row-selection="options.rowSelection"
>
<template #operator class="table-operator">
<a-space>
<a-button type="primary" @click="formRef.onOpen()" v-if="hasPerm('projectAdd')">
<template #icon><plus-outlined /></template>
新增
</a-button>
<xn-batch-button
v-if="hasPerm('projectBatchDelete')"
buttonName="批量删除"
icon="DeleteOutlined"
:selectedRowKeys="selectedRowKeys"
@batchCallBack="deleteBatchProject"
/>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'action'">
<a-space>
<a @click="formRef.onOpen(record)" v-if="hasPerm('projectEdit')">编辑</a>
<a-divider type="vertical" v-if="hasPerm(['projectEdit', 'projectDelete'], 'and')" />
<a-popconfirm title="确定要删除吗" @confirm="deleteProject(record)">
<a-button type="link" danger size="small" v-if="hasPerm('projectDelete')">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</s-table>
</a-card>
<Form ref="formRef" @successful="tableRef.refresh()" />
</template>
<script setup name="project">
import { cloneDeep } from 'lodash-es'
import Form from './form.vue'
import projectApi from '@/api/pmm/projectApi'
const tableRef = ref()
const formRef = ref()
const toolConfig = { refresh: true, height: true, columnSetting: true, striped: false }
const columns = [
{
title: '项目编码',
dataIndex: 'projectCode'
},
{
title: '项目名称',
dataIndex: 'projectName'
},
{
title: '项目经理',
dataIndex: 'projectManage'
},
{
title: '产品需求',
dataIndex: 'requireManage'
},
{
title: '开发组',
dataIndex: 'devGroup'
},
{
title: '开始时间',
dataIndex: 'startTime'
},
{
title: '结束时间',
dataIndex: 'endTime'
},
]
// 操作栏通过权限判断是否显示
if (hasPerm(['projectEdit', 'projectDelete'])) {
columns.push({
title: '操作',
dataIndex: 'action',
align: 'center',
width: 150
})
}
const selectedRowKeys = ref([])
// 列表选择配置
const options = {
// columns数字类型字段加入 needTotal: true 可以勾选自动算账
alert: {
show: true,
clear: () => {
selectedRowKeys.value = ref([])
}
},
rowSelection: {
onChange: (selectedRowKey, selectedRows) => {
selectedRowKeys.value = selectedRowKey
}
}
}
const loadData = (parameter) => {
return projectApi.projectPage(parameter).then((data) => {
return data
})
}
// 重置
const reset = () => {
searchFormRef.value.resetFields()
tableRef.value.refresh(true)
}
// 删除
const deleteProject = (record) => {
let params = [
{
projectId: record.projectId
}
]
projectApi.projectDelete(params).then(() => {
tableRef.value.refresh(true)
})
}
// 批量删除
const deleteBatchProject = (params) => {
projectApi.projectDelete(params).then(() => {
tableRef.value.clearRefreshSelected()
})
}
</script>

View File

@@ -0,0 +1,153 @@
<template>
<xn-form-container title="编辑项目阶段" :width="700" v-model:open="open" :destroy-on-close="true" @close="onClose">
<div class="stage-form">
<div class="stage-form__header">
<div>阶段名称</div>
<div>阶段序号</div>
<div>操作</div>
</div>
<div v-if="!visibleStages.length" class="stage-form__empty">
<a-empty description="暂无阶段,请新增一行" />
</div>
<div v-for="(stage, index) in visibleStages" :key="stage._key ?? index" class="stage-form__row">
<a-input v-model:value="stage.stageName" placeholder="请输入阶段名称" allow-clear :maxlength="50" />
<a-input-number v-model:value="stage.stageSeq" placeholder="请输入阶段序号" :min="1" style="width: 100%" />
<a-space>
<a-tooltip title="删除当前行以及需求">
<a-button type="link" danger @click="removeRow(stage)">删除</a-button>
</a-tooltip>
</a-space>
</div>
<a-button type="dashed" block @click="addRow"> 新增一行 </a-button>
</div>
<template #footer>
<a-button style="margin-right: 8px" @click="onClose">关闭</a-button>
<a-button type="primary" @click="onSubmit" :loading="submitLoading">保存</a-button>
</template>
</xn-form-container>
</template>
<script setup name="projectStageForm">
import projectStageApi from '@/api/pmm/projectStageApi'
import { createVNode } from 'vue'
import { Modal } from 'ant-design-vue'
import { ExclamationCircleOutlined } from '@ant-design/icons-vue'
import { cloneDeep } from 'lodash-es'
// 抽屉状态
const open = ref(false)
const emit = defineEmits({ successful: null })
// 表单数据
const formData = ref([])
const projectId = ref('')
const visibleStages = computed(() => formData.value.filter((item) => !item.isDeleted))
const submitLoading = ref(false)
const createEmptyStage = () => ({
stageId: '',
stageName: '',
projectId: projectId,
stageSeq: visibleStages.value.length + 1,
isDeleted: false,
_key: `${Date.now()}-${Math.random()}`
})
// 打开抽屉
const onOpen = (record) => {
if (record.length > 0) {
projectId.value = record[0].projectId
}
open.value = true
const list = Array.isArray(record) ? record : []
formData.value = cloneDeep(list).map((item, index) => ({
stageId: item.stageId ?? '',
stageName: item.stageName ?? '',
projectId: item.projectId ?? '',
stageSeq: item.stageSeq ?? index + 1,
isDeleted: item.isDeleted ?? false,
_key: `${Date.now()}-${index}`
}))
if (!formData.value.length) {
formData.value.push(createEmptyStage())
}
}
// 关闭抽屉
const onClose = () => {
formData.value = []
open.value = false
}
const addRow = () => {
formData.value.push(createEmptyStage())
}
const removeRow = (stage) => {
stage.isDeleted = true
if (!visibleStages.value.length) {
formData.value.push(createEmptyStage())
}
}
const submitStages = () => {
submitLoading.value = true
const submitList = formData.value.map(({ _key, ...rest }) => rest)
projectStageApi
.batchEdit(submitList)
.then(() => {
onClose()
emit('successful')
})
.finally(() => {
submitLoading.value = false
})
}
// 验证并提交数据
const onSubmit = () => {
const deletedStageCount = formData.value.filter((item) => item.stageId && item.isDeleted).length
const addedStageCount = formData.value.filter((item) => !item.stageId && !item.isDeleted).length
const content = `将删除${deletedStageCount}个阶段以及该阶段的需求,新增${addedStageCount}个阶段,是否继续?`
Modal.confirm({
title: '提示',
content,
icon: createVNode(ExclamationCircleOutlined),
okText: '确定',
cancelText: '取消',
onOk: () => {
submitStages()
}
})
}
// 抛出函数
defineExpose({
onOpen
})
</script>
<style scoped>
.stage-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.stage-form__header,
.stage-form__row {
display: grid;
grid-template-columns: 1fr 1fr 120px;
gap: 12px;
align-items: center;
}
.stage-form__header {
font-weight: 600;
color: var(--text-color);
}
.stage-form__empty {
padding: 24px 0;
border: 1px dashed var(--border-color-split);
border-radius: 4px;
text-align: center;
}
.stage-form__row {
padding: 12px;
border: 1px solid var(--border-color-split);
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,755 @@
<template>
<div class="description-wrapper">
<div v-if="!previewVisible">
<a-card :bordered="false">
<a-spin :spinning="loading">
<!-- 项目名称标题 -->
<div class="project-title">
<h2>{{ parsedQuery?.projectName || '项目名称' }}</h2>
</div>
<!-- 项目信息部分 -->
<a-card :bordered="false" class="project-info-card">
<a-form :model="projectInfo" layout="horizontal" :label-col="{ span: 7 }" :wrapper-col="{ span: 17 }" class="compact-form">
<!-- 第一行项目名称项目编号 -->
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="项目名称:">
<span>{{ projectInfo.projectName || '-' }}</span>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="项目编号:">
<span>{{ projectInfo.projectCode || '-' }}</span>
</a-form-item>
</a-col>
</a-row>
<!-- 第二行项目经理产品需求开发组 -->
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="项目经理:">
<span>{{ projectInfo.projectManage || '-' }}</span>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="产品需求:">
<span>{{ projectInfo.requireManage || '-' }}</span>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="开发组:">
<span>{{ projectInfo.devGroup || '-' }}</span>
</a-form-item>
</a-col>
</a-row>
<!-- 第三行开始时间完成时间 -->
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="开始时间:">
<span>{{ projectInfo.startTime || '-' }}</span>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="完成时间:">
<span>{{ projectInfo.endTime || '-' }}</span>
</a-form-item>
</a-col>
</a-row>
</a-form>
<div class="planned-hours">
<span class="hours-label">计划工时</span>
<span class="hours-value">{{ plannedHours }}</span>
<a-button type="primary" class="create-stage-btn" @click="formRef.onOpen(projectInfo.projectId)">创建项目阶段</a-button>
<a-button type="primary" class="create-stage-btn" @click="form2Ref.onOpen(stageList)">编辑项目阶段</a-button>
</div>
</a-card>
<!-- 表格信息部分 -->
<div v-if="stageList.length > 0" class="stage-tables">
<a-card
v-for="(stage, stageIndex) in stageList"
:key="stage.stageId"
:bordered="false"
class="stage-card"
>
<template #title>
<div class="stage-title-wrapper">
<span>{{ getStageNumber(stage.stageSeq) }}阶段 {{ stage.stageName }}</span>
<a-button type="primary" class="create-requirement-btn">创建需求</a-button>
</div>
</template>
<a-table
:columns="tableColumns"
:data-source="stage.descriptions"
:pagination="false"
:row-key="(record) => record.detailId"
:defaultExpandAllRows="true"
bordered
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'seq'">
{{ record.seq }}
</template>
<template v-if="column.dataIndex === 'modelName'">
{{ record.modelName }}
</template>
<template v-if="column.dataIndex === 'modelDetail'">
{{ record.modelDetail }}
</template>
<template v-if="column.dataIndex === 'modelDescription'">
{{ record.modelDescription }}
</template>
<template v-if="column.dataIndex === 'priority'">
{{ record.priority }}
</template>
<template v-if="column.dataIndex === 'remark'">
{{ record.remark || '无' }}
</template>
<template v-if="column.dataIndex === 'startTime'">
{{ formatDate(record.startTime) }}
</template>
<template v-if="column.dataIndex === 'endTime'">
{{ formatDate(record.endTime) }}
</template>
<template v-if="column.dataIndex === 'days'">
{{ record.days }}
</template>
<template v-if="column.dataIndex === 'action'">
<a-button type="link" size="small" class="action-btn">
新增同级
</a-button>
<a-button type="link" size="small" class="action-btn">
新增子级
</a-button>
<a-button type="link" size="small" class="action-btn">
修改需求
</a-button>
<a-button type="link" size="small" class="action-btn">
删除需求
</a-button>
</template>
</template>
<template #summary>
<a-table-summary fixed>
<a-table-summary-row>
<a-table-summary-cell :index="0" :col-span="10">
<span style="font-weight: bold">总计天数:{{ getStageTotalDays(stage.descriptions) }}</span>
</a-table-summary-cell>
</a-table-summary-row>
</a-table-summary>
</template>
</a-table>
</a-card>
</div>
<a-empty v-else description="暂无数据" />
<!-- 附件区域 -->
<div class="attachments-section">
<div class="attachments-header">
<div class="attachments-title">附件</div>
<a-button type="primary" @click="uploadFormRef.openUpload(projectInfo.projectId)">上传附件</a-button>
</div>
<div v-if="attachmentList.length" class="attachment-list">
<div
v-for="file in attachmentList"
:key="file.id"
class="attachment-item"
@click="handlePreview(file)"
>
<a-button
type="text"
size="small"
class="attachment-delete-btn"
@click.stop="handleAttachmentDelete(file)"
>
<CloseOutlined />
</a-button>
<div class="attachment-thumb">
<img :src="getAttachmentThumbnail(file)" :alt="file.name" />
</div>
<div class="attachment-name" :title="file.name">
{{ file.name }}
</div>
<a-tag v-if="!canPreview(file)" color="default" class="attachment-tag">暂不支持预览</a-tag>
</div>
</div>
<a-empty v-else description="暂无附件" />
</div>
</a-spin>
</a-card>
</div>
<preview v-else ref="previewRef" @goBack="handlePreviewClose" />
<stage-add-dialog ref="formRef" @successful="fetchDetail"/>
<fj-upload-form ref="uploadFormRef" @successful="fetchAttachments"/>
<batch-stage-add-dialog ref="form2Ref" @successful="fetchDetail"/>
</div>
</template>
<script setup lang="ts" name="Description">
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import projectApi from '@/api/pmm/projectApi'
import projectFileApi from '@/api/pmm/projectFileApi'
import { Modal, message } from 'ant-design-vue'
import StageAddDialog from "@/views/pmm/project/request/stageAddDialog.vue";
import Preview from '@/views/dev/file/preview.vue'
import FjUploadForm from "@/views/pmm/project/request/fjUploadForm.vue";
import BatchStageAddDialog from "@/views/pmm/project/request/batchStageAddDialog.vue";
import { CloseOutlined } from '@ant-design/icons-vue'
interface AttachmentItem {
id: number | string
name: string
suffix: string
downloadPath: string
thumbnail?: string
}
const route = useRoute()
const loading = ref(false)
const stageList = ref<any[]>([])
const plannedHours = ref(0)
const formRef = ref()
const form2Ref = ref()
const previewRef = ref()
const uploadFormRef = ref()
const previewVisible = ref(false)
const attachmentList = ref<AttachmentItem[]>([])
const parsedQuery = computed(() => {
const payload = route.query.item as string | undefined
if (!payload) return null
try {
return JSON.parse(decodeURIComponent(payload))
} catch (error) {
console.warn('解析项目详情失败', error)
return null
}
})
const projectInfo = computed(() => {
return parsedQuery.value || {}
})
const projectId = computed(() => parsedQuery.value?.projectId)
// 表格列配置
const tableColumns = [
{
title: '序号',
dataIndex: 'seq',
key: 'seq',
width: 80,
align: 'center'
},
{
title: '功能模块',
dataIndex: 'modelName',
key: 'modelName',
width: 120
},
{
title: '功能明细',
dataIndex: 'modelDetail',
key: 'modelDetail',
width: 120
},
{
title: '描述',
dataIndex: 'modelDescription',
key: 'modelDescription',
width: 150
},
{
title: '优先级',
dataIndex: 'priority',
key: 'priority',
width: 80,
align: 'center'
},
{
title: '备注',
dataIndex: 'remark',
key: 'remark',
width: 100
},
{
title: '开始日期',
dataIndex: 'startTime',
key: 'startTime',
width: 120
},
{
title: '结束日期',
dataIndex: 'endTime',
key: 'endTime',
width: 120
},
{
title: '计划天数',
dataIndex: 'days',
key: 'days',
width: 100,
align: 'center'
},
{
title: '操作',
dataIndex: 'action',
key: 'action',
width: 300,
align: 'center'
}
]
// 格式化日期
const formatDate = (dateStr: string) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
const year = date.getFullYear()
const month = date.getMonth() + 1
const day = date.getDate()
const monthStr = month < 10 ? '0' + month : String(month)
const dayStr = day < 10 ? '0' + day : String(day)
return `${year}-${monthStr}-${dayStr}`
}
// 获取阶段序号中文
const getStageNumber = (seq: string) => {
const num = parseInt(seq)
const numbers = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
if (num <= 10) {
return numbers[num - 1] || seq
}
return seq
}
// 计算阶段总天数(只统计一级,不包含子项)
const getStageTotalDays = (descriptions: any[]) => {
if (!descriptions || descriptions.length === 0) return 0
let total = 0
descriptions.forEach((item) => {
if (item.days) {
total += item.days
}
})
return total
}
// 计算计划工时(所有阶段的总天数 * 8
const calculatePlannedHours = (stages: any[]) => {
let total = 0
stages.forEach((stage) => {
if (stage.descriptions) {
total += getStageTotalDays(stage.descriptions)
}
})
return total * 8
}
// 获取项目需求明细
const fetchDetail = () => {
if (!projectId.value) {
stageList.value = []
return
}
loading.value = true
projectApi
.requestDetail({ projectId: projectId.value })
.then((data) => {
if (Array.isArray(data)) {
stageList.value = data
plannedHours.value = calculatePlannedHours(data)
} else {
stageList.value = []
message.warning('数据格式错误')
}
loading.value = false
})
.catch((error) => {
console.warn('获取项目需求明细失败', error)
stageList.value = []
message.error('获取项目需求明细失败')
loading.value = false
})
}
const resolveAttachmentSource = (payload: unknown) => {
if (Array.isArray(payload)) {
return payload
}
if (payload && typeof payload === 'object') {
const obj = payload as Record<string, any>
const candidateKeys = ['data', 'records', 'rows', 'list']
for (const key of candidateKeys) {
if (Array.isArray(obj[key])) {
return obj[key]
}
}
}
return []
}
const getSuffixFromName = (name?: string) => {
if (!name || typeof name !== 'string') {
return ''
}
const lastDotIndex = name.lastIndexOf('.')
if (lastDotIndex === -1) {
return ''
}
return name.slice(lastDotIndex + 1)
}
const normalizeAttachmentRecord = (raw: Record<string, any>): AttachmentItem | null => {
if (!raw) {
return null
}
const name =
raw.name ||
raw.fileName ||
raw.originName ||
raw.originalName ||
raw.fileOriginalName ||
raw.attachmentName ||
raw.title
const downloadPath =
raw.downloadPath ||
raw.fileUrl ||
raw.url ||
raw.downloadUrl ||
raw.path ||
raw.filePath ||
raw.storagePath
if (!downloadPath) {
return null
}
const suffixSource =
raw.suffix ||
raw.fileSuffix ||
raw.fileType ||
getSuffixFromName(name) ||
getSuffixFromName(downloadPath)
const suffix = typeof suffixSource === 'string' ? suffixSource.toLowerCase() : ''
const thumbnail = raw.thumbnail || raw.thumbUrl || raw.previewUrl || raw.cover
const id = raw.id || raw.fileId || raw.attachmentId || raw.uid || downloadPath || name || Date.now()
return {
id,
name: name || '未命名文件',
suffix,
downloadPath,
thumbnail
}
}
const fetchAttachments = () => {
if (!projectId.value) {
attachmentList.value = []
return
}
projectApi
.getAttachment({ projectId: projectId.value })
.then((response) => {
const source = resolveAttachmentSource(response)
if (!source.length) {
attachmentList.value = []
return
}
const parsed = source
.map((item) => normalizeAttachmentRecord(item))
.filter((item): item is AttachmentItem => Boolean(item))
if (!parsed.length) {
message.warning('附件数据格式错误')
}
attachmentList.value = parsed
})
.catch((error) => {
console.warn('获取项目附件失败', error)
message.error('获取项目附件失败')
attachmentList.value = []
})
}
watch(projectId, () => {
fetchDetail()
fetchAttachments()
}, { immediate: true })
const previewableSuffix = ['doc', 'docx', 'xls', 'xlsx', 'pdf', 'jpg', 'png', 'gif', 'svg', 'ico', 'tmp', 'jpeg']
const getFileIcon = (suffix: string) => {
const lower = suffix?.toLowerCase()
const iconMap: Record<string, string> = {
doc: '/src/assets/images/fileImg/docx.png',
docx: '/src/assets/images/fileImg/docx.png',
xls: '/src/assets/images/fileImg/xlsx.png',
xlsx: '/src/assets/images/fileImg/xlsx.png',
ppt: '/src/assets/images/fileImg/ppt.png',
pptx: '/src/assets/images/fileImg/ppt.png',
pdf: '/src/assets/images/fileImg/pdf.png',
txt: '/src/assets/images/fileImg/txt.png',
zip: '/src/assets/images/fileImg/zip.png',
rar: '/src/assets/images/fileImg/rar.png',
html: '/src/assets/images/fileImg/html.png'
}
return iconMap[lower] || '/src/assets/images/fileImg/file.png'
}
const getAttachmentThumbnail = (file: AttachmentItem) => {
if (file.thumbnail) {
return file.thumbnail
}
const imgSuffix = ['png', 'jpg', 'jpeg', 'gif', 'bmp']
if (imgSuffix.indexOf(file.suffix?.toLowerCase()) > -1) {
return file.downloadPath
}
return getFileIcon(file.suffix)
}
const canPreview = (file: AttachmentItem) => {
return previewableSuffix.indexOf(file.suffix?.toLowerCase()) > -1
}
const handlePreview = (file: AttachmentItem) => {
if (!canPreview(file)) {
message.warning('当前文件暂不支持在线预览')
return
}
previewVisible.value = true
nextTick(() => {
previewRef.value?.onOpen(file)
})
}
const handlePreviewClose = () => {
previewVisible.value = false
}
const handleAttachmentDelete = (file: AttachmentItem) => {
const param = {
id: file.id
}
Modal.confirm({
title: '确认删除该附件?',
content: `附件:${file.name}`,
okText: '确认',
cancelText: '取消',
onOk: () => {
projectFileApi
.deleteFile(param)
.then(() => {
message.success(`已确认删除:${file.name}`)
fetchAttachments()
})
},
onCancel: () => {
console.log('取消删除附件:', file)
}
})
}
</script>
<style scoped>
.project-title {
text-align: center;
margin-bottom: 10px;
padding: 16px;
}
.project-title h2 {
margin: 0;
color: #1890ff;
font-size: 24px;
font-weight: bold;
}
.project-info-card {
margin-bottom: 24px;
padding: 12px 16px;
}
.project-info-card :deep(.ant-card-body) {
padding: 12px 16px;
}
.compact-form :deep(.ant-form-item) {
margin-bottom: 0;
}
.compact-form :deep(.ant-row) {
margin-bottom: 12px;
}
.compact-form :deep(.ant-row:last-child) {
margin-bottom: 0;
}
.compact-form :deep(.ant-form-item-label) {
padding-bottom: 0;
width: 90px;
flex: 0 0 90px;
}
.compact-form :deep(.ant-form-item-label > label) {
height: 28px;
line-height: 28px;
font-size: 14px;
width: 90px;
text-align: right;
padding-right: 8px;
}
.compact-form :deep(.ant-form-item-control) {
line-height: 28px;
font-size: 14px;
flex: 1;
}
.compact-form :deep(.ant-form-item-control-input) {
min-height: 28px;
}
.compact-form :deep(.ant-form-item-control-input-content) {
line-height: 28px;
}
.planned-hours {
margin-top: 12px;
text-align: right;
padding-right: 24px;
}
.hours-label {
margin-right: 8px;
font-size: 14px;
}
.hours-value {
font-size: 24px;
font-weight: bold;
color: #1890ff;
margin-right: 16px;
}
.create-stage-btn {
margin-left: 16px;
}
.stage-tables {
margin-top: 24px;
}
.stage-card {
margin-bottom: 24px;
}
.stage-card :deep(.ant-card-head-title) {
text-align: center;
}
.stage-title-wrapper {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
position: relative;
}
.stage-title-wrapper > span {
flex: 1;
text-align: center;
}
.create-requirement-btn {
position: absolute;
right: 0;
}
.action-btn {
padding: 0 8px;
}
.attachments-section {
margin-top: 32px;
border-top: 1px solid #f0f0f0;
padding-top: 24px;
}
.attachments-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.attachments-title {
font-size: 18px;
font-weight: 600;
}
.attachment-list {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.attachment-item {
width: 120px;
text-align: center;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 12px;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.attachment-delete-btn {
position: absolute;
right: 4px;
top: 4px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
color: #999;
}
.attachment-delete-btn:hover {
color: #ff4d4f;
}
.attachment-item:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
.attachment-thumb {
width: 80px;
height: 80px;
margin: 0 auto 8px;
display: flex;
align-items: center;
justify-content: center;
}
.attachment-thumb img {
max-width: 80px;
max-height: 80px;
object-fit: contain;
}
.attachment-name {
font-size: 13px;
color: #333;
word-break: break-all;
}
.attachment-tag {
margin-top: 8px;
}
</style>

View File

@@ -0,0 +1,156 @@
<template>
<xn-form-container
title="文件上传"
:width="550"
:visible="visible"
:destroy-on-close="true"
:bodyStyle="{ 'padding-top': '0px' }"
@close="onClose"
>
<a-tabs v-model:activeKey="activeKey">
<a-tab-pane key="Local" tab="本地">
<a-spin :spinning="uploadLoading">
<a-upload-dragger :show-upload-list="false" :custom-request="customRequestLocal">
<p class="ant-upload-drag-icon">
<inbox-outlined></inbox-outlined>
</p>
<p class="ant-upload-text">单击或拖动文件到此区域进行上传</p>
<p class="ant-upload-hint">支持单个上传</p>
</a-upload-dragger>
</a-spin>
</a-tab-pane>
<a-tab-pane key="Aliyun" tab="阿里云">
<a-spin :spinning="uploadLoading">
<a-upload-dragger :custom-request="customRequestAliyun" :show-upload-list="false">
<p class="ant-upload-drag-icon">
<inbox-outlined></inbox-outlined>
</p>
<p class="ant-upload-text">单击或拖动文件到此区域进行上传</p>
<p class="ant-upload-hint">支持单个上传</p>
</a-upload-dragger>
</a-spin>
</a-tab-pane>
<a-tab-pane key="Tencent" tab="腾讯云">
<a-spin :spinning="uploadLoading">
<a-upload-dragger :custom-request="customRequestTencent" :show-upload-list="false">
<p class="ant-upload-drag-icon">
<inbox-outlined></inbox-outlined>
</p>
<p class="ant-upload-text">单击或拖动文件到此区域进行上传</p>
<p class="ant-upload-hint">支持单个上传</p>
</a-upload-dragger>
</a-spin>
</a-tab-pane>
<a-tab-pane key="Minio" tab="MINIO">
<a-spin :spinning="uploadLoading">
<a-upload-dragger :custom-request="customRequestMinio" :show-upload-list="false">
<p class="ant-upload-drag-icon">
<inbox-outlined></inbox-outlined>
</p>
<p class="ant-upload-text">单击或拖动文件到此区域进行上传</p>
<p class="ant-upload-hint">支持单个上传</p>
</a-upload-dragger>
</a-spin>
</a-tab-pane>
</a-tabs>
</xn-form-container>
</template>
<script setup name="uploadForm">
import fileApi from '@/api/dev/fileApi'
import projectFileApi from '@/api/pmm/projectFileApi'
// 定义emit事件
const emit = defineEmits({ successful: null })
// 默认是关闭状态
const visible = ref(false)
const activeKey = ref('Local')
const uploadLoading = ref(false)
const projectId = ref('')
// 打开抽屉
const openUpload = (record) => {
projectId.value = record
visible.value = true
}
// 关闭抽屉
const onClose = () => {
visible.value = false
projectId.value = ''
emit('successful')
}
// 上传本地文件
const customRequestLocal = (data) => {
uploadLoading.value = true
const fileData = new FormData()
fileData.append('file', data.file)
fileApi
.fileUploadLocalReturnUrl(fileData)
.then((res) => {
bindProjectFile(res).then(() => {
emit('successful')
})
})
.finally(() => {
uploadLoading.value = false
})
}
// 上传阿里云文件
const customRequestAliyun = (data) => {
uploadLoading.value = true
const fileData = new FormData()
fileData.append('file', data.file)
fileApi
.fileUploadAliyunReturnUrl(fileData)
.then((res) => {
bindProjectFile(res).then(() => {
emit('successful')
})
})
.finally(() => {
uploadLoading.value = false
})
}
// 上传腾讯文件
const customRequestTencent = (data) => {
uploadLoading.value = true
const fileData = new FormData()
fileData.append('file', data.file)
fileApi
.fileUploadTencentReturnUrl(fileData)
.then((res) => {
bindProjectFile(res).then(() => {
emit('successful')
})
})
.finally(() => {
uploadLoading.value = false
})
}
// 上传Minio文件
const customRequestMinio = (data) => {
uploadLoading.value = true
const fileData = new FormData()
fileData.append('file', data.file)
fileApi
.fileUploadMinioReturnUrl(fileData)
.then((res) => {
bindProjectFile(res).then(() => {
emit('successful')
})
})
.finally(() => {
uploadLoading.value = false
})
}
// 绑定项目与文件
const bindProjectFile = (fileUrl) => {
const param = {
projectId: projectId.value,
fileAddress: fileUrl
}
return projectFileApi.bindProjectFile(param)
}
// 调用这个函数将子组件的一些数据和方法暴露出去
defineExpose({
openUpload
})
</script>

View File

@@ -0,0 +1,102 @@
<template>
<a-card :bordered="false">
<s-table
ref="tableRef"
:columns="columns"
:data="loadData"
:alert="options.alert.show"
bordered
:row-key="(record) => record.projectId"
:tool-config="toolConfig"
:row-selection="options.rowSelection"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'projectName'">
<a @click="toDescriptionWeb(record)">{{ record.projectName }}</a>
</template>
</template>
</s-table>
</a-card>
</template>
<script setup name="projectRequest">
import { useRouter } from 'vue-router'
import projectApi from '@/api/pmm/projectApi'
const router = useRouter()
const tableRef = ref()
const formRef = ref()
const toolConfig = { refresh: true, height: true, columnSetting: true, striped: false }
const columns = [
{
title: '项目编码',
dataIndex: 'projectCode'
},
{
title: '项目名称',
dataIndex: 'projectName'
},
{
title: '项目经理',
dataIndex: 'projectManage'
},
{
title: '产品需求',
dataIndex: 'requireManage'
},
{
title: '开发组',
dataIndex: 'devGroup'
},
{
title: '开始时间',
dataIndex: 'startTime'
},
{
title: '结束时间',
dataIndex: 'endTime'
},
]
// 操作栏通过权限判断是否显示
// if (hasPerm(['projectEdit', 'projectDelete'])) {
// columns.push({
// title: '操作',
// dataIndex: 'action',
// align: 'center',
// width: 150
// })
// }
const selectedRowKeys = ref([])
// 列表选择配置
const options = {
// columns数字类型字段加入 needTotal: true 可以勾选自动算账
alert: {
show: false,
clear: () => {
selectedRowKeys.value = ref([])
}
},
rowSelection: {
onChange: (selectedRowKey, selectedRows) => {
selectedRowKeys.value = selectedRowKey
}
}
}
const loadData = (parameter) => {
return projectApi.projectPage(parameter).then((data) => {
return data
})
}
// 重置
const reset = () => {
searchFormRef.value.resetFields()
tableRef.value.refresh(true)
}
const toDescriptionWeb = (item) => {
router.push({
path: '/pmm/project/request/description',
query: {
item: encodeURIComponent(JSON.stringify(item))
}
})
}
</script>

View File

@@ -0,0 +1,86 @@
<template>
<xn-form-container
:title="formData.stageId ? '编辑项目阶段' : '增加项目阶段'"
:width="700"
v-model:open="open"
:destroy-on-close="true"
@close="onClose"
>
<a-form ref="formRef" :model="formData" :rules="formRules" layout="horizontal">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="阶段名称:" name="stageName">
<a-input v-model:value="formData.stageName" placeholder="请输入阶段名称" allow-clear />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="阶段序号:" name="stageSeq">
<a-input-number
v-model:value="formData.stageSeq"
placeholder="请输入阶段序号"
style="width: 100%"
allow-clear
:min="1"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
<template #footer>
<a-button style="margin-right: 8px" @click="onClose">关闭</a-button>
<a-button type="primary" @click="onSubmit" :loading="submitLoading">保存</a-button>
</template>
</xn-form-container>
</template>
<script setup name="projectStageForm">
import { cloneDeep } from 'lodash-es'
import { required } from '@/utils/formRules'
import projectStageApi from '@/api/pmm/projectStageApi'
// 抽屉状态
const open = ref(false)
const emit = defineEmits({ successful: null })
const formRef = ref()
// 表单数据
const formData = ref({})
const submitLoading = ref(false)
// 打开抽屉
const onOpen = (record) => {
open.value = true
if (record) {
formData.value.projectId = record
}
}
// 关闭抽屉
const onClose = () => {
formRef.value.resetFields()
formData.value = {}
open.value = false
}
// 默认要校验的
const formRules = {}
// 验证并提交数据
const onSubmit = () => {
formRef.value
.validate()
.then(() => {
submitLoading.value = true
const formDataParam = cloneDeep(formData.value)
projectStageApi
.projectStageSubmitForm(formDataParam, formDataParam.stageId)
.then(() => {
onClose()
emit('successful')
})
.finally(() => {
submitLoading.value = false
})
})
.catch(() => {})
}
// 抛出函数
defineExpose({
onOpen
})
</script>