소스 검색

运营平台前端新框架功能对齐原版

skyline 2 주 전
부모
커밋
6a7263b550

+ 5 - 0
admin-web-new/src/api/admin.ts

@@ -34,3 +34,8 @@ export const getAdminRoleList = () => {
 export const updateUserRole = (data: { userId: number; roleIdList: number[] }) => {
   return http.request<any>("post", "/admin-user/updateRole", { data });
 };
+
+/** 删除操作员 */
+export const removeAdminUser = (id: number) => {
+  return http.request<any>("get", `/admin-user/delete/${id}`);
+};

+ 31 - 0
admin-web-new/src/api/department.ts

@@ -0,0 +1,31 @@
+import { http } from "@/utils/http";
+
+/** 获取部门树 */
+export const getDepartmentTree = () => {
+  return http.request<any>("post", "/department/tree");
+};
+
+/** 获取部门列表 */
+export const getDepartmentList = (data?: object) => {
+  return http.request<any>("post", "/department/list", { data });
+};
+
+/** 获取部门详情 */
+export const getDepartmentDetail = (id: number) => {
+  return http.request<any>("get", `/department/detail/${id}`);
+};
+
+/** 新增部门 */
+export const addDepartment = (data: object) => {
+  return http.request<any>("post", "/department/add", { data });
+};
+
+/** 修改部门 */
+export const modifyDepartment = (data: object) => {
+  return http.request<any>("post", "/department/modify", { data });
+};
+
+/** 删除部门 */
+export const removeDepartment = (id: number) => {
+  return http.request<any>("get", `/department/remove/${id}`);
+};

+ 6 - 0
admin-web-new/src/api/error-log.ts

@@ -0,0 +1,6 @@
+import { http } from "@/utils/http";
+
+/** 获取错误日志列表 */
+export const getErrorLogList = (data?: object) => {
+  return http.request<any>("post", "/errorLog/list", { data });
+};

+ 26 - 0
admin-web-new/src/api/message-template.ts

@@ -0,0 +1,26 @@
+import { http } from "@/utils/http";
+
+/** 获取消息模板列表 */
+export const getTemplateList = (params?: object) => {
+  return http.request<any>("get", "/messageTemplate/list", { params });
+};
+
+/** 新增消息模板 */
+export const createTemplate = (data: object) => {
+  return http.request<any>("post", "/messageTemplate/create", { data });
+};
+
+/** 修改消息模板 */
+export const updateTemplate = (data: object) => {
+  return http.request<any>("post", "/messageTemplate/update", { data });
+};
+
+/** 删除消息模板 */
+export const deleteTemplate = (id: number) => {
+  return http.request<any>("post", `/messageTemplate/delete/${id}`);
+};
+
+/** 使用模板发送消息 */
+export const sendByTemplate = (data: object) => {
+  return http.request<any>("post", "/message/sendByTemplate", { data });
+};

+ 31 - 0
admin-web-new/src/api/message.ts

@@ -0,0 +1,31 @@
+import { http } from "@/utils/http";
+
+/** 获取消息列表 */
+export const getMessageList = (params?: object) => {
+  return http.request<any>("get", "/message/list", { params });
+};
+
+/** 发送消息 */
+export const sendMessage = (data: object) => {
+  return http.request<any>("post", "/message/send", { data });
+};
+
+/** 删除消息 */
+export const deleteMessage = (id: number) => {
+  return http.request<any>("post", `/message/delete/${id}`);
+};
+
+/** 批量标记已读 */
+export const batchReadMessage = (data: object) => {
+  return http.request<any>("post", "/message/batchRead", { data });
+};
+
+/** 批量删除消息 */
+export const batchDeleteMessage = (data: object) => {
+  return http.request<any>("post", "/message/batchDelete", { data });
+};
+
+/** 搜索管理员用户 */
+export const searchAdminUser = (params?: object) => {
+  return http.request<any>("get", "/adminUser/search", { params });
+};

+ 28 - 1
admin-web-new/src/router/modules/admin.ts

@@ -233,7 +233,34 @@ export default {
           component: () => import("@/views/admin/log/index.vue"),
           meta: {
             icon: "ri:file-list-2-line",
-            title: "操作日志"
+            title: "系统日志"
+          }
+        },
+        {
+          path: "/admin/system/department",
+          name: "AdminDepartment",
+          component: () => import("@/views/admin/department/index.vue"),
+          meta: {
+            icon: "ri:organization-chart",
+            title: "部门管理"
+          }
+        },
+        {
+          path: "/admin/system/message",
+          name: "AdminMessage",
+          component: () => import("@/views/admin/message/index.vue"),
+          meta: {
+            icon: "ri:mail-line",
+            title: "消息管理"
+          }
+        },
+        {
+          path: "/admin/system/template",
+          name: "AdminMessageTemplate",
+          component: () => import("@/views/admin/template/index.vue"),
+          meta: {
+            icon: "ri:file-copy-line",
+            title: "消息模板"
           }
         },
         {

+ 172 - 0
admin-web-new/src/views/admin/account/dialog.vue

@@ -0,0 +1,172 @@
+<script setup lang="ts">
+import { reactive, ref } from "vue";
+import { addAdminUser, modifyAdminUser, getAdminUserDetail, getAdminRoleList, updateUserRole } from "@/api/admin";
+import { ElMessage } from "element-plus";
+
+const emit = defineEmits(["refresh"]);
+
+const formRef = ref();
+const dialogVisible = ref(false);
+const dialogType = ref<"add" | "edit">("add");
+const loading = ref(false);
+
+const state = reactive({
+  ruleForm: {
+    id: null as number | null,
+    username: "",
+    nickname: "",
+    mobilePhone: "",
+    password: "",
+    avatar: "",
+    status: 1,
+    stationIdList: [] as string[]
+  },
+  rules: {
+    username: [{ required: true, message: "请输入用户名", trigger: "blur" }],
+    nickname: [{ required: true, message: "请输入昵称", trigger: "blur" }],
+    mobilePhone: [
+      { required: true, message: "请输入手机号", trigger: "blur" },
+      { pattern: /^1[3-9]\d{9}$/, message: "请输入正确的手机号", trigger: "blur" }
+    ],
+    password: [{ required: true, message: "请输入密码", trigger: "blur" }]
+  },
+  roleList: [] as any[],
+  checkRoleIdList: [] as number[]
+});
+
+const resetForm = () => {
+  state.ruleForm = {
+    id: null,
+    username: "",
+    nickname: "",
+    mobilePhone: "",
+    password: "",
+    avatar: "",
+    status: 1,
+    stationIdList: []
+  };
+  state.checkRoleIdList = [];
+  formRef.value?.resetFields();
+};
+
+const getTitle = () => {
+  return dialogType.value === "add" ? "新增运维账户" : "编辑运维账户";
+};
+
+const isEdit = () => dialogType.value === "edit";
+
+const open = async (type: "add" | "edit", row?: any) => {
+  dialogType.value = type;
+  resetForm();
+
+  // 加载角色列表
+  try {
+    const roles = await getAdminRoleList();
+    state.roleList = roles || [];
+    if (type === "add" && state.roleList.length > 0) {
+      state.checkRoleIdList = [state.roleList[0].id];
+    }
+  } catch (e) {
+    // ignore
+  }
+
+  if (type === "edit" && row) {
+    loading.value = true;
+    try {
+      const res = await getAdminUserDetail(row.id);
+      const { adminUser, roles: userRoles } = res || {};
+      Object.assign(state.ruleForm, adminUser || {});
+      if (userRoles) {
+        state.checkRoleIdList = userRoles.map((k: any) => k.roleId);
+      }
+    } catch (e) {
+      ElMessage.error("获取详情失败");
+    } finally {
+      loading.value = false;
+    }
+  }
+  dialogVisible.value = true;
+};
+
+const handleClose = () => {
+  dialogVisible.value = false;
+  resetForm();
+};
+
+const handleSubmit = async () => {
+  const valid = await formRef.value?.validate().catch(() => false);
+  if (!valid) return;
+
+  loading.value = true;
+  try {
+    if (dialogType.value === "add") {
+      const res = await addAdminUser(state.ruleForm);
+      if (res?.id) {
+        state.ruleForm.id = res.id;
+      }
+    } else {
+      await modifyAdminUser(state.ruleForm);
+    }
+
+    // 更新角色
+    if (state.ruleForm.id) {
+      await updateUserRole({
+        userId: state.ruleForm.id,
+        roleIdList: state.checkRoleIdList
+      });
+    }
+
+    ElMessage.success("操作成功");
+    handleClose();
+    emit("refresh");
+  } catch (e) {
+    ElMessage.error("操作失败");
+  } finally {
+    loading.value = false;
+  }
+};
+
+defineExpose({ open });
+</script>
+
+<template>
+  <el-dialog
+    v-model="dialogVisible"
+    :title="getTitle()"
+    width="650px"
+    destroy-on-close
+    :close-on-click-modal="false"
+  >
+    <el-form ref="formRef" :model="state.ruleForm" :rules="state.rules" label-width="120px" :disabled="loading">
+      <el-form-item label="用户名" prop="username">
+        <el-input v-model="state.ruleForm.username" placeholder="请输入用户名" clearable />
+      </el-form-item>
+      <el-form-item label="昵称" prop="nickname">
+        <el-input v-model="state.ruleForm.nickname" placeholder="请输入昵称" clearable />
+      </el-form-item>
+      <el-form-item label="密码" prop="password" v-if="!isEdit()">
+        <el-input
+          v-model="state.ruleForm.password"
+          type="password"
+          placeholder="请输入密码"
+          show-password
+          clearable
+        />
+      </el-form-item>
+      <el-form-item label="手机号" prop="mobilePhone">
+        <el-input v-model="state.ruleForm.mobilePhone" placeholder="请输入手机号" clearable />
+      </el-form-item>
+      <el-form-item label="角色">
+        <el-checkbox-group v-model="state.checkRoleIdList">
+          <el-checkbox v-for="role in state.roleList" :key="role.id" :label="role.id">
+            {{ role.roleName }}
+          </el-checkbox>
+        </el-checkbox-group>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="handleClose">取消</el-button>
+      <el-button :loading="loading" type="primary" @click="handleSubmit">确定</el-button>
+    </template>
+  </el-dialog>
+</template>

+ 29 - 4
admin-web-new/src/views/admin/account/index.vue

@@ -1,12 +1,15 @@
 <script setup lang="ts">
 import { reactive, onMounted, ref, nextTick } from "vue";
-import { getAdminUserList } from "@/api/admin";
+import { getAdminUserList, removeAdminUser } from "@/api/admin";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
 import { ExtDLabel, ExtDSelect } from "@/components/ExtForm";
+import { ElMessage, ElMessageBox } from "element-plus";
+import AccountDialog from "./dialog.vue";
 
 defineOptions({ name: "AdminAccount" });
 
 const queryRef = ref();
+const dialogRef = ref();
 
 interface ColumnItem {
   label: string;
@@ -92,6 +95,19 @@ const handleReset = () => {
   };
   loadData(true);
 };
+
+const handleAdd = () => dialogRef.value?.open("add");
+const handleEdit = (row: any) => dialogRef.value?.open("edit", row);
+const handleDelete = (row: any) => {
+  ElMessageBox.confirm(`确定要删除用户『${row.username}』吗?`, "提示", {
+    confirmButtonText: "确定", cancelButtonText: "取消", type: "warning"
+  }).then(() => {
+    removeAdminUser(row.id).then(() => {
+      ElMessage.success("删除成功");
+      loadData(true);
+    });
+  }).catch(() => {});
+};
 </script>
 
 <template>
@@ -129,6 +145,13 @@ const handleReset = () => {
           >
             重置
           </el-button>
+          <el-button
+            type="success"
+            :icon="useRenderIcon('ri/add-line')"
+            @click="handleAdd"
+          >
+            新增
+          </el-button>
         </el-form-item>
       </el-form>
       <el-table
@@ -159,9 +182,9 @@ const handleReset = () => {
           </template>
         </el-table-column>
         <el-table-column label="操作" width="150" fixed="right">
-          <template #default>
-            <el-button type="primary" link size="small">编辑</el-button>
-            <el-button type="info" link size="small">详情</el-button>
+          <template #default="{ row }">
+            <el-button type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
+            <el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
           </template>
         </el-table-column>
       </el-table>
@@ -177,6 +200,8 @@ const handleReset = () => {
         />
       </div>
     </el-card>
+
+    <AccountDialog ref="dialogRef" @refresh="loadData" />
   </div>
 </template>
 

+ 163 - 0
admin-web-new/src/views/admin/banner/dialog.vue

@@ -0,0 +1,163 @@
+<script setup lang="ts">
+import { reactive, ref } from "vue";
+import { addBanner, modifyBanner, getBannerDetail } from "@/api/banner";
+import { uploadFile } from "@/api/file";
+import { ElMessage } from "element-plus";
+
+const emit = defineEmits(["refresh"]);
+
+const formRef = ref();
+const dialogVisible = ref(false);
+const dialogType = ref<"add" | "edit">("add");
+const loading = ref(false);
+
+const state = reactive({
+  ruleForm: {
+    id: null as number | null,
+    bannerUrl: "",
+    bannerDesc: "",
+    linkUrl: "",
+    startTime: "",
+    endTime: "",
+    status: 1,
+    remark: ""
+  },
+  rules: {
+    startTime: [{ required: true, message: "请选择开始时间", trigger: "change" }],
+    endTime: [{ required: true, message: "请选择结束时间", trigger: "change" }]
+  }
+});
+
+const resetForm = () => {
+  state.ruleForm = {
+    id: null,
+    bannerUrl: "",
+    bannerDesc: "",
+    linkUrl: "",
+    startTime: "",
+    endTime: "",
+    status: 1,
+    remark: ""
+  };
+  formRef.value?.resetFields();
+};
+
+const getTitle = () => {
+  return dialogType.value === "add" ? "新增横幅广告" : "编辑横幅广告";
+};
+
+const open = async (type: "add" | "edit", row?: any) => {
+  dialogType.value = type;
+  resetForm();
+  if (type === "edit" && row) {
+    loading.value = true;
+    try {
+      const res = await getBannerDetail(row.id);
+      Object.assign(state.ruleForm, res);
+    } catch (e) {
+      ElMessage.error("获取详情失败");
+    } finally {
+      loading.value = false;
+    }
+  }
+  dialogVisible.value = true;
+};
+
+const handleClose = () => {
+  dialogVisible.value = false;
+  resetForm();
+};
+
+const handleUploadSuccess = (url: string) => {
+  state.ruleForm.bannerUrl = url;
+};
+
+const handleSubmit = async () => {
+  const valid = await formRef.value?.validate().catch(() => false);
+  if (!valid) return;
+
+  if (new Date(state.ruleForm.startTime).getTime() > new Date(state.ruleForm.endTime).getTime()) {
+    ElMessage.error("开始时间不能晚于结束时间");
+    return;
+  }
+
+  loading.value = true;
+  try {
+    if (dialogType.value === "add") {
+      await addBanner(state.ruleForm);
+    } else {
+      await modifyBanner(state.ruleForm);
+    }
+    ElMessage.success("操作成功");
+    handleClose();
+    emit("refresh");
+  } catch (e) {
+    ElMessage.error("操作失败");
+  } finally {
+    loading.value = false;
+  }
+};
+
+defineExpose({ open });
+</script>
+
+<template>
+  <el-dialog
+    v-model="dialogVisible"
+    :title="getTitle()"
+    width="820px"
+    destroy-on-close
+    :close-on-click-modal="false"
+  >
+    <el-form ref="formRef" :model="state.ruleForm" :rules="state.rules" label-width="125px" :disabled="loading">
+      <el-form-item label="Banner图片" prop="bannerUrl">
+        <el-input v-model="state.ruleForm.bannerUrl" placeholder="请输入图片地址" clearable />
+      </el-form-item>
+      <el-form-item label="描述" prop="bannerDesc">
+        <el-input
+          v-model="state.ruleForm.bannerDesc"
+          placeholder="请输入描述"
+          maxlength="200"
+          show-word-limit
+          type="textarea"
+          :rows="2"
+        />
+      </el-form-item>
+      <el-form-item label="关联跳转地址" prop="linkUrl">
+        <el-input v-model="state.ruleForm.linkUrl" placeholder="请输入跳转地址" clearable />
+      </el-form-item>
+      <el-form-item label="开始时间" prop="startTime">
+        <el-date-picker
+          v-model="state.ruleForm.startTime"
+          type="datetime"
+          placeholder="选择开始时间"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          style="width: 100%"
+        />
+      </el-form-item>
+      <el-form-item label="结束时间" prop="endTime">
+        <el-date-picker
+          v-model="state.ruleForm.endTime"
+          type="datetime"
+          placeholder="选择结束时间"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          style="width: 100%"
+        />
+      </el-form-item>
+      <el-form-item label="备注" prop="remark">
+        <el-input
+          v-model="state.ruleForm.remark"
+          placeholder="备注信息"
+          maxlength="500"
+          show-word-limit
+          type="textarea"
+          :rows="3"
+        />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="handleClose">取消</el-button>
+      <el-button :loading="loading" type="primary" @click="handleSubmit">确定</el-button>
+    </template>
+  </el-dialog>
+</template>

+ 31 - 4
admin-web-new/src/views/admin/banner/index.vue

@@ -1,11 +1,14 @@
 <script setup lang="ts">
 import { reactive, onMounted, ref, nextTick } from "vue";
-import { getBannerList } from "@/api/banner";
+import { getBannerList, removeBanner } from "@/api/banner";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { ElMessage, ElMessageBox } from "element-plus";
+import BannerDialog from "./dialog.vue";
 
 defineOptions({ name: "AdminBanner" });
 
 const queryRef = ref();
+const dialogRef = ref();
 
 interface ColumnItem {
   label: string;
@@ -91,6 +94,20 @@ const handleReset = () => {
   };
   loadData(true);
 };
+
+const handleAdd = () => dialogRef.value?.open("add");
+const handleEdit = (row: any) => dialogRef.value?.open("edit", row);
+const handleDelete = (row: any) => {
+  ElMessageBox.confirm(
+    `确定要删除此横幅广告吗?`, "提示",
+    { confirmButtonText: "确定", cancelButtonText: "取消", type: "warning" }
+  ).then(() => {
+    removeBanner(row.id).then(() => {
+      ElMessage.success("删除成功");
+      loadData(true);
+    });
+  }).catch(() => {});
+};
 </script>
 
 <template>
@@ -114,6 +131,13 @@ const handleReset = () => {
           >
             重置
           </el-button>
+          <el-button
+            type="success"
+            :icon="useRenderIcon('ri/add-line')"
+            @click="handleAdd"
+          >
+            新增
+          </el-button>
         </el-form-item>
       </el-form>
       <el-table
@@ -136,9 +160,10 @@ const handleReset = () => {
         <el-table-column prop="linkUrl" label="关联跳转地址" width="300" show-overflow-tooltip />
         <el-table-column prop="bannerDesc" label="描述" width="200" show-overflow-tooltip />
         <el-table-column prop="createTime" label="创建时间" width="180" />
-        <el-table-column label="操作" width="100" fixed="right">
-          <template #default>
-            <el-button type="primary" link size="small">编辑</el-button>
+        <el-table-column label="操作" width="150" fixed="right">
+          <template #default="{ row }">
+            <el-button type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
+            <el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
           </template>
         </el-table-column>
       </el-table>
@@ -154,6 +179,8 @@ const handleReset = () => {
         />
       </div>
     </el-card>
+
+    <BannerDialog ref="dialogRef" @refresh="loadData" />
   </div>
 </template>
 

+ 136 - 0
admin-web-new/src/views/admin/department/dialog.vue

@@ -0,0 +1,136 @@
+<script setup lang="ts">
+import { reactive, ref } from "vue";
+import { addDepartment, modifyDepartment, getDepartmentDetail } from "@/api/department";
+import { ElMessage } from "element-plus";
+
+const emit = defineEmits(["refresh"]);
+
+const formRef = ref();
+const dialogVisible = ref(false);
+const dialogType = ref<"add" | "edit">("add");
+const loading = ref(false);
+
+const state = reactive({
+  ruleForm: {
+    id: null as number | null,
+    parentId: null as number | null,
+    parentName: "",
+    name: "",
+    fullName: "",
+    weight: 0,
+    status: 1,
+    level: 1,
+    leaderIdList: [] as number[],
+    disLeaderIdList: [] as number[],
+    remark: ""
+  },
+  rules: {
+    name: [{ required: true, message: "请输入部门名称", trigger: "blur" }]
+  }
+});
+
+const resetForm = () => {
+  state.ruleForm = {
+    id: null,
+    parentId: null,
+    parentName: "",
+    name: "",
+    fullName: "",
+    weight: 0,
+    status: 1,
+    level: 1,
+    leaderIdList: [],
+    disLeaderIdList: [],
+    remark: ""
+  };
+  formRef.value?.resetFields();
+};
+
+const getTitle = () => {
+  return dialogType.value === "add" ? "新增部门" : "编辑部门";
+};
+
+const open = async (type: "add" | "edit", row?: any) => {
+  dialogType.value = type;
+  resetForm();
+  if (type === "edit" && row) {
+    loading.value = true;
+    try {
+      const res = await getDepartmentDetail(row.id);
+      Object.assign(state.ruleForm, res);
+    } catch (e) {
+      ElMessage.error("获取详情失败");
+    } finally {
+      loading.value = false;
+    }
+  }
+  dialogVisible.value = true;
+};
+
+const handleClose = () => {
+  dialogVisible.value = false;
+  resetForm();
+};
+
+const handleSubmit = async () => {
+  const valid = await formRef.value?.validate().catch(() => false);
+  if (!valid) return;
+
+  loading.value = true;
+  try {
+    if (dialogType.value === "add") {
+      await addDepartment(state.ruleForm);
+    } else {
+      await modifyDepartment(state.ruleForm);
+    }
+    ElMessage.success("操作成功");
+    handleClose();
+    emit("refresh");
+  } catch (e) {
+    ElMessage.error("操作失败");
+  } finally {
+    loading.value = false;
+  }
+};
+
+defineExpose({ open });
+</script>
+
+<template>
+  <el-dialog
+    v-model="dialogVisible"
+    :title="getTitle()"
+    width="650px"
+    destroy-on-close
+    :close-on-click-modal="false"
+  >
+    <el-form ref="formRef" :model="state.ruleForm" :rules="state.rules" label-width="120px" :disabled="loading">
+      <el-form-item label="上级部门" prop="parentId" v-if="state.ruleForm.level !== 1">
+        <el-input v-model="state.ruleForm.parentName" placeholder="上级部门" readonly />
+      </el-form-item>
+      <el-form-item label="部门名称" prop="name">
+        <el-input v-model="state.ruleForm.name" placeholder="请输入部门名称" clearable />
+      </el-form-item>
+      <el-form-item label="部门全称" prop="fullName">
+        <el-input v-model="state.ruleForm.fullName" placeholder="请输入部门全称" clearable />
+      </el-form-item>
+      <el-form-item label="排序" prop="weight">
+        <el-input-number v-model="state.ruleForm.weight" :min="0" :max="999" />
+      </el-form-item>
+      <el-form-item label="描述" prop="remark">
+        <el-input
+          v-model="state.ruleForm.remark"
+          placeholder="请输入部门描述"
+          maxlength="150"
+          show-word-limit
+          type="textarea"
+          :rows="3"
+        />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="handleClose">取消</el-button>
+      <el-button :loading="loading" type="primary" @click="handleSubmit">确定</el-button>
+    </template>
+  </el-dialog>
+</template>

+ 288 - 0
admin-web-new/src/views/admin/department/index.vue

@@ -0,0 +1,288 @@
+<script setup lang="ts">
+import { reactive, onMounted, ref, nextTick } from "vue";
+import { getDepartmentTree, getDepartmentList, removeDepartment } from "@/api/department";
+import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { ElMessage, ElMessageBox } from "element-plus";
+import DepartmentDialog from "./dialog.vue";
+
+defineOptions({ name: "AdminDepartment" });
+
+const queryRef = ref();
+const tableRef = ref();
+const dialogRef = ref();
+const treeRef = ref();
+
+const state = reactive({
+  formQuery: {
+    name: "",
+    parent: null as number | null
+  },
+  pageQuery: {
+    pageNum: 1,
+    pageSize: 10,
+    total: 0
+  },
+  tableData: {
+    height: 500,
+    data: [] as Array<any>,
+    loading: false,
+    columns: [
+      { label: "部门名称", prop: "name", width: 160 },
+      { label: "上级部门", prop: "parentName", width: 160 },
+      { label: "部门全称", prop: "fullName", width: 200 },
+      { label: "部门层级", prop: "level", width: 100 },
+      { label: "排序", prop: "weight", width: 80 },
+      { label: "更新时间", prop: "updateAt", width: 180 }
+    ]
+  },
+  treeData: [] as any[],
+  filterText: "",
+  selectedNode: null as any
+});
+
+onMounted(() => {
+  loadTreeData();
+  loadData();
+  nextTick(() => {
+    const bodyHeight = document.body.clientHeight;
+    const queryHeight = queryRef.value?.$el?.clientHeight || 0;
+    state.tableData.height = bodyHeight - queryHeight - 300;
+  });
+});
+
+const loadTreeData = () => {
+  getDepartmentTree().then((res: any) => {
+    state.treeData = res || [];
+  });
+};
+
+const loadData = (refresh: boolean = false) => {
+  if (refresh) {
+    state.pageQuery.pageNum = 1;
+  }
+  state.tableData.loading = true;
+  getDepartmentList({
+    ...state.formQuery,
+    ...state.pageQuery
+  })
+    .then((res: any) => {
+      const { list, total } = res || {};
+      state.tableData.data = list || [];
+      state.pageQuery.total = total || 0;
+    })
+    .catch(() => {
+      state.tableData.data = [];
+    })
+    .finally(() => {
+      state.tableData.loading = false;
+    });
+};
+
+const handleNodeClick = (data: any) => {
+  if (state.selectedNode?.id === data.id) {
+    state.selectedNode = null;
+    state.formQuery.parent = null;
+  } else {
+    state.selectedNode = data;
+    state.formQuery.parent = data.id;
+  }
+  loadData(true);
+};
+
+const filterNode = (value: string, data: any) => {
+  if (!value) return true;
+  return data.name?.includes(value);
+};
+
+const handleFilterChange = () => {
+  treeRef.value?.filter(state.filterText);
+};
+
+const handleSizeChange = (size: number) => {
+  state.pageQuery.pageSize = size;
+  loadData(true);
+};
+
+const handleCurrentChange = (page: number) => {
+  state.pageQuery.pageNum = page;
+  loadData();
+};
+
+const handleSearch = () => {
+  loadData(true);
+};
+
+const handleReset = () => {
+  state.formQuery = {
+    name: "",
+    parent: null
+  };
+  state.selectedNode = null;
+  loadData(true);
+};
+
+const handleAdd = () => {
+  dialogRef.value?.open("add");
+};
+
+const handleEdit = (row: any) => {
+  dialogRef.value?.open("edit", row);
+};
+
+const handleDelete = (row: any) => {
+  ElMessageBox.confirm(
+    `确定要删除部门『${row.name}』吗?`,
+    "提示",
+    { confirmButtonText: "确定", cancelButtonText: "取消", type: "warning" }
+  ).then(() => {
+    removeDepartment(row.id).then(() => {
+      ElMessage.success("删除成功");
+      loadTreeData();
+      loadData(true);
+    });
+  }).catch(() => {});
+};
+</script>
+
+<template>
+  <div class="page-container">
+    <el-row :gutter="15">
+      <el-col :span="6">
+        <el-card shadow="hover" class="tree-card">
+          <el-input
+            v-model="state.filterText"
+            placeholder="搜索部门"
+            clearable
+            @input="handleFilterChange"
+            class="mb-3"
+          />
+          <el-tree
+            ref="treeRef"
+            :data="state.treeData"
+            :props="{ label: 'name', children: 'children' }"
+            node-key="id"
+            highlight-current
+            :filter-node-method="filterNode"
+            @node-click="handleNodeClick"
+          />
+        </el-card>
+      </el-col>
+      <el-col :span="18">
+        <el-card shadow="hover">
+          <el-form ref="queryRef" :model="state.formQuery" inline class="search-form">
+            <el-form-item label="部门名称">
+              <el-input
+                v-model="state.formQuery.name"
+                placeholder="请输入部门名称"
+                clearable
+                style="width: 160px"
+                @keyup.enter="handleSearch"
+              />
+            </el-form-item>
+            <el-form-item>
+              <el-button
+                type="primary"
+                :icon="useRenderIcon('ri/search-line')"
+                @click="handleSearch"
+              >
+                查询
+              </el-button>
+              <el-button
+                :icon="useRenderIcon('ri/refresh-line')"
+                @click="handleReset"
+              >
+                重置
+              </el-button>
+              <el-button
+                type="success"
+                :icon="useRenderIcon('ri/add-line')"
+                @click="handleAdd"
+              >
+                新增部门
+              </el-button>
+            </el-form-item>
+          </el-form>
+
+          <el-table
+            ref="tableRef"
+            v-loading="state.tableData.loading"
+            :data="state.tableData.data"
+            :height="state.tableData.height"
+            border
+            stripe
+          >
+            <template #empty>
+              <el-empty description="暂无数据" />
+            </template>
+            <el-table-column
+              v-for="col in state.tableData.columns"
+              :key="col.prop"
+              :prop="col.prop"
+              :label="col.label"
+              :width="col.width"
+              show-overflow-tooltip
+            />
+            <el-table-column label="操作" width="150" fixed="right">
+              <template #default="{ row }">
+                <el-button type="primary" link size="small" @click="handleEdit(row)">修改</el-button>
+                <el-button
+                  type="danger"
+                  link
+                  size="small"
+                  :disabled="row.parent === 0"
+                  @click="handleDelete(row)"
+                >
+                  删除
+                </el-button>
+              </template>
+            </el-table-column>
+          </el-table>
+
+          <div class="pagination-container">
+            <el-pagination
+              v-model:current-page="state.pageQuery.pageNum"
+              v-model:page-size="state.pageQuery.pageSize"
+              :total="state.pageQuery.total"
+              :page-sizes="[10, 20, 50, 100]"
+              layout="total, sizes, prev, pager, next, jumper"
+              @size-change="handleSizeChange"
+              @current-change="handleCurrentChange"
+            />
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <DepartmentDialog ref="dialogRef" @refresh="() => { loadTreeData(); loadData(true); }" />
+  </div>
+</template>
+
+<style scoped lang="scss">
+.page-container {
+  padding: 15px;
+}
+
+.tree-card {
+  height: 100%;
+
+  :deep(.el-tree) {
+    .el-tree-node__content {
+      height: 36px;
+    }
+  }
+}
+
+.search-form {
+  margin-bottom: 15px;
+}
+
+.pagination-container {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 15px;
+}
+
+.mb-3 {
+  margin-bottom: 12px;
+}
+</style>

+ 112 - 0
admin-web-new/src/views/admin/faq/dialog.vue

@@ -0,0 +1,112 @@
+<script setup lang="ts">
+import { reactive, ref } from "vue";
+import { addFaq, modifyFaq, getFaqDetail } from "@/api/faq";
+import { ElMessage } from "element-plus";
+
+const emit = defineEmits(["refresh"]);
+
+const formRef = ref();
+const dialogVisible = ref(false);
+const dialogType = ref<"add" | "edit">("add");
+const loading = ref(false);
+
+const state = reactive({
+  ruleForm: {
+    id: null as number | null,
+    question: "",
+    answer: "",
+    status: 1
+  },
+  rules: {
+    question: [{ required: true, message: "请输入问题", trigger: "blur" }],
+    answer: [{ required: true, message: "请输入答案", trigger: "blur" }]
+  }
+});
+
+const resetForm = () => {
+  state.ruleForm = {
+    id: null,
+    question: "",
+    answer: "",
+    status: 1
+  };
+  formRef.value?.resetFields();
+};
+
+const getTitle = () => {
+  return dialogType.value === "add" ? "新增常见问题" : "编辑常见问题";
+};
+
+const open = async (type: "add" | "edit", row?: any) => {
+  dialogType.value = type;
+  resetForm();
+  if (type === "edit" && row) {
+    loading.value = true;
+    try {
+      const res = await getFaqDetail(row.id);
+      Object.assign(state.ruleForm, res);
+    } catch (e) {
+      ElMessage.error("获取详情失败");
+    } finally {
+      loading.value = false;
+    }
+  }
+  dialogVisible.value = true;
+};
+
+const handleClose = () => {
+  dialogVisible.value = false;
+  resetForm();
+};
+
+const handleSubmit = async () => {
+  const valid = await formRef.value?.validate().catch(() => false);
+  if (!valid) return;
+
+  loading.value = true;
+  try {
+    if (dialogType.value === "add") {
+      await addFaq(state.ruleForm);
+    } else {
+      await modifyFaq(state.ruleForm);
+    }
+    ElMessage.success("操作成功");
+    handleClose();
+    emit("refresh");
+  } catch (e) {
+    ElMessage.error("操作失败");
+  } finally {
+    loading.value = false;
+  }
+};
+
+defineExpose({ open });
+</script>
+
+<template>
+  <el-dialog
+    v-model="dialogVisible"
+    :title="getTitle()"
+    width="820px"
+    destroy-on-close
+    :close-on-click-modal="false"
+  >
+    <el-form ref="formRef" :model="state.ruleForm" :rules="state.rules" label-width="100px" :disabled="loading">
+      <el-form-item label="问题" prop="question">
+        <el-input v-model="state.ruleForm.question" placeholder="请输入问题" clearable />
+      </el-form-item>
+      <el-form-item label="答案" prop="answer">
+        <el-input
+          v-model="state.ruleForm.answer"
+          placeholder="请输入答案"
+          type="textarea"
+          :rows="6"
+        />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="handleClose">取消</el-button>
+      <el-button :loading="loading" type="primary" @click="handleSubmit">确定</el-button>
+    </template>
+  </el-dialog>
+</template>

+ 29 - 4
admin-web-new/src/views/admin/faq/index.vue

@@ -1,11 +1,14 @@
 <script setup lang="ts">
 import { reactive, onMounted, ref, nextTick } from "vue";
-import { getFaqList } from "@/api/faq";
+import { getFaqList, removeFaq } from "@/api/faq";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { ElMessage, ElMessageBox } from "element-plus";
+import FaqDialog from "./dialog.vue";
 
 defineOptions({ name: "AdminFaq" });
 
 const queryRef = ref();
+const dialogRef = ref();
 
 interface ColumnItem {
   label: string;
@@ -84,6 +87,19 @@ const handleReset = () => {
   };
   loadData(true);
 };
+
+const handleAdd = () => dialogRef.value?.open("add");
+const handleEdit = (row: any) => dialogRef.value?.open("edit", row);
+const handleDelete = (row: any) => {
+  ElMessageBox.confirm("确定要删除此问题吗?", "提示", {
+    confirmButtonText: "确定", cancelButtonText: "取消", type: "warning"
+  }).then(() => {
+    removeFaq(row.id).then(() => {
+      ElMessage.success("删除成功");
+      loadData(true);
+    });
+  }).catch(() => {});
+};
 </script>
 
 <template>
@@ -107,6 +123,13 @@ const handleReset = () => {
           >
             重置
           </el-button>
+          <el-button
+            type="success"
+            :icon="useRenderIcon('ri/add-line')"
+            @click="handleAdd"
+          >
+            新增
+          </el-button>
         </el-form-item>
       </el-form>
       <el-table
@@ -128,9 +151,9 @@ const handleReset = () => {
           show-overflow-tooltip
         />
         <el-table-column label="操作" width="150" fixed="right">
-          <template #default>
-            <el-button type="primary" link size="small">编辑</el-button>
-            <el-button type="info" link size="small">详情</el-button>
+          <template #default="{ row }">
+            <el-button type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
+            <el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
           </template>
         </el-table-column>
       </el-table>
@@ -146,6 +169,8 @@ const handleReset = () => {
         />
       </div>
     </el-card>
+
+    <FaqDialog ref="dialogRef" @refresh="loadData" />
   </div>
 </template>
 

+ 132 - 0
admin-web-new/src/views/admin/feedback/dialog.vue

@@ -0,0 +1,132 @@
+<script setup lang="ts">
+import { reactive, ref, computed } from "vue";
+import { getFeedbackDetail, modifyFeedback } from "@/api/feedback";
+import { ElMessage } from "element-plus";
+
+const emit = defineEmits(["refresh"]);
+
+const formRef = ref();
+const dialogVisible = ref(false);
+const dialogType = ref<"view" | "edit">("view");
+const loading = ref(false);
+
+const state = reactive({
+  ruleForm: {
+    id: null as number | null,
+    title: "",
+    type: "",
+    submitTime: "",
+    submitUserId: "",
+    attachList: "",
+    content: "",
+    replyContent: "",
+    replyTime: "",
+    replyUserId: "",
+    status: 1
+  },
+  attachUrls: [] as string[]
+});
+
+const getTitle = () => {
+  return "反馈详情";
+};
+
+const isView = () => dialogType.value === "view";
+
+const open = async (type: "view" | "edit" = "view", row?: any) => {
+  dialogType.value = type;
+  if (row) {
+    loading.value = true;
+    try {
+      const res = await getFeedbackDetail(row.id);
+      Object.assign(state.ruleForm, res);
+      if (res.attachList) {
+        try {
+          state.attachUrls = JSON.parse(res.attachList).map((k: any) => k.url);
+        } catch (e) {
+          state.attachUrls = [];
+        }
+      }
+    } catch (e) {
+      ElMessage.error("获取详情失败");
+    } finally {
+      loading.value = false;
+    }
+  }
+  dialogVisible.value = true;
+};
+
+const handleClose = () => {
+  dialogVisible.value = false;
+  state.attachUrls = [];
+};
+
+const handleSubmit = async () => {
+  const valid = await formRef.value?.validate().catch(() => false);
+  if (!valid) return;
+
+  loading.value = true;
+  try {
+    await modifyFeedback(state.ruleForm);
+    ElMessage.success("回复成功");
+    handleClose();
+    emit("refresh");
+  } catch (e) {
+    ElMessage.error("操作失败");
+  } finally {
+    loading.value = false;
+  }
+};
+
+defineExpose({ open });
+</script>
+
+<template>
+  <el-dialog
+    v-model="dialogVisible"
+    :title="getTitle()"
+    width="820px"
+    destroy-on-close
+    :close-on-click-modal="false"
+  >
+    <el-form ref="formRef" :model="state.ruleForm" label-width="100px" label-position="top">
+      <el-form-item label="反馈标题">
+        <el-input :model-value="state.ruleForm.title" readonly />
+      </el-form-item>
+      <el-form-item label="纠错类型">
+        <el-input :model-value="state.ruleForm.type" readonly />
+      </el-form-item>
+      <el-form-item label="提交时间">
+        <el-input :model-value="state.ruleForm.submitTime" readonly />
+      </el-form-item>
+      <el-form-item label="反馈人">
+        <el-input :model-value="state.ruleForm.submitUserId" readonly />
+      </el-form-item>
+      <el-form-item label="附件列表" v-if="state.attachUrls.length > 0">
+        <div style="display: flex; gap: 10px; flex-wrap: wrap">
+          <el-image
+            v-for="(url, idx) in state.attachUrls"
+            :key="idx"
+            :src="url"
+            style="width: 100px; height: 100px"
+            fit="cover"
+            :preview-src-list="state.attachUrls"
+          />
+        </div>
+      </el-form-item>
+      <el-form-item label="问题描述">
+        <el-input :model-value="state.ruleForm.content" type="textarea" :rows="3" readonly />
+      </el-form-item>
+      <el-form-item label="回复内容" prop="replyContent" v-if="!isView()">
+        <el-input v-model="state.ruleForm.replyContent" type="textarea" :rows="4" placeholder="请输入回复内容" />
+      </el-form-item>
+      <el-form-item label="回复内容" v-if="isView() && state.ruleForm.replyContent">
+        <el-input :model-value="state.ruleForm.replyContent" type="textarea" :rows="3" readonly />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="handleClose">关闭</el-button>
+      <el-button v-if="!isView()" :loading="loading" type="primary" @click="handleSubmit">提交回复</el-button>
+    </template>
+  </el-dialog>
+</template>

+ 24 - 5
admin-web-new/src/views/admin/feedback/index.vue

@@ -1,11 +1,14 @@
 <script setup lang="ts">
 import { reactive, onMounted, ref, nextTick } from "vue";
-import { getFeedbackList } from "@/api/feedback";
+import { getFeedbackList, removeFeedback } from "@/api/feedback";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { ElMessage, ElMessageBox } from "element-plus";
+import FeedbackDialog from "./dialog.vue";
 
 defineOptions({ name: "AdminFeedback" });
 
 const queryRef = ref();
+const dialogRef = ref();
 
 interface ColumnItem {
   label: string;
@@ -89,6 +92,19 @@ const handleReset = () => {
   };
   loadData(true);
 };
+
+const handleView = (row: any) => dialogRef.value?.open("view", row);
+const handleReply = (row: any) => dialogRef.value?.open("edit", row);
+const handleDelete = (row: any) => {
+  ElMessageBox.confirm("确定要删除此反馈吗?", "提示", {
+    confirmButtonText: "确定", cancelButtonText: "取消", type: "warning"
+  }).then(() => {
+    removeFeedback(row.id).then(() => {
+      ElMessage.success("删除成功");
+      loadData(true);
+    });
+  }).catch(() => {});
+};
 </script>
 
 <template>
@@ -132,10 +148,11 @@ const handleReset = () => {
           :width="col.width"
           show-overflow-tooltip
         />
-        <el-table-column label="操作" width="150" fixed="right">
-          <template #default>
-            <el-button type="primary" link size="small">查看</el-button>
-            <el-button type="info" link size="small">回复</el-button>
+        <el-table-column label="操作" width="180" fixed="right">
+          <template #default="{ row }">
+            <el-button type="primary" link size="small" @click="handleView(row)">查看</el-button>
+            <el-button type="success" link size="small" @click="handleReply(row)">回复</el-button>
+            <el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
           </template>
         </el-table-column>
       </el-table>
@@ -151,6 +168,8 @@ const handleReset = () => {
         />
       </div>
     </el-card>
+
+    <FeedbackDialog ref="dialogRef" @refresh="loadData" />
   </div>
 </template>
 

+ 157 - 0
admin-web-new/src/views/admin/investor/dialog.vue

@@ -0,0 +1,157 @@
+<script setup lang="ts">
+import { reactive, ref } from "vue";
+import { addInvestor, modifyInvestor, getInvestorDetail } from "@/api/investor";
+import { ElMessage } from "element-plus";
+
+const emit = defineEmits(["refresh"]);
+
+const formRef = ref();
+const dialogVisible = ref(false);
+const dialogType = ref<"add" | "edit">("add");
+const loading = ref(false);
+
+const state = reactive({
+  ruleForm: {
+    id: null as number | null,
+    accountName: "",
+    adminUserName: "",
+    adminUserId: "",
+    stationId: "",
+    status: 1,
+    telephone: "",
+    bankCardNo: "",
+    bankName: "",
+    taxNo: "",
+    vatRate: "",
+    elecLossProportion: "",
+    splittingProportion: "",
+    remark: ""
+  },
+  rules: {
+    telephone: [
+      { pattern: /^1[3-9]\d{9}$/, message: "请输入正确的手机号", trigger: "blur" }
+    ]
+  }
+});
+
+const resetForm = () => {
+  state.ruleForm = {
+    id: null,
+    accountName: "",
+    adminUserName: "",
+    adminUserId: "",
+    stationId: "",
+    status: 1,
+    telephone: "",
+    bankCardNo: "",
+    bankName: "",
+    taxNo: "",
+    vatRate: "",
+    elecLossProportion: "",
+    splittingProportion: "",
+    remark: ""
+  };
+  formRef.value?.resetFields();
+};
+
+const getTitle = () => {
+  return dialogType.value === "add" ? "新增投资人/物业" : "编辑投资人/物业";
+};
+
+const open = async (type: "add" | "edit", row?: any) => {
+  dialogType.value = type;
+  resetForm();
+  if (type === "edit" && row) {
+    loading.value = true;
+    try {
+      const res = await getInvestorDetail(row.id);
+      Object.assign(state.ruleForm, res);
+    } catch (e) {
+      ElMessage.error("获取详情失败");
+    } finally {
+      loading.value = false;
+    }
+  }
+  dialogVisible.value = true;
+};
+
+const handleClose = () => {
+  dialogVisible.value = false;
+  resetForm();
+};
+
+const handleSubmit = async () => {
+  const valid = await formRef.value?.validate().catch(() => false);
+  if (!valid) return;
+
+  loading.value = true;
+  try {
+    if (dialogType.value === "add") {
+      await addInvestor(state.ruleForm);
+    } else {
+      await modifyInvestor(state.ruleForm);
+    }
+    ElMessage.success("操作成功");
+    handleClose();
+    emit("refresh");
+  } catch (e) {
+    ElMessage.error("操作失败");
+  } finally {
+    loading.value = false;
+  }
+};
+
+defineExpose({ open });
+</script>
+
+<template>
+  <el-dialog
+    v-model="dialogVisible"
+    :title="getTitle()"
+    width="650px"
+    destroy-on-close
+    :close-on-click-modal="false"
+  >
+    <el-form ref="formRef" :model="state.ruleForm" :rules="state.rules" label-width="140px" :disabled="loading">
+      <el-form-item label="账户名" prop="accountName">
+        <el-input v-model="state.ruleForm.accountName" placeholder="请输入账户名" clearable />
+      </el-form-item>
+      <el-form-item label="客户名称" prop="adminUserName">
+        <el-input v-model="state.ruleForm.adminUserName" placeholder="请输入客户名称" clearable />
+      </el-form-item>
+      <el-form-item label="电话号码" prop="telephone">
+        <el-input v-model="state.ruleForm.telephone" placeholder="请输入电话号码" clearable />
+      </el-form-item>
+      <el-form-item label="银行卡号" prop="bankCardNo">
+        <el-input v-model="state.ruleForm.bankCardNo" placeholder="请输入银行卡号" clearable />
+      </el-form-item>
+      <el-form-item label="开户行名称" prop="bankName">
+        <el-input v-model="state.ruleForm.bankName" placeholder="请输入开户行名称" clearable />
+      </el-form-item>
+      <el-form-item label="税号" prop="taxNo">
+        <el-input v-model="state.ruleForm.taxNo" placeholder="请输入税号" clearable />
+      </el-form-item>
+      <el-form-item label="增值税率" prop="vatRate">
+        <el-input v-model="state.ruleForm.vatRate" placeholder="增值税率 (0.06表示6%)" clearable />
+      </el-form-item>
+      <el-form-item label="电损承担比例" prop="elecLossProportion">
+        <el-input v-model="state.ruleForm.elecLossProportion" placeholder="电损承担比例 (0.30表示30%)" clearable />
+      </el-form-item>
+      <el-form-item label="分成比例" prop="splittingProportion">
+        <el-input v-model="state.ruleForm.splittingProportion" placeholder="分成比例 (0.45表示45%)" clearable />
+      </el-form-item>
+      <el-form-item label="备注" prop="remark">
+        <el-input
+          v-model="state.ruleForm.remark"
+          placeholder="备注"
+          type="textarea"
+          :rows="3"
+        />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="handleClose">取消</el-button>
+      <el-button :loading="loading" type="primary" @click="handleSubmit">确定</el-button>
+    </template>
+  </el-dialog>
+</template>

+ 29 - 4
admin-web-new/src/views/admin/investor/index.vue

@@ -1,11 +1,14 @@
 <script setup lang="ts">
 import { reactive, onMounted, ref, nextTick } from "vue";
-import { getInvestorList } from "@/api/investor";
+import { getInvestorList, removeInvestor } from "@/api/investor";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { ElMessage, ElMessageBox } from "element-plus";
+import InvestorDialog from "./dialog.vue";
 
 defineOptions({ name: "AdminInvestor" });
 
 const queryRef = ref();
+const dialogRef = ref();
 
 interface ColumnItem {
   label: string;
@@ -91,6 +94,19 @@ const handleReset = () => {
   };
   loadData(true);
 };
+
+const handleAdd = () => dialogRef.value?.open("add");
+const handleEdit = (row: any) => dialogRef.value?.open("edit", row);
+const handleDelete = (row: any) => {
+  ElMessageBox.confirm(`确定要删除『${row.adminUserName}』吗?`, "提示", {
+    confirmButtonText: "确定", cancelButtonText: "取消", type: "warning"
+  }).then(() => {
+    removeInvestor(row.id).then(() => {
+      ElMessage.success("删除成功");
+      loadData(true);
+    });
+  }).catch(() => {});
+};
 </script>
 
 <template>
@@ -117,6 +133,13 @@ const handleReset = () => {
           >
             重置
           </el-button>
+          <el-button
+            type="success"
+            :icon="useRenderIcon('ri/add-line')"
+            @click="handleAdd"
+          >
+            新增
+          </el-button>
         </el-form-item>
       </el-form>
       <el-table
@@ -138,9 +161,9 @@ const handleReset = () => {
           show-overflow-tooltip
         />
         <el-table-column label="操作" width="150" fixed="right">
-          <template #default>
-            <el-button type="primary" link size="small">编辑</el-button>
-            <el-button type="info" link size="small">详情</el-button>
+          <template #default="{ row }">
+            <el-button type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
+            <el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
           </template>
         </el-table-column>
       </el-table>
@@ -156,6 +179,8 @@ const handleReset = () => {
         />
       </div>
     </el-card>
+
+    <InvestorDialog ref="dialogRef" @refresh="loadData" />
   </div>
 </template>
 

+ 172 - 0
admin-web-new/src/views/admin/log/error.vue

@@ -0,0 +1,172 @@
+<script setup lang="ts">
+import { reactive, onMounted, ref, nextTick } from "vue";
+import { getErrorLogList } from "@/api/error-log";
+import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+
+const queryRef = ref();
+
+const state = reactive({
+  formQuery: {
+    browserName: "",
+    url: "",
+    startDate: "",
+    endDate: ""
+  },
+  dateRange: [] as string[],
+  pageQuery: {
+    pageNum: 1,
+    pageSize: 10,
+    total: 0
+  },
+  tableData: {
+    height: 500,
+    data: [] as Array<any>,
+    loading: false
+  }
+});
+
+onMounted(() => {
+  loadData();
+  nextTick(() => {
+    const bodyHeight = document.body.clientHeight;
+    const queryHeight = queryRef.value?.$el?.clientHeight || 0;
+    state.tableData.height = bodyHeight - queryHeight - 280;
+  });
+});
+
+const loadData = (refresh: boolean = false) => {
+  if (refresh) state.pageQuery.pageNum = 1;
+
+  if (state.dateRange && state.dateRange.length === 2) {
+    state.formQuery.startDate = state.dateRange[0];
+    state.formQuery.endDate = state.dateRange[1];
+  } else {
+    state.formQuery.startDate = "";
+    state.formQuery.endDate = "";
+  }
+
+  state.tableData.loading = true;
+  getErrorLogList({ ...state.formQuery, ...state.pageQuery })
+    .then((res: any) => {
+      const { list, total } = res || {};
+      state.tableData.data = list || [];
+      state.pageQuery.total = total || 0;
+    })
+    .catch(() => { state.tableData.data = []; })
+    .finally(() => { state.tableData.loading = false; });
+};
+
+const handleSizeChange = (size: number) => { state.pageQuery.pageSize = size; loadData(true); };
+const handleCurrentChange = (page: number) => { state.pageQuery.pageNum = page; loadData(); };
+const handleSearch = () => loadData(true);
+const handleReset = () => {
+  state.formQuery = { browserName: "", url: "", startDate: "", endDate: "" };
+  state.dateRange = [];
+  loadData(true);
+};
+</script>
+
+<template>
+  <div class="error-log-container">
+    <el-form ref="queryRef" :model="state.formQuery" inline class="search-form">
+      <el-form-item label="浏览器">
+        <el-input v-model="state.formQuery.browserName" placeholder="请输入浏览器" clearable style="width: 140px" @keyup.enter="handleSearch" />
+      </el-form-item>
+      <el-form-item label="接口地址">
+        <el-input v-model="state.formQuery.url" placeholder="请输入接口地址" clearable style="width: 200px" @keyup.enter="handleSearch" />
+      </el-form-item>
+      <el-form-item label="产生时间">
+        <el-date-picker
+          v-model="state.dateRange"
+          type="daterange"
+          range-separator="至"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          value-format="YYYY-MM-DD"
+          style="width: 240px"
+          @change="handleSearch"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" :icon="useRenderIcon('ri/search-line')" @click="handleSearch">查询</el-button>
+        <el-button :icon="useRenderIcon('ri/refresh-line')" @click="handleReset">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <el-table
+      v-loading="state.tableData.loading"
+      :data="state.tableData.data"
+      :height="state.tableData.height"
+      border
+      stripe
+    >
+      <template #empty><el-empty description="暂无数据" /></template>
+      <el-table-column type="expand">
+        <template #default="{ row }">
+          <div class="stack-trace">
+            <div class="stack-label">错误详情 (Stack Trace)</div>
+            <pre class="stack-content">{{ row.stack || '无详细信息' }}</pre>
+          </div>
+        </template>
+      </el-table-column>
+      <el-table-column label="产生时间" prop="createAt" width="180" />
+      <el-table-column label="浏览器" prop="browserName" show-overflow-tooltip />
+      <el-table-column label="接口地址" prop="url" width="250" show-overflow-tooltip />
+      <el-table-column label="UserAgent" prop="userAgent" width="200" show-overflow-tooltip />
+      <el-table-column label="IP地址" prop="ip" width="140" />
+      <el-table-column label="错误简述" prop="error" min-width="300" show-overflow-tooltip />
+    </el-table>
+
+    <div class="pagination-container">
+      <el-pagination
+        v-model:current-page="state.pageQuery.pageNum"
+        v-model:page-size="state.pageQuery.pageSize"
+        :total="state.pageQuery.total"
+        :page-sizes="[10, 20, 50, 100]"
+        layout="total, sizes, prev, pager, next, jumper"
+        @size-change="handleSizeChange"
+        @current-change="handleCurrentChange"
+      />
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.error-log-container {
+  padding: 5px 0;
+}
+
+.search-form {
+  margin-bottom: 15px;
+}
+
+.pagination-container {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 15px;
+}
+
+.stack-trace {
+  padding: 12px 20px;
+
+  .stack-label {
+    font-weight: 500;
+    margin-bottom: 8px;
+    color: var(--el-text-color-primary);
+  }
+
+  .stack-content {
+    font-family: "Courier New", monospace;
+    font-size: 13px;
+    background: var(--el-fill-color-lighter);
+    padding: 12px;
+    border-radius: 4px;
+    white-space: pre-wrap;
+    word-break: break-all;
+    max-height: 400px;
+    overflow-y: auto;
+    line-height: 1.5;
+    margin: 0;
+  }
+}
+</style>

+ 13 - 6
admin-web-new/src/views/admin/log/index.vue

@@ -3,9 +3,11 @@ import { reactive, onMounted, ref, nextTick } from "vue";
 import { http } from "@/utils/http";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
 import { ElMessage, ElMessageBox } from "element-plus";
+import ErrorLog from "./error.vue";
 
 defineOptions({ name: "AdminLog" });
 
+const activeTab = ref("operation");
 const queryRef = ref();
 const detailVisible = ref(false);
 const currentLog = ref<any>(null);
@@ -43,7 +45,7 @@ onMounted(() => {
   nextTick(() => {
     const bodyHeight = document.body.clientHeight;
     const queryHeight = queryRef.value?.$el?.clientHeight || 0;
-    state.tableData.height = bodyHeight - queryHeight - 280;
+    state.tableData.height = bodyHeight - queryHeight - 320;
   });
 });
 
@@ -51,7 +53,7 @@ const loadData = (refresh: boolean = false) => {
   if (refresh) {
     state.pageQuery.pageNum = 1;
   }
-  
+
   if (state.dateRange && state.dateRange.length === 2) {
     state.formQuery.startDate = state.dateRange[0];
     state.formQuery.endDate = state.dateRange[1];
@@ -59,7 +61,7 @@ const loadData = (refresh: boolean = false) => {
     state.formQuery.startDate = "";
     state.formQuery.endDate = "";
   }
-  
+
   state.tableData.loading = true;
   http.request<any>("get", "/system-log/list", { params: { ...state.formQuery, ...state.pageQuery } })
     .then((res: any) => {
@@ -124,8 +126,9 @@ const getExecuteTimeType = (time: number) => {
 
 <template>
   <div class="page-container">
-    <el-card shadow="hover">
-      <el-form ref="queryRef" :model="state.formQuery" inline class="search-form">
+    <el-tabs v-model="activeTab" type="border-card">
+      <el-tab-pane label="操作日志" name="operation">
+        <el-form ref="queryRef" :model="state.formQuery" inline class="search-form">
         <el-form-item label="操作用户">
           <el-input
             v-model="state.formQuery.username"
@@ -221,7 +224,6 @@ const getExecuteTimeType = (time: number) => {
           @current-change="handleCurrentChange"
         />
       </div>
-    </el-card>
 
     <!-- 详情对话框 -->
     <el-dialog v-model="detailVisible" title="日志详情" width="600px">
@@ -246,6 +248,11 @@ const getExecuteTimeType = (time: number) => {
         <el-button @click="detailVisible = false">关闭</el-button>
       </template>
     </el-dialog>
+      </el-tab-pane>
+      <el-tab-pane label="错误日志" name="error">
+        <ErrorLog />
+      </el-tab-pane>
+    </el-tabs>
   </div>
 </template>
 

+ 440 - 0
admin-web-new/src/views/admin/message/index.vue

@@ -0,0 +1,440 @@
+<script setup lang="ts">
+import { reactive, onMounted, ref, nextTick } from "vue";
+import { getMessageList, sendMessage, deleteMessage, batchReadMessage, batchDeleteMessage, searchAdminUser } from "@/api/message";
+import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { ElMessage, ElMessageBox } from "element-plus";
+
+defineOptions({ name: "AdminMessage" });
+
+const queryRef = ref();
+
+const state = reactive({
+  formQuery: {
+    title: "",
+    type: "" as number | string,
+    status: "" as number | string
+  },
+  pageQuery: {
+    pageNum: 1,
+    pageSize: 10,
+    total: 0
+  },
+  tableData: {
+    height: 500,
+    data: [] as Array<any>,
+    loading: false
+  },
+  selectedIds: [] as number[]
+});
+
+// 发送消息弹窗
+const sendVisible = ref(false);
+const sendLoading = ref(false);
+const sendForm = reactive({
+  title: "",
+  type: 1,
+  priority: 0,
+  sendType: "all" as "all" | "selected",
+  receiverIds: [] as number[],
+  content: ""
+});
+const userOptions = ref<any[]>([]);
+
+// 详情弹窗
+const detailVisible = ref(false);
+const currentMsg = ref<any>(null);
+
+const typeOptions = [
+  { label: "系统通知", value: 1 },
+  { label: "站内信", value: 2 },
+  { label: "待办事项", value: 3 },
+  { label: "公告通知", value: 4 }
+];
+
+const priorityOptions = [
+  { label: "普通", value: 0 },
+  { label: "重要", value: 1 },
+  { label: "紧急", value: 2 }
+];
+
+const getTypeTag = (type: number) => {
+  const map: Record<number, string> = { 1: "", 2: "success", 3: "warning", 4: "info" };
+  return map[type] || "";
+};
+
+const getPriorityTag = (priority: number) => {
+  const map: Record<number, string> = { 0: "info", 1: "warning", 2: "danger" };
+  return map[priority] || "";
+};
+
+const getTypeName = (type: number) => {
+  return typeOptions.find(k => k.value === type)?.label || "";
+};
+
+const getPriorityName = (priority: number) => {
+  return priorityOptions.find(k => k.value === priority)?.label || "";
+};
+
+onMounted(() => {
+  loadData();
+  nextTick(() => {
+    const bodyHeight = document.body.clientHeight;
+    const queryHeight = queryRef.value?.$el?.clientHeight || 0;
+    state.tableData.height = bodyHeight - queryHeight - 280;
+  });
+});
+
+const loadData = (refresh: boolean = false) => {
+  if (refresh) {
+    state.pageQuery.pageNum = 1;
+  }
+  state.tableData.loading = true;
+  getMessageList({ ...state.formQuery, ...state.pageQuery })
+    .then((res: any) => {
+      const { list, total } = res || {};
+      state.tableData.data = list || [];
+      state.pageQuery.total = total || 0;
+    })
+    .catch(() => {
+      state.tableData.data = [];
+    })
+    .finally(() => {
+      state.tableData.loading = false;
+    });
+};
+
+const handleSizeChange = (size: number) => {
+  state.pageQuery.pageSize = size;
+  loadData(true);
+};
+
+const handleCurrentChange = (page: number) => {
+  state.pageQuery.pageNum = page;
+  loadData();
+};
+
+const handleSearch = () => {
+  loadData(true);
+};
+
+const handleReset = () => {
+  state.formQuery = {
+    title: "",
+    type: "",
+    status: ""
+  };
+  loadData(true);
+};
+
+const handleSelectionChange = (rows: any[]) => {
+  state.selectedIds = rows.map(k => k.id);
+};
+
+const handleView = (row: any) => {
+  currentMsg.value = row;
+  detailVisible.value = true;
+};
+
+const handleDelete = (row: any) => {
+  ElMessageBox.confirm("确定要删除此消息吗?", "提示", {
+    confirmButtonText: "确定",
+    cancelButtonText: "取消",
+    type: "warning"
+  }).then(() => {
+    deleteMessage(row.id).then(() => {
+      ElMessage.success("删除成功");
+      loadData(true);
+    });
+  }).catch(() => {});
+};
+
+const handleBatchRead = () => {
+  if (state.selectedIds.length === 0) {
+    ElMessage.warning("请选择消息");
+    return;
+  }
+  batchReadMessage({ ids: state.selectedIds }).then(() => {
+    ElMessage.success("已标记为已读");
+    loadData(true);
+  });
+};
+
+const handleBatchDelete = () => {
+  if (state.selectedIds.length === 0) {
+    ElMessage.warning("请选择消息");
+    return;
+  }
+  ElMessageBox.confirm("确定要批量删除选中的消息吗?", "提示", {
+    confirmButtonText: "确定",
+    cancelButtonText: "取消",
+    type: "warning"
+  }).then(() => {
+    batchDeleteMessage({ ids: state.selectedIds }).then(() => {
+      ElMessage.success("删除成功");
+      loadData(true);
+    });
+  }).catch(() => {});
+};
+
+// 发送消息
+const handleOpenSend = () => {
+  Object.assign(sendForm, {
+    title: "",
+    type: 1,
+    priority: 0,
+    sendType: "all",
+    receiverIds: [],
+    content: ""
+  });
+  userOptions.value = [];
+  sendVisible.value = true;
+};
+
+const handleSendTypeChange = (val: string) => {
+  if (val !== "selected") {
+    sendForm.receiverIds = [];
+  }
+};
+
+const searchUsers = (query: string) => {
+  if (!query) {
+    userOptions.value = [];
+    return;
+  }
+  searchAdminUser({ keyword: query, pageSize: 20 }).then((res: any) => {
+    userOptions.value = (res || []).map((k: any) => ({
+      label: `${k.nickname || k.username} (${k.mobilePhone || ""})`,
+      value: k.id || k.userId
+    }));
+  });
+};
+
+const handleSendSubmit = async () => {
+  if (!sendForm.title || !sendForm.content) {
+    ElMessage.warning("请填写标题和内容");
+    return;
+  }
+  sendLoading.value = true;
+  try {
+    await sendMessage({
+      title: sendForm.title,
+      content: sendForm.content,
+      type: sendForm.type,
+      priority: sendForm.priority,
+      sendAll: sendForm.sendType === "all",
+      receiverIds: sendForm.sendType === "selected" ? sendForm.receiverIds : []
+    });
+    ElMessage.success("发送成功");
+    sendVisible.value = false;
+    loadData(true);
+  } catch (e) {
+    ElMessage.error("发送失败");
+  } finally {
+    sendLoading.value = false;
+  }
+};
+</script>
+
+<template>
+  <div class="page-container">
+    <el-card shadow="hover">
+      <!-- 搜索栏 -->
+      <el-form ref="queryRef" :model="state.formQuery" inline class="search-form">
+        <el-form-item label="标题">
+          <el-input
+            v-model="state.formQuery.title"
+            placeholder="请输入标题"
+            clearable
+            style="width: 180px"
+            @keyup.enter="handleSearch"
+          />
+        </el-form-item>
+        <el-form-item label="类型">
+          <el-select v-model="state.formQuery.type" placeholder="请选择" clearable style="width: 140px">
+            <el-option v-for="opt in typeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-select v-model="state.formQuery.status" placeholder="请选择" clearable style="width: 120px">
+            <el-option label="未读" :value="0" />
+            <el-option label="已读" :value="1" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" :icon="useRenderIcon('ri/search-line')" @click="handleSearch">查询</el-button>
+          <el-button :icon="useRenderIcon('ri/refresh-line')" @click="handleReset">重置</el-button>
+        </el-form-item>
+      </el-form>
+
+      <!-- 操作栏 -->
+      <div class="toolbar">
+        <el-button type="primary" :icon="useRenderIcon('ri/add-line')" @click="handleOpenSend">发送消息</el-button>
+        <el-button :icon="useRenderIcon('ri/mail-check-line')" :disabled="state.selectedIds.length === 0" @click="handleBatchRead">批量已读</el-button>
+        <el-button type="danger" :icon="useRenderIcon('ri/delete-bin-line')" :disabled="state.selectedIds.length === 0" @click="handleBatchDelete">批量删除</el-button>
+      </div>
+
+      <!-- 表格 -->
+      <el-table
+        v-loading="state.tableData.loading"
+        :data="state.tableData.data"
+        :height="state.tableData.height"
+        border
+        stripe
+        @selection-change="handleSelectionChange"
+      >
+        <template #empty>
+          <el-empty description="暂无数据" />
+        </template>
+        <el-table-column type="selection" width="50" />
+        <el-table-column label="ID" prop="id" width="80" />
+        <el-table-column label="标题" prop="title" min-width="200" show-overflow-tooltip />
+        <el-table-column label="类型" prop="type" width="120">
+          <template #default="{ row }">
+            <el-tag :type="getTypeTag(row.type)" size="small">{{ getTypeName(row.type) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="发送者" prop="senderName" width="100" />
+        <el-table-column label="接收者" prop="receiverName" width="100" />
+        <el-table-column label="优先级" prop="priority" width="100">
+          <template #default="{ row }">
+            <el-tag :type="getPriorityTag(row.priority)" size="small">{{ getPriorityName(row.priority) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="状态" prop="status" width="80">
+          <template #default="{ row }">
+            <el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
+              {{ row.status === 1 ? "已读" : "未读" }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="发送时间" prop="createTime" width="170" />
+        <el-table-column label="操作" width="120" fixed="right">
+          <template #default="{ row }">
+            <el-button type="primary" link size="small" @click="handleView(row)">查看</el-button>
+            <el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <div class="pagination-container">
+        <el-pagination
+          v-model:current-page="state.pageQuery.pageNum"
+          v-model:page-size="state.pageQuery.pageSize"
+          :total="state.pageQuery.total"
+          :page-sizes="[10, 20, 50, 100]"
+          layout="total, sizes, prev, pager, next, jumper"
+          @size-change="handleSizeChange"
+          @current-change="handleCurrentChange"
+        />
+      </div>
+    </el-card>
+
+    <!-- 发送消息弹窗 -->
+    <el-dialog v-model="sendVisible" title="发送消息" width="650px" destroy-on-close>
+      <el-form :model="sendForm" label-width="100px">
+        <el-form-item label="标题" required>
+          <el-input v-model="sendForm.title" placeholder="请输入消息标题" />
+        </el-form-item>
+        <el-form-item label="类型">
+          <el-select v-model="sendForm.type" style="width: 100%">
+            <el-option v-for="opt in typeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="优先级">
+          <el-select v-model="sendForm.priority" style="width: 100%">
+            <el-option v-for="opt in priorityOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="发送方式">
+          <el-radio-group v-model="sendForm.sendType" @change="handleSendTypeChange">
+            <el-radio value="all">全部用户</el-radio>
+            <el-radio value="selected">指定用户</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="接收者" v-if="sendForm.sendType === 'selected'">
+          <el-select
+            v-model="sendForm.receiverIds"
+            multiple
+            filterable
+            remote
+            reserve-keyword
+            placeholder="搜索并选择用户"
+            :remote-method="searchUsers"
+            :loading="false"
+            style="width: 100%"
+          >
+            <el-option v-for="user in userOptions" :key="user.value" :label="user.label" :value="user.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="内容" required>
+          <el-input v-model="sendForm.content" type="textarea" :rows="5" placeholder="请输入消息内容" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="sendVisible = false">取消</el-button>
+        <el-button :loading="sendLoading" type="primary" @click="handleSendSubmit">发送</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 消息详情弹窗 -->
+    <el-dialog v-model="detailVisible" title="消息详情" width="600px" destroy-on-close>
+      <template v-if="currentMsg">
+        <el-descriptions :column="2" border>
+          <el-descriptions-item label="标题">{{ currentMsg.title }}</el-descriptions-item>
+          <el-descriptions-item label="类型">
+            <el-tag :type="getTypeTag(currentMsg.type)" size="small">{{ getTypeName(currentMsg.type) }}</el-tag>
+          </el-descriptions-item>
+          <el-descriptions-item label="发送者">{{ currentMsg.senderName }}</el-descriptions-item>
+          <el-descriptions-item label="发送时间">{{ currentMsg.createTime }}</el-descriptions-item>
+        </el-descriptions>
+        <div class="detail-section">
+          <div class="section-title">消息内容</div>
+          <div class="content-box">{{ currentMsg.content }}</div>
+        </div>
+      </template>
+      <template #footer>
+        <el-button @click="detailVisible = false">关闭</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.page-container {
+  padding: 15px;
+}
+
+.search-form {
+  margin-bottom: 15px;
+}
+
+.toolbar {
+  margin-bottom: 15px;
+  display: flex;
+  gap: 10px;
+}
+
+.pagination-container {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 15px;
+}
+
+.detail-section {
+  margin-top: 15px;
+
+  .section-title {
+    font-weight: 500;
+    margin-bottom: 10px;
+    color: var(--el-text-color-primary);
+  }
+
+  .content-box {
+    padding: 12px;
+    background: var(--el-fill-color-lighter);
+    border-radius: 4px;
+    white-space: pre-wrap;
+    line-height: 1.6;
+  }
+}
+</style>

+ 376 - 0
admin-web-new/src/views/admin/template/index.vue

@@ -0,0 +1,376 @@
+<script setup lang="ts">
+import { reactive, onMounted, ref, nextTick } from "vue";
+import { getTemplateList, createTemplate, updateTemplate, deleteTemplate, sendByTemplate } from "@/api/message-template";
+import { searchAdminUser } from "@/api/message";
+import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { ElMessage, ElMessageBox } from "element-plus";
+
+defineOptions({ name: "AdminMessageTemplate" });
+
+const queryRef = ref();
+
+const state = reactive({
+  formQuery: {
+    name: "",
+    code: "",
+    type: "" as number | string,
+    status: "" as number | string
+  },
+  pageQuery: {
+    pageNum: 1,
+    pageSize: 10,
+    total: 0
+  },
+  tableData: {
+    height: 500,
+    data: [] as Array<any>,
+    loading: false
+  }
+});
+
+// 新增/编辑模板弹窗
+const formVisible = ref(false);
+const formLoading = ref(false);
+const formType = ref<"add" | "edit">("add");
+const formData = reactive({
+  id: null as number | null,
+  code: "",
+  name: "",
+  type: 1,
+  priority: 0,
+  titleTemplate: "",
+  contentTemplate: "",
+  status: 1,
+  remark: ""
+});
+
+// 使用模板发送弹窗
+const sendVisible = ref(false);
+const sendLoading = ref(false);
+const sendForm = reactive({
+  templateId: null as number | null,
+  title: "",
+  content: "",
+  type: 1,
+  priority: 0,
+  sendType: "all" as "all" | "selected",
+  receiverIds: [] as number[]
+});
+const userOptions = ref<any[]>([]);
+
+const typeOptions = [
+  { label: "系统通知", value: 1 },
+  { label: "站内信", value: 2 },
+  { label: "待办事项", value: 3 },
+  { label: "公告通知", value: 4 }
+];
+
+const priorityOptions = [
+  { label: "普通", value: 0 },
+  { label: "重要", value: 1 },
+  { label: "紧急", value: 2 }
+];
+
+const getTypeTag = (type: number) => {
+  const map: Record<number, string> = { 1: "", 2: "success", 3: "warning", 4: "info" };
+  return map[type] || "";
+};
+
+const getTypeName = (type: number) => {
+  return typeOptions.find(k => k.value === type)?.label || "";
+};
+
+const getPriorityTag = (priority: number) => {
+  const map: Record<number, string> = { 0: "info", 1: "warning", 2: "danger" };
+  return map[priority] || "";
+};
+
+const getPriorityName = (priority: number) => {
+  return priorityOptions.find(k => k.value === priority)?.label || "";
+};
+
+onMounted(() => {
+  loadData();
+  nextTick(() => {
+    const bodyHeight = document.body.clientHeight;
+    const queryHeight = queryRef.value?.$el?.clientHeight || 0;
+    state.tableData.height = bodyHeight - queryHeight - 280;
+  });
+});
+
+const loadData = (refresh: boolean = false) => {
+  if (refresh) state.pageQuery.pageNum = 1;
+  state.tableData.loading = true;
+  getTemplateList({ ...state.formQuery, ...state.pageQuery })
+    .then((res: any) => {
+      const { list, total } = res || {};
+      state.tableData.data = list || [];
+      state.pageQuery.total = total || 0;
+    })
+    .catch(() => { state.tableData.data = []; })
+    .finally(() => { state.tableData.loading = false; });
+};
+
+const handleSizeChange = (size: number) => { state.pageQuery.pageSize = size; loadData(true); };
+const handleCurrentChange = (page: number) => { state.pageQuery.pageNum = page; loadData(); };
+const handleSearch = () => loadData(true);
+const handleReset = () => {
+  state.formQuery = { name: "", code: "", type: "", status: "" };
+  loadData(true);
+};
+
+// 新增/编辑模板
+const handleAdd = () => {
+  formType.value = "add";
+  Object.assign(formData, {
+    id: null, code: "", name: "", type: 1, priority: 0,
+    titleTemplate: "", contentTemplate: "", status: 1, remark: ""
+  });
+  formVisible.value = true;
+};
+
+const handleEdit = (row: any) => {
+  formType.value = "edit";
+  Object.assign(formData, row);
+  formVisible.value = true;
+};
+
+const handleFormSubmit = async () => {
+  if (!formData.code || !formData.name || !formData.titleTemplate || !formData.contentTemplate) {
+    ElMessage.warning("请完整填写必填项");
+    return;
+  }
+  formLoading.value = true;
+  try {
+    if (formType.value === "add") {
+      await createTemplate(formData);
+    } else {
+      await updateTemplate(formData);
+    }
+    ElMessage.success("操作成功");
+    formVisible.value = false;
+    loadData(true);
+  } catch (e) {
+    ElMessage.error("操作失败");
+  } finally {
+    formLoading.value = false;
+  }
+};
+
+// 删除模板
+const handleDelete = (row: any) => {
+  ElMessageBox.confirm(`确定要删除模板『${row.name}』吗?`, "提示", {
+    confirmButtonText: "确定", cancelButtonText: "取消", type: "warning"
+  }).then(() => {
+    deleteTemplate(row.id).then(() => {
+      ElMessage.success("删除成功");
+      loadData(true);
+    });
+  }).catch(() => {});
+};
+
+// 使用模板发送
+const handleOpenSend = (row: any) => {
+  Object.assign(sendForm, {
+    templateId: row.id,
+    title: row.titleTemplate || "",
+    content: row.contentTemplate || "",
+    type: row.type,
+    priority: row.priority,
+    sendType: "all",
+    receiverIds: []
+  });
+  userOptions.value = [];
+  sendVisible.value = true;
+};
+
+const searchUsers = (query: string) => {
+  if (!query) { userOptions.value = []; return; }
+  searchAdminUser({ keyword: query, pageSize: 20 }).then((res: any) => {
+    userOptions.value = (res || []).map((k: any) => ({
+      label: `${k.nickname || k.username} (${k.mobilePhone || ""})`,
+      value: k.id || k.userId
+    }));
+  });
+};
+
+const handleSendSubmit = async () => {
+  if (!sendForm.title || !sendForm.content) {
+    ElMessage.warning("请填写标题和内容");
+    return;
+  }
+  sendLoading.value = true;
+  try {
+    await sendByTemplate({
+      title: sendForm.title,
+      content: sendForm.content,
+      type: sendForm.type,
+      priority: sendForm.priority,
+      sendAll: sendForm.sendType === "all",
+      receiverIds: sendForm.sendType === "selected" ? sendForm.receiverIds : [],
+      templateId: sendForm.templateId
+    });
+    ElMessage.success("发送成功");
+    sendVisible.value = false;
+  } catch (e) {
+    ElMessage.error("发送失败");
+  } finally {
+    sendLoading.value = false;
+  }
+};
+</script>
+
+<template>
+  <div class="page-container">
+    <el-card shadow="hover">
+      <el-form ref="queryRef" :model="state.formQuery" inline class="search-form">
+        <el-form-item label="模板名称">
+          <el-input v-model="state.formQuery.name" placeholder="请输入" clearable style="width: 160px" @keyup.enter="handleSearch" />
+        </el-form-item>
+        <el-form-item label="模板编码">
+          <el-input v-model="state.formQuery.code" placeholder="请输入" clearable style="width: 140px" @keyup.enter="handleSearch" />
+        </el-form-item>
+        <el-form-item label="消息类型">
+          <el-select v-model="state.formQuery.type" placeholder="请选择" clearable style="width: 130px">
+            <el-option v-for="opt in typeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-select v-model="state.formQuery.status" placeholder="请选择" clearable style="width: 100px">
+            <el-option label="启用" :value="1" />
+            <el-option label="禁用" :value="0" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" :icon="useRenderIcon('ri/search-line')" @click="handleSearch">查询</el-button>
+          <el-button :icon="useRenderIcon('ri/refresh-line')" @click="handleReset">重置</el-button>
+          <el-button type="success" :icon="useRenderIcon('ri/add-line')" @click="handleAdd">新增模板</el-button>
+        </el-form-item>
+      </el-form>
+
+      <el-table v-loading="state.tableData.loading" :data="state.tableData.data" :height="state.tableData.height" border stripe>
+        <template #empty><el-empty description="暂无数据" /></template>
+        <el-table-column label="ID" prop="id" width="70" />
+        <el-table-column label="模板编码" prop="code" width="140" />
+        <el-table-column label="模板名称" prop="name" width="140" />
+        <el-table-column label="消息类型" prop="type" width="110">
+          <template #default="{ row }">
+            <el-tag :type="getTypeTag(row.type)" size="small">{{ getTypeName(row.type) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="标题模板" prop="titleTemplate" min-width="180" show-overflow-tooltip />
+        <el-table-column label="内容模板" prop="contentTemplate" min-width="250" show-overflow-tooltip />
+        <el-table-column label="优先级" prop="priority" width="90">
+          <template #default="{ row }">
+            <el-tag :type="getPriorityTag(row.priority)" size="small">{{ getPriorityName(row.priority) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="状态" prop="status" width="80">
+          <template #default="{ row }">
+            <el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">{{ row.status === 1 ? "启用" : "禁用" }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="创建时间" prop="createTime" width="170" />
+        <el-table-column label="操作" width="200" fixed="right">
+          <template #default="{ row }">
+            <el-button type="success" link size="small" @click="handleOpenSend(row)">发送</el-button>
+            <el-button type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
+            <el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <div class="pagination-container">
+        <el-pagination
+          v-model:current-page="state.pageQuery.pageNum"
+          v-model:page-size="state.pageQuery.pageSize"
+          :total="state.pageQuery.total"
+          :page-sizes="[10, 20, 50, 100]"
+          layout="total, sizes, prev, pager, next, jumper"
+          @size-change="handleSizeChange"
+          @current-change="handleCurrentChange"
+        />
+      </div>
+    </el-card>
+
+    <!-- 新增/编辑模板弹窗 -->
+    <el-dialog v-model="formVisible" :title="formType === 'add' ? '新增模板' : '编辑模板'" width="700px" destroy-on-close>
+      <el-form :model="formData" label-width="120px">
+        <el-form-item label="模板编码" required>
+          <el-input v-model="formData.code" placeholder="请输入编码(大写)" :disabled="formType === 'edit'" />
+        </el-form-item>
+        <el-form-item label="模板名称" required>
+          <el-input v-model="formData.name" placeholder="请输入名称" />
+        </el-form-item>
+        <el-form-item label="消息类型">
+          <el-select v-model="formData.type" style="width: 100%">
+            <el-option v-for="opt in typeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="优先级">
+          <el-select v-model="formData.priority" style="width: 100%">
+            <el-option v-for="opt in priorityOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="标题模板" required>
+          <el-input v-model="formData.titleTemplate" placeholder="支持 ${变量名} 语法" />
+        </el-form-item>
+        <el-form-item label="内容模板" required>
+          <el-input v-model="formData.contentTemplate" type="textarea" :rows="4" placeholder="支持 ${变量名} 语法" />
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-radio-group v-model="formData.status">
+            <el-radio :value="1">启用</el-radio>
+            <el-radio :value="0">禁用</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="备注">
+          <el-input v-model="formData.remark" type="textarea" :rows="2" placeholder="备注信息" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="formVisible = false">取消</el-button>
+        <el-button :loading="formLoading" type="primary" @click="handleFormSubmit">确定</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 使用模板发送弹窗 -->
+    <el-dialog v-model="sendVisible" title="使用模板发送消息" width="650px" destroy-on-close>
+      <el-form :model="sendForm" label-width="100px">
+        <el-form-item label="标题" required>
+          <el-input v-model="sendForm.title" placeholder="消息标题" />
+        </el-form-item>
+        <el-form-item label="内容" required>
+          <el-input v-model="sendForm.content" type="textarea" :rows="5" placeholder="消息内容" />
+        </el-form-item>
+        <el-form-item label="发送方式">
+          <el-radio-group v-model="sendForm.sendType">
+            <el-radio value="all">全部用户</el-radio>
+            <el-radio value="selected">指定用户</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="接收者" v-if="sendForm.sendType === 'selected'">
+          <el-select
+            v-model="sendForm.receiverIds"
+            multiple filterable remote reserve-keyword
+            placeholder="搜索并选择用户"
+            :remote-method="searchUsers"
+            style="width: 100%"
+          >
+            <el-option v-for="user in userOptions" :key="user.value" :label="user.label" :value="user.value" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="sendVisible = false">取消</el-button>
+        <el-button :loading="sendLoading" type="primary" @click="handleSendSubmit">发送</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.page-container { padding: 15px; }
+.search-form { margin-bottom: 15px; }
+.pagination-container { display: flex; justify-content: flex-end; margin-top: 15px; }
+</style>

+ 197 - 0
docs/admin-web-vs-admin-web-new.md

@@ -0,0 +1,197 @@
+# admin-web → admin-web-new 改造清单与完成情况
+
+> 对比时间:2026-05-14 | admin-web (vue-next-admin) → admin-web-new (vue-pure-admin v6.3)
+
+---
+
+## 一、项目架构对比
+
+| 维度 | admin-web (原版) | admin-web-new (新版) |
+|------|-----------------|---------------------|
+| 基础框架 | vue-next-admin (Vue 3 + Element Plus) | vue-pure-admin v6.3 (Vue 3 + Element Plus) |
+| 构建工具 | Vite (旧版) | Vite 7 |
+| TypeScript | 部分支持 | 全面支持 |
+| API 层 | 无独立 API 层,视图中直接调用 `$get/$post` | 独立 `src/api/` 目录,22 个模块文件 |
+| 路由结构 | 单文件 `route.ts` 定义全部路由 | 模块化 `router/modules/admin.ts` + `remaining.ts` |
+| 状态管理 | 8 个 Pinia Store | 6 个 Pinia Store (更聚焦) |
+| 权限控制 | `perm` 字段 + `frontEnd.ts` 过滤 | `RePerms` / `ReAuth` 组件化 + permission store |
+| 代码规范 | 无 | ESLint + Prettier + Stylelint + Husky + lint-staged |
+| CSS 方案 | Scoped SCSS | Scoped SCSS + Tailwind CSS |
+| 组件库 | Element Plus + 自定义 ExtForm 组件 | Element Plus + PureAdmin 组件 + ExtForm 适配 |
+| HTTP 封装 | `utils/request.ts` ($get/$post/$body/$put) | `utils/http/index.ts` (axios 实例) |
+
+---
+
+## 二、功能模块迁移清单
+
+### 状态说明
+- ✅ 已完成 — 页面存在,有 API 对接、搜索/分页/表格功能
+- ⚠️ 部分完成 — 页面存在但缺少弹窗/详情/上传等子功能
+- ❌ 未迁移 — 无对应路由和页面
+
+---
+
+### 2.1 信息总览 (Dashboard)
+
+| 原版路由 | 新版权限 | 状态 | 说明 |
+|---------|---------|------|------|
+| `/home` → `views/admin/index.vue` | `/admin/dashboard` → `views/admin/dashboard/index.vue` | ✅ | ECharts 折线图+饼图、统计卡片、日期筛选、站点切换、深色主题适配均已实现 |
+
+---
+
+### 2.2 站点管理
+
+| 功能 | 原版文件 | 新版文件 | 状态 | 说明 |
+|------|---------|---------|------|------|
+| 站点清单 | `station/list/index.vue` + `dialog.vue` + `upload.vue` | `station/list.vue` + `dialog.vue` | ⚠️ | 核心 CRUD 已完成,**站点数据上传功能缺失** |
+| 设备清单 | `station/device/index.vue` + `dialog.vue` + `config.vue` + `upload.vue` | `station/device.vue` + `device-dialog.vue` | ⚠️ | 核心 CRUD 已完成,**设备详情配置页缺失**、**设备上传功能缺失** |
+| 站点账户 | `station/account/index.vue` | `station/account.vue` | ✅ | 已迁移 |
+
+---
+
+### 2.3 订单管理
+
+| 功能 | 原版文件 | 新版文件 | 状态 | 说明 |
+|------|---------|---------|------|------|
+| 订单列表 | `ordering/index.vue` + `dialog.vue` | `ordering/index.vue` + `dialog.vue` | ✅ | 17 列表字段完整,含订单详情弹窗 |
+
+---
+
+### 2.4 用户管理 (C端)
+
+| 功能 | 原版文件 | 新版文件 | 状态 | 说明 |
+|------|---------|---------|------|------|
+| C端用户列表 | `account/index.vue` + `detail.vue` | `user/index.vue` | ⚠️ | 列表+搜索完整,**用户详情页缺失** |
+
+---
+
+### 2.5 横幅广告
+
+| 功能 | 原版文件 | 新版文件 | 状态 | 说明 |
+|------|---------|---------|------|------|
+| 横幅管理 | `banner/index.vue` + `dialog.vue` | `banner/index.vue` | ⚠️ | 列表+搜索完整,**新增/编辑弹窗未独立抽离**(可能内联在 index.vue 中) |
+
+---
+
+### 2.6 财务管理
+
+| 功能 | 原版文件 | 新版文件 | 状态 | 说明 |
+|------|---------|---------|------|------|
+| 充值记录 | `finance/index.vue` | `finance/recharge.vue` (+ 冗余 `finance/index.vue`) | ✅ | 已迁移 |
+| 退款清单 | `finance/refund.vue` | `finance/refund.vue` | ✅ | 已迁移 |
+| 提现记录 | `finance/withdraw.vue` | `finance/withdraw.vue` | ✅ | 已迁移 |
+| 分账记录 | `finance/splitRecord.vue` | `finance/split-record.vue` | ✅ | 已迁移 |
+| 结算记录 | `finance/settlement.vue` | `finance/settlement.vue` | ✅ | 已迁移 |
+
+---
+
+### 2.7 平台配置
+
+| 功能 | 原版文件 | 新版文件 | 状态 | 说明 |
+|------|---------|---------|------|------|
+| 平台费率 | `platform/rate/index.vue` + `dialog.vue` | `platform/rate.vue` + `rate-dialog.vue` | ✅ | 已迁移 |
+| 设备配置 | `platform/deviceConfig/index.vue` + `dialog.vue` | `platform/device-config.vue` + `device-config-dialog.vue` | ✅ | 已迁移 |
+
+---
+
+### 2.8 系统配置
+
+| 功能 | 原版文件 | 新版文件 | 状态 | 说明 |
+|------|---------|---------|------|------|
+| 角色权限 | `role/index.vue` + `dialog.vue` | `role/index.vue` | ✅ | 权限树已实现(339行),弹窗可能内联 |
+| 数据字典 | `dict/index.vue` | `dict/index.vue` | ✅ | 嵌套表格+弹窗编辑已实现 |
+| 系统公告 | `notice/index.vue` | `notice/index.vue` | ✅ | 已迁移 |
+| 常见问题 | `faq/index.vue` + `dialog.vue` | `faq/index.vue` | ⚠️ | 列表+搜索完成,**新增/编辑弹窗未独立** |
+| 反馈上报 | `feedback/index.vue` + `dialog.vue` | `feedback/index.vue` | ⚠️ | 列表+搜索完成,**反馈详情/回复弹窗未独立** |
+| 操作日志 | `log/opt/index.vue` | `log/index.vue` | ✅ | 已迁移(含详情弹窗) |
+| 投资人 | `investor/index.vue` + `dialog.vue` | `investor/index.vue` | ⚠️ | 列表+搜索完成,**新增/编辑弹窗未独立** |
+| 运维账户 | `user/index.vue` + `dialog.vue` | `account/index.vue` | ⚠️ | 列表+搜索完成,**新增/编辑弹窗未独立** |
+
+---
+
+### 2.9 未迁移功能
+
+| 功能 | 原版文件 | 状态 | 说明 |
+|------|---------|------|------|
+| **部门管理** | `department/index.vue` + `dialog.vue` | ❌ | 部门树形结构管理,完全缺失 |
+| **消息管理** | `message/index.vue` | ❌ | 消息列表/发送/已读管理,完全缺失 |
+| **消息模板** | `template/index.vue` | ❌ | 消息模板 CRUD,完全缺失 |
+| **错误日志** | `log/error/index.vue` | ❌ | 仅迁移了操作日志,错误日志缺失 |
+
+---
+
+## 三、API 层迁移对照
+
+原版 admin-web 没有独立 API 层,新版已创建 22 个 API 模块:
+
+| API 模块 | 对应原版调用 | 状态 |
+|---------|------------|------|
+| `api/stat.ts` | `stat/dashboard`, `stat/trend`, `stat/washDeviceStatus` | ✅ |
+| `api/station.ts` | `washStation/*`, `stationAccount/*`, `washDevice/*` | ✅ |
+| `api/order.ts` | `washOrder/*` | ✅ |
+| `api/user.ts` | 登录/个人信息/字典 | ✅ |
+| `api/custom.ts` | `custom/listUser`, `custom/listRecharge` | ✅ |
+| `api/admin.ts` | `admin-user/*`, `adminUser/*` | ✅ |
+| `api/banner.ts` | `banner/*` | ✅ |
+| `api/finance.ts` | `finance/*`, `splitRecord/*`, `payLog/*` | ✅ |
+| `api/platform.ts` | `platform-fee-rate/*` | ✅ |
+| `api/device-config.ts` | `device-config/*`, `deviceConfig/*` | ✅ |
+| `api/role.ts` | `role/*`, `permission/*` | ✅ |
+| `api/dict.ts` | `dict/*` | ✅ |
+| `api/notice.ts` | `notice/*` | ✅ |
+| `api/faq.ts` | `faq/*` | ✅ |
+| `api/feedback.ts` | `feedback/*` | ✅ |
+| `api/investor.ts` | `investorInfo/*` | ✅ |
+| `api/file.ts` | 文件上传 | ✅ |
+| `api/routes.ts` | 动态路由(预留) | ✅ |
+| `api/mock.ts` | Mock 数据 | ✅ |
+| `api/list.ts` | 通用列表 | ✅ |
+| `api/system.ts` | 系统监控日志 | ✅ |
+| — | `message/*` | ❌ 缺失 |
+| — | `messageTemplate/*` | ❌ 缺失 |
+| — | `department/*` | ❌ 缺失 |
+
+---
+
+## 四、组件层变化
+
+| 原版组件 | 新版对应 | 说明 |
+|---------|---------|------|
+| `components/form/Ext*.vue` (17 个) | `components/ExtForm/` | 已适配到 pure-admin |
+| `components/auth/*.vue` | `components/RePerms/`, `components/ReAuth/` | pure-admin 内置方案 |
+| `components/noticeBar/` | `layout/components/lay-notice/` | 已迁移 |
+| `components/svgIcon/` | `components/ReIcon/` | pure-admin 内置 |
+| `components/qrcode/` | `components/ReQrcode/` | pure-admin 内置 |
+| `components/pdf/` | `vue-pdf-embed` 依赖 | npm 包替代 |
+| `components/avatar/` | 布局组件内置 | pure-admin 内置 |
+| `components/ReDialog/` | `components/ReDialog/` | pure-admin 已有 |
+| — | `components/RePureTableBar/` | pure-admin 表格栏 |
+| — | `components/ReSegmented/` | pure-admin 分段控制器 |
+| — | `components/ReSelector/` | pure-admin 选择器 |
+
+---
+
+## 五、汇总统计
+
+| 类别 | 总数 | 已完成 | 部分完成 | 未完成 |
+|------|------|--------|---------|--------|
+| 页面模块 | 22 | 13 | 5 | 4 |
+| API 模块 | 22 | 22 | 0 | 0 |
+| 路由页面 | 25 | 22 | — | 3 |
+
+### 待补全项目(按优先级)
+
+**高优先级:**
+1. ~~无~~ — 所有核心业务页面均已迁移
+
+**中优先级:**
+2. 部门管理 (`department`) — 完整缺失,需新建路由+页面+API
+3. 消息管理 (`message`) — 完整缺失,需新建路由+页面+API
+4. 消息模板 (`template`) — 完整缺失,需新建路由+页面+API
+
+**低优先级:**
+5. 错误日志 (`log/error`) — 可合并到 log/index.vue 中作为 Tab
+6. 站点上传功能 (`station/list/upload`, `station/device/upload`)
+7. 各模块独立弹窗组件 (banner/faq/feedback/investor/user/account/dialog.vue)
+8. 用户详情页 (`account/detail`)
+9. 清理冗余文件 (`station/index.vue`, `finance/index.vue`)