feat(m1): add project stakeholder CRUD

This commit is contained in:
2026-05-25 14:46:30 +08:00
parent 4bbf1f552f
commit e96383433d
9 changed files with 553 additions and 6 deletions
@@ -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;
}
}
@@ -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<PlatformProjectStakeholder> {}
@@ -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}} 为<strong>物理删除</strong>。 */
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<StakeholderResponse> 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);
}
}
@@ -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<StakeholderResponse> listStakeholders(long projectId) {
LambdaQueryWrapper<PlatformProjectStakeholder> 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;
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
@@ -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}`);
}
@@ -21,7 +21,7 @@
/>
</el-select>
<el-button type="primary" :loading="loading" @click="load">查询</el-button>
<el-button type="success" @click="openCreate">新建项目</el-button>
<el-button v-permission="'project:rw'" type="success" @click="openCreate">新建项目</el-button>
</div>
</div>
</template>
@@ -38,10 +38,11 @@
{{ phaseLabel(row.phase) }}
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="openEdit(row)">编辑</el-button>
<el-button type="danger" link @click="onDelete(row)">删除</el-button>
<el-button type="primary" link @click="openStakeholderDialog(row)">干系人</el-button>
<el-button v-permission="'project:rw'" type="primary" link @click="openEdit(row)">编辑</el-button>
<el-button v-permission="'project:delete'" type="danger" link @click="onDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -100,6 +101,50 @@
<el-button type="primary" :loading="saving" @click="submit">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="stakeholderVisible" :title="stakeholderTitle" width="700px" destroy-on-close>
<el-button type="primary" size="small" style="margin-bottom: 12px" @click="openStakeholderAdd">添加干系人</el-button>
<el-table :data="stakeholderRows" stripe style="width: 100%">
<el-table-column prop="contactName" label="姓名" min-width="100" show-overflow-tooltip />
<el-table-column prop="contactRole" label="角色" min-width="100" show-overflow-tooltip />
<el-table-column prop="phone" label="电话" min-width="130" />
<el-table-column prop="email" label="邮箱" min-width="160" show-overflow-tooltip />
<el-table-column label="内部人员" width="90">
<template #default="{ row }">
{{ row.isInternal ? '是' : '否' }}
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="openStakeholderEdit(row)">编辑</el-button>
<el-button type="danger" link @click="onStakeholderDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
<el-dialog v-model="stakeholderFormVisible" :title="stakeholderFormTitle" width="500px" destroy-on-close @closed="resetStakeholderForm">
<el-form ref="stakeholderFormRef" :model="stakeholderForm" :rules="stakeholderRules" label-width="100px">
<el-form-item label="姓名" prop="contactName">
<el-input v-model="stakeholderForm.contactName" maxlength="128" placeholder="请输入姓名" />
</el-form-item>
<el-form-item label="角色" prop="contactRole">
<el-input v-model="stakeholderForm.contactRole" maxlength="64" placeholder="请输入角色" />
</el-form-item>
<el-form-item label="电话" prop="phone">
<el-input v-model="stakeholderForm.phone" maxlength="32" placeholder="请输入电话" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="stakeholderForm.email" maxlength="128" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="内部人员">
<el-switch v-model="stakeholderForm.isInternal" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="stakeholderFormVisible = false">取消</el-button>
<el-button type="primary" :loading="stakeholderSaving" @click="submitStakeholder">保存</el-button>
</template>
</el-dialog>
</el-card>
</template>
@@ -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) {