肖栋 13 годин тому
батько
коміт
5e96b93ef3

+ 5 - 0
src/api/project.js

@@ -42,6 +42,11 @@ export function getProjectProgress(projectId) {
   return get(`/api/project/progress/${projectId}`);
 }
 
+// 保存项目进展
+export function saveProjectProgress(data) {
+  return post("/api/project/progress", data);
+}
+
 // 项目概览统计卡片
 export function getOverviewStats() {
   return get("/api/project/overview/stats");

Різницю між файлами не показано, бо вона завелика
+ 0 - 0
src/assets/icons/project_add.svg


+ 2 - 0
src/components/layout/AppSidebar.vue

@@ -53,6 +53,8 @@ const menuGroups = [
 ];
 
 const activeKey = computed(() => {
+  const menuKey = route.meta?.menuKey;
+  if (menuKey) return menuKey;
   const name = route.name;
   for (const group of menuGroups) {
     const matched = group.children.find((item) => item.routeName === name);

+ 16 - 2
src/pages/home/particulars.vue

@@ -11,7 +11,7 @@
           导出Word
         </button>
         <button type="button" class="page-particulars-btn is-primary" @click="goCreateProject">
-          <icon-svg name="" size="16px" />
+          <icon-svg name="project_add" size="16px" />
           <span>新增项目</span>
         </button>
       </div>
@@ -160,7 +160,11 @@
                       <icon-svg name="edit_blue" size="16px" />
                       <span>明细</span>
                     </button>
-                    <button type="button" class="page-particulars-op-btn">
+                    <button
+                      type="button"
+                      class="page-particulars-op-btn"
+                      @click="goProjectProgress(row.project)"
+                    >
                       <icon-svg name="edit_blue" size="16px" />
                       <span>进展</span>
                     </button>
@@ -368,6 +372,16 @@ const goProjectDetail = (project) => {
   });
 };
 
+const goProjectProgress = (project) => {
+  if (project?.id == null) return;
+  router.push({
+    name: "projectProgress",
+    query: {
+      projectId: String(project.id),
+    },
+  });
+};
+
 watch(listFilter, () => {
   loadDetailList();
 });

+ 36 - 9
src/pages/home/project-detail.vue

@@ -5,7 +5,7 @@
       <button type="button" class="page-project-detail-crumb" @click="goBack">
         {{ parentName }}
       </button>
-      <span class="page-project-detail-crumb-sep">/</span>
+      <span class="page-project-detail-crumb-sep">></span>
       <span class="page-project-detail-crumb is-current">{{
         isCreate ? "新建项目" : "项目明细"
       }}</span>
@@ -105,6 +105,7 @@
           <el-select
             v-model="form.projectManagerId"
             placeholder="项目经理"
+            :disabled="!canEdit"
             style="width: 200px"
           >
             <el-option
@@ -120,6 +121,7 @@
           <el-select
             v-model="form.deptManagerId"
             placeholder="科室负责人"
+            :disabled="!canEdit"
             style="width: 200px"
           >
             <el-option
@@ -135,6 +137,7 @@
           <el-select
             v-model="form.devManagerId"
             placeholder="开发部负责人"
+            :disabled="!canEdit"
             style="width: 200px"
           >
             <el-option
@@ -147,7 +150,11 @@
         </div>
         <div class="page-project-detail-field is-full">
           <label>环境风险:</label>
-          <input v-model="form.environmentalRisk" type="text" />
+          <input
+            v-model="form.environmentalRisk"
+            type="text"
+            :disabled="!canEdit"
+          />
         </div>
       </div>
     </div>
@@ -168,7 +175,7 @@
         </span>
         <div class="page-project-detail-phase-header-actions">
           <button
-            v-if="isCreate && !phase.isReview"
+            v-if="canEdit && !phase.isReview"
             type="button"
             class="page-project-detail-add-btn"
             @click="addReportRow(phase)"
@@ -176,7 +183,7 @@
             + 添加报告
           </button>
           <button
-            v-if="!phase.isReview"
+            v-if="canEdit && !phase.isReview"
             type="button"
             class="page-project-detail-del"
             @click="removePhaseRows(phase)"
@@ -225,6 +232,7 @@
                   v-model="row.reportName"
                   type="text"
                   :placeholder="isCreate ? '输入报告名称' : ''"
+                  :disabled="!canEdit"
                   @input="syncReportName(phase.rows, rowIndex)"
                 />
               </td>
@@ -236,11 +244,12 @@
                     value-format="YYYY-MM-DD"
                     :placeholder="isCreate ? '选择开始时间' : '选择日期'"
                     :clearable="false"
+                    :disabled="!canEdit"
                     style="width: 100%"
                     @change="updatePlannedEndDate(row)"
                   />
                   <button
-                    v-if="isCreate"
+                    v-if="canEdit"
                     type="button"
                     class="page-project-detail-sub is-edit"
                     @click="openNameDialog(row, 'reportStartName')"
@@ -258,6 +267,7 @@
                   v-model="row.duration"
                   type="text"
                   :placeholder="isCreate ? '工期' : ''"
+                  :disabled="!canEdit"
                   @input="updatePlannedEndDate(row)"
                 />
               </td>
@@ -269,6 +279,7 @@
                   :placeholder="isCreate ? '自动获取完成时间' : '选择日期'"
                   :clearable="false"
                   :editable="!isCreate"
+                  :disabled="!canEdit"
                   :class="{ 'is-locked': isCreate }"
                   style="width: 100%"
                 />
@@ -281,10 +292,11 @@
                     value-format="YYYY-MM-DD"
                     :placeholder="isCreate ? '选择实际完成时间' : '选择日期'"
                     :clearable="false"
+                    :disabled="!canEdit"
                     style="width: 100%"
                   />
                   <button
-                    v-if="isCreate"
+                    v-if="canEdit"
                     type="button"
                     class="page-project-detail-sub is-edit"
                     @click="openNameDialog(row, 'reportEndName')"
@@ -302,6 +314,7 @@
                   v-model="row.reportUnit"
                   type="text"
                   :placeholder="isCreate ? '输入报告编制单位' : ''"
+                  :disabled="!canEdit"
                 />
               </td>
               <td>
@@ -309,6 +322,7 @@
                   v-model="row.personInCharge"
                   type="text"
                   :placeholder="isCreate ? '输入负责人' : ''"
+                  :disabled="!canEdit"
                 />
               </td>
               <td>
@@ -316,10 +330,12 @@
                   v-model="row.remarks"
                   type="text"
                   :placeholder="isCreate ? '输入备注' : ''"
+                  :disabled="!canEdit"
                 />
               </td>
               <td class="page-project-detail-op">
                 <button
+                  v-if="canEdit"
                   type="button"
                   class="page-project-detail-del"
                   @click="removeFeasibilityRow(phase, rowIndex)"
@@ -331,7 +347,7 @@
           </tbody>
         </table>
         <button
-          v-if="isCreate && Number(phase.phaseType) === 5"
+          v-if="canEdit && Number(phase.phaseType) === 5"
           type="button"
           class="page-project-detail-add-time"
           @click="addTimePhase(phase)"
@@ -347,7 +363,7 @@
             <input
               type="checkbox"
               v-model="phase.isCompleted"
-              :disabled="!isCreate"
+              :disabled="!canEdit"
             />
             <icon-svg
               :name="phase.isCompleted ? 'state_off' : 'state_on'"
@@ -364,6 +380,7 @@
             value-format="YYYY-MM-DD"
             placeholder="选择日期"
             :clearable="false"
+            :disabled="!canEdit"
             style="width: 200px"
           />
         </div>
@@ -379,7 +396,12 @@
       >
         返回
       </button>
-      <button type="button" class="page-project-detail-btn is-primary" @click="onSave">
+      <button
+        v-if="canEdit"
+        type="button"
+        class="page-project-detail-btn is-primary"
+        @click="onSave"
+      >
         {{ isCreate ? "保存" : "提交" }}
       </button>
     </div>
@@ -470,6 +492,9 @@ const parentName = computed(() =>
 );
 
 const isCreate = computed(() => route.query.mode === "create");
+const canEdit = computed(
+  () => isCreate.value || form.statusKey !== "done"
+);
 
 const form = reactive({
   projectId: "",
@@ -494,6 +519,7 @@ const nameDialogValue = ref("");
 const nameDialogTarget = ref(null);
 
 const openNameDialog = (row, field) => {
+  if (!canEdit.value) return;
   nameDialogTarget.value = { row, field };
   nameDialogValue.value = row[field] || "";
   nameDialogVisible.value = true;
@@ -855,6 +881,7 @@ const buildSavePayload = () => {
 };
 
 const onSave = async () => {
+  if (!canEdit.value) return;
   try {
     const res = await saveProject(buildSavePayload());
     if (res?.code !== 200) {

+ 437 - 0
src/pages/home/project-progress.vue

@@ -0,0 +1,437 @@
+<template>
+  <div class="page-project-progress">
+    <div class="page-project-progress-body">
+      <div class="page-project-progress-breadcrumb">
+        <button type="button" class="page-project-progress-crumb" @click="goBack">
+          项目明细
+        </button>
+        <span class="page-project-progress-crumb-sep">></span>
+        <span class="page-project-progress-crumb is-current">项目进展编辑</span>
+      </div>
+
+      <div class="page-project-progress-panel">
+        <h2 class="page-project-progress-title">
+          {{ projectName || "—" }}
+        </h2>
+
+        <div class="page-project-progress-tabs">
+          <button
+            v-for="section in sections"
+            :key="section.sectionId"
+            type="button"
+            class="page-project-progress-tab"
+            :class="{ 'is-active': activeSectionId === section.sectionId }"
+            @click="activeSectionId = section.sectionId"
+          >
+            {{ section.sectionName }}
+          </button>
+        </div>
+
+        <div class="page-project-progress-editor">
+          <div class="page-project-progress-toolbar">
+            <div class="page-project-progress-toolbar-left">
+              <button type="button" @click="exec('undo')">↺</button>
+              <button type="button" @click="exec('redo')">↻</button>
+              <button type="button" class="is-mark" @click="exec('bold')">
+                B
+              </button>
+              <button type="button" class="is-mark" @click="exec('italic')">
+                I
+              </button>
+              <button type="button" class="is-mark" @click="exec('underline')">
+                U
+              </button>
+              <span class="page-project-progress-size">16</span>
+              <span
+                v-for="color in colors"
+                :key="color"
+                class="page-project-progress-color"
+                :style="{ background: color }"
+                @click="exec('foreColor', color)"
+              ></span>
+            </div>
+            <button type="button" class="page-project-progress-clear" @click="clearContent">
+              清除
+            </button>
+          </div>
+          <div
+            ref="editorRef"
+            class="page-project-progress-content"
+            contenteditable="true"
+            @input="onInput"
+          ></div>
+        </div>
+      </div>
+    </div>
+
+    <div class="page-project-progress-actions">
+      <button
+        type="button"
+        class="page-project-progress-btn is-outline"
+        @click="goBack"
+      >
+        返回
+      </button>
+      <button
+        type="button"
+        class="page-project-progress-btn is-primary"
+        @click="onSave"
+      >
+        保存
+      </button>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { nextTick, ref, watch } from "vue";
+import { useRoute, useRouter } from "vue-router";
+import { ElMessage } from "element-plus";
+import { getProjectProgress, saveProjectProgress } from "@/api/project";
+
+defineOptions({
+  name: "ProjectProgress",
+});
+
+const colors = ["#16213a", "#f54848", "#ff8e38", "#10b837", "#2382e7"];
+
+const route = useRoute();
+const router = useRouter();
+const editorRef = ref(null);
+const projectName = ref("");
+const sections = ref([]);
+const activeSectionId = ref(null);
+
+const getSectionContent = (section) => {
+  if (!section) return "";
+  const progress = section.progress;
+  if (progress && typeof progress === "object") return progress.content || "";
+  return "";
+};
+
+const setActiveContent = (html) => {
+  const current = sections.value.find(
+    (item) => item.sectionId === activeSectionId.value
+  );
+  if (!current) return;
+  current.progress = {
+    ...(current.progress && typeof current.progress === "object"
+      ? current.progress
+      : {}),
+    content: html,
+  };
+};
+
+const exec = (command, value = null) => {
+  editorRef.value?.focus();
+  document.execCommand(command, false, value);
+};
+
+const onInput = () => {
+  setActiveContent(editorRef.value?.innerHTML || "");
+};
+
+const clearContent = () => {
+  if (!editorRef.value) return;
+  editorRef.value.innerHTML = "";
+  setActiveContent("");
+};
+
+const syncEditor = async () => {
+  await nextTick();
+  const current = sections.value.find(
+    (item) => item.sectionId === activeSectionId.value
+  );
+  if (editorRef.value) {
+    editorRef.value.innerHTML = getSectionContent(current);
+  }
+};
+
+const goBack = () => {
+  router.push({ name: "particulars" });
+};
+
+const onSave = async () => {
+  const projectId = route.query.projectId;
+  if (!projectId) {
+    ElMessage.error("缺少项目ID");
+    return;
+  }
+  setActiveContent(editorRef.value?.innerHTML || "");
+  try {
+    const res = await saveProjectProgress({
+      projectId,
+      progressList: sections.value.map((item) => ({
+        sectionId: item.sectionId,
+        content: getSectionContent(item),
+      })),
+    });
+    if (res?.code !== 200) {
+      ElMessage.error(res?.msg || "保存失败");
+      return;
+    }
+    ElMessage.success(res?.msg || "保存成功");
+  } catch (error) {
+    console.error(error);
+    ElMessage.error("保存失败,请稍后重试");
+  }
+};
+
+const applyProgress = (data) => {
+  if (!data || typeof data !== "object") {
+    sections.value = [];
+    activeSectionId.value = null;
+    return;
+  }
+  projectName.value = data.projectName || "";
+  const list = Array.isArray(data.sections) ? data.sections : [];
+  sections.value = list.map((item) => ({
+    sectionId: item.sectionId,
+    sectionName: item.sectionName || "",
+    progress: {
+      content:
+        item.progress && typeof item.progress === "object"
+          ? item.progress.content || ""
+          : "",
+    },
+  }));
+  activeSectionId.value = sections.value[0]?.sectionId ?? null;
+};
+
+const loadProgress = async () => {
+  projectName.value = "";
+  sections.value = [];
+  activeSectionId.value = null;
+  const projectId = route.query.projectId;
+  if (!projectId) {
+    ElMessage.error("缺少项目ID");
+    await syncEditor();
+    return;
+  }
+  try {
+    const res = await getProjectProgress(projectId);
+    if (res?.code !== 200) {
+      ElMessage.error(res?.msg || "获取项目进展失败");
+      await syncEditor();
+      return;
+    }
+    applyProgress(res.data);
+  } catch (error) {
+    console.error(error);
+    ElMessage.error("获取项目进展失败,请稍后重试");
+  }
+  await syncEditor();
+};
+
+watch(
+  () => route.query.projectId,
+  () => {
+    loadProgress();
+  },
+  { immediate: true }
+);
+
+watch(activeSectionId, () => {
+  syncEditor();
+});
+</script>
+
+<style scoped lang="scss">
+.page-project-progress {
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  height: 100%;
+  min-height: 0;
+  overflow: hidden;
+  background: #f3f7fc;
+}
+
+.page-project-progress-body {
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+  padding: 24px;
+  overflow: auto;
+}
+
+.page-project-progress-breadcrumb {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-family: PingFang SC, PingFang SC;
+  font-size: 14px;
+  line-height: 20px;
+}
+
+.page-project-progress-crumb {
+  padding: 0;
+  border: none;
+  background: transparent;
+  color: #58657c;
+  cursor: pointer;
+
+  &.is-current {
+    color: #16213a;
+    cursor: default;
+  }
+}
+
+.page-project-progress-crumb-sep {
+  color: #9aa5b8;
+}
+
+.page-project-progress-panel {
+  box-sizing: border-box;
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  min-height: 0;
+  padding: 24px 16px;
+  background: #ffffff;
+}
+
+.page-project-progress-title {
+  margin: 0 0 16px;
+  font-family: PingFang SC, PingFang SC;
+  font-weight: 600;
+  font-size: 18px;
+  color: #16213a;
+  line-height: 25px;
+  text-align: left;
+}
+
+.page-project-progress-tabs {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-bottom: 16px;
+}
+
+.page-project-progress-tab {
+  padding: 6px 16px;
+  border: none;
+  border-radius: 2px;
+  background: #f6f9fd;
+  font-family: PingFang SC, PingFang SC;
+  font-weight: 400;
+  font-size: 14px;
+  color: #58657c;
+  line-height: 20px;
+  cursor: pointer;
+
+  &.is-active {
+    color: #ffffff;
+    background: #2382e7;
+  }
+}
+
+.page-project-progress-editor {
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  min-height: 360px;
+  border: 1px solid #e5ebf3;
+  border-radius: 2px;
+}
+
+.page-project-progress-toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 8px 12px;
+  border-bottom: 1px solid #e5ebf3;
+}
+
+.page-project-progress-toolbar-left {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+
+  button {
+    padding: 0 4px;
+    border: none;
+    background: transparent;
+    color: #58657c;
+    cursor: pointer;
+  }
+
+  .is-mark {
+    font-weight: 700;
+  }
+}
+
+.page-project-progress-size {
+  font-size: 14px;
+  color: #58657c;
+}
+
+.page-project-progress-color {
+  width: 12px;
+  height: 12px;
+  border-radius: 50%;
+  cursor: pointer;
+}
+
+.page-project-progress-clear {
+  padding: 0;
+  border: none;
+  background: transparent;
+  color: #9aa5b8;
+  font-size: 14px;
+  cursor: pointer;
+}
+
+.page-project-progress-content {
+  flex: 1;
+  min-height: 0;
+  padding: 16px;
+  overflow: auto;
+  outline: none;
+  font-family: PingFang SC, PingFang SC;
+  font-size: 14px;
+  color: #16213a;
+  line-height: 22px;
+  text-align: left;
+  white-space: pre-wrap;
+}
+
+.page-project-progress-actions {
+  flex-shrink: 0;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  gap: 24px;
+  width: 100%;
+  padding: 12px;
+  background: #ffffff;
+  border-top: 1px solid #e5ebf3;
+}
+
+.page-project-progress-btn {
+  padding: 6px 16px;
+  border-radius: 2px;
+  font-family: PingFang SC, PingFang SC;
+  font-size: 14px;
+  line-height: 20px;
+  cursor: pointer;
+
+  &.is-outline {
+    color: #58657c;
+    font-weight: 400;
+    background: #ffffff;
+    border: 1px solid #e4eaf2;
+  }
+
+  &.is-primary {
+    color: #ffffff;
+    font-weight: 500;
+    background: #2382e7;
+    border: 1px solid #2382e7;
+    box-shadow: 0px 4px 10px 0px rgba(35, 130, 231, 0.2);
+  }
+}
+</style>

+ 10 - 0
src/router/index.js

@@ -39,6 +39,16 @@ const routes = [
         component: () => import("@/pages/home/project-detail.vue"),
         meta: {
           title: "项目明细",
+          menuKey: "particulars",
+        },
+      },
+      {
+        path: "project-progress",
+        name: "projectProgress",
+        component: () => import("@/pages/home/project-progress.vue"),
+        meta: {
+          title: "项目进展编辑",
+          menuKey: "particulars",
         },
       },
       {

Деякі файли не було показано, через те що забагато файлів було змінено