Quellcode durchsuchen

feat(regularization): 员工转正模块接入 M02 接口,对齐离职 / 调岗封装

- 新增 src/api/regularization.js:转正单列表 / 详情 / 提交 / 撤回 + 状态映射
- 新增 src/pages/manage/regular-{list,create,detail}.vue:转正三页
- src/pages/manage/index.vue:type==='regular' 分支接入 regularization API
- src/pages.json:注册 regular 三条路由
- src/data/mock.js:新增 regularizationStatus 枚举 + 流程节点 + 8 条 probationEmployees + 6 条 regularizations + 模板,workModules 加「转正办理」入口
- src/utils/storage.js:新增 REGULAR_KEY / REGULAR_CREATED_KEY / REGULAR_ID_KEY 三个 key 及对应 helper
xieyong vor 6 Tagen
Ursprung
Commit
3697a367de

+ 1029 - 0
src/api/regularization.js

@@ -0,0 +1,1029 @@
+import { getApiBaseUrl, getServerConfig, request } from '@/utils/auth'
+import {
+  probationEmployees,
+  regularizationStatus,
+  regularizationStages,
+  regularizationTemplates,
+  regularizations,
+} from '@/data/mock'
+import {
+  addCreatedRegularization,
+  formatNow,
+  getCreatedRegularizations,
+  getCurrentUser,
+  getLoginUser,
+  getRegularizationIds,
+  getRegularizationStates,
+  loadRegularizationBillMap,
+  rememberRegularizationId,
+  removeRegularizationBillId,
+  saveRegularizationBillId,
+  setRegularizationLocalState,
+  updateCreatedRegularization,
+} 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.regularizationId ?? null
+  }
+  return result
+}
+
+function firstBigIntId(value) {
+  if (Array.isArray(value)) {
+    const hit = value.find((item) => item !== undefined && item !== null && item !== '')
+    return keepBigIntId(hit)
+  }
+  if (value && typeof value === 'object') {
+    return keepBigIntId(value.id ?? value.fileId ?? value.value)
+  }
+  return keepBigIntId(value)
+}
+
+// ============ 枚举(与 PC 端 src/api/hr/regularization.js 对齐)============
+
+/** 转正类型 */
+export const REGULARIZATION_TYPE_LABELS = {
+  NORMAL: '按期转正',
+  EARLY: '提前转正',
+  EXTENDED: '延期转正',
+  FAILED: '不予转正',
+}
+
+export const REGULARIZATION_TYPE_OPTIONS = Object.keys(
+  REGULARIZATION_TYPE_LABELS,
+).map((value) => ({ value, label: REGULARIZATION_TYPE_LABELS[value] }))
+
+/** 审批状态 */
+export const APPROVAL_STATUS_LABELS = {
+  DRAFT: '草稿',
+  PENDING: '审批中',
+  APPROVING: '审批中',
+  APPROVED: '已通过',
+  REJECTED: '已驳回',
+  WITHDRAWN: '已撤回',
+  CANCELLED: '已取消',
+}
+
+/** 生效状态 */
+export const EFFECTIVE_STATUS_LABELS = {
+  PENDING: '待生效',
+  SCHEDULED: '已排期',
+  EFFECTIVE: '已生效',
+  CANCELLED: '已取消',
+}
+
+/** 转正结果 */
+export const RESULT_LABELS = {
+  NORMAL: '按期转正',
+  EARLY: '提前转正',
+  EXTENDED: '延期',
+  FAILED: '不通过',
+}
+
+/** 页面展示态文案(标签 + 样式定义在 data/mock.js) */
+export const DISPLAY_STATUS_LABELS = {
+  pending_apply: '待申请',
+  draft: '草稿',
+  reviewing: '审批中',
+  done: '已转正',
+  rejected: '已驳回',
+  withdrawn: '已撤回',
+  cancelled: '已取消',
+}
+
+export const REGULARIZATION_DISPLAY_STATUS = regularizationStatus
+
+export const REGULARIZATION_STAGES = regularizationStages
+
+export const REGULARIZATION_STATUS_FILTERS = [
+  { value: 'all', label: '全部' },
+  { value: 'pending_apply', label: '待申请' },
+  { value: 'draft', label: '草稿' },
+  { value: 'reviewing', label: '审批中' },
+  { value: 'done', label: '已转正' },
+  { value: 'rejected', label: '已驳回' },
+  { value: 'withdrawn', label: '已撤回' },
+]
+
+// ============ 状态机 ============
+
+/**
+ * 列表 / 详情统一展示态
+ * pending_apply | draft | reviewing | done | rejected | withdrawn | cancelled
+ * 与 PC 端 deriveRegularizationDisplayStatus 完全一致
+ */
+export function deriveRegularizationDisplayStatus(raw = {}) {
+  if (!raw || (!raw.id && !raw.regularizationId && !raw.approvalStatus)) {
+    return 'pending_apply'
+  }
+  const approval = String(raw.approvalStatus || '').toUpperCase()
+  const effective = String(raw.effectiveStatus || '').toUpperCase()
+  const result = String(raw.result || '').toUpperCase()
+
+  if (approval === 'CANCELLED' || effective === 'CANCELLED') return 'cancelled'
+  if (approval === 'WITHDRAWN') return 'withdrawn'
+  if (approval === 'REJECTED') return 'rejected'
+  if (
+    effective === 'EFFECTIVE' ||
+    (approval === 'APPROVED' && (result === 'NORMAL' || result === 'EARLY' || !result))
+  ) {
+    return 'done'
+  }
+  if (approval === 'PENDING' || approval === 'APPROVING' || approval === 'APPROVED') {
+    return 'reviewing'
+  }
+  if (approval === 'DRAFT' || !approval) return 'draft'
+  return 'pending_apply'
+}
+
+export function canApplyRegularization(row = {}) {
+  return ['pending_apply', 'draft', 'rejected', 'withdrawn'].includes(row.status)
+}
+
+export function canEditRegularization(row = {}) {
+  return ['draft', 'rejected', 'withdrawn'].includes(row.status)
+}
+
+export function canSubmitRegularization(row = {}) {
+  if (!row?.regularizationId) return false
+  return ['draft', 'rejected', 'withdrawn'].includes(row.status)
+}
+
+export function canWithdrawRegularization(row = {}) {
+  return row?.status === 'reviewing' && Boolean(row.regularizationId)
+}
+
+export function canCancelRegularization(row = {}) {
+  if (!row?.regularizationId) return false
+  return ['draft', 'pending_apply', 'withdrawn', 'rejected'].includes(row.status)
+}
+
+export function matchDisplayStatus(row = {}, filter = '') {
+  if (!filter || filter === 'all') return true
+  return row.status === filter
+}
+
+function stageKeyOf(displayStatus) {
+  if (displayStatus === 'done') return 'effective'
+  if (displayStatus === 'reviewing') return 'approval'
+  return 'submit'
+}
+
+function progressOf(displayStatus) {
+  switch (displayStatus) {
+    case 'done':
+      return 100
+    case 'reviewing':
+      return 60
+    case 'rejected':
+      return 40
+    case 'withdrawn':
+    case 'cancelled':
+      return 30
+    case 'draft':
+      return 20
+    default:
+      return 10
+  }
+}
+
+function progressTipOf(displayStatus) {
+  switch (displayStatus) {
+    case 'pending_apply':
+      return '试用期员工尚未建立转正单,请发起转正申请。'
+    case 'draft':
+      return '转正申请为草稿,提交后进入审批流程。'
+    case 'reviewing':
+      return '转正审批中,请等待主管与 HR 的审批结果。'
+    case 'done':
+      return '转正审批已通过,员工状态按生效日期更新。'
+    case 'rejected':
+      return '审批已驳回,可修改后重新提交。'
+    case 'withdrawn':
+      return '本次转正申请已撤回。'
+    case 'cancelled':
+      return '本次转正申请已取消。'
+    default:
+      return ''
+  }
+}
+
+// ============ VO / 演示记录 → 页面形状 ============
+
+export function adaptRegularization(raw = {}, extras = {}) {
+  if (!raw || typeof raw !== 'object') return null
+  const displayStatus = deriveRegularizationDisplayStatus(raw)
+  const statusMeta =
+    REGULARIZATION_DISPLAY_STATUS[displayStatus] || REGULARIZATION_DISPLAY_STATUS.draft
+  const plannedRegularDate =
+    dateOnly(raw.plannedRegularDate) ||
+    dateOnly(extras.plannedRegularDate) ||
+    dateOnly(extras.plannedDate) ||
+    ''
+  const effectiveDate =
+    dateOnly(raw.effectiveDate) || dateOnly(extras.effectiveDate) || ''
+
+  return {
+    ...raw,
+    /** 业务单主键;列表行请用 userId,勿把 id 当员工主键 */
+    id: raw.id,
+    billId: raw.id,
+    regularizationId: raw.id ?? extras.regularizationId,
+    userId: raw.userId ?? extras.userId,
+    employeeNo: extras.employeeNo || raw.employeeNo || '',
+    name: extras.name || raw.name || raw.employeeName || extras.employeeName || '',
+    avatar: extras.avatar || raw.avatar || '',
+    phone: extras.phone || raw.phone || '',
+    company: extras.company || raw.groupName || raw.company || '',
+    department: extras.department || raw.deptName || raw.department || '',
+    position: extras.position || raw.positionName || raw.position || '',
+    level: extras.level || raw.level || '',
+    manager: extras.manager || raw.manager || '',
+    hireDate: dateOnly(extras.hireDate || raw.hireDate) || '',
+    plannedDate: plannedRegularDate,
+    plannedRegularDate,
+    regularDate: effectiveDate || plannedRegularDate,
+    effectiveDate,
+    regularizationType: raw.regularizationType || 'NORMAL',
+    regularizationTypeLabel: labelOf(
+      REGULARIZATION_TYPE_LABELS,
+      raw.regularizationType || 'NORMAL',
+    ),
+    extensionDays: raw.extensionDays,
+    extensionReason: raw.extensionReason || '',
+    newProbationEndDate: dateOnly(raw.newProbationEndDate) || '',
+    userSummary: raw.userSummary || '',
+    managerEvaluation: raw.managerEvaluation || '',
+    hrComment: raw.hrComment || '',
+    remark: raw.userSummary || extras.remark || '',
+    approvalStatus: raw.approvalStatus || '',
+    approvalStatusLabel: labelOf(APPROVAL_STATUS_LABELS, raw.approvalStatus, '未提交'),
+    approvalNode: raw.approvalNode || '',
+    approvalComment: raw.hrComment || raw.managerEvaluation || extras.approvalComment || '',
+    approvalInstanceId: raw.approvalInstanceId || '',
+    effectiveStatus: raw.effectiveStatus || '',
+    effectiveStatusLabel: labelOf(EFFECTIVE_STATUS_LABELS, raw.effectiveStatus, '-'),
+    result: raw.result || '',
+    resultLabel: labelOf(RESULT_LABELS, raw.result),
+    employmentPeriodId: raw.employmentPeriodId,
+    onlineDocumentId: raw.onlineDocumentId,
+    onlineDocumentTemplateId:
+      raw.onlineDocumentTemplateId || extras.onlineDocumentTemplateId,
+    approvalDocumentFileId: firstBigIntId(raw.approvalDocumentFileId),
+    online: Boolean(
+      raw.onlineDocumentId ||
+        raw.onlineDocumentTemplateId ||
+        extras.onlineDocumentTemplateId,
+    ),
+    status: displayStatus,
+    statusLabel: statusMeta.label,
+    statusClass: statusMeta.class,
+    stageKey: stageKeyOf(displayStatus),
+    progress: progressOf(displayStatus),
+    progressTip: progressTipOf(displayStatus),
+    applyDate: dateOnly(raw.applyDate || raw.createTime || raw.submitDate) || '',
+    createTime: raw.createTime || '',
+    source: extras.source || raw.source || '',
+    raw,
+  }
+}
+
+const EMPLOYEE_FIELDS = [
+  'id',
+  'employeeNo',
+  'name',
+  'avatar',
+  'phone',
+  'company',
+  'department',
+  'position',
+  'level',
+  'manager',
+  'hireDate',
+  'regularDate',
+  'groupId',
+  'deptId',
+]
+
+function pickEmployeeFields(employee = {}) {
+  return EMPLOYEE_FIELDS.reduce((result, key) => {
+    if (employee[key] !== undefined) result[key] = employee[key]
+    return result
+  }, {})
+}
+
+/** 试用期员工 + 可选转正单 → 列表行(与 PC 端 mergeEmployeeRegularization 对齐) */
+export function mergeEmployeeRegularization(employee = {}, bill = null) {
+  const base = {
+    ...pickEmployeeFields(employee),
+    userId: employee.id,
+    id: employee.id,
+    avatar: employee.avatar || String(employee.name || '').charAt(0),
+    company: employee.company || employee.legalEntity || '',
+    department: employee.department || employee.deptName || '',
+    position: employee.position || employee.postName || '',
+    hireDate: dateOnly(employee.hireDate),
+    plannedDate: dateOnly(employee.regularDate),
+    plannedRegularDate: dateOnly(employee.regularDate),
+    regularDate: dateOnly(employee.regularDate),
+  }
+  if (!bill) {
+    const statusMeta = REGULARIZATION_DISPLAY_STATUS.pending_apply
+    return {
+      ...base,
+      regularizationId: null,
+      status: 'pending_apply',
+      statusLabel: statusMeta.label,
+      statusClass: statusMeta.class,
+      approvalStatus: '',
+      approvalStatusLabel: '未提交',
+      approvalComment: '',
+      regularizationType: 'NORMAL',
+      regularizationTypeLabel: REGULARIZATION_TYPE_LABELS.NORMAL,
+      effectiveDate: '',
+      effectiveStatus: '',
+      effectiveStatusLabel: '-',
+      stageKey: 'submit',
+      progress: progressOf('pending_apply'),
+      progressTip: progressTipOf('pending_apply'),
+      online: true,
+      onlineDocumentTemplateId: null,
+      approvalDocumentFileId: null,
+      remark: '',
+      userSummary: '',
+      managerEvaluation: '',
+      hrComment: '',
+      extensionDays: undefined,
+      extensionReason: '',
+      newProbationEndDate: '',
+    }
+  }
+  return {
+    ...base,
+    ...adaptRegularization(bill, base),
+    id: employee.id,
+    userId: employee.id,
+    regularizationId: bill.id,
+  }
+}
+
+/** 表单字段 → /main/regularizations 请求参数(与 PC 端 buildRegularizationPayload 对齐) */
+export function buildRegularizationPayload(form = {}) {
+  const type = form.regularizationType || 'NORMAL'
+  return compact({
+    id: keepBigIntId(form.regularizationId || form.billId || form.id),
+    userId: keepBigIntId(form.userId),
+    employmentPeriodId: keepBigIntId(form.employmentPeriodId),
+    plannedRegularDate: dateOnly(
+      form.plannedRegularDate || form.plannedDate || form.regularDate,
+    ),
+    effectiveDate: dateOnly(form.effectiveDate || form.regularDate),
+    regularizationType: type,
+    extensionDays:
+      type === 'EXTENDED' ? Number(form.extensionDays || 0) || undefined : undefined,
+    extensionReason: type === 'EXTENDED' ? form.extensionReason : undefined,
+    newProbationEndDate:
+      type === 'EXTENDED' ? dateOnly(form.newProbationEndDate) : undefined,
+    userSummary: form.userSummary || form.remark,
+    managerEvaluation: form.managerEvaluation,
+    hrComment: form.hrComment,
+    approvalDocumentFileId: firstBigIntId(form.approvalDocumentFileId),
+    onlineDocumentTemplateId: form.online
+      ? keepBigIntId(form.onlineDocumentTemplateId || form.templateId)
+      : undefined,
+  })
+}
+
+// ============ 服务端接口(M02-员工转正)============
+
+/** 员工 VO → 页面员工形状(字段口径与 PC 端 adaptEmployee 一致) */
+export function adaptEmployeeBrief(raw = {}) {
+  const name = raw.name || raw.userName || ''
+  return {
+    id: raw.id,
+    employeeNo: raw.jobNumber || raw.employeeNo || '',
+    name,
+    avatar: raw.avatar || String(name).charAt(0),
+    phone: raw.phone || '',
+    company: raw.groupName || raw.company || '',
+    department: raw.deptName || raw.deptNames || '',
+    position: raw.postName || raw.position || '',
+    level: raw.positionLevelName || raw.level || '',
+    manager: raw.managerName || raw.manager || '',
+    hireDate: dateOnly(raw.joinDate || raw.hireDate || raw.entryDate),
+    regularDate: dateOnly(raw.positiveDate || raw.regularDate),
+    groupId: raw.groupId,
+    deptId: raw.deptId,
+  }
+}
+
+/** 试用期员工分页 GET /main/user/getUserPage */
+export async function getProbationUserPage(params = {}) {
+  const res = await request({
+    url: `${serverPath()}/main/user/getUserPage`,
+    method: 'GET',
+    data: compact({
+      status: 5,
+      isQueryLZ: 0,
+      pageNum: params.pageNum,
+      size: params.size,
+    }),
+  })
+  const data = res.data || {}
+  return {
+    list: asList(data),
+    count: Number(data?.count ?? data?.total ?? 0),
+  }
+}
+
+/** 分页拉全量试用期员工(有上限,避免一次性打爆接口) */
+export async function fetchProbationEmployees(options = {}) {
+  const pageSize = options.pageSize || 200
+  const maxPages = options.maxPages || 10
+  const list = []
+  let pageNum = 1
+  let total = Infinity
+  while (pageNum <= maxPages && list.length < total) {
+    const data = await getProbationUserPage({ pageNum, size: pageSize })
+    const chunk = Array.isArray(data?.list) ? data.list : []
+    total = Number(data?.count ?? list.length + chunk.length)
+    list.push(...chunk)
+    if (!chunk.length || chunk.length < pageSize) break
+    pageNum += 1
+  }
+  return { list: list.map(adaptEmployeeBrief), count: total === Infinity ? list.length : total }
+}
+
+const employeeCache = new Map()
+
+/** 员工简要信息 GET /main/user/getById/{userId}(详情页缺少员工字段时补齐) */
+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 brief = adaptEmployeeBrief(res.data || {})
+    employeeCache.set(key, brief)
+    return brief
+  } catch (error) {
+    employeeCache.set(key, null)
+    return null
+  }
+}
+
+/** 新建转正草稿,返回转正单 id POST /main/regularizations */
+export async function createRegularization(payload) {
+  const res = await request({
+    url: `${serverPath()}/main/regularizations`,
+    method: 'POST',
+    data: payload,
+  })
+  const id = extractId(res.data)
+  if (id != null) {
+    rememberRegularizationId(id)
+    if (payload?.userId != null) saveRegularizationBillId(payload.userId, id)
+  }
+  return id
+}
+
+/** 修改转正草稿 PUT /main/regularizations */
+export async function updateRegularization(payload) {
+  await request({
+    url: `${serverPath()}/main/regularizations`,
+    method: 'PUT',
+    data: payload,
+  })
+  if (payload?.id != null) {
+    rememberRegularizationId(payload.id)
+    if (payload?.userId != null) saveRegularizationBillId(payload.userId, payload.id)
+  }
+  return payload?.id
+}
+
+/** 查询转正单 GET /main/regularizations/{id} */
+export async function getRegularizationById(id, extras = {}) {
+  const res = await request({
+    url: `${serverPath()}/main/regularizations/${id}`,
+    method: 'GET',
+  })
+  const adapted = adaptRegularization(res.data, extras)
+  if (adapted?.userId != null && adapted.id != null) {
+    saveRegularizationBillId(adapted.userId, adapted.id)
+  }
+  return adapted
+}
+
+/** 提交转正审批 POST /main/regularizations/{id}/submit */
+export async function submitRegularization(id) {
+  const res = await request({
+    url: `${serverPath()}/main/regularizations/${id}/submit`,
+    method: 'POST',
+  })
+  return res.data
+}
+
+/** 撤回转正审批 POST /main/regularizations/{id}/withdraw */
+export async function withdrawRegularization(id, reason = '') {
+  const res = await request({
+    url: `${serverPath()}/main/regularizations/${id}/withdraw`,
+    method: 'POST',
+    data: compact({ reason }),
+  })
+  return res.data
+}
+
+/** 取消转正单 POST /main/regularizations/{id}/cancel */
+export async function cancelRegularization(id, reason = '') {
+  const res = await request({
+    url: `${serverPath()}/main/regularizations/${id}/cancel`,
+    method: 'POST',
+    data: compact({ reason }),
+  })
+  return res.data
+}
+
+/** 任职周期(计划转正日 / employmentPeriodId)GET /main/users/{userId}/employment-periods */
+export async function getEmploymentPeriods(userId) {
+  const res = await request({
+    url: `${serverPath()}/main/users/${userId}/employment-periods`,
+    method: 'GET',
+  })
+  return asList(res.data)
+}
+
+/** 从人事时间轴尝试找回转正单 id(与 PC 端策略一致) */
+export async function resolveRegularizationIdFromHistory(userId) {
+  const mapped = loadRegularizationBillMap()[String(userId)]
+  if (mapped) return mapped
+  try {
+    const res = await request({
+      url: `${serverPath()}/main/users/${userId}/history`,
+      method: 'GET',
+    })
+    const list = asList(res.data)
+    const hit = [...list].reverse().find((item) => {
+      const text = `${item.eventType || ''} ${item.sourceBusinessType || ''}`.toUpperCase()
+      return text.includes('REGULAR')
+    })
+    if (hit?.sourceBusinessId != null) {
+      saveRegularizationBillId(userId, hit.sourceBusinessId)
+      return hit.sourceBusinessId
+    }
+  } catch (error) {
+    // 时间轴不可用时不阻塞列表
+  }
+  return null
+}
+
+/** 启用态线上 Word 模板分页 GET /hr/online-documents/templates */
+export async function getRegularizationTemplatePage(params = {}) {
+  const res = await request({
+    url: `${serverPath()}/hr/online-documents/templates`,
+    method: 'GET',
+    data: compact({
+      pageNum: 1,
+      size: 200,
+      templateStatus: 'ENABLED',
+      ...params,
+    }),
+  })
+  const data = res.data || {}
+  return { list: asList(data), count: Number(data?.count ?? data?.total ?? 0) }
+}
+
+// ============ 演示模式本地实现 ============
+
+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 currentUserKeys() {
+  const user = getCurrentUser() || {}
+  const login = getLoginUser() || {}
+  return [user.id, user.name, login.userId, login.loginName, login.userName]
+    .filter((value) => value !== undefined && value !== null && value !== '')
+    .map(String)
+}
+
+function isMine(row = {}) {
+  const mine = currentUserKeys()
+  if (!mine.length) return true
+  return [row.userId, row.employeeNo, row.name]
+    .filter((value) => value !== undefined && value !== null && value !== '')
+    .map(String)
+    .some((value) => mine.includes(value))
+}
+
+function regularizationText(row = {}) {
+  return `${row.name}${row.employeeNo}${row.department}${row.position}${row.regularizationTypeLabel}`
+}
+
+function mergeDemoBills() {
+  const overrides = getRegularizationStates()
+  const created = getCreatedRegularizations()
+  const seen = new Set(created.map((item) => String(item.id)))
+  const base = [...created, ...regularizations.filter((item) => !seen.has(String(item.id)))]
+  return base.map((record) =>
+    adaptRegularization(
+      { ...record, ...(overrides[String(record.id)] || {}) },
+      { source: 'demo' },
+    ),
+  )
+}
+
+function mergeBillIntoRow(rows, bill) {
+  const index = rows.findIndex((row) => String(row.userId) === String(bill.userId))
+  if (index >= 0) {
+    rows[index] = mergeEmployeeRegularization(rows[index], bill)
+    return
+  }
+  rows.push({
+    ...bill,
+    id: bill.userId,
+    userId: bill.userId,
+    regularizationId: bill.regularizationId || bill.id,
+  })
+}
+
+/** 演示模式列表行:试用期员工 + 转正单合并(与 PC 端 loadList 口径一致) */
+export function buildDemoRegularizationRows() {
+  const bills = mergeDemoBills()
+  const billMap = new Map(bills.map((bill) => [String(bill.userId), bill]))
+  const rows = probationEmployees.map((employee) => {
+    const bill = billMap.get(String(employee.id)) || null
+    return mergeEmployeeRegularization(employee, bill)
+  })
+  bills.forEach((bill) => {
+    if (
+      !probationEmployees.some((employee) => String(employee.id) === String(bill.userId))
+    ) {
+      mergeBillIntoRow(rows, bill)
+    }
+  })
+  return rows
+}
+
+function persistDemoRegularization(id, form, submit) {
+  const type = form.regularizationType || 'NORMAL'
+  const record = {
+    id: String(id),
+    userId: form.userId,
+    name: form.name || '',
+    avatar: form.avatar || String(form.name || '').charAt(0),
+    employeeNo: form.employeeNo || '',
+    phone: form.phone || '',
+    company: form.company || '',
+    department: form.department || '',
+    position: form.position || '',
+    level: form.level || '',
+    manager: form.manager || '',
+    hireDate: dateOnly(form.hireDate),
+    regularizationType: type,
+    plannedRegularDate: dateOnly(
+      form.plannedRegularDate || form.plannedDate || form.regularDate,
+    ),
+    effectiveDate: dateOnly(form.effectiveDate),
+    extensionDays: type === 'EXTENDED' ? Number(form.extensionDays || 0) || undefined : undefined,
+    extensionReason: type === 'EXTENDED' ? form.extensionReason || '' : '',
+    newProbationEndDate:
+      type === 'EXTENDED' ? dateOnly(form.newProbationEndDate) : '',
+    userSummary: form.userSummary || '',
+    managerEvaluation: form.managerEvaluation || '',
+    hrComment: form.hrComment || '',
+    onlineDocumentTemplateId: form.onlineDocumentTemplateId || null,
+    approvalDocumentFileId: form.approvalDocumentFileId || null,
+    approvalStatus: submit ? 'PENDING' : 'DRAFT',
+    effectiveStatus: 'PENDING',
+    approvalNode: submit ? '主管审批' : '',
+    applyDate: dateOnly(formatNow()),
+    source: 'demo',
+    createdByApp: true,
+  }
+  addCreatedRegularization(record)
+  return record
+}
+
+function updateDemoRegularization(id, patch) {
+  setRegularizationLocalState(id, patch)
+  updateCreatedRegularization(id, patch)
+  return mergeDemoBills().find((item) => String(item.id) === String(id)) || null
+}
+
+// ============ 对外统一入口(页面只调用这些)============
+
+/**
+ * 转正办理列表。
+ * @param {object} params
+ *   - scope: 'all' 全部员工(HR 视图,默认);'mine' 只看本人
+ *   - status: REGULARIZATION_STATUS_FILTERS 的 value
+ *   - keyword: 姓名 / 工号 / 部门 / 岗位 模糊匹配
+ */
+export async function loadRegularizationList(params = {}) {
+  const { scope = 'all', status = 'all', keyword = '' } = params
+  const match = (rows) =>
+    rows
+      .filter((row) => (scope === 'mine' ? isMine(row) : true))
+      .filter((row) => matchDisplayStatus(row, status))
+      .filter((row) => (keyword ? regularizationText(row).includes(keyword) : true))
+
+  if (!isServerMode()) {
+    await delay(150)
+    const list = match(buildDemoRegularizationRows()).sort((a, b) =>
+      String(b.plannedRegularDate || b.applyDate).localeCompare(
+        String(a.plannedRegularDate || a.applyDate),
+      ),
+    )
+    return { list, count: list.length, source: 'demo' }
+  }
+
+  let rows = []
+  try {
+    const { list } = await fetchProbationEmployees()
+    const billMap = loadRegularizationBillMap()
+    rows = await Promise.all(
+      list.map(async (employee) => {
+        const billId = billMap[String(employee.id)]
+        if (!billId) return mergeEmployeeRegularization(employee, null)
+        try {
+          const bill = await getRegularizationById(billId, employee)
+          return mergeEmployeeRegularization(employee, bill)
+        } catch (error) {
+          removeRegularizationBillId(employee.id)
+          return mergeEmployeeRegularization(employee, null)
+        }
+      }),
+    )
+  } catch (error) {
+    rows = []
+  }
+
+  // 集合接口不可用时,按本机记住的转正单主键补拉(与 PC 端兜底策略一致)
+  const ids = getRegularizationIds()
+  const attached = new Set(
+    rows.filter((row) => row.regularizationId).map((row) => String(row.regularizationId)),
+  )
+  const missing = ids.filter((id) => !attached.has(String(id)))
+  if (missing.length) {
+    const bills = (
+      await Promise.all(
+        missing.map((id) => getRegularizationById(id).catch(() => null)),
+      )
+    ).filter(Boolean)
+    bills.forEach((bill) => mergeBillIntoRow(rows, bill))
+  }
+
+  return { list: match(rows), count: rows.length, source: 'api' }
+}
+
+/** 转正单详情;传列表行时优先用行内数据兜底 */
+export async function loadRegularizationDetail(id, fallbackRow = null) {
+  if (!id) return fallbackRow || null
+
+  if (!isServerMode()) {
+    const rows = buildDemoRegularizationRows()
+    return (
+      rows.find((row) => String(row.regularizationId) === String(id)) ||
+      rows.find((row) => String(row.userId) === String(id)) ||
+      null
+    )
+  }
+
+  const extras = fallbackRow
+    ? {
+        userId: fallbackRow.userId,
+        employeeNo: fallbackRow.employeeNo,
+        name: fallbackRow.name,
+        avatar: fallbackRow.avatar,
+        phone: fallbackRow.phone,
+        company: fallbackRow.company,
+        department: fallbackRow.department,
+        position: fallbackRow.position,
+        level: fallbackRow.level,
+        manager: fallbackRow.manager,
+        hireDate: fallbackRow.hireDate,
+      }
+    : {}
+  const bill = await getRegularizationById(id, extras)
+  if (!bill) return null
+  // 服务端单据只带 userId 时,回查员工档案补齐姓名 / 组织信息
+  if (!bill.name || !bill.department) {
+    const brief = await fetchEmployeeBrief(bill.userId)
+    if (brief) {
+      Object.assign(bill, {
+        name: bill.name || brief.name,
+        avatar: bill.avatar || brief.avatar,
+        employeeNo: bill.employeeNo || brief.employeeNo,
+        phone: bill.phone || brief.phone,
+        company: bill.company || brief.company,
+        department: bill.department || brief.department,
+        position: bill.position || brief.position,
+        level: bill.level || brief.level,
+        manager: bill.manager || brief.manager,
+        hireDate: bill.hireDate || brief.hireDate,
+      })
+    }
+  }
+  return fallbackRow ? { ...fallbackRow, ...bill, userId: bill.userId ?? fallbackRow.userId } : bill
+}
+
+/** 转正申请表单选项:试用期员工 + 启用态线上模板 */
+export async function loadRegularizationFormOptions() {
+  const demoResult = {
+    employees: probationEmployees.map((employee) => ({
+      ...adaptEmployeeBrief(employee),
+      id: employee.id,
+      name: employee.name,
+      regularDate: employee.regularDate,
+    })),
+    templates: regularizationTemplates.filter(
+      (item) => String(item.status || 'ENABLED').toUpperCase() === 'ENABLED',
+    ),
+  }
+
+  if (!isServerMode()) {
+    await delay(150)
+    return demoResult
+  }
+
+  const [employees, templates] = await Promise.all([
+    fetchProbationEmployees()
+      .then((result) => result.list)
+      .catch(() => []),
+    getRegularizationTemplatePage()
+      .then((result) => result.list)
+      .catch(() => []),
+  ])
+  return {
+    employees: employees.length ? employees : demoResult.employees,
+    templates: templates.length ? templates : demoResult.templates,
+  }
+}
+
+/** 某员工的任职周期,用于带出计划转正日与 employmentPeriodId */
+export async function loadEmploymentPeriod(userId) {
+  if (!isServerMode()) {
+    const employee = probationEmployees.find((item) => String(item.id) === String(userId))
+    if (!employee) return null
+    return {
+      id: null,
+      periodStatus: 'ACTIVE',
+      probationEndDate: employee.regularDate,
+      regularDate: employee.regularDate,
+    }
+  }
+  try {
+    const list = await getEmploymentPeriods(userId)
+    return (
+      list.find((item) => String(item.periodStatus || '').toUpperCase() === 'ACTIVE') ||
+      list[0] ||
+      null
+    )
+  } catch (error) {
+    return null
+  }
+}
+
+/**
+ * 提交(或存草稿)转正申请。
+ * 服务器模式:建单 POST /main/regularizations → 提交 POST /{id}/submit
+ * 演示模式:本地落一条转正单,提交时状态置为审批中
+ *
+ * @returns {Promise<string|number>} 转正单主键
+ */
+export async function submitRegularizationApplication({
+  form,
+  submit = true,
+  existingId = null,
+} = {}) {
+  if (!form) throw new Error('申请参数为空')
+  const id = existingId || form.regularizationId || form.id || null
+
+  if (!isServerMode()) {
+    await delay(300)
+    const localId = id || `ZZ${Date.now()}`
+    const record = persistDemoRegularization(localId, form, submit)
+    saveRegularizationBillId(record.userId, record.id)
+    rememberRegularizationId(record.id)
+    return record.id
+  }
+
+  const payload = buildRegularizationPayload({ ...form, id })
+  const billId = id || (await createRegularization(payload))
+  if (id) await updateRegularization({ ...payload, id: billId })
+  if (submit && billId != null) await submitRegularization(billId)
+  if (form?.userId != null && billId != null) saveRegularizationBillId(form.userId, billId)
+  return billId
+}
+
+// —— 流程动作:服务器模式走接口,演示模式改本地态 ——
+
+export async function actSubmitRegularization(row) {
+  if (!row?.regularizationId) throw new Error('缺少转正单主键')
+  if (isServerMode()) return submitRegularization(row.regularizationId)
+  return updateDemoRegularization(row.regularizationId, {
+    approvalStatus: 'PENDING',
+    approvalNode: '主管审批',
+  })
+}
+
+export async function actWithdrawRegularization(row, reason = '') {
+  if (!row?.regularizationId) throw new Error('缺少转正单主键')
+  if (isServerMode()) return withdrawRegularization(row.regularizationId, reason)
+  return updateDemoRegularization(row.regularizationId, {
+    approvalStatus: 'WITHDRAWN',
+    approvalComment: reason,
+    approvalNode: '',
+  })
+}
+
+export async function actCancelRegularization(row, reason = '') {
+  if (!row?.regularizationId) throw new Error('缺少转正单主键')
+  if (isServerMode()) return cancelRegularization(row.regularizationId, reason)
+  const result = updateDemoRegularization(row.regularizationId, {
+    approvalStatus: 'CANCELLED',
+    effectiveStatus: 'CANCELLED',
+    approvalComment: reason,
+    approvalNode: '',
+  })
+  removeRegularizationBillId(row.userId)
+  return result
+}
+
+export const currentRegularizationUserId = currentUserId
+
+/**
+ * 解析某员工当前的转正单主键:
+ * 优先本机缓存(与 PC 端 billMap 同源),演示模式回落到本地记录,服务器模式回落到人事时间轴。
+ */
+export async function resolveRegularizationId(userId) {
+  if (userId === undefined || userId === null || userId === '') return null
+  const mapped = loadRegularizationBillMap()[String(userId)]
+  if (mapped) return mapped
+  if (!isServerMode()) {
+    const hit = buildDemoRegularizationRows().find(
+      (row) => String(row.userId) === String(userId) && row.regularizationId,
+    )
+    return hit?.regularizationId || null
+  }
+  return resolveRegularizationIdFromHistory(userId)
+}

+ 238 - 0
src/data/mock.js

@@ -431,6 +431,7 @@ export const workModules = [
     { label: '合同管理', icon: '合', color: '#7262fd', url: '/pages/manage/index?type=contract' },
     { label: '员工入职', icon: '入', color: '#78d3f8', url: '/pages/manage/index?type=onboard' },
     { label: '人事异动', icon: '动', color: '#f6903d', url: '/pages/manage/index?type=transfer' },
+    { label: '转正办理', icon: '正', color: '#269a99', url: '/pages/manage/index?type=regular' },
     { label: '离职管理', icon: '离', color: '#ee6666', url: '/pages/manage/index?type=resign' },
     { label: '数据看板', icon: '数', color: '#5ad8a6', url: '/pages/manage/index?type=dashboard' },
   ] },
@@ -746,3 +747,240 @@ export const userChanges = [
     effectiveStatus: 'PENDING',
   },
 ]
+
+// ============ 员工转正(M02 转正单,与 PC 端 regularizationHandling 对齐)============
+// 页面展示态枚举(与 api/regularization.js 的 deriveRegularizationDisplayStatus 对应)
+export const regularizationStatus = {
+  pending_apply: { label: '待申请', class: 'status-pending' },
+  draft:         { label: '草稿',   class: 'status-pending' },
+  reviewing:     { label: '审批中', class: 'status-processing' },
+  done:          { label: '已转正', class: 'status-approved' },
+  rejected:      { label: '已驳回', class: 'status-rejected' },
+  withdrawn:     { label: '已撤回', class: 'status-rejected' },
+  cancelled:     { label: '已取消', class: 'status-rejected' },
+}
+
+// H5 精简流程节点:提交 → 审批 → 转正生效(PC 端审批由 Flowable 驱动)
+export const regularizationStages = [
+  { key: 'submit',    label: '提交申请',       desc: '填写转正类型、生效日期与试用期工作总结' },
+  { key: 'approval',  label: '主管 / HR 审批', desc: '直属上级评价、部门负责人与 HR 复核转正结果' },
+  { key: 'effective', label: '转正生效',       desc: '按生效日期更新员工状态与任职周期' },
+]
+
+// 试用期员工(列表行的员工维度,对应 PC 端 fetchAllUsers + adaptEmployee 的结果)
+export const probationEmployees = [
+  {
+    id: 'ZY0068',
+    employeeNo: 'ZY0068',
+    name: '王宇',
+    avatar: '王',
+    gender: '男',
+    company: '中盈产业集团有限公司',
+    department: '信息部',
+    position: '前端开发工程师',
+    level: 'P2',
+    hireDate: '2026-06-16',
+    regularDate: '2026-09-16',
+    phone: '177****4250',
+    manager: '赵文博',
+  },
+  {
+    id: 'ZY0072',
+    employeeNo: 'ZY0072',
+    name: '赵晓彤',
+    avatar: '赵',
+    gender: '女',
+    company: '中盈产业集团有限公司',
+    department: '财务部',
+    position: '成本会计',
+    level: 'P3',
+    hireDate: '2026-07-01',
+    regularDate: '2026-10-01',
+    phone: '151****7344',
+    manager: '唐静',
+  },
+  {
+    id: 'ZY0156',
+    employeeNo: 'ZY0156',
+    name: '杨帆',
+    avatar: '杨',
+    gender: '男',
+    company: '中盈产业集团有限公司',
+    department: '产品研发部',
+    position: '机械工程师',
+    level: 'P3',
+    hireDate: '2026-05-18',
+    regularDate: '2026-08-18',
+    phone: '139****6120',
+    manager: '刘志鹏',
+  },
+  {
+    id: 'ZY0149',
+    employeeNo: 'ZY0149',
+    name: '陈思雨',
+    avatar: '陈',
+    gender: '女',
+    company: '中盈产业集团有限公司',
+    department: '人力资源部',
+    position: '培训专员',
+    level: 'P2',
+    hireDate: '2026-04-28',
+    regularDate: '2026-07-28',
+    phone: '186****0918',
+    manager: '周颖',
+  },
+  {
+    id: 'ZY0162',
+    employeeNo: 'ZY0162',
+    name: '许晨',
+    avatar: '许',
+    gender: '男',
+    company: '中盈产业集团有限公司',
+    department: '质量部',
+    position: '质量工程师',
+    level: 'P3',
+    hireDate: '2026-05-06',
+    regularDate: '2026-08-06',
+    phone: '176****1830',
+    manager: '刘志鹏',
+  },
+  {
+    id: 'BJ0031',
+    employeeNo: 'BJ0031',
+    name: '郭明',
+    avatar: '郭',
+    gender: '男',
+    company: '北京天成伟达工矿设备有限公司',
+    department: '市场部',
+    position: '区域销售经理',
+    level: 'M1',
+    hireDate: '2026-06-01',
+    regularDate: '2026-09-01',
+    phone: '137****8276',
+    manager: '张伟',
+  },
+  {
+    id: 'YD0018',
+    employeeNo: 'YD0018',
+    name: '廖佳',
+    avatar: '廖',
+    gender: '女',
+    company: '湖南云盾消防设备有限公司',
+    department: '市场部',
+    position: '商务专员',
+    level: 'P2',
+    hireDate: '2026-05-20',
+    regularDate: '2026-08-20',
+    phone: '150****3159',
+    manager: '郭涛',
+  },
+  {
+    id: 'ZY0138',
+    employeeNo: 'ZY0138',
+    name: '彭越',
+    avatar: '彭',
+    gender: '男',
+    company: '中盈产业集团有限公司',
+    department: '生产一车间',
+    position: '设备工程师',
+    level: 'P3',
+    hireDate: '2026-03-12',
+    regularDate: '2026-06-12',
+    phone: '138****2271',
+    manager: '彭卫华',
+  },
+]
+
+// 转正单(列表行的单据维度,对应 /main/regularizations 返回体)
+export const regularizations = [
+  {
+    id: 'ZZ20260818',
+    userId: 'ZY0156',
+    regularizationType: 'NORMAL',
+    plannedRegularDate: '2026-08-18',
+    effectiveDate: '',
+    approvalStatus: 'PENDING',
+    effectiveStatus: 'PENDING',
+    approvalNode: '部门负责人审批',
+    userSummary: '试用期内独立完成装配线工装改造,按期完成全部培训与考核。',
+    managerEvaluation: '工作表现稳定,建议按期转正。',
+    hrComment: '',
+    approvalInstanceId: 'FLW-2026-0818-0156',
+  },
+  {
+    id: 'ZZ20260920',
+    userId: 'ZY0072',
+    regularizationType: 'NORMAL',
+    plannedRegularDate: '2026-10-01',
+    effectiveDate: '',
+    approvalStatus: 'DRAFT',
+    effectiveStatus: 'PENDING',
+    approvalNode: '',
+    userSummary: '成本核算基础工作已上手,拟按期提交转正。',
+    managerEvaluation: '',
+    hrComment: '',
+  },
+  {
+    id: 'ZZ20260728',
+    userId: 'ZY0149',
+    regularizationType: 'NORMAL',
+    plannedRegularDate: '2026-07-28',
+    effectiveDate: '2026-07-28',
+    approvalStatus: 'APPROVED',
+    effectiveStatus: 'EFFECTIVE',
+    result: 'NORMAL',
+    approvalNode: '',
+    userSummary: '完成新员工培训体系梳理,累计组织 6 场入职培训。',
+    managerEvaluation: '培训落地效果好,同意按期转正。',
+    hrComment: '同意转正。',
+    approvalInstanceId: 'FLW-2026-0728-0149',
+  },
+  {
+    id: 'ZZ20260806',
+    userId: 'ZY0162',
+    regularizationType: 'NORMAL',
+    plannedRegularDate: '2026-08-06',
+    effectiveDate: '',
+    approvalStatus: 'REJECTED',
+    effectiveStatus: 'PENDING',
+    approvalNode: '',
+    userSummary: '负责来料检验与异常跟踪。',
+    managerEvaluation: '',
+    hrComment: '工作总结内容不完整,请补充后重新提交。',
+  },
+  {
+    id: 'ZZ20260820',
+    userId: 'YD0018',
+    regularizationType: 'NORMAL',
+    plannedRegularDate: '2026-08-20',
+    effectiveDate: '',
+    approvalStatus: 'PENDING',
+    effectiveStatus: 'PENDING',
+    approvalNode: 'HR 复核',
+    userSummary: '完成区域客户续签 12 单,商务流程熟练。',
+    managerEvaluation: '部门审批已通过。',
+    hrComment: '',
+    approvalInstanceId: 'FLW-2026-0820-0018',
+  },
+  {
+    id: 'ZZ20260612',
+    userId: 'ZY0138',
+    regularizationType: 'EARLY',
+    plannedRegularDate: '2026-06-12',
+    effectiveDate: '2026-06-12',
+    approvalStatus: 'APPROVED',
+    effectiveStatus: 'EFFECTIVE',
+    result: 'EARLY',
+    approvalNode: '',
+    userSummary: '提前独立完成 3 号线设备调试,申请提前转正。',
+    managerEvaluation: '技术水平超出试用期要求。',
+    hrComment: '同意提前转正。',
+    approvalInstanceId: 'FLW-2026-0612-0138',
+  },
+]
+
+// 启用态线上模板(对应 /hr/online-documents/templates,演示模式兜底)
+export const regularizationTemplates = [
+  { id: 'TPL-ZZ-001', templateName: '标准员工转正审批表', status: 'ENABLED' },
+  { id: 'TPL-ZZ-002', templateName: '管理干部转正评估表', status: 'ENABLED' },
+]

+ 4 - 1
src/pages.json

@@ -24,7 +24,10 @@
     { "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": "调岗详情" } }
+    { "path": "pages/manage/transfer-detail", "style": { "navigationBarTitleText": "调岗详情" } },
+    { "path": "pages/manage/regular-list", "style": { "navigationBarTitleText": "转正办理" } },
+    { "path": "pages/manage/regular-create", "style": { "navigationBarTitleText": "转正申请" } },
+    { "path": "pages/manage/regular-detail", "style": { "navigationBarTitleText": "转正详情" } }
   ],
   "globalStyle": {
     "navigationBarTextStyle": "white",

+ 79 - 2
src/pages/manage/index.vue

@@ -46,6 +46,13 @@
           ><text class="banner-sub">填写目标部门与岗位,提交后进入主管与 HR 审批流程</text></view
         ><text class="banner-arrow">›</text>
       </view>
+      <view v-if="type === 'regular'" class="create-banner regular-banner press" @click="onCreateRegular">
+        <view class="banner-icon">+</view>
+        <view class="banner-main"
+          ><text class="banner-title">发起转正申请</text
+          ><text class="banner-sub">选择试用期员工,填写转正类型与工作总结后提交审批</text></view
+        ><text class="banner-arrow">›</text>
+      </view>
       <view class="manage-list">
         <view
           v-for="item in filteredItems"
@@ -147,6 +154,11 @@ import {
   loadTransferList,
   matchDisplayStatus as matchTransferStatus,
 } from "@/api/userChange";
+import {
+  REGULARIZATION_STATUS_FILTERS,
+  loadRegularizationList,
+  matchDisplayStatus as matchRegularStatus,
+} from "@/api/regularization";
 import { go } from "@/utils/router";
 const type = ref("recruit");
 useAuthGuard();
@@ -154,6 +166,7 @@ const keyword = ref("");
 const statusFilter = ref("全部状态");
 const resignStatusValue = ref("all");
 const transferStatusValue = ref("all");
+const regularStatusValue = ref("all");
 
 // 入职记录原始数据(mock 或 API VO 经 adaptOnboardingRecord 归一后的形状)。
 // 服务器模式按 getOnboardingRecords 拉取;演示模式合并本地 mock + storage。
@@ -259,6 +272,41 @@ const transferItems = computed(() =>
 const pendingTransferCount = computed(
   () => transferRecords.value.filter((r) => r.displayStatus === "pending").length,
 );
+// 转正单:服务器模式走 M02 /main/regularizations,演示模式走 mock + 本地态(由 api/regularization 统一处理)
+const regularRecords = ref([]);
+async function loadRegularizationListData() {
+  try {
+    const page = await loadRegularizationList({ scope: "all" });
+    regularRecords.value = page.list || [];
+  } catch (e) {
+    console.warn("[manage/regular] 转正列表加载失败:", e?.message || e);
+    regularRecords.value = [];
+  }
+}
+const regularizationItems = computed(() =>
+  regularRecords.value
+    .filter((r) => matchRegularStatus(r, regularStatusValue.value))
+    .filter((r) =>
+      `${r.name}${r.employeeNo}${r.department}${r.position}`.includes(keyword.value),
+    )
+    .map((r) => ({
+      id: r.regularizationId,
+      userId: r.userId,
+      avatar: r.avatar || String(r.name || "").charAt(0),
+      name: r.name || "—",
+      sub: `${r.department || "—"} · ${r.position || "—"}`,
+      status: r.statusLabel,
+      statusClass: r.statusClass,
+      subType: "regular",
+      label1: "计划转正日期",
+      value1: r.plannedRegularDate || "—",
+      label2: "审核状态",
+      value2: r.approvalStatusLabel || "未提交",
+    })),
+);
+const pendingRegularCount = computed(
+  () => regularRecords.value.filter((r) => ["pending_apply", "draft"].includes(r.status)).length,
+);
 const configs = computed(() => ({
   recruit: {
     title: "招聘面试",
@@ -358,6 +406,15 @@ const configs = computed(() => ({
     gradient: "linear-gradient(145deg,#f6903d,#f8b26a)",
     items: transferItems.value,
   },
+  regular: {
+    title: "转正办理",
+    subtitle: "试用期员工转正申请与审批",
+    count: String(pendingRegularCount.value),
+    unit: "待办理",
+    search: "员工或岗位",
+    gradient: "linear-gradient(145deg,#269a99,#45b9b3)",
+    items: regularizationItems.value,
+  },
   resign: {
     title: "离职管理",
     subtitle: "离职审批、工作交接与离职证明",
@@ -370,8 +427,8 @@ const configs = computed(() => ({
 }));
 const config = computed(() => configs.value[type.value] || configs.value.recruit);
 const filteredItems = computed(() => {
-  // 离职 / 调岗列表已在各自 items 里按接口状态 + 关键词过滤,这里不再二次过滤
-  if (type.value === "resign" || type.value === "transfer") return config.value.items;
+  // 离职 / 调岗 / 转正列表已在各自 items 里按接口状态 + 关键词过滤,这里不再二次过滤
+  if (["resign", "transfer", "regular"].includes(type.value)) return config.value.items;
   return config.value.items.filter(
     (i) =>
       `${i.name}${i.sub}${i.value1}${i.value2}`.includes(keyword.value) &&
@@ -401,6 +458,7 @@ function syncType(o = {}) {
   statusFilter.value = "全部状态";
   resignStatusValue.value = "all";
   transferStatusValue.value = "all";
+  regularStatusValue.value = "all";
   uni.setNavigationBarTitle({
     title: type.value === "dashboard" ? "数据看板" : config.value.title,
   });
@@ -412,6 +470,7 @@ onShow(() => {
   loadOnboardList();
   loadResignList();
   loadTransferListData();
+  loadRegularizationListData();
 });
 function syncHash() {
   if (typeof location === "undefined") return;
@@ -440,6 +499,14 @@ function showDetail(item) {
     go(`/pages/manage/transfer-detail?id=${item.id}`);
     return;
   }
+  if (item.subType === "regular" && item.id) {
+    go(`/pages/manage/regular-detail?id=${item.id}`);
+    return;
+  }
+  if (item.subType === "regular" && item.userId) {
+    go(`/pages/manage/regular-create?userId=${item.userId}`);
+    return;
+  }
   uni.showModal({
     title: item.name,
     content: `${item.sub}\n${item.label1}:${item.value1}\n${item.label2}:${item.value2}\n\n当前为静态演示数据。`,
@@ -455,12 +522,17 @@ function onOpenHandover() {
 function onCreateTransfer() {
   go("/pages/manage/transfer-create");
 }
+function onCreateRegular() {
+  go("/pages/manage/regular-create");
+}
 function chooseStatus() {
   const options =
     type.value === "resign"
       ? RESIGN_STATUS_FILTERS
       : type.value === "transfer"
         ? TRANSFER_STATUS_FILTERS
+        : type.value === "regular"
+          ? REGULARIZATION_STATUS_FILTERS
         : ["全部状态", "待入职", "入职中", "已入职", "已逾期"].map((label) => ({
             value: label,
             label,
@@ -473,6 +545,7 @@ function chooseStatus() {
       statusFilter.value = picked.label;
       if (type.value === "resign") resignStatusValue.value = picked.value;
       else if (type.value === "transfer") transferStatusValue.value = picked.value;
+      else if (type.value === "regular") regularStatusValue.value = picked.value;
     },
   });
 }
@@ -575,6 +648,10 @@ function showWarning(w) {
   background: linear-gradient(135deg, #f6903d 0%, #f8b26a 100%);
   box-shadow: 0 6rpx 20rpx rgba(246, 144, 61, 0.18);
 }
+.regular-banner {
+  background: linear-gradient(135deg, #269a99 0%, #45b9b3 100%);
+  box-shadow: 0 6rpx 20rpx rgba(38, 154, 153, 0.18);
+}
 .banner-icon {
   width: 72rpx;
   height: 72rpx;

+ 661 - 0
src/pages/manage/regular-create.vue

@@ -0,0 +1,661 @@
+<template>
+  <view class="page-no-tab">
+    <view class="form-intro">
+      <ModuleIcon :icon="meta.icon" :color="meta.color" soft />
+      <view>
+        <text class="intro-title">{{ form.regularizationId ? '编辑转正申请' : 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>主管 / HR 审批</text></view>
+      <view class="step-line"></view>
+      <view class="step"><view>3</view><text>转正生效</text></view>
+    </view>
+
+    <view class="section-title"><text>员工信息</text></view>
+    <view class="form-card card section">
+      <view class="field press" @click="pickEmployee">
+        <text class="label required">申请员工</text>
+        <text :class="form.userId ? 'value' : 'placeholder'">{{ employeeText }}</text>
+        <text v-if="!lockedEmployee" class="arrow">›</text>
+      </view>
+      <view class="divider"></view>
+      <view class="field"><text class="label">手机号</text><text class="value">{{ form.phone || '—' }}</text></view>
+      <view class="divider"></view>
+      <view class="field"><text class="label">所属法人</text><text class="value">{{ form.company || '—' }}</text></view>
+      <view class="divider"></view>
+      <view class="field"><text class="label">所属部门</text><text class="value">{{ form.department || '—' }}</text></view>
+      <view class="divider"></view>
+      <view class="field"><text class="label">岗位</text><text class="value">{{ form.position || '—' }}</text></view>
+      <view class="divider"></view>
+      <view class="field"><text class="label">入职日期</text><text class="value">{{ form.hireDate || '—' }}</text></view>
+      <view class="divider"></view>
+      <view class="field"><text class="label">直属上级</text><text class="value">{{ form.manager || '—' }}</text></view>
+    </view>
+
+    <view class="section-title"><text>转正信息</text></view>
+    <view class="form-card card section">
+      <view class="field press" @click="chooseType">
+        <text class="label required">转正类型</text>
+        <text class="value">{{ typeLabel }}</text>
+        <text class="arrow">›</text>
+      </view>
+      <view class="divider"></view>
+      <view class="field press"><text class="label required">计划转正日期</text>
+        <picker mode="date" :value="form.plannedRegularDate" @change="onPlannedDateChange">
+          <text :class="form.plannedRegularDate ? 'value' : 'placeholder'">{{ form.plannedRegularDate || '请选择' }}</text>
+        </picker>
+        <text class="arrow">›</text>
+      </view>
+      <view class="divider"></view>
+      <view class="field press"><text class="label">实际生效日期</text>
+        <picker mode="date" :value="form.effectiveDate || form.plannedRegularDate" @change="onEffectiveDateChange">
+          <text :class="form.effectiveDate ? 'value' : 'placeholder'">{{ form.effectiveDate || '按计划日期生效' }}</text>
+        </picker>
+        <text class="arrow">›</text>
+      </view>
+      <view class="divider"></view>
+      <view class="field switch-field"><text class="label">线上签署</text>
+        <switch :checked="form.online" color="#1677ff" @change="onOnlineChange" />
+      </view>
+      <template v-if="form.online">
+        <view class="divider"></view>
+        <view class="field press" @click="chooseTemplate">
+          <text class="label required">转正申请模板</text>
+          <text :class="form.onlineDocumentTemplateId ? 'value' : 'placeholder'">{{ templateText }}</text>
+          <text class="arrow">›</text>
+        </view>
+      </template>
+    </view>
+
+    <template v-if="form.regularizationType === 'EXTENDED'">
+      <view class="section-title"><text>延期信息</text></view>
+      <view class="form-card card section">
+        <view class="field"><text class="label required">延期天数</text>
+          <input v-model="form.extensionDays" type="number" class="number-input" placeholder="请输入延期天数" />
+        </view>
+        <view class="divider"></view>
+        <view class="field press"><text class="label">延期后试用结束日</text>
+          <picker mode="date" :value="form.newProbationEndDate" @change="onProbationEndDateChange">
+            <text :class="form.newProbationEndDate ? 'value' : 'placeholder'">{{ form.newProbationEndDate || '请选择' }}</text>
+          </picker>
+          <text class="arrow">›</text>
+        </view>
+        <view class="divider"></view>
+        <view class="field textarea-field"><text class="label required">延期原因</text>
+          <textarea v-model="form.extensionReason" maxlength="200" placeholder="请说明延期原因" />
+          <text class="counter">{{ form.extensionReason.length }}/200</text>
+        </view>
+      </view>
+    </template>
+
+    <view class="section-title"><text>评估意见</text></view>
+    <view class="form-card card section">
+      <view class="field textarea-field"><text class="label">员工总结</text>
+        <textarea v-model="form.userSummary" maxlength="500" placeholder="试用期工作总结(选填)" />
+        <text class="counter">{{ form.userSummary.length }}/500</text>
+      </view>
+      <view class="divider"></view>
+      <view class="field textarea-field"><text class="label">上级评价</text>
+        <textarea v-model="form.managerEvaluation" maxlength="500" placeholder="直属上级评价(选填)" />
+        <text class="counter">{{ form.managerEvaluation.length }}/500</text>
+      </view>
+      <view class="divider"></view>
+      <view class="field textarea-field"><text class="label">HR 意见</text>
+        <textarea v-model="form.hrComment" maxlength="500" placeholder="HR 复核意见(选填)" />
+        <text class="counter">{{ form.hrComment.length }}/500</text>
+      </view>
+    </view>
+
+    <view class="section-title"><text>申请资料</text></view>
+    <view class="form-card card section">
+      <view class="field"><text class="label">附件</text>
+        <view class="upload press" @click="chooseImage"><text class="plus">+</text><text>拍照或上传</text></view>
+        <view v-if="attachmentSummary" class="file-name">{{ attachmentSummary }}</view>
+      </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 { applicationTypes } from '@/data/mock'
+import { uploadAttachment } from '@/api/file'
+import { getServerConfig } from '@/utils/auth'
+import { back } from '@/utils/router'
+import {
+  REGULARIZATION_TYPE_LABELS,
+  REGULARIZATION_TYPE_OPTIONS,
+  canEditRegularization,
+  loadEmploymentPeriod,
+  loadRegularizationDetail,
+  loadRegularizationFormOptions,
+  resolveRegularizationId,
+  submitRegularizationApplication,
+} from '@/api/regularization'
+
+useAuthGuard()
+const meta = applicationTypes.regular || { title: '转正申请', subtitle: '', icon: '正', color: '#269a99' }
+
+const form = reactive({
+  regularizationId: null,
+  userId: '',
+  name: '',
+  employeeNo: '',
+  avatar: '',
+  phone: '',
+  company: '',
+  department: '',
+  position: '',
+  level: '',
+  manager: '',
+  hireDate: '',
+  regularizationType: 'NORMAL',
+  plannedRegularDate: '',
+  effectiveDate: '',
+  online: true,
+  onlineDocumentTemplateId: null,
+  employmentPeriodId: null,
+  extensionDays: '',
+  extensionReason: '',
+  newProbationEndDate: '',
+  userSummary: '',
+  managerEvaluation: '',
+  hrComment: '',
+  attachments: [],
+})
+
+const employees = ref([])
+const templates = ref([])
+const submitting = ref(false)
+
+const lockedEmployee = computed(() => Boolean(form.regularizationId))
+const employeeText = computed(() =>
+  form.userId ? `${form.name || '—'}(${form.employeeNo || '-'})` : '请选择试用期员工',
+)
+const typeLabel = computed(() => REGULARIZATION_TYPE_LABELS[form.regularizationType] || '按期转正')
+const templateText = computed(() => {
+  const hit = templates.value.find(
+    (item) => String(item.id) === String(form.onlineDocumentTemplateId),
+  )
+  return hit ? hit.templateName || hit.name : '请选择已启用模板'
+})
+const attachmentSummary = computed(() =>
+  form.attachments.map((item) => item.name || '附件').join('、'),
+)
+const footerTip = computed(() =>
+  getServerConfig().mode === 'server'
+    ? '提交后由 BPM 流程驱动审批,审批通过后按生效日期更新员工状态与任职周期。'
+    : '演示模式:数据保存在本机,操作不会同步到服务器;接入服务器后走 M02 /main/regularizations。',
+)
+
+async function loadOptions() {
+  try {
+    const options = await loadRegularizationFormOptions()
+    employees.value = options.employees || []
+    templates.value = options.templates || []
+  } catch (error) {
+    uni.showToast({ title: error.message || '选项加载失败', icon: 'none' })
+  }
+}
+
+function pickEmployee() {
+  if (lockedEmployee.value) return
+  if (!employees.value.length) {
+    uni.showToast({ title: '暂无试用期员工', icon: 'none' })
+    return
+  }
+  uni.showActionSheet({
+    itemList: employees.value.map((item) => `${item.name}(${item.employeeNo || '-'})`),
+    success: (r) => {
+      const picked = employees.value[r.tapIndex]
+      if (picked) selectEmployee(picked)
+    },
+  })
+}
+
+async function selectEmployee(employee) {
+  Object.assign(form, {
+    userId: employee.id,
+    name: employee.name || '',
+    employeeNo: employee.employeeNo || '',
+    avatar: employee.avatar || String(employee.name || '').charAt(0),
+    phone: employee.phone || '',
+    company: employee.company || '',
+    department: employee.department || '',
+    position: employee.position || '',
+    level: employee.level || '',
+    manager: employee.manager || '',
+    hireDate: employee.hireDate || '',
+  })
+  if (!form.plannedRegularDate) {
+    form.plannedRegularDate = employee.regularDate || ''
+  }
+  // 任职周期带出计划转正日与 employmentPeriodId(与 PC 端 fillEmploymentPeriod 一致)
+  const period = await loadEmploymentPeriod(employee.id)
+  if (period) {
+    form.employmentPeriodId = period.id ?? null
+    if (!form.plannedRegularDate) {
+      form.plannedRegularDate = String(period.probationEndDate || period.regularDate || '').slice(0, 10)
+    }
+  }
+  // 该员工已有转正单时直接带出单据,避免重复建单
+  const billId = await resolveRegularizationId(employee.id)
+  if (billId) await hydrateBill(billId)
+}
+
+async function hydrateBill(billId) {
+  if (!billId) return
+  try {
+    const row = await loadRegularizationDetail(billId)
+    if (!row) return
+    Object.assign(form, {
+      regularizationId: row.regularizationId || row.id,
+      userId: row.userId || form.userId,
+      name: row.name || form.name,
+      employeeNo: row.employeeNo || form.employeeNo,
+      avatar: row.avatar || form.avatar,
+      phone: row.phone || form.phone,
+      company: row.company || form.company,
+      department: row.department || form.department,
+      position: row.position || form.position,
+      level: row.level || form.level,
+      manager: row.manager || form.manager,
+      hireDate: row.hireDate || form.hireDate,
+      regularizationType: row.regularizationType || 'NORMAL',
+      plannedRegularDate: row.plannedRegularDate || form.plannedRegularDate,
+      effectiveDate: row.effectiveDate || '',
+      online: row.online !== false,
+      onlineDocumentTemplateId: row.onlineDocumentTemplateId || null,
+      employmentPeriodId: row.employmentPeriodId ?? form.employmentPeriodId,
+      extensionDays: row.extensionDays ?? '',
+      extensionReason: row.extensionReason || '',
+      newProbationEndDate: row.newProbationEndDate || '',
+      userSummary: row.userSummary || '',
+      managerEvaluation: row.managerEvaluation || '',
+      hrComment: row.hrComment || '',
+    })
+    const fileId = row.approvalDocumentFileId
+    form.attachments = fileId
+      ? [{ id: String(fileId), name: '转正申请资料', status: 'success' }]
+      : []
+  } catch (error) {
+    uni.showToast({ title: error.message || '转正单加载失败', icon: 'none' })
+  }
+}
+
+function chooseType() {
+  const options = REGULARIZATION_TYPE_OPTIONS
+  uni.showActionSheet({
+    itemList: options.map((item) => item.label),
+    success: (r) => {
+      const picked = options[r.tapIndex]
+      if (picked) form.regularizationType = picked.value
+    },
+  })
+}
+
+function chooseTemplate() {
+  if (!templates.value.length) {
+    uni.showToast({ title: '暂无启用态模板', icon: 'none' })
+    return
+  }
+  uni.showActionSheet({
+    itemList: templates.value.map((item) => item.templateName || item.name || '未命名模板'),
+    success: (r) => {
+      const picked = templates.value[r.tapIndex]
+      if (picked) form.onlineDocumentTemplateId = picked.id
+    },
+  })
+}
+
+function onPlannedDateChange(e) {
+  form.plannedRegularDate = e.detail.value
+}
+
+function onEffectiveDateChange(e) {
+  form.effectiveDate = e.detail.value
+}
+
+function onProbationEndDateChange(e) {
+  form.newProbationEndDate = e.detail.value
+}
+
+function onOnlineChange(e) {
+  form.online = !!e.detail.value
+  if (!form.online) form.onlineDocumentTemplateId = null
+}
+
+function chooseImage() {
+  uni.chooseImage({
+    count: 9,
+    success: (r) => {
+      const paths = (r.tempFilePaths || []).filter(Boolean)
+      if (!paths.length) return
+      const items = paths.map((p) => ({ localPath: p, name: '上传中…', status: 'uploading' }))
+      form.attachments = [...form.attachments, ...items]
+      items.forEach((item) => {
+        uploadAttachment(item.localPath, 'hr_regularization')
+          .then((file) => {
+            item.status = 'success'
+            item.id = String(file?.id ?? '')
+            item.name = file?.name || '转正申请资料'
+          })
+          .catch((e) => {
+            item.status = 'failed'
+            item.name = '上传失败'
+            uni.showToast({ title: e?.message || '上传失败', icon: 'none' })
+          })
+      })
+    },
+  })
+}
+
+function validate() {
+  if (!form.userId) return '请选择申请员工'
+  if (!form.regularizationType) return '请选择转正类型'
+  if (!form.plannedRegularDate) return '请选择计划转正日期'
+  if (form.online && !form.onlineDocumentTemplateId) return '请选择已启用转正申请模板'
+  if (form.regularizationType === 'EXTENDED') {
+    if (!(Number(form.extensionDays) > 0)) return '请填写延期天数'
+    if (!form.extensionReason.trim()) return '请填写延期原因'
+  }
+  if (form.attachments.some((item) => item.status === 'uploading')) return '附件上传中,请稍候'
+  if (form.attachments.some((item) => item.status === 'failed')) return '存在上传失败的附件,请重试'
+  return ''
+}
+
+async function save(submit) {
+  if (submitting.value) return
+  const error = validate()
+  if (error) {
+    uni.showToast({ title: error, icon: 'none' })
+    return
+  }
+  submitting.value = true
+  uni.showLoading({ title: submit ? '提交中...' : '保存中...' })
+  try {
+    const fileIds = form.attachments
+      .filter((item) => item.status === 'success' && item.id)
+      .map((item) => item.id)
+    await submitRegularizationApplication({
+      form: {
+        ...form,
+        approvalDocumentFileId: fileIds[0] || null,
+      },
+      submit,
+      existingId: form.regularizationId,
+    })
+    uni.hideLoading()
+    uni.showModal({
+      title: submit ? '提交成功' : '已保存草稿',
+      content: submit
+        ? '转正申请已提交审批,可在「转正办理」列表中查看进度。'
+        : '草稿已保存,可在「转正办理」列表中继续提交或编辑。',
+      showCancel: false,
+      success: () => uni.redirectTo({ url: '/pages/manage/regular-list' }),
+    })
+  } catch (e) {
+    uni.hideLoading()
+    uni.showToast({ title: e.message || '操作失败', icon: 'none' })
+  } finally {
+    submitting.value = false
+  }
+}
+
+function onSaveDraft() {
+  save(false)
+}
+
+function onSubmit() {
+  save(true)
+}
+
+onLoad(async (options) => {
+  uni.setNavigationBarTitle({ title: '转正申请' })
+  await loadOptions()
+
+  const id = options?.id || ''
+  if (id) {
+    uni.showLoading({ title: '加载中...' })
+    try {
+      const row = await loadRegularizationDetail(id)
+      if (!row) throw new Error('未找到该转正单')
+      if (!canEditRegularization(row)) throw new Error('当前状态不可编辑')
+      await hydrateBill(row.regularizationId || row.id)
+    } catch (e) {
+      uni.hideLoading()
+      uni.showToast({ title: e.message || '加载失败', icon: 'none' })
+      setTimeout(() => back('/pages/manage/regular-list'), 900)
+      return
+    }
+    uni.hideLoading()
+    return
+  }
+
+  const userId = options?.userId || ''
+  if (userId) {
+    const employee =
+      employees.value.find((item) => String(item.id) === String(userId)) || null
+    if (employee) {
+      await selectEmployee(employee)
+    } else {
+      form.userId = userId
+      const billId = await resolveRegularizationId(userId)
+      if (billId) await hydrateBill(billId)
+    }
+  }
+})
+</script>
+
+<style scoped>
+.page-no-tab {
+  padding-bottom: 170rpx;
+}
+.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: 200rpx;
+  flex-shrink: 0;
+  font-size: 28rpx;
+}
+.required::before {
+  content: '*';
+  color: #ee4d4d;
+  margin-right: 6rpx;
+}
+.value {
+  flex: 1;
+  text-align: right;
+  color: #1f2329;
+  font-size: 27rpx;
+}
+.placeholder {
+  flex: 1;
+  text-align: right;
+  color: #b2b7be;
+  font-size: 27rpx;
+}
+.arrow {
+  margin-left: 13rpx;
+  color: #c4c8ce;
+  font-size: 38rpx;
+}
+.number-input {
+  flex: 1;
+  height: 68rpx;
+  text-align: right;
+  font-size: 27rpx;
+  color: #1f2329;
+}
+.switch-field {
+  justify-content: space-between;
+}
+.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;
+}
+.upload {
+  margin-left: auto;
+  padding: 16rpx 24rpx;
+  border-radius: 12rpx;
+  border: 1rpx dashed #c8d2de;
+  color: #1677ff;
+  font-size: 24rpx;
+  display: flex;
+  align-items: center;
+  gap: 8rpx;
+}
+.plus {
+  font-size: 30rpx;
+}
+.file-name {
+  margin-left: 20rpx;
+  color: #8b939f;
+  font-size: 22rpx;
+  flex: 1;
+  text-align: right;
+}
+.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>

+ 471 - 0
src/pages/manage/regular-detail.vue

@@ -0,0 +1,471 @@
+<template>
+  <view class="page-no-tab">
+    <view class="detail-head">
+      <view class="head-base">
+        <view class="avatar head-avatar">{{ regularization?.avatar || '—' }}</view>
+        <view class="head-main">
+          <text class="head-name">{{ regularization?.name || '—' }}</text>
+          <text class="head-sub">{{ regularization?.department || '—' }} · {{ regularization?.position || '—' }}</text>
+        </view>
+        <text class="status-tag head-status" :class="regularization?.statusClass">{{ regularization?.statusLabel }}</text>
+      </view>
+      <view class="head-meta">
+        <view><text>转正类型</text><text>{{ regularization?.regularizationTypeLabel || '—' }}</text></view>
+        <view><text>计划转正日期</text><text>{{ regularization?.plannedRegularDate || '—' }}</text></view>
+        <view><text>实际生效日期</text><text>{{ regularization?.effectiveDate || '—' }}</text></view>
+        <view><text>审核状态</text><text>{{ regularization?.approvalStatusLabel || '未提交' }}</text></view>
+      </view>
+    </view>
+
+    <view v-if="loading" class="loading card section">加载中…</view>
+    <view v-else-if="!regularization" class="loading card section">未找到该转正单</view>
+
+    <template v-else>
+      <view class="progress-card card section">
+        <view class="progress-head">
+          <text class="progress-title">转正进度</text>
+          <text class="progress-value">{{ regularization.progress }}%</text>
+        </view>
+        <view class="progress-bar">
+          <view class="progress-fill" :style="{ width: regularization.progress + '%' }"></view>
+        </view>
+        <text class="progress-tip">{{ regularization.progressTip }}</text>
+      </view>
+
+      <view class="section-title"><text>审批流程</text></view>
+      <view class="timeline card section">
+        <view
+          v-for="(stage, index) in regularizationStages"
+          :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 !== regularizationStages.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-row"><text>工号</text><text>{{ regularization.employeeNo || '—' }}</text></view>
+        <view class="info-row"><text>手机号</text><text>{{ regularization.phone || '—' }}</text></view>
+        <view class="info-row"><text>所属法人</text><text>{{ regularization.company || '—' }}</text></view>
+        <view class="info-row"><text>所属部门</text><text>{{ regularization.department || '—' }}</text></view>
+        <view class="info-row"><text>岗位</text><text>{{ regularization.position || '—' }}</text></view>
+        <view class="info-row"><text>入职日期</text><text>{{ regularization.hireDate || '—' }}</text></view>
+        <view class="info-row"><text>直属上级</text><text>{{ regularization.manager || '—' }}</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="regularization.statusClass">{{ regularization.statusLabel }}</text>
+        </view>
+        <view class="info-row"><text>转正单号</text><text>{{ regularization.regularizationId || '—' }}</text></view>
+        <view class="info-row"><text>转正类型</text><text>{{ regularization.regularizationTypeLabel }}</text></view>
+        <view class="info-row"><text>计划转正日期</text><text>{{ regularization.plannedRegularDate || '—' }}</text></view>
+        <view class="info-row"><text>实际生效日期</text><text>{{ regularization.effectiveDate || '—' }}</text></view>
+        <view class="info-row"><text>审批状态</text><text>{{ regularization.approvalStatusLabel }}</text></view>
+        <view class="info-row"><text>生效状态</text><text>{{ regularization.effectiveStatusLabel }}</text></view>
+        <template v-if="regularization.regularizationType === 'EXTENDED'">
+          <view class="info-row"><text>延期天数</text><text>{{ regularization.extensionDays || '—' }}</text></view>
+          <view class="info-row"><text>延期后试用结束日</text><text>{{ regularization.newProbationEndDate || '—' }}</text></view>
+          <view class="info-row"><text>延期原因</text><text>{{ regularization.extensionReason || '—' }}</text></view>
+        </template>
+        <view class="info-row"><text>员工总结</text><text>{{ regularization.userSummary || '—' }}</text></view>
+        <view class="info-row"><text>上级评价</text><text>{{ regularization.managerEvaluation || '—' }}</text></view>
+        <view class="info-row"><text>HR 意见</text><text>{{ regularization.hrComment || '—' }}</text></view>
+        <view v-if="regularization.approvalComment" class="info-row"><text>审批意见</text><text>{{ regularization.approvalComment }}</text></view>
+        <view v-if="regularization.approvalInstanceId" class="info-row"><text>流程实例</text><text>{{ regularization.approvalInstanceId }}</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 { regularizationStages } from '@/data/mock'
+import { go } from '@/utils/router'
+import { getServerConfig } from '@/utils/auth'
+import {
+  actCancelRegularization,
+  actSubmitRegularization,
+  actWithdrawRegularization,
+  canCancelRegularization,
+  canEditRegularization,
+  canSubmitRegularization,
+  canWithdrawRegularization,
+  loadRegularizationDetail,
+} from '@/api/regularization'
+
+useAuthGuard()
+const id = ref('')
+const regularization = 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 = regularization.value
+  if (!row) return 'todo'
+  const order = regularizationStages.map((stage) => stage.key)
+  const index = order.indexOf(key)
+  const current = order.indexOf(row.stageKey)
+  if (row.status === 'done') return 'done'
+  if (row.status === 'rejected') {
+    return index === 0 ? 'done' : index === 1 ? 'rejected' : 'todo'
+  }
+  if (row.status === 'withdrawn' || row.status === '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 = regularization.value
+  if (!row) return []
+  const list = []
+  if (canSubmitRegularization(row)) list.push({ key: 'submit', label: '提交审批', primary: true })
+  if (canEditRegularization(row)) list.push({ key: 'edit', label: '编辑', primary: false })
+  if (canWithdrawRegularization(row)) list.push({ key: 'withdraw', label: '撤回审批', primary: false })
+  else if (canCancelRegularization(row)) list.push({ key: 'cancel', label: '取消转正单', primary: false })
+  return list
+})
+
+const footerTip = computed(() => {
+  const row = regularization.value
+  if (!row) return ''
+  if (row.source === 'demo') {
+    return '演示模式:数据保存在本机,操作不会同步到服务器;接入服务器后走 M02 /main/regularizations。'
+  }
+  return '转正审批由 BPM 流程驱动,审批通过后按生效日期更新员工状态与任职周期。'
+})
+
+async function loadDetail() {
+  if (!id.value) return
+  loading.value = true
+  try {
+    regularization.value = await loadRegularizationDetail(id.value)
+  } catch (error) {
+    regularization.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()
+  } catch (error) {
+    uni.hideLoading()
+    uni.showToast({ title: error.message || `${title}失败`, icon: 'none' })
+  }
+}
+
+async function runAction(key) {
+  const row = regularization.value
+  if (!row) return
+
+  if (key === 'edit') {
+    go(`/pages/manage/regular-create?id=${row.regularizationId}`)
+  } else if (key === 'submit') {
+    const ok = await confirmAction('提交转正审批', '提交后将进入主管与 HR 审批流程,确认提交?')
+    if (!ok) return
+    await exec('提交审批', () => actSubmitRegularization(row))
+  } else if (key === 'withdraw') {
+    const reason = await promptReason('撤回转正审批', false)
+    if (reason === null) return
+    await exec('撤回审批', () => actWithdrawRegularization(row, reason))
+  } else if (key === 'cancel') {
+    const reason = await promptReason('取消转正单', true)
+    if (reason === null) return
+    await exec('取消转正单', () => actCancelRegularization(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, #269a99, #45b9b3);
+}
+.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;
+}
+.progress-card {
+  padding: 26rpx;
+}
+.progress-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.progress-title {
+  font-size: 30rpx;
+  font-weight: 600;
+}
+.progress-value {
+  color: #269a99;
+  font-size: 30rpx;
+  font-weight: 600;
+}
+.progress-bar {
+  height: 12rpx;
+  margin: 22rpx 0 18rpx;
+  border-radius: 6rpx;
+  background: #eef0f2;
+  overflow: hidden;
+}
+.progress-fill {
+  height: 100%;
+  border-radius: 6rpx;
+  background: linear-gradient(90deg, #45b9b3, #269a99);
+  transition: width 0.3s;
+}
+.progress-tip {
+  color: #8b939f;
+  font-size: 23rpx;
+  line-height: 34rpx;
+}
+.timeline {
+  padding: 12rpx 26rpx;
+}
+.timeline-row {
+  display: flex;
+  align-items: flex-start;
+  padding: 22rpx 0;
+}
+.timeline-node {
+  width: 40rpx;
+  flex-shrink: 0;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.node-dot {
+  width: 18rpx;
+  height: 18rpx;
+  margin-top: 10rpx;
+  border-radius: 50%;
+  background: #d8dce2;
+}
+.node-line {
+  flex: 1;
+  width: 2rpx;
+  min-height: 40rpx;
+  margin-top: 8rpx;
+  background: #edf0f2;
+}
+.is-done .node-dot {
+  background: #269a99;
+}
+.is-current .node-dot {
+  background: #1677ff;
+  box-shadow: 0 0 0 6rpx rgba(22, 119, 255, 0.14);
+}
+.timeline-main {
+  flex: 1;
+  min-width: 0;
+  margin: 0 18rpx;
+}
+.timeline-label {
+  display: block;
+  font-size: 28rpx;
+  font-weight: 500;
+}
+.timeline-desc {
+  display: block;
+  margin-top: 8rpx;
+  color: #a0a7b0;
+  font-size: 22rpx;
+  line-height: 32rpx;
+}
+.info-card {
+  padding: 6rpx 26rpx 20rpx;
+}
+.info-head {
+  padding: 22rpx 0;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.info-title {
+  font-size: 29rpx;
+  font-weight: 600;
+}
+.info-row {
+  display: flex;
+  align-items: flex-start;
+  padding: 16rpx 0;
+  border-top: 1rpx solid #f4f6f8;
+  font-size: 26rpx;
+}
+.info-row text:first-child {
+  width: 200rpx;
+  flex-shrink: 0;
+  color: #8b939f;
+}
+.info-row text:last-child {
+  flex: 1;
+  text-align: right;
+  line-height: 36rpx;
+}
+.footer-tip {
+  padding: 8rpx 34rpx 24rpx;
+  font-size: 22rpx;
+  line-height: 34rpx;
+}
+.footer-actions {
+  padding: 0 24rpx 40rpx;
+  display: flex;
+  gap: 18rpx;
+  flex-wrap: wrap;
+}
+.footer-actions .primary-btn {
+  flex: 1;
+  min-width: 200rpx;
+}
+.footer-actions .ghost-btn {
+  flex: 1;
+  min-width: 160rpx;
+}
+</style>

+ 354 - 0
src/pages/manage/regular-list.vue

@@ -0,0 +1,354 @@
+<template>
+  <view class="page-no-tab">
+    <view class="tabs">
+      <view
+        v-for="item in REGULARIZATION_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.userId"
+        class="regular-card card press"
+        @click="openItem(item)"
+      >
+        <view class="card-head">
+          <view class="card-icon" :style="{ color: meta.color, background: meta.color + '18' }">正</view>
+          <view class="card-title">
+            <text>{{ item.name || '—' }}<text class="muted small"> {{ item.employeeNo || '' }}</text></text>
+            <text>{{ item.department || '—' }} · {{ item.position || '—' }}</text>
+          </view>
+          <text class="status-tag" :class="item.statusClass">{{ item.statusLabel }}</text>
+        </view>
+
+        <view class="card-meta">
+          <view><text>计划转正日期</text><text>{{ item.plannedRegularDate || '—' }}</text></view>
+          <view><text>生效日期</text><text>{{ item.effectiveDate || '—' }}</text></view>
+        </view>
+
+        <view class="card-foot">
+          <text class="type-tag">{{ item.regularizationTypeLabel }}</text>
+          <text class="approval muted">{{ item.approvalComment || item.approvalStatusLabel || '未提交' }}</text>
+        </view>
+
+        <view class="card-actions">
+          <text class="link" @click.stop="openItem(item)">查看</text>
+          <text v-if="canApplyRegularization(item)" class="link" @click.stop="openCreate(item)">申请转正</text>
+          <text v-if="canWithdrawRegularization(item)" class="link" @click.stop="onWithdraw(item)">撤回</text>
+          <text v-if="canCancelRegularization(item)" class="link danger" @click.stop="onCancel(item)">取消</text>
+        </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 {
+  REGULARIZATION_STATUS_FILTERS,
+  actCancelRegularization,
+  actWithdrawRegularization,
+  canApplyRegularization,
+  canCancelRegularization,
+  canWithdrawRegularization,
+  loadRegularizationList,
+  matchDisplayStatus,
+} from '@/api/regularization'
+
+useAuthGuard()
+const meta = applicationTypes.regular || { title: '转正申请', color: '#269a99' }
+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'
+    ? '数据来自 /main/regularizations'
+    : '演示数据保存在本机',
+)
+
+async function loadList() {
+  loading.value = true
+  try {
+    const page = await loadRegularizationList({ scope: 'all' })
+    records.value = page.list || []
+  } catch (error) {
+    records.value = []
+    uni.showToast({ title: error.message || '转正列表加载失败', icon: 'none' })
+  } finally {
+    loading.value = false
+  }
+}
+
+function openCreate(item) {
+  const userId = item?.userId ? `?userId=${item.userId}` : ''
+  go(`/pages/manage/regular-create${userId}`)
+}
+
+function openItem(item) {
+  if (item?.regularizationId) {
+    go(`/pages/manage/regular-detail?id=${item.regularizationId}`)
+    return
+  }
+  if (item?.userId) {
+    openCreate(item)
+    return
+  }
+  uni.showToast({ title: '未找到该员工的转正单', icon: 'none' })
+}
+
+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 loadList()
+  } catch (error) {
+    uni.hideLoading()
+    uni.showToast({ title: error.message || `${title}失败`, icon: 'none' })
+  }
+}
+
+async function onWithdraw(item) {
+  const reason = await promptReason('撤回转正审批', false)
+  if (reason === null) return
+  await exec('撤回', () => actWithdrawRegularization(item, reason))
+}
+
+async function onCancel(item) {
+  const reason = await promptReason('取消转正单', true)
+  if (reason === null) return
+  await exec('取消', () => actCancelRegularization(item, reason))
+}
+
+onShow(loadList)
+</script>
+
+<style scoped>
+.page-no-tab {
+  padding-bottom: 150rpx;
+}
+.tabs {
+  display: flex;
+  overflow-x: auto;
+  background: #fff;
+  border-bottom: 1rpx solid #edf0f2;
+}
+.tab {
+  position: relative;
+  flex: 1;
+  min-width: 120rpx;
+  height: 92rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #6f7782;
+  font-size: 26rpx;
+  white-space: nowrap;
+}
+.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;
+}
+.list {
+  padding: 18rpx 24rpx;
+}
+.regular-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;
+}
+.small {
+  font-size: 21rpx;
+  font-weight: 400;
+}
+.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-foot {
+  margin-top: 16rpx;
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+}
+.type-tag {
+  padding: 6rpx 14rpx;
+  border-radius: 999rpx;
+  background: #eef6f5;
+  color: #269a99;
+  font-size: 22rpx;
+}
+.approval {
+  flex: 1;
+  min-width: 0;
+  font-size: 22rpx;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+.card-actions {
+  margin-top: 18rpx;
+  padding-top: 18rpx;
+  border-top: 1rpx solid #f2f3f5;
+  display: flex;
+  justify-content: flex-end;
+  gap: 30rpx;
+}
+.link {
+  color: #1677ff;
+  font-size: 25rpx;
+}
+.link.danger {
+  color: #ee4d4d;
+}
+.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>

+ 90 - 1
src/utils/storage.js

@@ -16,6 +16,10 @@ 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 REGULARIZATION_KEY = 'aimill_hr_regularizations'
+const REGULARIZATION_CREATED_KEY = 'aimill_hr_regularization_created'
+const REGULARIZATION_ID_KEY = 'aimill_hr_regularization_ids'
+const REGULARIZATION_BILL_MAP_KEY = 'aimill_hr_regularization_bill_map'
 const AUTH_KEY = 'aimill_hr_auth'
 const REMEMBER_KEY = 'aimill_hr_remembered_account'
 const PASSWORD_MEMO_KEY = 'passwordMemo'
@@ -191,7 +195,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, USER_CHANGE_KEY, USER_CHANGE_CREATED_KEY, USER_CHANGE_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, REGULARIZATION_KEY, REGULARIZATION_CREATED_KEY, REGULARIZATION_ID_KEY, REGULARIZATION_BILL_MAP_KEY].forEach((key) =>
     uni.removeStorageSync(key),
   )
 }
@@ -686,3 +690,88 @@ export function formatNow() {
   const pad = (n) => String(n).padStart(2, '0')
   return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
 }
+
+// ============ 员工转正(M02 转正单)演示态 ============
+// 与人事异动同构:服务器模式下列表 / 详情 / 流程动作都走 /main/regularizations,
+// 这里只兜底演示模式:
+//   - 转正申请提交后在本地落一条转正单,使列表与详情能串起来
+//   - 记录本机状态覆盖(撤回 / 取消等)与服务端返回过的转正单主键
+//   - billMap 对应 PC 端 localStorage 的「userId → 转正单 id」映射
+
+export function getCreatedRegularizations() {
+  const saved = uni.getStorageSync(REGULARIZATION_CREATED_KEY)
+  return Array.isArray(saved) ? saved : []
+}
+
+export function addCreatedRegularization(record) {
+  if (!record || !record.id) return getCreatedRegularizations()
+  const id = String(record.id)
+  const rest = getCreatedRegularizations().filter((item) => String(item.id) !== id)
+  const list = [{ ...record, createdAt: record.createdAt || formatNow() }, ...rest]
+  uni.setStorageSync(REGULARIZATION_CREATED_KEY, list)
+  return list
+}
+
+export function updateCreatedRegularization(id, patch = {}) {
+  if (!id) return getCreatedRegularizations()
+  const list = getCreatedRegularizations().map((item) =>
+    String(item.id) === String(id) ? { ...item, ...patch, updatedAt: formatNow() } : item,
+  )
+  uni.setStorageSync(REGULARIZATION_CREATED_KEY, list)
+  return list
+}
+
+export function getRegularizationStates() {
+  const saved = uni.getStorageSync(REGULARIZATION_KEY)
+  return saved && typeof saved === 'object' ? saved : {}
+}
+
+export function getRegularizationLocalState(id) {
+  if (!id) return null
+  const all = getRegularizationStates()
+  const hit = all[String(id)]
+  return hit && typeof hit === 'object' ? hit : null
+}
+
+export function setRegularizationLocalState(id, patch) {
+  if (!id || !patch || typeof patch !== 'object') return null
+  const all = getRegularizationStates()
+  const key = String(id)
+  const prev = all[key] && typeof all[key] === 'object' ? all[key] : {}
+  all[key] = { ...prev, ...patch, updatedAt: formatNow() }
+  uni.setStorageSync(REGULARIZATION_KEY, all)
+  return all[key]
+}
+
+export function getRegularizationIds() {
+  const saved = uni.getStorageSync(REGULARIZATION_ID_KEY)
+  return Array.isArray(saved) ? saved.map(String).filter(Boolean) : []
+}
+
+export function rememberRegularizationId(id) {
+  const text = id == null ? '' : String(id).trim()
+  if (!text) return getRegularizationIds()
+  const next = [text, ...getRegularizationIds().filter((item) => item !== text)].slice(0, 200)
+  uni.setStorageSync(REGULARIZATION_ID_KEY, next)
+  return next
+}
+
+export function loadRegularizationBillMap() {
+  const saved = uni.getStorageSync(REGULARIZATION_BILL_MAP_KEY)
+  return saved && typeof saved === 'object' ? saved : {}
+}
+
+export function saveRegularizationBillId(userId, billId) {
+  if (userId === undefined || userId === null || userId === '') return
+  if (billId === undefined || billId === null || billId === '') return
+  const map = loadRegularizationBillMap()
+  map[String(userId)] = billId
+  uni.setStorageSync(REGULARIZATION_BILL_MAP_KEY, map)
+}
+
+export function removeRegularizationBillId(userId) {
+  if (userId === undefined || userId === null) return
+  const map = loadRegularizationBillMap()
+  delete map[String(userId)]
+  uni.setStorageSync(REGULARIZATION_BILL_MAP_KEY, map)
+}