Browse Source

feat(regularization): 接入转正申请模板与 A4 预览,扩展线上文档能力

- 新增 src/api/onlineDocument.js:线上 Word 模板 API(/hr/online-documents/templates)
- 新增 src/components/A4Preview.vue + src/utils/a4-preview.js:A4 画布渲染组件与解析工具
- 新增 src/pages/manage/regular-template-{list,form}.vue:模板列表与编辑页
- src/api/regularization.js:扩展支持模板选择与变量渲染
- src/pages/manage/regular-{list,create,detail}.vue:列表 / 申请 / 详情接入模板能力
- src/pages.json:注册 regular-template-list / regular-template-form 两条路由
- src/data/mock.js:替换 regularizationTemplates,新增 onlineDocumentTemplate{Categories,Templates,Versions}
- src/utils/storage.js:新增 ONLINE_TEMPLATE_* 三个 storage key 及对应 helper
xieyong 1 month ago
parent
commit
3cb5164bc7

+ 400 - 0
src/api/onlineDocument.js

@@ -0,0 +1,400 @@
+import { getApiBaseUrl, getServerConfig, request } from '@/utils/auth'
+import {
+  onlineDocumentTemplateCategories,
+  onlineDocumentTemplateVersions,
+  onlineDocumentTemplates,
+} from '@/data/mock'
+import {
+  addCreatedOnlineTemplate,
+  addOnlineTemplateVersionRecord,
+  getCreatedOnlineTemplates,
+  getOnlineTemplateStates,
+  getOnlineTemplateVersionRecords,
+  setOnlineTemplateLocalState,
+  updateCreatedOnlineTemplate,
+} 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))
+}
+
+// ============ 枚举与工具 ============
+
+/** 模板状态(后端 templateStatus):DRAFT 草稿 / ENABLED 已启用 / DISABLED 已停用 */
+export const TEMPLATE_STATUS_LABELS = {
+  DRAFT: '草稿',
+  ENABLED: '已启用',
+  DISABLED: '已停用',
+}
+
+export const TEMPLATE_STATUS_FILTERS = [
+  { value: 'all', label: '全部' },
+  { value: 'ENABLED', label: '已启用' },
+  { value: 'DRAFT', label: '草稿' },
+  { value: 'DISABLED', label: '已停用' },
+]
+
+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
+  }, {})
+}
+
+/** int64 主键:后端以字符串下发,统一转字符串避免精度问题 */
+function keepId(value) {
+  if (value === undefined || value === null || value === '') return undefined
+  return String(value).trim()
+}
+
+function extractId(result) {
+  if (result == null) return null
+  if (typeof result === 'object') return result.id ?? result.templateId ?? null
+  return result
+}
+
+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 []
+}
+
+/** 后端「HR线上Word模板信息」→ 页面形状 */
+export function adaptOnlineTemplate(raw = {}) {
+  if (!raw || typeof raw !== 'object') return null
+  const status = String(raw.templateStatus || 'DRAFT').toUpperCase()
+  // status 是 0/1 启停位,templateStatus 是 DRAFT/ENABLED/DISABLED;以 status 为准
+  const enabled =
+    raw.status === undefined || raw.status === null
+      ? status === 'ENABLED'
+      : Number(raw.status) === 1
+  return {
+    ...raw,
+    id: raw.id ?? raw.templateId ?? '',
+    templateName: raw.templateName || '',
+    templateCode: raw.templateCode || '',
+    categoryId:
+      raw.categoryId === undefined || raw.categoryId === null ? '' : String(raw.categoryId),
+    templateStatus: status,
+    templateStatusLabel: TEMPLATE_STATUS_LABELS[status] || status,
+    versionStatus: raw.versionStatus || '',
+    currentVersionNo: raw.currentVersionNo ?? 1,
+    documentContent: raw.documentContent || '',
+    variablesJson: raw.variablesJson || '',
+    changeRemark: raw.changeRemark || '',
+    remark: raw.remark || '',
+    createTime: raw.createTime || '',
+    status: raw.status,
+    enabled,
+  }
+}
+
+/** 后端版本记录 → 页面形状 */
+export function adaptOnlineTemplateVersion(raw = {}, index = 0) {
+  return {
+    id: raw.id ?? raw.versionId ?? `v-${index}`,
+    versionNo: raw.currentVersionNo ?? raw.versionNo ?? index + 1,
+    versionStatus: raw.versionStatus || '',
+    changeRemark: raw.changeRemark || '',
+    createTime: raw.createTime || '',
+  }
+}
+
+// ============ 服务端接口(HR线上Word模板)============
+
+/** 模板分页 GET /hr/online-documents/templates */
+export async function getOnlineTemplatePage(params = {}) {
+  const res = await request({
+    url: `${serverPath()}/hr/online-documents/templates`,
+    method: 'GET',
+    data: compact({
+      pageNum: params.pageNum || 1,
+      size: params.size || 200,
+      templateName: params.templateName,
+      templateStatus: params.templateStatus,
+    }),
+  })
+  const data = res.data || {}
+  return {
+    list: asList(data).map(adaptOnlineTemplate).filter(Boolean),
+    count: Number(data?.count ?? data?.total ?? 0),
+  }
+}
+
+/** 模板详情 GET /hr/online-documents/templates/{id} */
+export async function getOnlineTemplateById(id) {
+  const res = await request({
+    url: `${serverPath()}/hr/online-documents/templates/${id}`,
+    method: 'GET',
+  })
+  return adaptOnlineTemplate(res.data || {})
+}
+
+/** 新增模板 POST /hr/online-documents/templates */
+export async function createOnlineTemplate(payload) {
+  const res = await request({
+    url: `${serverPath()}/hr/online-documents/templates`,
+    method: 'POST',
+    data: payload,
+  })
+  return extractId(res.data)
+}
+
+/** 修改模板草稿 PUT /hr/online-documents/templates/{id} */
+export async function updateOnlineTemplate(id, payload) {
+  await request({
+    url: `${serverPath()}/hr/online-documents/templates/${id}`,
+    method: 'PUT',
+    data: payload,
+  })
+  return id
+}
+
+/** 启用模板 POST /hr/online-documents/templates/{id}/enable */
+export async function enableOnlineTemplate(id) {
+  await request({
+    url: `${serverPath()}/hr/online-documents/templates/${id}/enable`,
+    method: 'POST',
+  })
+  return id
+}
+
+/** 停用模板 POST /hr/online-documents/templates/{id}/disable */
+export async function disableOnlineTemplate(id) {
+  await request({
+    url: `${serverPath()}/hr/online-documents/templates/${id}/disable`,
+    method: 'POST',
+  })
+  return id
+}
+
+/** 版本历史 GET /hr/online-documents/templates/{id}/versions */
+export async function getOnlineTemplateVersions(id) {
+  const res = await request({
+    url: `${serverPath()}/hr/online-documents/templates/${id}/versions`,
+    method: 'GET',
+  })
+  return asList(res.data).map(adaptOnlineTemplateVersion)
+}
+
+/** 创建新版本 POST /hr/online-documents/templates/{id}/versions */
+export async function createOnlineTemplateVersion(id, payload = {}) {
+  const res = await request({
+    url: `${serverPath()}/hr/online-documents/templates/${id}/versions`,
+    method: 'POST',
+    data: compact({
+      documentContent: payload.documentContent,
+      variablesJson: payload.variablesJson,
+      changeRemark: payload.changeRemark,
+    }),
+  })
+  return extractId(res.data)
+}
+
+/** 表单 → 后端「HR线上Word模板保存参数」 */
+export function buildOnlineTemplatePayload(form = {}) {
+  return compact({
+    id: keepId(form.id),
+    templateName: form.templateName,
+    templateCode: form.templateCode,
+    categoryId: keepId(form.categoryId),
+    deptId: keepId(form.deptId),
+    remark: form.remark,
+    documentContent: form.documentContent,
+    variablesJson: form.variablesJson,
+  })
+}
+
+// ============ 演示模式本地实现 ============
+
+function mergeDemoTemplates() {
+  const overrides = getOnlineTemplateStates()
+  const created = getCreatedOnlineTemplates()
+  const seen = new Set(created.map((item) => String(item.id)))
+  const base = [...created, ...onlineDocumentTemplates.filter((item) => !seen.has(String(item.id)))]
+  return base
+    .map((record) => adaptOnlineTemplate({ ...record, ...(overrides[String(record.id)] || {}) }))
+    .filter(Boolean)
+}
+
+function persistDemoTemplate(id, form) {
+  const record = {
+    id: String(id),
+    templateName: form.templateName || '',
+    templateCode: form.templateCode || '',
+    categoryId: form.categoryId ? String(form.categoryId) : '',
+    deptId: form.deptId ? String(form.deptId) : '',
+    remark: form.remark || '',
+    documentContent: form.documentContent || '',
+    variablesJson: form.variablesJson || '',
+    templateStatus: form.templateStatus || 'DRAFT',
+    versionStatus: 'RELEASED',
+    currentVersionNo: form.currentVersionNo || 1,
+    source: 'demo',
+  }
+  addCreatedOnlineTemplate(record)
+  return record
+}
+
+// ============ 对外统一入口(页面只调用这些)============
+
+/**
+ * 模板列表。
+ * @param {object} params
+ *   - templateName: 模板名称 / 编码模糊匹配
+ *   - templateStatus: TEMPLATE_STATUS_FILTERS 的 value
+ */
+export async function loadOnlineTemplateList(params = {}) {
+  const { templateName = '', templateStatus = '' } = params
+  const match = (rows) =>
+    rows
+      .filter(
+        (row) => !templateStatus || templateStatus === 'all' || row.templateStatus === templateStatus,
+      )
+      .filter((row) => {
+        if (!templateName) return true
+        const text = `${row.templateName}${row.templateCode}`.toLowerCase()
+        return text.includes(String(templateName).toLowerCase())
+      })
+      .sort((a, b) => String(b.createTime).localeCompare(String(a.createTime)))
+
+  if (!isServerMode()) {
+    await delay(150)
+    const list = match(mergeDemoTemplates())
+    return { list, count: list.length, source: 'demo' }
+  }
+  const page = await getOnlineTemplatePage({
+    templateName: templateName || undefined,
+    templateStatus: templateStatus && templateStatus !== 'all' ? templateStatus : undefined,
+  })
+  return { list: match(page.list), count: page.count, source: 'api' }
+}
+
+/** 模板详情;传列表行时优先用行内数据兜底 */
+export async function loadOnlineTemplateDetail(id, fallbackRow = null) {
+  if (!id) return fallbackRow || null
+  if (!isServerMode()) {
+    const hit = mergeDemoTemplates().find((row) => String(row.id) === String(id)) || null
+    return hit || fallbackRow || null
+  }
+  const row = await getOnlineTemplateById(id)
+  return row ? { ...(fallbackRow || {}), ...row } : fallbackRow || null
+}
+
+/**
+ * 保存模板:有 id 走 PUT 修改草稿,无 id 走 POST 新建。
+ * @returns {Promise<string|number>} 模板主键
+ */
+export async function saveOnlineTemplate({ form, existingId = null } = {}) {
+  if (!form) throw new Error('模板参数为空')
+  const id = existingId || form.id || null
+  if (!isServerMode()) {
+    await delay(300)
+    const localId = id || `TPL${Date.now()}`
+    return persistDemoTemplate(localId, { ...form, id: localId }).id
+  }
+  const payload = buildOnlineTemplatePayload({ ...form, id })
+  if (id) {
+    await updateOnlineTemplate(id, payload)
+    return id
+  }
+  return createOnlineTemplate(payload)
+}
+
+/** 启用 / 停用切换 */
+export async function toggleOnlineTemplate(row = {}) {
+  if (!row.id) throw new Error('缺少模板主键')
+  const nextEnabled = !row.enabled
+  if (!isServerMode()) {
+    await delay(200)
+    setOnlineTemplateLocalState(row.id, {
+      templateStatus: nextEnabled ? 'ENABLED' : 'DISABLED',
+    })
+    return nextEnabled
+  }
+  if (nextEnabled) await enableOnlineTemplate(row.id)
+  else await disableOnlineTemplate(row.id)
+  return nextEnabled
+}
+
+/** 版本历史 */
+export async function loadOnlineTemplateVersions(id) {
+  if (!id) return []
+  if (!isServerMode()) {
+    await delay(150)
+    const local = getOnlineTemplateVersionRecords(id)
+    if (local.length) return local
+    const preset = onlineDocumentTemplateVersions[String(id)]
+    if (Array.isArray(preset) && preset.length) return preset
+    const hit = mergeDemoTemplates().find((row) => String(row.id) === String(id))
+    return [
+      {
+        id: `${id}-v1`,
+        versionNo: hit?.currentVersionNo || 1,
+        versionStatus: hit?.versionStatus || 'RELEASED',
+        changeRemark: '初始版本',
+        createTime: hit?.createTime || '',
+      },
+    ]
+  }
+  return getOnlineTemplateVersions(id)
+}
+
+/** 创建新版本 */
+export async function publishOnlineTemplateVersion(id, payload = {}) {
+  if (!id) throw new Error('缺少模板主键')
+  if (!isServerMode()) {
+    await delay(250)
+    const preset = onlineDocumentTemplateVersions[String(id)] || []
+    const nextNo =
+      Math.max(getOnlineTemplateVersionRecords(id).length, preset.length, 0) + 1
+    addOnlineTemplateVersionRecord(id, {
+      id: `${id}-v${nextNo}`,
+      versionNo: nextNo,
+      versionStatus: 'RELEASED',
+      changeRemark: payload.changeRemark || '',
+    })
+    const current = mergeDemoTemplates().find((row) => String(row.id) === String(id))
+    updateCreatedOnlineTemplate(id, { currentVersionNo: nextNo })
+    setOnlineTemplateLocalState(id, {
+      currentVersionNo: nextNo,
+      // 与后端口径一致:发布新版本会把已启用模板置为停用,需重新启用
+      ...(current?.templateStatus === 'ENABLED' ? { templateStatus: 'DISABLED' } : {}),
+    })
+    return nextNo
+  }
+  return createOnlineTemplateVersion(id, payload)
+}
+
+/**
+ * 模板分类选项。
+ * 后端目前没有「模板分类」列表接口(保存参数里的 categoryId 为必填),
+ * 这里统一收口:演示模式用 mock 分类,服务器模式退化为「已有模板中出现过的分类」。
+ * 后续拿到真实分类接口后只需改这里。
+ */
+export async function loadOnlineTemplateCategories() {
+  if (!isServerMode()) return onlineDocumentTemplateCategories
+  try {
+    const page = await getOnlineTemplatePage({ size: 200 })
+    const ids = [
+      ...new Set(page.list.map((row) => row.categoryId).filter(Boolean).map(String)),
+    ]
+    return ids.map((id) => ({ id, categoryName: `分类 ${id}` }))
+  } catch (error) {
+    return []
+  }
+}

+ 100 - 48
src/api/regularization.js

@@ -3,7 +3,6 @@ import {
   probationEmployees,
   regularizationStatus,
   regularizationStages,
-  regularizationTemplates,
   regularizations,
 } from '@/data/mock'
 import {
@@ -21,6 +20,7 @@ import {
   setRegularizationLocalState,
   updateCreatedRegularization,
 } from '@/utils/storage'
+import { getOnlineTemplatePage, loadOnlineTemplateList } from './onlineDocument'
 
 // ============ 模式判断 ============
 function isServerMode() {
@@ -189,8 +189,25 @@ export function deriveRegularizationDisplayStatus(raw = {}) {
   return 'pending_apply'
 }
 
+/**
+ * 仍可发起 / 编辑 / 提交转正申请的展示态。
+ * 与 PC 端 regularizationHandling 的 selectableEmployees 口径一致:
+ * 审批中 / 已转正 / 已取消的员工不可再建单。
+ */
+export const REGULARIZATION_APPLICABLE_STATUS = [
+  'pending_apply',
+  'draft',
+  'rejected',
+  'withdrawn',
+]
+
+/** 该展示态是否还允许发起 / 编辑 / 提交转正申请 */
+export function isApplicableRegularizationStatus(status) {
+  return REGULARIZATION_APPLICABLE_STATUS.includes(status)
+}
+
 export function canApplyRegularization(row = {}) {
-  return ['pending_apply', 'draft', 'rejected', 'withdrawn'].includes(row.status)
+  return isApplicableRegularizationStatus(row.status)
 }
 
 export function canEditRegularization(row = {}) {
@@ -322,6 +339,9 @@ export function adaptRegularization(raw = {}, extras = {}) {
     onlineDocumentId: raw.onlineDocumentId,
     onlineDocumentTemplateId:
       raw.onlineDocumentTemplateId || extras.onlineDocumentTemplateId,
+    // 线上文档正文与变量值:仅透传,供后续更新时原样回写(PC 端 buildRegularizationPayload 同样透传)
+    onlineDocumentContent: raw.onlineDocumentContent || '',
+    onlineDocumentVariableValuesJson: raw.onlineDocumentVariableValuesJson || '',
     approvalDocumentFileId: firstBigIntId(raw.approvalDocumentFileId),
     online: Boolean(
       raw.onlineDocumentId ||
@@ -444,6 +464,8 @@ export function buildRegularizationPayload(form = {}) {
     onlineDocumentTemplateId: form.online
       ? keepBigIntId(form.onlineDocumentTemplateId || form.templateId)
       : undefined,
+    onlineDocumentContent: form.onlineDocumentContent,
+    onlineDocumentVariableValuesJson: form.onlineDocumentVariableValuesJson,
   })
 }
 
@@ -634,18 +656,7 @@ export async function resolveRegularizationIdFromHistory(userId) {
 
 /** 启用态线上 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) }
+  return getOnlineTemplatePage({ templateStatus: 'ENABLED', ...params })
 }
 
 // ============ 演示模式本地实现 ============
@@ -751,6 +762,8 @@ function persistDemoRegularization(id, form, submit) {
     managerEvaluation: form.managerEvaluation || '',
     hrComment: form.hrComment || '',
     onlineDocumentTemplateId: form.onlineDocumentTemplateId || null,
+    onlineDocumentContent: form.onlineDocumentContent || '',
+    onlineDocumentVariableValuesJson: form.onlineDocumentVariableValuesJson || '',
     approvalDocumentFileId: form.approvalDocumentFileId || null,
     approvalStatus: submit ? 'PENDING' : 'DRAFT',
     effectiveStatus: 'PENDING',
@@ -778,6 +791,24 @@ function updateDemoRegularization(id, patch) {
  *   - status: REGULARIZATION_STATUS_FILTERS 的 value
  *   - keyword: 姓名 / 工号 / 部门 / 岗位 模糊匹配
  */
+/** 试用期员工批量合并转正单(列表与表单选项共用) */
+async function mergeBillsIntoEmployees(list = []) {
+  const billMap = loadRegularizationBillMap()
+  return 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)
+      }
+    }),
+  )
+}
+
 export async function loadRegularizationList(params = {}) {
   const { scope = 'all', status = 'all', keyword = '' } = params
   const match = (rows) =>
@@ -799,20 +830,7 @@ export async function loadRegularizationList(params = {}) {
   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)
-        }
-      }),
-    )
+    rows = await mergeBillsIntoEmployees(list)
   } catch (error) {
     rows = []
   }
@@ -886,36 +904,66 @@ export async function loadRegularizationDetail(id, fallbackRow = null) {
   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',
-    ),
-  }
+/**
+ * 合并行 → 申请表单的员工选项。
+ * 只保留还能发起 / 编辑转正申请的状态,避免对「审批中 / 已转正 / 已取消」的单据重复建单
+ * (与 PC 端 selectableEmployees 一致)。
+ */
+function toApplicableEmployeeOptions(rows = []) {
+  return rows
+    .filter((row) => isApplicableRegularizationStatus(row.status))
+    .map((row) => ({
+      id: row.userId,
+      employeeNo: row.employeeNo || '',
+      name: row.name || '',
+      avatar: row.avatar || String(row.name || '').charAt(0),
+      phone: row.phone || '',
+      company: row.company || '',
+      department: row.department || '',
+      position: row.position || '',
+      level: row.level || '',
+      manager: row.manager || '',
+      hireDate: row.hireDate || '',
+      regularDate: dateOnly(row.plannedRegularDate || row.regularDate),
+      groupId: row.groupId,
+      deptId: row.deptId,
+    }))
+}
 
+/** 演示模式可用的转正申请模板:取「线上 Word 模板」中启用态的那些,与服务器口径一致 */
+async function loadEnabledDemoTemplates() {
+  const page = await loadOnlineTemplateList({ templateStatus: 'ENABLED' })
+  return (page.list || []).map((row) => ({
+    id: row.id,
+    templateName: row.templateName,
+    status: row.templateStatus,
+  }))
+}
+
+/** 转正申请表单选项:可办理的试用期员工 + 启用态线上模板 */
+export async function loadRegularizationFormOptions() {
   if (!isServerMode()) {
     await delay(150)
-    return demoResult
+    return {
+      employees: toApplicableEmployeeOptions(buildDemoRegularizationRows()),
+      templates: await loadEnabledDemoTemplates(),
+    }
   }
 
-  const [employees, templates] = await Promise.all([
-    fetchProbationEmployees()
-      .then((result) => result.list)
-      .catch(() => []),
+  const [employeePage, templates] = await Promise.all([
+    fetchProbationEmployees().catch(() => ({ list: [] })),
     getRegularizationTemplatePage()
       .then((result) => result.list)
       .catch(() => []),
   ])
+  const source = Array.isArray(employeePage?.list) ? employeePage.list : []
+  const rows = await mergeBillsIntoEmployees(source)
   return {
-    employees: employees.length ? employees : demoResult.employees,
-    templates: templates.length ? templates : demoResult.templates,
+    // 仅当接口本身没返回员工时才回落演示数据;被状态过滤掉不算"无数据"
+    employees: source.length
+      ? toApplicableEmployeeOptions(rows)
+      : toApplicableEmployeeOptions(buildDemoRegularizationRows()),
+    templates: templates.length ? templates : await loadEnabledDemoTemplates(),
   }
 }
 
@@ -957,6 +1005,10 @@ export async function submitRegularizationApplication({
 } = {}) {
   if (!form) throw new Error('申请参数为空')
   const id = existingId || form.regularizationId || form.id || null
+  // 已有单据时先校验展示态,避免对「审批中 / 已转正 / 已取消」的单据再次提交
+  if (id && form.status && !canEditRegularization({ status: form.status, regularizationId: id })) {
+    throw new Error('当前单据状态不可编辑或提交')
+  }
 
   if (!isServerMode()) {
     await delay(300)

+ 143 - 0
src/components/A4Preview.vue

@@ -0,0 +1,143 @@
+<template>
+  <view class="a4-wrap">
+    <view class="a4-sheet">
+      <view v-if="parsed.type === 'empty'" class="a4-empty">暂无画布正文</view>
+      <rich-text v-else-if="parsed.type === 'html'" class="a4-html" :nodes="parsed.html" />
+      <view v-else-if="parsed.type === 'text'" class="a4-raw">{{ parsed.text }}</view>
+      <view v-else class="a4-body">
+        <template v-for="block in parsed.blocks" :key="block.key">
+          <view v-if="block.kind === 'title'" class="a4-title">
+            <text v-for="(seg, i) in block.segments" :key="i" :class="{ 'a4-var': seg.isVar }">{{ seg.text }}</text>
+          </view>
+          <view v-else-if="block.kind === 'table'" class="a4-table">
+            <view v-for="(row, r) in block.rows" :key="r" class="a4-tr">
+              <view v-for="(cell, c) in row" :key="c" class="a4-td">
+                <text v-for="(seg, i) in cell" :key="i" :class="{ 'a4-var': seg.isVar }">{{ seg.text }}</text>
+              </view>
+            </view>
+          </view>
+          <view v-else-if="block.kind === 'sign'" class="a4-sign">
+            <text v-for="(seg, i) in block.segments" :key="i" :class="{ 'a4-var': seg.isVar }">{{ seg.text }}</text>
+          </view>
+          <view v-else class="a4-para">
+            <text v-for="(seg, i) in block.segments" :key="i" :class="{ 'a4-var': seg.isVar }">{{ seg.text }}</text>
+          </view>
+        </template>
+      </view>
+    </view>
+    <view class="a4-legend">
+      <text class="a4-legend-var">变量</text>
+      <text class="a4-legend-tip">蓝色标记为待回填的变量,A4 比例展示</text>
+    </view>
+  </view>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import { parseA4Content } from '@/utils/a4-preview'
+
+const props = defineProps({
+  content: { type: String, default: '' },
+})
+
+const parsed = computed(() => parseA4Content(props.content))
+</script>
+
+<style scoped>
+.a4-wrap {
+  margin-top: 6rpx;
+}
+.a4-sheet {
+  width: 100%;
+  min-height: 880rpx;
+  padding: 44rpx 36rpx;
+  box-sizing: border-box;
+  border: 1rpx solid #e8e9eb;
+  border-radius: 12rpx;
+  background: #fff;
+  box-shadow: 0 6rpx 22rpx rgba(31, 35, 41, 0.05);
+}
+.a4-title {
+  margin-bottom: 32rpx;
+  font-size: 34rpx;
+  font-weight: 700;
+  text-align: center;
+  color: #1f2329;
+  line-height: 1.6;
+}
+.a4-para {
+  margin-bottom: 18rpx;
+  font-size: 26rpx;
+  line-height: 1.9;
+  color: #1f2329;
+  word-break: break-all;
+}
+.a4-sign {
+  margin-top: 40rpx;
+  font-size: 26rpx;
+  line-height: 1.9;
+  text-align: right;
+  color: #1f2329;
+}
+.a4-var {
+  padding: 0 8rpx;
+  border-radius: 6rpx;
+  background: #eef4ff;
+  color: #1677ff;
+  font-size: 24rpx;
+}
+.a4-table {
+  margin-bottom: 20rpx;
+  border-top: 1rpx solid #e8e9eb;
+  border-left: 1rpx solid #e8e9eb;
+}
+.a4-tr {
+  display: flex;
+}
+.a4-td {
+  flex: 1;
+  min-width: 0;
+  padding: 14rpx 16rpx;
+  border-right: 1rpx solid #e8e9eb;
+  border-bottom: 1rpx solid #e8e9eb;
+  font-size: 24rpx;
+  line-height: 1.7;
+  color: #1f2329;
+  word-break: break-all;
+}
+.a4-raw {
+  font-size: 24rpx;
+  line-height: 1.8;
+  color: #1f2329;
+  white-space: pre-wrap;
+  word-break: break-all;
+}
+.a4-html {
+  font-size: 26rpx;
+  line-height: 1.9;
+  color: #1f2329;
+}
+.a4-empty {
+  padding-top: 320rpx;
+  text-align: center;
+  color: #a0a7b0;
+  font-size: 26rpx;
+}
+.a4-legend {
+  margin-top: 16rpx;
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+}
+.a4-legend-var {
+  padding: 0 10rpx;
+  border-radius: 6rpx;
+  background: #eef4ff;
+  color: #1677ff;
+  font-size: 22rpx;
+}
+.a4-legend-tip {
+  font-size: 22rpx;
+  color: #a0a7b0;
+}
+</style>

+ 78 - 4
src/data/mock.js

@@ -979,8 +979,82 @@ export const regularizations = [
   },
 ]
 
-// 启用态线上模板(对应 /hr/online-documents/templates,演示模式兜底)
-export const regularizationTemplates = [
-  { id: 'TPL-ZZ-001', templateName: '标准员工转正审批表', status: 'ENABLED' },
-  { id: 'TPL-ZZ-002', templateName: '管理干部转正评估表', status: 'ENABLED' },
+// ============ 线上 Word 模板(/hr/online-documents/templates)============
+// 字段口径与后端「HR线上Word模板信息」一致;演示模式下由 api/onlineDocument 消费
+export const onlineDocumentTemplateCategories = [
+  { id: '1', categoryName: '入职类模板' },
+  { id: '2', categoryName: '转正类模板' },
+  { id: '3', categoryName: '异动类模板' },
+  { id: '4', categoryName: '离职类模板' },
 ]
+
+export const onlineDocumentTemplates = [
+  {
+    id: '1900000000000001001',
+    templateCode: 'ZZ-TPL-001',
+    templateName: '标准员工转正审批表',
+    categoryId: '2',
+    deptId: '102',
+    templateStatus: 'ENABLED',
+    versionStatus: 'RELEASED',
+    currentVersionNo: 1,
+    remark: '适用于普通员工按期转正,含试用期工作总结与上级评价。',
+    documentContent:
+      '{"type":"a4","blocks":[{"type":"title","text":"员工转正申请审批表"},{"type":"para","text":"员工:{{EMPLOYEE_NAME}}"},{"type":"para","text":"计划转正日期:{{PLANNED_REGULAR_DATE}}"}]}',
+    variablesJson:
+      '[{"key":"EMPLOYEE_NAME","label":"员工姓名","required":true},{"key":"PLANNED_REGULAR_DATE","label":"计划转正日期","required":true}]',
+    createTime: '2026-07-28 09:12:00',
+  },
+  {
+    id: '1900000000000001002',
+    templateCode: 'ZZ-TPL-002',
+    templateName: '管理干部转正评估表',
+    categoryId: '2',
+    deptId: '202',
+    templateStatus: 'ENABLED',
+    versionStatus: 'RELEASED',
+    currentVersionNo: 2,
+    remark: '管理岗转正使用,额外补充团队管理目标达成情况评价。',
+    documentContent:
+      '{"type":"a4","blocks":[{"type":"title","text":"管理干部转正评估表"},{"type":"para","text":"员工:{{EMPLOYEE_NAME}}"},{"type":"para","text":"部门:{{DEPT_NAME}}"},{"type":"para","text":"团队管理目标达成情况:{{MANAGEMENT_REVIEW}}"}]}',
+    variablesJson:
+      '[{"key":"EMPLOYEE_NAME","label":"员工姓名","required":true},{"key":"DEPT_NAME","label":"所属部门","required":true},{"key":"MANAGEMENT_REVIEW","label":"团队管理评价","required":true}]',
+    createTime: '2026-08-05 14:36:00',
+  },
+  {
+    id: '1900000000000001003',
+    templateCode: 'ZZ-TPL-003',
+    templateName: '试用期延期确认单',
+    categoryId: '2',
+    deptId: '102',
+    templateStatus: 'DISABLED',
+    versionStatus: 'RELEASED',
+    currentVersionNo: 1,
+    remark: '延期转正场景使用,需填写延期天数与原因。',
+    documentContent:
+      '{"type":"a4","blocks":[{"type":"title","text":"试用期延期确认单"},{"type":"para","text":"员工:{{EMPLOYEE_NAME}}"},{"type":"para","text":"延期天数:{{EXTENSION_DAYS}}"}]}',
+    variablesJson:
+      '[{"key":"EMPLOYEE_NAME","label":"员工姓名","required":true},{"key":"EXTENSION_DAYS","label":"延期天数","required":true}]',
+    createTime: '2026-08-12 16:02:00',
+  },
+]
+
+// 模板版本历史(演示模式兜底,对应 GET /hr/online-documents/templates/{id}/versions)
+export const onlineDocumentTemplateVersions = {
+  '1900000000000001002': [
+    {
+      id: '1900000000000001002-1',
+      versionNo: 1,
+      versionStatus: 'RELEASED',
+      changeRemark: '首次发布',
+      createTime: '2026-08-05 14:36:00',
+    },
+    {
+      id: '1900000000000001002-2',
+      versionNo: 2,
+      versionStatus: 'RELEASED',
+      changeRemark: '补充团队管理目标达成情况评价',
+      createTime: '2026-08-20 10:05:00',
+    },
+  ],
+}

+ 3 - 1
src/pages.json

@@ -27,7 +27,9 @@
     { "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": "转正详情" } }
+    { "path": "pages/manage/regular-detail", "style": { "navigationBarTitleText": "转正详情" } },
+    { "path": "pages/manage/regular-template-list", "style": { "navigationBarTitleText": "转正申请模板" } },
+    { "path": "pages/manage/regular-template-form", "style": { "navigationBarTitleText": "转正申请模板" } }
   ],
   "globalStyle": {
     "navigationBarTextStyle": "white",

+ 26 - 2
src/pages/manage/regular-create.vue

@@ -169,6 +169,8 @@ const form = reactive({
   effectiveDate: '',
   online: true,
   onlineDocumentTemplateId: null,
+  onlineDocumentContent: '',
+  onlineDocumentVariableValuesJson: '',
   employmentPeriodId: null,
   extensionDays: '',
   extensionReason: '',
@@ -176,6 +178,8 @@ const form = reactive({
   userSummary: '',
   managerEvaluation: '',
   hrComment: '',
+  status: '',
+  statusLabel: '',
   attachments: [],
 })
 
@@ -216,7 +220,7 @@ async function loadOptions() {
 function pickEmployee() {
   if (lockedEmployee.value) return
   if (!employees.value.length) {
-    uni.showToast({ title: '暂无试用期员工', icon: 'none' })
+    uni.showToast({ title: '暂无可办理转正申请的员工', icon: 'none' })
     return
   }
   uni.showActionSheet({
@@ -288,6 +292,11 @@ async function hydrateBill(billId) {
       userSummary: row.userSummary || '',
       managerEvaluation: row.managerEvaluation || '',
       hrComment: row.hrComment || '',
+      status: row.status || form.status,
+      statusLabel: row.statusLabel || '',
+      // 线上文档正文 / 变量值仅透传,更新时原样回写
+      onlineDocumentContent: row.onlineDocumentContent || '',
+      onlineDocumentVariableValuesJson: row.onlineDocumentVariableValuesJson || '',
     })
     const fileId = row.approvalDocumentFileId
     form.attachments = fileId
@@ -367,6 +376,13 @@ function chooseImage() {
 
 function validate() {
   if (!form.userId) return '请选择申请员工'
+  if (
+    form.regularizationId &&
+    form.status &&
+    !canEditRegularization({ status: form.status, regularizationId: form.regularizationId })
+  ) {
+    return '当前单据状态不可编辑或提交'
+  }
   if (!form.regularizationType) return '请选择转正类型'
   if (!form.plannedRegularDate) return '请选择计划转正日期'
   if (form.online && !form.onlineDocumentTemplateId) return '请选择已启用转正申请模板'
@@ -456,7 +472,15 @@ onLoad(async (options) => {
     } else {
       form.userId = userId
       const billId = await resolveRegularizationId(userId)
-      if (billId) await hydrateBill(billId)
+      if (billId) {
+        await hydrateBill(billId)
+        // 员工不在可办理列表里(例如单据已进入审批中 / 已转正),直接拦回列表
+        if (form.status && !canEditRegularization({ status: form.status })) {
+          uni.showToast({ title: '该员工当前状态不可申请转正', icon: 'none' })
+          setTimeout(() => back('/pages/manage/regular-list'), 900)
+          return
+        }
+      }
     }
   }
 })

+ 1 - 1
src/pages/manage/regular-detail.vue

@@ -241,7 +241,7 @@ async function runAction(key) {
     if (!ok) return
     await exec('提交审批', () => actSubmitRegularization(row))
   } else if (key === 'withdraw') {
-    const reason = await promptReason('撤回转正审批', false)
+    const reason = await promptReason('撤回转正审批', true)
     if (reason === null) return
     await exec('撤回审批', () => actWithdrawRegularization(row, reason))
   } else if (key === 'cancel') {

+ 357 - 4
src/pages/manage/regular-list.vue

@@ -1,5 +1,19 @@
 <template>
   <view class="page-no-tab">
+    <view class="metrics">
+      <view
+        v-for="item in metrics"
+        :key="item.key"
+        class="metric press"
+        :class="['tone-' + item.tone, { active: active === item.filter }]"
+        @click="active = item.filter"
+      >
+        <text class="metric-value">{{ item.value }}<text class="metric-unit">人</text></text>
+        <text class="metric-label">{{ item.label }}</text>
+        <text class="metric-note">{{ item.note }}</text>
+      </view>
+    </view>
+
     <view class="tabs">
       <view
         v-for="item in REGULARIZATION_STATUS_FILTERS"
@@ -10,9 +24,66 @@
       >{{ item.label }}</view>
     </view>
 
+    <view class="filters">
+      <view class="search-row">
+        <input
+          v-model.trim="keyword"
+          class="search-input"
+          type="text"
+          confirm-type="search"
+          placeholder="姓名 / 工号 / 岗位"
+        />
+        <text class="reset press" @click="resetFilters">重置</text>
+      </view>
+      <view class="filter-row">
+        <picker
+          class="filter-cell press"
+          mode="selector"
+          :range="companyRange"
+          :value="companyIndex"
+          @change="onCompanyChange"
+        >
+          <view class="filter-item">
+            <text :class="company ? 'value' : 'placeholder'">{{ company || '全部所属法人' }}</text>
+            <text class="arrow">›</text>
+          </view>
+        </picker>
+        <picker
+          class="filter-cell press"
+          mode="selector"
+          :range="departmentRange"
+          :value="departmentIndex"
+          @change="onDepartmentChange"
+        >
+          <view class="filter-item">
+            <text :class="department ? 'value' : 'placeholder'">{{ department || '全部所属部门' }}</text>
+            <text class="arrow">›</text>
+          </view>
+        </picker>
+      </view>
+      <view class="filter-row">
+        <picker class="filter-cell press" mode="date" :value="startDate" @change="onStartDateChange">
+          <view class="filter-item">
+            <text :class="startDate ? 'value' : 'placeholder'">{{ startDate || '转正开始' }}</text>
+            <text class="arrow">›</text>
+          </view>
+        </picker>
+        <picker class="filter-cell press" mode="date" :value="endDate" @change="onEndDateChange">
+          <view class="filter-item">
+            <text :class="endDate ? 'value' : 'placeholder'">{{ endDate || '转正结束' }}</text>
+            <text class="arrow">›</text>
+          </view>
+        </picker>
+      </view>
+    </view>
+
     <view class="summary">
-      <text>共 {{ records.length }} 名试用期 / 转正人员</text>
-      <text class="muted">{{ summaryTip }}</text>
+      <text>共 {{ list.length }} 条记录</text>
+      <view class="summary-right">
+        <text class="muted">{{ summaryTip }}</text>
+        <text class="link press" @click="openTemplates">模板</text>
+        <text class="link press" @click="exportRows">导出</text>
+      </view>
     </view>
 
     <view v-if="loading" class="empty">加载中…</view>
@@ -86,11 +157,88 @@ const meta = applicationTypes.regular || { title: '转正申请', color: '#269a9
 const active = ref('all')
 const records = ref([])
 const loading = ref(false)
+const keyword = ref('')
+const company = ref('')
+const department = ref('')
+const startDate = ref('')
+const endDate = ref('')
 
 const list = computed(() =>
-  records.value.filter((row) => matchDisplayStatus(row, active.value)),
+  records.value
+    .filter((row) => matchDisplayStatus(row, active.value))
+    .filter((row) => {
+      const k = keyword.value.toLowerCase()
+      const keywordOk =
+        !k ||
+        [row.name, row.employeeNo, row.position].some((value) =>
+          String(value || '').toLowerCase().includes(k),
+        )
+      const companyOk = !company.value || row.company === company.value
+      const departmentOk = !department.value || row.department === department.value
+      const date = row.plannedRegularDate || row.plannedDate || ''
+      const dateOk =
+        (!startDate.value || (date && date >= startDate.value)) &&
+        (!endDate.value || (date && date <= endDate.value))
+      return keywordOk && companyOk && departmentOk && dateOk
+    }),
 )
 
+const companyOptions = computed(() =>
+  [...new Set(records.value.map((row) => row.company).filter(Boolean))].sort(),
+)
+const companyRange = computed(() => ['全部所属法人', ...companyOptions.value])
+const companyIndex = computed(() =>
+  Math.max(0, companyOptions.value.indexOf(company.value) + 1),
+)
+
+const departmentOptions = computed(() =>
+  [...new Set(records.value.map((row) => row.department).filter(Boolean))].sort(),
+)
+const departmentRange = computed(() => ['全部所属部门', ...departmentOptions.value])
+const departmentIndex = computed(() =>
+  Math.max(0, departmentOptions.value.indexOf(department.value) + 1),
+)
+
+function countStatus(status) {
+  return records.value.filter((row) => row.status === status).length
+}
+
+/** 指标卡口径与 PC 端 metric-grid 一致 */
+const metrics = computed(() => [
+  {
+    key: 'pending',
+    label: '待发起申请',
+    value: countStatus('pending_apply'),
+    note: '试用期员工未建单',
+    tone: 'amber',
+    filter: 'pending_apply',
+  },
+  {
+    key: 'review',
+    label: '审批进行中',
+    value: countStatus('reviewing'),
+    note: '已提交审批',
+    tone: 'blue',
+    filter: 'reviewing',
+  },
+  {
+    key: 'done',
+    label: '本期已转正',
+    value: countStatus('done'),
+    note: '审批通过已生效',
+    tone: 'green',
+    filter: 'done',
+  },
+  {
+    key: 'risk',
+    label: '需要重新提交',
+    value: countStatus('rejected') + countStatus('withdrawn'),
+    note: '驳回或撤回后可再提',
+    tone: 'red',
+    filter: 'rejected',
+  },
+])
+
 const summaryTip = computed(() =>
   getServerConfig().mode === 'server'
     ? '数据来自 /main/regularizations'
@@ -115,6 +263,10 @@ function openCreate(item) {
   go(`/pages/manage/regular-create${userId}`)
 }
 
+function openTemplates() {
+  go('/pages/manage/regular-template-list')
+}
+
 function openItem(item) {
   if (item?.regularizationId) {
     go(`/pages/manage/regular-detail?id=${item.regularizationId}`)
@@ -161,7 +313,7 @@ async function exec(title, task) {
 }
 
 async function onWithdraw(item) {
-  const reason = await promptReason('撤回转正审批', false)
+  const reason = await promptReason('撤回转正审批', true)
   if (reason === null) return
   await exec('撤回', () => actWithdrawRegularization(item, reason))
 }
@@ -172,6 +324,81 @@ async function onCancel(item) {
   await exec('取消', () => actCancelRegularization(item, reason))
 }
 
+function onCompanyChange(e) {
+  const index = Number(e.detail.value)
+  company.value = index > 0 ? companyOptions.value[index - 1] || '' : ''
+}
+
+function onDepartmentChange(e) {
+  const index = Number(e.detail.value)
+  department.value = index > 0 ? departmentOptions.value[index - 1] || '' : ''
+}
+
+function onStartDateChange(e) {
+  startDate.value = e.detail.value
+}
+
+function onEndDateChange(e) {
+  endDate.value = e.detail.value
+}
+
+function resetFilters() {
+  keyword.value = ''
+  company.value = ''
+  department.value = ''
+  startDate.value = ''
+  endDate.value = ''
+  active.value = 'all'
+}
+
+function csvCell(value) {
+  const text = String(value === undefined || value === null ? '' : value)
+  return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
+}
+
+/** 导出当前筛选结果(列口径与 PC 端 exportRows 一致) */
+function exportRows() {
+  const rows = list.value
+  if (!rows.length) {
+    uni.showToast({ title: '暂无可导出的记录', icon: 'none' })
+    return
+  }
+  const csv = [
+    '工号,姓名,所属部门,岗位,入职日期,转正日期,状态,审核状态',
+    ...rows.map((row) =>
+      [
+        row.employeeNo,
+        row.name,
+        row.department,
+        row.position,
+        row.hireDate,
+        row.effectiveDate || row.regularDate || row.plannedRegularDate,
+        row.statusLabel || row.status,
+        row.approvalStatusLabel || row.approvalStatus,
+      ]
+        .map(csvCell)
+        .join(','),
+    ),
+  ].join('\n')
+
+  // #ifdef H5
+  const blob = new Blob([`\uFEFF${csv}`], { type: 'text/csv' })
+  const link = document.createElement('a')
+  link.href = URL.createObjectURL(blob)
+  link.download = '转正办理.csv'
+  link.click()
+  URL.revokeObjectURL(link.href)
+  return
+  // #endif
+
+  // #ifndef H5
+  uni.setClipboardData({
+    data: csv,
+    success: () => uni.showToast({ title: '已复制到剪贴板', icon: 'none' }),
+  })
+  // #endif
+}
+
 onShow(loadList)
 </script>
 
@@ -179,6 +406,121 @@ onShow(loadList)
 .page-no-tab {
   padding-bottom: 150rpx;
 }
+.metrics {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+  padding: 24rpx 24rpx 8rpx;
+  background: #fff;
+}
+.metric {
+  width: calc(50% - 8rpx);
+  padding: 20rpx 22rpx;
+  border-radius: 16rpx;
+  background: #fafbfc;
+  border: 1rpx solid #eef0f2;
+  box-sizing: border-box;
+}
+.metric.active {
+  border-color: #1677ff;
+  background: #f3f8ff;
+}
+.metric-value {
+  display: block;
+  font-size: 40rpx;
+  font-weight: 600;
+  line-height: 1.1;
+  color: #1f2329;
+}
+.metric-unit {
+  margin-left: 6rpx;
+  font-size: 22rpx;
+  font-weight: 400;
+  color: #8a919a;
+}
+.metric-label {
+  display: block;
+  margin-top: 10rpx;
+  font-size: 26rpx;
+  color: #1f2329;
+}
+.metric-note {
+  display: block;
+  margin-top: 6rpx;
+  font-size: 21rpx;
+  color: #a0a7b0;
+}
+.metric.tone-amber .metric-value {
+  color: #f59a23;
+}
+.metric.tone-blue .metric-value {
+  color: #1677ff;
+}
+.metric.tone-green .metric-value {
+  color: #16a34a;
+}
+.metric.tone-red .metric-value {
+  color: #ee4d4d;
+}
+.filters {
+  padding: 20rpx 24rpx 20rpx;
+  background: #fff;
+  border-bottom: 1rpx solid #edf0f2;
+}
+.search-row {
+  display: flex;
+  align-items: center;
+  gap: 20rpx;
+}
+.search-input {
+  flex: 1;
+  min-width: 0;
+  height: 72rpx;
+  padding: 0 24rpx;
+  border-radius: 12rpx;
+  background: #f5f6f7;
+  font-size: 26rpx;
+}
+.reset {
+  flex-shrink: 0;
+  color: #6f7782;
+  font-size: 26rpx;
+}
+.filter-row {
+  display: flex;
+  gap: 16rpx;
+  margin-top: 16rpx;
+}
+.filter-cell {
+  flex: 1;
+  min-width: 0;
+}
+.filter-item {
+  height: 72rpx;
+  padding: 0 20rpx;
+  border-radius: 12rpx;
+  background: #f5f6f7;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.filter-item text {
+  font-size: 25rpx;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+.filter-item .value {
+  color: #1f2329;
+}
+.filter-item .placeholder {
+  color: #a0a7b0;
+}
+.filter-item .arrow {
+  flex-shrink: 0;
+  color: #c2c7cd;
+  font-size: 28rpx;
+}
 .tabs {
   display: flex;
   overflow-x: auto;
@@ -217,6 +559,17 @@ onShow(loadList)
   justify-content: space-between;
   font-size: 24rpx;
 }
+.summary-right {
+  display: flex;
+  align-items: center;
+  gap: 20rpx;
+  min-width: 0;
+}
+.summary-right .muted {
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
 .list {
   padding: 18rpx 24rpx;
 }

+ 673 - 0
src/pages/manage/regular-template-form.vue

@@ -0,0 +1,673 @@
+<template>
+  <view class="page-no-tab">
+    <view class="form-intro">
+      <ModuleIcon :icon="meta.icon" :color="meta.color" soft />
+      <view>
+        <text class="intro-title">{{ form.id ? '编辑模板' : '新增模板' }}</text>
+        <text class="intro-sub">{{ introSub }}</text>
+      </view>
+    </view>
+
+    <view v-if="form.id && !isDraft" class="notice">
+      <text>
+        已{{ form.templateStatusLabel || '启用' }}模板:正文与变量改完后需点底部「发布新版本」保存;
+        发布后模板会自动回到停用态,需重新启用。分类暂不支持修改。
+      </text>
+    </view>
+
+    <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.templateName" class="text-input" maxlength="128" placeholder="请输入模板名称" />
+      </view>
+      <view class="divider"></view>
+      <view class="field">
+        <text class="label required">模板编码</text>
+        <input v-model="form.templateCode" class="text-input" maxlength="64" placeholder="同租户唯一" />
+      </view>
+      <view class="divider"></view>
+      <view class="field press" @click="chooseCategory">
+        <text class="label required">模板分类</text>
+        <text :class="categoryLabel ? 'value' : 'placeholder'">{{ categoryLabel || '请选择' }}</text>
+        <text class="arrow">›</text>
+      </view>
+      <view class="divider"></view>
+      <view class="field press" @click="chooseLegal">
+        <text class="label required">所属法人</text>
+        <text :class="form.legalName ? 'value' : 'placeholder'">{{ form.legalName || '请选择' }}</text>
+        <text class="arrow">›</text>
+      </view>
+      <view class="divider"></view>
+      <view class="field press" @click="onDeptFieldTap">
+        <text class="label required">所属组织</text>
+        <picker
+          v-if="form.legalId"
+          mode="multiSelector"
+          :range="deptColumns"
+          :value="deptIndex"
+          range-key="name"
+          @columnchange="onDeptColChange"
+          @change="onDeptPick"
+        >
+          <text :class="form.deptPath ? 'value' : 'placeholder'">{{ form.deptPath || '请选择' }}</text>
+        </picker>
+        <text v-else class="placeholder">请先选择法人</text>
+        <text class="arrow">›</text>
+      </view>
+      <view class="divider"></view>
+      <view class="field textarea-field">
+        <text class="label">模板说明</text>
+        <textarea v-model="form.remark" maxlength="2000" placeholder="选填,说明模板适用场景" />
+        <text class="counter">{{ form.remark.length }}/2000</text>
+      </view>
+    </view>
+
+    <view class="section-title"><text>模板正文</text></view>
+    <view class="form-card card section">
+      <view class="field textarea-field">
+        <text class="label required">A4 画布正文</text>
+        <textarea
+          v-model="form.documentContent"
+          maxlength="-1"
+          placeholder="JSON 或 HTML 文本"
+        />
+        <text class="hint">{{ contentHint }}</text>
+      </view>
+      <view class="divider"></view>
+      <view class="field textarea-field">
+        <text class="label">变量定义</text>
+        <textarea
+          v-model="form.variablesJson"
+          maxlength="-1"
+          placeholder="变量定义 JSON 数组,选填"
+        />
+      </view>
+    </view>
+
+    <view class="section-title"><text>画布预览</text></view>
+    <view class="form-card card section">
+      <A4Preview :content="form.documentContent" />
+    </view>
+
+    <template v-if="form.id">
+      <view class="section-title"><text>版本</text></view>
+      <view class="form-card card section">
+        <view class="field">
+          <text class="label">当前版本</text>
+          <text class="value">V{{ form.currentVersionNo || 1 }} · {{ form.versionStatus || '—' }}</text>
+        </view>
+        <view class="divider"></view>
+        <view class="field textarea-field">
+          <text class="label">新版本变更说明</text>
+          <textarea v-model="versionChangeRemark" maxlength="200" placeholder="发布新版本时记录,选填" />
+        </view>
+        <view class="divider"></view>
+        <view class="field press" @click="onPublishVersion">
+          <text class="label">发布新版本</text>
+          <text class="value link">用当前正文发布 ›</text>
+        </view>
+      </view>
+    </template>
+
+    <view class="footer-tip muted"><text>{{ footerTip }}</text></view>
+
+    <view class="bottom-action">
+      <view class="draft-btn press" @click="onCancel">取消</view>
+      <view class="submit-btn press" @click="onSubmit">{{ submitLabel }}</view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+import { computed, reactive, ref } from 'vue'
+import { onLoad } from '@dcloudio/uni-app'
+import A4Preview from '@/components/A4Preview.vue'
+import ModuleIcon from '@/components/ModuleIcon.vue'
+import { useAuthGuard } from '@/hooks/useAuthGuard'
+import { applicationTypes } from '@/data/mock'
+import { back } from '@/utils/router'
+import { getServerConfig } from '@/utils/auth'
+import { listOrganizations } from '@/api/organization'
+import { buildDeptTreeUnderLegal, pickLegalEntities } from '@/utils/organization-tree'
+import {
+  TEMPLATE_STATUS_LABELS,
+  loadOnlineTemplateCategories,
+  loadOnlineTemplateDetail,
+  publishOnlineTemplateVersion,
+  saveOnlineTemplate,
+} from '@/api/onlineDocument'
+
+useAuthGuard()
+const meta = applicationTypes.regular || { title: '转正申请', color: '#269a99' }
+
+const form = reactive({
+  id: '',
+  templateName: '',
+  templateCode: '',
+  categoryId: '',
+  legalId: '',
+  legalName: '',
+  deptId: '',
+  deptPath: '',
+  remark: '',
+  documentContent: '',
+  variablesJson: '',
+  templateStatus: '',
+  templateStatusLabel: '',
+  versionStatus: '',
+  currentVersionNo: 1,
+})
+
+const orgList = ref([])
+const companies = ref([])
+const deptTree = ref([])
+const deptIndex = ref([0, 0, 0])
+const categories = ref([])
+const versionChangeRemark = ref('')
+const submitting = ref(false)
+
+/** 只有草稿模板允许直接改正文 / 变量 / 分类,其余要走「发布新版本」 */
+const isDraft = computed(() => !form.id || form.templateStatus === 'DRAFT')
+
+const introSub = computed(() =>
+  form.id
+    ? `${form.templateStatusLabel || TEMPLATE_STATUS_LABELS[form.templateStatus] || ''} · 当前版本 V${form.currentVersionNo || 1}`
+    : '线上 Word 模板,供转正申请选用',
+)
+
+const submitLabel = computed(() => {
+  if (!form.id) return '创建模板'
+  return isDraft.value ? '保存修改' : '发布新版本'
+})
+
+const contentHint = computed(() =>
+  isDraft.value
+    ? '变量用双花括号占位,例如 {{EMPLOYEE_NAME}}'
+    : '已启用 / 停用模板:改完正文点底部「发布新版本」生效',
+)
+
+const footerTip = computed(() =>
+  getServerConfig().mode === 'server'
+    ? '保存走 /hr/online-documents/templates,新建后默认为停用,需手动启用'
+    : '演示模式:模板保存在本机,操作不会同步到服务器',
+)
+
+const categoryLabel = computed(() => {
+  if (!form.categoryId) return ''
+  const hit = categories.value.find((item) => String(item.id) === String(form.categoryId))
+  return hit ? hit.categoryName : `分类 ${form.categoryId}`
+})
+
+const deptColumns = computed(() => {
+  const t = deptTree.value
+  const l1 = t.map((n) => ({ name: n.name, id: n.id }))
+  const l2 =
+    l1.length && deptIndex.value[0] < l1.length
+      ? (t[deptIndex.value[0]].children || []).map((n) => ({ name: n.name, id: n.id }))
+      : []
+  const l3 =
+    l2.length && deptIndex.value[1] < l2.length
+      ? (t[deptIndex.value[0]].children[deptIndex.value[1]].children || []).map((n) => ({
+          name: n.name,
+          id: n.id,
+        }))
+      : []
+  return [l1, l2, l3]
+})
+
+function validate() {
+  if (!form.templateName.trim()) return '请填写模板名称'
+  if (!form.templateCode.trim()) return '请填写模板编码'
+  if (!form.categoryId) return '请选择模板分类'
+  if (!form.deptId) return '请选择所属组织'
+  if (!form.documentContent.trim()) return '请填写模板正文'
+  const variables = form.variablesJson.trim()
+  if (variables) {
+    try {
+      const parsed = JSON.parse(variables)
+      if (!Array.isArray(parsed)) return '变量定义需为 JSON 数组'
+    } catch (error) {
+      return '变量定义不是合法 JSON'
+    }
+  }
+  return ''
+}
+
+async function refreshOrganizations() {
+  try {
+    orgList.value = await listOrganizations()
+    companies.value = pickLegalEntities(orgList.value)
+  } catch (error) {
+    orgList.value = []
+    companies.value = []
+    uni.showToast({ title: '组织列表加载失败', icon: 'none' })
+  }
+}
+
+function refreshDeptTree(legalId) {
+  deptTree.value = legalId ? buildDeptTreeUnderLegal(orgList.value, legalId) : []
+  deptIndex.value = [0, 0, 0]
+}
+
+/** 在组织树里定位 deptId 所在的三级索引,用于回填 picker */
+function findDeptIndex(tree = [], deptId) {
+  const key = String(deptId || '')
+  for (let i = 0; i < tree.length; i += 1) {
+    if (String(tree[i].id) === key) return [i, 0, 0]
+    const level2 = tree[i].children || []
+    for (let j = 0; j < level2.length; j += 1) {
+      if (String(level2[j].id) === key) return [i, j, 0]
+      const level3 = level2[j].children || []
+      for (let k = 0; k < level3.length; k += 1) {
+        if (String(level3[k].id) === key) return [i, j, k]
+      }
+    }
+  }
+  return [0, 0, 0]
+}
+
+/** 由模板的 deptId 反查所属法人与组织路径(编辑态回填,避免保存时丢组织) */
+function restoreOrganization(deptId) {
+  form.legalId = ''
+  form.legalName = ''
+  form.deptPath = ''
+  form.deptId = deptId ? String(deptId) : ''
+  if (!form.deptId) return
+  const byId = new Map(orgList.value.map((item) => [String(item.id), item]))
+  const node = byId.get(form.deptId)
+  // 组织列表里查不到(跨法人 / 已停用组织)时保留原主键,只做提示
+  if (!node) {
+    form.deptPath = `组织 ${form.deptId}`
+    return
+  }
+  const isCompany = (item) =>
+    companies.value.some((company) => String(company.id) === String(item.id))
+  // 自下而上收集祖先链(含自身)
+  const chain = []
+  const visited = new Set()
+  let cursor = node
+  while (cursor && !visited.has(String(cursor.id))) {
+    visited.add(String(cursor.id))
+    chain.unshift(cursor)
+    const parentId = cursor.parentId == null ? '0' : String(cursor.parentId)
+    cursor = parentId === '0' ? null : byId.get(parentId) || null
+  }
+  const legal = chain.find(isCompany)
+  const deptNodes = chain.filter((item) => !isCompany(item))
+  const tail = deptNodes.length ? deptNodes : chain
+  form.deptId = tail[tail.length - 1].id
+  form.deptPath = tail.map((item) => item.name).filter(Boolean).join(' / ')
+  if (!legal) return
+  form.legalId = legal.id
+  form.legalName = legal.name
+  refreshDeptTree(legal.id)
+  deptIndex.value = findDeptIndex(deptTree.value, form.deptId)
+}
+
+function chooseCategory() {
+  if (!isDraft.value) {
+    uni.showToast({ title: '已启用 / 停用模板不可改分类,请发布新版本', icon: 'none' })
+    return
+  }
+  const options = categories.value
+  if (!options.length) {
+    uni.showModal({
+      title: '模板分类',
+      content: '后端暂未提供分类列表接口,请直接填写分类主键(数字)',
+      showCancel: false,
+      success: () => promptCategoryId(),
+    })
+    return
+  }
+  const itemList = [...options.map((item) => item.categoryName), '手动输入分类主键']
+  uni.showActionSheet({
+    itemList,
+    success: (r) => {
+      if (r.tapIndex === options.length) {
+        promptCategoryId()
+        return
+      }
+      const picked = options[r.tapIndex]
+      if (picked) form.categoryId = String(picked.id)
+    },
+  })
+}
+
+function promptCategoryId() {
+  uni.showModal({
+    title: '输入分类主键',
+    editable: true,
+    placeholderText: '请输入模板分类主键(数字)',
+    success: (r) => {
+      if (!r.confirm) return
+      const text = String(r.content || '').trim()
+      if (!text) return
+      form.categoryId = text
+    },
+  })
+}
+
+function chooseLegal() {
+  if (!companies.value.length) {
+    uni.showToast({ title: '法人列表为空', icon: 'none' })
+    return
+  }
+  uni.showActionSheet({
+    itemList: companies.value.map((item) => item.name),
+    success: (r) => {
+      const picked = companies.value[r.tapIndex]
+      if (!picked) return
+      form.legalId = picked.id
+      form.legalName = picked.name
+      form.deptId = ''
+      form.deptPath = ''
+      refreshDeptTree(picked.id)
+    },
+  })
+}
+
+function onDeptFieldTap() {
+  if (!form.legalId) {
+    uni.showToast({ title: '请先选择法人', icon: 'none' })
+    chooseLegal()
+  }
+}
+
+function onDeptColChange(e) {
+  const next = [...deptIndex.value]
+  next[e.detail.column] = e.detail.value
+  if (e.detail.column === 0) {
+    next[1] = 0
+    next[2] = 0
+  }
+  if (e.detail.column === 1) next[2] = 0
+  deptIndex.value = next
+}
+
+function onDeptPick(e) {
+  const [i0, i1, i2] = e.detail.value
+  const l1 = deptTree.value[i0]
+  const l2 = l1?.children?.[i1]
+  const l3 = l2?.children?.[i2]
+  const nodes = [l2, l3].filter(Boolean)
+  if (!nodes.length) {
+    form.deptId = ''
+    form.deptPath = ''
+    return
+  }
+  form.deptId = nodes[nodes.length - 1].id
+  form.deptPath = nodes.map((n) => n.name).filter(Boolean).join(' / ')
+}
+
+async function onSave() {
+  if (submitting.value) return
+  const error = validate()
+  if (error) {
+    uni.showToast({ title: error, icon: 'none' })
+    return
+  }
+  submitting.value = true
+  uni.showLoading({ title: '保存中...' })
+  try {
+    await saveOnlineTemplate({ form: { ...form }, existingId: form.id || null })
+    uni.hideLoading()
+    uni.showToast({ title: form.id ? '已保存' : '模板已创建', icon: 'success' })
+    setTimeout(() => back('/pages/manage/regular-template-list'), 800)
+  } catch (e) {
+    uni.hideLoading()
+    uni.showToast({ title: e.message || '保存失败', icon: 'none' })
+  } finally {
+    submitting.value = false
+  }
+}
+
+/** 底部主按钮:草稿直接保存,已启用 / 停用走发布新版本 */
+function onSubmit() {
+  if (submitting.value) return
+  if (isDraft.value) return onSave()
+  return onPublishVersion()
+}
+
+async function onPublishVersion() {
+  if (!form.id) return
+  if (!form.documentContent.trim()) {
+    uni.showToast({ title: '请先填写模板正文', icon: 'none' })
+    return
+  }
+  uni.showModal({
+    title: '发布新版本',
+    content: '将用当前正文创建新版本,确认发布?',
+    success: async (r) => {
+      if (!r.confirm) return
+      uni.showLoading({ title: '发布中...' })
+      try {
+        await publishOnlineTemplateVersion(form.id, {
+          documentContent: form.documentContent,
+          variablesJson: form.variablesJson,
+          changeRemark: versionChangeRemark.value,
+        })
+        uni.hideLoading()
+        versionChangeRemark.value = ''
+        // 发布新版本会自动停用模板并生成草稿版本,重新拉一次详情同步状态
+        await loadTemplate(form.id)
+        uni.showToast({ title: '新版本已发布', icon: 'success' })
+      } catch (e) {
+        uni.hideLoading()
+        uni.showToast({ title: e.message || '发布失败', icon: 'none' })
+      }
+    },
+  })
+}
+
+function onCancel() {
+  back('/pages/manage/regular-template-list')
+}
+
+/** 拉取模板详情并回填表单(编辑进入与发布新版本后复用) */
+async function loadTemplate(id) {
+  const row = await loadOnlineTemplateDetail(id)
+  if (!row) throw new Error('未找到该模板')
+  Object.assign(form, {
+    id: row.id,
+    templateName: row.templateName || '',
+    templateCode: row.templateCode || '',
+    categoryId: row.categoryId || '',
+    remark: row.remark || '',
+    documentContent: row.documentContent || '',
+    variablesJson: row.variablesJson || '',
+    templateStatus: row.templateStatus || '',
+    templateStatusLabel: row.templateStatusLabel || '',
+    versionStatus: row.versionStatus || '',
+    currentVersionNo: row.currentVersionNo || 1,
+  })
+  // 详情里没有法人 / 组织路径,需要用 deptId 反查回填,避免保存时丢组织
+  restoreOrganization(row.deptId)
+  return row
+}
+
+onLoad(async (options) => {
+  uni.setNavigationBarTitle({ title: '转正申请模板' })
+  await refreshOrganizations()
+  try {
+    categories.value = await loadOnlineTemplateCategories()
+  } catch (error) {
+    categories.value = []
+  }
+
+  const id = options?.id || ''
+  if (!id) return
+  uni.showLoading({ title: '加载中...' })
+  try {
+    await loadTemplate(id)
+  } catch (e) {
+    uni.hideLoading()
+    uni.showToast({ title: e.message || '加载失败', icon: 'none' })
+    setTimeout(() => back('/pages/manage/regular-template-list'), 900)
+    return
+  }
+  uni.hideLoading()
+})
+</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;
+}
+.form-card {
+  overflow: hidden;
+}
+.notice {
+  margin: 20rpx 24rpx 0;
+  padding: 20rpx 24rpx;
+  border-radius: 12rpx;
+  background: #fff8e6;
+  color: #a86b00;
+  font-size: 23rpx;
+  line-height: 1.6;
+}
+.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;
+}
+.value.link {
+  color: #1677ff;
+}
+.placeholder {
+  flex: 1;
+  text-align: right;
+  color: #b2b7be;
+  font-size: 27rpx;
+}
+.arrow {
+  margin-left: 13rpx;
+  color: #c4c8ce;
+  font-size: 38rpx;
+}
+.text-input {
+  flex: 1;
+  height: 68rpx;
+  text-align: right;
+  font-size: 27rpx;
+  color: #1f2329;
+}
+.textarea-field {
+  display: block;
+  min-height: 240rpx;
+}
+.textarea-field .label {
+  display: block;
+  width: auto;
+  margin-bottom: 20rpx;
+}
+.textarea-field textarea {
+  width: 100%;
+  height: 180rpx;
+  padding: 18rpx;
+  border-radius: 12rpx;
+  background: #f6f7f8;
+  font-size: 25rpx;
+  box-sizing: border-box;
+}
+.textarea-field textarea[disabled] {
+  color: #8b939f;
+  background: #f2f3f5;
+}
+.counter {
+  display: block;
+  margin-top: 10rpx;
+  text-align: right;
+  color: #b2b7be;
+  font-size: 22rpx;
+}
+.hint {
+  display: block;
+  margin-top: 12rpx;
+  color: #8b939f;
+  font-size: 22rpx;
+}
+.footer-tip {
+  padding: 8rpx 30rpx 0;
+  font-size: 22rpx;
+}
+.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;
+  display: flex;
+  gap: 20rpx;
+  box-sizing: border-box;
+}
+.draft-btn {
+  flex: 1;
+  height: 84rpx;
+  line-height: 84rpx;
+  text-align: center;
+  border-radius: 12rpx;
+  background: #f5f6f7;
+  color: #4a5058;
+  font-size: 30rpx;
+}
+.submit-btn {
+  flex: 2;
+  height: 84rpx;
+  line-height: 84rpx;
+  text-align: center;
+  border-radius: 12rpx;
+  background: #1677ff;
+  color: #fff;
+  font-size: 30rpx;
+}
+@media (min-width: 800px) {
+  .bottom-action {
+    max-width: 520px;
+    left: 50%;
+    transform: translateX(-50%);
+  }
+}
+</style>

+ 531 - 0
src/pages/manage/regular-template-list.vue

@@ -0,0 +1,531 @@
+<template>
+  <view class="page-no-tab">
+    <view class="tabs">
+      <view
+        v-for="item in TEMPLATE_STATUS_FILTERS"
+        :key="item.value"
+        class="tab press"
+        :class="{ active: active === item.value }"
+        @click="active = item.value"
+      >{{ item.label }}<text class="tab-count">{{ countStatus(item.value) }}</text></view>
+    </view>
+
+    <view class="filters">
+      <view class="search-row">
+        <input
+          v-model.trim="keyword"
+          class="search-input"
+          type="text"
+          confirm-type="search"
+          placeholder="模板名称 / 编码"
+        />
+        <text class="reset press" @click="resetFilters">重置</text>
+      </view>
+    </view>
+
+    <view class="summary">
+      <text>共 {{ list.length }} 个模板</text>
+      <view class="summary-right">
+        <text class="muted">{{ summaryTip }}</text>
+        <text class="link press" @click="loadList">刷新</text>
+      </view>
+    </view>
+
+    <view v-if="loading" class="empty">加载中…</view>
+
+    <view v-else-if="list.length" class="list">
+      <view
+        v-for="item in list"
+        :key="item.id"
+        class="tpl-card card press"
+        @click="openEdit(item)"
+      >
+        <view class="card-head">
+          <view class="card-icon" :style="{ color: meta.color, background: meta.color + '18' }">模</view>
+          <view class="card-title">
+            <text>{{ item.templateName || '—' }}<text class="muted small"> {{ item.templateCode || '' }}</text></text>
+            <text>分类 {{ item.categoryId || '—' }} · 当前版本 V{{ item.currentVersionNo || 1 }}</text>
+          </view>
+          <text class="status-tag" :class="item.enabled ? 'status-approved' : 'status-pending'">
+            {{ item.templateStatusLabel }}
+          </text>
+        </view>
+
+        <view v-if="item.remark" class="card-tip">{{ item.remark }}</view>
+
+        <view class="card-meta">
+          <view><text>变量个数</text><text>{{ variableCount(item) }}</text></view>
+          <view><text>创建时间</text><text>{{ item.createTime || '—' }}</text></view>
+        </view>
+
+        <view class="card-actions">
+          <text class="link" @click.stop="openPreview(item)">预览</text>
+          <text class="link" @click.stop="openEdit(item)">编辑</text>
+          <text class="link" @click.stop="openVersions(item)">版本历史</text>
+          <text
+            class="link"
+            :class="{ danger: item.enabled }"
+            @click.stop="onToggle(item)"
+          >{{ item.enabled ? '停用' : '启用' }}</text>
+        </view>
+      </view>
+    </view>
+
+    <view v-else class="empty">
+      <view>□</view>
+      <text>{{ active === 'all' ? '暂无线上 Word 模板' : '该状态下暂无模板' }}</text>
+    </view>
+
+    <view class="bottom-action">
+      <view class="primary-btn press" @click="openCreate">新增模板</view>
+    </view>
+
+    <view v-if="versionVisible" class="mask" @click="versionVisible = false">
+      <view class="sheet" @click.stop>
+        <view class="sheet-head">
+          <text>{{ versionTitle }}</text>
+          <text class="link" @click="versionVisible = false">关闭</text>
+        </view>
+        <view v-if="versionLoading" class="empty">加载中…</view>
+        <scroll-view v-else scroll-y class="version-list">
+          <view v-for="item in versions" :key="item.id" class="version-item">
+            <text class="version-no">V{{ item.versionNo }}</text>
+            <view class="version-main">
+              <text>{{ item.changeRemark || '无变更说明' }}</text>
+              <text class="muted small">{{ item.createTime || '—' }}</text>
+            </view>
+            <text class="muted small">{{ item.versionStatus || '' }}</text>
+          </view>
+          <view v-if="!versions.length" class="empty">暂无版本记录</view>
+        </scroll-view>
+      </view>
+    </view>
+
+    <view v-if="previewVisible" class="mask" @click="previewVisible = false">
+      <view class="sheet" @click.stop>
+        <view class="sheet-head">
+          <text>{{ previewTitle }}</text>
+          <text class="link" @click="previewVisible = false">关闭</text>
+        </view>
+        <view v-if="previewLoading" class="empty">加载中…</view>
+        <scroll-view v-else scroll-y class="preview-body">
+          <A4Preview :content="previewContent" />
+        </scroll-view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+import { computed, ref } from 'vue'
+import { onShow } from '@dcloudio/uni-app'
+import { useAuthGuard } from '@/hooks/useAuthGuard'
+import A4Preview from '@/components/A4Preview.vue'
+import { applicationTypes } from '@/data/mock'
+import { go } from '@/utils/router'
+import { getServerConfig } from '@/utils/auth'
+import {
+  TEMPLATE_STATUS_FILTERS,
+  loadOnlineTemplateList,
+  loadOnlineTemplateDetail,
+  loadOnlineTemplateVersions,
+  toggleOnlineTemplate,
+} from '@/api/onlineDocument'
+
+useAuthGuard()
+const meta = applicationTypes.regular || { title: '转正申请', color: '#269a99' }
+const active = ref('all')
+const keyword = ref('')
+const records = ref([])
+const loading = ref(false)
+const versionVisible = ref(false)
+const versionLoading = ref(false)
+const versionTitle = ref('版本历史')
+const versions = ref([])
+const previewVisible = ref(false)
+const previewLoading = ref(false)
+const previewTitle = ref('画布预览')
+const previewContent = ref('')
+
+const list = computed(() =>
+  records.value
+    .filter((row) => active.value === 'all' || row.templateStatus === active.value)
+    .filter((row) => {
+      if (!keyword.value) return true
+      const text = `${row.templateName}${row.templateCode}`.toLowerCase()
+      return text.includes(keyword.value.toLowerCase())
+    }),
+)
+
+const summaryTip = computed(() =>
+  getServerConfig().mode === 'server' ? '数据来自 /hr/online-documents/templates' : '演示数据保存在本机',
+)
+
+function countStatus(status) {
+  if (status === 'all') return records.value.length
+  return records.value.filter((row) => row.templateStatus === status).length
+}
+
+function variableCount(item) {
+  try {
+    const parsed = JSON.parse(item.variablesJson || '[]')
+    return Array.isArray(parsed) ? parsed.length : 0
+  } catch (error) {
+    return 0
+  }
+}
+
+async function loadList() {
+  loading.value = true
+  try {
+    const page = await loadOnlineTemplateList({})
+    records.value = page.list || []
+  } catch (error) {
+    records.value = []
+    uni.showToast({ title: error.message || '模板列表加载失败', icon: 'none' })
+  } finally {
+    loading.value = false
+  }
+}
+
+function resetFilters() {
+  keyword.value = ''
+  active.value = 'all'
+}
+
+function openCreate() {
+  go('/pages/manage/regular-template-form')
+}
+
+function openEdit(item) {
+  if (!item?.id) return
+  go(`/pages/manage/regular-template-form?id=${item.id}`)
+}
+
+function onToggle(item) {
+  const label = item.enabled ? '停用' : '启用'
+  uni.showModal({
+    title: `${label}模板`,
+    content: `确认${label}「${item.templateName || item.templateCode}」?`,
+    success: async (r) => {
+      if (!r.confirm) return
+      uni.showLoading({ title: '处理中...' })
+      try {
+        await toggleOnlineTemplate(item)
+        uni.hideLoading()
+        uni.showToast({ title: `${label}成功`, icon: 'success' })
+        await loadList()
+      } catch (error) {
+        uni.hideLoading()
+        uni.showToast({ title: error.message || `${label}失败`, icon: 'none' })
+      }
+    },
+  })
+}
+
+async function openVersions(item) {
+  versionTitle.value = `${item.templateName || '模板'} · 版本历史`
+  versionVisible.value = true
+  versionLoading.value = true
+  versions.value = []
+  try {
+    versions.value = await loadOnlineTemplateVersions(item.id)
+  } catch (error) {
+    uni.showToast({ title: error.message || '版本历史加载失败', icon: 'none' })
+  } finally {
+    versionLoading.value = false
+  }
+}
+
+async function openPreview(item) {
+  previewTitle.value = `${item.templateName || '模板'} · 画布预览`
+  previewContent.value = ''
+  previewVisible.value = true
+  previewLoading.value = true
+  try {
+    // 列表接口不回传长正文,预览需要按主键取详情
+    const row = await loadOnlineTemplateDetail(item.id, item)
+    previewContent.value = row?.documentContent || ''
+  } catch (error) {
+    uni.showToast({ title: error.message || '正文加载失败', icon: 'none' })
+  } finally {
+    previewLoading.value = false
+  }
+}
+
+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: 130rpx;
+  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;
+}
+.tab-count {
+  margin-left: 8rpx;
+  font-size: 22rpx;
+  color: #a0a7b0;
+}
+.filters {
+  padding: 20rpx 24rpx;
+  background: #fff;
+  border-bottom: 1rpx solid #edf0f2;
+}
+.search-row {
+  display: flex;
+  align-items: center;
+  gap: 20rpx;
+}
+.search-input {
+  flex: 1;
+  min-width: 0;
+  height: 72rpx;
+  padding: 0 24rpx;
+  border-radius: 12rpx;
+  background: #f5f6f7;
+  font-size: 26rpx;
+}
+.reset {
+  flex-shrink: 0;
+  color: #6f7782;
+  font-size: 26rpx;
+}
+.summary {
+  padding: 22rpx 28rpx 6rpx;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 24rpx;
+}
+.summary-right {
+  display: flex;
+  align-items: center;
+  gap: 20rpx;
+  min-width: 0;
+}
+.summary-right .muted {
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+.list {
+  padding: 18rpx 24rpx;
+}
+.tpl-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-tip {
+  margin-top: 16rpx;
+  font-size: 23rpx;
+  color: #6f7782;
+}
+.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;
+  color: #a0a7b0;
+}
+.card-meta text + text {
+  margin-top: 8rpx;
+  color: #1f2329;
+  font-size: 26rpx;
+  font-weight: 500;
+}
+.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;
+}
+.mask {
+  position: fixed;
+  z-index: 20;
+  left: 0;
+  right: 0;
+  top: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.45);
+  display: flex;
+  align-items: flex-end;
+}
+.sheet {
+  width: 100%;
+  max-height: 70vh;
+  padding: 28rpx 28rpx 40rpx;
+  border-radius: 24rpx 24rpx 0 0;
+  background: #fff;
+  box-sizing: border-box;
+}
+.sheet-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 30rpx;
+  font-weight: 600;
+  margin-bottom: 12rpx;
+}
+.version-list {
+  max-height: 52vh;
+}
+.preview-body {
+  max-height: 58vh;
+}
+.version-item {
+  display: flex;
+  align-items: center;
+  gap: 20rpx;
+  padding: 22rpx 0;
+  border-bottom: 1rpx solid #f2f3f5;
+}
+.version-no {
+  width: 68rpx;
+  height: 44rpx;
+  line-height: 44rpx;
+  text-align: center;
+  border-radius: 10rpx;
+  background: #f3f8ff;
+  color: #1677ff;
+  font-size: 24rpx;
+  flex-shrink: 0;
+}
+.version-main {
+  flex: 1;
+  min-width: 0;
+}
+.version-main text {
+  display: block;
+  font-size: 26rpx;
+}
+.version-main text + text {
+  margin-top: 6rpx;
+  color: #a0a7b0;
+}
+@media (min-width: 800px) {
+  .bottom-action {
+    max-width: 520px;
+    left: 50%;
+    transform: translateX(-50%);
+  }
+  .mask {
+    justify-content: center;
+  }
+  .sheet {
+    max-width: 520px;
+    border-radius: 24rpx;
+    margin-bottom: 40rpx;
+  }
+}
+</style>

+ 97 - 0
src/utils/a4-preview.js

@@ -0,0 +1,97 @@
+/**
+ * A4 画布正文解析(配合 components/A4Preview.vue 使用)。
+ *
+ * 后端 documentContent 的字段口径:「A4画布正文JSON或HTML;变量统一使用双花括号编码占位,
+ * 例如 {{EMPLOYEE_NAME}}」。这里统一解析成三种形态:
+ *   - blocks:JSON 画布({ blocks: [...] } 或 block 数组),拆成可渲染的块
+ *   - html  :HTML 正文,交给 rich-text 渲染
+ *   - text  :无法解析时按纯文本展示,保证历史数据也能看到内容
+ */
+
+const TITLE_TYPES = ['title', 'heading', 'h1', 'h2', 'header']
+const SIGN_TYPES = ['sign', 'signature', 'date', 'footer']
+
+/** 把一段文本按 {{变量名}} 切成普通文本 / 变量片段,供预览高亮 */
+export function splitVariableSegments(value) {
+  const source = String(value == null ? '' : value)
+  const pattern = /\{\{\s*([^{}]+?)\s*\}\}/g
+  const segments = []
+  let last = 0
+  let match = pattern.exec(source)
+  while (match) {
+    if (match.index > last) {
+      segments.push({ text: source.slice(last, match.index), isVar: false })
+    }
+    segments.push({ text: match[1], isVar: true })
+    last = match.index + match[0].length
+    match = pattern.exec(source)
+  }
+  if (last < source.length) segments.push({ text: source.slice(last), isVar: false })
+  return segments
+}
+
+function cellText(cell) {
+  if (cell && typeof cell === 'object') return cell.text ?? cell.value ?? ''
+  return cell
+}
+
+function normalizeRows(block = {}) {
+  const rows = block.rows || block.cells || block.table || []
+  if (!Array.isArray(rows)) return []
+  return rows
+    .map((row) => (Array.isArray(row) ? row : [row]))
+    .map((row) => row.map((cell) => splitVariableSegments(cellText(cell))))
+    .filter((row) => row.length)
+}
+
+function normalizeBlocks(parsed) {
+  const raw = Array.isArray(parsed) ? parsed : parsed && parsed.blocks
+  if (!Array.isArray(raw)) return []
+  return raw.reduce((blocks, item, index) => {
+    const key = `block-${index}`
+    if (item == null) return blocks
+    if (typeof item !== 'object') {
+      const text = String(item)
+      if (text.trim()) blocks.push({ key, kind: 'para', segments: splitVariableSegments(text) })
+      return blocks
+    }
+    const type = String(item.type || '').toLowerCase()
+    if (type === 'table') {
+      const rows = normalizeRows(item)
+      if (rows.length) blocks.push({ key, kind: 'table', rows })
+      return blocks
+    }
+    const text = item.text ?? item.content ?? item.value ?? ''
+    if (!String(text).trim()) return blocks
+    const kind = TITLE_TYPES.includes(type) ? 'title' : SIGN_TYPES.includes(type) ? 'sign' : 'para'
+    blocks.push({ key, kind, segments: splitVariableSegments(text) })
+    return blocks
+  }, [])
+}
+
+/** HTML 正文里去掉脚本与内联事件,避免画布内容带出可执行代码 */
+function sanitizeHtml(html) {
+  return String(html)
+    .replace(/<script[\s\S]*?<\/script>/gi, '')
+    .replace(/<script[^>]*>/gi, '')
+    .replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
+}
+
+/**
+ * 解析画布正文。
+ * @returns {{type:'empty'|'blocks'|'html'|'text', blocks:Array, html:string, text:string}}
+ */
+export function parseA4Content(content = '') {
+  const text = String(content == null ? '' : content).trim()
+  if (!text) return { type: 'empty', blocks: [], html: '', text: '' }
+  if (text.startsWith('<')) return { type: 'html', blocks: [], html: sanitizeHtml(text), text: '' }
+  if (text.startsWith('{') || text.startsWith('[')) {
+    try {
+      const blocks = normalizeBlocks(JSON.parse(text))
+      if (blocks.length) return { type: 'blocks', blocks, html: '', text: '' }
+    } catch (error) {
+      // 解析失败按纯文本展示
+    }
+  }
+  return { type: 'text', blocks: [], html: '', text }
+}

+ 69 - 1
src/utils/storage.js

@@ -20,6 +20,9 @@ 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 ONLINE_TEMPLATE_STATE_KEY = 'aimill_hr_online_template_states'
+const ONLINE_TEMPLATE_CREATED_KEY = 'aimill_hr_online_template_created'
+const ONLINE_TEMPLATE_VERSION_KEY = 'aimill_hr_online_template_versions'
 const AUTH_KEY = 'aimill_hr_auth'
 const REMEMBER_KEY = 'aimill_hr_remembered_account'
 const PASSWORD_MEMO_KEY = 'passwordMemo'
@@ -195,7 +198,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, REGULARIZATION_KEY, REGULARIZATION_CREATED_KEY, REGULARIZATION_ID_KEY, REGULARIZATION_BILL_MAP_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, ONLINE_TEMPLATE_STATE_KEY, ONLINE_TEMPLATE_CREATED_KEY, ONLINE_TEMPLATE_VERSION_KEY].forEach((key) =>
     uni.removeStorageSync(key),
   )
 }
@@ -775,3 +778,68 @@ export function removeRegularizationBillId(userId) {
   delete map[String(userId)]
   uni.setStorageSync(REGULARIZATION_BILL_MAP_KEY, map)
 }
+
+// ============ 线上 Word 模板(/hr/online-documents/templates)演示态 ============
+// 服务器模式下模板的增删改查全部走真实接口,这里只兜底演示模式:
+//   - 新建 / 修改的模板落本地,保证列表与详情能串起来
+//   - 记录本机的启用 / 停用等状态覆盖
+//   - 记录模板的版本历史追加
+
+export function getOnlineTemplateStates() {
+  const saved = uni.getStorageSync(ONLINE_TEMPLATE_STATE_KEY)
+  return saved && typeof saved === 'object' ? saved : {}
+}
+
+export function setOnlineTemplateLocalState(id, patch) {
+  if (!id || !patch || typeof patch !== 'object') return null
+  const all = getOnlineTemplateStates()
+  const key = String(id)
+  const prev = all[key] && typeof all[key] === 'object' ? all[key] : {}
+  all[key] = { ...prev, ...patch, updatedAt: formatNow() }
+  uni.setStorageSync(ONLINE_TEMPLATE_STATE_KEY, all)
+  return all[key]
+}
+
+export function getCreatedOnlineTemplates() {
+  const saved = uni.getStorageSync(ONLINE_TEMPLATE_CREATED_KEY)
+  return Array.isArray(saved) ? saved : []
+}
+
+export function addCreatedOnlineTemplate(record) {
+  if (!record || !record.id) return getCreatedOnlineTemplates()
+  const id = String(record.id)
+  const rest = getCreatedOnlineTemplates().filter((item) => String(item.id) !== id)
+  const list = [{ ...record, createTime: record.createTime || formatNow() }, ...rest]
+  uni.setStorageSync(ONLINE_TEMPLATE_CREATED_KEY, list)
+  return list
+}
+
+export function updateCreatedOnlineTemplate(id, patch = {}) {
+  if (!id) return getCreatedOnlineTemplates()
+  const list = getCreatedOnlineTemplates().map((item) =>
+    String(item.id) === String(id) ? { ...item, ...patch, updatedAt: formatNow() } : item,
+  )
+  uni.setStorageSync(ONLINE_TEMPLATE_CREATED_KEY, list)
+  return list
+}
+
+export function getOnlineTemplateVersionMap() {
+  const saved = uni.getStorageSync(ONLINE_TEMPLATE_VERSION_KEY)
+  return saved && typeof saved === 'object' ? saved : {}
+}
+
+export function getOnlineTemplateVersionRecords(templateId) {
+  if (!templateId) return []
+  const hit = getOnlineTemplateVersionMap()[String(templateId)]
+  return Array.isArray(hit) ? hit : []
+}
+
+export function addOnlineTemplateVersionRecord(templateId, record) {
+  if (!templateId || !record) return getOnlineTemplateVersionRecords(templateId)
+  const all = getOnlineTemplateVersionMap()
+  const key = String(templateId)
+  const prev = Array.isArray(all[key]) ? all[key] : []
+  all[key] = [{ ...record, createTime: record.createTime || formatNow() }, ...prev]
+  uni.setStorageSync(ONLINE_TEMPLATE_VERSION_KEY, all)
+  return all[key]
+}