Переглянути джерело

feat(transfer): 人事异动 / 调岗模块接入 M02 接口,对齐离职模块封装

- 新增 src/api/userChange.js:异动单列表 / 详情 / 提交 / 撤回 + 状态映射
- 新增 src/pages/manage/transfer-{list,create,detail}.vue:调岗三页
- src/pages/manage/index.vue:type==='transfer' 分支接入 userChange API
- src/pages.json:注册 transfer 三条路由
- src/data/mock.js:新增 userChangeStatus 枚举 + 流程节点 + 5 条 mock,workModules 加「调岗申请」入口
- src/utils/storage.js:新增 USER_CHANGE_KEY / USER_CHANGE_CREATED_KEY / USER_CHANGE_ID_KEY 三个 key 及对应 helper
xieyong 4 днів тому
батько
коміт
8de06c66eb

+ 716 - 0
src/api/userChange.js

@@ -0,0 +1,716 @@
+import { getApiBaseUrl, getServerConfig, request } from '@/utils/auth'
+import { userChangeStatus, userChangeStages, userChanges } from '@/data/mock'
+import {
+  addCreatedUserChange,
+  formatNow,
+  getCreatedUserChanges,
+  getCurrentUser,
+  getLoginUser,
+  getUserChangeIds,
+  getUserChangeStates,
+  rememberUserChangeId,
+  setUserChangeLocalState,
+  updateCreatedUserChange,
+} from '@/utils/storage'
+
+// ============ 模式判断 ============
+function isServerMode() {
+  return getServerConfig().mode === 'server'
+}
+
+function serverPath() {
+  const base = getApiBaseUrl()
+  if (!base) throw new Error('缺少服务器地址,请先在登录页右上角配置')
+  return base
+}
+
+function delay(ms = 200) {
+  return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+// ============ 工具 ============
+function compact(object = {}) {
+  return Object.keys(object).reduce((result, key) => {
+    const value = object[key]
+    if (value !== '' && value !== null && value !== undefined) result[key] = value
+    return result
+  }, {})
+}
+
+function dateOnly(value) {
+  if (!value) return ''
+  return String(value).slice(0, 10)
+}
+
+function labelOf(map, raw, fallback = '') {
+  const text = String(raw || '').trim()
+  if (!text) return fallback
+  return map[text] || map[text.toUpperCase()] || text
+}
+
+function asList(data) {
+  if (Array.isArray(data)) return data
+  if (Array.isArray(data?.list)) return data.list
+  if (Array.isArray(data?.records)) return data.records
+  if (data && typeof data === 'object' && data.id != null) return [data]
+  return []
+}
+
+function keepBigIntId(value) {
+  if (value === undefined || value === null || value === '') return undefined
+  const text = String(value).trim()
+  if (!/^-?\d+$/.test(text)) return value
+  if (!Number.isSafeInteger(Number(text))) return text
+  return Number(text)
+}
+
+function extractId(result) {
+  if (result == null) return null
+  if (typeof result === 'object') return result.id ?? result.changeId ?? null
+  return result
+}
+
+function parseSnapshot(raw) {
+  if (!raw) return {}
+  if (typeof raw === 'object') return raw
+  try {
+    return JSON.parse(raw) || {}
+  } catch (error) {
+    return {}
+  }
+}
+
+function detailMap(details = []) {
+  const map = {}
+  const list = Array.isArray(details) ? details : []
+  list.forEach((item) => {
+    const field = String(item?.changeField || '').toUpperCase()
+    if (field) map[field] = item
+  })
+  return map
+}
+
+// ============ 枚举(与 PC 端 src/api/hr/userChange.js 对齐)============
+
+/** 异动类型 */
+export const CHANGE_TYPE_LABELS = {
+  POSITION_TRANSFER: '调岗',
+  DEPARTMENT_TRANSFER: '调部门',
+  GRADE_CHANGE: '职级调整',
+  PROMOTION: '晋升',
+  DEMOTION: '降级',
+  SALARY_CHANGE: '调薪',
+  MANAGER_CHANGE: '调整上级',
+  WORK_LOCATION_CHANGE: '工作地点变更',
+  FACTORY_TRANSFER: '调工厂',
+  LEGAL_ENTITY_TRANSFER: '跨法人调动',
+  EMPLOYMENT_TYPE_CHANGE: '用工类型变更',
+  COMPOSITE_CHANGE: '综合异动',
+}
+
+/** 员工端可自助提交的异动类型,其余由 HR 在 PC 端办理 */
+export const ESS_CHANGE_TYPE_KEYS = [
+  'POSITION_TRANSFER',
+  'DEPARTMENT_TRANSFER',
+  'PROMOTION',
+  'GRADE_CHANGE',
+  'COMPOSITE_CHANGE',
+]
+
+export const ESS_CHANGE_TYPE_OPTIONS = ESS_CHANGE_TYPE_KEYS.map((value) => ({
+  value,
+  label: CHANGE_TYPE_LABELS[value],
+}))
+
+export function changeTypeLabel(value) {
+  return labelOf(CHANGE_TYPE_LABELS, value, value || '调岗')
+}
+
+export const APPROVAL_STATUS_LABELS = {
+  DRAFT: '草稿',
+  PENDING: '审批中',
+  APPROVING: '审批中',
+  APPROVED: '已通过',
+  REJECTED: '已驳回',
+  WITHDRAWN: '已撤回',
+  CANCELLED: '已取消',
+}
+
+export const EFFECTIVE_STATUS_LABELS = {
+  PENDING: '待生效',
+  SCHEDULED: '已排期',
+  EFFECTIVE: '已生效',
+  CANCELLED: '已取消',
+}
+
+/** 页面展示态 → 标签 / 样式(枚举定义在 data/mock.js) */
+export const TRANSFER_DISPLAY_STATUS = userChangeStatus
+
+export const TRANSFER_STATUS_FILTERS = [
+  { value: 'all', label: '全部状态' },
+  { value: 'draft', label: '草稿' },
+  { value: 'pending', label: '审批中' },
+  { value: 'approved', label: '已通过' },
+  { value: 'rejected', label: '已驳回' },
+]
+
+// ============ 状态机 ============
+
+export function deriveDocStatus(raw = {}) {
+  const approval = String(raw.approvalStatus || '').toUpperCase()
+  const effective = String(raw.effectiveStatus || '').toUpperCase()
+  if (approval === 'CANCELLED' || effective === 'CANCELLED') return 'cancelled'
+  if (approval === 'APPROVED' || effective === 'EFFECTIVE') return 'approved'
+  if (approval === 'REJECTED') return 'rejected'
+  if (approval === 'PENDING' || approval === 'APPROVING') return 'pending'
+  if (approval === 'WITHDRAWN') return 'withdrawn'
+  return 'draft'
+}
+
+export function canEditUserChange(row = {}) {
+  const status = String(row.approvalStatus || '').toUpperCase()
+  return status === 'DRAFT' || status === 'REJECTED' || status === ''
+}
+
+export function canSubmitUserChange(row = {}) {
+  if (!row?.id) return false
+  return ['draft', 'rejected'].includes(row.displayStatus)
+}
+
+export function canWithdrawUserChange(row = {}) {
+  const status = String(row.approvalStatus || '').toUpperCase()
+  return status === 'PENDING' || status === 'APPROVING'
+}
+
+export function canCancelUserChange(row = {}) {
+  if (!row?.id) return false
+  if (String(row.approvalStatus || '').toUpperCase() === 'CANCELLED') return false
+  return String(row.effectiveStatus || '').toUpperCase() !== 'EFFECTIVE'
+}
+
+export function matchDisplayStatus(row = {}, filter = '') {
+  if (!filter || filter === 'all') return true
+  if (filter === 'rejected') return ['rejected', 'withdrawn', 'cancelled'].includes(row.displayStatus)
+  return row.displayStatus === filter
+}
+
+function stageKeyOf(displayStatus) {
+  if (displayStatus === 'approved') return 'effective'
+  if (displayStatus === 'pending') return 'approval'
+  return 'submit'
+}
+
+function progressOf(displayStatus, stageKey) {
+  if (displayStatus === 'approved') return 100
+  if (['rejected', 'withdrawn', 'cancelled'].includes(displayStatus)) return 30
+  const index = Math.max(
+    userChangeStages.findIndex((stage) => stage.key === stageKey),
+    0,
+  )
+  return Math.round(((index + 1) / userChangeStages.length) * 100)
+}
+
+function progressTipOf(displayStatus) {
+  switch (displayStatus) {
+    case 'draft':
+      return '草稿尚未提交,提交后进入审批流程。'
+    case 'pending':
+      return '调岗审批中,请等待主管与 HR 的审批结果。'
+    case 'approved':
+      return '调岗审批已通过,将按生效日期更新岗位信息。'
+    case 'rejected':
+      return '审批已驳回,可修改后重新提交。'
+    case 'withdrawn':
+      return '本次调岗申请已撤回。'
+    case 'cancelled':
+      return '本次调岗申请已取消。'
+    default:
+      return ''
+  }
+}
+
+// ============ VO / 演示记录 → 页面形状 ============
+export function adaptUserChange(raw = {}, extras = {}) {
+  const before = parseSnapshot(raw.beforeSnapshot)
+  const after = parseSnapshot(raw.afterSnapshot)
+  const details = Array.isArray(raw.details) ? raw.details : []
+  const map = detailMap(details)
+  const deptDetail = map.DEPT
+  const positionDetail = map.POSITION
+  const gradeDetail = map.GRADE
+  const locationDetail = map.WORK_LOCATION
+
+  const changeType = String(raw.changeType || '').toUpperCase()
+  const approvalStatus = String(raw.approvalStatus || '').toUpperCase()
+  const effectiveStatus = String(raw.effectiveStatus || '').toUpperCase()
+  const displayStatus = deriveDocStatus({ approvalStatus, effectiveStatus })
+  const statusMeta = TRANSFER_DISPLAY_STATUS[displayStatus] || TRANSFER_DISPLAY_STATUS.draft
+  const stageKey = stageKeyOf(displayStatus)
+  const name = extras.name || raw.name || raw.employeeName || ''
+
+  const dept =
+    extras.dept || raw.dept || raw.originDept || before.deptName || deptDetail?.beforeText || ''
+  const position =
+    extras.position ||
+    raw.position ||
+    raw.originPosition ||
+    before.positionName ||
+    positionDetail?.beforeText ||
+    ''
+  const targetDept = raw.targetDept || raw.targetDeptName || after.deptName || deptDetail?.afterText || ''
+  const targetPosition =
+    raw.targetPosition || raw.targetPositionName || after.positionName || positionDetail?.afterText || ''
+
+  return {
+    id: raw.id,
+    changeNo: raw.changeNo || raw.contractNo || (raw.id == null ? '' : String(raw.id)),
+    source: extras.source || raw.source || '',
+    userId: raw.userId ?? extras.userId,
+    employeeId: raw.userId ?? extras.employeeId,
+    name,
+    avatar: raw.avatar || String(name).charAt(0),
+    employeeNo: extras.employeeNo || raw.employeeNo || raw.userNo || '',
+    dept,
+    position,
+    level: extras.level || raw.level || before.positionLevelName || before.grade || gradeDetail?.beforeText || '',
+    targetDept,
+    targetDeptId: raw.targetDeptId ?? deptDetail?.afterValue ?? null,
+    targetPosition,
+    targetPositionId: raw.targetPositionId ?? positionDetail?.afterValue ?? null,
+    targetLevel: raw.targetLevel || after.positionLevelName || after.grade || gradeDetail?.afterText || '',
+    workLocation: raw.workLocation || locationDetail?.afterText || '',
+    changeType,
+    changeTypeLabel: changeTypeLabel(changeType),
+    effectiveDate: dateOnly(raw.effectiveDate),
+    applyDate: dateOnly(raw.applyDate || raw.createTime || raw.submitDate),
+    reason: raw.reason || '',
+    remark: raw.remark || '',
+    approvalNode: raw.approvalNode || '',
+    approvalComment: raw.approvalComment || raw.hrComment || '',
+    approvalStatus,
+    approvalStatusLabel: labelOf(APPROVAL_STATUS_LABELS, approvalStatus, '草稿'),
+    effectiveStatus,
+    effectiveStatusLabel: labelOf(EFFECTIVE_STATUS_LABELS, effectiveStatus, '-'),
+    displayStatus,
+    statusLabel: statusMeta.label,
+    statusClass: statusMeta.class,
+    stageKey,
+    progress: progressOf(displayStatus, stageKey),
+    progressTip: progressTipOf(displayStatus),
+    details,
+    createTime: raw.createTime || '',
+    raw,
+  }
+}
+
+// ============ 演示模式本地实现 ============
+function mergeDemoRecords() {
+  const overrides = getUserChangeStates()
+  const created = getCreatedUserChanges()
+  const seen = new Set(created.map((item) => String(item.id)))
+  const base = [...created, ...userChanges.filter((item) => !seen.has(String(item.id)))]
+  return base.map((record) => {
+    const merged = { ...record, ...(overrides[String(record.id)] || {}) }
+    return adaptUserChange(merged, { source: 'demo' })
+  })
+}
+
+function updateDemoUserChange(id, patch) {
+  setUserChangeLocalState(id, patch)
+  updateCreatedUserChange(id, patch)
+  return mergeDemoRecords().find((item) => String(item.id) === String(id)) || null
+}
+
+function persistDemoTransfer(id, form, submit) {
+  const user = getCurrentUser() || {}
+  const name = form.name || user.name || ''
+  const record = {
+    id: String(id),
+    userId: form.userId || form.employeeId || user.id || '',
+    name,
+    avatar: form.avatar || String(name).charAt(0),
+    employeeNo: form.employeeNo || user.id || '',
+    dept: form.dept || user.department || '',
+    position: form.position || user.position || '',
+    level: form.level || '',
+    targetDeptId: form.targetDeptId ?? null,
+    targetDept: form.targetDeptName || form.targetDept || '',
+    targetPositionId: form.targetPositionId ?? null,
+    targetPosition: form.targetPositionName || form.targetPosition || '',
+    targetLevel: form.targetLevel || '',
+    changeType: inferChangeType(form),
+    effectiveDate: dateOnly(form.effectiveDate),
+    applyDate: dateOnly(formatNow()),
+    reason: form.reason || '',
+    remark: form.remark || '',
+    approvalNode: submit ? '主管审批' : '',
+    approvalStatus: submit ? 'PENDING' : 'DRAFT',
+    effectiveStatus: 'PENDING',
+    source: 'demo',
+    createdByApp: true,
+  }
+  addCreatedUserChange(record)
+  return record
+}
+
+// ============ 服务器接口(M02-人事异动)============
+const employeeCache = new Map()
+
+async function fetchEmployeeBrief(userId) {
+  if (userId === undefined || userId === null || userId === '') return null
+  const key = String(userId)
+  if (employeeCache.has(key)) return employeeCache.get(key)
+  try {
+    const res = await request({ url: `${serverPath()}/main/user/getById/${key}`, method: 'GET' })
+    const vo = res.data || {}
+    const brief = {
+      name: vo.name || vo.userName || '',
+      employeeNo: vo.jobNumber || '',
+      dept: vo.deptName || '',
+      position: vo.postName || vo.position || '',
+      level: vo.positionLevelName || vo.grade || '',
+    }
+    employeeCache.set(key, brief)
+    return brief
+  } catch (error) {
+    employeeCache.set(key, null)
+    return null
+  }
+}
+
+function extrasOf(brief) {
+  if (!brief) return {}
+  return {
+    name: brief.name,
+    employeeNo: brief.employeeNo,
+    dept: brief.dept,
+    position: brief.position,
+    level: brief.level,
+  }
+}
+
+/** 当前登录人的服务端数字主键(登录响应里的 userId) */
+export function currentUserId() {
+  const login = getLoginUser() || {}
+  const raw = getCurrentUser()?.raw || {}
+  const candidates = [login.userId, login.id, raw.userId, raw.id]
+  return candidates.find((value) => value !== undefined && value !== null && value !== '')
+}
+
+function isMine(row = {}) {
+  const user = getCurrentUser() || {}
+  const login = getLoginUser() || {}
+  const mine = [user.id, user.name, login.userId, login.loginName, login.userName]
+    .filter((value) => value !== undefined && value !== null && value !== '')
+    .map(String)
+  if (!mine.length) return true
+  const rowIds = [row.userId, row.employeeNo, row.name]
+    .filter((value) => value !== undefined && value !== null && value !== '')
+    .map(String)
+  return rowIds.some((value) => mine.includes(value))
+}
+
+function transferText(row = {}) {
+  return `${row.name}${row.employeeNo}${row.changeNo}${row.dept}${row.position}${row.targetDept}${row.targetPosition}`
+}
+
+/** 条件查询异动单 GET /main/user-changes */
+export async function getUserChangePage(params = {}) {
+  const res = await request({
+    url: `${serverPath()}/main/user-changes`,
+    method: 'GET',
+    data: compact({
+      pageNum: params.pageNum,
+      size: params.size,
+      userId: keepBigIntId(params.userId),
+      changeType: params.changeType,
+      approvalStatus: params.approvalStatus,
+    }),
+  })
+  const data = res.data || {}
+  return { list: asList(data), count: Number(data?.count ?? data?.total ?? 0) }
+}
+
+/** 某员工的异动记录 GET /main/user-changes/users/{userId} */
+export async function getUserChangesByUserId(userId) {
+  const res = await request({
+    url: `${serverPath()}/main/user-changes/users/${userId}`,
+    method: 'GET',
+  })
+  return asList(res.data)
+}
+
+/** 异动单详情 GET /main/user-changes/{id} */
+export async function getUserChangeById(id) {
+  const res = await request({ url: `${serverPath()}/main/user-changes/${id}`, method: 'GET' })
+  return res.data
+}
+
+/** 新建异动单(草稿)POST /main/user-changes */
+export async function createUserChange(payload) {
+  const res = await request({
+    url: `${serverPath()}/main/user-changes`,
+    method: 'POST',
+    data: payload,
+  })
+  const id = extractId(res.data)
+  if (id != null) rememberUserChangeId(id)
+  return id
+}
+
+/** 修改异动草稿 PUT /main/user-changes */
+export async function updateUserChange(payload) {
+  await request({ url: `${serverPath()}/main/user-changes`, method: 'PUT', data: payload })
+  if (payload?.id != null) rememberUserChangeId(payload.id)
+  return payload.id
+}
+
+/** 异动预校验 POST /main/user-changes/{id}/validate */
+export async function validateUserChange(id) {
+  const res = await request({
+    url: `${serverPath()}/main/user-changes/${id}/validate`,
+    method: 'POST',
+  })
+  return res.data
+}
+
+/** 提交异动审批 POST /main/user-changes/{id}/submit */
+export async function submitUserChange(id) {
+  const res = await request({
+    url: `${serverPath()}/main/user-changes/${id}/submit`,
+    method: 'POST',
+  })
+  return res.data
+}
+
+/** 撤回异动审批 POST /main/user-changes/{id}/withdraw */
+export async function withdrawUserChange(id, reason = '') {
+  const res = await request({
+    url: `${serverPath()}/main/user-changes/${id}/withdraw`,
+    method: 'POST',
+    data: compact({ reason }),
+  })
+  return res.data
+}
+
+/** 取消异动单 POST /main/user-changes/{id}/cancel */
+export async function cancelUserChange(id, reason = '') {
+  const res = await request({
+    url: `${serverPath()}/main/user-changes/${id}/cancel`,
+    method: 'POST',
+    data: compact({ reason }),
+  })
+  return res.data
+}
+
+// ============ 对外统一入口(页面只调用这些)============
+
+/**
+ * 调岗列表。
+ * @param {object} params
+ *   - scope: 'mine' 只看本人;'all' 全部员工(HR 视图)
+ *   - status: TRANSFER_STATUS_FILTERS 的 value
+ *   - keyword: 姓名 / 工号 / 单号 / 岗位 模糊匹配
+ */
+export async function loadTransferList(params = {}) {
+  const { scope = 'mine', status = 'all', keyword = '', pageNum = 1, size = 50 } = params
+
+  if (!isServerMode()) {
+    const list = mergeDemoRecords()
+      .filter((row) => (scope === 'mine' ? isMine(row) : true))
+      .filter((row) => matchDisplayStatus(row, status))
+      .filter((row) => (keyword ? transferText(row).includes(keyword) : true))
+      .sort((a, b) =>
+        String(b.applyDate || b.createTime).localeCompare(String(a.applyDate || a.createTime)),
+      )
+    return { list, count: list.length, source: 'demo' }
+  }
+
+  const userId = currentUserId()
+  let rows = []
+  if (scope === 'mine' && userId != null) {
+    try {
+      rows = await getUserChangesByUserId(userId)
+    } catch (error) {
+      rows = []
+    }
+  }
+  if (!rows.length) {
+    try {
+      const page = await getUserChangePage({
+        pageNum,
+        size,
+        userId: scope === 'mine' ? userId : undefined,
+      })
+      rows = page.list
+    } catch (error) {
+      rows = []
+    }
+  }
+  // 集合接口不可用时,按本机记住的主键逐个拉详情兜底(与 PC 端策略一致)
+  if (!rows.length) {
+    const details = await Promise.all(
+      getUserChangeIds().map(async (id) => {
+        try {
+          return await getUserChangeById(id)
+        } catch (error) {
+          return null
+        }
+      }),
+    )
+    rows = details.filter(Boolean)
+  }
+
+  const list = []
+  for (const vo of rows) {
+    if (!vo || vo.id == null) continue
+    const extras = vo.name ? {} : extrasOf(await fetchEmployeeBrief(vo.userId))
+    const row = adaptUserChange(vo, extras)
+    rememberUserChangeId(row.id)
+    if (scope === 'mine' && userId != null && !isMine(row)) continue
+    if (!matchDisplayStatus(row, status)) continue
+    if (keyword && !transferText(row).includes(keyword)) continue
+    list.push(row)
+  }
+  return { list, count: list.length, source: 'api' }
+}
+
+/** 调岗单详情 */
+export async function loadTransferDetail(id) {
+  if (!id) return null
+
+  if (!isServerMode()) {
+    return mergeDemoRecords().find((item) => String(item.id) === String(id)) || null
+  }
+
+  const vo = await getUserChangeById(id)
+  if (!vo) return null
+  rememberUserChangeId(id)
+  const extras = vo.name ? {} : extrasOf(await fetchEmployeeBrief(vo.userId))
+  return adaptUserChange(vo, extras)
+}
+
+// ============ 员工端申请表单 ============
+
+function inferChangeType(form = {}) {
+  if (form.changeType) return form.changeType
+  const hasDept = form.targetDeptId != null && form.targetDeptId !== ''
+  const hasPosition = form.targetPositionId != null && form.targetPositionId !== ''
+  if (hasDept && hasPosition) return 'COMPOSITE_CHANGE'
+  if (hasDept) return 'DEPARTMENT_TRANSFER'
+  return 'POSITION_TRANSFER'
+}
+
+function buildDetails(form = {}) {
+  if (Array.isArray(form.details) && form.details.length) return form.details
+  const details = []
+  if (form.targetDeptName) {
+    details.push({
+      changeField: 'DEPT',
+      beforeValue: form.originDeptId != null ? String(form.originDeptId) : undefined,
+      beforeText: form.dept || '',
+      afterValue: form.targetDeptId != null ? String(form.targetDeptId) : undefined,
+      afterText: form.targetDeptName,
+    })
+  }
+  if (form.targetPositionName) {
+    details.push({
+      changeField: 'POSITION',
+      beforeValue: form.originPositionId != null ? String(form.originPositionId) : undefined,
+      beforeText: form.position || '',
+      afterValue: form.targetPositionId != null ? String(form.targetPositionId) : undefined,
+      afterText: form.targetPositionName,
+    })
+  }
+  // 职级未变化时不产生 GRADE 变动,避免把岗位自带层级误判为职级调整
+  if (form.targetLevel && form.targetLevel !== form.level) {
+    details.push({
+      changeField: 'GRADE',
+      beforeValue: form.level || undefined,
+      beforeText: form.level || '',
+      afterValue: form.targetLevel,
+      afterText: form.targetLevel,
+    })
+  }
+  return details
+}
+
+/**
+ * 表单字段 → /main/user-changes 请求参数(与 PC 端 buildUserChangePayload 对齐)。
+ * 申请人由服务端按当前任职确定,前端只送变动目标与事由。
+ */
+export function buildUserChangePayload(form = {}) {
+  return compact({
+    id: keepBigIntId(form.id),
+    userId: keepBigIntId(form.userId || form.employeeId),
+    changeType: inferChangeType(form),
+    effectiveDate: dateOnly(form.effectiveDate) || undefined,
+    reason: form.reason,
+    remark: form.remark,
+    targetDeptId: keepBigIntId(form.targetDeptId),
+    targetPositionId: keepBigIntId(form.targetPositionId),
+    targetPositionName: form.targetPositionName || form.targetPosition,
+    details: buildDetails(form),
+  })
+}
+
+/**
+ * 提交(或存草稿)调岗申请。
+ * 服务器模式:建单 POST /main/user-changes → 提交 POST /{id}/submit
+ * 演示模式:本地落一条异动单,提交时状态置为审批中
+ *
+ * @returns {Promise<string|number>} 异动单主键
+ */
+export async function submitTransferApplication({ form, submit = true, existingId = null } = {}) {
+  if (!form) throw new Error('申请参数为空')
+  const id = existingId || form.id || null
+
+  if (!isServerMode()) {
+    await delay(300)
+    const localId = id || `DC${Date.now()}`
+    persistDemoTransfer(localId, form, submit)
+    return localId
+  }
+
+  const payload = buildUserChangePayload({ ...form, id })
+  const changeId = id || (await createUserChange(payload))
+  if (id) await updateUserChange({ ...payload, id: changeId })
+  if (submit && changeId != null) await submitUserChange(changeId)
+  return changeId
+}
+
+// —— 流程动作:服务器模式走接口,演示模式改本地态 ——
+
+export async function actSubmitTransfer(row) {
+  if (!row?.id) throw new Error('缺少异动单主键')
+  if (isServerMode()) {
+    // 与 PC 端一致:先预校验,再提交审批
+    const result = await validateUserChange(row.id)
+    if (result && result.valid === false) {
+      throw new Error((result.messages || []).join(';') || '异动预校验未通过')
+    }
+    return submitUserChange(row.id)
+  }
+  return updateDemoUserChange(row.id, { approvalStatus: 'PENDING', approvalNode: '主管审批' })
+}
+
+export async function actWithdrawTransfer(row, reason = '') {
+  if (!row?.id) throw new Error('缺少异动单主键')
+  if (isServerMode()) return withdrawUserChange(row.id, reason)
+  return updateDemoUserChange(row.id, {
+    approvalStatus: 'WITHDRAWN',
+    approvalComment: reason,
+    approvalNode: '',
+  })
+}
+
+export async function actCancelTransfer(row, reason = '') {
+  if (!row?.id) throw new Error('缺少异动单主键')
+  if (isServerMode()) return cancelUserChange(row.id, reason)
+  return updateDemoUserChange(row.id, {
+    approvalStatus: 'CANCELLED',
+    effectiveStatus: 'CANCELLED',
+    approvalComment: reason,
+    approvalNode: '',
+  })
+}

+ 124 - 0
src/data/mock.js

@@ -420,6 +420,7 @@ export const workModules = [
     { label: '培训学习', icon: '学', color: '#269a99', url: '/pages/training/index' },
     { label: '个人档案', icon: '档', color: '#f6903d', url: '/pages/profile/edit' },
     { label: '证明申请', icon: '证', color: '#6dc8ec', url: '/pages/apply/form?type=certificate' },
+    { label: '调岗申请', icon: '岗', color: '#9270ca', url: '/pages/manage/transfer-create' },
     { label: '离职申请', icon: '离', color: '#ee6666', url: '/pages/apply/form?type=resign' },
   ] },
   { group: '人事管理', items: [
@@ -622,3 +623,126 @@ export const resignings = [
     stage: 'assetReturn',
   },
 ]
+
+// ============ 人事异动(调岗)============
+// 枚举与状态机与 PC 端 src/api/hr/userChange.js 对齐;演示模式下列表读 userChanges,
+// 员工提交的调岗申请落到本地(见 utils/storage 的 USER_CHANGE_* 三个 key)。
+
+export const userChangeStatus = {
+  draft:     { label: '草稿',   class: 'status-pending' },
+  pending:   { label: '审批中', class: 'status-pending' },
+  approved:  { label: '已通过', class: 'status-approved' },
+  rejected:  { label: '已驳回', class: 'status-rejected' },
+  withdrawn: { label: '已撤回', class: 'status-rejected' },
+  cancelled: { label: '已取消', class: 'status-rejected' },
+}
+
+// H5 精简流程节点:提交 → 审批 → 生效(PC 端审批由 BPM 流程驱动)
+export const userChangeStages = [
+  { key: 'submit',    label: '提交申请',        desc: '员工提交调岗申请并填写目标岗位与生效日期' },
+  { key: 'approval',  label: '主管 / HR 审批',  desc: '直属主管、部门负责人与 HR 审批调动安排' },
+  { key: 'effective', label: '调动生效',        desc: '按生效日期更新员工的部门、岗位与职级' },
+]
+
+export const userChanges = [
+  {
+    id: 'DC20260805',
+    userId: 'ZY20230118',
+    name: '王伟',
+    avatar: '王',
+    employeeNo: 'ZY20230118',
+    dept: '生产二部',
+    position: '装配工',
+    level: 'P2',
+    targetDept: '品质管理部',
+    targetPosition: '质检员',
+    targetLevel: 'P3',
+    changeType: 'COMPOSITE_CHANGE',
+    effectiveDate: '2026-08-15',
+    applyDate: '2026-08-05',
+    reason: '品质管理部来料检验岗缺员,结合个人装配与工艺经验调岗支持',
+    approvalNode: 'HR 审核',
+    approvalStatus: 'PENDING',
+    effectiveStatus: 'PENDING',
+  },
+  {
+    id: 'DC20260910',
+    userId: 'ZY20230128',
+    name: '陈晓雨',
+    avatar: '陈',
+    employeeNo: 'ZY20230128',
+    dept: '人力资源部',
+    position: '人事专员',
+    level: 'P2',
+    targetDept: '人力资源部',
+    targetPosition: '人事主管',
+    targetLevel: 'P3',
+    changeType: 'POSITION_TRANSFER',
+    effectiveDate: '2026-10-01',
+    applyDate: '2026-09-10',
+    reason: '拟申请岗位调整为人事主管,扩大招聘与员工关系职责范围',
+    approvalNode: '',
+    approvalStatus: 'DRAFT',
+    effectiveStatus: 'PENDING',
+  },
+  {
+    id: 'DC20260826',
+    userId: 'ZY20230128',
+    name: '陈晓雨',
+    avatar: '陈',
+    employeeNo: 'ZY20230128',
+    dept: '人力资源部',
+    position: '人事专员',
+    level: 'P2',
+    targetDept: '人力资源部',
+    targetPosition: '人事主管',
+    targetLevel: 'P3',
+    changeType: 'POSITION_TRANSFER',
+    effectiveDate: '2026-09-01',
+    applyDate: '2026-08-26',
+    reason: '负责招聘与员工关系模块满两年,申请晋升为人事主管',
+    approvalNode: '部门负责人审批',
+    approvalStatus: 'PENDING',
+    effectiveStatus: 'PENDING',
+  },
+  {
+    id: 'DC20260301',
+    userId: 'ZY20230128',
+    name: '陈晓雨',
+    avatar: '陈',
+    employeeNo: 'ZY20230128',
+    dept: '综合管理部',
+    position: '行政专员',
+    level: 'P1',
+    targetDept: '人力资源部',
+    targetPosition: '人事专员',
+    targetLevel: 'P2',
+    changeType: 'COMPOSITE_CHANGE',
+    effectiveDate: '2026-03-01',
+    applyDate: '2026-02-18',
+    reason: '内部转岗至人力资源部,承担招聘与入离职办理工作',
+    approvalNode: '',
+    approvalStatus: 'APPROVED',
+    effectiveStatus: 'EFFECTIVE',
+  },
+  {
+    id: 'DC20260718',
+    userId: 'ZY20200713',
+    name: '周倩',
+    avatar: '周',
+    employeeNo: 'ZY20200713',
+    dept: '财务部',
+    position: '成本会计',
+    level: 'P3',
+    targetDept: '财务部',
+    targetPosition: '财务主管',
+    targetLevel: 'M1',
+    changeType: 'PROMOTION',
+    effectiveDate: '2026-08-01',
+    applyDate: '2026-07-18',
+    reason: '晋升财务主管,统筹成本核算与预算管理',
+    approvalNode: '',
+    approvalStatus: 'REJECTED',
+    effectiveStatus: 'PENDING',
+  },
+]

+ 4 - 1
src/pages.json

@@ -21,7 +21,10 @@
     { "path": "pages/manage/onboard-detail", "style": { "navigationBarTitleText": "入职详情" } },
     { "path": "pages/manage/onboard-create", "style": { "navigationBarTitleText": "新建入职流程" } },
     { "path": "pages/manage/resign-detail", "style": { "navigationBarTitleText": "离职详情" } },
-    { "path": "pages/manage/resign-handover", "style": { "navigationBarTitleText": "离职交接单" } }
+    { "path": "pages/manage/resign-handover", "style": { "navigationBarTitleText": "离职交接单" } },
+    { "path": "pages/manage/transfer-list", "style": { "navigationBarTitleText": "调岗申请" } },
+    { "path": "pages/manage/transfer-create", "style": { "navigationBarTitleText": "调岗申请" } },
+    { "path": "pages/manage/transfer-detail", "style": { "navigationBarTitleText": "调岗详情" } }
   ],
   "globalStyle": {
     "navigationBarTextStyle": "white",

+ 72 - 42
src/pages/manage/index.vue

@@ -39,6 +39,13 @@
           ><text class="banner-sub">查看交接事项进度,完成 / 回退交接并归档离职单</text></view
         ><text class="banner-arrow">›</text>
       </view>
+      <view v-if="type === 'transfer'" class="create-banner transfer-banner press" @click="onCreateTransfer">
+        <view class="banner-icon">+</view>
+        <view class="banner-main"
+          ><text class="banner-title">发起调岗申请</text
+          ><text class="banner-sub">填写目标部门与岗位,提交后进入主管与 HR 审批流程</text></view
+        ><text class="banner-arrow">›</text>
+      </view>
       <view class="manage-list">
         <view
           v-for="item in filteredItems"
@@ -135,12 +142,18 @@ import {
   loadResignationList,
   matchDisplayStatus,
 } from "@/api/resign";
+import {
+  TRANSFER_STATUS_FILTERS,
+  loadTransferList,
+  matchDisplayStatus as matchTransferStatus,
+} from "@/api/userChange";
 import { go } from "@/utils/router";
 const type = ref("recruit");
 useAuthGuard();
 const keyword = ref("");
 const statusFilter = ref("全部状态");
 const resignStatusValue = ref("all");
+const transferStatusValue = ref("all");
 
 // 入职记录原始数据(mock 或 API VO 经 adaptOnboardingRecord 归一后的形状)。
 // 服务器模式按 getOnboardingRecords 拉取;演示模式合并本地 mock + storage。
@@ -188,6 +201,18 @@ async function loadResignList() {
   }
 }
 
+// 调岗单:服务器模式走 M02 人事异动接口,演示模式走 mock + 本地态(由 api/userChange 统一处理)
+const transferRecords = ref([]);
+async function loadTransferListData() {
+  try {
+    const page = await loadTransferList({ scope: "all" });
+    transferRecords.value = page.list || [];
+  } catch (e) {
+    console.warn("[manage/transfer] 调岗列表加载失败:", e?.message || e);
+    transferRecords.value = [];
+  }
+}
+
 const resignItems = computed(() =>
   resignRecords.value
     .filter((r) => matchDisplayStatus(r, resignStatusValue.value))
@@ -211,6 +236,29 @@ const resignItems = computed(() =>
 const pendingResignCount = computed(
   () => resignRecords.value.filter((r) => !["completed", "withdrawn", "cancelled"].includes(r.displayStatus)).length,
 );
+const transferItems = computed(() =>
+  transferRecords.value
+    .filter((r) => matchTransferStatus(r, transferStatusValue.value))
+    .filter((r) =>
+      `${r.name}${r.employeeNo}${r.dept}${r.position}${r.targetDept}${r.targetPosition}`.includes(keyword.value),
+    )
+    .map((r) => ({
+      id: r.id,
+      avatar: r.avatar || String(r.name || "").charAt(0),
+      name: r.name || "—",
+      sub: `${r.changeTypeLabel} · ${r.dept || "—"} → ${r.targetDept || r.dept || "—"}`,
+      status: r.statusLabel,
+      statusClass: r.statusClass,
+      subType: "transfer",
+      label1: "生效日期",
+      value1: r.effectiveDate || "—",
+      label2: "当前节点",
+      value2: r.approvalNode || r.statusLabel,
+    })),
+);
+const pendingTransferCount = computed(
+  () => transferRecords.value.filter((r) => r.displayStatus === "pending").length,
+);
 const configs = computed(() => ({
   recruit: {
     title: "招聘面试",
@@ -304,45 +352,11 @@ const configs = computed(() => ({
   transfer: {
     title: "人事异动",
     subtitle: "调岗、转正、晋升与离职流程",
-    count: "4",
+    count: String(pendingTransferCount.value),
     unit: "审批中",
     search: "员工或流程",
     gradient: "linear-gradient(145deg,#f6903d,#f8b26a)",
-    items: [
-      {
-        avatar: "王",
-        name: "王伟",
-        sub: "调岗 · 生产二部 → 品质管理部",
-        status: "待处理",
-        label1: "生效日期",
-        value1: "08月15日",
-        label2: "当前节点",
-        value2: "HR审核",
-      },
-      {
-        avatar: "林",
-        name: "林雪",
-        sub: "转正 · 采购专员",
-        status: "进行中",
-        label1: "转正日期",
-        value1: "08月20日",
-        label2: "试用评价",
-        value2: "优秀",
-      },
-      {
-        id: "ZY20260812",
-        avatar: "郭",
-        name: "郭峰",
-        sub: "离职 · 设备维修工",
-        status: "进行中",
-        statusClass: "status-processing",
-        subType: "resign",
-        label1: "预计离职",
-        value1: "08月25日",
-        label2: "交接进度",
-        value2: "70%",
-      },
-    ],
+    items: transferItems.value,
   },
   resign: {
     title: "离职管理",
@@ -356,8 +370,8 @@ const configs = computed(() => ({
 }));
 const config = computed(() => configs.value[type.value] || configs.value.recruit);
 const filteredItems = computed(() => {
-  // 离职列表已在 resignItems 里按接口状态 + 关键词过滤,这里不再二次过滤
-  if (type.value === "resign") return config.value.items;
+  // 离职 / 调岗列表已在各自 items 里按接口状态 + 关键词过滤,这里不再二次过滤
+  if (type.value === "resign" || type.value === "transfer") return config.value.items;
   return config.value.items.filter(
     (i) =>
       `${i.name}${i.sub}${i.value1}${i.value2}`.includes(keyword.value) &&
@@ -386,6 +400,7 @@ function syncType(o = {}) {
   keyword.value = "";
   statusFilter.value = "全部状态";
   resignStatusValue.value = "all";
+  transferStatusValue.value = "all";
   uni.setNavigationBarTitle({
     title: type.value === "dashboard" ? "数据看板" : config.value.title,
   });
@@ -396,6 +411,7 @@ onShow(() => {
   syncType(pages[pages.length - 1]?.options || {});
   loadOnboardList();
   loadResignList();
+  loadTransferListData();
 });
 function syncHash() {
   if (typeof location === "undefined") return;
@@ -420,6 +436,10 @@ function showDetail(item) {
     go(`/pages/manage/resign-detail?id=${item.id}`);
     return;
   }
+  if (item.subType === "transfer" && item.id) {
+    go(`/pages/manage/transfer-detail?id=${item.id}`);
+    return;
+  }
   uni.showModal({
     title: item.name,
     content: `${item.sub}\n${item.label1}:${item.value1}\n${item.label2}:${item.value2}\n\n当前为静态演示数据。`,
@@ -432,14 +452,19 @@ function onCreateOnboarding() {
 function onOpenHandover() {
   go("/pages/manage/resign-handover");
 }
+function onCreateTransfer() {
+  go("/pages/manage/transfer-create");
+}
 function chooseStatus() {
   const options =
     type.value === "resign"
       ? RESIGN_STATUS_FILTERS
-      : ["全部状态", "待入职", "入职中", "已入职", "已逾期"].map((label) => ({
-          value: label,
-          label,
-        }));
+      : type.value === "transfer"
+        ? TRANSFER_STATUS_FILTERS
+        : ["全部状态", "待入职", "入职中", "已入职", "已逾期"].map((label) => ({
+            value: label,
+            label,
+          }));
   uni.showActionSheet({
     itemList: options.map((o) => o.label),
     success: (r) => {
@@ -447,6 +472,7 @@ function chooseStatus() {
       if (!picked) return;
       statusFilter.value = picked.label;
       if (type.value === "resign") resignStatusValue.value = picked.value;
+      else if (type.value === "transfer") transferStatusValue.value = picked.value;
     },
   });
 }
@@ -545,6 +571,10 @@ function showWarning(w) {
   background: linear-gradient(135deg, #ee6666 0%, #f08a8a 100%);
   box-shadow: 0 6rpx 20rpx rgba(238, 102, 102, 0.18);
 }
+.transfer-banner {
+  background: linear-gradient(135deg, #f6903d 0%, #f8b26a 100%);
+  box-shadow: 0 6rpx 20rpx rgba(246, 144, 61, 0.18);
+}
 .banner-icon {
   width: 72rpx;
   height: 72rpx;

+ 548 - 0
src/pages/manage/transfer-create.vue

@@ -0,0 +1,548 @@
+<template>
+  <view class="page-no-tab">
+    <view class="form-intro">
+      <ModuleIcon :icon="meta.icon" :color="meta.color" soft />
+      <view>
+        <text class="intro-title">{{ meta.title }}</text>
+        <text class="intro-sub">{{ meta.subtitle }}</text>
+      </view>
+    </view>
+
+    <view class="steps">
+      <view class="step active"><view>1</view><text>填写申请</text></view>
+      <view class="step-line"></view>
+      <view class="step"><view>2</view><text>主管审批</text></view>
+      <view class="step-line"></view>
+      <view class="step"><view>3</view><text>完成</text></view>
+    </view>
+
+    <view class="form-card card section">
+      <view class="field"><text class="label">申请人</text>
+        <view class="readonly">
+          <view class="avatar small-avatar">{{ currentUser.initials || '—' }}</view>
+          <text>{{ currentUser.name }} · {{ currentUser.department }}</text>
+        </view>
+      </view>
+      <view class="divider"></view>
+      <view class="field"><text class="label">当前岗位</text>
+        <text class="value">{{ currentUser.position || '—' }}</text>
+      </view>
+      <view class="divider"></view>
+      <view class="field press" @click="chooseChangeType"><text class="label required">异动类型</text>
+        <text :class="form.changeType ? 'value' : 'placeholder'">{{ changeTypeText }}</text>
+        <text class="arrow">›</text>
+      </view>
+      <view class="divider"></view>
+      <view class="field press" @click="chooseTargetDept"><text class="label">目标部门</text>
+        <text :class="form.targetDeptName ? 'value' : 'placeholder'">{{ form.targetDeptName || '请选择' }}</text>
+        <text class="arrow">›</text>
+      </view>
+      <view class="divider"></view>
+      <view class="field press" @click="chooseTargetPosition"><text class="label required">目标岗位</text>
+        <text :class="form.targetPositionName ? 'value' : 'placeholder'">{{ form.targetPositionName || '请选择' }}</text>
+        <text class="arrow">›</text>
+      </view>
+      <template v-if="form.targetLevel">
+        <view class="divider"></view>
+        <view class="field"><text class="label">目标职级</text><text class="value">{{ form.targetLevel }}</text></view>
+      </template>
+      <view class="divider"></view>
+      <view class="field press"><text class="label required">生效日期</text>
+        <picker mode="date" :value="form.effectiveDate" :start="todayIso" @change="onEffectiveDateChange">
+          <text :class="form.effectiveDate ? 'value' : 'placeholder'">{{ form.effectiveDate || '请选择' }}</text>
+        </picker>
+        <text class="arrow">›</text>
+      </view>
+    </view>
+
+    <view class="section-title"><text>申请说明</text></view>
+    <view class="form-card card section">
+      <view class="field textarea-field"><text class="label required">调岗事由</text>
+        <textarea v-model="form.reason" maxlength="200" placeholder="请说明调岗原因与工作安排" />
+        <text class="counter">{{ form.reason.length }} / 200</text>
+      </view>
+    </view>
+    <view class="form-card card section">
+      <view class="field textarea-field"><text class="label">备注</text>
+        <textarea v-model="form.remark" maxlength="200" placeholder="补充说明(选填)" />
+        <text class="counter">{{ form.remark.length }} / 200</text>
+      </view>
+    </view>
+
+    <view class="footer-tip muted">
+      <text>{{ footerTip }}</text>
+    </view>
+
+    <view class="bottom-action">
+      <view class="draft-btn press" @click="onSaveDraft">保存草稿</view>
+      <view class="submit-btn press" @click="onSubmit">提交审批</view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+import { computed, reactive, ref } from 'vue'
+import { onLoad } from '@dcloudio/uni-app'
+import ModuleIcon from '@/components/ModuleIcon.vue'
+import { useAuthGuard } from '@/hooks/useAuthGuard'
+import { useCurrentUser } from '@/hooks/useCurrentUser'
+import { applicationTypes } from '@/data/mock'
+import { listOrganizations } from '@/api/organization'
+import { pagePosition } from '@/api/position'
+import { buildOrganizationTree } from '@/utils/organization-tree'
+import { addApplication } from '@/utils/storage'
+import { getServerConfig } from '@/utils/auth'
+import { back } from '@/utils/router'
+import {
+  ESS_CHANGE_TYPE_OPTIONS,
+  canEditUserChange,
+  changeTypeLabel,
+  loadTransferDetail,
+  submitTransferApplication,
+} from '@/api/userChange'
+
+useAuthGuard()
+const currentUser = useCurrentUser()
+const meta = computed(() => applicationTypes.transfer || { title: '调岗申请', subtitle: '', icon: '岗', color: '#9270ca' })
+
+const today = new Date()
+const todayIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
+
+const form = reactive({
+  id: null,
+  userId: null,
+  name: '',
+  employeeNo: '',
+  dept: '',
+  position: '',
+  level: '',
+  changeType: 'POSITION_TRANSFER',
+  targetDeptId: null,
+  targetDeptName: '',
+  targetPositionId: null,
+  targetPositionName: '',
+  targetLevel: '',
+  effectiveDate: '',
+  reason: '',
+  remark: '',
+})
+
+const submitting = ref(false)
+const deptOptions = ref([])
+const positionOptions = ref([])
+const positionLoading = ref(false)
+
+const changeTypeText = computed(() => (form.changeType ? changeTypeLabel(form.changeType) : '请选择'))
+
+const summaryText = computed(() => {
+  const parts = [changeTypeLabel(form.changeType)]
+  if (form.targetDeptName) parts.push(form.targetDeptName)
+  if (form.targetPositionName) parts.push(form.targetPositionName)
+  if (form.effectiveDate) parts.push(`生效 ${form.effectiveDate}`)
+  return parts.join(' · ')
+})
+
+const footerTip = computed(() =>
+  getServerConfig().mode === 'server'
+    ? '提交后由 BPM 流程驱动审批,可在「调岗申请」中查看审批状态。'
+    : '演示模式:申请保存在本机,提交后可在「调岗申请」与「我的申请」中查看进度。',
+)
+
+// 法人节点只作为路径前缀,不作为可选的调动目标
+const LEGAL_TYPES = new Set(['10', '20', 'group', 'company'])
+
+function flattenDepartments(nodes = []) {
+  const result = []
+  const walk = (items, parentPath) => {
+    items.forEach((node) => {
+      const path = parentPath ? `${parentPath} / ${node.name}` : node.name
+      if (!LEGAL_TYPES.has(String(node.type))) result.push({ id: node.id, name: node.name, path })
+      walk(node.children || [], path)
+    })
+  }
+  walk(nodes, '')
+  return result
+}
+
+async function loadDepartments() {
+  try {
+    const list = await listOrganizations()
+    deptOptions.value = flattenDepartments(buildOrganizationTree(list))
+  } catch (error) {
+    deptOptions.value = []
+  }
+}
+
+async function loadPositions() {
+  positionLoading.value = true
+  try {
+    const data = await pagePosition({ pageNum: 1, size: 50 })
+    positionOptions.value = (data?.list || []).map((p) => ({
+      id: p.id,
+      name: p.positionName,
+      deptId: p.deptId,
+      deptName: p.deptName,
+      level: p.positionLevel || '',
+    }))
+  } catch (error) {
+    positionOptions.value = []
+  } finally {
+    positionLoading.value = false
+  }
+}
+
+function syncOrigin() {
+  const user = currentUser || {}
+  form.userId = form.userId || user.id || null
+  form.name = form.name || user.name || ''
+  form.employeeNo = form.employeeNo || user.id || ''
+  form.dept = form.dept || user.department || ''
+  form.position = form.position || user.position || ''
+  form.level = form.level || user.level || ''
+}
+
+function chooseChangeType() {
+  uni.showActionSheet({
+    itemList: ESS_CHANGE_TYPE_OPTIONS.map((item) => item.label),
+    success: (r) => {
+      const picked = ESS_CHANGE_TYPE_OPTIONS[r.tapIndex]
+      if (picked) form.changeType = picked.value
+    },
+  })
+}
+
+function chooseTargetDept() {
+  if (!deptOptions.value.length) {
+    uni.showToast({ title: '部门数据加载中,请稍后重试', icon: 'none' })
+    loadDepartments()
+    return
+  }
+  uni.showActionSheet({
+    itemList: deptOptions.value.map((item) => item.path),
+    success: (r) => {
+      const dept = deptOptions.value[r.tapIndex]
+      if (!dept) return
+      form.targetDeptId = dept.id
+      form.targetDeptName = dept.name
+    },
+  })
+}
+
+async function chooseTargetPosition() {
+  if (positionLoading.value) return
+  if (!positionOptions.value.length) {
+    await loadPositions()
+  }
+  if (!positionOptions.value.length) {
+    uni.showToast({ title: '暂无岗位数据', icon: 'none' })
+    return
+  }
+  uni.showActionSheet({
+    itemList: positionOptions.value.map((item) => (item.deptName ? `${item.name}(${item.deptName})` : item.name)),
+    success: (r) => {
+      const position = positionOptions.value[r.tapIndex]
+      if (!position) return
+      form.targetPositionId = position.id
+      form.targetPositionName = position.name
+      // 目标岗位自带岗位层级时同步展示;与当前职级一致则不产生 GRADE 变动
+      form.targetLevel = position.level || ''
+      // 岗位归属部门即调动目标部门,避免部门与岗位不一致
+      if (position.deptId != null && position.deptName) {
+        form.targetDeptId = position.deptId
+        form.targetDeptName = position.deptName
+      }
+    },
+  })
+}
+
+function onEffectiveDateChange(e) {
+  form.effectiveDate = e.detail.value
+}
+
+function validate() {
+  if (!form.changeType) return '请选择异动类型'
+  if (['DEPARTMENT_TRANSFER', 'COMPOSITE_CHANGE'].includes(form.changeType) && !form.targetDeptName) return '请选择目标部门'
+  if (!form.targetPositionName) return '请选择目标岗位'
+  if (!form.effectiveDate) return '请选择生效日期'
+  if (!String(form.reason || '').trim()) return '请填写调岗事由'
+  return null
+}
+
+async function save(submit) {
+  const error = validate()
+  if (error) {
+    uni.showToast({ title: error, icon: 'none' })
+    return
+  }
+  if (submitting.value) return
+  submitting.value = true
+  uni.showLoading({ title: submit ? '提交中...' : '保存中...' })
+  try {
+    syncOrigin()
+    const id = await submitTransferApplication({
+      form: { ...form, name: form.name || currentUser.name },
+      submit,
+      existingId: form.id,
+    })
+    form.id = id
+    if (submit) {
+      addApplication({
+        type: meta.value.title,
+        summary: summaryText.value,
+        reason: form.reason,
+        color: meta.value.color,
+        metadata: {
+          changeId: id,
+          changeType: form.changeType,
+          targetPositionName: form.targetPositionName,
+          effectiveDate: form.effectiveDate,
+          source: getServerConfig().mode === 'server' ? 'server' : 'demo',
+        },
+      })
+    }
+    uni.hideLoading()
+    uni.showModal({
+      title: submit ? '提交成功' : '已保存草稿',
+      content: submit
+        ? '调岗申请已提交审批,可在「调岗申请」或「我的申请」中查看进度。'
+        : '草稿已保存,可在「调岗申请」列表中继续提交或编辑。',
+      showCancel: false,
+      success: () => uni.redirectTo({ url: '/pages/manage/transfer-list' }),
+    })
+  } catch (e) {
+    uni.hideLoading()
+    uni.showToast({ title: e.message || '操作失败', icon: 'none' })
+  } finally {
+    submitting.value = false
+  }
+}
+
+function onSaveDraft() {
+  save(false)
+}
+
+function onSubmit() {
+  save(true)
+}
+
+async function hydrateForm(id) {
+  uni.showLoading({ title: '加载中...' })
+  try {
+    const row = await loadTransferDetail(id)
+    if (!row) throw new Error('未找到该调岗单')
+    if (!canEditUserChange(row)) throw new Error('当前状态不可编辑')
+    Object.assign(form, {
+      id: row.id,
+      userId: row.userId,
+      name: row.name,
+      employeeNo: row.employeeNo,
+      dept: row.dept,
+      position: row.position,
+      level: row.level,
+      changeType: row.changeType || 'POSITION_TRANSFER',
+      targetDeptId: row.targetDeptId ?? null,
+      targetDeptName: row.targetDept,
+      targetPositionId: row.targetPositionId ?? null,
+      targetPositionName: row.targetPosition,
+      targetLevel: row.targetLevel,
+      effectiveDate: row.effectiveDate,
+      reason: row.reason,
+      remark: row.remark,
+    })
+  } catch (e) {
+    uni.hideLoading()
+    uni.showToast({ title: e.message || '加载失败', icon: 'none' })
+    setTimeout(() => back('/pages/manage/transfer-list'), 900)
+    return
+  }
+  uni.hideLoading()
+}
+
+onLoad((options) => {
+  uni.setNavigationBarTitle({ title: '调岗申请' })
+  loadDepartments()
+  loadPositions()
+  const id = options?.id || ''
+  if (id) {
+    hydrateForm(id)
+    return
+  }
+  syncOrigin()
+})
+</script>
+
+<style scoped lang="scss">
+.page-no-tab {
+  padding-bottom: 150rpx;
+}
+.form-intro {
+  padding: 30rpx 30rpx 22rpx;
+  display: flex;
+  align-items: center;
+  background: #fff;
+}
+.form-intro > view:last-child {
+  margin-left: 22rpx;
+}
+.intro-title {
+  display: block;
+  font-size: 32rpx;
+  font-weight: 600;
+}
+.intro-sub {
+  display: block;
+  margin-top: 9rpx;
+  color: #8b939f;
+  font-size: 23rpx;
+}
+.steps {
+  height: 128rpx;
+  padding: 18rpx 70rpx 12rpx;
+  display: flex;
+  align-items: flex-start;
+  background: #fff;
+  border-top: 1rpx solid #f2f3f5;
+}
+.step {
+  width: 92rpx;
+  text-align: center;
+  color: #a0a7b0;
+  font-size: 20rpx;
+}
+.step > view {
+  width: 42rpx;
+  height: 42rpx;
+  margin: 0 auto 8rpx;
+  border-radius: 50%;
+  background: #e8eaed;
+  color: #8b939f;
+  line-height: 42rpx;
+}
+.step.active {
+  color: #1677ff;
+}
+.step.active > view {
+  background: #1677ff;
+  color: #fff;
+}
+.step-line {
+  flex: 1;
+  height: 2rpx;
+  margin-top: 20rpx;
+  background: #e1e4e8;
+}
+.form-card {
+  overflow: hidden;
+}
+.field {
+  position: relative;
+  min-height: 108rpx;
+  padding: 28rpx;
+  display: flex;
+  align-items: center;
+}
+.label {
+  width: 180rpx;
+  flex-shrink: 0;
+  font-size: 28rpx;
+}
+.required::before {
+  content: '*';
+  color: #ee4d4d;
+  margin-right: 6rpx;
+}
+.readonly {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  font-size: 26rpx;
+}
+.small-avatar {
+  width: 52rpx;
+  height: 52rpx;
+  margin-right: 14rpx;
+  font-size: 22rpx;
+}
+.value {
+  color: #1f2329;
+  font-size: 27rpx;
+}
+.placeholder {
+  color: #b2b7be;
+  font-size: 27rpx;
+}
+.arrow {
+  margin-left: 13rpx;
+  color: #c4c8ce;
+  font-size: 38rpx;
+}
+.textarea-field {
+  display: block;
+  min-height: 240rpx;
+}
+.textarea-field .label {
+  display: block;
+  width: auto;
+  margin-bottom: 20rpx;
+}
+.textarea-field textarea {
+  width: 100%;
+  height: 130rpx;
+  padding: 18rpx;
+  border-radius: 12rpx;
+  background: #f6f7f8;
+  font-size: 27rpx;
+}
+.counter {
+  position: absolute;
+  right: 45rpx;
+  bottom: 38rpx;
+  color: #b2b7be;
+  font-size: 21rpx;
+}
+.footer-tip {
+  padding: 8rpx 34rpx 0;
+  font-size: 22rpx;
+  line-height: 34rpx;
+}
+.bottom-action {
+  position: fixed;
+  z-index: 10;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  height: 120rpx;
+  padding: 16rpx 24rpx;
+  display: flex;
+  gap: 18rpx;
+  background: #fff;
+  border-top: 1rpx solid #e8e9eb;
+}
+.draft-btn,
+.submit-btn {
+  height: 84rpx;
+  border-radius: 12rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 30rpx;
+}
+.draft-btn {
+  width: 210rpx;
+  border: 1rpx solid #d7dce2;
+  color: #4b5563;
+}
+.submit-btn {
+  flex: 1;
+  background: #1677ff;
+  color: #fff;
+}
+@media (min-width: 800px) {
+  .bottom-action {
+    max-width: 520px;
+    left: 50%;
+    transform: translateX(-50%);
+  }
+}
+</style>

+ 494 - 0
src/pages/manage/transfer-detail.vue

@@ -0,0 +1,494 @@
+<template>
+  <view class="page-no-tab">
+    <view class="detail-head">
+      <view class="head-base">
+        <view class="avatar head-avatar">{{ transfer?.avatar || '—' }}</view>
+        <view class="head-main">
+          <text class="head-name">{{ transfer?.name || '—' }}</text>
+          <text class="head-sub">{{ transfer?.dept || '—' }} · {{ transfer?.position || '—' }}</text>
+        </view>
+        <text class="status-tag head-status" :class="transfer?.statusClass">{{ transfer?.statusLabel }}</text>
+      </view>
+      <view class="head-meta">
+        <view><text>调岗单号</text><text>{{ transfer?.changeNo || '—' }}</text></view>
+        <view><text>异动类型</text><text>{{ transfer?.changeTypeLabel || '—' }}</text></view>
+        <view><text>生效日期</text><text>{{ transfer?.effectiveDate || '—' }}</text></view>
+        <view><text>当前节点</text><text>{{ transfer?.approvalNode || transfer?.statusLabel || '—' }}</text></view>
+      </view>
+    </view>
+
+    <view v-if="loading" class="loading card section">加载中…</view>
+    <view v-else-if="!transfer" class="loading card section">未找到该调岗单</view>
+
+    <template v-else>
+      <view class="route-card card section">
+        <view class="route-side">
+          <text class="route-label">调整前</text>
+          <text class="route-name">{{ transfer.position || '—' }}</text>
+          <text class="route-dept">{{ transfer.dept || '—' }}{{ transfer.level ? ' · ' + transfer.level : '' }}</text>
+        </view>
+        <text class="route-arrow">→</text>
+        <view class="route-side route-side-to">
+          <text class="route-label">调整后</text>
+          <text class="route-name">{{ transfer.targetPosition || '—' }}</text>
+          <text class="route-dept">{{ transfer.targetDept || transfer.dept || '—' }}{{ transfer.targetLevel ? ' · ' + transfer.targetLevel : '' }}</text>
+        </view>
+      </view>
+
+      <view class="progress-card card section">
+        <view class="progress-head">
+          <text class="progress-title">调岗进度</text>
+          <text class="progress-value">{{ transfer.progress }}%</text>
+        </view>
+        <view class="progress-bar">
+          <view class="progress-fill" :style="{ width: transfer.progress + '%' }"></view>
+        </view>
+        <text class="progress-tip">{{ transfer.progressTip }}</text>
+      </view>
+
+      <view class="section-title"><text>审批流程</text></view>
+      <view class="timeline card section">
+        <view
+          v-for="(stage, index) in userChangeStages"
+          :key="stage.key"
+          class="timeline-row"
+          :class="{ 'is-done': stageState(stage.key) === 'done', 'is-current': stageState(stage.key) === 'current' }"
+        >
+          <view class="timeline-node">
+            <view class="node-dot"></view>
+            <view v-if="index !== userChangeStages.length - 1" class="node-line"></view>
+          </view>
+          <view class="timeline-main">
+            <text class="timeline-label">{{ stage.label }}</text>
+            <text class="timeline-desc">{{ stage.desc }}</text>
+          </view>
+          <text class="status-tag" :class="stageMeta(stage.key).class">{{ stageMeta(stage.key).label }}</text>
+        </view>
+      </view>
+
+      <view class="section-title"><text>申请信息</text></view>
+      <view class="info-card card section">
+        <view class="info-head">
+          <text class="info-title">调岗单信息</text>
+          <text class="status-tag" :class="transfer.statusClass">{{ transfer.statusLabel }}</text>
+        </view>
+        <view class="info-row"><text>异动单号</text><text>{{ transfer.changeNo || '—' }}</text></view>
+        <view class="info-row"><text>异动类型</text><text>{{ transfer.changeTypeLabel }}</text></view>
+        <view class="info-row"><text>申请日期</text><text>{{ transfer.applyDate || '—' }}</text></view>
+        <view class="info-row"><text>生效日期</text><text>{{ transfer.effectiveDate || '—' }}</text></view>
+        <view class="info-row"><text>审批状态</text><text>{{ transfer.approvalStatusLabel }}</text></view>
+        <view class="info-row"><text>生效状态</text><text>{{ transfer.effectiveStatusLabel }}</text></view>
+        <view class="info-row"><text>调岗事由</text><text>{{ transfer.reason || '—' }}</text></view>
+        <view v-if="transfer.remark" class="info-row"><text>备注</text><text>{{ transfer.remark }}</text></view>
+        <view v-if="transfer.approvalComment" class="info-row"><text>审批意见</text><text>{{ transfer.approvalComment }}</text></view>
+      </view>
+
+      <view class="footer-tip muted"><text>{{ footerTip }}</text></view>
+
+      <view v-if="actions.length" class="footer-actions">
+        <view
+          v-for="action in actions"
+          :key="action.key"
+          :class="action.primary ? 'primary-btn' : 'ghost-btn'"
+          @click="runAction(action.key)"
+        >{{ action.label }}</view>
+      </view>
+    </template>
+  </view>
+</template>
+
+<script setup>
+import { computed, ref } from 'vue'
+import { onLoad, onShow } from '@dcloudio/uni-app'
+import { useAuthGuard } from '@/hooks/useAuthGuard'
+import { userChangeStages } from '@/data/mock'
+import { go } from '@/utils/router'
+import { getServerConfig } from '@/utils/auth'
+import {
+  actCancelTransfer,
+  actSubmitTransfer,
+  actWithdrawTransfer,
+  canCancelUserChange,
+  canEditUserChange,
+  canSubmitUserChange,
+  canWithdrawUserChange,
+  loadTransferDetail,
+} from '@/api/userChange'
+
+useAuthGuard()
+const id = ref('')
+const transfer = ref(null)
+const loading = ref(false)
+
+const STAGE_STATE_META = {
+  done: { label: '已完成', class: 'status-approved' },
+  current: { label: '进行中', class: 'status-processing' },
+  rejected: { label: '已驳回', class: 'status-rejected' },
+  todo: { label: '待开始', class: 'status-pending' },
+}
+
+function stageState(key) {
+  const row = transfer.value
+  if (!row) return 'todo'
+  const order = userChangeStages.map((stage) => stage.key)
+  const index = order.indexOf(key)
+  const current = order.indexOf(row.stageKey)
+  if (row.displayStatus === 'approved') return 'done'
+  if (row.displayStatus === 'rejected') return index === 0 ? 'done' : index === 1 ? 'rejected' : 'todo'
+  if (row.displayStatus === 'withdrawn' || row.displayStatus === 'cancelled') return index === 0 ? 'done' : 'todo'
+  if (index < current) return 'done'
+  if (index === current) return 'current'
+  return 'todo'
+}
+
+function stageMeta(key) {
+  return STAGE_STATE_META[stageState(key)] || STAGE_STATE_META.todo
+}
+
+const actions = computed(() => {
+  const row = transfer.value
+  if (!row) return []
+  const list = []
+  if (canSubmitUserChange(row)) list.push({ key: 'submit', label: '提交审批', primary: true })
+  if (canEditUserChange(row)) list.push({ key: 'edit', label: '编辑', primary: false })
+  if (canWithdrawUserChange(row)) list.push({ key: 'withdraw', label: '撤回审批', primary: false })
+  else if (canCancelUserChange(row)) list.push({ key: 'cancel', label: '取消调岗单', primary: false })
+  return list
+})
+
+const footerTip = computed(() => {
+  const row = transfer.value
+  if (!row) return ''
+  if (row.source === 'demo') {
+    return '演示模式:数据保存在本机,操作不会同步到服务器;接入服务器后走 M02 人事异动接口。'
+  }
+  return '调岗审批由 BPM 流程驱动,审批通过后按生效日期更新员工的部门、岗位与职级。'
+})
+
+async function loadDetail() {
+  if (!id.value) return
+  loading.value = true
+  try {
+    transfer.value = await loadTransferDetail(id.value)
+  } catch (error) {
+    transfer.value = null
+    uni.showToast({ title: error.message || '调岗单加载失败', icon: 'none' })
+  } finally {
+    loading.value = false
+  }
+}
+
+function confirmAction(title, content) {
+  return new Promise((resolve) => {
+    uni.showModal({
+      title,
+      content,
+      success: (r) => resolve(!!r.confirm),
+      fail: () => resolve(false),
+    })
+  })
+}
+
+function promptReason(title, required) {
+  return new Promise((resolve) => {
+    uni.showModal({
+      title,
+      editable: true,
+      placeholderText: required ? '请输入原因(必填)' : '请输入原因(选填)',
+      success: (r) => {
+        if (!r.confirm) return resolve(null)
+        const reason = String(r.content || '').trim()
+        if (required && !reason) {
+          uni.showToast({ title: '请填写原因', icon: 'none' })
+          return resolve(null)
+        }
+        resolve(reason)
+      },
+      fail: () => resolve(null),
+    })
+  })
+}
+
+async function exec(title, task) {
+  uni.showLoading({ title: '处理中...' })
+  try {
+    await task()
+    uni.hideLoading()
+    uni.showToast({ title: `${title}成功`, icon: 'success' })
+    await loadDetail()
+    return true
+  } catch (error) {
+    uni.hideLoading()
+    uni.showToast({ title: error.message || `${title}失败`, icon: 'none' })
+    return false
+  }
+}
+
+async function runAction(key) {
+  const row = transfer.value
+  if (!row) return
+
+  if (key === 'edit') {
+    go(`/pages/manage/transfer-create?id=${row.id}`)
+  } else if (key === 'submit') {
+    const ok = await confirmAction('提交调岗审批', '提交后将进入主管与 HR 审批流程,确认提交?')
+    if (!ok) return
+    await exec('提交审批', () => actSubmitTransfer(row))
+  } else if (key === 'withdraw') {
+    const reason = await promptReason('撤回调岗审批', false)
+    if (reason === null) return
+    await exec('撤回审批', () => actWithdrawTransfer(row, reason))
+  } else if (key === 'cancel') {
+    const reason = await promptReason('取消调岗单', true)
+    if (reason === null) return
+    await exec('取消调岗单', () => actCancelTransfer(row, reason))
+  }
+}
+
+let firstShow = true
+
+onLoad((options) => {
+  id.value = options?.id || ''
+  uni.setNavigationBarTitle({ title: '调岗详情' })
+  loadDetail()
+})
+
+onShow(() => {
+  if (firstShow) {
+    firstShow = false
+    return
+  }
+  loadDetail()
+})
+</script>
+
+<style scoped>
+.page-no-tab {
+  padding-bottom: 40rpx;
+}
+.detail-head {
+  padding: 32rpx 28rpx 28rpx;
+  color: #fff;
+  background: linear-gradient(145deg, #f6903d, #f8b26a);
+}
+.head-base {
+  display: flex;
+  align-items: center;
+}
+.head-avatar {
+  width: 86rpx;
+  height: 86rpx;
+  font-size: 32rpx;
+  background: rgba(255, 255, 255, 0.24);
+}
+.head-main {
+  flex: 1;
+  min-width: 0;
+  margin-left: 20rpx;
+}
+.head-name {
+  display: block;
+  font-size: 34rpx;
+  font-weight: 600;
+}
+.head-sub {
+  display: block;
+  margin-top: 8rpx;
+  font-size: 23rpx;
+  opacity: 0.86;
+}
+.head-status {
+  background: rgba(255, 255, 255, 0.22);
+  color: #fff;
+}
+.head-meta {
+  margin-top: 26rpx;
+  display: flex;
+  flex-wrap: wrap;
+}
+.head-meta view {
+  width: 50%;
+  margin-top: 14rpx;
+}
+.head-meta text {
+  display: block;
+  font-size: 21rpx;
+  opacity: 0.76;
+}
+.head-meta text + text {
+  margin-top: 6rpx;
+  font-size: 25rpx;
+  opacity: 1;
+}
+.loading {
+  padding: 60rpx;
+  text-align: center;
+  color: #8b939f;
+}
+.route-card {
+  padding: 26rpx;
+  display: flex;
+  align-items: center;
+}
+.route-side {
+  flex: 1;
+  min-width: 0;
+}
+.route-side-to {
+  text-align: right;
+}
+.route-label {
+  display: block;
+  color: #a0a7b0;
+  font-size: 21rpx;
+}
+.route-name {
+  display: block;
+  margin-top: 8rpx;
+  font-size: 30rpx;
+  font-weight: 600;
+}
+.route-dept {
+  display: block;
+  margin-top: 6rpx;
+  color: #8b939f;
+  font-size: 22rpx;
+}
+.route-arrow {
+  margin: 0 18rpx;
+  color: #f6903d;
+  font-size: 34rpx;
+  flex-shrink: 0;
+}
+.progress-card {
+  padding: 26rpx;
+}
+.progress-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.progress-title {
+  font-size: 28rpx;
+  font-weight: 600;
+}
+.progress-value {
+  color: #f6903d;
+  font-size: 30rpx;
+  font-weight: 600;
+}
+.progress-bar {
+  height: 12rpx;
+  margin: 20rpx 0 18rpx;
+  border-radius: 999rpx;
+  background: #f0f1f3;
+  overflow: hidden;
+}
+.progress-fill {
+  height: 100%;
+  border-radius: 999rpx;
+  background: linear-gradient(90deg, #f6903d, #f8b26a);
+}
+.progress-tip {
+  color: #8b939f;
+  font-size: 23rpx;
+}
+.timeline {
+  padding: 26rpx 26rpx 6rpx;
+}
+.timeline-row {
+  display: flex;
+  align-items: flex-start;
+  padding-bottom: 26rpx;
+}
+.timeline-node {
+  width: 34rpx;
+  flex-shrink: 0;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.node-dot {
+  width: 18rpx;
+  height: 18rpx;
+  margin-top: 10rpx;
+  border-radius: 50%;
+  background: #cfd4da;
+}
+.is-done .node-dot {
+  background: #07c160;
+}
+.is-current .node-dot {
+  background: #f6903d;
+  box-shadow: 0 0 0 6rpx rgba(246, 144, 61, 0.16);
+}
+.node-line {
+  width: 2rpx;
+  height: 46rpx;
+  margin-top: 6rpx;
+  background: #e1e4e8;
+}
+.timeline-main {
+  flex: 1;
+  min-width: 0;
+  margin: 0 16rpx;
+}
+.timeline-label {
+  display: block;
+  font-size: 27rpx;
+  font-weight: 500;
+}
+.timeline-desc {
+  display: block;
+  margin-top: 8rpx;
+  color: #8b939f;
+  font-size: 22rpx;
+  line-height: 32rpx;
+}
+.info-card {
+  padding: 26rpx;
+}
+.info-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding-bottom: 18rpx;
+  border-bottom: 1rpx solid #f0f1f3;
+}
+.info-title {
+  font-size: 28rpx;
+  font-weight: 600;
+}
+.info-row {
+  display: flex;
+  align-items: flex-start;
+  padding-top: 20rpx;
+  font-size: 26rpx;
+}
+.info-row > text:first-child {
+  width: 170rpx;
+  flex-shrink: 0;
+  color: #8b939f;
+}
+.info-row > text:last-child {
+  flex: 1;
+  text-align: right;
+  color: #1f2329;
+  word-break: break-all;
+}
+.footer-tip {
+  padding: 10rpx 34rpx 0;
+  font-size: 22rpx;
+  line-height: 34rpx;
+}
+.footer-actions {
+  margin: 30rpx 24rpx 0;
+  display: flex;
+  gap: 18rpx;
+}
+.footer-actions > view {
+  flex: 1;
+  height: 88rpx;
+  font-size: 29rpx;
+}
+</style>

+ 296 - 0
src/pages/manage/transfer-list.vue

@@ -0,0 +1,296 @@
+<template>
+  <view class="page-no-tab">
+    <view class="tabs">
+      <view
+        v-for="item in TRANSFER_STATUS_FILTERS"
+        :key="item.value"
+        class="tab press"
+        :class="{ active: active === item.value }"
+        @click="active = item.value"
+      >{{ item.label }}</view>
+    </view>
+
+    <view class="summary">
+      <text>共 {{ records.length }} 条调岗申请</text>
+      <text class="muted">{{ summaryTip }}</text>
+    </view>
+
+    <view v-if="loading" class="empty">加载中…</view>
+
+    <view v-else-if="list.length" class="list">
+      <view
+        v-for="item in list"
+        :key="item.id"
+        class="transfer-card card press"
+        @click="openDetail(item)"
+      >
+        <view class="card-head">
+          <view class="card-icon" :style="{ color: meta.color, background: meta.color + '18' }">岗</view>
+          <view class="card-title">
+            <text>{{ item.changeTypeLabel }}</text>
+            <text>{{ item.changeNo }} · {{ item.applyDate || '—' }}</text>
+          </view>
+          <text class="status-tag" :class="item.statusClass">{{ item.statusLabel }}</text>
+        </view>
+
+        <view class="card-route">
+          <view class="route-side">
+            <text class="route-label">调整前</text>
+            <text class="route-name">{{ item.position || '—' }}</text>
+            <text class="route-dept">{{ item.dept || '—' }}</text>
+          </view>
+          <text class="route-arrow">→</text>
+          <view class="route-side route-side-to">
+            <text class="route-label">调整后</text>
+            <text class="route-name">{{ item.targetPosition || '—' }}</text>
+            <text class="route-dept">{{ item.targetDept || item.dept || '—' }}</text>
+          </view>
+        </view>
+
+        <view class="card-meta">
+          <view><text>生效日期</text><text>{{ item.effectiveDate || '—' }}</text></view>
+          <view><text>当前节点</text><text>{{ item.approvalNode || item.statusLabel }}</text></view>
+        </view>
+
+        <view v-if="item.progressTip" class="card-tip">{{ item.progressTip }}</view>
+      </view>
+    </view>
+
+    <view v-else class="empty">
+      <view>□</view>
+      <text>{{ active === 'all' ? '暂无调岗申请' : '该状态下暂无调岗申请' }}</text>
+    </view>
+
+    <view class="bottom-action">
+      <view class="primary-btn press" @click="onCreate">发起调岗申请</view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+import { computed, ref } from 'vue'
+import { onShow } from '@dcloudio/uni-app'
+import { useAuthGuard } from '@/hooks/useAuthGuard'
+import { applicationTypes } from '@/data/mock'
+import { go } from '@/utils/router'
+import { getServerConfig } from '@/utils/auth'
+import { TRANSFER_STATUS_FILTERS, loadTransferList, matchDisplayStatus } from '@/api/userChange'
+
+useAuthGuard()
+const meta = applicationTypes.transfer || { title: '调岗申请', color: '#9270ca' }
+const active = ref('all')
+const records = ref([])
+const loading = ref(false)
+
+const list = computed(() => records.value.filter((row) => matchDisplayStatus(row, active.value)))
+
+const summaryTip = computed(() =>
+  getServerConfig().mode === 'server' ? '数据来自人事异动接口' : '演示数据保存在本机',
+)
+
+async function loadList() {
+  loading.value = true
+  try {
+    const page = await loadTransferList({ scope: 'mine' })
+    records.value = page.list || []
+  } catch (error) {
+    records.value = []
+    uni.showToast({ title: error.message || '调岗列表加载失败', icon: 'none' })
+  } finally {
+    loading.value = false
+  }
+}
+
+function openDetail(item) {
+  if (!item?.id) return
+  go(`/pages/manage/transfer-detail?id=${item.id}`)
+}
+
+function onCreate() {
+  go('/pages/manage/transfer-create')
+}
+
+onShow(loadList)
+</script>
+
+<style scoped>
+.page-no-tab {
+  padding-bottom: 150rpx;
+}
+.tabs {
+  display: flex;
+  background: #fff;
+  border-bottom: 1rpx solid #edf0f2;
+}
+.tab {
+  position: relative;
+  flex: 1;
+  height: 92rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #6f7782;
+  font-size: 26rpx;
+}
+.tab.active {
+  color: #1677ff;
+  font-weight: 500;
+}
+.tab.active:after {
+  content: '';
+  position: absolute;
+  bottom: 0;
+  width: 40rpx;
+  height: 5rpx;
+  border-radius: 3rpx;
+  background: #1677ff;
+}
+.summary {
+  padding: 22rpx 28rpx 6rpx;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 24rpx;
+}
+.summary + .summary {
+  padding-top: 0;
+}
+.list {
+  padding: 18rpx 24rpx;
+}
+.transfer-card {
+  margin-bottom: 20rpx;
+  padding: 26rpx;
+}
+.card-head {
+  display: flex;
+  align-items: center;
+}
+.card-icon {
+  width: 68rpx;
+  height: 68rpx;
+  border-radius: 15rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 28rpx;
+  font-weight: 600;
+  flex-shrink: 0;
+}
+.card-title {
+  flex: 1;
+  min-width: 0;
+  margin-left: 18rpx;
+}
+.card-title text {
+  display: block;
+  font-size: 29rpx;
+  font-weight: 600;
+}
+.card-title text + text {
+  margin-top: 8rpx;
+  color: #a0a7b0;
+  font-size: 21rpx;
+  font-weight: 400;
+}
+.card-route {
+  margin: 22rpx 0 6rpx;
+  padding: 20rpx 22rpx;
+  border-radius: 12rpx;
+  background: #f7f8fa;
+  display: flex;
+  align-items: center;
+}
+.route-side {
+  flex: 1;
+  min-width: 0;
+}
+.route-side-to {
+  text-align: right;
+}
+.route-label {
+  display: block;
+  color: #a0a7b0;
+  font-size: 20rpx;
+}
+.route-name {
+  display: block;
+  margin-top: 6rpx;
+  color: #1f2329;
+  font-size: 27rpx;
+  font-weight: 500;
+}
+.route-dept {
+  display: block;
+  margin-top: 4rpx;
+  color: #8b939f;
+  font-size: 21rpx;
+}
+.route-arrow {
+  margin: 0 16rpx;
+  color: #1677ff;
+  font-size: 30rpx;
+  flex-shrink: 0;
+}
+.card-meta {
+  display: flex;
+  gap: 16rpx;
+  margin-top: 18rpx;
+}
+.card-meta view {
+  flex: 1;
+  padding: 16rpx 18rpx;
+  border-radius: 12rpx;
+  background: #fafbfc;
+}
+.card-meta text {
+  display: block;
+  font-size: 22rpx;
+}
+.card-meta text + text {
+  margin-top: 8rpx;
+  color: #1f2329;
+  font-size: 26rpx;
+  font-weight: 500;
+}
+.card-tip {
+  margin-top: 16rpx;
+  color: #8b939f;
+  font-size: 22rpx;
+}
+.empty {
+  padding-top: 180rpx;
+  text-align: center;
+  color: #a0a7b0;
+}
+.empty view {
+  font-size: 80rpx;
+  color: #cfd4da;
+}
+.empty text {
+  display: block;
+  margin-top: 20rpx;
+}
+.bottom-action {
+  position: fixed;
+  z-index: 10;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  height: 120rpx;
+  padding: 16rpx 24rpx;
+  background: #fff;
+  border-top: 1rpx solid #e8e9eb;
+}
+.bottom-action .primary-btn {
+  height: 84rpx;
+  font-size: 30rpx;
+}
+@media (min-width: 800px) {
+  .bottom-action {
+    max-width: 520px;
+    left: 50%;
+    transform: translateX(-50%);
+  }
+}
+</style>

+ 67 - 1
src/utils/storage.js

@@ -13,6 +13,9 @@ const RESIGN_KEY = 'aimill_hr_resignations'
 const RESIGN_CREATED_KEY = 'aimill_hr_resign_created'
 const RESIGN_HANDOVER_KEY = 'aimill_hr_resign_handovers'
 const RESIGN_ID_KEY = 'aimill_hr_resign_ids'
+const USER_CHANGE_KEY = 'aimill_hr_user_changes'
+const USER_CHANGE_CREATED_KEY = 'aimill_hr_user_change_created'
+const USER_CHANGE_ID_KEY = 'aimill_hr_user_change_ids'
 const AUTH_KEY = 'aimill_hr_auth'
 const REMEMBER_KEY = 'aimill_hr_remembered_account'
 const PASSWORD_MEMO_KEY = 'passwordMemo'
@@ -188,7 +191,7 @@ export function getAuthCredentials() {
 }
 
 export function clearDemoStorage() {
-  [APPLY_KEY, DRAFT_KEY, APPROVAL_KEY, TRAINING_KEY, PROFILE_KEY, ONBOARD_MATERIALS_KEY, ONBOARD_LIST_KEY, ONBOARD_INVITATION_KEY, ONBOARD_DRAFT_KEY, RESIGN_KEY, RESIGN_CREATED_KEY, RESIGN_HANDOVER_KEY, RESIGN_ID_KEY].forEach((key) =>
+  [APPLY_KEY, DRAFT_KEY, APPROVAL_KEY, TRAINING_KEY, PROFILE_KEY, ONBOARD_MATERIALS_KEY, ONBOARD_LIST_KEY, ONBOARD_INVITATION_KEY, ONBOARD_DRAFT_KEY, RESIGN_KEY, RESIGN_CREATED_KEY, RESIGN_HANDOVER_KEY, RESIGN_ID_KEY, USER_CHANGE_KEY, USER_CHANGE_CREATED_KEY, USER_CHANGE_ID_KEY].forEach((key) =>
     uni.removeStorageSync(key),
   )
 }
@@ -615,6 +618,69 @@ export function updateHandoverItemState(handoverId, itemId, patch = {}) {
   return saveHandover({ ...handover, items })
 }
 
+// ============ 人事异动(调岗)演示态 ============
+// 与离职流程同构:服务器模式下列表 / 详情 / 操作都走接口,这里只兜底演示模式:
+//   - 调岗申请提交后在本地落一条异动单,使列表与详情能串起来
+//   - 记录本机状态覆盖(撤回 / 取消等)与服务端返回过的异动单主键
+
+export function getCreatedUserChanges() {
+  const saved = uni.getStorageSync(USER_CHANGE_CREATED_KEY)
+  return Array.isArray(saved) ? saved : []
+}
+
+export function addCreatedUserChange(record) {
+  if (!record || !record.id) return getCreatedUserChanges()
+  const id = String(record.id)
+  const rest = getCreatedUserChanges().filter((item) => String(item.id) !== id)
+  const list = [{ ...record, createdAt: record.createdAt || formatNow() }, ...rest]
+  uni.setStorageSync(USER_CHANGE_CREATED_KEY, list)
+  return list
+}
+
+export function updateCreatedUserChange(id, patch = {}) {
+  if (!id) return getCreatedUserChanges()
+  const list = getCreatedUserChanges().map((item) =>
+    String(item.id) === String(id) ? { ...item, ...patch, updatedAt: formatNow() } : item,
+  )
+  uni.setStorageSync(USER_CHANGE_CREATED_KEY, list)
+  return list
+}
+
+export function getUserChangeStates() {
+  const saved = uni.getStorageSync(USER_CHANGE_KEY)
+  return saved && typeof saved === 'object' ? saved : {}
+}
+
+export function getUserChangeLocalState(id) {
+  if (!id) return null
+  const all = getUserChangeStates()
+  const hit = all[String(id)]
+  return hit && typeof hit === 'object' ? hit : null
+}
+
+export function setUserChangeLocalState(id, patch) {
+  if (!id || !patch || typeof patch !== 'object') return null
+  const all = getUserChangeStates()
+  const key = String(id)
+  const prev = all[key] && typeof all[key] === 'object' ? all[key] : {}
+  all[key] = { ...prev, ...patch, updatedAt: formatNow() }
+  uni.setStorageSync(USER_CHANGE_KEY, all)
+  return all[key]
+}
+
+export function getUserChangeIds() {
+  const saved = uni.getStorageSync(USER_CHANGE_ID_KEY)
+  return Array.isArray(saved) ? saved.map(String).filter(Boolean) : []
+}
+
+export function rememberUserChangeId(id) {
+  const text = id == null ? '' : String(id).trim()
+  if (!text) return getUserChangeIds()
+  const next = [text, ...getUserChangeIds().filter((item) => item !== text)].slice(0, 200)
+  uni.setStorageSync(USER_CHANGE_ID_KEY, next)
+  return next
+}
+
 export function formatNow() {
   const d = new Date()
   const pad = (n) => String(n).padStart(2, '0')