From e96383433d592d9aba80224b00d7e3a8b3434ab5 Mon Sep 17 00:00:00 2001 From: huangping Date: Mon, 25 May 2026 14:46:30 +0800 Subject: [PATCH] feat(m1): add project stakeholder CRUD --- .../project/PlatformProjectStakeholder.java | 98 ++++++++++ .../PlatformProjectStakeholderMapper.java | 7 + .../api/project/ProjectController.java | 34 +++- .../platform/api/service/ProjectService.java | 76 +++++++- .../api/web/dto/StakeholderRequest.java | 62 ++++++ .../api/web/dto/StakeholderResponse.java | 79 ++++++++ .../db/migration/V17__project_stakeholder.sql | 11 ++ web/delivery-platform-ui/src/api/platform.js | 13 ++ .../src/views/ProjectsView.vue | 179 +++++++++++++++++- 9 files changed, 553 insertions(+), 6 deletions(-) create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/project/PlatformProjectStakeholder.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/project/PlatformProjectStakeholderMapper.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/StakeholderRequest.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/StakeholderResponse.java create mode 100644 services/delivery-platform-api/src/main/resources/db/migration/V17__project_stakeholder.sql diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/project/PlatformProjectStakeholder.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/project/PlatformProjectStakeholder.java new file mode 100644 index 0000000..b02febe --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/project/PlatformProjectStakeholder.java @@ -0,0 +1,98 @@ +package cn.craftlabs.platform.api.persistence.project; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.time.OffsetDateTime; + +@TableName("platform_project_stakeholder") +public class PlatformProjectStakeholder { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("project_id") + private Long projectId; + + @TableField("contact_name") + private String contactName; + + @TableField("contact_role") + private String contactRole; + + private String phone; + + private String email; + + @TableField("is_internal") + private Boolean isInternal; + + @TableField("created_at") + private OffsetDateTime createdAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getProjectId() { + return projectId; + } + + public void setProjectId(Long projectId) { + this.projectId = projectId; + } + + public String getContactName() { + return contactName; + } + + public void setContactName(String contactName) { + this.contactName = contactName; + } + + public String getContactRole() { + return contactRole; + } + + public void setContactRole(String contactRole) { + this.contactRole = contactRole; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public Boolean getIsInternal() { + return isInternal; + } + + public void setIsInternal(Boolean isInternal) { + this.isInternal = isInternal; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/project/PlatformProjectStakeholderMapper.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/project/PlatformProjectStakeholderMapper.java new file mode 100644 index 0000000..2f56565 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/project/PlatformProjectStakeholderMapper.java @@ -0,0 +1,7 @@ +package cn.craftlabs.platform.api.persistence.project; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PlatformProjectStakeholderMapper extends BaseMapper {} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/project/ProjectController.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/project/ProjectController.java index 74ba200..f120545 100644 --- a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/project/ProjectController.java +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/project/ProjectController.java @@ -4,6 +4,8 @@ import cn.craftlabs.platform.api.service.ProjectService; import cn.craftlabs.platform.api.web.dto.PageResponse; import cn.craftlabs.platform.api.web.dto.ProjectRequest; import cn.craftlabs.platform.api.web.dto.ProjectResponse; +import cn.craftlabs.platform.api.web.dto.StakeholderRequest; +import cn.craftlabs.platform.api.web.dto.StakeholderResponse; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; @@ -20,7 +22,8 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; -/** 项目 API。{@code DELETE /{id}} 为物理删除。 */ +import java.util.List; + @RestController @RequestMapping("/api/v1/projects") @Validated @@ -62,4 +65,33 @@ public class ProjectController { public void delete(@PathVariable("id") long id) { projectService.delete(id); } + + @GetMapping("/{projectId}/stakeholders") + public List listStakeholders(@PathVariable("projectId") long projectId) { + return projectService.listStakeholders(projectId); + } + + @PostMapping("/{projectId}/stakeholders") + @ResponseStatus(HttpStatus.CREATED) + public StakeholderResponse addStakeholder( + @PathVariable("projectId") long projectId, + @Valid @RequestBody StakeholderRequest request) { + return projectService.addStakeholder(projectId, request); + } + + @PutMapping("/{projectId}/stakeholders/{id}") + public StakeholderResponse updateStakeholder( + @PathVariable("projectId") long projectId, + @PathVariable("id") long id, + @Valid @RequestBody StakeholderRequest request) { + return projectService.updateStakeholder(id, request); + } + + @DeleteMapping("/{projectId}/stakeholders/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteStakeholder( + @PathVariable("projectId") long projectId, + @PathVariable("id") long id) { + projectService.deleteStakeholder(id); + } } diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/ProjectService.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/ProjectService.java index bf2cae0..17c4adf 100644 --- a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/ProjectService.java +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/ProjectService.java @@ -2,9 +2,13 @@ package cn.craftlabs.platform.api.service; import cn.craftlabs.platform.api.persistence.project.PlatformProject; import cn.craftlabs.platform.api.persistence.project.PlatformProjectMapper; +import cn.craftlabs.platform.api.persistence.project.PlatformProjectStakeholder; +import cn.craftlabs.platform.api.persistence.project.PlatformProjectStakeholderMapper; import cn.craftlabs.platform.api.web.dto.PageResponse; import cn.craftlabs.platform.api.web.dto.ProjectRequest; import cn.craftlabs.platform.api.web.dto.ProjectResponse; +import cn.craftlabs.platform.api.web.dto.StakeholderRequest; +import cn.craftlabs.platform.api.web.dto.StakeholderResponse; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; @@ -26,10 +30,12 @@ public class ProjectService { private static final String DEFAULT_PHASE = "PLANNING"; private final PlatformProjectMapper projectMapper; + private final PlatformProjectStakeholderMapper stakeholderMapper; private final CustomerService customerService; - public ProjectService(PlatformProjectMapper projectMapper, CustomerService customerService) { + public ProjectService(PlatformProjectMapper projectMapper, PlatformProjectStakeholderMapper stakeholderMapper, CustomerService customerService) { this.projectMapper = projectMapper; + this.stakeholderMapper = stakeholderMapper; this.customerService = customerService; } @@ -101,6 +107,74 @@ public class ProjectService { } } + @Transactional(readOnly = true) + public List listStakeholders(long projectId) { + LambdaQueryWrapper q = + Wrappers.lambdaQuery(PlatformProjectStakeholder.class) + .eq(PlatformProjectStakeholder::getProjectId, projectId) + .orderByAsc(PlatformProjectStakeholder::getId); + return stakeholderMapper.selectList(q).stream() + .map(this::toStakeholderResponse) + .collect(Collectors.toList()); + } + + @Transactional + public StakeholderResponse addStakeholder(long projectId, StakeholderRequest request) { + requireProjectExists(projectId); + PlatformProjectStakeholder s = new PlatformProjectStakeholder(); + s.setProjectId(projectId); + s.setContactName(request.getContactName().trim()); + s.setContactRole(request.getContactRole()); + s.setPhone(request.getPhone()); + s.setEmail(request.getEmail()); + s.setIsInternal(request.getIsInternal() != null && request.getIsInternal()); + s.setCreatedAt(OffsetDateTime.now(ZoneOffset.UTC)); + stakeholderMapper.insert(s); + return toStakeholderResponse(s); + } + + @Transactional + public StakeholderResponse updateStakeholder(long stakeholderId, StakeholderRequest request) { + PlatformProjectStakeholder s = stakeholderMapper.selectById(stakeholderId); + if (s == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "stakeholder not found"); + } + s.setContactName(request.getContactName().trim()); + s.setContactRole(request.getContactRole()); + s.setPhone(request.getPhone()); + s.setEmail(request.getEmail()); + s.setIsInternal(request.getIsInternal() != null && request.getIsInternal()); + stakeholderMapper.updateById(s); + return toStakeholderResponse(s); + } + + @Transactional + public void deleteStakeholder(long stakeholderId) { + int rows = stakeholderMapper.deleteById(stakeholderId); + if (rows == 0) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "stakeholder not found"); + } + } + + private void requireProjectExists(long projectId) { + if (projectMapper.selectById(projectId) == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "project not found"); + } + } + + private StakeholderResponse toStakeholderResponse(PlatformProjectStakeholder s) { + StakeholderResponse r = new StakeholderResponse(); + r.setId(s.getId()); + r.setProjectId(s.getProjectId()); + r.setContactName(s.getContactName()); + r.setContactRole(s.getContactRole()); + r.setPhone(s.getPhone()); + r.setEmail(s.getEmail()); + r.setIsInternal(s.getIsInternal()); + r.setCreatedAt(s.getCreatedAt()); + return r; + } + private String resolvePhase(String phase) { return StringUtils.hasText(phase) ? phase.trim() : DEFAULT_PHASE; } diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/StakeholderRequest.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/StakeholderRequest.java new file mode 100644 index 0000000..e134458 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/StakeholderRequest.java @@ -0,0 +1,62 @@ +package cn.craftlabs.platform.api.web.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public class StakeholderRequest { + + @NotBlank + @Size(max = 128) + private String contactName; + + @Size(max = 64) + private String contactRole; + + @Size(max = 32) + private String phone; + + @Size(max = 128) + private String email; + + private Boolean isInternal; + + public String getContactName() { + return contactName; + } + + public void setContactName(String contactName) { + this.contactName = contactName; + } + + public String getContactRole() { + return contactRole; + } + + public void setContactRole(String contactRole) { + this.contactRole = contactRole; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public Boolean getIsInternal() { + return isInternal; + } + + public void setIsInternal(Boolean isInternal) { + this.isInternal = isInternal; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/StakeholderResponse.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/StakeholderResponse.java new file mode 100644 index 0000000..15f1662 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/StakeholderResponse.java @@ -0,0 +1,79 @@ +package cn.craftlabs.platform.api.web.dto; + +import java.time.OffsetDateTime; + +public class StakeholderResponse { + + private Long id; + private Long projectId; + private String contactName; + private String contactRole; + private String phone; + private String email; + private Boolean isInternal; + private OffsetDateTime createdAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getProjectId() { + return projectId; + } + + public void setProjectId(Long projectId) { + this.projectId = projectId; + } + + public String getContactName() { + return contactName; + } + + public void setContactName(String contactName) { + this.contactName = contactName; + } + + public String getContactRole() { + return contactRole; + } + + public void setContactRole(String contactRole) { + this.contactRole = contactRole; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public Boolean getIsInternal() { + return isInternal; + } + + public void setIsInternal(Boolean isInternal) { + this.isInternal = isInternal; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/services/delivery-platform-api/src/main/resources/db/migration/V17__project_stakeholder.sql b/services/delivery-platform-api/src/main/resources/db/migration/V17__project_stakeholder.sql new file mode 100644 index 0000000..09c45eb --- /dev/null +++ b/services/delivery-platform-api/src/main/resources/db/migration/V17__project_stakeholder.sql @@ -0,0 +1,11 @@ +CREATE TABLE platform_project_stakeholder ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL REFERENCES platform_project(id), + contact_name VARCHAR(128) NOT NULL, + contact_role VARCHAR(64), + phone VARCHAR(32), + email VARCHAR(128), + is_internal BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX idx_stakeholder_project ON platform_project_stakeholder(project_id); diff --git a/web/delivery-platform-ui/src/api/platform.js b/web/delivery-platform-ui/src/api/platform.js index 8f5061b..10d0a47 100644 --- a/web/delivery-platform-ui/src/api/platform.js +++ b/web/delivery-platform-ui/src/api/platform.js @@ -437,3 +437,16 @@ export function updateJsonTemplate(id, body) { export function deleteJsonTemplate(id) { return axios.delete(`/api/v1/integration/json-templates/${id}`); } + +export function listStakeholders(projectId) { + return axios.get(`/api/v1/projects/${projectId}/stakeholders`); +} +export function addStakeholder(projectId, body) { + return axios.post(`/api/v1/projects/${projectId}/stakeholders`, body); +} +export function updateStakeholder(projectId, id, body) { + return axios.put(`/api/v1/projects/${projectId}/stakeholders/${id}`, body); +} +export function deleteStakeholder(projectId, id) { + return axios.delete(`/api/v1/projects/${projectId}/stakeholders/${id}`); +} diff --git a/web/delivery-platform-ui/src/views/ProjectsView.vue b/web/delivery-platform-ui/src/views/ProjectsView.vue index 8f2b7cc..edb4658 100644 --- a/web/delivery-platform-ui/src/views/ProjectsView.vue +++ b/web/delivery-platform-ui/src/views/ProjectsView.vue @@ -21,7 +21,7 @@ /> 查询 - 新建项目 + 新建项目 @@ -38,10 +38,11 @@ {{ phaseLabel(row.phase) }} - + @@ -100,6 +101,50 @@ 保存 + + 添加干系人 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -114,6 +159,10 @@ import { updateProject, deleteProject, getProjectPhaseDictionary, + listStakeholders, + addStakeholder, + updateStakeholder, + deleteStakeholder, } from "../api/platform"; import { apiErrorMessage } from "../utils/apiErrorMessage"; @@ -151,6 +200,128 @@ const rules = { const dialogTitle = computed(() => (editingId.value ? "编辑项目" : "新建项目")); +// —— 干系人 —————————————————————————————————————————— +const stakeholderVisible = ref(false); +const stakeholderFormVisible = ref(false); +const stakeholderSaving = ref(false); +const stakeholderRows = ref([]); +const stakeholderProjectId = ref(null); +const stakeholderEditingId = ref(null); +const stakeholderFormRef = ref(null); +const stakeholderForm = reactive({ + contactName: "", + contactRole: "", + phone: "", + email: "", + isInternal: false, +}); + +const stakeholderRules = { + contactName: [{ required: true, message: "请输入姓名", trigger: "blur" }], +}; + +const stakeholderTitle = computed(() => { + const p = rows.value.find((r) => r.id === stakeholderProjectId.value); + return `干系人 - ${p?.name ?? stakeholderProjectId.value}`; +}); + +const stakeholderFormTitle = computed(() => + stakeholderEditingId.value ? "编辑干系人" : "添加干系人" +); + +function openStakeholderDialog(row) { + stakeholderProjectId.value = row.id; + stakeholderVisible.value = true; + loadStakeholders(); +} + +async function loadStakeholders() { + try { + const { data } = await listStakeholders(stakeholderProjectId.value); + stakeholderRows.value = Array.isArray(data) ? data : []; + } catch (e) { + ElMessage.error(apiErrorMessage(e, "加载干系人列表失败")); + stakeholderRows.value = []; + } +} + +function openStakeholderAdd() { + stakeholderEditingId.value = null; + resetStakeholderForm(); + stakeholderFormVisible.value = true; +} + +function openStakeholderEdit(row) { + stakeholderEditingId.value = row.id; + stakeholderForm.contactName = row.contactName ?? ""; + stakeholderForm.contactRole = row.contactRole ?? ""; + stakeholderForm.phone = row.phone ?? ""; + stakeholderForm.email = row.email ?? ""; + stakeholderForm.isInternal = row.isInternal ?? false; + stakeholderFormVisible.value = true; +} + +function resetStakeholderForm() { + stakeholderForm.contactName = ""; + stakeholderForm.contactRole = ""; + stakeholderForm.phone = ""; + stakeholderForm.email = ""; + stakeholderForm.isInternal = false; + stakeholderFormRef.value?.resetFields?.(); +} + +async function submitStakeholder() { + const f = stakeholderFormRef.value; + if (!f) return; + try { + await f.validate(); + } catch { + return; + } + stakeholderSaving.value = true; + const payload = { + contactName: stakeholderForm.contactName.trim(), + contactRole: stakeholderForm.contactRole || null, + phone: stakeholderForm.phone || null, + email: stakeholderForm.email || null, + isInternal: stakeholderForm.isInternal, + }; + try { + const pid = stakeholderProjectId.value; + if (stakeholderEditingId.value != null) { + await updateStakeholder(pid, stakeholderEditingId.value, payload); + ElMessage.success("已保存"); + } else { + await addStakeholder(pid, payload); + ElMessage.success("已添加"); + } + stakeholderFormVisible.value = false; + await loadStakeholders(); + } catch (e) { + ElMessage.error(apiErrorMessage(e, "保存失败")); + } finally { + stakeholderSaving.value = false; + } +} + +function onStakeholderDelete(row) { + ElMessageBox.confirm(`确定删除干系人「${row.contactName || row.id}」吗?`, "提示", { + type: "warning", + confirmButtonText: "删除", + cancelButtonText: "取消", + }) + .then(async () => { + try { + await deleteStakeholder(stakeholderProjectId.value, row.id); + ElMessage.success("已删除"); + await loadStakeholders(); + } catch (e) { + ElMessage.error(apiErrorMessage(e, "删除失败")); + } + }) + .catch(() => {}); +} + const customerMap = computed(() => { const m = new Map(); for (const c of customerOptions.value) {