Prechádzať zdrojové kódy

岗位管理功能修改,分菜单层级,增加offer通知页面

ZGC4846 1 týždeň pred
rodič
commit
3a00e1a7f0

+ 20 - 6
src/App.vue

@@ -13,7 +13,25 @@ export default {
   data() {
     return { PROJECT_NAME, sidebarCollapsed: false, menus: [
       { path: '/', meta: { title: '首页', icon: 'el-icon-house' } },
-      { path: '/organization', meta: { title: '组织架构', icon: 'el-icon-office-building' } },
+      {
+        path: '/hr',
+        meta: { title: '人事管理', icon: 'el-icon-user' },
+        children: [
+          { path: '/organization', meta: { title: '组织机构' } },
+          { path: '/employee-archive', meta: { title: '员工档案' } },
+          { path: '/onboarding-handling', meta: { title: '办理入职' } },
+          { path: '/regularization-handling', meta: { title: '转正办理' } },
+          {
+            path: '/resignation',
+            meta: { title: '离职管理' },
+            children: [
+              { path: '/resignation-application', meta: { title: '离职申请' } },
+              { path: '/resignation-handover-order', meta: { title: '离职交接单' } }
+            ]
+          },
+          { path: '/position-transfer', meta: { title: '岗位调动' } }
+        ]
+      },
       {
         path: '/position',
         meta: { title: '岗位管理', icon: 'el-icon-suitcase' },
@@ -23,16 +41,12 @@ export default {
           { path: '/position-application', meta: { title: '岗位申请' } }
         ]
       },
-      { path: '/employee-archive', meta: { title: '员工档案', icon: 'el-icon-folder-opened' } },
       { path: '/recruitment-plan', meta: { title: '招聘需求计划', icon: 'el-icon-suitcase' } },
       { path: '/talent-pool', meta: { title: '人才库', icon: 'el-icon-user' } },
+      { path: '/offer-notify', meta: { title: 'Offer 通知', icon: 'el-icon-s-promotion' } },
       { path: '/onboarding-contract-template', meta: { title: '入职合同模板', icon: 'el-icon-document' } },
-      { path: '/regularization-handling', meta: { title: '转正办理', icon: 'el-icon-finished' } },
       { path: '/regularization-template', meta: { title: '转正申请模板', icon: 'el-icon-tickets' } },
-      { path: '/onboarding-handling', meta: { title: '办理入职', icon: 'el-icon-user-solid' } },
       { path: '/resignation-letter', meta: { title: '辞职申请书', icon: 'el-icon-document' } },
-      { path: '/resignation-application', meta: { title: '离职申请', icon: 'el-icon-edit-outline' } },
-      { path: '/resignation-handover-order', meta: { title: '离职交接单', icon: 'el-icon-s-operation' } },
       { path: '/resignation-handover-table', meta: { title: '离职交接表', icon: 'el-icon-tickets' } },
       { path: '/resignation-certificate', meta: { title: '离职证明', icon: 'el-icon-document-checked' } },
       { path: '/performance-scoring', meta: { title: '绩效评分', icon: 'el-icon-edit-outline' } },

+ 7 - 2
src/api/hr/index.js

@@ -278,6 +278,7 @@ export function serializePosition(form, positionList = []) {
     positionSequence: form.positionSequence || sequenceName(form.seq),
     positionLevel: form.positionLevel ?? apiLevel(form.level, form.seq),
     positionType: TYPE_TO_API[form.type] || TYPE_TO_API[3],
+    contractTemplateId: form.contractTemplateId,
     status: STATUS_TO_API[form.status] ?? 0,
     revokeDate: form.status === 3 ? form.revokeDate : undefined,
     departmentPosition: {
@@ -559,8 +560,7 @@ export const getPositionSequenceNames = async () =>
 export const getPositionSequenceLevels = async (sequence) =>
   unwrap(
     await request.get("/hr/positionSequence/levels", {
-      data: sequence,
-      headers: { "Content-Type": "application/json" },
+      params: { sequence },
     }),
   );
 export const getPositionSequencePage = async (data = {}) =>
@@ -571,6 +571,8 @@ export const updatePositionSequence = async (data) =>
   unwrap(await request.put("/hr/positionSequence/update", data));
 export const isPositionSequenceInUse = async (id) =>
   unwrap(await request.get(`/hr/positionSequence/${id}/in-use`));
+export const savePositionSequenceLevels = async (data) =>
+  unwrap(await request.post("/hr/positionSequence/levels/save", data));
 
 // 劳动合同模板
 export const getContractTemplatePage = async (data = {}) =>
@@ -646,6 +648,7 @@ export function adaptPositionApplicationDetail(raw = {}) {
     duties,
     dutiesText: duties.join("\n"),
     salaryMin: Number(salary.salaryMin ?? 0),
+    salaryMid: Number(salary.salaryMid ?? 0),
     salaryMax: Number(salary.salaryMax ?? 0),
     expYears: Number(salary.experienceYears ?? 0),
     education: salary.educationRequirement || "",
@@ -677,7 +680,9 @@ export function serializePositionApplication(form) {
     .filter(Boolean);
   const salary = compact({
     dutyContent: duties.join("\n"),
+    // 薪资字段统一数字类型
     salaryMin: Number(form.salaryMin || 0),
+    salaryMid: Number(form.salaryMid || 0),
     salaryMax: Number(form.salaryMax || 0),
     experienceYears: Number(form.expYears || 0),
     educationRequirement: form.education || "",

+ 40 - 0
src/api/sys/index.js

@@ -0,0 +1,40 @@
+import request from "@/utils/request";
+
+function unwrap(response) {
+  const result = response?.data || {};
+  if (Number(result.code) === 0) return result.data;
+  return Promise.reject(new Error(result.message || "服务调用失败"));
+}
+
+// 字典响应 List<Map<string,string>> 归一化为 {label, value}
+// 兼容多种结构:{label,value} / {dictLabel,dictValue} / {name,code} / 单键值对 {文案:值}
+export function normalizeDictItems(list = []) {
+  const items = Array.isArray(list) ? list : [];
+  return items
+    .map((item) => {
+      if (item === null || typeof item !== "object") {
+        const text = String(item ?? "");
+        return { label: text, value: text };
+      }
+      const entries = Object.entries(item);
+      const label = entries.find(([key]) => /label|name|text/i.test(key))?.[1];
+      const value = entries.find(([key]) => /value|code/i.test(key))?.[1];
+      if (label !== undefined || value !== undefined) {
+        return { label: String(label ?? value ?? ""), value: value ?? label ?? "" };
+      }
+      if (entries.length === 1) {
+        // 单键值对:键为文案,值为编码
+        return { label: String(entries[0][0] ?? ""), value: entries[0][1] ?? entries[0][0] };
+      }
+      return { label: String(item), value: String(item) };
+    })
+    .filter((item) => item.label !== "" && item.label !== undefined);
+}
+
+// 通用字典获取:按字典编码返回 {label, value} 项列表
+export async function getDictByCode(code) {
+  const data = unwrap(
+    await request.get(`/system/dict/getByCode/${encodeURIComponent(code)}`),
+  );
+  return normalizeDictItems(data);
+}

+ 2 - 0
src/router/index.js

@@ -21,6 +21,8 @@ const routes = [
   { path: '/personnel-structure-analysis-board', name: 'personnelStructureAnalysisBoard', component: () => import('@/views/personnelStructureAnalysisBoard/index.vue'), meta: { title: '人员结构分析看板', public: dev } },
   { path: '/recruitment-plan', name: 'recruitmentPlan', component: () => import('@/views/recruitmentPlan/index.vue'), meta: { title: '招聘需求计划', public: dev } },
   { path: '/talent-pool', name: 'talentPool', component: () => import('@/views/talentPool/index.vue'), meta: { title: '人才库', public: dev } },
+  { path: '/offer-notify', name: 'offerNotify', component: () => import('@/views/offerNotify/index.vue'), meta: { title: 'Offer 通知', public: dev } },
+  { path: '/position-transfer', name: 'positionTransfer', component: () => import('@/views/positionTransfer/index.vue'), meta: { title: '岗位调动', public: dev } },
   { path: '/onboarding-contract-template', name: 'onboardingContractTemplate', component: () => import('@/views/onboardingContractTemplate/index.vue'), meta: { title: '入职合同模板', public: dev } },
   { path: '/regularization-handling', name: 'regularizationHandling', component: () => import('@/views/regularizationHandling/index.vue'), meta: { title: '转正办理', public: dev } },
   { path: '/regularization-template', name: 'regularizationTemplate', component: () => import('@/views/regularizationTemplate/index.vue'), meta: { title: '转正申请模板', public: dev } },

+ 89 - 0
src/styles/views/offerNotify/index.scss

@@ -0,0 +1,89 @@
+.offer-page {
+  --primary: #1768e5;
+  --ink: #203047;
+  --muted: #7c899c;
+  min-height: 100%;
+  padding: 22px;
+  color: var(--ink);
+  background: radial-gradient(circle at 7% 0, rgba(32,183,216,.08), transparent 24%), radial-gradient(circle at 94% 3%, rgba(23,104,229,.08), transparent 22%), #f3f6fb;
+}
+.metric-grid { margin-bottom: 18px; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
+.metric-card { min-height: 104px; padding: 17px 18px; display: flex; align-items: center; gap: 14px; border: 1px solid rgba(211,221,235,.78); border-radius: 10px; background: rgba(255,255,255,.97); box-shadow: 0 8px 24px rgba(40,67,106,.065); transition: transform .2s ease, box-shadow .2s ease; }
+.metric-card:hover { transform: translateY(-2px); box-shadow: 0 13px 28px rgba(40,67,106,.1); }
+.metric-icon { width: 46px; height: 46px; flex: 0 0 46px; display: grid; place-items: center; border-radius: 9px; font-size: 20px; }
+.metric-card small { color: #718096; font-size: 11px; }
+.metric-card strong { margin-top: 3px; display: block; color: #1d2c42; font-size: 25px; line-height: 1.1; }
+.metric-card strong em { margin-left: 4px; color: #8390a2; font-size: 10px; font-style: normal; font-weight: 500; }
+.metric-card p { margin: 6px 0 0; color: #94a0af; font-size: 10px; }
+.metric-blue .metric-icon { color: #1768e5; background: #eaf2ff; }
+.metric-green .metric-icon { color: #168d69; background: #e7f7f1; }
+.metric-orange .metric-icon { color: #db7a25; background: #fff1e5; }
+.workspace-panel { overflow: hidden; border: 1px solid #e0e7f1; border-radius: 9px; background: #fff; box-shadow: 0 8px 24px rgba(40,67,106,.055); }
+.filter-bar { padding: 16px 18px; display: grid; grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) auto; align-items: end; gap: 14px; background: #fafbfd; border-bottom: 1px solid #edf1f6; }
+.filter-field label { margin-bottom: 7px; display: block; color: #536177; font-size: 11px; font-weight: 600; }
+.filter-actions { display: flex; gap: 8px; }
+.filter-actions ::v-deep .el-button + .el-button { margin-left: 0; }
+.active-filters { min-height: 45px; padding: 8px 18px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px; border-bottom: 1px solid #edf1f6; background: #fff; }
+.active-filters > span { margin-right: 3px; color: #68778c; font-size: 10px; font-weight: 600; }
+.active-filters > button { margin-left: auto; border: 0; color: #1768e5; background: transparent; font-size: 10px; cursor: pointer; }
+.offer-table { width: 100%; }
+.offer-table::before { display: none; }
+.offer-table ::v-deep .ele-table-tool { min-height: 68px; padding: 12px 18px; border-bottom: 1px solid #e8edf3; }
+.offer-table ::v-deep .ele-table-tool-title-label h6 { margin: 0; color: #26364c; font-size: 16px; font-weight: 600; }
+.offer-table ::v-deep .ele-table-tool-title-label > div { margin-top: 4px; color: #8d99a9; font-size: 10px; }
+.table-toolbar-actions { display: flex; gap: 8px; }
+.table-toolbar-actions ::v-deep .el-button + .el-button { margin-left: 0; }
+.offer-table ::v-deep th.el-table__cell { height: 48px; padding: 0; color: #68778c; background: #f6f8fb; font-size: 11px; font-weight: 600; }
+.offer-table ::v-deep td.el-table__cell { padding: 11px 0; border-bottom-color: #edf0f3; }
+.offer-table ::v-deep .el-table__row:hover > td.el-table__cell { background: #f2f7ff !important; }
+.candidate-cell { width: 100%; min-width: 0; padding: 0; display: flex; align-items: center; gap: 10px; border: 0; color: inherit; background: none; text-align: left; cursor: pointer; }
+.candidate-avatar { width: 34px; height: 34px; flex: 0 0 34px; display: grid; place-items: center; border-radius: 8px; color: #fff; background: linear-gradient(135deg, #1768e5, #55a1ff); font-size: 12px; font-weight: 700; }
+.candidate-cell > span:last-child { min-width: 0; }
+.candidate-cell strong, .candidate-cell small, .two-line strong, .two-line small { display: block; }
+.candidate-cell strong { overflow: hidden; color: #26364c; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
+.candidate-cell:hover strong { color: #1768e5; }
+.candidate-cell small, .two-line small { margin-top: 4px; color: #929dac; font-size: 9px; }
+.status-pill { display: inline-flex; align-items: center; gap: 5px; padding: 4px 9px; border-radius: 12px; font-size: 11px; }
+.status-pill i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
+.status-pill.is-active { color: #118969; background: #e8f8f3; }
+.status-pill.is-inactive { color: #8b94a2; background: #f0f2f5; }
+.two-line strong { color: #536177; font-size: 11px; font-weight: 500; }
+.file-name { display: inline-flex; align-items: center; gap: 5px; color: #58739b; font-size: 11px; }
+.file-name i { color: #4d6f9f; }
+.offer-table ::v-deep .el-pagination { padding: 15px 18px; }
+.table-empty { min-height: 210px; display: grid; place-content: center; justify-items: center; color: #8a96a7; }
+.table-empty > span { width: 46px; height: 46px; display: grid; place-items: center; border-radius: 8px; color: #1768e5; background: #edf4ff; font-size: 20px; }
+.table-empty strong { margin-top: 11px; color: #415069; font-size: 12px; }
+.table-empty p { margin: 5px 0 11px; font-size: 10px; }
+
+.form-guide { margin-bottom: 15px; padding: 11px 13px; display: flex; align-items: flex-start; gap: 9px; border: 1px solid #d9e8fb; border-radius: 7px; color: #59718f; background: #f5f9ff; }
+.form-guide > i { margin-top: 1px; color: #1768e5; }
+.form-guide strong, .form-guide small { display: block; }
+.form-guide strong { color: #385b86; font-size: 11px; }
+.form-guide small { margin-top: 3px; color: #8290a3; font-size: 9px; }
+.form-guide b { color: #e05a50; }
+.form-section { padding: 18px; border: 1px solid #e1e7ef; border-radius: 8px; background: #fff; }
+.form-section + .form-section { margin-top: 14px; }
+.section-title { margin-bottom: 17px; display: flex; align-items: center; gap: 10px; }
+.section-title > span { width: 33px; height: 33px; flex: 0 0 33px; display: grid; place-items: center; border-radius: 7px; color: #1768e5; background: #edf4ff; }
+.section-title h3 { margin: 0; color: #2a384d; font-size: 14px; }
+.section-title p { margin: 4px 0 0; color: #8a96a7; font-size: 9px; }
+.offer-form ::v-deep .el-form-item__label { padding-bottom: 5px; color: #4a586d; font-size: 11px; line-height: 19px; }
+.offer-form ::v-deep .el-select, .offer-form ::v-deep .el-date-editor { width: 100%; }
+.field-help { margin: 6px 0 0; color: #8a96a7; font-size: 9px; line-height: 1.5; }
+.offer-upload ::v-deep .el-upload-dragger { width: 100%; }
+.offer-upload ::v-deep .el-upload__tip { margin-top: 6px; color: #8a96a7; font-size: 9px; }
+.drawer-title { min-width: 0; display: flex; align-items: center; gap: 10px; }
+.drawer-title > span { width: 38px; height: 38px; flex: 0 0 38px; display: grid; place-items: center; border-radius: 8px; color: #1768e5; background: #eaf2ff; font-size: 17px; }
+.drawer-title > div { min-width: 0; }
+.drawer-title h2 { margin: 0; color: #1f2e43; font-size: 16px; }
+.drawer-title p { margin: 4px 0 0; color: #8b97a7; font-size: 9px; }
+.drawer-body { padding: 0 20px 92px; }
+.drawer-footer { position: absolute; right: 0; bottom: 0; left: 0; z-index: 5; min-height: 68px; padding: 11px 20px; display: flex; align-items: center; justify-content: space-between; gap: 13px; border-top: 1px solid #dfe6ef; background: #fff; box-shadow: 0 -8px 20px rgba(40,67,106,.06); }
+.drawer-footer > span { color: #7e8a9a; font-size: 9px; }
+.drawer-footer > span i { margin-right: 4px; }
+.drawer-footer > div { display: flex; gap: 8px; }
+.drawer-footer ::v-deep .el-button + .el-button { margin-left: 0; }
+::v-deep .offer-notify-drawer { position: absolute; right: 0; left: auto; overflow-y: auto; }
+::v-deep .offer-notify-drawer .el-drawer__header { margin-bottom: 15px; padding: 17px 20px 14px; border-bottom: 1px solid #e4e9f0; }
+@media (max-width: 760px) { .offer-page { padding: 14px; }.metric-grid, .filter-bar { grid-template-columns: 1fr; }.filter-actions .el-button { flex: 1; } }

+ 6 - 3
src/styles/views/positionApplication/index.scss

@@ -1,5 +1,7 @@
+
 .application-page {
   --primary: #1768e5;
+
   --ink: #203047;
   --muted: #7c899c;
   min-height: 100%;
@@ -123,9 +125,10 @@
 .field-help { margin: 6px 0 0; color: #8a96a7; font-size: 9px; line-height: 1.5; }
 .field-help i { margin-right: 4px; color: #1768e5; }
 .form-subtitle { margin: 16px 0 10px; color: #2a384d; font-size: 12px; font-weight: 700; }
-.salary-range-field { width: 100%; display: flex; align-items: center; gap: 8px; }
-.salary-range-field ::v-deep .el-input-number { flex: 1 1 0; width: auto; }
-.salary-range-field span { color: #8a96a7; font-size: 10px; }
+.salary-range-field { width: 100%; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
+.salary-range-item { min-width: 0; }
+.salary-range-label { margin-bottom: 4px; display: block; color: #8a96a7; font-size: 10px; }
+.salary-range-item ::v-deep .el-input-number { width: 100%; }
 .dynamic-row { margin-bottom: 10px; display: grid; grid-template-columns: minmax(0, 1fr) 130px auto auto; align-items: center; gap: 10px; }
 .dynamic-row.cert-row { grid-template-columns: minmax(0, 1fr) 120px 140px auto; }
 .dynamic-row ::v-deep .el-input-number { width: 100%; }

+ 31 - 0
src/styles/views/positionManagement/components/PositionEditDialog.scss

@@ -310,3 +310,34 @@
       grid-template-columns: 1fr;
     }
   }
+
+.basic-section { margin-bottom: 14px; }
+.level-tabs-wrap { margin-top: 14px; }
+.level-tabs-wrap.is-empty { padding: 8px 0; }
+.level-tabs ::v-deep .el-tabs__header { margin: 0 0 12px; }
+.level-config-body { margin-top: 12px; }
+.level-empty-tip { padding: 46px 0; display: flex; align-items: center; justify-content: center; gap: 6px; color: #8a96a7; font-size: 12px; border: 1px dashed #dbe4ee; border-radius: 8px; background: #fafbfd; }
+.level-empty-tip i { color: #1768e5; }
+
+.status-tabs.module-tabs { margin: 0 0 16px; padding: 5px; width: fit-content; display: flex; gap: 4px; border: 1px solid #dfe7f2; border-radius: 10px; background: #fff; box-shadow: 0 8px 22px rgba(40,67,106,.06); }
+.status-tabs.module-tabs button { height: 38px; padding: 0 16px; display: inline-flex; align-items: center; gap: 7px; border: 0; border-radius: 7px; color: #607187; background: transparent; font-size: 12px; cursor: pointer; transition: color .2s ease, background .2s ease; }
+.status-tabs.module-tabs button:hover { color: #1768e5; }
+.status-tabs.module-tabs button.active { color: #fff; background: linear-gradient(135deg, #1768e5, #20b7d8); box-shadow: 0 8px 18px rgba(23,104,229,.22); }
+.status-tabs.module-tabs button span { min-width: 18px; padding: 1px 5px; border-radius: 9px; color: #8090a4; background: #eef2f7; font-size: 10px; text-align: center; }
+.status-tabs.module-tabs button.active span { color: #fff; background: rgba(255,255,255,.25); }
+.module-panel { padding-top: 16px; }
+
+.drawer-title { min-width: 0; display: flex; align-items: center; gap: 10px; }
+.drawer-title > span { width: 38px; height: 38px; flex: 0 0 38px; display: grid; place-items: center; border-radius: 8px; color: #1768e5; background: #eaf2ff; font-size: 17px; }
+.drawer-title > div { min-width: 0; }
+.drawer-title h2 { margin: 0; color: #1f2e43; font-size: 16px; }
+.drawer-title p { margin: 4px 0 0; color: #8b97a7; font-size: 9px; }
+.drawer-body { padding: 0 22px 92px; }
+.drawer-footer { position: absolute; right: 0; bottom: 0; left: 0; z-index: 5; min-height: 68px; padding: 11px 20px; display: flex; align-items: center; justify-content: space-between; gap: 13px; border-top: 1px solid #dfe6ef; background: #fff; box-shadow: 0 -8px 20px rgba(40,67,106,.06); }
+.drawer-footer > span { color: #7e8a9a; font-size: 9px; }
+.drawer-footer > span i { margin-right: 4px; }
+.drawer-footer > div { display: flex; gap: 8px; }
+.drawer-footer ::v-deep .el-button + .el-button { margin-left: 0; }
+::v-deep .position-edit-drawer { position: absolute; right: 0; left: auto; overflow-y: auto; }
+::v-deep .position-edit-drawer .el-drawer__header { margin-bottom: 15px; padding: 17px 22px 14px; border-bottom: 1px solid #e4e9f0; }
+::v-deep .position-edit-drawer .el-drawer__body { padding: 0; }

+ 7 - 0
src/styles/views/positionSequence/index.scss

@@ -99,6 +99,13 @@
 .sequence-form ::v-deep .el-radio-button__inner { width: 100%; }
 .field-help { margin: 6px 0 0; color: #8a96a7; font-size: 9px; line-height: 1.5; }
 .field-help i { margin-right: 4px; color: #1768e5; }
+.level-editor { padding: 2px 0; }
+.level-row { display: grid; grid-template-columns: minmax(0, 1fr) 140px auto; align-items: center; gap: 10px; }
+.level-row + .level-row { margin-top: 10px; }
+.level-row ::v-deep .el-input-number { width: 100%; }
+.add-line { margin-top: 12px; }
+::v-deep .sequence-level-drawer { position: absolute; right: 0; left: auto; overflow-y: auto; }
+::v-deep .sequence-level-drawer .el-drawer__header { margin-bottom: 15px; padding: 17px 20px 14px; border-bottom: 1px solid #e4e9f0; }
 .detail-overview { margin-bottom: 15px; padding: 16px; display: flex; align-items: center; gap: 12px; border: 1px solid #dce8f6; border-radius: 8px; background: linear-gradient(110deg, #f6f9ff, #fbfdff); }
 .detail-category { width: 42px; height: 42px; flex: 0 0 42px; display: grid; place-items: center; border-radius: 9px; font-size: 15px; font-weight: 700; }
 .detail-overview h3 { margin: 0; color: #26364c; font-size: 17px; }

+ 121 - 0
src/styles/views/positionTransfer/index.scss

@@ -0,0 +1,121 @@
+.transfer-page {
+  --primary: #1768e5;
+  --ink: #203047;
+  --muted: #7c899c;
+  min-height: 100%;
+  padding: 22px;
+  color: var(--ink);
+  background: radial-gradient(circle at 7% 0, rgba(32,183,216,.08), transparent 24%), radial-gradient(circle at 94% 3%, rgba(23,104,229,.08), transparent 22%), #f3f6fb;
+}
+.metric-grid { margin-bottom: 18px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
+.metric-card { min-height: 104px; padding: 17px 18px; display: flex; align-items: center; gap: 14px; border: 1px solid rgba(211,221,235,.78); border-radius: 10px; background: rgba(255,255,255,.97); box-shadow: 0 8px 24px rgba(40,67,106,.065); transition: transform .2s ease, box-shadow .2s ease; }
+.metric-card:hover { transform: translateY(-2px); box-shadow: 0 13px 28px rgba(40,67,106,.1); }
+.metric-icon { width: 46px; height: 46px; flex: 0 0 46px; display: grid; place-items: center; border-radius: 9px; font-size: 20px; }
+.metric-card small { color: #718096; font-size: 11px; }
+.metric-card strong { margin-top: 3px; display: block; color: #1d2c42; font-size: 25px; line-height: 1.1; }
+.metric-card strong em { margin-left: 4px; color: #8390a2; font-size: 10px; font-style: normal; font-weight: 500; }
+.metric-card p { margin: 6px 0 0; color: #94a0af; font-size: 10px; }
+.metric-blue .metric-icon { color: #1768e5; background: #eaf2ff; }
+.metric-green .metric-icon { color: #168d69; background: #e7f7f1; }
+.metric-cyan .metric-icon { color: #168c98; background: #e7f7f7; }
+.metric-orange .metric-icon { color: #db7a25; background: #fff1e5; }
+.workspace-panel { overflow: hidden; border: 1px solid #e0e7f1; border-radius: 9px; background: #fff; box-shadow: 0 8px 24px rgba(40,67,106,.055); }
+.filter-bar { padding: 16px 18px; display: grid; grid-template-columns: repeat(4, minmax(180px, 1fr)) auto; align-items: end; gap: 14px; background: #fafbfd; border-bottom: 1px solid #edf1f6; }
+.filter-field label { margin-bottom: 7px; display: block; color: #536177; font-size: 11px; font-weight: 600; }
+.filter-field ::v-deep .el-select { width: 100%; }
+.filter-actions { display: flex; gap: 8px; }
+.filter-actions ::v-deep .el-button + .el-button { margin-left: 0; }
+.active-filters { min-height: 45px; padding: 8px 18px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px; border-bottom: 1px solid #edf1f6; background: #fff; }
+.active-filters > span { margin-right: 3px; color: #68778c; font-size: 10px; font-weight: 600; }
+.active-filters > button { margin-left: auto; border: 0; color: #1768e5; background: transparent; font-size: 10px; cursor: pointer; }
+.transfer-table { width: 100%; }
+.transfer-table::before { display: none; }
+.transfer-table ::v-deep .ele-table-tool { min-height: 68px; padding: 12px 18px; border-bottom: 1px solid #e8edf3; }
+.transfer-table ::v-deep .ele-table-tool-title-label h6 { margin: 0; color: #26364c; font-size: 16px; font-weight: 600; }
+.transfer-table ::v-deep .ele-table-tool-title-label > div { margin-top: 4px; color: #8d99a9; font-size: 10px; }
+.table-toolbar-actions { display: flex; gap: 8px; }
+.table-toolbar-actions ::v-deep .el-button + .el-button { margin-left: 0; }
+.transfer-table ::v-deep th.el-table__cell { height: 48px; padding: 0; color: #68778c; background: #f6f8fb; font-size: 11px; font-weight: 600; }
+.transfer-table ::v-deep td.el-table__cell { padding: 11px 0; border-bottom-color: #edf0f3; }
+.transfer-table ::v-deep .el-table__row:hover > td.el-table__cell { background: #f2f7ff !important; }
+.contract-cell { padding: 0; border: 0; color: #1768e5; background: none; font-size: 11px; font-family: Consolas, monospace; cursor: pointer; }
+.employee-cell { width: 100%; min-width: 0; padding: 0; display: flex; align-items: center; gap: 8px; border: 0; color: inherit; background: none; text-align: left; cursor: pointer; }
+.employee-avatar { width: 30px; height: 30px; flex: 0 0 30px; display: grid; place-items: center; border-radius: 7px; color: #fff; background: linear-gradient(135deg, #1768e5, #55a1ff); font-size: 11px; font-weight: 700; }
+.employee-cell strong { overflow: hidden; color: #26364c; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
+.employee-cell:hover strong { color: #1768e5; }
+.duties-cell { display: inline-block; max-width: 100%; overflow: hidden; color: #59677b; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; }
+.move-cell strong, .move-cell small { display: block; }
+.move-cell strong { color: #26364c; font-size: 11px; }
+.move-cell small { margin-top: 4px; color: #929dac; font-size: 9px; }
+.status-pill { display: inline-flex; align-items: center; gap: 5px; padding: 4px 9px; border-radius: 12px; font-size: 11px; }
+.status-pill i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
+.status-pill.is-active { color: #118969; background: #e8f8f3; }
+.status-pill.is-warning { color: #c46f11; background: #fff4e5; }
+.status-pill.is-inactive { color: #8b94a2; background: #f0f2f5; }
+.status-pill.is-danger { color: #c94242; background: #fff0f0; }
+.transfer-table ::v-deep .el-pagination { padding: 15px 18px; }
+.action-cell { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 2px; }
+.action-cell ::v-deep .el-button { font-size: 11px; }
+.action-cell ::v-deep .el-button + .el-button { margin-left: 4px; }
+.danger-text { color: #d85151 !important; }
+.table-empty { min-height: 210px; display: grid; place-content: center; justify-items: center; color: #8a96a7; }
+.table-empty > span { width: 46px; height: 46px; display: grid; place-items: center; border-radius: 8px; color: #1768e5; background: #edf4ff; font-size: 20px; }
+.table-empty strong { margin-top: 11px; color: #415069; font-size: 12px; }
+.table-empty p { margin: 5px 0 11px; font-size: 10px; }
+
+.drawer-title { min-width: 0; display: flex; align-items: center; gap: 10px; }
+.drawer-title > span { width: 38px; height: 38px; flex: 0 0 38px; display: grid; place-items: center; border-radius: 8px; color: #1768e5; background: #eaf2ff; font-size: 17px; }
+.drawer-title > div { min-width: 0; }
+.drawer-title h2 { margin: 0; color: #1f2e43; font-size: 16px; }
+.drawer-title p { margin: 4px 0 0; color: #8b97a7; font-size: 9px; }
+.drawer-title > em { margin-left: 7px; display: inline-flex; align-items: center; gap: 5px; color: #168d69; font-size: 10px; font-style: normal; white-space: nowrap; }
+.drawer-title > em i { width: 6px; height: 6px; border-radius: 50%; background: #1da274; }
+.drawer-title > em.muted { color: #8793a3; }
+.drawer-title > em.muted i { background: #a7b1be; }
+.drawer-title > em.warning { color: #d87826; }
+.drawer-title > em.warning i { background: #e0a25a; }
+.drawer-body { padding: 0 20px 92px; }
+.detail-overview { margin-bottom: 15px; padding: 16px; display: flex; justify-content: space-between; align-items: center; gap: 12px; border: 1px solid #dce8f6; border-radius: 8px; background: linear-gradient(110deg, #f6f9ff, #fbfdff); }
+.detail-overview h3 { margin: 0; color: #26364c; font-size: 16px; }
+.detail-overview p { margin: 5px 0 0; color: #8290a3; font-size: 10px; }
+.detail-overview > strong { padding: 6px 10px; border-radius: 6px; color: #1768e5; background: #eaf2ff; font-size: 10px; white-space: nowrap; }
+.detail-section { padding: 18px; border: 1px solid #e1e7ef; border-radius: 8px; background: #fff; }
+.detail-section + .detail-section { margin-top: 14px; }
+.detail-heading { margin-bottom: 17px; display: flex; align-items: center; gap: 10px; }
+.detail-heading > span { width: 33px; height: 33px; display: grid; place-items: center; border-radius: 7px; color: #1768e5; background: #edf4ff; }
+.detail-heading h3 { margin: 0; color: #2a384d; font-size: 14px; }
+.detail-heading p { margin: 4px 0 0; color: #8a96a7; font-size: 9px; }
+.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 24px; }
+.detail-grid span, .detail-block > span { display: block; color: #8a96a7; font-size: 10px; }
+.detail-grid strong { margin-top: 5px; display: block; color: #3d4d64; font-size: 12px; font-weight: 600; }
+.detail-block { margin-top: 16px; padding-top: 14px; border-top: 1px dashed #edf1f6; }
+.detail-text { margin: 7px 0 0; color: #59677b; font-size: 11px; line-height: 1.65; }
+.detail-tags { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 7px; }
+.detail-tags em { padding: 5px 9px; border: 1px solid #d9e5f4; border-radius: 5px; color: #58739b; background: #f7faff; font-size: 10px; font-style: normal; }
+.detail-muted { margin: 8px 0 0; color: #9aa5b4; font-size: 10px; }
+
+.form-guide { margin-bottom: 15px; padding: 11px 13px; display: flex; align-items: flex-start; gap: 9px; border: 1px solid #d9e8fb; border-radius: 7px; color: #59718f; background: #f5f9ff; }
+.form-guide > i { margin-top: 1px; color: #1768e5; }
+.form-guide strong, .form-guide small { display: block; }
+.form-guide strong { color: #385b86; font-size: 11px; }
+.form-guide small { margin-top: 3px; color: #8290a3; font-size: 9px; }
+.form-guide b { color: #e05a50; }
+.form-section { padding: 18px; border: 1px solid #e1e7ef; border-radius: 8px; background: #fff; }
+.form-section + .form-section { margin-top: 14px; }
+.section-title { margin-bottom: 17px; display: flex; align-items: center; gap: 10px; }
+.section-title > span { width: 33px; height: 33px; flex: 0 0 33px; display: grid; place-items: center; border-radius: 7px; color: #1768e5; background: #edf4ff; }
+.section-title h3 { margin: 0; color: #2a384d; font-size: 14px; }
+.section-title p { margin: 4px 0 0; color: #8a96a7; font-size: 9px; }
+.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 15px; }
+.transfer-form ::v-deep .el-form-item__label { padding-bottom: 5px; color: #4a586d; font-size: 11px; line-height: 19px; }
+.transfer-form ::v-deep .el-select { width: 100%; }
+.transfer-upload ::v-deep .el-upload__tip { margin-top: 6px; color: #8a96a7; font-size: 9px; }
+.drawer-footer { position: absolute; right: 0; bottom: 0; left: 0; z-index: 5; min-height: 68px; padding: 11px 20px; display: flex; align-items: center; justify-content: space-between; gap: 13px; border-top: 1px solid #dfe6ef; background: #fff; box-shadow: 0 -8px 20px rgba(40,67,106,.06); }
+.drawer-footer > span { color: #7e8a9a; font-size: 9px; }
+.drawer-footer > span i { margin-right: 4px; }
+.drawer-footer > div { display: flex; gap: 8px; }
+.drawer-footer ::v-deep .el-button + .el-button { margin-left: 0; }
+::v-deep .transfer-drawer, ::v-deep .flow-drawer { position: absolute; right: 0; left: auto; overflow-y: auto; }
+::v-deep .transfer-drawer .el-drawer__header, ::v-deep .flow-drawer .el-drawer__header { margin-bottom: 15px; padding: 17px 20px 14px; border-bottom: 1px solid #e4e9f0; }
+@media (max-width: 1280px) { .metric-grid { grid-template-columns: 1fr 1fr; }.filter-bar { grid-template-columns: 1fr 1fr; }.filter-actions { justify-content: flex-end; } }
+@media (max-width: 760px) { .transfer-page { padding: 14px; }.metric-grid, .filter-bar, .form-grid, .detail-grid { grid-template-columns: 1fr; }.drawer-footer > span { display: none; }.drawer-footer > div { margin-left: auto; } }

+ 564 - 0
src/views/offerNotify/index.vue

@@ -0,0 +1,564 @@
+<template>
+  <main class="offer-page" v-loading="loading">
+    <section class="metric-grid" aria-label="Offer 通知概览">
+      <article
+        v-for="item in metrics"
+        :key="item.label"
+        class="metric-card"
+        :class="'metric-' + item.tone"
+      >
+        <span class="metric-icon"><i :class="item.icon"></i></span>
+        <div>
+          <small>{{ item.label }}</small
+          ><strong>{{ item.value }}<em>{{ item.unit }}</em></strong>
+          <p>{{ item.note }}</p>
+        </div>
+      </article>
+    </section>
+
+    <section class="workspace-panel">
+      <div class="filter-bar">
+        <div class="filter-field keyword-field">
+          <label>姓名</label>
+          <el-input
+            v-model.trim="draftFilters.name"
+            clearable
+            prefix-icon="el-icon-search"
+            placeholder="输入候选人姓名"
+            @keyup.enter.native="applyFilters"
+          />
+        </div>
+        <div class="filter-field">
+          <label>邮箱</label>
+          <el-input
+            v-model.trim="draftFilters.email"
+            clearable
+            prefix-icon="el-icon-message"
+            placeholder="输入接收邮箱"
+            @keyup.enter.native="applyFilters"
+          />
+        </div>
+        <div class="filter-actions">
+          <el-button type="primary" icon="el-icon-search" @click="applyFilters"
+            >查询</el-button
+          >
+          <el-button icon="el-icon-refresh-left" @click="resetFilters"
+            >重置</el-button
+          >
+        </div>
+      </div>
+
+      <div v-if="hasFilters" class="active-filters">
+        <span><i class="el-icon-filter"></i> 筛选条件</span>
+        <el-tag
+          v-if="filters.name"
+          closable
+          size="small"
+          @close="clearFilter('name')"
+          >姓名:{{ filters.name }}</el-tag
+        >
+        <el-tag
+          v-if="filters.email"
+          closable
+          size="small"
+          type="success"
+          @close="clearFilter('email')"
+          >邮箱:{{ filters.email }}</el-tag
+        >
+        <button type="button" @click="resetFilters">清空全部</button>
+      </div>
+
+      <ele-pro-table
+        ref="table"
+        row-key="id"
+        :columns="columns"
+        :datasource="filteredRecords"
+        :need-page="true"
+        :page-size="6"
+        title="Offer 通知记录"
+        :sub-title="
+          '共 ' +
+          filteredRecords.length +
+          ' 条通知,点击「发起 Offer 通知」发送新邮件'
+        "
+        :toolkit="['size', 'columns', 'fullscreen']"
+        stripe
+        class="offer-table"
+      >
+        <template slot="toolbar">
+          <div class="table-toolbar-actions">
+            <el-button
+              size="small"
+              type="primary"
+              icon="el-icon-s-promotion"
+              @click="openNotify()"
+              >发起 Offer 通知</el-button
+            >
+          </div>
+        </template>
+        <template v-slot:name="{ row }">
+          <button
+            type="button"
+            class="candidate-cell"
+            @click.stop="openNotify(row)"
+          >
+            <span class="candidate-avatar">{{ initials(row.name) }}</span>
+            <span
+              ><strong>{{ row.name }}</strong
+              ><small>{{ row.email }}</small></span
+            >
+          </button>
+        </template>
+        <template v-slot:online="{ row }">
+          <span
+            class="status-pill"
+            :class="row.online === 1 ? 'is-active' : 'is-inactive'"
+            ><i></i>{{ row.online === 1 ? "线上通知" : "线下邮件" }}</span
+          >
+        </template>
+        <template v-slot:dates="{ row }">
+          <div class="two-line">
+            <strong>{{ row.effectiveDate }}</strong
+            ><small>截止 {{ row.reportDeadline }}</small>
+          </div>
+        </template>
+        <template v-slot:attachment="{ row }">
+          <span class="file-name"
+            ><i class="el-icon-document"></i>{{ row.fileName }}</span
+          >
+        </template>
+        <template v-slot:status="{ row }">
+          <span class="status-pill is-active"><i></i>{{ row.status }}</span>
+        </template>
+        <template v-slot:action="{ row }">
+          <el-button
+            type="text"
+            icon="el-icon-s-promotion"
+            @click.stop="openNotify(row)"
+            >重新通知</el-button
+          >
+        </template>
+        <template slot="empty"
+          ><div class="table-empty">
+            <span><i class="el-icon-s-promotion"></i></span
+            ><strong>暂无 Offer 通知记录</strong>
+            <p>点击「发起 Offer 通知」发送第一封 Offer 邮件。</p>
+          </div></template
+        >
+      </ele-pro-table>
+    </section>
+
+    <el-drawer
+      :visible.sync="notifyVisible"
+      direction="rtl"
+      :size="drawerSize"
+      custom-class="offer-notify-drawer"
+      append-to-body
+      :before-close="closeNotify"
+      @closed="resetNotify"
+    >
+      <template slot="title">
+        <div class="drawer-title">
+          <span><i class="el-icon-s-promotion"></i></span>
+          <div>
+            <h2>{{
+              notifyForm.name ? "Offer 通知 · " + notifyForm.name : "Offer 通知"
+            }}</h2>
+            <p>填写候选人信息并发送 Offer 邮件</p>
+          </div>
+        </div>
+      </template>
+      <div class="drawer-body">
+      <el-form
+        ref="notifyForm"
+        :model="notifyForm"
+        :rules="notifyRules"
+        label-position="top"
+        class="offer-form"
+      >
+        <div class="form-guide">
+          <i class="el-icon-info"></i>
+          <span>
+            <strong>发起 Offer 通知</strong>
+            <small
+              >带 <b>*</b> 的项目为必填项,填写候选人信息并上传 Offer 文件(PDF)后发送。</small
+            >
+          </span>
+        </div>
+
+        <section class="form-section">
+          <div class="section-title">
+            <span><i class="el-icon-user"></i></span>
+            <div>
+              <h3>候选人信息</h3>
+              <p>姓名、通知方式与接收邮箱</p>
+            </div>
+          </div>
+          <el-row :gutter="16">
+            <el-col :xs="24" :sm="12"
+              ><el-form-item label="姓名" prop="name"
+                ><el-input
+                  v-model.trim="notifyForm.name"
+                  maxlength="20"
+                  placeholder="候选人姓名"
+              /></el-form-item
+            ></el-col>
+            <el-col :xs="24" :sm="12"
+              ><el-form-item label="通知方式" prop="notifyType"
+                ><el-select v-model="notifyForm.notifyType" class="full-width">
+                  <el-option
+                    v-for="item in notifyTypes"
+                    :key="item.value"
+                    :label="item.label"
+                    :value="item.value"
+                  />
+                </el-select></el-form-item
+            ></el-col>
+            <el-col :xs="24" :sm="12"
+              ><el-form-item label="接收邮箱" prop="email"
+                ><el-input
+                  v-model.trim="notifyForm.email"
+                  placeholder="请输入接收邮箱地址"
+                /><p class="field-help">原型无标签,即候选人接收邮箱</p></el-form-item
+            ></el-col>
+            <el-col :xs="24" :sm="12"
+              ><el-form-item label="是否线上通知" prop="online"
+                ><el-select v-model="notifyForm.online" class="full-width">
+                  <el-option label="是" :value="1" />
+                  <el-option label="否" :value="0" />
+                </el-select></el-form-item
+            ></el-col>
+          </el-row>
+        </section>
+
+        <section class="form-section">
+          <div class="section-title">
+            <span><i class="el-icon-date"></i></span>
+            <div>
+              <h3>邮件配置</h3>
+              <p>邮件主题、生效与报到截止日期</p>
+            </div>
+          </div>
+          <el-row :gutter="16">
+            <el-col :xs="24" :sm="12"
+              ><el-form-item label="主题" prop="subject"
+                ><el-input
+                  v-model.trim="notifyForm.subject"
+                  placeholder="邮件主题(线上发送使用)"
+              /></el-form-item
+            ></el-col>
+            <el-col :xs="24" :sm="12"
+              ><el-form-item label="生效日期">
+                <el-date-picker
+                  v-model="notifyForm.effectiveDate"
+                  type="date"
+                  value-format="yyyy-MM-dd"
+                  class="full-width"
+                  placeholder="审核通过系统日期"
+                />
+                <p class="field-help">默认审核通过系统日期</p>
+              </el-form-item>
+            </el-col>
+            <el-col :xs="24" :sm="12"
+              ><el-form-item label="报到截止日期">
+                <el-date-picker
+                  v-model="notifyForm.reportDeadline"
+                  type="date"
+                  value-format="yyyy-MM-dd"
+                  class="full-width"
+                  placeholder="审核通过系统日期"
+                />
+                <p class="field-help">默认审核通过系统日期</p>
+              </el-form-item>
+            </el-col>
+            <el-col :xs="24" :sm="12"
+              ><el-form-item label="附件" prop="attachment"
+                ><el-upload
+                  :auto-upload="false"
+                  :limit="1"
+                  accept="application/pdf,.pdf"
+                  :on-change="handleFileChange"
+                  :on-remove="handleFileRemove"
+                  :file-list="notifyForm.fileList"
+                  class="offer-upload"
+                >
+                  <el-button size="small" icon="el-icon-upload2"
+                    >上传 Offer 文件(PDF)</el-button
+                  >
+                  <div slot="tip" class="el-upload__tip">
+                    仅支持 PDF 文件,必填
+                  </div>
+                </el-upload></el-form-item
+              ></el-col>
+          </el-row>
+        </section>
+
+        <section class="form-section">
+          <div class="section-title">
+            <span><i class="el-icon-chat-dot-round"></i></span>
+            <div>
+              <h3>备注</h3>
+              <p>预置文案,可修改</p>
+            </div>
+          </div>
+          <el-form-item label="备注" prop="remark">
+            <el-input
+              v-model="notifyForm.remark"
+              type="textarea"
+              :rows="4"
+              maxlength="200"
+              show-word-limit
+            />
+          </el-form-item>
+        </section>
+      </el-form>
+
+      </div>
+      <div class="drawer-footer">
+        <span><i class="el-icon-lock"></i> 附件需上传 Offer 文件(PDF)</span>
+        <div>
+          <el-button @click="closeNotify()">取消</el-button>
+          <el-button type="primary" :loading="sending" @click="sendNotify"
+            >发送通知</el-button
+          >
+        </div>
+      </div>
+    </el-drawer>
+  </main>
+</template>
+
+<script>
+import { notifyTypes, presetRemark, notifyRecords } from "./mock";
+
+const today = () => new Date().toISOString().slice(0, 10);
+const emptyFilters = () => ({ name: "", email: "" });
+const emptyNotify = () => ({
+  id: null,
+  name: "",
+  notifyType: "email",
+  email: "",
+  online: 1,
+  subject: "",
+  effectiveDate: today(),
+  reportDeadline: today(),
+  fileList: [],
+  remark: presetRemark,
+});
+
+export default {
+  name: "OfferNotify",
+  data() {
+    return {
+      loading: false,
+      sending: false,
+      records: JSON.parse(JSON.stringify(notifyRecords)),
+      draftFilters: emptyFilters(),
+      filters: emptyFilters(),
+      notifyTypes,
+      notifyVisible: false,
+      notifyForm: emptyNotify(),
+      notifyRules: {
+        name: [{ required: true, message: "请输入候选人姓名", trigger: "blur" }],
+        notifyType: [
+          { required: true, message: "请选择通知方式", trigger: "change" },
+        ],
+        email: [
+          { required: true, message: "请输入接收邮箱地址", trigger: "blur" },
+          { type: "email", message: "邮箱格式不正确", trigger: "blur" },
+        ],
+        online: [
+          { required: true, message: "请选择是否线上通知", trigger: "change" },
+        ],
+        attachment: [
+          {
+            validator: (_rule, _value, callback) => {
+              if (!this.notifyForm.fileList || !this.notifyForm.fileList.length) {
+                callback(new Error("请上传 Offer 文件(PDF)"));
+              } else {
+                callback();
+              }
+            },
+            trigger: "change",
+          },
+        ],
+      },
+    };
+  },
+  computed: {
+    columns() {
+      return [
+        { type: "index", label: "序号", width: 60, align: "center" },
+        {
+          columnKey: "name",
+          label: "候选人",
+          minWidth: 190,
+          slot: "name",
+        },
+        {
+          columnKey: "online",
+          label: "通知方式",
+          width: 120,
+          align: "center",
+          slot: "online",
+        },
+        {
+          columnKey: "dates",
+          label: "生效 / 报到截止",
+          width: 150,
+          slot: "dates",
+        },
+        {
+          columnKey: "attachment",
+          label: "附件",
+          minWidth: 180,
+          slot: "attachment",
+        },
+        {
+          columnKey: "status",
+          label: "状态",
+          width: 100,
+          align: "center",
+          slot: "status",
+        },
+        {
+          columnKey: "action",
+          label: "操作",
+          width: 110,
+          fixed: "right",
+          align: "center",
+          resizable: false,
+          slot: "action",
+        },
+      ];
+    },
+    metrics() {
+      const total = this.records.length;
+      const online = this.records.filter((item) => item.online === 1).length;
+      const offline = this.records.filter((item) => item.online === 0).length;
+      return [
+        {
+          label: "通知总数",
+          value: total,
+          unit: "封",
+          note: "已发送 Offer 通知合计",
+          icon: "el-icon-s-promotion",
+          tone: "blue",
+        },
+        {
+          label: "线上通知",
+          value: online,
+          unit: "封",
+          note: "邮件系统实时发送",
+          icon: "el-icon-upload",
+          tone: "green",
+        },
+        {
+          label: "线下邮件",
+          value: offline,
+          unit: "封",
+          note: "线下发送,需人工处理",
+          icon: "el-icon-message",
+          tone: "orange",
+        },
+      ];
+    },
+    // 按姓名 / 邮箱过滤通知记录
+    filteredRecords() {
+      const name = this.filters.name.trim().toLowerCase();
+      const email = this.filters.email.trim().toLowerCase();
+      return this.records.filter((item) => {
+        return (
+          (!name || String(item.name || "").toLowerCase().includes(name)) &&
+          (!email || String(item.email || "").toLowerCase().includes(email))
+        );
+      });
+    },
+    hasFilters() {
+      return Boolean(this.filters.name || this.filters.email);
+    },
+    drawerSize() {
+      return window.innerWidth < 720 ? "96%" : "560px";
+    },
+  },
+  methods: {
+    openNotify(row) {
+      this.notifyForm = emptyNotify();
+      if (row) {
+        this.notifyForm.id = row.id;
+        this.notifyForm.name = row.name;
+        this.notifyForm.email = row.email;
+        this.notifyForm.online = row.online;
+        this.notifyForm.subject = row.subject;
+        this.notifyForm.effectiveDate = row.effectiveDate;
+        this.notifyForm.reportDeadline = row.reportDeadline;
+        if (row.fileName) {
+          this.notifyForm.fileList = [
+            { name: row.fileName, url: "", status: "ready" },
+          ];
+        }
+      }
+      this.notifyVisible = true;
+      this.$nextTick(() => this.$refs.notifyForm?.clearValidate());
+    },
+    resetNotify() {
+      this.notifyForm = emptyNotify();
+      this.$refs.notifyForm?.clearValidate();
+    },
+    closeNotify(done) {
+      this.notifyVisible = false;
+      if (typeof done === "function") done();
+    },
+    applyFilters() {
+      this.filters = { ...this.draftFilters };
+      this.reloadTable();
+    },
+    resetFilters() {
+      this.draftFilters = emptyFilters();
+      this.filters = emptyFilters();
+      this.reloadTable();
+    },
+    clearFilter(key) {
+      this.draftFilters[key] = "";
+      this.filters[key] = "";
+      this.reloadTable();
+    },
+    reloadTable() {
+      this.$nextTick(() => this.$refs.table?.reload({ page: 1 }));
+    },
+    handleFileChange(file) {
+      if (file.name && !file.name.toLowerCase().endsWith(".pdf")) {
+        this.$message.warning("仅支持上传 PDF 文件");
+        this.notifyForm.fileList = [];
+        return;
+      }
+      this.notifyForm.fileList = [file];
+    },
+    handleFileRemove() {
+      this.notifyForm.fileList = [];
+    },
+    sendNotify() {
+      this.$refs.notifyForm.validate((valid) => {
+        if (!valid || this.sending) return;
+        this.sending = true;
+        // mock:模拟发送,后续替换为通知接口
+        setTimeout(() => {
+          this.$message.success(
+            `Offer 通知已发送至 ${this.notifyForm.email}`,
+          );
+          this.notifyVisible = false;
+          this.sending = false;
+        }, 600);
+      });
+    },
+    initials(name = "") {
+      return name.length > 2 ? name.slice(-2) : name;
+    },
+  },
+};
+</script>
+
+<style
+  lang="scss"
+  scoped
+  src="../../styles/views/offerNotify/index.scss"
+></style>

+ 47 - 0
src/views/offerNotify/mock.js

@@ -0,0 +1,47 @@
+// Offer 通知 - 离线 mock 数据(后续接入接口后替换为 API 数据源)
+
+// 通知方式:当前仅邮箱
+export const notifyTypes = [{ value: "email", label: "邮箱" }];
+
+// 备注预置文案
+export const presetRemark = "报到截止日期到期视为无效,特此告知通知人员!";
+
+// 近期通知记录(页面列表展示,mock)
+export const notifyRecords = [
+  {
+    id: 1,
+    name: "刘启航",
+    email: "liuqihang@example.com",
+    online: 1,
+    subject: "诚邀您加入长沙中盈制造",
+    effectiveDate: "2026-08-20",
+    reportDeadline: "2026-08-27",
+    fileName: "Offer-刘启航.pdf",
+    status: "已发送",
+    sentAt: "2026-08-20 10:24",
+  },
+  {
+    id: 2,
+    name: "赵晨",
+    email: "zhaochen@example.com",
+    online: 0,
+    subject: "Offer 通知书",
+    effectiveDate: "2026-08-18",
+    reportDeadline: "2026-08-25",
+    fileName: "Offer-赵晨.pdf",
+    status: "已发送",
+    sentAt: "2026-08-18 15:40",
+  },
+  {
+    id: 3,
+    name: "孙浩",
+    email: "sunhao@example.com",
+    online: 1,
+    subject: "诚邀您加入常德精工",
+    effectiveDate: "2026-08-15",
+    reportDeadline: "2026-08-22",
+    fileName: "Offer-孙浩.pdf",
+    status: "已发送",
+    sentAt: "2026-08-15 09:12",
+  },
+];

+ 129 - 61
src/views/positionApplication/index.vue

@@ -483,29 +483,44 @@
               /></el-form-item>
               <el-form-item label="岗位薪资区间" prop="salaryMax">
                 <div class="salary-range-field">
-                  <el-input-number
-                    v-model="form.salaryMin"
-                    :min="0"
-                    :step="500"
-                    controls-position="right"
-                    placeholder="最低月薪"
-                  />
-                  <span>至</span>
-                  <el-input-number
-                    v-model="form.salaryMax"
-                    :min="0"
-                    :step="500"
-                    controls-position="right"
-                    placeholder="最高月薪"
-                  />
+                  <div class="salary-range-item">
+                    <span class="salary-range-label">下限</span>
+                    <el-input-number
+                      v-model="form.salaryMin"
+                      :min="0"
+                      :step="500"
+                      controls-position="right"
+                      placeholder="下限"
+                      @change="syncSalaryMid"
+                    />
+                  </div>
+                  <div class="salary-range-item">
+                    <span class="salary-range-label">中位值</span>
+                    <el-input-number
+                      v-model="form.salaryMid"
+                      :min="0"
+                      :step="500"
+                      controls-position="right"
+                      placeholder="中位值"
+                    />
+                  </div>
+                  <div class="salary-range-item">
+                    <span class="salary-range-label">上限</span>
+                    <el-input-number
+                      v-model="form.salaryMax"
+                      :min="0"
+                      :step="500"
+                      controls-position="right"
+                      placeholder="上限"
+                      @change="syncSalaryMid"
+                    />
+                  </div>
                 </div>
+                <p class="field-help">
+                  <i class="el-icon-info"></i>
+                  单位:元,修改上下限自动计算中位值。
+                </p>
               </el-form-item>
-              <el-form-item label="申请人" class="applicant-field"
-                ><el-input
-                  v-model="form.applicant"
-                  disabled
-                  placeholder="保存后自动记录当前登录用户"
-              /></el-form-item>
             </div>
             <el-form-item label="岗位职责" prop="dutiesText"
               ><el-input
@@ -668,10 +683,6 @@ import {
   updatePositionApplication,
 } from "@/api/hr";
 import { listOrganizations } from "@/api/organization";
-import {
-  buildOrganizationTree,
-  flattenOrganizationTree,
-} from "@/utils/organization-tree";
 import {
   positionLevels,
   positionTypes,
@@ -724,6 +735,7 @@ const emptyForm = () => ({
   contractTemplateId: null,
   headcount: 1,
   salaryMin: 0,
+  salaryMid: 0,
   salaryMax: 0,
   expYears: 0,
   education: "",
@@ -785,9 +797,9 @@ export default {
         positionSequence: [
           { required: true, message: "请选择岗位序列", trigger: "change" },
         ],
-        contractTemplateId: [
-          { required: true, message: "请选择绑定合同模板", trigger: "change" },
-        ],
+        // contractTemplateId: [
+        //   { required: true, message: "请选择绑定合同模板", trigger: "change" },
+        // ],
         headcount: [
           { required: true, message: "请输入申请招聘人数", trigger: "change" },
         ],
@@ -796,16 +808,6 @@ export default {
           { required: true, message: "请输入岗位职责", trigger: "blur" },
         ],
       },
-      salaryRangeValidator: (_rule, _value, callback) => {
-        if (
-          Number(this.form.salaryMax) > 0 &&
-          Number(this.form.salaryMax) < Number(this.form.salaryMin)
-        ) {
-          callback(new Error("薪资上限不能低于下限"));
-        } else {
-          callback();
-        }
-      },
     };
   },
   computed: {
@@ -925,14 +927,18 @@ export default {
     },
     departmentOptions() {
       return this.departments.filter(
-        (item) => item.legalId === this.form.organizationId,
+        (item) => String(item.legalId) === String(this.form.organizationId),
       );
     },
     salaryText() {
       const min = Number(this.form.salaryMin || 0);
+      const mid = Number(this.form.salaryMid || 0);
       const max = Number(this.form.salaryMax || 0);
       if (!min && !max) return "-";
-      return `¥${min.toLocaleString("zh-CN")} - ¥${max.toLocaleString("zh-CN")}`;
+      const range = `¥${min.toLocaleString("zh-CN")} - ¥${max.toLocaleString(
+        "zh-CN",
+      )}`;
+      return mid ? `${range}(中位 ¥${mid.toLocaleString("zh-CN")})` : range;
     },
     hasFilters() {
       return Boolean(
@@ -991,32 +997,39 @@ export default {
         return { ...item, duties: [] };
       }
     },
-    // 加载组织架构:顶层机构 + 部门(部门归属到顶层机构)
+    // 加载组织架构:顶层机构 + 部门(部门向上回溯归属到顶层机构),与岗位设定保持一致
     async loadOrganizations() {
       try {
         const list = await listOrganizations();
         const records = Array.isArray(list) ? list : [];
-        const tree = buildOrganizationTree(records);
-        const flat = flattenOrganizationTree(tree);
-        const rootOf = new Map();
-        const walk = (nodes, rootId) => {
-          nodes.forEach((node) => {
-            rootOf.set(String(node.id), rootId);
-            walk(node.children || [], rootId);
-          });
-        };
-        tree.forEach((node) => walk([node], node.id));
-        this.legalEntities = tree.map((node) => ({
-          id: node.id,
-          name: node.name,
-        }));
-        this.departments = flat.map((node) => ({
-          id: node.id,
-          name: node.name,
-          legalId: rootOf.get(String(node.id)),
-        }));
+        const parents = new Map(records.map((item) => [String(item.id), item]));
+        this.legalEntities = records
+          .filter((item) => !parents.has(String(item.parentId || 0)))
+          .map((item) => ({
+            id: item.id,
+            name: item.name,
+            code: item.groupCode || item.code || "ZY",
+          }));
+        if (!this.legalEntities.length) {
+          this.legalEntities = records.map((item) => ({
+            id: item.id,
+            name: item.name,
+            code: item.groupCode || item.code || "ZY",
+          }));
+        }
+        this.departments = records.map((item) => {
+          let current = item;
+          while (parents.has(String(current.parentId)))
+            current = parents.get(String(current.parentId));
+          return {
+            id: item.id,
+            name: item.name,
+            code: item.groupCode || item.code || "",
+            legalId: current.id,
+          };
+        });
         this.orgMap = Object.fromEntries(
-          flat.map((node) => [String(node.id), node.name]),
+          records.map((item) => [String(item.id), item.name]),
         );
       } catch (error) {
         this.legalEntities = [];
@@ -1055,10 +1068,42 @@ export default {
       try {
         const data = await getPositionApplicationPage({ pageNum: 1, size: 1000 });
         this.overview = (data?.list || []).map(adaptPositionApplication);
+        this.fallbackOrganizations();
       } catch (error) {
         this.overview = [];
       }
     },
+    // 组织接口无数据时,从岗位申请数据推导机构和部门,保证下拉可用
+    fallbackOrganizations() {
+      if (this.departments.length || !this.overview.length) return;
+      this.legalEntities = [
+        ...new Map(
+          this.overview
+            .filter((item) => item.organizationId)
+            .map((item) => [
+              String(item.organizationId),
+              {
+                id: item.organizationId,
+                name: item.organizationName || `组织${item.organizationId}`,
+              },
+            ]),
+        ).values(),
+      ];
+      this.departments = [
+        ...new Map(
+          this.overview
+            .filter((item) => item.deptId)
+            .map((item) => [
+              String(item.deptId),
+              {
+                id: item.deptId,
+                name: item.deptName || `部门${item.deptId}`,
+                legalId: item.organizationId,
+              },
+            ]),
+        ).values(),
+      ];
+    },
     refreshData() {
       this.loadOverview();
       this.reloadTable();
@@ -1083,6 +1128,9 @@ export default {
     openCreate() {
       this.mode = "create";
       this.form = emptyForm();
+      // 默认选中第一个机构并联动默认部门,与岗位设定保持一致
+      this.form.organizationId = this.legalEntities[0]?.id || null;
+      this.form.deptId = this.departmentOptions[0]?.id || null;
       this.drawerVisible = true;
       this.$nextTick(() => this.$refs.applicationForm?.clearValidate());
     },
@@ -1131,6 +1179,26 @@ export default {
     handleOrganizationChange() {
       this.form.deptId = null;
     },
+    // 修改薪资上下限时自动计算中位值
+    syncSalaryMid() {
+      const min = Number(this.form.salaryMin || 0);
+      const max = Number(this.form.salaryMax || 0);
+      if (max >= min) {
+        this.form.salaryMid = Math.round((min + max) / 2);
+      }
+    },
+    // 薪资区间校验:必填 + 上下限关系;定义在 methods 保证 rules 里能取到有效引用
+    salaryRangeValidator(_rule, _value, callback) {
+      const min = Number(this.form.salaryMin || 0);
+      const max = Number(this.form.salaryMax || 0);
+      if (!min || !max) {
+        callback(new Error("请输入完整的岗位薪资区间"));
+      } else if (max < min) {
+        callback(new Error("薪资上限不能低于下限"));
+      } else {
+        callback();
+      }
+    },
     addSkill() {
       this.form.skills.push({ name: "", level: "中级", required: true });
     },

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 643 - 601
src/views/positionManagement/components/PositionEditDialog.vue


+ 203 - 117
src/views/positionSequence/index.vue

@@ -205,6 +205,12 @@
                 icon="el-icon-edit-outline"
                 @click.stop="openEdit(row)"
             /></el-tooltip>
+            <el-tooltip content="维护岗位层级" placement="top"
+              ><el-button
+                class="action-button is-level"
+                icon="el-icon-s-grid"
+                @click.stop="openLevelManage(row)"
+            /></el-tooltip>
           </div>
         </template>
         <template slot="empty"
@@ -251,10 +257,7 @@
             >
             <div>
               <h3>{{ form.name }}</h3>
-              <p>
-                {{ categoryLabel(form.category) }} ·
-                {{ form.levels.length }} 个岗位层级
-              </p>
+              <p>{{ form.levels.length }} 个岗位层级</p>
             </div>
           </section>
           <section class="detail-section">
@@ -374,12 +377,14 @@
                     :value="item.value" /></el-select
               ></el-form-item>
               <el-form-item label="状态" prop="status"
-                ><el-radio-group v-model="form.status" :disabled="readOnly"
-                  ><el-radio-button :label="1">启用</el-radio-button
-                  ><el-radio-button :label="0"
-                    >停用</el-radio-button
-                  ></el-radio-group
-                ></el-form-item
+                ><el-switch
+                  v-model="form.status"
+                  :active-value="1"
+                  :inactive-value="0"
+                  active-text="启用"
+                  inactive-text="停用"
+                  :disabled="readOnly"
+                /></el-form-item
               >
             </div>
             <el-form-item v-if="false" label="序列说明" prop="description"
@@ -393,45 +398,6 @@
                 placeholder="说明该序列适用岗位及发展方向"
             /></el-form-item>
           </section>
-          <section class="form-section">
-            <div class="section-title">
-              <span><i class="el-icon-s-data"></i></span>
-              <div>
-                <h3>层级与岗位</h3>
-                <p>配置该序列包含的职级和典型岗位示例</p>
-              </div>
-            </div>
-            <el-form-item label="岗位层级" prop="levels"
-              ><el-select
-                v-model="form.levels"
-                multiple
-                filterable
-                allow-create
-                default-first-option
-                :disabled="readOnly"
-                placeholder="输入层级后回车,如 T1"
-                ><el-option
-                  v-for="level in suggestedLevels"
-                  :key="level"
-                  :label="level"
-                  :value="level"
-              /></el-select>
-              <p class="field-help">
-                <i class="el-icon-info"></i>
-                建议按由低到高顺序维护,保存时会自动排序。
-              </p></el-form-item
-            >
-            <el-form-item v-if="false" label="典型岗位" prop="roleExamples"
-              ><el-select
-                v-model="form.roleExamples"
-                multiple
-                filterable
-                allow-create
-                default-first-option
-                :disabled="readOnly"
-                placeholder="输入岗位名称后回车"
-            /></el-form-item>
-          </section>
         </el-form>
       </div>
       <div class="drawer-footer">
@@ -453,15 +419,81 @@
         </div>
       </div>
     </el-drawer>
+
+    <el-drawer
+      :visible.sync="levelVisible"
+      direction="rtl"
+      :size="drawerSize"
+      custom-class="sequence-level-drawer"
+      append-to-body
+      :before-close="closeLevelDrawer"
+    >
+      <template slot="title">
+        <div class="drawer-title">
+          <span><i class="el-icon-s-grid"></i></span>
+          <div>
+            <h2>岗位序列 · 维护层级 · {{ levelSeqName }}</h2>
+            <p>可添加多个层级,已使用层级仅支持修改启停状态</p>
+          </div>
+        </div>
+      </template>
+      <div class="drawer-body">
+        <div class="form-guide">
+          <i class="el-icon-info"></i>
+          <span>
+            <strong>维护岗位层级</strong>
+            <small
+              >可添加多个层级,已使用层级仅支持修改启停状态,删除请改为停用。</small
+            >
+          </span>
+        </div>
+        <div class="level-editor">
+          <div
+            v-for="(item, index) in levelList"
+            :key="item.id || index"
+            class="level-row"
+          >
+            <el-input
+              v-model.trim="item.level"
+              maxlength="20"
+              placeholder="层级文本,如 一级 / M1"
+            />
+            <el-switch
+              v-model="item.status"
+              :active-value="1"
+              :inactive-value="0"
+              active-text="启用"
+              inactive-text="停用"
+            />
+            <el-button icon="el-icon-delete" @click="removeLevel(index)" />
+          </div>
+          <el-button class="add-line" icon="el-icon-plus" @click="addLevel"
+            >新增层级</el-button
+          >
+        </div>
+      </div>
+      <div class="drawer-footer">
+        <span
+          ><i class="el-icon-lock"></i>
+          岗位序列将用于岗位库、职级与晋升关联</span
+        >
+        <div>
+          <el-button @click="closeLevelDrawer()">取消</el-button>
+          <el-button type="primary" icon="el-icon-check" @click="saveLevels"
+            >保存</el-button
+          >
+        </div>
+      </div>
+    </el-drawer>
   </main>
 </template>
 
 <script>
 import {
-  adaptSequenceGroups,
   getPositionSequencePage,
   isPositionSequenceInUse,
   savePositionSequence,
+  savePositionSequenceLevels,
   updatePositionSequence,
 } from "@/api/hr";
 import { levelOptions } from "./mock";
@@ -494,6 +526,12 @@ export default {
       drawerVisible: false,
       mode: "view",
       form: emptyForm(),
+      // 维护层级弹窗
+      levelVisible: false,
+      levelSeqName: "",
+      levelSequenceId: null,
+      levelList: [],
+      levelOriginal: [],
       rules: {
         name: [
           { required: true, message: "请输入岗位序列名称", trigger: "blur" },
@@ -501,14 +539,6 @@ export default {
         code: [
           { required: false, message: "请输入岗位序列编码", trigger: "blur" },
         ],
-        levels: [
-          {
-            type: "array",
-            required: true,
-            message: "请至少配置一个岗位层级",
-            trigger: "change",
-          },
-        ],
         roleExamples: [
           {
             type: "array",
@@ -601,10 +631,6 @@ export default {
     drawerSize() {
       return window.innerWidth < 720 ? "96%" : "640px";
     },
-    suggestedLevels() {
-      const category = this.form.category || "P";
-      return Array.from({ length: 8 }, (_, index) => `${category}${index + 1}`);
-    },
   },
   created() {
     this.loadData();
@@ -619,7 +645,7 @@ export default {
           sortName: "createTime",
           orderBy: "descending",
         });
-        this.sequences = adaptSequenceGroups(page?.list || []);
+        this.sequences = (page?.list || []).map((item) => this.adaptSequence(item));
         if (showMessage) this.$message.success("岗位序列数据已刷新");
       } catch (error) {
         this.sequences = [];
@@ -628,6 +654,24 @@ export default {
         this.loading = false;
       }
     },
+    // 接口序列实体 {id, sequence, status, levelList:[{level,status}]} → 页面字段
+    adaptSequence(item = {}) {
+      const levelList = Array.isArray(item.levelList) ? item.levelList : [];
+      return {
+        id: item.id,
+        sequence: item.sequence || "",
+        name: item.sequence || "",
+        status: Number(item.status) === 1 ? 1 : 0,
+        category: "",
+        roleExamples: [],
+        updatedAt: "",
+        levelList: levelList.map((level) => ({
+          level: level.level || "",
+          status: Number(level.status) === 1 ? 1 : 0,
+        })),
+        levels: levelList.map((level) => level.level).filter(Boolean),
+      };
+    },
     refreshData() {
       this.loadData(true);
     },
@@ -678,13 +722,21 @@ export default {
       this.drawerVisible = false;
       if (typeof done === "function") done();
     },
+    closeLevelDrawer(done) {
+      this.levelVisible = false;
+      if (typeof done === "function") done();
+    },
+    // 新增/编辑序列:仅维护名称与状态;层级通过「维护层级」弹窗单独管理
+    // 新增/编辑序列:仅维护名称与状态;层级通过「维护层级」单独管理
     saveSequence() {
       this.$refs.sequenceForm.validate(async (valid) => {
         if (!valid) return;
+        const name = this.form.name.trim();
+        if (!name) return;
         const duplicate = this.sequences.some(
           (item) =>
-            item.name.trim().toLowerCase() ===
-              this.form.name.trim().toLowerCase() && item.id !== this.form.id,
+            item.name.trim().toLowerCase() === name.toLowerCase() &&
+            item.id !== this.form.id,
         );
         if (duplicate) {
           this.$message.warning("岗位序列名称已存在,请更换后重试");
@@ -692,49 +744,21 @@ export default {
         }
         this.loading = true;
         try {
-          const originalRecords = this.form.levelRecords || [];
-          const originalByLevel = new Map(
-            originalRecords.map((item) => [
-              String(item.level).toUpperCase(),
-              item,
-            ]),
-          );
-          if (this.mode === "edit") {
-            const removed = originalRecords.filter(
-              (item) =>
-                !this.form.levels.some(
-                  (level) =>
-                    String(level).toUpperCase() ===
-                    String(item.level).toUpperCase(),
-                ),
-            );
-            if (removed.length) {
-              throw new Error(
-                "接口暂不支持删除岗位层级,请将不再使用的层级改为停用",
-              );
-            }
-          }
-          for (const level of [...this.form.levels].sort()) {
-            const record = originalByLevel.get(String(level).toUpperCase());
-            const payload = {
-              sequence: this.form.name,
-              level,
+          if (this.mode === "create") {
+            await savePositionSequence({
+              sequence: name,
               status: String(this.form.status),
-            };
-            if (record) {
-              const inUse = await isPositionSequenceInUse(record.id);
-              const nameChanged = record.sequence !== payload.sequence;
-              const levelChanged =
-                String(record.level) !== String(payload.level);
-              if (inUse && (nameChanged || levelChanged)) {
-                throw new Error(
-                  `层级 ${record.level} 已被岗位使用,只允许修改启停状态`,
-                );
-              }
-              await updatePositionSequence({ ...payload, id: record.id });
-            } else {
-              await savePositionSequence(payload);
+            });
+          } else {
+            const inUse = await isPositionSequenceInUse(this.form.id);
+            if (inUse && this.form.sequence !== name) {
+              throw new Error("岗位序列已被岗位使用,不允许修改名称");
             }
+            await updatePositionSequence({
+              id: this.form.id,
+              sequence: name,
+              status: String(this.form.status),
+            });
           }
           this.$message.success(
             this.mode === "create" ? "岗位序列新增成功" : "岗位序列保存成功",
@@ -749,22 +773,84 @@ export default {
         }
       });
     },
+    // ===== 维护层级 =====
+    openLevelManage(row) {
+      this.levelSeqName = row.name;
+      this.levelSequenceId = row.id;
+      this.levelOriginal = (row.levelList || []).map((item) => ({
+        level: item.level,
+        status: item.status,
+      }));
+      this.levelList = (row.levelList || []).map((item) => ({
+        id: null,
+        level: item.level,
+        status: item.status,
+      }));
+      this.levelVisible = true;
+    },
+    addLevel() {
+      this.levelList.push({ id: null, level: "", status: 1 });
+    },
+    removeLevel(index) {
+      this.levelList.splice(index, 1);
+    },
+    // 保存层级:新增走批量接口 levels/save;已有层级不支持修改(接口暂无更新入口)
+    async saveLevels() {
+      this.loading = true;
+      try {
+        const originalByLevel = new Map(
+          (this.levelOriginal || []).map((item) => [item.level, item.status]),
+        );
+        const newLevels = this.levelList.filter((item) => {
+          const level = String(item.level || "").trim();
+          return level && !originalByLevel.has(level);
+        });
+        const removed = (this.levelOriginal || []).filter(
+          (item) => !this.levelList.some((row) => row.level.trim() === item.level),
+        );
+        const changed = this.levelList.filter((item) => {
+          const origin = originalByLevel.get(String(item.level || "").trim());
+          return origin !== undefined && origin !== item.status;
+        });
+        if (removed.length) {
+          throw new Error("接口暂不支持删除岗位层级,请保留原层级");
+        }
+        if (changed.length) {
+          throw new Error(
+            `接口暂不支持修改已有层级(如 ${changed[0].level}),仅支持新增层级`,
+          );
+        }
+        if (newLevels.length) {
+          await savePositionSequenceLevels({
+            sequenceId: this.levelSequenceId,
+            levelList: newLevels.map((item) => ({
+              level: String(item.level).trim(),
+              status: String(item.status),
+            })),
+          });
+        }
+        this.$message.success("岗位层级已保存");
+        this.levelVisible = false;
+        await this.loadData();
+        this.$nextTick(() => this.$refs.table?.reload({ page: 1 }));
+      } catch (error) {
+        this.$message.error(error.message || "岗位层级保存失败");
+      } finally {
+        this.loading = false;
+      }
+    },
     async toggleStatus(row) {
       const previous = row.status === 1 ? 0 : 1;
+      const nextStatus = row.status;
       this.loading = true;
       try {
-        await Promise.all(
-          (row.levelRecords || []).map((record) =>
-            updatePositionSequence({
-              id: record.id,
-              sequence: record.sequence,
-              level: record.level,
-              status: String(row.status),
-            }),
-          ),
-        );
+        await updatePositionSequence({
+          id: row.id,
+          sequence: row.sequence,
+          status: String(nextStatus),
+        });
         this.$message.success(
-          `${row.name}已${row.status === 1 ? "启用" : "停用"}`,
+          `${row.name}已${nextStatus === 1 ? "启用" : "停用"}`,
         );
         await this.loadData();
       } catch (error) {

+ 884 - 0
src/views/positionTransfer/index.vue

@@ -0,0 +1,884 @@
+<template>
+  <main class="transfer-page" v-loading="loading">
+    <section class="metric-grid" aria-label="岗位调动概览">
+      <article
+        v-for="item in metrics"
+        :key="item.label"
+        class="metric-card"
+        :class="'metric-' + item.tone"
+      >
+        <span class="metric-icon"><i :class="item.icon"></i></span>
+        <div>
+          <small>{{ item.label }}</small
+          ><strong>{{ item.value }}<em>{{ item.unit }}</em></strong>
+          <p>{{ item.note }}</p>
+        </div>
+      </article>
+    </section>
+
+    <section class="workspace-panel">
+      <div class="filter-bar">
+        <div class="filter-field">
+          <label>合同编号</label>
+          <el-input
+            v-model.trim="draftFilters.contractNo"
+            clearable
+            placeholder="输入合同编号"
+            @keyup.enter.native="applyFilters"
+          />
+        </div>
+        <div class="filter-field">
+          <label>员工姓名</label>
+          <el-input
+            v-model.trim="draftFilters.employeeName"
+            clearable
+            prefix-icon="el-icon-search"
+            placeholder="输入员工姓名"
+            @keyup.enter.native="applyFilters"
+          />
+        </div>
+        <div class="filter-field">
+          <label>单据状态</label>
+          <el-select v-model="draftFilters.docStatus" clearable placeholder="全部状态">
+            <el-option
+              v-for="item in docStatusOptions"
+              :key="item.value"
+              :label="item.label"
+              :value="item.value"
+            />
+          </el-select>
+        </div>
+        <div class="filter-field">
+          <label>岗位</label>
+          <el-select
+            v-model="draftFilters.position"
+            clearable
+            filterable
+            placeholder="全部岗位"
+          >
+            <el-option
+              v-for="item in positionOptions"
+              :key="item.id"
+              :label="item.name"
+              :value="item.name"
+            />
+          </el-select>
+        </div>
+        <div class="filter-actions">
+          <el-button type="primary" icon="el-icon-search" @click="applyFilters"
+            >查询</el-button
+          >
+          <el-button icon="el-icon-refresh-left" @click="resetFilters"
+            >重置</el-button
+          >
+        </div>
+      </div>
+
+      <div v-if="hasFilters" class="active-filters">
+        <span><i class="el-icon-filter"></i> 筛选条件</span>
+        <el-tag
+          v-if="filters.contractNo"
+          closable
+          size="small"
+          @close="clearFilter('contractNo')"
+          >合同编号:{{ filters.contractNo }}</el-tag
+        >
+        <el-tag
+          v-if="filters.employeeName"
+          closable
+          size="small"
+          type="success"
+          @close="clearFilter('employeeName')"
+          >员工:{{ filters.employeeName }}</el-tag
+        >
+        <el-tag
+          v-if="filters.docStatus"
+          closable
+          size="small"
+          :type="docStatusTagType(filters.docStatus)"
+          @close="clearFilter('docStatus')"
+          >单据:{{ docStatusName(filters.docStatus) }}</el-tag
+        >
+        <el-tag
+          v-if="filters.position"
+          closable
+          size="small"
+          type="warning"
+          @close="clearFilter('position')"
+          >岗位:{{ filters.position }}</el-tag
+        >
+        <button type="button" @click="resetFilters">清空全部</button>
+      </div>
+
+      <ele-pro-table
+        ref="table"
+        row-key="id"
+        :columns="columns"
+        :datasource="filteredRecords"
+        :need-page="true"
+        :page-size="8"
+        :cache-key="cacheKeyUrl"
+        title="岗位调动台账"
+        :sub-title="
+          '共 ' +
+          filteredRecords.length +
+          ' 条调动记录,可按合同编号、员工、单据状态与岗位快速定位'
+        "
+        :toolkit="['size', 'columns', 'fullscreen']"
+        stripe
+        class="transfer-table"
+      >
+        <template slot="toolbar">
+          <div class="table-toolbar-actions">
+            <el-button size="small" icon="el-icon-refresh" @click="refreshData"
+              >刷新</el-button
+            >
+            <el-button size="small" icon="el-icon-download" @click="exportLedger"
+              >导出台账</el-button
+            >
+            <el-button
+              size="small"
+              type="primary"
+              icon="el-icon-plus"
+              @click="openCreate"
+              >新增岗位变更</el-button
+            >
+          </div>
+        </template>
+        <template v-slot:contractNo="{ row }">
+          <button type="button" class="contract-cell" @click.stop="openView(row)">
+            <strong>{{ row.contractNo }}</strong>
+          </button>
+        </template>
+        <template v-slot:employee="{ row }">
+          <button type="button" class="employee-cell" @click.stop="openView(row)">
+            <span class="employee-avatar">{{ initials(row.employeeName) }}</span>
+            <span><strong>{{ row.employeeName }}</strong></span>
+          </button>
+        </template>
+        <template v-slot:duties="{ row }">
+          <span class="duties-cell">{{ row.duties }}</span>
+        </template>
+        <template v-slot:move="{ row }">
+          <div class="move-cell">
+            <strong>{{ row.targetPosition }}</strong>
+            <small>{{ row.targetDept }}</small>
+          </div>
+        </template>
+        <template v-slot:audit="{ row }">
+          <span class="status-pill" :class="auditClass(row.auditStatus)"
+            ><i></i>{{ row.auditStatus }}</span
+          >
+        </template>
+        <template v-slot:docStatus="{ row }">
+          <span class="status-pill" :class="docStatusClass(row.docStatus)"
+            ><i></i>{{ docStatusName(row.docStatus) }}</span
+          >
+        </template>
+        <template v-slot:action="{ row }">
+          <div class="action-cell">
+            <el-button type="text" icon="el-icon-view" @click.stop="openView(row)"
+              >查看</el-button
+            >
+            <el-button
+              v-if="row.docStatus === 'draft'"
+              type="text"
+              icon="el-icon-edit"
+              @click.stop="openEdit(row)"
+              >编辑</el-button
+            >
+            <el-button
+              v-if="row.docStatus === 'draft'"
+              type="text"
+              class="danger-text"
+              icon="el-icon-delete"
+              @click.stop="removeRecord(row)"
+              >删除</el-button
+            >
+            <el-button
+              type="text"
+              icon="el-icon-s-check"
+              @click.stop="openFlow(row)"
+              >审批流程</el-button
+            >
+          </div>
+        </template>
+        <template slot="empty"
+          ><div class="table-empty">
+            <span><i class="el-icon-switch-button"></i></span
+            ><strong>没有找到匹配的岗位调动记录</strong>
+            <p>请调整筛选条件后再试,或新增一条岗位变更。</p>
+          </div></template
+        >
+      </ele-pro-table>
+    </section>
+
+    <el-drawer
+      :visible.sync="formVisible"
+      direction="rtl"
+      :size="drawerSize"
+      custom-class="transfer-drawer"
+      append-to-body
+      :before-close="closeForm"
+      @closed="resetForm"
+    >
+      <template slot="title">
+        <div class="drawer-title">
+          <span><i :class="formReadOnly ? 'el-icon-view' : 'el-icon-switch-button'"></i></span>
+          <div>
+            <h2>{{ formTitle }}</h2>
+            <p>{{ form.contractNo || "保存后自动生成合同编码" }}</p>
+          </div>
+          <em v-if="form.id" :class="docStatusTone(form.docStatus)"
+            ><i></i>{{ docStatusName(form.docStatus) }}</em
+          >
+        </div>
+      </template>
+      <div class="drawer-body">
+        <template v-if="formReadOnly">
+          <section class="detail-overview">
+            <div>
+              <h3>{{ form.employeeName }} · 岗位调动</h3>
+              <p>
+                {{ form.originPosition }} → {{ form.targetPosition }} ·
+                {{ form.targetDept }}
+              </p>
+            </div>
+            <strong>{{ auditStatusName(form.auditStatus) }}</strong>
+          </section>
+          <section class="detail-section">
+            <div class="detail-heading">
+              <span><i class="el-icon-document"></i></span>
+              <div><h3>变更信息</h3><p>调动目标与签署方式</p></div>
+            </div>
+            <div class="detail-grid">
+              <div><span>调动岗位</span><strong>{{ form.targetPosition }}</strong></div>
+              <div><span>调动部门</span><strong>{{ form.targetDept }}</strong></div>
+              <div><span>岗位序列</span><strong>{{ form.sequence || "-" }}</strong></div>
+              <div><span>工作地点</span><strong>{{ form.workLocation || "-" }}</strong></div>
+              <div><span>岗位层级</span><strong>{{ form.targetLevel || "-" }}</strong></div>
+              <div><span>签署方式</span><strong>{{ signTypeName(form.signType) }}</strong></div>
+            </div>
+            <div class="detail-block">
+              <span>变更原因</span>
+              <p class="detail-text">{{ form.reason || "暂无" }}</p>
+            </div>
+            <div class="detail-block">
+              <span>附件</span>
+              <div class="detail-tags">
+                <em v-for="file in form.attachments" :key="file.name">{{ file.name }}</em>
+                <span v-if="!form.attachments.length" class="detail-muted">未上传附件</span>
+              </div>
+            </div>
+          </section>
+          <section class="detail-section">
+            <div class="detail-heading">
+              <span><i class="el-icon-user"></i></span>
+              <div><h3>原合同信息</h3><p>员工当前档案信息</p></div>
+            </div>
+            <div class="detail-grid">
+              <div><span>所属部门</span><strong>{{ form.dept }}</strong></div>
+              <div><span>原岗位</span><strong>{{ form.originPosition }}</strong></div>
+              <div><span>原合同签订年限</span><strong>{{ form.contractYears || "-" }}</strong></div>
+              <div><span>原合同期限</span><strong>{{ form.contractPeriod || "-" }}</strong></div>
+              <div><span>岗位层级</span><strong>{{ form.level || "-" }}</strong></div>
+              <div><span>手机号</span><strong>{{ form.phone || "-" }}</strong></div>
+            </div>
+          </section>
+        </template>
+
+        <el-form
+          v-else
+          ref="transferForm"
+          :model="form"
+          :rules="rules"
+          label-position="top"
+          class="transfer-form"
+        >
+          <div class="form-guide">
+            <i class="el-icon-info"></i>
+            <span>
+              <strong>{{ formMode === "create" ? "发起岗位变更" : "调整岗位变更单据" }}</strong>
+              <small>选择员工后自动带出原合同信息,填写调动目标与签署方式。</small>
+            </span>
+          </div>
+          <section class="form-section">
+            <div class="section-title">
+              <span><i class="el-icon-user"></i></span>
+              <div><h3>原合同信息</h3><p>选择员工后自动带出,只读</p></div>
+            </div>
+            <div class="form-grid">
+              <el-form-item label="合同编码">
+                <el-input v-model="form.contractNo" disabled placeholder="系统自动生成" />
+              </el-form-item>
+              <el-form-item label="姓名" prop="employeeId">
+                <el-select v-model="form.employeeId" filterable placeholder="请选择员工" @change="fillEmployee">
+                  <el-option
+                    v-for="item in employees"
+                    :key="item.id"
+                    :label="item.name"
+                    :value="item.id"
+                  />
+                </el-select>
+              </el-form-item>
+              <el-form-item label="所属部门">
+                <el-input v-model="form.dept" disabled />
+              </el-form-item>
+              <el-form-item label="岗位">
+                <el-input v-model="form.originPosition" disabled />
+              </el-form-item>
+              <el-form-item label="原合同签订年限">
+                <el-input v-model="form.contractYears" disabled />
+              </el-form-item>
+              <el-form-item label="原合同期限">
+                <el-input v-model="form.contractPeriod" disabled />
+              </el-form-item>
+              <el-form-item label="岗位层级">
+                <el-input v-model="form.level" disabled />
+              </el-form-item>
+              <el-form-item label="手机号">
+                <el-input v-model="form.phone" disabled />
+              </el-form-item>
+            </div>
+          </section>
+          <section class="form-section">
+            <div class="section-title">
+              <span><i class="el-icon-switch-button"></i></span>
+              <div><h3>变更信息</h3><p>填写调动目标岗位、部门与签署方式</p></div>
+            </div>
+            <div class="form-grid">
+              <el-form-item label="调动岗位" prop="targetPositionId">
+                <el-select v-model="form.targetPositionId" filterable placeholder="请选择调动岗位" @change="syncTargetPosition">
+                  <el-option
+                    v-for="item in positionOptions"
+                    :key="item.id"
+                    :label="item.name"
+                    :value="item.id"
+                  />
+                </el-select>
+              </el-form-item>
+              <el-form-item label="调动部门" prop="targetDeptId">
+                <el-select v-model="form.targetDeptId" filterable placeholder="请选择调动部门" @change="syncTargetDept">
+                  <el-option
+                    v-for="item in organizationOptions"
+                    :key="item.id"
+                    :label="item.name"
+                    :value="item.id"
+                  />
+                </el-select>
+              </el-form-item>
+              <el-form-item label="岗位序列">
+                <el-select v-model="form.sequence" clearable placeholder="请选择岗位序列">
+                  <el-option
+                    v-for="item in sequenceOptions"
+                    :key="item"
+                    :label="item"
+                    :value="item"
+                  />
+                </el-select>
+              </el-form-item>
+              <el-form-item label="岗位层级">
+                <el-select v-model="form.targetLevel" clearable placeholder="请选择岗位层级">
+                  <el-option
+                    v-for="item in levelOptions"
+                    :key="item"
+                    :label="item"
+                    :value="item"
+                  />
+                </el-select>
+              </el-form-item>
+              <el-form-item label="工作地址">
+                <el-input v-model.trim="form.workLocation" placeholder="调动后工作地点" />
+              </el-form-item>
+            </div>
+            <el-form-item label="附件">
+              <el-upload
+                :auto-upload="false"
+                multiple
+                :file-list="form.attachments"
+                :on-change="handleFileChange"
+                :on-remove="handleFileRemove"
+                class="transfer-upload"
+              >
+                <el-button size="small" icon="el-icon-upload2">上传证明材料</el-button>
+                <div slot="tip" class="el-upload__tip">支持多文件上传,如调岗协议、岗位变动证明</div>
+              </el-upload>
+            </el-form-item>
+            <el-form-item label="签署方式" prop="signType">
+              <el-checkbox
+                :value="form.signType === 'online'"
+                @change="(v) => (form.signType = v ? 'online' : '')"
+                >线上签署</el-checkbox
+              >
+              <el-checkbox
+                :value="form.signType === 'offline'"
+                @change="(v) => (form.signType = v ? 'offline' : '')"
+                >线下签署</el-checkbox
+              >
+            </el-form-item>
+            <el-form-item label="变更原因" prop="reason">
+              <el-input
+                v-model.trim="form.reason"
+                type="textarea"
+                :rows="4"
+                maxlength="300"
+                show-word-limit
+                placeholder="填写岗位调动原因:晋升、轮岗、组织调整等"
+              />
+            </el-form-item>
+          </section>
+        </el-form>
+      </div>
+      <div class="drawer-footer">
+        <span><i class="el-icon-lock"></i> 合同编码由系统按编码规则自动生成</span>
+        <div v-if="formReadOnly">
+          <el-button @click="formVisible = false">关闭</el-button>
+          <el-button
+            v-if="form.docStatus === 'draft'"
+            type="primary"
+            icon="el-icon-edit"
+            @click="formMode = 'edit'"
+            >编辑</el-button
+          >
+        </div>
+        <div v-else>
+          <el-button @click="closeForm()">取消</el-button>
+          <el-button @click="saveForm(false)">保存草稿</el-button>
+          <el-button type="primary" icon="el-icon-position" @click="saveForm(true)"
+            >提交审批</el-button
+          >
+        </div>
+      </div>
+    </el-drawer>
+
+    <el-drawer
+      :visible.sync="flowVisible"
+      direction="rtl"
+      :size="drawerSize"
+      custom-class="flow-drawer"
+      append-to-body
+    >
+      <template slot="title">
+        <div class="drawer-title">
+          <span><i class="el-icon-s-check"></i></span>
+          <div>
+            <h2>审批流程</h2>
+            <p>{{ flowRecord.contractNo }} · {{ flowRecord.employeeName }}</p>
+          </div>
+        </div>
+      </template>
+      <div class="drawer-body">
+        <section class="detail-section">
+          <div class="detail-heading">
+            <span><i class="el-icon-s-opportunity"></i></span>
+            <div><h3>岗位调动审批进度</h3><p>部门负责人 → HR 专员 → HR 经理</p></div>
+          </div>
+          <el-steps
+            :active="flowActive"
+            finish-status="success"
+            process-status="process"
+            align-center
+          >
+            <el-step title="提交申请" />
+            <el-step title="部门负责人" />
+            <el-step title="HR 专员" />
+            <el-step title="HR 经理" />
+            <el-step title="审批通过" />
+          </el-steps>
+        </section>
+        <section class="detail-section">
+          <div class="detail-heading">
+            <span><i class="el-icon-document"></i></span>
+            <div><h3>单据信息</h3><p>岗位调动内容摘要</p></div>
+          </div>
+          <div class="detail-grid">
+            <div><span>员工姓名</span><strong>{{ flowRecord.employeeName }}</strong></div>
+            <div><span>原岗位</span><strong>{{ flowRecord.originPosition }}</strong></div>
+            <div><span>调动岗位</span><strong>{{ flowRecord.targetPosition }}</strong></div>
+            <div><span>调动部门</span><strong>{{ flowRecord.targetDept }}</strong></div>
+            <div><span>变更原因</span><strong>{{ flowRecord.reason }}</strong></div>
+            <div><span>审核状态</span><strong>{{ flowRecord.auditStatus }}</strong></div>
+          </div>
+        </section>
+      </div>
+      <div class="drawer-footer">
+        <span><i class="el-icon-time"></i> 最近更新 {{ flowRecord.updatedAt }}</span>
+        <div><el-button type="primary" @click="flowVisible = false">关闭</el-button></div>
+      </div>
+    </el-drawer>
+  </main>
+</template>
+
+<script>
+import {
+  employees,
+  positions,
+  organizations,
+  sequenceOptions,
+  levelOptions,
+  transferRecords,
+} from "./mock";
+
+const emptyFilters = () => ({
+  contractNo: "",
+  employeeName: "",
+  docStatus: "",
+  position: "",
+});
+
+const emptyForm = () => ({
+  id: null,
+  contractNo: "",
+  employeeId: null,
+  employeeName: "",
+  dept: "",
+  originPosition: "",
+  contractYears: "",
+  contractPeriod: "",
+  level: "",
+  phone: "",
+  targetPositionId: null,
+  targetPosition: "",
+  targetDeptId: null,
+  targetDept: "",
+  sequence: "",
+  targetLevel: "",
+  workLocation: "",
+  attachments: [],
+  signType: "",
+  reason: "",
+  auditStatus: "待审核",
+  docStatus: "draft",
+  updatedAt: "",
+});
+
+const DOC_STATUS_LABELS = { draft: "草稿", pending: "待审核", approved: "已审核" };
+
+export default {
+  name: "PositionTransfer",
+  data() {
+    return {
+      loading: false,
+      records: JSON.parse(JSON.stringify(transferRecords)),
+      employees,
+      positionOptions: positions,
+      organizationOptions: organizations,
+      sequenceOptions,
+      levelOptions,
+      docStatusOptions: [
+        { value: "draft", label: "草稿" },
+        { value: "pending", label: "待审核" },
+        { value: "approved", label: "已审核" },
+      ],
+      draftFilters: emptyFilters(),
+      filters: emptyFilters(),
+      cacheKeyUrl: "oa-pc-position-transfer-v1",
+      formVisible: false,
+      formMode: "view",
+      form: emptyForm(),
+      flowVisible: false,
+      flowRecord: {},
+      rules: {
+        employeeId: [
+          { required: true, message: "请选择员工", trigger: "change" },
+        ],
+        targetPositionId: [
+          { required: true, message: "请选择调动岗位", trigger: "change" },
+        ],
+        targetDeptId: [
+          { required: true, message: "请选择调动部门", trigger: "change" },
+        ],
+        signType: [
+          {
+            validator: (_rule, _value, callback) => {
+              if (!this.form.signType) {
+                callback(new Error("请选择签署方式(线上或线下)"));
+              } else {
+                callback();
+              }
+            },
+            trigger: "change",
+          },
+        ],
+        reason: [
+          { required: true, message: "请填写变更原因", trigger: "blur" },
+        ],
+      },
+    };
+  },
+  computed: {
+    columns() {
+      return [
+        { type: "index", label: "序号", width: 58, align: "center" },
+        { columnKey: "contractNo", label: "合同编号", width: 140, slot: "contractNo" },
+        { columnKey: "employee", label: "员工姓名", minWidth: 130, slot: "employee" },
+        { prop: "dept", label: "所属部门", minWidth: 150 },
+        { prop: "originPosition", label: "原岗位", minWidth: 130 },
+        { columnKey: "duties", label: "岗位职责", minWidth: 180, slot: "duties" },
+        { columnKey: "move", label: "调动岗位 / 部门", minWidth: 170, slot: "move" },
+        { prop: "reason", label: "调动原因", minWidth: 100 },
+        { prop: "workLocation", label: "调动工作地点", minWidth: 120 },
+        { columnKey: "audit", label: "审核状态", width: 100, slot: "audit" },
+        { columnKey: "docStatus", label: "单据状态", width: 100, slot: "docStatus" },
+        { columnKey: "action", label: "操作", width: 210, fixed: "right", align: "center", resizable: false, slot: "action" },
+      ];
+    },
+    metrics() {
+      const total = this.records.length;
+      const pending = this.records.filter((item) => item.docStatus === "pending").length;
+      const draft = this.records.filter((item) => item.docStatus === "draft").length;
+      const approved = this.records.filter((item) => item.docStatus === "approved").length;
+      return [
+        { label: "变更总数", value: total, unit: "单", note: "岗位调动台账合计", icon: "el-icon-switch-button", tone: "blue" },
+        { label: "待审核", value: pending, unit: "单", note: "需部门负责人 / HR 处理", icon: "el-icon-time", tone: "orange" },
+        { label: "草稿", value: draft, unit: "单", note: "未提交可编辑删除", icon: "el-icon-edit-outline", tone: "cyan" },
+        { label: "已通过", value: approved, unit: "单", note: "已生效岗位调动", icon: "el-icon-circle-check", tone: "green" },
+      ];
+    },
+    filteredRecords() {
+      const contractNo = this.filters.contractNo.trim().toLowerCase();
+      const employeeName = this.filters.employeeName.trim().toLowerCase();
+      const position = this.filters.position;
+      return this.records.filter((item) => {
+        const text = [item.originPosition, item.targetPosition].join(" ");
+        return (
+          (!contractNo || String(item.contractNo || "").toLowerCase().includes(contractNo)) &&
+          (!employeeName || item.employeeName.toLowerCase().includes(employeeName)) &&
+          (!this.filters.docStatus || item.docStatus === this.filters.docStatus) &&
+          (!position || text.includes(position))
+        );
+      });
+    },
+    hasFilters() {
+      return Boolean(
+        this.filters.contractNo ||
+          this.filters.employeeName ||
+          this.filters.docStatus ||
+          this.filters.position,
+      );
+    },
+    formReadOnly() {
+      return this.formMode === "view";
+    },
+    formTitle() {
+      return this.formMode === "create"
+        ? "新增岗位变更"
+        : this.formMode === "edit"
+        ? "编辑岗位变更"
+        : "岗位变更详情";
+    },
+    drawerSize() {
+      return window.innerWidth < 760 ? "96%" : "620px";
+    },
+    flowActive() {
+      return (
+        {
+          draft: 0,
+          pending: 2,
+          approved: 4,
+        }[this.flowRecord.docStatus] || 0
+      );
+    },
+  },
+  methods: {
+    // ===== 筛选 =====
+    applyFilters() {
+      this.filters = { ...this.draftFilters };
+      this.reloadTable();
+    },
+    resetFilters() {
+      this.draftFilters = emptyFilters();
+      this.filters = emptyFilters();
+      this.reloadTable();
+    },
+    clearFilter(key) {
+      this.draftFilters[key] = "";
+      this.filters[key] = "";
+      this.reloadTable();
+    },
+    reloadTable() {
+      this.$nextTick(() => this.$refs.table?.reload({ page: 1 }));
+    },
+    refreshData() {
+      this.$message.success("岗位调动数据已刷新(mock)");
+    },
+    exportLedger() {
+      this.$message.success("岗位调动台账已导出(mock)");
+    },
+    // ===== 表单 =====
+    openCreate() {
+      this.formMode = "create";
+      this.form = {
+        ...emptyForm(),
+        contractNo: this.generateContractNo(),
+      };
+      this.formVisible = true;
+      this.$nextTick(() => this.$refs.transferForm?.clearValidate());
+    },
+    openEdit(row) {
+      if (row.docStatus !== "draft") return;
+      this.formMode = "edit";
+      this.form = this.rowToForm(row);
+      this.formVisible = true;
+      this.$nextTick(() => this.$refs.transferForm?.clearValidate());
+    },
+    openView(row) {
+      this.formMode = "view";
+      this.form = this.rowToForm(row);
+      this.formVisible = true;
+    },
+    rowToForm(row) {
+      const employee = this.employees.find((item) => item.name === row.employeeName);
+      const target = this.positionOptions.find((item) => item.name === row.targetPosition);
+      const dept = this.organizationOptions.find((item) => item.name === row.targetDept);
+      return {
+        ...emptyForm(),
+        ...row,
+        employeeId: employee?.id || null,
+        dept: row.dept,
+        originPosition: row.originPosition,
+        contractYears: employee?.contractYears || "",
+        contractPeriod: employee?.contractPeriod || "",
+        level: employee?.level || "",
+        phone: employee?.phone || "",
+        targetPositionId: target?.id || null,
+        targetDeptId: dept?.id || null,
+        attachments: row.attachments || [],
+        signType: row.signType || "",
+        auditStatus: row.auditStatus || "待审核",
+        docStatus: row.docStatus || "draft",
+      };
+    },
+    // 选择员工后自动带出原合同信息
+    fillEmployee() {
+      const employee = this.employees.find(
+        (item) => item.id === this.form.employeeId,
+      );
+      if (!employee) return;
+      this.form.employeeName = employee.name;
+      this.form.dept = employee.dept;
+      this.form.originPosition = employee.position;
+      this.form.contractYears = employee.contractYears;
+      this.form.contractPeriod = employee.contractPeriod;
+      this.form.level = employee.level;
+      this.form.phone = employee.phone;
+    },
+    syncTargetPosition() {
+      const item = this.positionOptions.find(
+        (p) => p.id === this.form.targetPositionId,
+      );
+      if (item) {
+        this.form.targetPosition = item.name;
+        this.form.sequence = item.sequence || this.form.sequence;
+        this.form.targetLevel = item.level || this.form.targetLevel;
+      }
+    },
+    syncTargetDept() {
+      const item = this.organizationOptions.find(
+        (p) => p.id === this.form.targetDeptId,
+      );
+      if (item) this.form.targetDept = item.name;
+    },
+    handleFileChange(file) {
+      this.form.attachments = this.form.attachments || [];
+      if (!this.form.attachments.some((f) => f.name === file.name)) {
+        this.form.attachments.push({ name: file.name, url: file.url, status: "ready" });
+      }
+    },
+    handleFileRemove(file) {
+      this.form.attachments = (this.form.attachments || []).filter(
+        (f) => f.name !== file.name,
+      );
+    },
+    generateContractNo() {
+      const now = new Date();
+      const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(
+        now.getDate(),
+      ).padStart(2, "0")}`;
+      const serial = String(this.records.length + 1).padStart(3, "0");
+      return `HT${ymd}${serial}`;
+    },
+    closeForm(done) {
+      this.formVisible = false;
+      if (typeof done === "function") done();
+    },
+    resetForm() {
+      this.form = emptyForm();
+      this.$refs.transferForm?.clearValidate();
+    },
+    saveForm(submit) {
+      const persist = () => {
+        const record = {
+          ...this.form,
+          employeeName: this.form.employeeName,
+          docStatus: submit ? "pending" : "draft",
+          auditStatus: submit ? "待审核" : this.form.auditStatus || "待审核",
+          updatedAt: new Date().toLocaleString("zh-CN", { hour12: false }),
+        };
+        if (record.id) {
+          const index = this.records.findIndex((item) => item.id === record.id);
+          if (index >= 0) this.$set(this.records, index, record);
+        } else {
+          record.id = Date.now();
+          this.records.unshift(record);
+        }
+        this.formVisible = false;
+        this.$message.success(submit ? "岗位变更已提交审批" : "岗位变更草稿已保存");
+        this.reloadTable();
+      };
+      if (submit) this.$refs.transferForm.validate((valid) => valid && persist());
+      else persist();
+    },
+    removeRecord(row) {
+      this.$confirm(`确定删除合同编号【${row.contractNo}】的岗位变更吗?`, "删除确认", {
+        type: "warning",
+      })
+        .then(() => {
+          this.records = this.records.filter((item) => item.id !== row.id);
+          this.$message.success("岗位变更已删除");
+          this.reloadTable();
+        })
+        .catch(() => {});
+    },
+    openFlow(row) {
+      this.flowRecord = row;
+      this.flowVisible = true;
+    },
+    // ===== 展示辅助 =====
+    initials(name = "") {
+      return name.length > 2 ? name.slice(-2) : name;
+    },
+    docStatusName(value) {
+      return DOC_STATUS_LABELS[value] || value || "-";
+    },
+    docStatusClass(value) {
+      return { draft: "is-inactive", pending: "is-warning", approved: "is-active" }[value];
+    },
+    docStatusTagType(value) {
+      return { draft: "info", pending: "warning", approved: "success" }[value];
+    },
+    docStatusTone(value) {
+      return { draft: "muted", pending: "warning", approved: "success" }[value];
+    },
+    auditClass(status) {
+      return status === "已通过"
+        ? "is-active"
+        : status === "已驳回"
+        ? "is-danger"
+        : "is-warning";
+    },
+    auditStatusName(status) {
+      return status || "-";
+    },
+    signTypeName(value) {
+      return value === "online" ? "线上签署" : value === "offline" ? "线下签署" : "-";
+    },
+  },
+};
+</script>
+
+<style
+  lang="scss"
+  scoped
+  src="../../styles/views/positionTransfer/index.scss"
+></style>

+ 132 - 0
src/views/positionTransfer/mock.js

@@ -0,0 +1,132 @@
+// 岗位调动 - 离线 mock 数据(后续接入接口后替换为 API 数据源)
+
+// 员工档案:姓名选择后自动带出原合同信息
+export const employees = [
+  { id: 1, name: "刘启航", dept: "常德数控加工车间", position: "数控车床操作工", contractYears: "3 年", contractPeriod: "2023-01-01 至 2026-01-01", level: "中级", phone: "138****6688" },
+  { id: 2, name: "周立新", dept: "常德数控加工车间", position: "加工中心操作工", contractYears: "5 年", contractPeriod: "2021-09-01 至 2026-09-01", level: "中级", phone: "139****1256" },
+  { id: 3, name: "陈宇", dept: "常德工厂仓储", position: "仓库管理员", contractYears: "2 年", contractPeriod: "2024-03-01 至 2026-03-01", level: "初级", phone: "137****9034" },
+  { id: 4, name: "孙浩", dept: "长沙生产一部", position: "生产班组长", contractYears: "5 年", contractPeriod: "2018-11-02 至 2023-11-02", level: "主管", phone: "135****4471" },
+  { id: 5, name: "赵晨", dept: "长沙设备工程部", position: "设备工程师", contractYears: "3 年", contractPeriod: "2021-01-18 至 2024-01-18", level: "高级", phone: "136****7820" },
+  { id: 6, name: "何佳", dept: "长沙质量管理部", position: "质量工程师", contractYears: "4 年", contractPeriod: "2022-06-01 至 2026-06-01", level: "高级", phone: "133****2156" },
+];
+
+// 岗位档案(调动岗位下拉)
+export const positions = [
+  { id: 101, name: "数控技师", sequence: "操作序列", level: "四级" },
+  { id: 102, name: "生产班组长", sequence: "管理序列", level: "一级" },
+  { id: 103, name: "设备工程师", sequence: "技术序列", level: "二级" },
+  { id: 104, name: "质量体系专员", sequence: "专业序列", level: "二级" },
+  { id: 105, name: "加工中心操作工", sequence: "操作序列", level: "一级" },
+  { id: 106, name: "仓库管理员", sequence: "专业序列", level: "一级" },
+  { id: 107, name: "工艺工程师", sequence: "技术序列", level: "三级" },
+  { id: 108, name: "质量工程师", sequence: "技术序列", level: "二级" },
+];
+
+// 组织(调动部门下拉)
+export const organizations = [
+  { id: 201, name: "常德数控加工车间", parentId: 1 },
+  { id: 202, name: "常德工厂仓储", parentId: 1 },
+  { id: 203, name: "长沙生产一部", parentId: 2 },
+  { id: 204, name: "长沙设备工程部", parentId: 2 },
+  { id: 205, name: "长沙质量管理部", parentId: 2 },
+];
+
+// 岗位序列选项
+export const sequenceOptions = ["管理序列", "专业序列", "技术序列", "操作序列"];
+
+// 岗位层级选项
+export const levelOptions = ["初级", "中级", "高级", "主管", "经理", "技师", "一级", "二级", "三级", "四级"];
+
+// 岗位变更记录
+export const transferRecords = [
+  {
+    id: 1,
+    contractNo: "HT20260801001",
+    employeeName: "刘启航",
+    dept: "常德数控加工车间",
+    originPosition: "数控车床操作工",
+    duties: "数控车床调机、加工与首件自检",
+    targetPosition: "数控技师",
+    targetDept: "常德数控加工车间",
+    reason: "晋升",
+    workLocation: "常德工厂",
+    auditStatus: "已通过",
+    docStatus: "approved",
+    updatedAt: "2026-08-05 14:20",
+  },
+  {
+    id: 2,
+    contractNo: "HT20260802002",
+    employeeName: "赵晨",
+    dept: "长沙设备工程部",
+    originPosition: "设备工程师",
+    duties: "关键设备点检与预防性维护",
+    targetPosition: "工艺工程师",
+    targetDept: "长沙生产一部",
+    reason: "轮岗",
+    workLocation: "长沙工厂",
+    auditStatus: "待审核",
+    docStatus: "pending",
+    updatedAt: "2026-08-06 09:40",
+  },
+  {
+    id: 3,
+    contractNo: "HT20260803003",
+    employeeName: "孙浩",
+    dept: "长沙生产一部",
+    originPosition: "生产班组长",
+    duties: "班组计划达成、排班与异常协调",
+    targetPosition: "生产主管",
+    targetDept: "长沙生产一部",
+    reason: "晋升",
+    workLocation: "长沙工厂",
+    auditStatus: "已驳回",
+    docStatus: "draft",
+    updatedAt: "2026-08-03 16:05",
+  },
+  {
+    id: 4,
+    contractNo: "HT20260804004",
+    employeeName: "陈宇",
+    dept: "常德工厂仓储",
+    originPosition: "仓库管理员",
+    duties: "物料出入库、盘点与台账维护",
+    targetPosition: "质量体系专员",
+    targetDept: "长沙质量管理部",
+    reason: "组织调整",
+    workLocation: "长沙工厂",
+    auditStatus: "待审核",
+    docStatus: "pending",
+    updatedAt: "2026-08-07 11:12",
+  },
+  {
+    id: 5,
+    contractNo: "HT20260805005",
+    employeeName: "何佳",
+    dept: "长沙质量管理部",
+    originPosition: "质量工程师",
+    duties: "过程质量管控与客诉分析改善",
+    targetPosition: "质量工程师",
+    targetDept: "长沙质量管理部",
+    reason: "平级调动",
+    workLocation: "长沙工厂",
+    auditStatus: "已通过",
+    docStatus: "approved",
+    updatedAt: "2026-08-08 15:33",
+  },
+  {
+    id: 6,
+    contractNo: "HT20260806006",
+    employeeName: "周立新",
+    dept: "常德数控加工车间",
+    originPosition: "加工中心操作工",
+    duties: "加工中心上下料与程序调用",
+    targetPosition: "数控技师",
+    targetDept: "常德数控加工车间",
+    reason: "晋升",
+    workLocation: "常德工厂",
+    auditStatus: "待审核",
+    docStatus: "pending",
+    updatedAt: "2026-08-09 10:22",
+  },
+];

+ 2 - 2
vue.config.js

@@ -38,7 +38,7 @@ module.exports = {
         // target: "http://192.168.1.125:18086",
         // target: 'http://192.168.1.251:51005',
         // target: 'http://192.168.1.251:18186',
-        target: 'http://192.168.1.251:18086',
+        // target: 'http://192.168.1.251:18086', // 服务器
         // target: 'http://192.168.1.251:18186',
         // target: 'http://192.168.1.3:18086',
         // target: 'http://192.168.1.251:18186', // 测试环境
@@ -50,7 +50,7 @@ module.exports = {
         // target: 'http://aiot.zoomwin.com.cn:51001/api',
         // target: 'http://f222326r53.imwork.net',
         // target: 'http://aiot.zoomwin.com.cn:51005/api',
-
+        target: 'http://192.168.1.147:18086',
         changeOrigin: true, // 只有这个值为true的情况下 才表示开启跨域
         pathRewrite: {
           "^/api": "",

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov