edit.vue 68 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858
  1. <template>
  2. <view class="page-no-tab profile-edit">
  3. <uni-nav-bar
  4. fixed="true"
  5. statusBar="true"
  6. left-icon="back"
  7. title="个人档案"
  8. background-color="#ffffff"
  9. color="#1f2329"
  10. @clickLeft="goBack"
  11. ></uni-nav-bar>
  12. <!-- 概要卡片 -->
  13. <view class="profile-card card section">
  14. <view class="pc-row">
  15. <text class="pc-name">{{ form.name || "—" }}</text>
  16. <view v-if="form.employeeNo" class="job-number">工号 {{ form.employeeNo }}</view>
  17. </view>
  18. <view class="pc-main">{{ [form.positionName, form.deptName].filter(Boolean).join(" · ") || "暂无任职信息" }}</view>
  19. <text v-if="form.company" class="pc-sub">{{ form.company }}</text>
  20. </view>
  21. <!-- 模块切换 -->
  22. <scroll-view class="tabs" scroll-x :show-scrollbar="false">
  23. <view class="tabs-inner">
  24. <view
  25. v-for="tab in tabs"
  26. :key="tab.name"
  27. class="tab press"
  28. :class="{ active: activeTab === tab.name }"
  29. @click="activeTab = tab.name"
  30. >{{ tab.label }}</view>
  31. </view>
  32. </scroll-view>
  33. <view class="tab-body">
  34. <block v-for="group in activeGroups" :key="group.title">
  35. <view class="group-title">
  36. <view class="gt-left">
  37. <view class="gt-bar"></view>
  38. <text class="gt-label">{{ group.title }}</text>
  39. </view>
  40. <text v-if="group.list" class="gt-more press" @click="addItem(group)">+ {{ group.addText || "添加" }}</text>
  41. </view>
  42. <!-- 字段分组 -->
  43. <view v-if="group.fields" class="form-card card section">
  44. <block v-for="(field, i) in group.fields" :key="field.key">
  45. <view v-if="i > 0" class="divider"></view>
  46. <!-- 文本 / 数字 -->
  47. <view v-if="field.type === 'text' || field.type === 'number'" class="field">
  48. <text class="label" :class="{ required: field.required }">{{ field.label }}</text>
  49. <input
  50. class="value-input"
  51. :type="field.type === 'number' ? 'number' : 'text'"
  52. :maxlength="field.maxlength || 60"
  53. :placeholder="field.placeholder || ''"
  54. v-model="form[field.key]"
  55. />
  56. </view>
  57. <!-- 只读 -->
  58. <view v-else-if="field.type === 'readonly'" class="field">
  59. <text class="label">{{ field.label }}</text>
  60. <text class="value muted">{{ form[field.key] || "—" }}</text>
  61. </view>
  62. <!-- 直属上级:选人弹窗 -->
  63. <view v-else-if="field.type === 'enum' && field.source === 'manager'" class="field press" @click="openManagerPicker">
  64. <text class="label">{{ field.label }}</text>
  65. <text :class="form.manager ? 'value' : 'placeholder'">{{ form.manager || "请选择" }}</text>
  66. <text class="arrow">›</text>
  67. </view>
  68. <!-- 下拉选择 -->
  69. <picker
  70. v-else-if="field.type === 'enum'"
  71. mode="selector"
  72. :range="fieldRange(field)"
  73. range-key="label"
  74. :value="fieldIndex(field)"
  75. :disabled="enumDisabled(field)"
  76. @change="onFieldPick(field, $event)"
  77. >
  78. <view class="field press">
  79. <text class="label" :class="{ required: field.required }">{{ field.label }}</text>
  80. <text :class="fieldText(field) ? 'value' : 'placeholder'">{{ fieldText(field) || enumHint(field) || "请选择" }}</text>
  81. <text class="arrow">›</text>
  82. </view>
  83. </picker>
  84. <!-- 日期 -->
  85. <picker
  86. v-else-if="field.type === 'date'"
  87. mode="date"
  88. :value="form[field.key]"
  89. :start="field.start || '1940-01-01'"
  90. :end="field.end || '2099-12-31'"
  91. @change="onFieldDate(field, $event)"
  92. >
  93. <view class="field press">
  94. <text class="label" :class="{ required: field.required }">{{ field.label }}</text>
  95. <text :class="form[field.key] ? 'value' : 'placeholder'">{{ form[field.key] || "请选择" }}</text>
  96. <text class="arrow">›</text>
  97. </view>
  98. </picker>
  99. <!-- 隶属部门:树形选择(层级不限,公司层已在 deptTree 里去掉) -->
  100. <view v-else-if="field.type === 'dept'" class="field press" @click="openDeptPicker">
  101. <text class="label" :class="{ required: field.required }">{{ field.label }}</text>
  102. <text v-if="!form.groupId" class="placeholder">请先选择所属机构</text>
  103. <text v-else :class="form.deptName ? 'value' : 'placeholder'">{{ form.deptName || "请选择" }}</text>
  104. <text class="arrow">›</text>
  105. </view>
  106. <!-- 开关 -->
  107. <view v-else-if="field.type === 'switch'" class="field">
  108. <text class="label">{{ field.label }}</text>
  109. <view class="switch-wrap">
  110. <switch :checked="form[field.key]" color="#157a2c" @change="onFieldSwitch(field, $event)" />
  111. <text class="switch-text">{{ form[field.key] ? "启用" : "待岗" }}</text>
  112. </view>
  113. </view>
  114. <!-- 多行文本 -->
  115. <view v-else-if="field.type === 'textarea'" class="field field-block">
  116. <text class="label">{{ field.label }}</text>
  117. <textarea
  118. class="value-textarea"
  119. :maxlength="field.maxlength || 500"
  120. :placeholder="field.placeholder || ''"
  121. v-model="form[field.key]"
  122. />
  123. </view>
  124. <!-- 附件 -->
  125. <view v-else-if="field.type === 'file'" class="field field-block">
  126. <text class="label">{{ field.label }}</text>
  127. <view class="field-file"><fileMain v-model="form[field.key]" /></view>
  128. </view>
  129. </block>
  130. </view>
  131. <!-- 可编辑明细列表 -->
  132. <block v-if="group.list">
  133. <view v-for="(item, idx) in form[group.list]" :key="item.key || idx" class="list-item card section">
  134. <view class="li-head">
  135. <text class="li-title">{{ listItemTitle(group, item, idx) }}</text>
  136. <view class="li-actions">
  137. <text class="li-action press" @click="openItemEditor(group, item, idx)">编辑</text>
  138. <text class="li-action danger press" @click="removeItem(group, idx)">删除</text>
  139. </view>
  140. </view>
  141. <view v-for="(line, li) in listItemLines(group, item)" :key="li" class="li-line">{{ line }}</view>
  142. </view>
  143. <view v-if="!form[group.list].length" class="empty card section">
  144. <text>{{ group.emptyText || "暂无记录" }}</text>
  145. </view>
  146. </block>
  147. <!-- 只读明细列表 -->
  148. <block v-if="group.readonlyList">
  149. <view v-for="(item, idx) in readonlyList(group)" :key="idx" class="list-item card section">
  150. <view class="li-head">
  151. <text class="li-title">{{ item.title }}</text>
  152. <text v-if="item.tag" class="li-tag">{{ item.tag }}</text>
  153. </view>
  154. <view v-for="(line, li) in item.lines" :key="li" class="li-line">{{ line }}</view>
  155. </view>
  156. <view v-if="!readonlyList(group).length" class="empty card section">
  157. <text>{{ group.emptyText || "暂无记录(请在 PC 端维护)" }}</text>
  158. </view>
  159. </block>
  160. <!-- 劳动合同(只读) -->
  161. <view v-if="group.readonlyContract" class="form-card card section">
  162. <view v-for="(row, ri) in contractRows" :key="ri">
  163. <view v-if="ri > 0" class="divider"></view>
  164. <view class="field">
  165. <text class="label">{{ row.label }}</text>
  166. <text class="value muted">{{ row.value || "—" }}</text>
  167. </view>
  168. </view>
  169. </view>
  170. <!-- 业务记录(只读) -->
  171. <block v-if="group.records">
  172. <view v-for="sec in recordSections" :key="sec.title" class="card section record-card">
  173. <view class="record-head">
  174. <text class="record-title">{{ sec.title }}</text>
  175. <text class="record-count">{{ sec.items.length }}</text>
  176. </view>
  177. <view v-for="(line, li) in sec.items" :key="li" class="record-line">{{ line }}</view>
  178. <view v-if="!sec.items.length" class="record-empty">暂无记录</view>
  179. </view>
  180. </block>
  181. </block>
  182. </view>
  183. <view class="footer-bar">
  184. <view class="ghost-btn press" @click="resetForm">重置</view>
  185. <view class="primary-btn press" :class="{ disabled: saving }" @click="submit">
  186. {{ saving ? "保存中…" : "保存" }}
  187. </view>
  188. </view>
  189. <!-- 明细编辑弹层 -->
  190. <view v-if="editor.visible" class="popup-mask" @click="closeEditor">
  191. <view class="popup" @click.stop>
  192. <view class="popup-head">
  193. <text class="popup-title">{{ editor.title }}</text>
  194. <text class="popup-close press" @click="closeEditor">✕</text>
  195. </view>
  196. <scroll-view class="popup-body" scroll-y>
  197. <view v-for="f in visibleEditorFields" :key="f.key" class="p-field">
  198. <text class="p-label" :class="{ required: f.required }">{{ f.label }}</text>
  199. <input
  200. v-if="f.type === 'text' || f.type === 'number'"
  201. class="p-input"
  202. :type="f.type === 'number' ? 'digit' : 'text'"
  203. :maxlength="f.maxlength || 60"
  204. :placeholder="f.placeholder || ''"
  205. v-model="editor.values[f.key]"
  206. />
  207. <textarea
  208. v-else-if="f.type === 'textarea'"
  209. class="p-textarea"
  210. :maxlength="f.maxlength || 300"
  211. :placeholder="f.placeholder || ''"
  212. v-model="editor.values[f.key]"
  213. />
  214. <picker
  215. v-else-if="f.type === 'date'"
  216. mode="date"
  217. :value="editor.values[f.key]"
  218. @change="onEditorDate(f, $event)"
  219. >
  220. <view class="p-picker">
  221. <text :class="editor.values[f.key] ? 'p-value' : 'p-placeholder'">{{ editor.values[f.key] || "请选择" }}</text>
  222. <text class="arrow">›</text>
  223. </view>
  224. </picker>
  225. <picker
  226. v-else-if="f.type === 'select'"
  227. mode="selector"
  228. :range="editorFieldRange(f)"
  229. range-key="label"
  230. :value="editorFieldIndex(f)"
  231. @change="onEditorPick(f, $event)"
  232. >
  233. <view class="p-picker">
  234. <text :class="editorFieldText(f) ? 'p-value' : 'p-placeholder'">{{ editorFieldText(f) || "请选择" }}</text>
  235. <text class="arrow">›</text>
  236. </view>
  237. </picker>
  238. <view v-else-if="f.type === 'file'" class="p-file"><fileMain v-model="editor.values[f.key]" /></view>
  239. </view>
  240. </scroll-view>
  241. <view class="popup-actions">
  242. <text class="popup-btn press" @click="closeEditor">取消</text>
  243. <text class="popup-btn primary press" @click="confirmEditor">确定</text>
  244. </view>
  245. </view>
  246. </view>
  247. <selectUserDialog ref="userPicker" @confirm="onUserPicked" />
  248. <!-- 隶属部门:树形选择器(层级不限,可展开折叠,公司层已去掉) -->
  249. <ba-tree-picker
  250. ref="deptPicker"
  251. :multiple="false"
  252. :localdata="deptTree"
  253. valueKey="id"
  254. textKey="name"
  255. childrenKey="children"
  256. title="选择部门"
  257. :border="true"
  258. confirm-color="#157a2c"
  259. switch-color="#157a2c"
  260. @select-change="onDeptTreeSelect"
  261. />
  262. </view>
  263. </template>
  264. <script>
  265. import fileMain from "@/pages/doc/index.vue";
  266. import selectUserDialog from "@/components/selectUserDialog.vue";
  267. import baTreePicker from "@/components/ba-tree-picker/ba-tree-picker.vue";
  268. import { getDictByCode } from "@/api/common";
  269. import { listOrganizations } from "@/api/hr/organization";
  270. import { pagePosition, getDeptPositionSequences } from "@/api/hr/position";
  271. import { getPositionSequenceList } from "@/api/hr/positionSequence";
  272. import { back } from "@/utils/hr/router";
  273. import { buildDeptTreeUnderLegal } from "@/utils/hr/organization-tree";
  274. import { getOrgOptionsWithFallback } from "@/utils/hr/current-org";
  275. import {
  276. ALLOWANCE_UNIT_OPTIONS,
  277. CERT_TYPE_OPTIONS,
  278. DEGREE_OPTIONS,
  279. EDUCATION_OPTIONS,
  280. EMPLOYEE_STATUS_LABEL,
  281. EMPLOYEE_STATUS_OPTIONS,
  282. ETHNIC_OPTIONS,
  283. MARITAL_OPTIONS,
  284. SEX_OPTIONS,
  285. SOCIAL_SECURITY_OPTIONS,
  286. STOP_REASON_OPTIONS,
  287. allowanceUnitLabel,
  288. buildProfileForm,
  289. buildUserPayload,
  290. compactPayload,
  291. currentUserId,
  292. dictToPositionNatureOptions,
  293. dictToStatusOptions,
  294. enumLabel,
  295. fetchUserProfile,
  296. keepIdString,
  297. refetchMyProfile,
  298. saveEmployee,
  299. syncWorkExperiences,
  300. listFactories,
  301. CHANGE_TYPE_LABELS,
  302. APPROVAL_STATUS_LABELS,
  303. EFFECTIVE_STATUS_LABELS,
  304. REGULARIZATION_TYPE_LABELS,
  305. REGULARIZATION_RESULT_LABELS,
  306. RESIGNATION_TYPE_LABELS,
  307. PAYSLIP_STATUS_LABELS,
  308. } from "@/api/hr/user";
  309. /** 数字/字符串统一成 picker 需要的 {label,value} */
  310. function toOptions(list) {
  311. if (!Array.isArray(list)) return [];
  312. return list.map((item) =>
  313. item && typeof item === "object"
  314. ? { label: String(item.label != null ? item.label : item.name != null ? item.name : item.value), value: item.value }
  315. : { label: String(item), value: item }
  316. );
  317. }
  318. /** 去掉非数字字符(卡号展示用) */
  319. function digitsOnly(value) {
  320. return String(value == null ? "" : value).replace(/\D/g, "");
  321. }
  322. /** 在部门树里按 id 找节点,返回「一级 / 二级 / 三级」路径名(与详情接口回填、树选择展示口径一致) */
  323. function findDeptPath(tree = [], id, parents = []) {
  324. for (let i = 0; i < tree.length; i += 1) {
  325. const node = tree[i];
  326. const path = parents.concat(node.name);
  327. if (String(node.id) === String(id)) return path.filter(Boolean).join(" / ");
  328. const hit = findDeptPath(node.children || [], id, path);
  329. if (hit) return hit;
  330. }
  331. return "";
  332. }
  333. function formatCardNumber(value) {
  334. const text = digitsOnly(value);
  335. return text ? text.replace(/(.{4})/g, "$1 ").trim() : "";
  336. }
  337. function tailOf(value) {
  338. const text = digitsOnly(value);
  339. return text ? text.slice(-4) : "----";
  340. }
  341. function calcAge(birthday) {
  342. if (!birthday) return "";
  343. const d = new Date(String(birthday).replace(/-/g, "/"));
  344. if (isNaN(d.getTime())) return "";
  345. const now = new Date();
  346. let age = now.getFullYear() - d.getFullYear();
  347. const m = now.getMonth() - d.getMonth();
  348. if (m < 0 || (m === 0 && now.getDate() < d.getDate())) age -= 1;
  349. return age >= 0 && age < 130 ? String(age) : "";
  350. }
  351. const JOB_FIELDS = [
  352. { key: "startDate", label: "工作开始日期", type: "date", required: true },
  353. { key: "endDate", label: "工作结束日期", type: "date", required: true },
  354. { key: "company", label: "前公司名称", type: "text", required: true, maxlength: 50, placeholder: "请输入公司名称" },
  355. { key: "position", label: "前公司岗位", type: "text", maxlength: 30, placeholder: "请输入岗位" },
  356. { key: "department", label: "前公司部门", type: "text", maxlength: 30, placeholder: "请输入部门" },
  357. { key: "leaveReason", label: "离职原因", type: "text", maxlength: 40, placeholder: "请输入" },
  358. { key: "description", label: "工作描述", type: "textarea", maxlength: 300, placeholder: "请输入工作描述" },
  359. ];
  360. const BANK_FIELDS = [
  361. { key: "bankName", label: "开户行", type: "text", required: true, maxlength: 50, placeholder: "如:中国工商银行" },
  362. { key: "bankBranch", label: "银行网点", type: "text", maxlength: 60, placeholder: "请输入银行网点" },
  363. { key: "bankAccount", label: "银行卡号", type: "number", required: true, maxlength: 24, placeholder: "请输入银行卡号" },
  364. { key: "bankAccountName", label: "开户人姓名", type: "text", required: true, maxlength: 20, placeholder: "请输入开户人" },
  365. ];
  366. const SOCIAL_FIELDS = [
  367. { key: "type", label: "社保类型", type: "select", required: true, options: SOCIAL_SECURITY_OPTIONS },
  368. {
  369. key: "isEnabled",
  370. label: "是否启用",
  371. type: "select",
  372. options: [
  373. { label: "是", value: 1 },
  374. { label: "否", value: 0 },
  375. ],
  376. },
  377. { key: "base", label: "缴费基数", type: "number", maxlength: 12, placeholder: "元" },
  378. { key: "insuredDate", label: "参保日期", type: "date" },
  379. { key: "stopDate", label: "停保日期", type: "date" },
  380. { key: "stopReason", label: "停缴记录", type: "select", options: STOP_REASON_OPTIONS },
  381. ];
  382. const CERT_FIELDS = [
  383. { key: "type", label: "证照类型", type: "select", required: true, options: CERT_TYPE_OPTIONS },
  384. { key: "number", label: "证照编号", type: "text", required: true, maxlength: 30, placeholder: "请输入证照编号" },
  385. { key: "level", label: "等级", type: "select", options: ["初级", "中级", "高级"] },
  386. { key: "startDate", label: "有效期开始", type: "date", required: true },
  387. { key: "endDate", label: "有效期结束", type: "date", required: true },
  388. { key: "issueDate", label: "颁布日期", type: "date" },
  389. { key: "status", label: "状态", type: "select", required: true, options: ["有效", "失效"] },
  390. {
  391. key: "hasPositionAllowance",
  392. label: "是否有岗位津贴",
  393. type: "select",
  394. options: [
  395. { label: "是", value: 1 },
  396. { label: "否", value: 0 },
  397. ],
  398. },
  399. {
  400. key: "positionAllowanceAmount",
  401. label: "津贴金额",
  402. type: "number",
  403. maxlength: 12,
  404. placeholder: "请输入",
  405. showIf: (values) => Number(values.hasPositionAllowance) === 1,
  406. },
  407. {
  408. key: "positionAllowanceUnit",
  409. label: "津贴单位",
  410. type: "select",
  411. options: ALLOWANCE_UNIT_OPTIONS,
  412. showIf: (values) => Number(values.hasPositionAllowance) === 1,
  413. },
  414. { key: "attachment", label: "证照附件", type: "file" },
  415. { key: "remark", label: "备注", type: "textarea", maxlength: 200, placeholder: "请输入" },
  416. ];
  417. const EMPTY_JOB = { startDate: "", endDate: "", company: "", position: "", department: "", leaveReason: "", description: "" };
  418. const EMPTY_BANK = { bankName: "", bankBranch: "", bankAccount: "", bankAccountName: "" };
  419. const EMPTY_SOCIAL = { type: "", isEnabled: 0, base: "", insuredDate: "", stopDate: "", stopReason: "" };
  420. const EMPTY_CERT = {
  421. type: "",
  422. number: "",
  423. level: "",
  424. startDate: "",
  425. endDate: "",
  426. issueDate: "",
  427. status: "有效",
  428. hasPositionAllowance: 0,
  429. positionAllowanceAmount: "",
  430. positionAllowanceUnit: "",
  431. attachment: [],
  432. remark: "",
  433. };
  434. let seq = 0;
  435. function newKey(prefix) {
  436. seq += 1;
  437. return `${prefix}_new_${Date.now().toString(36)}_${seq}`;
  438. }
  439. export default {
  440. components: { fileMain, selectUserDialog, baTreePicker },
  441. data() {
  442. return {
  443. tabs: [
  444. { name: "base", label: "基本信息" },
  445. { name: "experience", label: "教育履历" },
  446. { name: "payroll", label: "薪酬社保" },
  447. { name: "qualification", label: "资质证照" },
  448. { name: "contract", label: "合同记录" },
  449. ],
  450. activeTab: "base",
  451. saving: false,
  452. form: buildProfileForm({}),
  453. raw: {},
  454. rawPositionList: [],
  455. rawPrimaryPositionId: "",
  456. originalJobs: [],
  457. jobsDirty: false,
  458. // 选项数据
  459. orgList: [],
  460. legalOptions: [],
  461. factoryOptions: [],
  462. positionSource: [],
  463. sequenceList: [],
  464. sequenceOptions: [],
  465. positionNatureOptions: [
  466. { label: "全职", value: "全职" },
  467. { label: "兼职", value: "兼职" },
  468. { label: "实习", value: "实习" },
  469. { label: "劳务", value: "劳务" },
  470. ],
  471. statusChoices: EMPLOYEE_STATUS_OPTIONS,
  472. deptTree: [],
  473. // 明细编辑弹层
  474. editor: { visible: false, title: "", fields: [], values: {}, index: -1, list: "" },
  475. baseGroups: [],
  476. experienceGroups: [],
  477. payrollGroups: [],
  478. qualificationGroups: [],
  479. contractGroups: [],
  480. };
  481. },
  482. computed: {
  483. activeGroups() {
  484. if (this.activeTab === "base") return this.baseGroups;
  485. if (this.activeTab === "experience") return this.experienceGroups;
  486. if (this.activeTab === "payroll") return this.payrollGroups;
  487. if (this.activeTab === "qualification") return this.qualificationGroups;
  488. return this.contractGroups;
  489. },
  490. /** 岗位下拉:按岗位序列 + 部门收敛 */
  491. positionChoices() {
  492. let list = this.positionSource || [];
  493. const seqName = this.form.positionSequenceName;
  494. if (seqName) {
  495. const filtered = list.filter((p) => !p.sequence || String(p.sequence) === String(seqName));
  496. if (filtered.length) list = filtered;
  497. }
  498. if (this.form.deptId) {
  499. const filtered = list.filter((p) => !p.deptId || String(p.deptId) === String(this.form.deptId));
  500. if (filtered.length) list = filtered;
  501. }
  502. return list.map((p) => ({ label: p.level ? `${p.name}(${p.level})` : p.name, value: p.id }));
  503. },
  504. /** 岗位层级下拉:取自所选岗位的层级集合 */
  505. levelChoices() {
  506. const pos = (this.positionSource || []).find((p) => String(p.id) === String(this.form.positionId));
  507. const levels = pos && Array.isArray(pos.levels) ? pos.levels : [];
  508. const options = levels.map((lv) => ({
  509. label: typeof lv === "object" ? lv.name || lv.level : String(lv),
  510. value: typeof lv === "object" ? lv.name || lv.level : lv,
  511. }));
  512. if (options.length) return options;
  513. // 岗位未回填层级集合时,至少保留当前值可选,避免选择器为空
  514. return this.form.staffingLevel ? [{ label: this.form.staffingLevel, value: this.form.staffingLevel }] : [];
  515. },
  516. contractRows() {
  517. const c = this.form.contract || {};
  518. return [
  519. { label: "合同分类", value: c.category },
  520. { label: "合同编号", value: c.name },
  521. { label: "签订时间", value: c.signDate },
  522. { label: "开始时间", value: c.startDate },
  523. { label: "结束时间", value: c.endDate },
  524. { label: "生效状态", value: c.status },
  525. { label: "备注", value: c.remark },
  526. ];
  527. },
  528. recordSections() {
  529. const r = this.raw || {};
  530. const changes = (r.changes || []).map((item) => {
  531. const type = enumLabel(CHANGE_TYPE_LABELS, item.changeType) || "异动";
  532. const status =
  533. enumLabel(APPROVAL_STATUS_LABELS, item.approvalStatus) ||
  534. enumLabel(EFFECTIVE_STATUS_LABELS, item.effectiveStatus);
  535. return `${type}|生效 ${item.effectiveDate || "—"}|${status || "—"}`;
  536. });
  537. const regulars = (r.regularizations || []).map((item) => {
  538. const type = enumLabel(REGULARIZATION_TYPE_LABELS, item.regularizationType) || "转正";
  539. const result =
  540. enumLabel(REGULARIZATION_RESULT_LABELS, item.result) ||
  541. enumLabel(APPROVAL_STATUS_LABELS, item.approvalStatus);
  542. return `${type}|计划 ${item.plannedRegularDate || "—"}|实际 ${item.effectiveDate || "—"}|${result || "—"}`;
  543. });
  544. const resignations = (r.resignations || []).map((item) => {
  545. const type = enumLabel(RESIGNATION_TYPE_LABELS, item.resignationType) || "离职";
  546. const status =
  547. enumLabel(APPROVAL_STATUS_LABELS, item.approvalStatus) ||
  548. enumLabel(EFFECTIVE_STATUS_LABELS, item.effectiveStatus);
  549. return `${type}|申请 ${item.applyDate || "—"}|生效 ${item.effectiveDate || "—"}|${status || "—"}`;
  550. });
  551. const performances = (r.performanceRecords || []).map(
  552. (item) =>
  553. `${item.cycleCode || item.cycleName || "绩效"}|得分 ${item.finalScore != null ? item.finalScore : "—"}|等级 ${item.finalLevel || "—"}`
  554. );
  555. const payslips = (r.payslipSummaries || []).map((item) => {
  556. const status = enumLabel(PAYSLIP_STATUS_LABELS, item.payslipStatus);
  557. return `${item.payrollMonth || "—"}|${status || "—"}`;
  558. });
  559. const rewards = (r.rewardsPunishments || []).map(
  560. (item) =>
  561. `${Number(item.type) === 2 ? "惩罚" : "奖励"}|${item.desce || ""}|${item.startTime || ""}`
  562. );
  563. return [
  564. { title: "人事异动", items: changes },
  565. { title: "转正记录", items: regulars },
  566. { title: "离职记录", items: resignations },
  567. { title: "绩效结果", items: performances },
  568. { title: "工资条", items: payslips },
  569. { title: "奖惩记录", items: rewards },
  570. ];
  571. },
  572. visibleEditorFields() {
  573. return (this.editor.fields || []).filter((f) => (typeof f.showIf === "function" ? f.showIf(this.editor.values) : true));
  574. },
  575. },
  576. onLoad() {
  577. this.buildGroups();
  578. this.loadOptions();
  579. this.loadProfile();
  580. },
  581. methods: {
  582. goBack() {
  583. back("/pages/index/index");
  584. },
  585. /* ================= 配置 ================= */
  586. buildGroups() {
  587. this.baseGroups = [
  588. {
  589. title: "身份信息",
  590. fields: [
  591. { key: "name", label: "姓名", type: "text", required: true, maxlength: 20, placeholder: "请输入姓名" },
  592. { key: "gender", label: "性别", type: "enum", options: SEX_OPTIONS },
  593. { key: "idCard", label: "身份证号", type: "text", maxlength: 18, placeholder: "请输入 18 位身份证号" },
  594. { key: "birthDate", label: "出生日期", type: "date" },
  595. { key: "age", label: "年龄", type: "readonly" },
  596. { key: "ethnic", label: "民族", type: "enum", options: ETHNIC_OPTIONS },
  597. { key: "politicalStatus", label: "政治面貌", type: "text", maxlength: 20, placeholder: "党员、群众等" },
  598. { key: "maritalStatus", label: "婚姻状况", type: "enum", options: MARITAL_OPTIONS },
  599. { key: "nativePlace", label: "籍贯", type: "text", maxlength: 30, placeholder: "省 / 市" },
  600. { key: "censusAddress", label: "户籍地址", type: "text", maxlength: 100, placeholder: "请输入户籍地址" },
  601. { key: "currentAddress", label: "现居住地址", type: "text", maxlength: 100, placeholder: "请输入现居住地址" },
  602. { key: "workAddress", label: "工作地址", type: "text", maxlength: 60, placeholder: "实际办公地点" },
  603. ],
  604. },
  605. {
  606. title: "组织与任职",
  607. fields: [
  608. { key: "employeeNo", label: "工号", type: "readonly" },
  609. { key: "account", label: "用户账号", type: "readonly" },
  610. { key: "groupId", label: "所属机构", type: "enum", source: "legal", displayKey: "company", required: true },
  611. { key: "factoryId", label: "所属工厂", type: "enum", source: "factory", displayKey: "factory" },
  612. { key: "dept", label: "隶属部门", type: "dept", required: true },
  613. { key: "positionSequenceId", label: "岗位序列", type: "enum", source: "sequence", displayKey: "positionSequenceName", required: true },
  614. { key: "positionId", label: "岗位", type: "enum", source: "position", displayKey: "positionName", required: true },
  615. { key: "employmentType", label: "岗位性质", type: "enum", source: "positionNature", displayKey: "employmentTypeName", required: true },
  616. { key: "staffingLevel", label: "岗位层级", type: "enum", source: "level", required: true },
  617. { key: "managerId", label: "直属上级", type: "enum", source: "manager", displayKey: "manager" },
  618. { key: "hireDate", label: "入职时间", type: "date", required: true },
  619. { key: "regularDate", label: "转正日期", type: "date" },
  620. { key: "status", label: "员工状态", type: "enum", source: "status", required: true },
  621. { key: "keyProcess", label: "关键工序", type: "text", maxlength: 30, placeholder: "多个用英文逗号分隔" },
  622. { key: "enabled", label: "是否启用", type: "switch" },
  623. ],
  624. },
  625. {
  626. title: "联系方式",
  627. fields: [
  628. { key: "phone", label: "手机号码", type: "number", required: true, maxlength: 11, placeholder: "11 位手机号" },
  629. { key: "email", label: "邮箱", type: "text", maxlength: 50, placeholder: "name@company.com" },
  630. { key: "wechat", label: "微信号", type: "text", maxlength: 30, placeholder: "请输入微信号" },
  631. { key: "emergencyContact", label: "紧急联系人", type: "text", maxlength: 20, placeholder: "请输入姓名" },
  632. { key: "emergencyRelation", label: "联系人关系", type: "text", maxlength: 20, placeholder: "如配偶、父母" },
  633. { key: "emergencyPhone", label: "紧急联系电话", type: "number", maxlength: 11, placeholder: "请输入电话" },
  634. ],
  635. },
  636. ];
  637. this.experienceGroups = [
  638. {
  639. title: "教育经历",
  640. fields: [
  641. { key: "education", label: "最高学历", type: "enum", options: EDUCATION_OPTIONS },
  642. { key: "degree", label: "学位", type: "enum", options: DEGREE_OPTIONS },
  643. { key: "school", label: "毕业院校", type: "text", maxlength: 40, placeholder: "请输入毕业院校" },
  644. { key: "major", label: "所学专业", type: "text", maxlength: 40, placeholder: "请输入所学专业" },
  645. { key: "graduationDate", label: "毕业时间", type: "date" },
  646. ],
  647. },
  648. {
  649. title: "工作履历",
  650. list: "previousJobs",
  651. listFields: JOB_FIELDS,
  652. addText: "添加履历",
  653. emptyText: "暂无入职前工作履历",
  654. },
  655. ];
  656. this.payrollGroups = [
  657. {
  658. title: "薪酬银行",
  659. list: "bankAccounts",
  660. listFields: BANK_FIELDS,
  661. addText: "添加银行",
  662. emptyText: "暂无银行账户",
  663. },
  664. {
  665. title: "社保公积金",
  666. list: "socialSecurityRecords",
  667. listFields: SOCIAL_FIELDS,
  668. addText: "添加记录",
  669. emptyText: "暂无社保公积金记录",
  670. },
  671. ];
  672. this.qualificationGroups = [
  673. {
  674. title: "资质证照",
  675. list: "certificates",
  676. listFields: CERT_FIELDS,
  677. addText: "添加证照",
  678. emptyText: "暂无资质证照记录",
  679. },
  680. { title: "工种", readonlyList: "professions", emptyText: "暂无工种记录(请在 PC 端维护)" },
  681. { title: "技能", readonlyList: "skills", emptyText: "暂无技能记录(请在 PC 端维护)" },
  682. {
  683. title: "档案备注",
  684. fields: [
  685. {
  686. key: "remarks",
  687. label: "备注",
  688. type: "textarea",
  689. maxlength: 500,
  690. placeholder: "补充需要说明的员工档案信息",
  691. },
  692. ],
  693. },
  694. ];
  695. this.contractGroups = [{ title: "劳动合同", readonlyContract: true }, { title: "业务记录", records: true }];
  696. },
  697. /* ================= 选项加载 ================= */
  698. async loadOptions() {
  699. this.loadOrganizations();
  700. this.loadFactories();
  701. this.loadPositions();
  702. this.loadSequenceMeta();
  703. this.loadSequences();
  704. this.loadDicts();
  705. },
  706. async loadOrganizations() {
  707. try {
  708. // 组织树仍取全量:隶属部门的级联要按当前机构从中过滤出部门
  709. this.orgList = await listOrganizations({}, false);
  710. } catch (error) {
  711. this.orgList = [];
  712. }
  713. // 所属机构候选项与 PC 一致(EmployeeArchiveForm.legalEntities):
  714. // 主源是登录缓存 loginChangeGroupVOList(当前账号所属公司),不再把全量机构树全列出来;
  715. // 缓存缺名称时回落组织树兜底,编辑老档案时补当前项,避免下拉只显示 ID。
  716. this.legalOptions = getOrgOptionsWithFallback(
  717. this.orgList,
  718. this.form && this.form.groupId,
  719. this.form && this.form.company
  720. ).map((item) => ({ label: item.name || String(item.id), value: item.id }));
  721. this.refreshDeptTree();
  722. this.hydrateFromOptions();
  723. },
  724. async loadFactories() {
  725. const list = await listFactories(false);
  726. this.factoryOptions = list.map((item) => ({ label: item.name, value: item.id }));
  727. this.hydrateFromOptions();
  728. },
  729. async loadPositions() {
  730. try {
  731. const page = await pagePosition({ pageNum: 1, size: 1000 });
  732. const rows = (page && page.list) || [];
  733. this.positionSource = rows.map((raw) => ({
  734. id: String(raw.id != null ? raw.id : raw.positionId || ""),
  735. name: raw.positionName || raw.name || "",
  736. deptId: raw.deptId != null ? raw.deptId : "",
  737. level: raw.positionLevel || "",
  738. levels: Array.isArray(raw.positionLevels) ? raw.positionLevels : [],
  739. sequence: raw.positionSequence || "",
  740. }));
  741. } catch (error) {
  742. this.positionSource = [];
  743. }
  744. this.hydrateFromOptions();
  745. },
  746. /**
  747. * 全局岗位序列列表:只作「序列名称 → 主键」的回填兜底(PC 的 sequenceLevelMap 同用途),
  748. * **不再当作下拉选项** —— /hr/positionSequence/getPositionSequenceList 在部分环境返回空,
  749. * 之前用它填下拉,导致「岗位序列」选择器一片空白(2026-10-15 用户报障)。
  750. */
  751. async loadSequenceMeta() {
  752. try {
  753. const list = await getPositionSequenceList();
  754. this.sequenceList = Array.isArray(list) ? list : [];
  755. } catch (error) {
  756. this.sequenceList = [];
  757. }
  758. },
  759. /**
  760. * 岗位序列下拉:与 PC EmployeeArchiveForm.loadPositionSequences 同口径 ——
  761. * 按「隶属部门」级联拉 /fm/tmaindepartmentposition/sequence/list?deptId=,
  762. * 未选部门时清空(PC 同样是「请先选择隶属部门」+ 禁用)。
  763. * @param {object} options resetStale=true 时,把不属于新部门的已选序列一并清掉(换部门时才用)
  764. */
  765. async loadSequences(options = {}) {
  766. const deptId = this.form && this.form.deptId;
  767. if (!deptId) {
  768. this.sequenceOptions = [];
  769. return;
  770. }
  771. try {
  772. const data = await getDeptPositionSequences(deptId, false);
  773. const rows = Array.isArray(data) ? data : (data && (data.list || data.records)) || [];
  774. const map = new Map();
  775. rows.forEach((item) => {
  776. const id = item && (item.positionSequenceId != null ? item.positionSequenceId : item.id);
  777. if (id == null || id === "") return;
  778. const key = String(id);
  779. if (map.has(key)) return;
  780. map.set(key, { label: item.positionSequence || item.name || "", value: key });
  781. });
  782. this.sequenceOptions = Array.from(map.values());
  783. } catch (error) {
  784. this.sequenceOptions = [];
  785. console.log("[hr/profile/edit] 岗位序列加载失败:", (error && error.message) || error);
  786. }
  787. if (options.resetStale && this.form.positionSequenceId) {
  788. const stillValid = this.sequenceOptions.some(
  789. (item) => String(item.value) === String(this.form.positionSequenceId)
  790. );
  791. if (!stillValid) {
  792. this.form.positionSequenceId = "";
  793. this.form.positionSequenceName = "";
  794. }
  795. }
  796. this.hydrateFromOptions();
  797. },
  798. async loadDicts() {
  799. try {
  800. const dict = await getDictByCode("hr_user_status");
  801. const list = dictToStatusOptions(dict);
  802. if (list.length) this.statusChoices = list;
  803. } catch (error) {
  804. // 字典缺失时保留内置兜底选项
  805. }
  806. try {
  807. const dict = await getDictByCode("position_nature");
  808. const list = dictToPositionNatureOptions(dict);
  809. if (list.length) this.positionNatureOptions = list;
  810. } catch (error) {
  811. // 岗位性质字典缺失时留空,由后端按岗位收敛
  812. }
  813. this.hydrateFromOptions();
  814. },
  815. refreshDeptTree() {
  816. if (!this.form.groupId) {
  817. this.deptTree = [];
  818. return;
  819. }
  820. // 所属机构已锁定为当前公司,公司这一层不再作为部门级联的可选项:
  821. // 直接从部门层开始(否则第一列会是一列只有一个「公司」的冗余项,
  822. // 选出来的路径也会带上公司名,与详情接口回填的「研发部」口径不一致)。
  823. const nodes = buildDeptTreeUnderLegal(this.orgList, this.form.groupId);
  824. const legal = nodes.find((node) => String(node.id) === String(this.form.groupId)) || nodes[0];
  825. this.deptTree = legal && Array.isArray(legal.children) ? legal.children : [];
  826. },
  827. /* ================= 名称 / 主键兜底回填(对齐 PC attachOrgMeta、resolveSequenceForEdit) ================= */
  828. /** 只补空值,不覆盖已回填内容;组织 / 工厂 / 岗位等候选加载完成后都要再跑一次 */
  829. hydrateFromOptions() {
  830. const form = this.form;
  831. if (!form) return;
  832. // ① 所属机构:档案接口常只回 groupId 不回 groupName,用组织列表补名称
  833. if (form.groupId && !form.company) {
  834. const hit = (this.legalOptions || []).find((item) => String(item.value) === String(form.groupId));
  835. if (hit) form.company = hit.label;
  836. }
  837. // ② 所属工厂:只有 factoryId 时用工厂列表补名称
  838. if (form.factoryId && !form.factory) {
  839. const hit = (this.factoryOptions || []).find((item) => String(item.value) === String(form.factoryId));
  840. if (hit) form.factory = hit.label;
  841. }
  842. // ③ 隶属部门:只有 deptId 时按部门树补「一级 / 二级 / 三级」路径名
  843. if (form.deptId && !form.deptName) {
  844. form.deptName = findDeptPath(this.deptTree, form.deptId);
  845. }
  846. // ④ 岗位序列:只有名称时按名称反查主键(序列主键用于提交 positionSequenceId)
  847. if (!form.positionSequenceId && form.positionSequenceName) {
  848. // 先在下拉的部门级序列里找,找不到再用全局序列列表兜底(老档案的序列可能不在该部门级联里)
  849. const hit =
  850. (this.sequenceOptions || []).find(
  851. (item) => String(item.label) === String(form.positionSequenceName)
  852. ) ||
  853. (this.sequenceList || []).find(
  854. (item) => String(item.name || item.sequenceName || "") === String(form.positionSequenceName)
  855. );
  856. if (hit) form.positionSequenceId = hit.value != null ? hit.value : hit.id;
  857. }
  858. // ⑤ 岗位:只有名称时按名称反查主键;名称为「岗位A、岗位B」时取第一个
  859. if (!form.positionId && form.positionName) {
  860. const first =
  861. String(form.positionName)
  862. .split(/[、,,]/)
  863. .map((text) => text.trim())
  864. .filter(Boolean)[0] || "";
  865. const hit = (this.positionSource || []).find((item) => String(item.name) === first);
  866. if (hit) {
  867. form.positionId = hit.id;
  868. if (!form.positionSequenceName && hit.sequence) form.positionSequenceName = hit.sequence;
  869. }
  870. }
  871. // ⑥ 岗位性质:接口只回字典码时按字典补中文名(已回填中文名则不动)
  872. if (form.employmentType) {
  873. const hit = (this.positionNatureOptions || []).find(
  874. (item) => String(item.value) === String(form.employmentType)
  875. );
  876. if (hit && (!form.employmentTypeName || /^[\dA-Za-z_]+$/.test(String(form.employmentTypeName)))) {
  877. form.employmentTypeName = hit.label;
  878. }
  879. }
  880. // ⑦ 年龄:接口不回 age 时按出生日期补算
  881. if (!form.age && form.birthDate) form.age = calcAge(form.birthDate);
  882. },
  883. /* ================= 档案加载 ================= */
  884. async loadProfile() {
  885. const login = uni.getStorageSync("userInfo") || {};
  886. this.applyProfile(buildProfileForm(login, {}), login);
  887. try {
  888. const profile = await fetchUserProfile(currentUserId());
  889. if (!profile) return;
  890. const workExperiences = profile.workExperiences || [];
  891. const form = buildProfileForm(profile, { workExperiences });
  892. this.applyProfile(form, profile);
  893. } catch (error) {
  894. console.warn("[hr/profile/edit] 档案加载失败:", error && error.message);
  895. uni.showToast({ title: "档案加载失败,已展示本地缓存", icon: "none" });
  896. }
  897. // 岗位序列按部门级联,必须等档案带出 deptId 之后再拉一次
  898. this.loadSequences();
  899. },
  900. applyProfile(form, raw) {
  901. this.form = form;
  902. this.raw = raw || {};
  903. // 注意:三元的两支都必须保证是数组。原来的写法
  904. // `Array.isArray((raw && raw.positionList) || []) ? raw.positionList : []`
  905. // 在 raw.positionList 为 undefined 时会命中条件却取回 undefined
  906. // (判断的是 []、赋值的是 undefined),随后 .find 就抛
  907. // TypeError: Cannot read property 'find' of undefined。
  908. // 首次进入页面用登录缓存 userInfo 调本方法,而登录对象没有 positionList,必然触发。
  909. const positionList = (raw && raw.positionList) || [];
  910. this.rawPositionList = Array.isArray(positionList) ? positionList : [];
  911. const primary = this.rawPositionList.find((item) => Number(item.primaryFlag) === 1) || this.rawPositionList[0];
  912. this.rawPrimaryPositionId = primary && primary.positionId != null ? String(primary.positionId) : "";
  913. this.originalJobs = (form.previousJobs || []).map((item) => ({ id: item.id, company: item.company, startDate: item.startDate, endDate: item.endDate }));
  914. this.jobsDirty = false;
  915. this.refreshDeptTree();
  916. this.hydrateFromOptions();
  917. },
  918. /* ================= 字段取值 / 展示 ================= */
  919. fieldRange(field) {
  920. if (field.source) return this.sourceList(field.source);
  921. return toOptions(field.options);
  922. },
  923. /**
  924. * 依赖上级字段的枚举:上级没选时禁用选择器(PC 的岗位序列同样是 :disabled="!hasDept"),
  925. * 避免点开一个空选择器。
  926. */
  927. enumDisabled(field) {
  928. if (!field || field.source !== "sequence") return false;
  929. return !this.form || !this.form.deptId;
  930. },
  931. /** 被禁用时的占位提示 */
  932. enumHint(field) {
  933. return this.enumDisabled(field) ? "请先选择隶属部门" : "";
  934. },
  935. sourceList(source) {
  936. if (source === "legal") return this.legalOptions;
  937. if (source === "factory") return this.factoryOptions;
  938. if (source === "sequence") return this.sequenceOptions;
  939. if (source === "position") return this.positionChoices;
  940. if (source === "level") return this.levelChoices;
  941. if (source === "positionNature") return this.positionNatureOptions;
  942. if (source === "status") return this.statusChoices;
  943. return [];
  944. },
  945. fieldText(field) {
  946. const value = this.form[field.key];
  947. if (field.displayKey) {
  948. const text = this.form[field.displayKey];
  949. if (text) return String(text);
  950. // 名称字段缺失时按主键在候选里反查,避免「已回填主键却显示请选择」
  951. if (value === "" || value === null || value === undefined) return "";
  952. const hit = this.fieldRange(field).find((item) => String(item.value) === String(value));
  953. // 候选里也没有(字典/组织未配全)时兜底显示原始值,一眼可看出缺配置
  954. return hit ? hit.label : String(value);
  955. }
  956. if (value === "" || value === null || value === undefined) return "";
  957. if (field.source === "status") {
  958. const hit = (this.statusChoices || []).find((item) => String(item.value) === String(value));
  959. if (hit) return hit.label;
  960. // 字典没配到该码时用接口给的中文名兜底,最后才原样显示码
  961. if (this.form.statusName) return String(this.form.statusName);
  962. return EMPLOYEE_STATUS_LABEL[Number(value)] || String(value);
  963. }
  964. const options = this.fieldRange(field);
  965. const matched = options.find((item) => String(item.value) === String(value));
  966. return matched ? matched.label : String(value);
  967. },
  968. fieldIndex(field) {
  969. const options = this.fieldRange(field);
  970. const value = this.form[field.key];
  971. const idx = options.findIndex((item) => String(item.value) === String(value));
  972. return idx < 0 ? 0 : idx;
  973. },
  974. onFieldPick(field, e) {
  975. const options = this.fieldRange(field);
  976. const hit = options[Number(e.detail.value)];
  977. if (!hit) return;
  978. this.form[field.key] = hit.value;
  979. if (field.displayKey) this.form[field.displayKey] = hit.label;
  980. this.afterPick(field);
  981. },
  982. onFieldDate(field, e) {
  983. this.form[field.key] = (e && e.detail && e.detail.value) || "";
  984. if (field.key === "birthDate") this.form.age = calcAge(this.form.birthDate);
  985. },
  986. onFieldSwitch(field, e) {
  987. this.form[field.key] = Boolean(e.detail.value);
  988. },
  989. afterPick(field) {
  990. if (field.key === "groupId") {
  991. this.form.deptIds = [];
  992. this.form.deptId = "";
  993. this.form.deptName = "";
  994. this.refreshDeptTree();
  995. this.form.positionSequenceId = "";
  996. this.form.positionSequenceName = "";
  997. this.form.positionId = "";
  998. this.form.positionName = "";
  999. this.form.staffingLevel = "";
  1000. } else if (field.key === "positionSequenceId") {
  1001. const pos = this.positionSource.find((p) => String(p.id) === String(this.form.positionId));
  1002. if (pos && this.form.positionSequenceName && pos.sequence && String(pos.sequence) !== String(this.form.positionSequenceName)) {
  1003. this.form.positionId = "";
  1004. this.form.positionName = "";
  1005. this.form.staffingLevel = "";
  1006. }
  1007. } else if (field.key === "positionId") {
  1008. const pos = this.positionSource.find((p) => String(p.id) === String(this.form.positionId));
  1009. if (pos) {
  1010. this.form.positionName = pos.name;
  1011. this.form.staffingLevel = pos.level || this.form.staffingLevel;
  1012. }
  1013. this.syncSequenceByPosition();
  1014. }
  1015. },
  1016. syncSequenceByPosition() {
  1017. if (this.form.positionSequenceName) return;
  1018. const pos = this.positionSource.find((p) => String(p.id) === String(this.form.positionId));
  1019. if (pos && pos.sequence) {
  1020. this.form.positionSequenceName = String(pos.sequence);
  1021. const hit = this.sequenceOptions.find((item) => String(item.label) === String(pos.sequence));
  1022. if (hit) this.form.positionSequenceId = hit.value;
  1023. }
  1024. },
  1025. openManagerPicker() {
  1026. if (this.$refs.userPicker) this.$refs.userPicker.open("2");
  1027. },
  1028. onUserPicked(list) {
  1029. const user = Array.isArray(list) ? list[0] : null;
  1030. if (!user) return;
  1031. this.form.managerId = user.id;
  1032. this.form.manager = user.name || user.userName || "";
  1033. },
  1034. /* ================= 隶属部门(树形选择) ================= */
  1035. /** 打开部门树选择器(层级不限,可展开折叠) */
  1036. openDeptPicker() {
  1037. if (!this.form.groupId) {
  1038. uni.showToast({ title: "请先选择所属机构", icon: "none" });
  1039. return;
  1040. }
  1041. if (!this.deptTree.length) {
  1042. uni.showToast({ title: "该机构下暂无可选部门", icon: "none" });
  1043. return;
  1044. }
  1045. const picker = this.$refs.deptPicker;
  1046. if (picker && typeof picker._show === "function") picker._show();
  1047. },
  1048. /**
  1049. * 树节点选中回调:ba-tree-picker 单选时回传 (ids, names)。
  1050. * 名称统一按部门树路径生成(如「制造部 / 装配车间」),与详情接口回填口径一致。
  1051. */
  1052. onDeptTreeSelect(ids, names) {
  1053. const id = Array.isArray(ids) ? ids[0] : ids;
  1054. if (id == null || id === "") return;
  1055. this.form.deptIds = [String(id)];
  1056. this.form.deptId = String(id);
  1057. this.form.deptName = findDeptPath(this.deptTree, id) || names || "";
  1058. this.syncPositionWithDept();
  1059. // 岗位序列跟着部门走:重新拉该部门下的序列,并把已不属于新部门的旧序列清掉
  1060. this.loadSequences({ resetStale: true });
  1061. },
  1062. /** 部门变化后,已选岗位不属于该部门时清掉岗位(与岗位下拉的收敛口径一致) */
  1063. syncPositionWithDept() {
  1064. if (!this.form.positionId) return;
  1065. const pos = (this.positionSource || []).find((p) => String(p.id) === String(this.form.positionId));
  1066. if (pos && pos.deptId && String(pos.deptId) !== String(this.form.deptId)) {
  1067. this.form.positionId = "";
  1068. this.form.positionName = "";
  1069. this.form.staffingLevel = "";
  1070. }
  1071. },
  1072. /* ================= 明细列表 ================= */
  1073. listItemTitle(group, item, idx) {
  1074. if (group.list === "previousJobs") return item.company || `工作履历 ${idx + 1}`;
  1075. if (group.list === "bankAccounts") return item.bankName || `银行账户 ${idx + 1}`;
  1076. if (group.list === "socialSecurityRecords") return item.type || `社保记录 ${idx + 1}`;
  1077. if (group.list === "certificates") return [item.type, item.number].filter(Boolean).join(" · ") || `证照 ${idx + 1}`;
  1078. return group.title;
  1079. },
  1080. listItemLines(group, item) {
  1081. const lines = [];
  1082. if (group.list === "previousJobs") {
  1083. if (item.startDate || item.endDate) lines.push(`时间:${item.startDate || "—"} ~ ${item.endDate || "—"}`);
  1084. if (item.position) lines.push(`岗位:${item.position}`);
  1085. if (item.department) lines.push(`部门:${item.department}`);
  1086. if (item.leaveReason) lines.push(`离职原因:${item.leaveReason}`);
  1087. if (item.description) lines.push(`工作描述:${item.description}`);
  1088. } else if (group.list === "bankAccounts") {
  1089. if (item.bankAccount) lines.push(`卡号:${formatCardNumber(item.bankAccount)}`);
  1090. if (item.bankBranch) lines.push(`网点:${item.bankBranch}`);
  1091. if (item.bankAccountName) lines.push(`开户人:${item.bankAccountName}`);
  1092. } else if (group.list === "socialSecurityRecords") {
  1093. lines.push(`状态:${Number(item.isEnabled) === 1 ? "启用" : "停用"}`);
  1094. if (item.base !== "" && item.base != null) lines.push(`基数:${item.base} 元`);
  1095. if (item.insuredDate) lines.push(`参保:${item.insuredDate}`);
  1096. if (item.stopDate) lines.push(`停保:${item.stopDate}`);
  1097. if (item.stopReason) lines.push(`停缴记录:${item.stopReason}`);
  1098. } else if (group.list === "certificates") {
  1099. lines.push(`状态:${item.status || "—"}|等级:${item.level || "—"}`);
  1100. if (item.startDate || item.endDate) lines.push(`有效期:${item.startDate || "—"} ~ ${item.endDate || "—"}`);
  1101. if (Number(item.hasPositionAllowance) === 1) {
  1102. lines.push(`岗位津贴:${item.positionAllowanceAmount || 0} ${allowanceUnitLabel(item.positionAllowanceUnit)}`);
  1103. }
  1104. if (item.remark) lines.push(`备注:${item.remark}`);
  1105. }
  1106. return lines;
  1107. },
  1108. openItemEditor(group, item, idx) {
  1109. const fields = group.listFields || [];
  1110. const values = {};
  1111. fields.forEach((f) => {
  1112. const raw = item[f.key];
  1113. if (f.type === "file") values[f.key] = Array.isArray(raw) ? raw.slice() : [];
  1114. else values[f.key] = raw === null || raw === undefined ? "" : raw;
  1115. });
  1116. this.editor = {
  1117. visible: true,
  1118. title: `编辑${group.title}`,
  1119. fields,
  1120. values,
  1121. index: idx,
  1122. list: group.list,
  1123. group,
  1124. };
  1125. },
  1126. addItem(group) {
  1127. const fields = group.listFields || [];
  1128. const emptyMap = {
  1129. previousJobs: EMPTY_JOB,
  1130. bankAccounts: EMPTY_BANK,
  1131. socialSecurityRecords: EMPTY_SOCIAL,
  1132. certificates: EMPTY_CERT,
  1133. };
  1134. const template = emptyMap[group.list] || {};
  1135. const values = {};
  1136. fields.forEach((f) => {
  1137. const raw = template[f.key];
  1138. if (f.type === "file") values[f.key] = Array.isArray(raw) ? raw.slice() : [];
  1139. else values[f.key] = raw === null || raw === undefined ? "" : raw;
  1140. });
  1141. if (group.list === "bankAccounts") values.bankAccountName = values.bankAccountName || this.form.name || "";
  1142. this.editor = {
  1143. visible: true,
  1144. title: `新增${group.title}`,
  1145. fields,
  1146. values,
  1147. index: -1,
  1148. list: group.list,
  1149. group,
  1150. };
  1151. },
  1152. closeEditor() {
  1153. this.editor = { visible: false, title: "", fields: [], values: {}, index: -1, list: "" };
  1154. },
  1155. editorFieldRange(field) {
  1156. return toOptions(field.options);
  1157. },
  1158. editorFieldText(field) {
  1159. const value = this.editor.values[field.key];
  1160. if (value === "" || value === null || value === undefined) return "";
  1161. const hit = this.editorFieldRange(field).find((item) => String(item.value) === String(value));
  1162. return hit ? hit.label : String(value);
  1163. },
  1164. editorFieldIndex(field) {
  1165. const options = this.editorFieldRange(field);
  1166. const idx = options.findIndex((item) => String(item.value) === String(this.editor.values[field.key]));
  1167. return idx < 0 ? 0 : idx;
  1168. },
  1169. onEditorPick(field, e) {
  1170. const hit = this.editorFieldRange(field)[Number(e.detail.value)];
  1171. if (hit) this.editor.values[field.key] = hit.value;
  1172. },
  1173. onEditorDate(field, e) {
  1174. this.editor.values[field.key] = (e && e.detail && e.detail.value) || "";
  1175. },
  1176. validateEditor() {
  1177. const { fields, values } = this.editor;
  1178. for (const f of fields) {
  1179. if (typeof f.showIf === "function" && !f.showIf(values)) continue;
  1180. if (f.required && !String(values[f.key] == null ? "" : values[f.key]).trim()) {
  1181. return `请填写${f.label}`;
  1182. }
  1183. }
  1184. if (this.editor.list === "previousJobs") {
  1185. if (values.startDate && values.endDate && values.startDate > values.endDate) {
  1186. return "工作结束日期不能早于开始日期";
  1187. }
  1188. }
  1189. if (this.editor.list === "socialSecurityRecords") {
  1190. if (values.insuredDate && values.stopDate && values.insuredDate > values.stopDate) {
  1191. return "停保日期不能早于参保日期";
  1192. }
  1193. }
  1194. if (this.editor.list === "certificates") {
  1195. if (values.startDate && values.endDate && values.startDate > values.endDate) {
  1196. return "证照有效期结束不能早于开始";
  1197. }
  1198. if (Number(values.hasPositionAllowance) === 1 && !String(values.positionAllowanceAmount || "").trim()) {
  1199. return "请填写岗位津贴金额";
  1200. }
  1201. if (Number(values.hasPositionAllowance) === 1 && !String(values.positionAllowanceUnit || "").trim()) {
  1202. return "请选择岗位津贴单位";
  1203. }
  1204. }
  1205. return null;
  1206. },
  1207. confirmEditor() {
  1208. const error = this.validateEditor();
  1209. if (error) {
  1210. uni.showToast({ title: error, icon: "none" });
  1211. return;
  1212. }
  1213. const { list, index, values } = this.editor;
  1214. const copy = Object.assign({}, values);
  1215. if (Array.isArray(copy.attachment)) copy.attachment = copy.attachment.slice();
  1216. if (index < 0) {
  1217. copy.key = newKey(list);
  1218. this.form[list].push(copy);
  1219. } else {
  1220. const target = this.form[list][index];
  1221. copy.key = target.key || newKey(list);
  1222. this.$set(this.form[list], index, copy);
  1223. }
  1224. if (list === "previousJobs") this.jobsDirty = true;
  1225. this.closeEditor();
  1226. },
  1227. removeItem(group, idx) {
  1228. const item = this.form[group.list][idx];
  1229. const label = this.listItemTitle(group, item, idx);
  1230. uni.showModal({
  1231. title: "删除确认",
  1232. content: `确定删除「${label}」吗?`,
  1233. confirmColor: "#ee4d4d",
  1234. success: (r) => {
  1235. if (!r.confirm) return;
  1236. this.form[group.list].splice(idx, 1);
  1237. if (group.list === "previousJobs") this.jobsDirty = true;
  1238. },
  1239. });
  1240. },
  1241. readonlyList(group) {
  1242. const source = (this.raw && this.raw[group.readonlyList]) || [];
  1243. if (!Array.isArray(source)) return [];
  1244. if (group.readonlyList === "professions") {
  1245. return source.map((item) => ({
  1246. title: item.name || "工种",
  1247. tag: item.level || "",
  1248. lines: [item.code ? `编码:${item.code}` : "", item.type ? `类型:${item.type}` : ""].filter(Boolean),
  1249. }));
  1250. }
  1251. if (group.readonlyList === "skills") {
  1252. return source.map((item) => ({
  1253. title: item.name || "技能",
  1254. tag: item.level || "",
  1255. lines: [item.years != null ? `经验:${item.years} 年` : "", item.remark ? `备注:${item.remark}` : ""].filter(Boolean),
  1256. }));
  1257. }
  1258. return [];
  1259. },
  1260. /* ================= 保存 ================= */
  1261. validate() {
  1262. const f = this.form;
  1263. if (!String(f.name || "").trim()) return "请填写姓名";
  1264. if (!f.groupId) return "请选择所属机构";
  1265. // deptIds 做兜底:档案接口/兜底表单可能不带该字段,直接 .length 会抛错
  1266. if (!(f.deptIds || []).length && !f.deptId) return "请选择隶属部门";
  1267. // 岗位四件套:按「部门 → 序列 → 岗位 → 性质 → 层级」的级联顺序依次校验
  1268. if (!f.positionSequenceId) return "请选择岗位序列";
  1269. if (!f.positionId) return "请选择岗位";
  1270. if (!f.employmentType) return "请选择岗位性质";
  1271. if (!f.staffingLevel) return "请选择岗位层级";
  1272. if (!f.status) return "请选择员工状态";
  1273. if (!f.hireDate) return "请选择入职时间";
  1274. if (f.phone && !/^1[3-9]\d{9}$/.test(String(f.phone).trim())) return "手机号格式不正确";
  1275. if (f.email && !/^[\w.+-]+@[\w-]+\.[\w.-]+$/.test(String(f.email).trim())) return "邮箱格式不正确";
  1276. if (f.idCard && !/^\d{17}[\dXx]$/.test(String(f.idCard).trim())) return "身份证号格式不正确";
  1277. if (f.birthDate && f.hireDate && f.birthDate > f.hireDate) return "入职日期不能早于出生日期";
  1278. if (f.hireDate && f.regularDate && f.hireDate > f.regularDate) return "转正日期不能早于入职日期";
  1279. return null;
  1280. },
  1281. /** 由所选岗位解析 positionList(岗位主键 + 层级主键 + 序列主键) */
  1282. resolvePositionList() {
  1283. const id = this.form.positionId;
  1284. if (!id) return [];
  1285. const pos = this.positionSource.find((p) => String(p.id) === String(id));
  1286. const levelName = this.form.staffingLevel || (pos ? pos.level : "");
  1287. let positionLevelId;
  1288. // 主键一律保持字符串:雪花 id 转 Number 会丢精度(岗位 / 序列都是主键)
  1289. let positionSequenceId = keepIdString(this.form.positionSequenceId);
  1290. (this.sequenceList || []).forEach((sequence) => {
  1291. (sequence.levelList || []).forEach((level) => {
  1292. if (String(level.level) === String(levelName)) {
  1293. if (positionLevelId == null) positionLevelId = level.id;
  1294. if (!positionSequenceId) positionSequenceId = level.sequenceId;
  1295. }
  1296. });
  1297. });
  1298. if (positionLevelId == null && this.rawPositionList.length && String(this.rawPrimaryPositionId) === String(id)) {
  1299. // 层级解析不到且岗位没变:保留原绑定,避免清空
  1300. return this.rawPositionList;
  1301. }
  1302. return [
  1303. compactPayload({
  1304. positionId: keepIdString(id),
  1305. positionLevelId,
  1306. positionSequenceId,
  1307. primaryFlag: 1,
  1308. positionType: this.form.employmentType || undefined,
  1309. positionTypeName: this.form.employmentTypeName || undefined,
  1310. }),
  1311. ];
  1312. },
  1313. async submit() {
  1314. if (this.saving) return;
  1315. const error = this.validate();
  1316. if (error) {
  1317. uni.showToast({ title: error, icon: "none" });
  1318. return;
  1319. }
  1320. const dirtyJobs = (this.form.previousJobs || []).filter(
  1321. (item) => item.company && item.startDate && item.endDate
  1322. );
  1323. if ((this.form.previousJobs || []).length !== dirtyJobs.length) {
  1324. uni.showToast({ title: "工作履历需填写公司、开始与结束日期", icon: "none" });
  1325. return;
  1326. }
  1327. this.saving = true;
  1328. uni.showLoading({ title: "保存中..." });
  1329. try {
  1330. const payload = buildUserPayload(this.form, {
  1331. raw: this.raw,
  1332. positionList: this.resolvePositionList(),
  1333. });
  1334. await saveEmployee(payload);
  1335. const userId = this.form.id || currentUserId();
  1336. if (this.jobsDirty) {
  1337. const saved = await syncWorkExperiences(userId, this.form.previousJobs, this.originalJobs);
  1338. this.form.previousJobs = (saved || []).map((item, index) => ({
  1339. ...item,
  1340. key: item.key || newKey("job"),
  1341. id: item.id,
  1342. }));
  1343. this.originalJobs = (saved || []).map((item) => ({
  1344. id: item.id,
  1345. company: item.company,
  1346. startDate: item.startDate,
  1347. endDate: item.endDate,
  1348. }));
  1349. this.jobsDirty = false;
  1350. }
  1351. await refetchMyProfile();
  1352. uni.hideLoading();
  1353. uni.showToast({ title: "已保存", icon: "success" });
  1354. await this.loadProfile();
  1355. } catch (e) {
  1356. uni.hideLoading();
  1357. uni.showToast({ title: (e && e.message) || "保存失败", icon: "none" });
  1358. } finally {
  1359. this.saving = false;
  1360. }
  1361. },
  1362. resetForm() {
  1363. uni.showModal({
  1364. title: "重置档案",
  1365. content: "将放弃未保存的修改并重新拉取档案,确定继续?",
  1366. success: async (r) => {
  1367. if (!r.confirm) return;
  1368. await this.loadProfile();
  1369. uni.showToast({ title: "已重置", icon: "none" });
  1370. },
  1371. });
  1372. },
  1373. },
  1374. };
  1375. </script>
  1376. <style scoped lang="scss">
  1377. @import "@/pages/hr/common-style.scss";
  1378. .profile-edit {
  1379. padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
  1380. }
  1381. .profile-card {
  1382. margin: 24rpx;
  1383. padding: 34rpx 32rpx 30rpx;
  1384. background: #fff;
  1385. border: 1rpx solid #e8f2ea;
  1386. border-top: 6rpx solid #157a2c;
  1387. }
  1388. .pc-row {
  1389. display: flex;
  1390. align-items: center;
  1391. gap: 16rpx;
  1392. }
  1393. .pc-name {
  1394. font-size: 36rpx;
  1395. font-weight: 700;
  1396. color: #1f2329;
  1397. max-width: 420rpx;
  1398. overflow: hidden;
  1399. text-overflow: ellipsis;
  1400. white-space: nowrap;
  1401. }
  1402. .profile-card .job-number {
  1403. flex: none;
  1404. padding: 5rpx 18rpx;
  1405. border-radius: 999rpx;
  1406. background: #f0f8f2;
  1407. color: #157a2c;
  1408. font-size: 20rpx;
  1409. }
  1410. .pc-main {
  1411. margin-top: 14rpx;
  1412. font-size: 25rpx;
  1413. color: #4b5563;
  1414. }
  1415. .pc-sub {
  1416. display: block;
  1417. margin-top: 6rpx;
  1418. font-size: 22rpx;
  1419. color: #8b939f;
  1420. }
  1421. .tabs {
  1422. white-space: nowrap;
  1423. background: #f5f6f7;
  1424. }
  1425. .tabs-inner {
  1426. display: inline-flex;
  1427. padding: 12rpx 18rpx 4rpx;
  1428. }
  1429. .tab {
  1430. flex: none;
  1431. height: 64rpx;
  1432. line-height: 64rpx;
  1433. padding: 0 28rpx;
  1434. margin-right: 14rpx;
  1435. border-radius: 999rpx;
  1436. background: #fff;
  1437. color: #5f6b7a;
  1438. font-size: 26rpx;
  1439. border: 1rpx solid #eceff2;
  1440. }
  1441. .tab.active {
  1442. background: #157a2c;
  1443. border-color: #157a2c;
  1444. color: #fff;
  1445. font-weight: 600;
  1446. }
  1447. .group-title {
  1448. display: flex;
  1449. align-items: center;
  1450. justify-content: space-between;
  1451. padding: 26rpx 32rpx 12rpx;
  1452. }
  1453. .gt-left {
  1454. display: flex;
  1455. align-items: center;
  1456. }
  1457. .gt-bar {
  1458. width: 6rpx;
  1459. height: 26rpx;
  1460. margin-right: 12rpx;
  1461. border-radius: 3rpx;
  1462. background: #157a2c;
  1463. }
  1464. .gt-label {
  1465. color: #1f2329;
  1466. font-size: 27rpx;
  1467. font-weight: 600;
  1468. }
  1469. .gt-more {
  1470. color: #157a2c;
  1471. font-size: 25rpx;
  1472. }
  1473. .form-card {
  1474. padding: 4rpx 28rpx;
  1475. }
  1476. .field {
  1477. display: flex;
  1478. align-items: center;
  1479. min-height: 96rpx;
  1480. font-size: 28rpx;
  1481. }
  1482. .field .label {
  1483. width: 190rpx;
  1484. flex: none;
  1485. color: #4b5563;
  1486. }
  1487. .field .label.required::before {
  1488. content: "*";
  1489. color: #ee4d4d;
  1490. margin-right: 4rpx;
  1491. }
  1492. .field .value,
  1493. .field .value-input,
  1494. .field .placeholder {
  1495. flex: 1;
  1496. min-width: 0;
  1497. text-align: right;
  1498. color: #1f2329;
  1499. font-size: 28rpx;
  1500. }
  1501. .field .placeholder {
  1502. color: #b5bac3;
  1503. }
  1504. .field .muted {
  1505. color: #8b939f;
  1506. }
  1507. .field-block {
  1508. display: block;
  1509. padding: 20rpx 0 24rpx;
  1510. }
  1511. .field-block .label {
  1512. display: block;
  1513. width: auto;
  1514. margin-bottom: 14rpx;
  1515. }
  1516. .value-textarea {
  1517. width: 100%;
  1518. min-height: 130rpx;
  1519. padding: 0;
  1520. border: 0;
  1521. border-radius: 0;
  1522. background: transparent;
  1523. outline: none;
  1524. color: #1f2329;
  1525. font-size: 28rpx;
  1526. line-height: 1.6;
  1527. box-sizing: border-box;
  1528. }
  1529. .field-file {
  1530. padding: 6rpx 0;
  1531. }
  1532. .value-input {
  1533. height: 96rpx;
  1534. padding: 0;
  1535. border: 0;
  1536. border-radius: 0;
  1537. background: transparent;
  1538. outline: none;
  1539. }
  1540. /* H5 端 uni-input / uni-textarea 内部真实节点兜底:不留边框与底色 */
  1541. :deep(.uni-input-input),
  1542. :deep(.uni-textarea-textarea) {
  1543. border: 0;
  1544. border-radius: 0;
  1545. background: transparent;
  1546. outline: none;
  1547. text-align: inherit;
  1548. font-size: 28rpx;
  1549. color: #1f2329;
  1550. }
  1551. .divider {
  1552. height: 1rpx;
  1553. background: #edf0f2;
  1554. margin-left: 190rpx;
  1555. }
  1556. .arrow {
  1557. flex: none;
  1558. margin-left: 12rpx;
  1559. color: #c4c8ce;
  1560. font-size: 32rpx;
  1561. line-height: 1;
  1562. }
  1563. .switch-wrap {
  1564. flex: 1;
  1565. display: flex;
  1566. align-items: center;
  1567. justify-content: flex-end;
  1568. }
  1569. .switch-text {
  1570. margin-left: 14rpx;
  1571. color: #5f6b7a;
  1572. font-size: 26rpx;
  1573. }
  1574. .list-item {
  1575. padding: 24rpx 28rpx;
  1576. }
  1577. .li-head {
  1578. display: flex;
  1579. align-items: center;
  1580. justify-content: space-between;
  1581. }
  1582. .li-title {
  1583. flex: 1;
  1584. min-width: 0;
  1585. font-size: 29rpx;
  1586. font-weight: 600;
  1587. color: #1f2329;
  1588. overflow: hidden;
  1589. text-overflow: ellipsis;
  1590. white-space: nowrap;
  1591. }
  1592. .li-tag {
  1593. flex: none;
  1594. margin-left: 12rpx;
  1595. padding: 4rpx 14rpx;
  1596. border-radius: 999rpx;
  1597. background: #f0f8f2;
  1598. color: #157a2c;
  1599. font-size: 21rpx;
  1600. }
  1601. .li-actions {
  1602. flex: none;
  1603. display: flex;
  1604. align-items: center;
  1605. gap: 24rpx;
  1606. margin-left: 16rpx;
  1607. }
  1608. .li-action {
  1609. color: #157a2c;
  1610. font-size: 24rpx;
  1611. }
  1612. .li-action.danger {
  1613. color: #ee4d4d;
  1614. }
  1615. .li-line {
  1616. margin-top: 12rpx;
  1617. color: #5f6b7a;
  1618. font-size: 24rpx;
  1619. line-height: 1.6;
  1620. }
  1621. .empty {
  1622. padding: 46rpx 0;
  1623. text-align: center;
  1624. color: #8b939f;
  1625. font-size: 25rpx;
  1626. }
  1627. .record-card {
  1628. padding: 24rpx 28rpx;
  1629. }
  1630. .record-head {
  1631. display: flex;
  1632. align-items: center;
  1633. justify-content: space-between;
  1634. }
  1635. .record-title {
  1636. font-size: 27rpx;
  1637. font-weight: 600;
  1638. color: #1f2329;
  1639. }
  1640. .record-count {
  1641. color: #8b939f;
  1642. font-size: 23rpx;
  1643. }
  1644. .record-line {
  1645. margin-top: 12rpx;
  1646. color: #5f6b7a;
  1647. font-size: 24rpx;
  1648. line-height: 1.6;
  1649. }
  1650. .record-empty {
  1651. margin-top: 12rpx;
  1652. color: #b5bac3;
  1653. font-size: 23rpx;
  1654. }
  1655. .footer-tip {
  1656. padding: 24rpx 32rpx 0;
  1657. color: #a0a7b0;
  1658. font-size: 22rpx;
  1659. text-align: center;
  1660. line-height: 1.6;
  1661. }
  1662. .footer-bar {
  1663. position: fixed;
  1664. left: 0;
  1665. right: 0;
  1666. bottom: 0;
  1667. z-index: 30;
  1668. display: flex;
  1669. gap: 20rpx;
  1670. padding: 20rpx 24rpx calc(20rpx + env(safe-area-inset-bottom));
  1671. background: #fff;
  1672. box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.05);
  1673. }
  1674. .footer-bar > view {
  1675. flex: 1;
  1676. }
  1677. .primary-btn.disabled {
  1678. opacity: 0.6;
  1679. }
  1680. /* 明细编辑弹层 */
  1681. .popup-mask {
  1682. position: fixed;
  1683. left: 0;
  1684. right: 0;
  1685. top: 0;
  1686. bottom: 0;
  1687. z-index: 900;
  1688. background: rgba(0, 0, 0, 0.45);
  1689. display: flex;
  1690. align-items: flex-end;
  1691. }
  1692. .popup {
  1693. width: 100%;
  1694. max-height: 86vh;
  1695. display: flex;
  1696. flex-direction: column;
  1697. border-radius: 24rpx 24rpx 0 0;
  1698. background: #fff;
  1699. }
  1700. .popup-head {
  1701. flex: none;
  1702. display: flex;
  1703. align-items: center;
  1704. justify-content: space-between;
  1705. padding: 30rpx 32rpx 20rpx;
  1706. border-bottom: 1rpx solid #f2f4f7;
  1707. }
  1708. .popup-title {
  1709. font-size: 31rpx;
  1710. font-weight: 600;
  1711. color: #1f2329;
  1712. }
  1713. .popup-close {
  1714. color: #8b939f;
  1715. font-size: 32rpx;
  1716. }
  1717. .popup-body {
  1718. flex: 1;
  1719. min-height: 0;
  1720. padding: 10rpx 32rpx 20rpx;
  1721. box-sizing: border-box;
  1722. }
  1723. .p-field {
  1724. padding: 24rpx 0;
  1725. border-bottom: 1rpx solid #f2f4f7;
  1726. }
  1727. .p-field:last-child {
  1728. border-bottom: 0;
  1729. }
  1730. .p-label {
  1731. display: block;
  1732. margin-bottom: 12rpx;
  1733. color: #5f6b7a;
  1734. font-size: 24rpx;
  1735. }
  1736. .p-label.required::before {
  1737. content: "*";
  1738. color: #ee4d4d;
  1739. margin-right: 4rpx;
  1740. }
  1741. .p-input,
  1742. .p-textarea {
  1743. width: 100%;
  1744. padding: 0;
  1745. border: 0;
  1746. border-radius: 0;
  1747. background: transparent;
  1748. outline: none;
  1749. color: #1f2329;
  1750. font-size: 28rpx;
  1751. box-sizing: border-box;
  1752. }
  1753. .p-input {
  1754. height: 64rpx;
  1755. line-height: 64rpx;
  1756. }
  1757. .p-textarea {
  1758. min-height: 140rpx;
  1759. line-height: 1.6;
  1760. }
  1761. .p-picker {
  1762. display: flex;
  1763. align-items: center;
  1764. justify-content: space-between;
  1765. min-height: 64rpx;
  1766. padding: 0;
  1767. background: transparent;
  1768. font-size: 28rpx;
  1769. }
  1770. .p-value {
  1771. color: #1f2329;
  1772. }
  1773. .p-placeholder {
  1774. color: #b5bac3;
  1775. }
  1776. .p-file {
  1777. padding: 6rpx 0;
  1778. }
  1779. .popup-actions {
  1780. flex: none;
  1781. display: flex;
  1782. border-top: 1rpx solid #f2f4f7;
  1783. }
  1784. .popup-btn {
  1785. flex: 1;
  1786. height: 96rpx;
  1787. line-height: 96rpx;
  1788. text-align: center;
  1789. color: #5f6b7a;
  1790. font-size: 30rpx;
  1791. }
  1792. .popup-btn.primary {
  1793. color: #157a2c;
  1794. font-weight: 600;
  1795. }
  1796. </style>