肖栋 3 روز پیش
والد
کامیت
542b2dc267

+ 33 - 57
src/api/login.js

@@ -1,59 +1,35 @@
 import { post, get } from "@/utils/request";
-
-// 获取用户信息
-export function getInfo() {
-  return get("/user/getInfo");
-}
-
-// 获取验证码
-export function getCode(username) {
-  return get("/login/getSmsCode", { username });
-}
-
-// 短信登录
-export function codeLogin(username, code) {
-  return post("/login/smsLogin", { username, code });
-}
-
-// 短信登录
-export function passwordLogin(username, password) {
-  return post("/login/passwordLogin", { username, password });
-}
-
-// 退出登录
-export function loginout() {
-  return get("/logout");
-}
-
-// 生成登录二维码
-export function selectQrCode() {
-  return get("/login/getQrCode");
-}
-
-// 查询登录状态
-export function selectQrLoop(qrToken) {
-  return get("/login/loopQrStatus", { qrToken });
-}
-
-// 扫码登录
-export function qrLogin(data) {
-  return post("/login/qrLogin", data);
-}
-// app中访问登录信息同步
-export function appLogin(data) {
-  return post("/login/h5", data);
-}
-
-//获取菜单权限
-export function getMenuPromission() {
-  return get("/system/menuAuth/list");
-}
-
-// 微信登录相关
-export function loginByThirdKey(unionId) {
-  return post("/user/third/loginByKey/"+unionId, { });
-}
-
-export function bindThirdAccount(username, code, unionId) {
-  return post("/user/third/bindThirdAccount", { username, code, unionId });
+import globalStore from "@/store/modules/global";
+
+// 简易登录
+export function simpleLogin(username, password) {
+  return post(
+    "/api/login/simple",
+    { username, password },
+    { Authorization: "" }
+  );
+}
+
+// 存储 userInfo 返回数据,供后续使用
+function saveUserInfoData(data) {
+  if (!data) return;
+  const global = globalStore();
+  global.setUserInfo(data);
+  if (data.id != null) {
+    global.setUserId(data.id);
+  }
+  if (data.permissions != null) {
+    global.setPromission(data.permissions);
+  }
+}
+
+// 获取当前用户信息
+export function getUserInfo() {
+  return get("/api/userInfo").then((res) => {
+    console.log("/api/userInfo", res);
+    if (res?.code === 200) {
+      saveUserInfoData(res.data);
+    }
+    return res;
+  });
 }

+ 16 - 0
src/api/permission.js

@@ -0,0 +1,16 @@
+import { get, post } from "@/utils/request";
+
+// 用户权限列表
+export function getUserList() {
+  return get("/api/list");
+}
+
+// 用户统计数据
+export function getUserCount() {
+  return get("/api/count");
+}
+
+// 修改用户状态与权限
+export function updateUser(id, data) {
+  return post(`/api/user/${id}`, data);
+}

+ 36 - 0
src/api/project.js

@@ -0,0 +1,36 @@
+import { get } from "@/utils/request";
+
+// 获取项目明细
+export function getProject(id) {
+  return get(`/api/project/${id}`);
+}
+
+// 项目概览统计卡片
+export function getOverviewStats() {
+  return get("/api/project/overview/stats");
+}
+
+// 项目概览列表
+export function getOverviewList(status = 0) {
+  return get("/api/project/overview/list", { status });
+}
+
+// 项目概览异常报告(1=预警 2=滞后)
+export function getAbnormalReports(type) {
+  return get("/api/project/overview/abnormal-reports", { type });
+}
+
+// 总体计划-项目选择列表
+export function getPlanProjects() {
+  return get("/api/project/plan/projects");
+}
+
+// 总体计划-项目明细
+export function getPlanDetail(projectId) {
+  return get("/api/project/plan/detail", { projectId });
+}
+
+// 总体计划-导出
+export function exportPlan(projectId) {
+  return get("/api/project/plan/export", { projectId }, {}, { responseType: "blob" });
+}

+ 1 - 0
src/assets/icons/checkbox_off.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="14" height="14" viewBox="0 0 14 14"><rect x="1.5" y="1.5" width="11" height="11" rx="0.5" fill-opacity="0" stroke-opacity="1" stroke="#E5EBF3" fill="none" stroke-width="1"/></svg>

+ 1 - 0
src/assets/icons/checkbox_on.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="14" height="14" viewBox="0 0 14 14"><rect x="1" y="1" width="12" height="12" rx="1" fill="#2382E7" fill-opacity="1"/><path d="M10.8534827,5.35348269Q10.9237947,5.28317058,10.961847299999999,5.19130343Q10.999899899999999,5.099436291,10.999899899999999,5Q10.999899899999999,4.950764146,10.990294500000001,4.902474351Q10.980689,4.85418455,10.9618468,4.80869657Q10.9430056,4.76320857,10.9156513,4.72227046Q10.888297099999999,4.68133235,10.8534822,4.64651734Q10.8186669,4.61170232,10.777729,4.58434838Q10.7367907,4.55699441,10.691302799999999,4.53815266Q10.645814900000001,4.51931089,10.5975251,4.50970545Q10.5492353,4.50010002,10.5,4.50009999Q10.4005642,4.50009999,10.308696699999999,4.53815264Q10.2168293,4.57620522,10.1465173,4.64651731L10.1464467,4.64644662L6.5,8.2928934L4.85355338,6.6464466L4.85348266,6.6465173Q4.78317058,6.5762051,4.69130343,6.5381525Q4.599436283,6.5001,4.5,6.5001Q4.450764146,6.5001,4.402474351,6.5097054Q4.35418455,6.5193107999999995,4.30869657,6.5381525Q4.26320857,6.5569942,4.22227046,6.5843483Q4.18133235,6.6117022,4.14651734,6.6465173Q4.11170232,6.6813323,4.08434838,6.7222703Q4.05699441,6.7632084,4.03815266,6.8086964000000005Q4.01931089,6.8541844,4.00970545,6.9024743Q4.00010002,6.9507641,4.00009999,7Q4.00010002,7.0994363,4.03815264,7.1913032999999995Q4.07620522,7.283170500000001,4.14651725,7.3534827L4.14644662,7.3535533L6.1464466,9.3535533Q6.1812685,9.3883753,6.2222147,9.4157343Q6.2631609,9.4430938,6.3086581,9.461939300000001Q6.3541552,9.4807849,6.4024546,9.490392700000001Q6.450754,9.5,6.4999999,9.5Q6.5492454,9.5,6.5975449,9.490392700000001Q6.6458442,9.4807849,6.691341400000001,9.461939300000001Q6.7368383000000005,9.4430938,6.7777848,9.4157343Q6.8187313,9.3883753,6.8535533,9.3535533L10.8535533,5.35355338L10.8534827,5.35348269Z" fill-rule="evenodd" fill="#FFFFFF" fill-opacity="1"/></svg>

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
src/assets/icons/edit_blue.svg


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 0
src/assets/icons/possword_blank.svg


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
src/assets/icons/possword_show.svg


+ 1 - 0
src/assets/icons/radio_off.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="14" height="14" viewBox="0 0 14 14"><ellipse cx="7" cy="7" rx="5.5" ry="5.5" fill-opacity="0" stroke-opacity="1" stroke="#E5EBF3" fill="none" stroke-width="1"/></svg>

+ 1 - 0
src/assets/icons/radio_on.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="14" height="14" viewBox="0 0 14 14"><ellipse cx="7" cy="7" rx="6" ry="6" fill="#2382E7" fill-opacity="1"/><ellipse cx="7" cy="7" rx="2" ry="2" fill="#FFFFFF" fill-opacity="1"/></svg>

+ 6 - 14
src/components/layout/AppHeader.vue

@@ -22,7 +22,6 @@
 import { computed, onBeforeUnmount, onMounted, ref } from "vue";
 import { useRouter } from "vue-router";
 import { ElMessage } from "element-plus";
-import { loginout } from "@/api/login";
 import globalStore from "@/store/modules/global";
 
 defineOptions({
@@ -35,9 +34,8 @@ const currentTime = ref("");
 const currentDate = ref("");
 let timer = null;
 
-/** 暂写死,后续接登录用户信息 */
-const displayName = "苏小苏";
-const avatarText = computed(() => displayName.slice(0, 1) || "");
+const displayName = computed(() => global.userInfo?.userName || "");
+const avatarText = computed(() => displayName.value.slice(0, 1) || "");
 
 const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
 
@@ -49,16 +47,10 @@ const updateDateTime = () => {
   currentDate.value = `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日   ${WEEKDAYS[now.getDay()]}`;
 };
 
-const handleLogout = async () => {
-  try {
-    await loginout();
-  } catch (error) {
-    console.error(error);
-  } finally {
-    global.logout();
-    ElMessage.success("已退出登录");
-    router.replace({ name: "login" });
-  }
+const handleLogout = () => {
+  global.logout();
+  ElMessage.success("已退出登录");
+  router.replace({ name: "login" });
 };
 
 onMounted(() => {

+ 434 - 0
src/pages/home/overall-plan.vue

@@ -0,0 +1,434 @@
+<template>
+  <div class="page-overall-plan">
+    <div class="page-overall-plan-header">
+      <h2 class="page-overall-plan-title">总体计划列表</h2>
+      <button
+        type="button"
+        class="page-overall-plan-export-btn"
+        @click="onExportClick"
+      >
+        导出Excel
+      </button>
+    </div>
+
+    <div class="page-overall-plan-panel">
+      <aside class="page-overall-plan-list">
+        <button
+          v-for="item in projectList"
+          :key="item.id"
+          type="button"
+          class="page-overall-plan-list-item"
+          :class="{ 'is-active': item.id === activeProjectId }"
+          @click="activeProjectId = item.id"
+        >
+          {{ item.name }}
+        </button>
+      </aside>
+
+      <section class="page-overall-plan-main">
+        <h3 class="page-overall-plan-main-title">
+          {{ planDetail?.projectName || activeProject?.name || "" }} 总体计划
+        </h3>
+
+        <div class="page-overall-plan-table-scroll">
+          <table class="page-overall-plan-table">
+            <thead>
+              <tr>
+                <th class="col-phase">阶段</th>
+                <th class="col-name">报告名称</th>
+                <th class="col-date">开始时间</th>
+                <th class="col-date">实际完成时间</th>
+                <th class="col-duration">实际工期</th>
+                <th class="col-remark">备注</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr v-for="(row, index) in tableRows" :key="index">
+                <td
+                  v-if="row.showPhase"
+                  class="col-phase"
+                  :class="`is-phase-${row.phaseType}`"
+                  :rowspan="row.phaseRowspan"
+                >
+                  {{ row.phaseName }}
+                </td>
+                <td class="col-name">{{ row.reportName }}</td>
+                <td class="col-date">{{ formatValue(row.startDate) }}</td>
+                <td class="col-date">{{ formatValue(row.actualEndDate) }}</td>
+                <td class="col-duration">{{ formatDuration(row.duration) }}</td>
+                <td class="col-remark">{{ formatValue(row.remark) }}</td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+      </section>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, ref, watch } from "vue";
+import { ElMessage } from "element-plus";
+import { getPlanDetail, getPlanProjects, exportPlan } from "@/api/project";
+
+defineOptions({
+  name: "OverallPlan",
+});
+
+const projectList = ref([]);
+const activeProjectId = ref(null);
+const planDetail = ref(null);
+
+const activeProject = computed(() =>
+  projectList.value.find((item) => item.id === activeProjectId.value)
+);
+
+const tableRows = computed(() => {
+  const phases = planDetail.value?.phases || [];
+  if (!phases.length) return [];
+
+  const rows = [];
+  phases.forEach((phase) => {
+    const phaseType = Number(phase.phaseType);
+    const phaseName = phase.phaseName || "";
+    const phaseRows = [];
+
+    (phase.tasks || []).forEach((task) => {
+      const nodes = task.nodes || [];
+      if (!nodes.length) {
+        phaseRows.push({
+          reportName: task.taskName || "",
+          startDate: "",
+          actualEndDate: "",
+          duration: "",
+          remark: "",
+        });
+        return;
+      }
+      nodes.forEach((node) => {
+        phaseRows.push({
+          reportName: task.taskName || "",
+          startDate: node.startDate || "",
+          actualEndDate: node.actualEndDate || "",
+          duration: node.actualDuration || "",
+          remark: node.remarks || "",
+        });
+      });
+    });
+
+    phaseRows.forEach((row, index) => {
+      rows.push({
+        showPhase: index === 0,
+        phaseRowspan: phaseRows.length,
+        phaseType,
+        phaseName,
+        ...row,
+      });
+    });
+  });
+
+  return rows;
+});
+
+const formatValue = (value) => {
+  if (value == null || value === "") return "—";
+  return value;
+};
+
+const formatDuration = (value) => {
+  if (value == null || value === "") return "—";
+  const text = String(value);
+  if (text.includes("天")) return text;
+  return `${text}天`;
+};
+
+const loadPlanDetail = async (projectId) => {
+  if (projectId == null || projectId === "") {
+    planDetail.value = null;
+    return;
+  }
+  try {
+    const res = await getPlanDetail(projectId);
+    if (res?.code !== 200) {
+      ElMessage.error(res?.msg || "获取总体计划明细失败");
+      planDetail.value = null;
+      return;
+    }
+    planDetail.value = res.data || null;
+  } catch (error) {
+    console.error(error);
+    planDetail.value = null;
+    ElMessage.error("获取总体计划明细失败,请稍后重试");
+  }
+};
+
+const onExportClick = async () => {
+  if (activeProjectId.value == null || activeProjectId.value === "") return;
+  try {
+    const res = await exportPlan(activeProjectId.value);
+    const cd = res.headers?.["content-disposition"] || "";
+    const m = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(cd);
+    const fileName = m ? decodeURIComponent(m[1]) : "总体计划.xlsx";
+    const url = URL.createObjectURL(new Blob([res.data]));
+    const a = document.createElement("a");
+    a.href = url;
+    a.download = fileName;
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+    URL.revokeObjectURL(url);
+  } catch (error) {
+    console.error(error);
+    ElMessage.error("导出失败,请稍后重试");
+  }
+};
+
+const loadPlanProjects = async () => {
+  try {
+    const res = await getPlanProjects();
+    if (res?.code !== 200) {
+      ElMessage.error(res?.msg || "获取项目列表失败");
+      return;
+    }
+    const list = Array.isArray(res.data) ? res.data : [];
+    projectList.value = list.map((item) => ({
+      id: item.id,
+      name: item.projectName || "",
+    }));
+    if (
+      !projectList.value.some((item) => item.id === activeProjectId.value)
+    ) {
+      activeProjectId.value = projectList.value[0]?.id ?? null;
+    } else {
+      loadPlanDetail(activeProjectId.value);
+    }
+  } catch (error) {
+    console.error(error);
+    ElMessage.error("获取项目列表失败,请稍后重试");
+  }
+};
+
+watch(activeProjectId, (id) => {
+  loadPlanDetail(id);
+});
+
+onMounted(() => {
+  loadPlanProjects();
+});
+</script>
+
+<style scoped lang="scss">
+.page-overall-plan {
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+  width: 100%;
+  min-height: 100%;
+  padding: 16px 24px 20px;
+  background: #f3f7fc;
+}
+
+.page-overall-plan-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+}
+
+.page-overall-plan-title {
+  margin: 0;
+  font-family: PingFang SC, PingFang SC;
+  font-weight: 600;
+  font-size: 18px;
+  color: #16213a;
+  line-height: 25px;
+  text-align: left;
+}
+
+.page-overall-plan-export-btn {
+  padding: 6px 16px;
+  font-family: PingFang SC, PingFang SC;
+  font-weight: 500;
+  font-size: 14px;
+  color: #ffffff;
+  line-height: 20px;
+  text-align: left;
+  font-style: normal;
+  background: #2382e7;
+  box-shadow: 0px 4px 10px 0px rgba(35, 130, 231, 0.2);
+  border-radius: 2px 2px 2px 2px;
+  border: 1px solid #2382e7;
+  cursor: pointer;
+
+  &:hover,
+  &:focus,
+  &:focus-visible {
+    color: #ffffff;
+    background: #2382e7;
+    border: 1px solid #2382e7;
+    outline: none;
+  }
+}
+
+.page-overall-plan-panel {
+  box-sizing: border-box;
+  display: flex;
+  flex: 1;
+  gap: 16px;
+  min-height: 0;
+}
+
+.page-overall-plan-list {
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  flex-shrink: 0;
+  gap: 6px;
+  width: 280px;
+  padding: 0;
+  background: #ffffff;
+  border-radius: 0;
+  overflow: auto;
+}
+
+.page-overall-plan-list-item {
+  display: block;
+  box-sizing: border-box;
+  width: 100%;
+  margin: 0;
+  padding: 8px;
+  border: none;
+  border-left: 3px solid transparent;
+  border-radius: 0;
+  background: transparent;
+  font-family: PingFang SC, PingFang SC;
+  font-weight: 400;
+  font-size: 14px;
+  color: #16213a;
+  line-height: 20px;
+  text-align: left;
+  cursor: pointer;
+  outline: none;
+
+  &:focus,
+  &:focus-visible {
+    outline: none;
+  }
+
+  &.is-active {
+    background: #edf7ff;
+    border-radius: 0;
+    border: none;
+    border-left: 3px solid #2382e7;
+    padding: 8px;
+    font-family: PingFang SC, PingFang SC;
+    font-weight: 600;
+    font-size: 14px;
+    color: #2382e7;
+    line-height: 20px;
+    text-align: left;
+  }
+}
+
+.page-overall-plan-main {
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  flex: 1;
+  min-width: 0;
+  padding: 20px 24px 24px;
+  background: #ffffff;
+}
+
+.page-overall-plan-main-title {
+  margin: 0 0 16px;
+  font-family: PingFang SC, PingFang SC;
+  font-weight: 600;
+  font-size: 18px;
+  color: #2382e7;
+  line-height: 25px;
+  text-align: center;
+}
+
+.page-overall-plan-table-scroll {
+  flex: 1;
+  min-height: 0;
+  overflow: auto;
+}
+
+.page-overall-plan-table {
+  width: 100%;
+  border-collapse: collapse;
+  table-layout: fixed;
+
+  th,
+  td {
+    box-sizing: border-box;
+    padding: 12px 16px;
+    font-family: PingFang SC, PingFang SC;
+    font-size: 14px;
+    line-height: 20px;
+    border: 1px solid #e5ebf3;
+    vertical-align: middle;
+  }
+
+  th {
+    background: #f5f7fb;
+    font-weight: 500;
+    color: #58657c;
+    text-align: center;
+    white-space: nowrap;
+  }
+
+  td {
+    background: #ffffff;
+    font-weight: 400;
+    color: #16213a;
+    text-align: left;
+  }
+
+  td.col-phase {
+    text-align: center;
+    vertical-align: middle;
+    color: #16213a;
+
+    &.is-phase-1 {
+      background: #e8f3ff;
+    }
+
+    &.is-phase-3 {
+      background: #e8f8ef;
+    }
+
+    &.is-phase-5 {
+      background: #eef1f6;
+    }
+  }
+
+  td.col-date,
+  td.col-duration {
+    text-align: center;
+  }
+
+  .col-phase {
+    width: 18%;
+  }
+
+  .col-name {
+    width: 22%;
+  }
+
+  .col-date {
+    width: 14%;
+  }
+
+  .col-duration {
+    width: 12%;
+  }
+
+  .col-remark {
+    width: 20%;
+  }
+}
+</style>

+ 289 - 215
src/pages/home/overview.vue

@@ -347,7 +347,13 @@
 </template>
 
 <script setup>
-import { ref } from "vue";
+import { onMounted, ref, watch } from "vue";
+import { ElMessage } from "element-plus";
+import {
+  getAbnormalReports,
+  getOverviewList,
+  getOverviewStats,
+} from "@/api/project";
 
 defineOptions({
   name: "ProjectOverview",
@@ -372,264 +378,323 @@ const reportDialogVisible = ref(false);
 const reportDialogType = ref("warning");
 const reportDialogList = ref([]);
 
-const warningReportList = [
-  {
-    id: 1,
-    projectName: "垦利10-1油田开发项目",
-    reportName: "可行性研究-环境质量现状调查",
-    startTime: "2026-06-05",
-    duration: "60天",
-    planEndTime: "2026-08-05",
-    days: 2,
-    unit: "樟树市邦厉有限公司",
-    owner: "苏小苏",
-  },
-  {
-    id: 2,
-    projectName: "渤中19-6凝析气田开发项目",
-    reportName: "详细设计/建造安装-通航安全影响咨询",
-    startTime: "2026-05-01",
-    duration: "90天",
-    planEndTime: "2026-08-20",
-    days: 22,
-    unit: "天津海蓝工程咨询有限公司",
-    owner: "李明",
-  },
-];
+const ABNORMAL_TYPE_MAP = {
+  warning: 1,
+  delay: 2,
+};
 
-const delayReportList = [
-  {
-    id: 1,
-    projectName: "锦州25-1油田开发项目",
-    reportName: "可行性研究-通航安全影响咨询",
-    startTime: "2026-05-01",
-    duration: "90天",
-    planEndTime: "2026-07-30",
-    days: 3,
-    unit: "天津海蓝工程咨询有限公司",
-    owner: "王强",
-  },
-];
+const mapAbnormalReports = (list = []) => {
+  const result = [];
+  list.forEach((project) => {
+    (project.reports || []).forEach((report) => {
+      (report.nodes || []).forEach((node) => {
+        result.push({
+          id: node.id,
+          projectName: project.projectName || "",
+          reportName: report.reportName || "",
+          startTime: node.startDate || "",
+          duration:
+            node.duration != null && node.duration !== ""
+              ? `${node.duration}天`
+              : "",
+          planEndTime: node.plannedEndDate || "",
+          days: node.daysDiff ?? 0,
+          unit: node.reportUnit || "",
+          owner: node.personInCharge || "",
+        });
+      });
+    });
+  });
+  return result;
+};
 
-const onSummaryNumClick = (card) => {
+const onSummaryNumClick = async (card) => {
   if (card.key !== "warning" && card.key !== "delay") return;
+  const type = ABNORMAL_TYPE_MAP[card.key];
   reportDialogType.value = card.key;
-  reportDialogList.value =
-    card.key === "warning" ? warningReportList : delayReportList;
-  reportDialogVisible.value = true;
+  try {
+    const res = await getAbnormalReports(type);
+    if (res?.code !== 200) {
+      ElMessage.error(res?.msg || "获取异常报告失败");
+      return;
+    }
+    reportDialogList.value = mapAbnormalReports(
+      Array.isArray(res.data) ? res.data : []
+    );
+    reportDialogVisible.value = true;
+  } catch (error) {
+    console.error(error);
+    ElMessage.error("获取异常报告失败,请稍后重试");
+  }
 };
 
-const summaryCards = [
+const PHASE_NAME_MAP = {
+  1: "可行性研究",
+  3: "基本设计",
+  5: "详细设计/建造安装",
+};
+
+const PHASE_ORDER = [1, 3, 5];
+
+const getPhaseCounts = (phaseCounts = []) => {
+  const countMap = {};
+  phaseCounts.forEach((item) => {
+    countMap[item.phaseType] = item.count ?? 0;
+  });
+  return PHASE_ORDER.map((phaseType) => ({
+    name: PHASE_NAME_MAP[phaseType],
+    count: countMap[phaseType] ?? 0,
+  }));
+};
+
+const createEmptySummaryCards = () => [
   {
     key: "total",
     label: "项目总数",
-    value: "24",
+    value: "0",
     unit: "个项目",
     color: "#2382E7",
     icon: "total_number_of_projects",
-    phases: [
-      { name: "可行性研究", count: 8 },
-      { name: "基本设计", count: 8 },
-      { name: "详细设计/建造安装", count: 8 },
-    ],
+    phases: getPhaseCounts([]),
   },
   {
     key: "warning",
     label: "预警报告",
     tip: "(计划日期内仍未完成)",
-    value: "2",
+    value: "0",
     unit: "个项目",
     color: "#FF8E38",
     icon: "early_warning_report",
-    phases: [
-      { name: "可行性研究", count: 1 },
-      { name: "基本设计", count: 0 },
-      { name: "详细设计/建造安装", count: 1 },
-    ],
+    phases: getPhaseCounts([]),
   },
   {
     key: "delay",
     label: "滞后报告",
     tip: "(计划日期内仍未完成)",
-    value: "1",
+    value: "0",
     unit: "个项目",
     color: "#F54848",
     icon: "late_report",
-    phases: [
-      { name: "可行性研究", count: 1 },
-      { name: "基本设计", count: 0 },
-      { name: "详细设计/建造安装", count: 0 },
-    ],
+    phases: getPhaseCounts([]),
   },
   {
     key: "rate",
     label: "报告完成率",
-    value: "58",
+    value: "0",
     unit: "%",
     color: "#10B837",
     icon: "report_completion_rate",
     subTitle: "年度计划完成率",
     subReport: {
-      prefix: "2026年报告:",
-      value: "58",
-      total: "100",
+      prefix: `${new Date().getFullYear()}年报告:`,
+      value: "0",
+      total: "0",
     },
   },
 ];
 
-const createNormalPopover = (name, planTime = "2026-07-30") => ({
-  name,
-  planTime,
-  completeTime: planTime,
-});
-
-const projectList = [
-  {
-    id: 1,
-    name: "垦利10-1油田开发项目",
-    progress: 20,
-    timeline: {
-      plan: [
-        { type: "plan", flex: 1 },
-        { type: "empty", flex: 13 },
-      ],
-      actual: [
-        {
-          type: "warning",
-          flex: 1,
-          label: "环境质量现状调查",
-          popover: {
-            name: "环境质量现状调查",
-            planTime: "2026-08-05",
-            remainDays: 2,
-          },
-        },
-        { type: "empty", flex: 13 },
-      ],
+const summaryCards = ref(createEmptySummaryCards());
+
+const applyOverviewStats = (data = {}) => {
+  const projectTotal = data.projectTotal || {};
+  const warning = data.warning || {};
+  const delayed = data.delayed || {};
+  const completion = data.completion || {};
+  const year = new Date().getFullYear();
+
+  summaryCards.value = [
+    {
+      key: "total",
+      label: "项目总数",
+      value: String(projectTotal.total ?? 0),
+      unit: "个项目",
+      color: "#2382E7",
+      icon: "total_number_of_projects",
+      phases: getPhaseCounts(projectTotal.phaseCounts),
     },
-    warning: 1,
-    delay: 0,
-  },
-  {
-    id: 2,
-    name: "锦州25-1油田开发项目",
-    progress: 43,
-    timeline: {
-      plan: [
-        { type: "done", flex: 5 },
-        { type: "plan", flex: 1 },
-        { type: "empty", flex: 8 },
-      ],
-      actual: [
-        {
-          type: "actual",
-          flex: 1,
-          label: "环境质量现状调查",
-          popover: createNormalPopover("环境质量现状调查"),
-        },
-        {
-          type: "actual",
-          flex: 1,
-          label: "渔业资源现状调查",
-          popover: createNormalPopover("渔业资源现状调查"),
-        },
-        {
-          type: "actual",
-          flex: 1,
-          label: "环境风险预评价",
-          popover: createNormalPopover("环境风险预评价"),
-        },
-        {
-          type: "actual",
-          flex: 1,
-          label: "桌面路由方案",
-          popover: createNormalPopover("桌面路由方案"),
-        },
-        {
-          type: "delay",
-          flex: 1,
-          label: "通航安全影响咨询",
-          popover: {
-            name: "通航安全影响咨询",
-            planTime: "2026-07-30",
-            overdueDays: 3,
-          },
-        },
-        {
-          type: "actual",
-          flex: 1,
-          label: "安全预评价",
-          popover: createNormalPopover("安全预评价"),
-        },
-        { type: "empty", flex: 8 },
-      ],
+    {
+      key: "warning",
+      label: "预警报告",
+      tip: "(计划日期内仍未完成)",
+      value: String(warning.total ?? 0),
+      unit: "个项目",
+      color: "#FF8E38",
+      icon: "early_warning_report",
+      phases: getPhaseCounts(warning.phaseCounts),
     },
-    warning: 0,
-    delay: 1,
-  },
-  {
-    id: 3,
-    name: "渤中19-6凝析气田开发项目",
-    progress: 100,
-    timeline: {
-      plan: [
-        { type: "done", flex: 1 },
-        { type: "done", flex: 1 },
-        { type: "done", flex: 1 },
-        { type: "done", flex: 1 },
-        { type: "done", flex: 1 },
-        { type: "done", flex: 1 },
-        { type: "done", flex: 1 },
-        { type: "done", flex: 1 },
-        { type: "done", flex: 1 },
-        { type: "done", flex: 1 },
-        { type: "done", flex: 1 },
-        { type: "done", flex: 1 },
-        { type: "done", flex: 1 },
-        { type: "done", flex: 1 },
-      ],
-      actual: [
-        "环境质量现状调查",
-        "环境影响评价",
-        "可行性研究报告",
-        "项目建议书",
-        "核准批复",
-        "总图设计",
-        "工艺设计",
-        "结构设计",
-        "公用工程设计",
-        "基本设计审查",
-        "详细设计",
-        "设备采购",
-        "建造安装",
-        "竣工验收",
-      ].map((label) => ({
-        type: "actual",
-        flex: 1,
-        label,
-        popover: createNormalPopover(label),
-      })),
+    {
+      key: "delay",
+      label: "滞后报告",
+      tip: "(计划日期内仍未完成)",
+      value: String(delayed.total ?? 0),
+      unit: "个项目",
+      color: "#F54848",
+      icon: "late_report",
+      phases: getPhaseCounts(delayed.phaseCounts),
     },
-    warning: 0,
-    delay: 0,
-  },
-  {
-    id: 4,
-    name: "曹妃甸6-4油田开发项目",
-    progress: 0,
-    timeline: { plan: [], actual: [] },
-    warning: 0,
-    delay: 0,
-  },
-  {
-    id: 5,
-    name: "旅大5-2油田开发项目",
-    progress: 0,
-    timeline: { plan: [], actual: [] },
-    warning: 0,
-    delay: 0,
-  },
-];
+    {
+      key: "rate",
+      label: "报告完成率",
+      value: String(completion.rate ?? 0),
+      unit: "%",
+      color: "#10B837",
+      icon: "report_completion_rate",
+      subTitle: "年度计划完成率",
+      subReport: {
+        prefix: `${year}年报告:`,
+        value: String(completion.yearCompleted ?? 0),
+        total: String(completion.yearTotal ?? 0),
+      },
+    },
+  ];
+};
+
+const loadOverviewStats = async () => {
+  try {
+    const res = await getOverviewStats();
+    if (res?.code !== 200) {
+      ElMessage.error(res?.msg || "获取概览统计失败");
+      return;
+    }
+    applyOverviewStats(res.data || {});
+  } catch (error) {
+    console.error(error);
+    ElMessage.error("获取概览统计失败,请稍后重试");
+  }
+};
+
+onMounted(() => {
+  loadOverviewStats();
+  loadOverviewList();
+});
+
+watch(listFilter, () => {
+  loadOverviewList();
+});
+
+const STATUS_FILTER_MAP = {
+  all: 0,
+  warning: 1,
+  delay: 2,
+};
+
+const buildNodePopover = (node, reportName) => {
+  const popover = {
+    name: reportName || "",
+    planTime: node.plannedEndDate || "",
+  };
+  if (Number(node.status) === 3) {
+    popover.remainDays = node.daysDiff ?? null;
+  } else if (Number(node.status) === 4) {
+    popover.overdueDays = node.daysDiff ?? null;
+  } else if (Number(node.status) === 2) {
+    popover.completeTime = node.actualEndDate || node.plannedEndDate || "";
+  }
+  return popover;
+};
+
+const PHASE_FLEX_MAP = {
+  1: 5,
+  3: 5,
+  5: 4,
+};
+
+const collectPhaseNodes = (phase) => {
+  const nodes = [];
+  (phase?.reports || []).forEach((report) => {
+    (report.nodes || []).forEach((node) => {
+      nodes.push({
+        ...node,
+        reportName: report.reportName || "",
+      });
+    });
+  });
+  return nodes;
+};
+
+const buildNodeActualSeg = (node, flex) => {
+  const status = Number(node.status);
+  let type = "empty";
+  if (status === 1 || status === 2) type = "actual";
+  else if (status === 3) type = "warning";
+  else if (status === 4) type = "delay";
+
+  const seg = {
+    type,
+    flex,
+    label: type === "empty" ? "" : node.reportName,
+  };
+  if (type !== "empty") {
+    seg.popover = buildNodePopover(node, node.reportName);
+  }
+  return seg;
+};
+
+const buildTimeline = (phases = []) => {
+  const phaseMap = {};
+  phases.forEach((phase) => {
+    phaseMap[Number(phase.phaseType)] = phase;
+  });
+
+  const hasAnyNode = PHASE_ORDER.some(
+    (phaseType) => collectPhaseNodes(phaseMap[phaseType]).length > 0
+  );
+  if (!hasAnyNode) {
+    return { plan: [], actual: [] };
+  }
+
+  const plan = [];
+  const actual = [];
+
+  PHASE_ORDER.forEach((phaseType) => {
+    const phaseFlex = PHASE_FLEX_MAP[phaseType];
+    const nodes = collectPhaseNodes(phaseMap[phaseType]);
+
+    if (!nodes.length) {
+      plan.push({ type: "empty", flex: phaseFlex });
+      actual.push({ type: "empty", flex: phaseFlex });
+      return;
+    }
+
+    const unit = phaseFlex / nodes.length;
+    nodes.forEach((node) => {
+      plan.push({
+        type: Number(node.status) === 2 ? "done" : "plan",
+        flex: unit,
+      });
+      actual.push(buildNodeActualSeg(node, unit));
+    });
+  });
+
+  return { plan, actual };
+};
+
+const mapOverviewListItem = (item) => ({
+  id: item.id,
+  name: item.projectName || "",
+  progress: item.overallProgress ?? 0,
+  timeline: buildTimeline(item.phases || []),
+  warning: item.warningCount ?? 0,
+  delay: item.delayedCount ?? 0,
+});
+
+const projectList = ref([]);
+
+const loadOverviewList = async () => {
+  try {
+    const status = STATUS_FILTER_MAP[listFilter.value] ?? 0;
+    const res = await getOverviewList(status);
+    if (res?.code !== 200) {
+      ElMessage.error(res?.msg || "获取项目列表失败");
+      return;
+    }
+    const list = Array.isArray(res.data) ? res.data : [];
+    projectList.value = list.map(mapOverviewListItem);
+  } catch (error) {
+    console.error(error);
+    ElMessage.error("获取项目列表失败,请稍后重试");
+  }
+};
 </script>
 
 <style scoped lang="scss">
@@ -1521,5 +1586,14 @@ const projectList = [
   border: 1px solid #e4eaf2;
   border-radius: 2px;
   cursor: pointer;
+
+  &:hover,
+  &:focus,
+  &:focus-visible {
+    color: #58657c;
+    background: #ffffff;
+    border: 1px solid #e4eaf2;
+    outline: none;
+  }
 }
 </style>

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

@@ -131,10 +131,12 @@
                 >
                   <div class="page-particulars-ops">
                     <button type="button" class="page-particulars-op-btn">
-                      明细
+                      <icon-svg name="edit_blue" size="16px" />
+                      <span>明细</span>
                     </button>
                     <button type="button" class="page-particulars-op-btn">
-                      进展
+                      <icon-svg name="edit_blue" size="16px" />
+                      <span>进展</span>
                     </button>
                   </div>
                 </td>
@@ -638,6 +640,9 @@ const dateClass = (status) => {
 }
 
 .page-particulars-op-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
   padding: 0;
   font-family: PingFang SC, PingFang SC;
   font-size: 13px;

+ 65 - 18
src/pages/login/index.vue

@@ -16,14 +16,26 @@
             autocomplete="username"
             placeholder="请输入账号"
           />
-          <input
-            v-model="password"
-            class="login-input"
-            type="password"
-            name="password"
-            autocomplete="current-password"
-            placeholder="请输入密码"
-          />
+          <div class="login-password-wrap">
+            <input
+              v-model="password"
+              class="login-input login-input--password"
+              :type="passwordVisible ? 'text' : 'password'"
+              name="password"
+              autocomplete="current-password"
+              placeholder="请输入密码"
+            />
+            <button
+              type="button"
+              class="login-password-toggle"
+              @click="passwordVisible = !passwordVisible"
+            >
+              <icon-svg
+                :name="passwordVisible ? 'possword_show' : 'possword_blank'"
+                size="24px"
+              />
+            </button>
+          </div>
           <button class="login-btn" type="submit" :disabled="submitting">
             {{ submitting ? "登录中..." : "登录" }}
           </button>
@@ -37,8 +49,9 @@
 import { ref } from "vue";
 import { useRouter } from "vue-router";
 import { ElMessage } from "element-plus";
-import { passwordLogin } from "@/api/login";
+import { simpleLogin, getUserInfo } from "@/api/login";
 import globalStore from "@/store/modules/global";
+import { useTokenStore } from "@/store/token";
 
 defineOptions({
   name: "LoginPage",
@@ -46,15 +59,22 @@ defineOptions({
 
 const router = useRouter();
 const global = globalStore();
+const tokenStore = useTokenStore();
 const username = ref("");
 const password = ref("");
+const passwordVisible = ref(false);
 const submitting = ref(false);
 
-const saveUserInfo = (userInfo) => {
-  global.setUserInfo(userInfo.data);
-  global.setWebsid(userInfo?.extraMap?.WEBSID ?? "");
-  global.setUserId(userInfo?.data?.id ?? "");
-  global.setYundian(userInfo?.ydCount);
+const saveLoginSession = (res) => {
+  const data = res?.data || {};
+  global.setUserInfo(data);
+  global.setWebsid(data.token ?? "");
+  global.setUserId(data.userId ?? "");
+  tokenStore.setAccessToken(data.token ?? "");
+};
+
+const loadCurrentUser = async () => {
+  await getUserInfo();
 };
 
 const goAfterLogin = () => {
@@ -79,12 +99,17 @@ const handleLogin = async () => {
 
   submitting.value = true;
   try {
-    const res = await passwordLogin(username.value, password.value);
+    const res = await simpleLogin(username.value, password.value);
     if (res?.code !== 200) {
       ElMessage.error(res?.msg || "登录失败");
       return;
     }
-    saveUserInfo(res);
+    saveLoginSession(res);
+    try {
+      await loadCurrentUser();
+    } catch (error) {
+      console.error(error);
+    }
     ElMessage.success(res?.msg || "登录成功");
     goAfterLogin();
   } catch (error) {
@@ -130,7 +155,7 @@ const handleLogin = async () => {
 
 .login-card {
   box-sizing: border-box;
-  width: 420px;
+  width: 500px;
   flex-shrink: 0;
   padding: 60px 48px;
   background: #ffffff;
@@ -146,6 +171,7 @@ const handleLogin = async () => {
   line-height: 34px;
   text-shadow: 0px 4px 10px rgba(0, 0, 0, 0.1);
   text-align: left;
+  white-space: nowrap;
 }
 
 .login-subtitle {
@@ -184,6 +210,27 @@ const handleLogin = async () => {
   }
 }
 
+.login-password-wrap {
+  position: relative;
+}
+
+.login-input--password {
+  padding-right: 46px;
+}
+
+.login-password-toggle {
+  position: absolute;
+  top: 50%;
+  right: 14px;
+  display: inline-flex;
+  padding: 0;
+  border: none;
+  background: transparent;
+  line-height: 0;
+  transform: translateY(-50%);
+  cursor: pointer;
+}
+
 .login-btn {
   box-sizing: border-box;
   width: 100%;
@@ -218,7 +265,7 @@ const handleLogin = async () => {
 
   .login-card {
     width: 100%;
-    max-width: 420px;
+    max-width: 500px;
   }
 }
 </style>

+ 719 - 0
src/pages/permission/index.vue

@@ -0,0 +1,719 @@
+<template>
+  <div class="page-permission">
+    <h2 class="page-permission-title">用户明细</h2>
+
+    <div class="page-permission-panel">
+      <div class="page-permission-tabs">
+        <button
+          v-for="tab in statusTabs"
+          :key="tab.value"
+          type="button"
+          class="page-permission-tab"
+          :class="{ 'is-active': statusFilter === tab.value }"
+          @click="statusFilter = tab.value"
+        >
+          {{ tab.label }} {{ tab.count }}
+        </button>
+      </div>
+
+      <div class="page-permission-table-scroll">
+        <table class="page-permission-table">
+          <thead>
+            <tr>
+              <th class="col-name">姓名</th>
+              <th class="col-account">海油通账号</th>
+              <th class="col-status">用户状态</th>
+              <th class="col-permission">权限配置</th>
+              <th v-if="isCurrentAdmin" class="col-action">操作</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="item in filteredList" :key="item.id">
+              <td class="col-name">
+                <div class="page-permission-name">
+                  <span>{{ item.userName }}</span>
+                  <span v-if="item.isAdmin === 1" class="page-permission-admin-tag"
+                    >管理员</span
+                  >
+                </div>
+              </td>
+              <td class="col-account">{{ item.hytAccount }}</td>
+              <td class="col-status">
+                <span
+                  class="page-permission-status"
+                  :class="item.status === 1 ? 'is-enabled' : 'is-disabled'"
+                >
+                  <i class="page-permission-status-dot"></i>
+                  {{ item.status === 1 ? "已启用" : "已停用" }}
+                </span>
+              </td>
+              <td class="col-permission">
+                <div class="page-permission-ops">
+                  <span
+                    v-for="perm in getPermissionLabels(item)"
+                    :key="perm"
+                    class="page-permission-op"
+                  >
+                    {{ perm }}
+                  </span>
+                </div>
+              </td>
+              <td v-if="isCurrentAdmin" class="col-action">
+                <button
+                  v-if="Number(item.isAdmin) !== 1"
+                  type="button"
+                  class="page-permission-action-btn"
+                  @click="openEditDialog(item)"
+                >
+                  <icon-svg name="edit_blue" size="16px" />
+                  <span>编辑</span>
+                </button>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+  </div>
+
+  <el-dialog
+    v-model="editDialogVisible"
+    width="720px"
+    append-to-body
+    align-center
+    class="page-permission-edit-dialog"
+    :close-on-click-modal="false"
+  >
+    <template #header>
+      <div class="page-permission-edit-title">账号编辑</div>
+    </template>
+
+    <div class="page-permission-edit-middle">
+      <div class="page-permission-edit-card">
+        <div class="page-permission-edit-row">
+          <label class="page-permission-edit-label">姓名:</label>
+          <input
+            v-model.trim="editForm.userName"
+            class="page-permission-edit-input"
+            type="text"
+          />
+        </div>
+        <div class="page-permission-edit-row">
+          <label class="page-permission-edit-label">海油通账号:</label>
+          <input
+            v-model.trim="editForm.hytAccount"
+            class="page-permission-edit-input"
+            type="text"
+          />
+        </div>
+        <div class="page-permission-edit-row">
+          <label class="page-permission-edit-label">用户状态:</label>
+          <div class="page-permission-edit-options is-status">
+            <label class="page-permission-edit-option">
+              <input v-model="editForm.status" type="radio" :value="1" />
+              <icon-svg
+                :name="editForm.status === 1 ? 'radio_on' : 'radio_off'"
+                size="14px"
+              />
+              <span>启用</span>
+            </label>
+            <label class="page-permission-edit-option">
+              <input v-model="editForm.status" type="radio" :value="0" />
+              <icon-svg
+                :name="editForm.status === 0 ? 'radio_on' : 'radio_off'"
+                size="14px"
+              />
+              <span>停用</span>
+            </label>
+          </div>
+        </div>
+        <div class="page-permission-edit-row">
+          <label class="page-permission-edit-label">用户权限:</label>
+          <div class="page-permission-edit-options is-permission">
+            <label class="page-permission-edit-option">
+              <input v-model="editForm.permissions.view" type="checkbox" />
+              <icon-svg
+                :name="editForm.permissions.view ? 'checkbox_on' : 'checkbox_off'"
+                size="14px"
+              />
+              <span>查看</span>
+            </label>
+            <label class="page-permission-edit-option">
+              <input v-model="editForm.permissions.edit" type="checkbox" />
+              <icon-svg
+                :name="editForm.permissions.edit ? 'checkbox_on' : 'checkbox_off'"
+                size="14px"
+              />
+              <span>编辑</span>
+            </label>
+            <label class="page-permission-edit-option">
+              <input v-model="editForm.permissions.export" type="checkbox" />
+              <icon-svg
+                :name="
+                  editForm.permissions.export ? 'checkbox_on' : 'checkbox_off'
+                "
+                size="14px"
+              />
+              <span>导出</span>
+            </label>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <template #footer>
+      <div class="page-permission-edit-footer">
+        <button
+          type="button"
+          class="page-permission-edit-btn is-cancel"
+          @click="closeEditDialog"
+        >
+          关闭
+        </button>
+        <button
+          type="button"
+          class="page-permission-edit-btn is-primary"
+          @click="handleSaveEdit"
+          :disabled="editSaving"
+        >
+          {{ editSaving ? "保存中..." : "保存" }}
+        </button>
+      </div>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup>
+import { computed, onMounted, ref } from "vue";
+import { ElMessage } from "element-plus";
+import { getUserCount, getUserList, updateUser } from "@/api/permission";
+import globalStore from "@/store/modules/global";
+
+defineOptions({
+  name: "PermissionPage",
+});
+
+const global = globalStore();
+const statusFilter = ref("all");
+const userList = ref([]);
+const userCount = ref({
+  total: 0,
+  enabled: 0,
+  disabled: 0,
+});
+
+const isCurrentAdmin = computed(
+  () => Number(global.userInfo?.isAdmin) === 1
+);
+
+const editDialogVisible = ref(false);
+const editForm = ref({
+  id: null,
+  userName: "",
+  hytAccount: "",
+  status: 1,
+  permissions: {
+    view: false,
+    edit: false,
+    export: false,
+  },
+});
+
+const editSaving = ref(false);
+
+const openEditDialog = (item) => {
+  editForm.value = {
+    id: item.id,
+    userName: item.userName || "",
+    hytAccount: item.hytAccount || "",
+    status: Number(item.status) === 0 ? 0 : 1,
+    permissions: {
+      view: !!item.permissions?.view,
+      edit: !!item.permissions?.edit,
+      export: !!item.permissions?.export,
+    },
+  };
+  editDialogVisible.value = true;
+};
+
+const closeEditDialog = () => {
+  editDialogVisible.value = false;
+};
+
+const handleSaveEdit = async () => {
+  if (editSaving.value || editForm.value.id == null) return;
+  editSaving.value = true;
+  try {
+    const res = await updateUser(editForm.value.id, {
+      status: Number(editForm.value.status),
+      permissions: {
+        view: !!editForm.value.permissions.view,
+        edit: !!editForm.value.permissions.edit,
+        export: !!editForm.value.permissions.export,
+      },
+    });
+    if (res?.code !== 200) {
+      ElMessage.error(res?.msg || "保存失败");
+      return;
+    }
+    ElMessage.success(res?.msg || "保存成功");
+    closeEditDialog();
+    await Promise.all([loadUserList(), loadUserCount()]);
+  } catch (error) {
+    console.error(error);
+    ElMessage.error("保存失败,请稍后重试");
+  } finally {
+    editSaving.value = false;
+  }
+};
+
+const statusTabs = computed(() => [
+  { label: "全部用户", value: "all", count: userCount.value.total },
+  { label: "已启用", value: "enabled", count: userCount.value.enabled },
+  { label: "已停用", value: "disabled", count: userCount.value.disabled },
+]);
+
+const filteredList = computed(() => {
+  if (statusFilter.value === "enabled") {
+    return userList.value.filter((item) => item.status === 1);
+  }
+  if (statusFilter.value === "disabled") {
+    return userList.value.filter((item) => item.status === 0);
+  }
+  return userList.value;
+});
+
+const getPermissionLabels = (item) => {
+  const labels = [];
+  if (item.permissions?.view) labels.push("查看");
+  if (item.permissions?.edit) labels.push("编辑");
+  if (item.permissions?.export) labels.push("导出");
+  return labels;
+};
+
+const loadUserList = async () => {
+  try {
+    const res = await getUserList();
+    if (res?.code !== 200) {
+      ElMessage.error(res?.msg || "获取用户列表失败");
+      return;
+    }
+    userList.value = Array.isArray(res.data) ? res.data : [];
+  } catch (error) {
+    console.error(error);
+    ElMessage.error("获取用户列表失败,请稍后重试");
+  }
+};
+
+const loadUserCount = async () => {
+  try {
+    const res = await getUserCount();
+    if (res?.code !== 200) {
+      ElMessage.error(res?.msg || "获取用户统计失败");
+      return;
+    }
+    const data = res.data || {};
+    userCount.value = {
+      total: data.total ?? 0,
+      enabled: data.enabled ?? 0,
+      disabled: data.disabled ?? 0,
+    };
+  } catch (error) {
+    console.error(error);
+    ElMessage.error("获取用户统计失败,请稍后重试");
+  }
+};
+
+onMounted(() => {
+  loadUserCount();
+  loadUserList();
+});
+</script>
+
+<style scoped lang="scss">
+.page-permission {
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+  width: 100%;
+  min-height: 100%;
+  padding: 14px 24px;
+  background: #f3f7fc;
+}
+
+.page-permission-title {
+  margin: 0;
+  font-family: PingFang SC, PingFang SC;
+  font-weight: 600;
+  font-size: 18px;
+  color: #16213a;
+  line-height: 25px;
+  text-align: left;
+  font-style: normal;
+}
+
+.page-permission-panel {
+  box-sizing: border-box;
+  padding: 0 16px 16px;
+  background: #ffffff;
+  border-radius: 4px;
+}
+
+.page-permission-tabs {
+  display: flex;
+  align-items: center;
+  gap: 0;
+  padding: 12px 0;
+}
+
+.page-permission-tab {
+  padding: 6px 16px;
+  border: 1px solid transparent;
+  border-radius: 2px;
+  background: transparent;
+  font-family: PingFang SC, PingFang SC;
+  font-weight: 400;
+  font-size: 14px;
+  color: #58657c;
+  line-height: 20px;
+  text-align: left;
+  font-style: normal;
+  cursor: pointer;
+
+  &.is-active {
+    padding: 6px 16px;
+    background: #f2f7fe;
+    border-radius: 2px;
+    border: 1px solid #2382e7;
+    font-family: PingFang SC, PingFang SC;
+    font-weight: 500;
+    font-size: 14px;
+    color: #2382e7;
+    line-height: 20px;
+    text-align: left;
+    font-style: normal;
+  }
+}
+
+.page-permission-table-scroll {
+  width: 100%;
+  overflow: hidden;
+  border-radius: 4px;
+}
+
+.page-permission-table {
+  width: 100%;
+  border-collapse: collapse;
+  table-layout: fixed;
+
+  th,
+  td {
+    border: 1px solid #dfe3e7;
+    text-align: left;
+    vertical-align: middle;
+  }
+
+  th {
+    padding: 10px 16px;
+    background: #edf1f6;
+    font-family: PingFang SC, PingFang SC;
+    font-weight: 400;
+    font-size: 14px;
+    color: #58657c;
+    line-height: 20px;
+  }
+
+  td {
+    padding: 18px 16px;
+    background: #ffffff;
+    font-family: PingFang SC, PingFang SC;
+    font-weight: 400;
+    font-size: 14px;
+    color: #16213a;
+    line-height: 20px;
+    text-align: left;
+  }
+
+  .col-name {
+    width: 18%;
+  }
+
+  .col-account {
+    width: 28%;
+  }
+
+  .col-status {
+    width: 16%;
+  }
+
+  .col-permission {
+    width: 26%;
+  }
+
+  .col-action {
+    width: 12%;
+  }
+}
+
+.page-permission-name {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.page-permission-admin-tag {
+  flex-shrink: 0;
+  padding: 1px 6px;
+  background: #2382e7;
+  border-radius: 11px;
+  font-family: PingFang SC, PingFang SC;
+  font-weight: 400;
+  font-size: 14px;
+  color: #ffffff;
+  line-height: 20px;
+  text-align: left;
+}
+
+.page-permission-status {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  font-family: PingFang SC, PingFang SC;
+  font-weight: 400;
+  font-size: 14px;
+  line-height: 20px;
+  text-align: left;
+
+  &.is-enabled {
+    color: #16213a;
+
+    .page-permission-status-dot {
+      background: #10b837;
+    }
+  }
+
+  &.is-disabled {
+    color: #9aa5b8;
+
+    .page-permission-status-dot {
+      background: #9aa5b8;
+    }
+  }
+}
+
+.page-permission-status-dot {
+  flex-shrink: 0;
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+}
+
+.page-permission-ops {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 12px;
+}
+
+.page-permission-op {
+  padding: 2px 8px;
+  background: #f2f7fe;
+  border-radius: 2px;
+  font-family: PingFang SC, PingFang SC;
+  font-weight: 400;
+  font-size: 14px;
+  color: #2382e7;
+  line-height: 20px;
+  text-align: left;
+  cursor: pointer;
+}
+
+.page-permission-action-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 0;
+  border: none;
+  background: transparent;
+  font-family: PingFang SC, PingFang SC;
+  font-weight: 400;
+  font-size: 14px;
+  color: #2382e7;
+  line-height: 20px;
+  cursor: pointer;
+}
+</style>
+
+<style lang="scss">
+.page-permission-edit-dialog.el-dialog {
+  --el-dialog-padding-primary: 0;
+  padding: 0;
+  overflow: hidden;
+  border-radius: 4px;
+
+  .el-dialog__header {
+    margin: 0;
+    padding: 16px;
+    background: #ffffff;
+    border-radius: 4px 4px 0 0;
+    border: 1px solid #e5ebf3;
+  }
+
+  .el-dialog__headerbtn {
+    top: 18.5px;
+    right: 16px;
+    width: 20px;
+    height: 20px;
+
+    .el-dialog__close {
+      font-size: 20px;
+      width: 20px;
+      height: 20px;
+      color: #9aa5b8;
+    }
+  }
+
+  .el-dialog__body {
+    margin: 0;
+    padding: 0;
+  }
+
+  .el-dialog__footer {
+    margin: 0;
+    padding: 12px;
+    background: #ffffff;
+    border-radius: 0 0 4px 4px;
+    border: 1px solid #e5ebf3;
+  }
+}
+
+.page-permission-edit-title {
+  font-family: PingFang SC, PingFang SC;
+  font-weight: 600;
+  font-size: 18px;
+  color: #16213a;
+  line-height: 25px;
+  text-align: left;
+}
+
+.page-permission-edit-middle {
+  box-sizing: border-box;
+  padding: 16px;
+  background: #f6f9fd;
+}
+
+.page-permission-edit-card {
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  gap: 24px;
+  padding: 16px 8px;
+  background: #ffffff;
+  border-radius: 2px;
+}
+
+.page-permission-edit-row {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.page-permission-edit-label {
+  box-sizing: border-box;
+  flex-shrink: 0;
+  width: 84px;
+  height: 20px;
+  font-family: PingFang SC, PingFang SC;
+  font-weight: 400;
+  font-size: 14px;
+  color: #16213a;
+  line-height: 20px;
+  text-align: left;
+}
+
+.page-permission-edit-input {
+  box-sizing: border-box;
+  width: 270px;
+  flex: 0 0 270px;
+  padding: 8px 12px;
+  background: #ffffff;
+  border: 1px solid #e5ebf3;
+  border-radius: 2px;
+  font-family: PingFang SC, PingFang SC;
+  font-weight: 400;
+  font-size: 14px;
+  color: #16213a;
+  line-height: 20px;
+  outline: none;
+}
+
+.page-permission-edit-options {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+
+  &.is-status {
+    gap: 16px;
+  }
+
+  &.is-permission {
+    gap: 8px;
+  }
+}
+
+.page-permission-edit-option {
+  position: relative;
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  font-family: PingFang SC, PingFang SC;
+  font-weight: 400;
+  font-size: 14px;
+  color: #16213a;
+  line-height: 20px;
+  text-align: left;
+  font-style: normal;
+  cursor: pointer;
+
+  > input {
+    position: absolute;
+    opacity: 0;
+    width: 0;
+    height: 0;
+    margin: 0;
+    pointer-events: none;
+  }
+}
+
+.page-permission-edit-footer {
+  display: flex;
+  justify-content: center;
+  gap: 24px;
+}
+
+.page-permission-edit-btn {
+  padding: 6px 16px;
+  border-radius: 2px;
+  font-family: PingFang SC, PingFang SC;
+  font-size: 14px;
+  line-height: 20px;
+  text-align: left;
+  cursor: pointer;
+
+  &.is-cancel {
+    font-weight: 400;
+    color: #58657c;
+    background: #ffffff;
+    border: 1px solid #e4eaf2;
+  }
+
+  &.is-primary {
+    font-weight: 500;
+    color: #ffffff;
+    background: #2382e7;
+    border: 1px solid #2382e7;
+    box-shadow: 0px 4px 10px 0px rgba(35, 130, 231, 0.2);
+  }
+}
+</style>

+ 44 - 2
src/router/index.js

@@ -1,4 +1,7 @@
 import { createRouter, createWebHistory } from "vue-router";
+import { getUserInfo } from "@/api/login";
+import globalStore from "@/store/modules/global";
+import { useTokenStore } from "@/store/token";
 
 const routes = [
   {
@@ -41,7 +44,7 @@ const routes = [
       {
         path: "overall-plan",
         name: "overallPlan",
-        component: () => import("@/pages/empty/index.vue"),
+        component: () => import("@/pages/home/overall-plan.vue"),
         meta: {
           title: "总体计划",
         },
@@ -49,7 +52,7 @@ const routes = [
       {
         path: "permission",
         name: "permission",
-        component: () => import("@/pages/empty/index.vue"),
+        component: () => import("@/pages/permission/index.vue"),
         meta: {
           title: "权限管理",
         },
@@ -70,6 +73,45 @@ const router = createRouter({
   },
 });
 
+const clearLoginAndToLogin = (next, redirect) => {
+  const global = globalStore();
+  global.logout();
+  next({
+    name: "login",
+    query: redirect ? { redirect } : undefined,
+  });
+};
+
+router.beforeEach(async (to, from, next) => {
+  if (to.name === "login") {
+    next();
+    return;
+  }
+
+  const tokenStore = useTokenStore();
+  if (!tokenStore.getAccessToken()) {
+    clearLoginAndToLogin(next, to.fullPath);
+    return;
+  }
+
+  try {
+    const res = await getUserInfo();
+    if (res?.code !== 200) {
+      clearLoginAndToLogin(next, to.fullPath);
+      return;
+    }
+    next();
+  } catch (error) {
+    console.error(error);
+    // 若请求过程中已因 401 清掉 token,则跳转登录;纯网络异常不误清
+    if (!tokenStore.getAccessToken()) {
+      clearLoginAndToLogin(next, to.fullPath);
+      return;
+    }
+    next();
+  }
+});
+
 router.afterEach((to) => {
   const defaultTitle = "前期研究前置报告计划管理系统";
   const title = to.meta && to.meta.title ? to.meta.title : defaultTitle;

+ 32 - 12
src/utils/request.js

@@ -2,6 +2,7 @@ import axios from "axios";
 import { ElMessage } from "element-plus";
 import { baseUrl, baseUrlPro, pathUrl } from "./baseUrl";
 import globalStore from "@/store/modules/global";
+import { useTokenStore } from "@/store/token";
 import { IsPC } from "./common.js";
 
 let isPC = IsPC();
@@ -82,6 +83,19 @@ service.interceptors.request.use(
       }
     }
 
+    try {
+      const tokenStore = useTokenStore();
+      const token = tokenStore.getAccessToken();
+      // 所有接口都传 Authorization;登录接口可显式传入空字符串
+      if (config.headers.Authorization === undefined) {
+        config.headers.Authorization = token ? `Bearer ${token}` : "";
+      }
+    } catch {
+      if (config.headers.Authorization === undefined) {
+        config.headers.Authorization = "";
+      }
+    }
+
     // 检测网络状态
     if (!navigator.onLine) {
       // 重置标志位,允许同一批次首次检测到网络问题时显示提示
@@ -147,14 +161,16 @@ service.interceptors.response.use(
       // 检查业务状态码是否为401
       if (response.data && response.data.code === 401) {
         const global = globalStore();
+        const tokenStore = useTokenStore();
+        const isLoggedIn = !!(
+          global.userId ||
+          global.WEBSID ||
+          tokenStore.getAccessToken()
+        );
 
-        const isLoggedIn = !!(global.userId || global.WEBSID);
-
-        // 只有在已登录状态下才执行退出登录操作
+        // 过期时清除登录信息
         if (isLoggedIn) {
           global.logout();
-
-          window.location.href = "login?redirect=" + encodeURIComponent("/");
         }
       }
 
@@ -171,17 +187,21 @@ service.interceptors.response.use(
     });
 
     // 处理HTTP状态码401
-    if (error?.response?.data && error?.response?.data?.code === 401) {
+    if (
+      error?.response?.status === 401 ||
+      error?.response?.data?.code === 401
+    ) {
       const global = globalStore();
+      const tokenStore = useTokenStore();
+      const isLoggedIn = !!(
+        global.userId ||
+        global.WEBSID ||
+        tokenStore.getAccessToken()
+      );
 
-      const isLoggedIn = !!(global.userId || global.WEBSID);
-
-      // 只有在已登录状态下才执行退出登录操作
+      // 过期时清除登录信息
       if (isLoggedIn) {
         global.logout();
-
-        // 跳转到登录页
-        window.location.href = "login?redirect=" + encodeURIComponent("/");
       }
     }
 

+ 1 - 3
vite.config.js

@@ -35,9 +35,7 @@ export default defineConfig({
     proxy: {
       "/dev-api": {
         changeOrigin: true, //是否允许跨域
-        // target: "https://ai.eiacloud.com/chat-api/", // 线上测试地址
-        // target: "http://192.168.0.3:8021", // 本地测试地址
-        target: "https://cktest.eiacloud.com/chat-api/", // 本地测试地址
+        target: "https://prpms.eiacloud.com/", // 本地测试地址
         rewrite: (path) => path.replace(/^\/dev-api/, ""),
       },
     },

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است