Quellcode durchsuchen

feat: 优化个人资料编辑界面,添加银行卡管理功能

xieyong vor 1 Woche
Ursprung
Commit
f3d36c117d
1 geänderte Dateien mit 493 neuen und 28 gelöschten Zeilen
  1. 493 28
      src/pages/profile/edit.vue

+ 493 - 28
src/pages/profile/edit.vue

@@ -12,59 +12,397 @@
         ><view v-for="(row, i) in group.rows" :key="row.label"
           ><view class="info-row press" @click="row.edit && editRow(row)"
             ><text>{{ row.label }}</text
-            ><text>{{ row.value }}</text
+            ><text>{{ resolveValue(row) }}</text
             ><text v-if="row.edit" class="arrow">›</text></view
           ><view
             v-if="i < group.rows.length - 1"
             class="divider"
           ></view></view></view></view
-    ><view v-if="lastRequest" class="request-tip card section"
-      ><text>最近修改申请</text
-      ><text>{{ lastRequest.field }}:{{ lastRequest.value }}</text
-      ><text class="status-tag status-pending">审核中</text></view
-    ><text class="tip">点击可修改字段,提交后由人力资源部审核生效</text></view
+    ><view class="group-title"
+      ><text>银行卡({{ bankCardList.length }})</text
+      ><text class="add-bank press" @click="addBankCard">+ 添加</text></view
+    ><view v-if="bankCardList.length" class="bank-card-list"
+      ><view v-for="card in bankCardList" :key="card.id" class="bank-card card section"
+        ><view class="bank-head"
+          ><view class="bank-title"
+            ><text class="bank-name">{{ card.openingBank || "未命名银行" }}</text></view
+          ><view class="bank-actions"
+            ><text class="bank-action press" @click="editBankCard(card)">编辑</text
+            ><text class="bank-action danger press" @click="removeBankCard(card)">删除</text></view
+          ></view
+        ><view class="bank-no">{{ formatCardNumber(card.cardNumber) }}</view
+        ><view v-if="card.branch" class="bank-branch">开户行:{{ card.branch }}</view
+        ></view
+      ></view
+    ><view v-else class="bank-empty card section"
+      ><text>暂未绑定银行卡</text
+      ><text class="press" @click="addBankCard">点击添加</text></view
+    ><text class="tip">点击可修改字段,提交后由人力资源部审核生效</text
+    ><view v-if="twoModal.visible" class="two-modal-mask" @click.self="cancelTwoModal"
+      ><view class="two-modal"
+        ><text class="two-modal-title">{{ twoModal.title }}</text
+        ><view class="two-modal-fields"
+          ><view v-for="field in twoModal.fields" :key="field.key" class="two-modal-field"
+            ><text class="two-modal-label">{{ field.label }}</text
+            ><input
+              class="two-modal-input"
+              :type="field.inputType || 'text'"
+              :maxlength="field.maxlength || 50"
+              v-model="twoModal.values[field.key]"
+              :placeholder="field.placeholder || ''"
+            /></view
+          ></view
+        ><view class="two-modal-actions"
+          ><text class="two-modal-btn press" @click="cancelTwoModal">取消</text
+          ><text class="two-modal-btn primary press" @click="confirmTwoModal">确认</text></view
+        ></view
+      ></view
+    ></view
   >
 </template>
 <script setup>
 import { computed, ref } from "vue";
 import { useAuthGuard } from "@/hooks/useAuthGuard";
 import { useCurrentUser } from "@/hooks/useCurrentUser";
-import { saveProfileChange } from "@/utils/storage";
+import { refetchMyProfile, saveEmployee } from "@/api/user";
 const currentUser = useCurrentUser();
 useAuthGuard();
-const lastRequest = ref(null);
+const saving = ref(false);
+// 银行卡:本地可编辑数组,先克隆接口数据,避免直接改 reactive 引用
+const bankCardList = ref(
+  (currentUser.bankCardList || []).map((c) => ({ ...c })),
+);
+
+function resolveValue(row) {
+  if (row.editor === "bank") {
+    const card = bankCardList.value[0];
+    return card ? `${card.openingBank} · 尾号 ${tailOf(card.cardNumber)}` : "未绑定";
+  }
+  return row.value;
+}
+
+function tailOf(cardNumber) {
+  const digits = String(cardNumber || "").replace(/\D/g, "");
+  return digits ? digits.slice(-4) : "----";
+}
+
+function formatCardNumber(cardNumber) {
+  const digits = String(cardNumber || "").replace(/\D/g, "");
+  return digits.replace(/(.{4})/g, "$1 ").trim() || "----";
+}
+
+function bankLabel(card) {
+  return `${card.openingBank} · 尾号 ${tailOf(card.cardNumber)}`;
+}
+
 const groups = computed(() => [
   {
     title: "任职信息",
     rows: [
       { label: "员工编号", value: currentUser.id },
       { label: "入职日期", value: currentUser.entryDate },
-      { label: "工作年限", value: currentUser.workAge },
+      // { label: "工作年限", value: currentUser.workAge },
       { label: "劳动合同", value: "固定期限 · 2028-02-12到期" },
     ],
   },
   {
     title: "个人信息",
     rows: [
-      { label: "手机号码", value: currentUser.phone, edit: true },
-      { label: "婚姻状况", value: "已婚", edit: true },
-      { label: "紧急联系人", value: "陈志刚 · 138 **** 0903", edit: true },
-      { label: "银行卡", value: "中国工商银行 · 尾号 8621", edit: true },
+      { label: "手机号码", value: currentUser.phone, edit: true, editor: "phone" },
+      { label: "婚姻状况", value: currentUser.maritalStatus || "未填写", edit: true, editor: "marital" },
+      { label: "紧急联系人", value: currentUser.emergencyContact || "暂未填写", edit: true, editor: "emergency" },
     ],
   },
 ]);
+const MARITAL_OPTIONS = ["未婚", "已婚", "离异", "丧偶"];
+
+// ============ 通用双输入弹框 ============
+const twoModal = ref({
+  visible: false,
+  title: "",
+  fields: [],
+  values: {},
+  resolver: null,
+})
+
+function openTwoModal({ title, fields, initial = {} }) {
+  return new Promise((resolve) => {
+    const values = {}
+    fields.forEach((f) => {
+      values[f.key] = String(initial[f.key] ?? "")
+    })
+    twoModal.value = {
+      visible: true,
+      title,
+      fields,
+      values,
+      resolver: resolve,
+    }
+  })
+}
+
+function confirmTwoModal() {
+  const m = twoModal.value
+  if (!m.resolver) return
+  // 把 values 里每个字段裁掉首尾空白
+  const result = {}
+  m.fields.forEach((f) => {
+    result[f.key] = String(m.values[f.key] || "").trim()
+  })
+  m.resolver(result)
+  twoModal.value = { visible: false, title: "", fields: [], values: {}, resolver: null }
+}
+
+function cancelTwoModal() {
+  const m = twoModal.value
+  if (m.resolver) m.resolver(null)
+  twoModal.value = { visible: false, title: "", fields: [], values: {}, resolver: null }
+}
+
+function formatEmergency(name, phone, relation) {
+  const parts = []
+  if (name && relation) parts.push(`${name}(${relation})`)
+  else if (name) parts.push(name)
+  if (phone) parts.push(phone)
+  return parts.join(" · ")
+}
+
+// 把 profile 接口返回的完整 VO 摊平后写入 currentUser(reactive)
+// 入参是 fetchUserDetail 摊平过的 raw 字段集(与 mapCurrentUser 输入一致)
+function applyProfileVo(vo) {
+  if (!vo || typeof vo !== "object") return
+  // 字符串字段
+  if ("phone" in vo) currentUser.phone = vo.phone || ""
+  if ("maritalStatus" in vo) currentUser.maritalStatus = vo.maritalStatus || ""
+  // 紧急联系人:从 secondLink* 拆出 name / phone / relation 三个扁平字段
+  if ("secondLinkName" in vo || "secondLinkPhone" in vo || "secondLinkRelation" in vo) {
+    currentUser.emergencyName = vo.secondLinkName || ""
+    currentUser.emergencyPhone = vo.secondLinkPhone || ""
+    currentUser.emergencyRelation = vo.secondLinkRelation || ""
+    currentUser.emergencyContact = formatEmergency(
+      vo.secondLinkName,
+      vo.secondLinkPhone,
+      vo.secondLinkRelation,
+    )
+  }
+  // 银行卡列表:替换整个数组
+  if (Array.isArray(vo.bankCardList)) {
+    const next = vo.bankCardList.map((c) => ({
+      id: String(c.id ?? c.cardId ?? c.cardNumber ?? ""),
+      cardNumber: String(c.cardNumber || ""),
+      openingBank: String(c.openingBank || ""),
+      branch: String(c.branch || c.subBranch || ""),
+    }))
+    currentUser.bankCardList = next
+    bankCardList.value = next.map((c) => ({ ...c }))
+  }
+}
+
+// 乐观合并:saveNew 接口响应里 data 通常只是 {id},
+// 不一定能拿到完整字段,所以这里用"调用方已知的本次修改 payload"直接落到 currentUser
+// - payload 是 saveEmployee 收到的入参对象(如 { phone }, { maritalStatus }, { bankCardList })
+function applyOptimistic(payload) {
+  if (!payload || typeof payload !== "object") return
+  if ("phone" in payload) currentUser.phone = payload.phone || ""
+  if ("maritalStatus" in payload) currentUser.maritalStatus = payload.maritalStatus || ""
+  if ("secondLinkName" in payload) currentUser.emergencyName = payload.secondLinkName || ""
+  if ("secondLinkPhone" in payload) currentUser.emergencyPhone = payload.secondLinkPhone || ""
+  if ("secondLinkRelation" in payload) currentUser.emergencyRelation = payload.secondLinkRelation || ""
+  if ("secondLinkName" in payload || "secondLinkPhone" in payload || "secondLinkRelation" in payload) {
+    currentUser.emergencyContact = formatEmergency(
+      currentUser.emergencyName,
+      currentUser.emergencyPhone,
+      currentUser.emergencyRelation,
+    )
+  }
+  if (Array.isArray(payload.bankCardList)) {
+    const next = payload.bankCardList.map((c) => ({
+      id: String(c.id ?? c.cardId ?? c.cardNumber ?? ""),
+      cardNumber: String(c.cardNumber || ""),
+      openingBank: String(c.openingBank || ""),
+      branch: String(c.branch || ""),
+    }))
+    currentUser.bankCardList = next
+    bankCardList.value = next.map((c) => ({ ...c }))
+  }
+}
+
+// 统一提交入口:手机号 / 婚姻 / 紧急联系人 / 银行卡(增删改)都走这里
+async function commitChange(payload, { toast = "已保存" } = {}) {
+  if (saving.value) return
+  saving.value = true
+  uni.showLoading({ title: "保存中..." })
+  try {
+    await saveEmployee(payload)
+    // 先用本地 payload 做乐观合并(saveNew 通常只返回 {id},不会回完整 VO)
+    applyOptimistic(payload)
+    // 再调一次 profile 拉服务端权威数据,覆盖包括银行卡默认卡等被服务端修正的字段
+    try {
+      const freshVo = await refetchMyProfile()
+      if (freshVo) applyProfileVo(freshVo)
+    } catch (refetchErr) {
+      console.warn("[profile] refetch profile failed:", refetchErr?.message || refetchErr)
+    }
+    uni.hideLoading()
+    uni.showToast({ title: toast, icon: "success" })
+  } catch (error) {
+    uni.hideLoading()
+    uni.showToast({ title: error.message || "保存失败", icon: "none" })
+  } finally {
+    saving.value = false
+  }
+}
+
 function editRow(row) {
+  if (row.editor === "marital") {
+    return uni.showActionSheet({
+      itemList: MARITAL_OPTIONS,
+      success: (r) => {
+        if (r.tapIndex < 0) return
+        const picked = MARITAL_OPTIONS[r.tapIndex]
+        commitChange(
+          { maritalStatus: picked },
+          { toast: "已保存" },
+        )
+      },
+    });
+  }
+  if (row.editor === "phone") {
+    return uni.showModal({
+      title: "修改手机号码",
+      editable: true,
+      placeholderText: "请输入新的手机号码",
+      content: currentUser.phone || "",
+      success: (r) => {
+        if (!r.confirm || !r.content?.trim()) return
+        const phone = r.content.trim()
+        if (!/^1[3-9]\d{9}$/.test(phone)) {
+          return uni.showToast({ title: "请输入正确的手机号", icon: "none" });
+        }
+        commitChange(
+          { phone },
+          { toast: "已保存" },
+        )
+      },
+    });
+  }
+  if (row.editor === "emergency") {
+    return (async () => {
+      const r = await openTwoModal({
+        title: "修改紧急联系人",
+        fields: [
+          { key: "name", label: "名称", placeholder: "请输入联系人姓名", maxlength: 30 },
+          { key: "phone", label: "手机号", placeholder: "请输入 11 位手机号", inputType: "number", maxlength: 11 },
+        ],
+        initial: {
+          name: currentUser.emergencyName || "",
+          phone: currentUser.emergencyPhone || "",
+        },
+      })
+      if (!r) return
+      if (!r.name) return uni.showToast({ title: "请填写名称", icon: "none" })
+      if (!/^1[3-9]\d{9}$/.test(r.phone)) {
+        return uni.showToast({ title: "请输入正确的手机号", icon: "none" })
+      }
+      const relation = currentUser.emergencyRelation || ""
+      commitChange(
+        {
+          secondLinkName: r.name,
+          secondLinkPhone: r.phone,
+          secondLinkRelation: relation,
+        },
+        { toast: "已保存" },
+      )
+    })()
+  }
   uni.showModal({
     title: `修改${row.label}`,
     editable: true,
     placeholderText: `请输入新的${row.label}`,
     success: (r) => {
       if (r.confirm && r.content?.trim()) {
-        lastRequest.value = { field: row.label, value: r.content.trim() };
-        saveProfileChange(lastRequest.value);
-        uni.showToast({ title: "已提交HR审核", icon: "success" });
+        uni.showToast({ title: "已提交HR审核", icon: "success" })
       }
     },
+  })
+}
+
+// ============ 银行卡:新增 / 编辑 / 删除 / 设为默认 ============
+
+function nextCardId() {
+  return `local_${Date.now().toString(36)}_${Math.floor(Math.random() * 1000)}`
+}
+
+// 仅本地组装 bankCardList,把后端要求的 accountName 兜底为当前用户姓名
+function buildBankPayload() {
+  return bankCardList.value.map((c) => ({
+    cardNumber: String(c.cardNumber || "").trim(),
+    openingBank: String(c.openingBank || "").trim(),
+    accountName: String(c.accountName || currentUser.name || "").trim(),
+  }))
+}
+
+async function addBankCard() {
+  const r = await openTwoModal({
+    title: "新增银行卡",
+    fields: [
+      { key: "cardNumber", label: "卡号", placeholder: "请输入完整卡号", inputType: "number", maxlength: 24 },
+      { key: "openingBank", label: "银行", placeholder: "如:中国工商银行", maxlength: 30 },
+    ],
+  })
+  if (!r) return
+  if (!r.cardNumber) return uni.showToast({ title: "请填写卡号", icon: "none" })
+  if (!r.openingBank) return uni.showToast({ title: "请填写银行", icon: "none" })
+  bankCardList.value.push({
+    id: nextCardId(),
+    cardNumber: r.cardNumber,
+    openingBank: r.openingBank,
+    branch: "",
+  });
+  await commitChange(
+    { bankCardList: buildBankPayload() },
+    { toast: "已添加" },
+  )
+}
+
+async function editBankCard(card) {
+  const r = await openTwoModal({
+    title: "编辑银行卡",
+    fields: [
+      { key: "cardNumber", label: "卡号", placeholder: "请输入完整卡号", inputType: "number", maxlength: 24 },
+      { key: "openingBank", label: "银行", placeholder: "如:中国工商银行", maxlength: 30 },
+    ],
+    initial: {
+      cardNumber: card.cardNumber || "",
+      openingBank: card.openingBank || "",
+    },
+  })
+  if (!r) return
+  if (!r.cardNumber) return uni.showToast({ title: "请填写卡号", icon: "none" })
+  if (!r.openingBank) return uni.showToast({ title: "请填写银行", icon: "none" })
+  card.cardNumber = r.cardNumber
+  card.openingBank = r.openingBank
+  await commitChange(
+    { bankCardList: buildBankPayload() },
+    { toast: "已修改" },
+  )
+}
+
+function removeBankCard(card) {
+  uni.showModal({
+    title: "删除银行卡",
+    content: `确定删除 ${bankLabel(card)} 吗?`,
+    confirmColor: "#ee4d4d",
+    success: async (r) => {
+      if (!r.confirm) return;
+      const idx = bankCardList.value.findIndex((c) => c.id === card.id);
+      if (idx < 0) return;
+      bankCardList.value.splice(idx, 1);
+      await commitChange(
+        { bankCardList: buildBankPayload() },
+        { toast: "已删除" },
+      )
+    },
   });
 }
 </script>
@@ -125,25 +463,152 @@ function editRow(row) {
   color: #c4c8ce;
   font-size: 36rpx;
 }
-.request-tip {
-  padding: 25rpx 28rpx;
+.tip {
+  display: block;
+  margin: 25rpx 0;
+  text-align: center;
+  color: #a0a7b0;
+  font-size: 21rpx;
+}
+.group-title {
   display: flex;
   align-items: center;
-  gap: 15rpx;
+  justify-content: space-between;
+}
+.add-bank {
+  color: #1677ff;
+  font-size: 24rpx;
+}
+.bank-card-list {
+  padding: 0 24rpx;
+}
+.bank-card {
+  padding: 24rpx 28rpx;
+  margin-bottom: 18rpx;
 }
-.request-tip > text:first-child {
+.bank-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.bank-title {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+}
+.bank-name {
+  font-size: 29rpx;
   font-weight: 600;
+  color: #1f2329;
 }
-.request-tip > text:nth-child(2) {
-  flex: 1;
-  color: #6f7782;
-  text-align: right;
+.bank-actions {
+  display: flex;
+  gap: 18rpx;
 }
-.tip {
+.bank-action {
+  font-size: 25rpx;
+  color: #1677ff;
+}
+.bank-action.danger {
+  color: #ee4d4d;
+}
+.bank-no {
+  margin-top: 16rpx;
+  font-size: 32rpx;
+  font-weight: 600;
+  letter-spacing: 1rpx;
+  color: #1f2329;
+}
+.bank-branch {
+  margin-top: 10rpx;
+  font-size: 22rpx;
+  color: #8b939f;
+}
+.bank-empty {
+  margin: 0 24rpx;
+  padding: 48rpx 28rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 16rpx;
+  color: #8b939f;
+  font-size: 25rpx;
+}
+.bank-empty > text:last-child {
+  color: #1677ff;
+}
+.two-modal-mask {
+  position: fixed;
+  inset: 0;
+  background: rgba(0, 0, 0, 0.45);
+  z-index: 99;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.two-modal {
+  width: 620rpx;
+  max-width: 92vw;
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 36rpx 36rpx 0;
+  display: flex;
+  flex-direction: column;
+}
+.two-modal-title {
   display: block;
-  margin: 25rpx 0;
   text-align: center;
-  color: #a0a7b0;
-  font-size: 21rpx;
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #1f2329;
+  margin-bottom: 28rpx;
+}
+.two-modal-fields {
+  display: flex;
+  flex-direction: column;
+  gap: 20rpx;
+  padding-bottom: 8rpx;
+}
+.two-modal-field {
+  display: flex;
+  align-items: center;
+  background: #f6f7f8;
+  border-radius: 12rpx;
+  padding: 0 22rpx;
+  min-height: 88rpx;
+}
+.two-modal-label {
+  width: 140rpx;
+  flex-shrink: 0;
+  font-size: 26rpx;
+  color: #6f7782;
+}
+.two-modal-input {
+  flex: 1;
+  font-size: 28rpx;
+  color: #1f2329;
+  background: transparent;
+}
+.two-modal-actions {
+  margin: 28rpx -36rpx 0;
+  border-top: 1rpx solid #edf0f2;
+  display: flex;
+}
+.two-modal-btn {
+  flex: 1;
+  height: 96rpx;
+  line-height: 96rpx;
+  text-align: center;
+  font-size: 30rpx;
+  color: #4b5563;
+  border-right: 1rpx solid #edf0f2;
+}
+.two-modal-btn:last-child {
+  border-right: 0;
+  color: #1677ff;
+  font-weight: 600;
+}
+.two-modal-btn.primary {
+  color: #1677ff;
 }
 </style>