瀏覽代碼

发票抬头管理功能

skyline 2 天之前
父節點
當前提交
3f5ba22a52

+ 37 - 6
charge-front/src/api/index.ts

@@ -11,12 +11,21 @@ export function fetchCommonQuestions() {
   return indexHttp.get("/qa");
 }
 
-export function applyInvoice(startChargeSeqs: string[]) {
-  return indexHttp.post("/invoice/applyInvoice", {
-    data: {
-      startChargeSeqs,
-    },
-  });
+export interface ApplyInvoiceParams {
+  startChargeSeqs: string[];
+  email?: string;
+  phone?: string;
+  invoiceType?: string;
+  invoiceTitle?: string;
+  taxId?: string;
+  address?: string;
+  bankName?: string;
+  bankAccount?: string;
+  remark?: string;
+}
+
+export function applyInvoice(params: ApplyInvoiceParams) {
+  return indexHttp.post("/invoice/applyInvoice", { data: params });
 }
 
 export function fetchHomeBanner() {
@@ -34,3 +43,25 @@ export function cancelApplyInvoice() {
 export function fetchInvoicePDF(invoiceId: string) {
   return indexHttp.get(`/invoice/downloadInvoice/${invoiceId}`);
 }
+
+// ==================== 发票抬头管理 ====================
+
+export function fetchTitleList() {
+  return indexHttp.get("/invoice/title/list");
+}
+
+export function createTitle(data: any) {
+  return indexHttp.post("/invoice/title", { data });
+}
+
+export function updateTitle(id: number, data: any) {
+  return indexHttp.put(`/invoice/title/${id}`, { data });
+}
+
+export function deleteTitle(id: number) {
+  return indexHttp.post(`/invoice/title/${id}/delete`);
+}
+
+export function setDefaultTitle(id: number) {
+  return indexHttp.put(`/invoice/title/${id}/default`);
+}

+ 11 - 34
charge-front/src/pages-charge/orders/orders.vue

@@ -127,11 +127,7 @@ import {
   onShow,
 } from "@dcloudio/uni-app";
 import { fetchOrders } from "../../api/user";
-import {
-  applyInvoice,
-  cancelApplyInvoice,
-  fetchInvoiceList,
-} from "../../api/index";
+import { fetchInvoiceList } from "../../api/index";
 import { useInfiniteScroll } from "../../utils/infinite-scroll";
 import { ref } from "vue";
 import { to } from "@/utils/navigate";
@@ -225,35 +221,16 @@ const openInvoiceHistory = () => {
   });
 };
 const nextInvoice = () => {
-  uni.showLoading({
-    title: "请求中",
-  });
-  applyInvoice(
-    infiniteScroller.list
-      .filter((item: any) => item.checked)
-      .map((item: any) => item.startChargeSeq)
-  )
-    .then((res) => {
-      uni.hideLoading();
-      uni.navigateToMiniProgram({
-        appId: res.miniprogramAppid,
-        path: res.miniprogramPath,
-        success(res) {
-          console.log("打开成功");
-          isInvoiceing.value = true;
-        },
-        fail() {
-          console.log("打开失败");
-          cancelApplyInvoice();
-        },
-      });
-    })
-    .catch((err) => {
-      uni.hideLoading();
-      uni.showModal({
-        content: `${err.errMsg}`,
-      });
-    });
+  const checkedSeqs = infiniteScroller.list
+    .filter((item: any) => item.checked)
+    .map((item: any) => item.startChargeSeq);
+
+  if (!checkedSeqs.length) {
+    uni.showToast({ title: "请选择订单", icon: "none" });
+    return;
+  }
+
+  to(`/pages-common/invoice/invoice-form?startChargeSeqs=${checkedSeqs.join(",")}`);
 };
 const checkPage = () => {
   infiniteScroller.list = infiniteScroller.list.map((item: any) => {

+ 366 - 0
charge-front/src/pages-common/invoice/invoice-form.vue

@@ -0,0 +1,366 @@
+<template>
+  <view class="page">
+    <!-- 已选订单摘要 -->
+    <view class="summary-bar flex-align-center pl-30 pr-30 pt-20 pb-20">
+      <view class="fs-28 color-666">已选 {{ checkedCount }} 个订单</view>
+    </view>
+
+    <!-- 已保存抬头选择 -->
+    <view class="block mt-20" v-if="titleList.length">
+      <view class="fs-30 fw-600 color-333 mb-20">选择已有抬头</view>
+      <scroll-view scroll-x class="title-scroll">
+        <view class="title-cards flex">
+          <view
+            v-for="item in titleList"
+            :key="item.id"
+            class="title-card br-12 p-20 mr-20"
+            :class="{ active: selectedTitleId === item.id }"
+            @click="selectTitle(item)"
+          >
+            <view class="fs-28 fw-500 color-333 ellipsis">{{ item.invoiceTitle }}</view>
+            <view class="fs-22 color-999 mt-8">{{ item.invoiceType === 'ORGANIZATION' ? '企业' : '个人' }}</view>
+            <view class="fs-20 color-theme mt-4" v-if="item.isDefault">默认</view>
+          </view>
+        </view>
+      </scroll-view>
+    </view>
+
+    <!-- 发票抬头表单 -->
+    <view class="block mt-20">
+      <view class="fs-30 fw-600 color-333 mb-28">填写发票信息</view>
+
+      <!-- 发票类型 -->
+      <view class="form-item">
+        <view class="fs-28 color-333 mb-12">发票类型</view>
+        <view class="type-selector flex">
+          <view
+            class="type-option flex-center br-8 fs-28 py-12 px-24 mr-20"
+            :class="{ active: form.invoiceType === 'INDIVIDUAL' }"
+            @click="form.invoiceType = 'INDIVIDUAL'"
+          >个人</view>
+          <view
+            class="type-option flex-center br-8 fs-28 py-12 px-24"
+            :class="{ active: form.invoiceType === 'ORGANIZATION' }"
+            @click="form.invoiceType = 'ORGANIZATION'"
+          >企业</view>
+        </view>
+      </view>
+
+      <!-- 抬头名称 -->
+      <view class="form-item">
+        <view class="fs-28 color-333 mb-12">
+          {{ form.invoiceType === 'ORGANIZATION' ? '公司名称' : '姓名' }}
+          <text class="color-primary">*</text>
+        </view>
+        <input
+          class="input br-8 fs-28 p-20"
+          v-model="form.invoiceTitle"
+          :placeholder="form.invoiceType === 'ORGANIZATION' ? '请输入公司全称' : '请输入姓名'"
+        />
+      </view>
+
+      <!-- 企业专有字段 -->
+      <template v-if="form.invoiceType === 'ORGANIZATION'">
+        <view class="form-item">
+          <view class="fs-28 color-333 mb-12">公司税号<text class="color-primary">*</text></view>
+          <input class="input br-8 fs-28 p-20" v-model="form.taxId" placeholder="请输入纳税人识别号" />
+        </view>
+        <view class="form-item">
+          <view class="fs-28 color-333 mb-12">公司地址</view>
+          <input class="input br-8 fs-28 p-20" v-model="form.address" placeholder="请输入注册地址" />
+        </view>
+        <view class="form-item">
+          <view class="fs-28 color-333 mb-12">公司电话</view>
+          <input class="input br-8 fs-28 p-20" v-model="form.phone" placeholder="请输入公司电话" />
+        </view>
+        <view class="form-item">
+          <view class="fs-28 color-333 mb-12">开户银行</view>
+          <input class="input br-8 fs-28 p-20" v-model="form.bankName" placeholder="请输入开户银行" />
+        </view>
+        <view class="form-item">
+          <view class="fs-28 color-333 mb-12">银行账号</view>
+          <input class="input br-8 fs-28 p-20" v-model="form.bankAccount" placeholder="请输入银行账号" />
+        </view>
+      </template>
+
+      <!-- 公共字段 -->
+      <view class="form-item">
+        <view class="fs-28 color-333 mb-12">接收邮箱</view>
+        <input class="input br-8 fs-28 p-20" v-model="form.email" placeholder="请输入邮箱(用于接收电子发票)" />
+      </view>
+      <view class="form-item">
+        <view class="fs-28 color-333 mb-12">备注</view>
+        <input class="input br-8 fs-28 p-20" v-model="form.remark" placeholder="可选备注信息" />
+      </view>
+
+      <!-- 保存此抬头 -->
+      <view class="form-item flex-align-center mt-10">
+        <view class="fs-28 color-333 mr-16">保存此抬头</view>
+        <view class="switch-container" @click="saveTitleCheck = !saveTitleCheck">
+          <view class="switch br-24" :class="{ on: saveTitleCheck }">
+            <view class="switch-dot"></view>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 底部提交 -->
+    <view class="foot-placeholder"></view>
+    <view class="foot-bar flex-center bg-fff">
+      <view class="submit-btn br-40 bg-theme color-fff fs-32 fw-500 flex-center" @click="submit">
+        提交开票
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { onLoad } from "@dcloudio/uni-app";
+import { ref, reactive } from "vue";
+import { applyInvoice, fetchTitleList } from "@/api";
+import { back } from "@/utils/navigate";
+
+const checkedCount = ref(0);
+const startChargeSeqs = ref<string[]>([]);
+const titleList = ref<any[]>([]);
+const selectedTitleId = ref(0);
+const saveTitleCheck = ref(true);
+
+const form = reactive({
+  invoiceType: "ORGANIZATION",
+  invoiceTitle: "",
+  taxId: "",
+  address: "",
+  phone: "",
+  bankName: "",
+  bankAccount: "",
+  email: "",
+  remark: "",
+});
+
+onLoad((options: any) => {
+  if (options.startChargeSeqs) {
+    startChargeSeqs.value = options.startChargeSeqs.split(",");
+    checkedCount.value = startChargeSeqs.value.length;
+  }
+
+  // 加载已保存的抬头
+  fetchTitleList()
+    .then((res: any) => {
+      titleList.value = res || [];
+    })
+    .catch(() => {});
+});
+
+const selectTitle = (item: any) => {
+  selectedTitleId.value = item.id;
+  form.invoiceType = item.invoiceType || "ORGANIZATION";
+  form.invoiceTitle = item.invoiceTitle || "";
+  form.taxId = item.taxId || "";
+  form.address = item.address || "";
+  form.phone = item.phone || item.telephone || "";
+  form.bankName = item.bankName || "";
+  form.bankAccount = item.bankAccount || "";
+  form.email = item.email || "";
+};
+
+const submit = () => {
+  if (!form.invoiceTitle.trim()) {
+    uni.showToast({ title: "请填写抬头名称", icon: "none" });
+    return;
+  }
+  if (form.invoiceType === "ORGANIZATION" && !form.taxId.trim()) {
+    uni.showToast({ title: "请填写公司税号", icon: "none" });
+    return;
+  }
+
+  uni.showLoading({ title: "提交中" });
+
+  const params: any = {
+    startChargeSeqs: startChargeSeqs.value,
+    invoiceType: form.invoiceType,
+    invoiceTitle: form.invoiceTitle.trim(),
+    email: form.email.trim(),
+    remark: form.remark.trim(),
+  };
+
+  if (form.invoiceType === "ORGANIZATION") {
+    params.taxId = form.taxId.trim();
+    params.address = form.address.trim();
+    params.phone = form.phone.trim();
+    params.bankName = form.bankName.trim();
+    params.bankAccount = form.bankAccount.trim();
+  }
+
+  applyInvoice(params)
+    .then(() => {
+      uni.hideLoading();
+      uni.showToast({ title: "开票申请已提交", icon: "success", duration: 2000 });
+      setTimeout(() => {
+        back();
+      }, 2000);
+    })
+    .catch((err) => {
+      uni.hideLoading();
+      uni.showModal({ content: err.errMsg || "提交失败,请重试" });
+    });
+};
+</script>
+
+<style lang="scss">
+.page {
+  min-height: 100vh;
+  background-color: #f6f7fa;
+  padding-bottom: 120rpx;
+}
+
+.summary-bar {
+  background-color: #fff;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+
+.block {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 30rpx;
+  margin: 0 20rpx;
+}
+
+.form-item {
+  margin-bottom: 28rpx;
+}
+
+.input {
+  width: 100%;
+  background: #f6f7fa;
+  border: 1rpx solid #eee;
+  box-sizing: border-box;
+  height: 80rpx;
+  line-height: 80rpx;
+}
+
+.type-selector {
+  .type-option {
+    border: 2rpx solid #e0e0e0;
+    color: #666;
+    transition: all 0.2s;
+
+    &.active {
+      border-color: #419D95;
+      background: rgba(65, 157, 149, 0.08);
+      color: #419D95;
+    }
+  }
+}
+
+.title-scroll {
+  white-space: nowrap;
+}
+
+.title-cards {
+  .title-card {
+    display: inline-block;
+    width: 240rpx;
+    border: 2rpx solid #eee;
+    flex-shrink: 0;
+
+    &.active {
+      border-color: #419D95;
+      background: rgba(65, 157, 149, 0.06);
+    }
+  }
+}
+
+.ellipsis {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.switch {
+  width: 80rpx;
+  height: 44rpx;
+  background: #ddd;
+  position: relative;
+  transition: background 0.2s;
+
+  &.on {
+    background: #419D95;
+  }
+}
+
+.switch-dot {
+  width: 36rpx;
+  height: 36rpx;
+  background: #fff;
+  border-radius: 50%;
+  position: absolute;
+  top: 4rpx;
+  left: 4rpx;
+  transition: transform 0.2s;
+
+  .switch.on & {
+    transform: translateX(36rpx);
+  }
+}
+
+.foot-placeholder {
+  height: 140rpx;
+}
+
+.foot-bar {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  height: 120rpx;
+  padding-bottom: env(safe-area-inset-bottom);
+  box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.05);
+}
+
+.submit-btn {
+  width: 600rpx;
+  height: 80rpx;
+}
+
+.br-8 { border-radius: 8rpx; }
+.br-12 { border-radius: 12rpx; }
+.br-20 { border-radius: 20rpx; }
+.br-24 { border-radius: 24rpx; }
+.br-40 { border-radius: 40rpx; }
+.p-20 { padding: 20rpx; }
+.pl-30 { padding-left: 30rpx; }
+.pr-30 { padding-right: 30rpx; }
+.pt-20 { padding-top: 20rpx; }
+.pb-20 { padding-bottom: 20rpx; }
+.mt-10 { margin-top: 10rpx; }
+.mt-20 { margin-top: 20rpx; }
+.mt-8 { margin-top: 8rpx; }
+.mt-4 { margin-top: 4rpx; }
+.mb-12 { margin-bottom: 12rpx; }
+.mb-20 { margin-bottom: 20rpx; }
+.mb-28 { margin-bottom: 28rpx; }
+.mr-16 { margin-right: 16rpx; }
+.mr-20 { margin-right: 20rpx; }
+.fs-20 { font-size: 20rpx; }
+.fs-22 { font-size: 22rpx; }
+.fs-24 { font-size: 24rpx; }
+.fs-28 { font-size: 28rpx; }
+.fs-30 { font-size: 30rpx; }
+.fs-32 { font-size: 32rpx; }
+.fw-500 { font-weight: 500; }
+.fw-600 { font-weight: 600; }
+.color-333 { color: #333; }
+.color-666 { color: #666; }
+.color-999 { color: #999; }
+.color-primary { color: #e74c3c; }
+.color-theme { color: #419D95; }
+.color-fff { color: #fff; }
+.bg-fff { background-color: #fff; }
+.bg-theme { background-color: #419D95; }
+.flex { display: flex; }
+.flex-center { display: flex; align-items: center; justify-content: center; }
+.flex-align-center { display: flex; align-items: center; }
+.py-12 { padding-top: 12rpx; padding-bottom: 12rpx; }
+.px-24 { padding-left: 24rpx; padding-right: 24rpx; }
+</style>

+ 54 - 97
charge-front/src/pages-common/invoice/invoice.vue

@@ -3,9 +3,7 @@
     <view class="block">
       <view
         class="flex-align-center"
-        :style="{
-          marginTop: index === 0 ? 0 : '40rpx',
-        }"
+        :style="{ marginTop: index === 0 ? 0 : '40rpx' }"
         v-for="(item, index) in list"
         :key="index"
       >
@@ -24,15 +22,9 @@
       <view class="fs-28 lh-48 color-000">{{ email }}</view>
     </view>
     <view class="block pt-40 pb-40 pl-30 pr-30 flex mt-30">
-      <view class="fs-32 lh-48 color-999" style="width: 192rpx">发票</view>
-      <view class="ml-auto flex-align-center" @click="openInvoice">
-        <view class="fs-28 color-primary mr-4">立即查看</view>
-        <uni-icons
-          type="right"
-          size="14"
-          color="var(--color-primary)"
-        ></uni-icons>
-      </view>
+      <view class="fs-32 lh-48 color-999" style="width: 192rpx">电子发票</view>
+      <view class="fs-28 color-theme" @click="openInvoice('pdf')" v-if="pdfUrl">PDF版</view>
+      <view class="fs-28 color-999" v-else>暂无</view>
     </view>
   </view>
 </template>
@@ -44,52 +36,23 @@ import { ref } from "vue";
 
 const openInvoiceId = ref();
 const email = ref();
+const pdfUrl = ref("");
 const list = ref([
-  {
-    label: "公司名称",
-    value: "",
-  },
-  {
-    label: "公司税号",
-    value: "",
-  },
-  {
-    label: "注册地址",
-    value: "",
-  },
-  {
-    label: "注册电话",
-    value: "",
-  },
-  {
-    label: "开户银行",
-    value: "",
-  },
-  {
-    label: "银行账号",
-    value: "",
-  },
-  {
-    label: "备注",
-    value: "",
-  },
-  {
-    label: "发票金额",
-    value: "",
-  },
-  {
-    label: "申请时间",
-    value: "",
-  },
+  { label: "公司名称", value: "" },
+  { label: "公司税号", value: "" },
+  { label: "注册地址", value: "" },
+  { label: "注册电话", value: "" },
+  { label: "开户银行", value: "" },
+  { label: "银行账号", value: "" },
+  { label: "备注", value: "" },
+  { label: "发票金额", value: "" },
+  { label: "申请时间", value: "" },
 ]);
 
 onLoad((options: any) => {
-  uni.showLoading({
-    title: "加载中",
-  });
+  uni.showLoading({ title: "加载中" });
   fetchInvoiceList()
     .then((res) => {
-      // console.log(res);
       uni.hideLoading();
       const fd = res.findIndex((item: any) => {
         if (item.orderDetails && item.orderDetails.length) {
@@ -100,66 +63,60 @@ onLoad((options: any) => {
         return false;
       });
       if (fd >= 0) {
-        list.value[0].value = res[fd].invoiceTitle;
-        list.value[1].value = res[fd].taxId;
+        list.value[0].value = res[fd].invoiceTitle || "-";
+        list.value[1].value = res[fd].taxId || "-";
         list.value[2].value = res[fd].address || "-";
-        list.value[3].value = res[fd].phone || "-";
+        list.value[3].value = res[fd].phone || res[fd].telephone || "-";
         list.value[4].value = res[fd].bankName || "-";
         list.value[5].value = res[fd].bankAccount || "-";
         list.value[6].value = res[fd].remark || "-";
-        list.value[7].value = `${(Number(res[fd].invoiceAmount) / 100).toFixed(
-          2
-        )}`;
-        list.value[8].value = res[fd].createTime;
+        list.value[7].value = `${(Number(res[fd].invoiceAmount) / 100).toFixed(2)}`;
+        list.value[8].value = res[fd].createTime || "-";
         openInvoiceId.value = res[fd].id;
         email.value = res[fd].email || "";
         return;
       }
-      return Promise.reject({
-        errMsg: "找不到发票数据",
-      });
+      return Promise.reject({ errMsg: "找不到发票数据" });
     })
     .catch((err) => {
       uni.hideLoading();
-      uni.showModal({
-        content: `${err.errMsg},请重试`,
-      });
+      uni.showModal({ content: `${err.errMsg},请重试` });
     });
 });
 
-const openInvoice = () => {
-  uni.showLoading({
-    title: "加载中",
-  });
-  fetchInvoicePDF(openInvoiceId.value).then((res) => {
-    uni.downloadFile({
-      url: res.downloadUrl,
-      success: function (res) {
-        const filePath = res.tempFilePath;
-        uni.openDocument({
-          filePath,
-          fileType: "pdf",
-          showMenu: true,
-          success: function (res) {
-            uni.hideLoading();
-            console.log("打开文档成功");
-          },
-          fail(err) {
-            uni.hideLoading();
-            uni.showModal({
-              content: `${err.errMsg},请重试`,
-            });
-          },
-        });
-      },
-      fail(err) {
-        uni.hideLoading();
-        uni.showModal({
-          content: `${err.errMsg},请重试`,
-        });
-      },
+const openInvoice = (type: string) => {
+  uni.showLoading({ title: "加载中" });
+  fetchInvoicePDF(openInvoiceId.value)
+    .then((res: any) => {
+      uni.hideLoading();
+      const urls = res.downloadUrls || res;
+      const fileUrl = type === "pdf" ? (urls.invoiceUrl || urls.downloadUrl) : urls.ofdUrl;
+
+      if (!fileUrl) {
+        uni.showToast({ title: "暂无发票文件", icon: "none" });
+        return;
+      }
+
+      uni.downloadFile({
+        url: fileUrl,
+        success: (downloadRes) => {
+          uni.openDocument({
+            filePath: downloadRes.tempFilePath,
+            showMenu: true,
+            fail(err: any) {
+              uni.showModal({ content: `打开失败: ${err.errMsg}` });
+            },
+          });
+        },
+        fail(err: any) {
+          uni.showModal({ content: `下载失败: ${err.errMsg}` });
+        },
+      });
+    })
+    .catch(() => {
+      uni.hideLoading();
+      uni.showToast({ title: "获取发票失败", icon: "none" });
     });
-  });
 };
 </script>
 

+ 362 - 0
charge-front/src/pages-user/invoice-title.vue

@@ -0,0 +1,362 @@
+<template>
+  <view class="page">
+    <!-- 抬头列表 -->
+    <view class="block" v-if="titleList.length">
+      <view
+        v-for="(item, idx) in titleList"
+        :key="item.id"
+        class="swipe-clip"
+        :style="{ width: itemWidth + 'px' }"
+      >
+        <view
+          class="swipe-track"
+          :class="{ default: item.isDefault }"
+          :style="trackStyle(idx)"
+          @touchstart="onTouchStart($event, idx)"
+          @touchmove="onTouchMove($event, idx)"
+          @touchend="onTouchEnd(idx)"
+        >
+          <view
+            class="item-content"
+            :style="{ width: itemWidth + 'px' }"
+          >
+            <view class="item-header">
+              <view class="item-title-row">
+                <text class="title-name">{{ item.invoiceTitle }}</text>
+                <text class="type-tag" :class="item.invoiceType === 'ORGANIZATION' ? 'tag-org' : 'tag-personal'">
+                  {{ item.invoiceType === 'ORGANIZATION' ? '企业' : '个人' }}
+                </text>
+                <text class="default-tag" v-if="item.isDefault">默认</text>
+              </view>
+            </view>
+            <view class="item-detail" v-if="item.taxId">税号:{{ item.taxId }}</view>
+            <view class="item-footer" v-if="!item.isDefault">
+              <text class="set-default-btn" @click.stop="setDefault(item.id)">设为默认</text>
+            </view>
+          </view>
+          <view class="item-btns" :style="{ width: btnWidth + 'px' }">
+            <view class="action-btn edit-btn" @click.stop="editTitle(item)">编辑</view>
+            <view class="action-btn delete-btn" @click.stop="removeTitle(item.id)">删除</view>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 空状态 -->
+    <view class="empty-state" v-else>
+      <uni-icons type="compose" size="64" color="rgba(0,0,0,0.1)"></uni-icons>
+      <text class="empty-title">暂无保存的抬头</text>
+      <text class="empty-desc">添加抬头后可快速开具发票</text>
+    </view>
+
+    <!-- 底部新增按钮 -->
+    <view class="add-row">
+      <view class="add-btn" @click="openForm()">新增抬头</view>
+    </view>
+
+    <!-- 编辑/新增弹窗 -->
+    <view class="modal-mask" :class="{ visible: showForm }" v-if="showForm" @click="closeForm">
+      <view class="modal-content" :class="{ visible: showForm }" @click.stop>
+        <!-- 拖拽手柄 -->
+        <view class="modal-handle"><view class="handle-bar"></view></view>
+
+        <view class="modal-header">
+          <text class="modal-title">{{ editId ? '编辑抬头' : '新增抬头' }}</text>
+          <view class="modal-close" @click="closeForm">
+            <uni-icons type="closeempty" size="20" color="rgba(0,0,0,0.4)"></uni-icons>
+          </view>
+        </view>
+
+        <view class="modal-body" :style="{ maxHeight: maxModalHeight + 'px' }">
+          <view class="form-group">
+            <view class="form-label">发票类型</view>
+            <view class="type-options">
+              <view class="type-opt" :class="{ active: editForm.invoiceType === 'INDIVIDUAL' }" @click="editForm.invoiceType = 'INDIVIDUAL'">个人</view>
+              <view class="type-opt" :class="{ active: editForm.invoiceType === 'ORGANIZATION' }" @click="editForm.invoiceType = 'ORGANIZATION'">企业</view>
+            </view>
+          </view>
+
+          <view class="form-group">
+            <view class="form-label">
+              {{ editForm.invoiceType === 'ORGANIZATION' ? '公司名称' : '姓名' }}
+              <text class="required">*</text>
+            </view>
+            <view class="form-row"><input class="form-input" v-model="editForm.invoiceTitle" :placeholder="editForm.invoiceType === 'ORGANIZATION' ? '请输入公司全称' : '请输入姓名'" placeholder-class="input-placeholder" /></view>
+          </view>
+
+          <template v-if="editForm.invoiceType === 'ORGANIZATION'">
+            <view class="form-section-label">企业信息</view>
+            <view class="form-group">
+              <view class="form-label">公司税号<text class="required">*</text></view>
+              <view class="form-row"><input class="form-input" v-model="editForm.taxId" placeholder="请输入纳税人识别号" placeholder-class="input-placeholder" /></view>
+            </view>
+            <view class="form-group"><view class="form-label">公司地址</view><view class="form-row"><input class="form-input" v-model="editForm.address" placeholder="请输入注册地址" placeholder-class="input-placeholder" /></view>
+            <view class="form-group"><view class="form-label">公司电话</view><view class="form-row"><input class="form-input" v-model="editForm.phone" type="number" placeholder="请输入公司电话" placeholder-class="input-placeholder" /></view>
+            <view class="form-group"><view class="form-label">开户银行</view><view class="form-row"><input class="form-input" v-model="editForm.bankName" placeholder="请输入开户行名称" placeholder-class="input-placeholder" /></view>
+            <view class="form-group"><view class="form-label">银行账号</view><view class="form-row"><input class="form-input" v-model="editForm.bankAccount" type="number" placeholder="请输入银行账号" placeholder-class="input-placeholder" /></view>
+          </template>
+
+          <view class="form-section-label">其他</view>
+          <view class="form-group">
+            <view class="form-label">接收邮箱</view>
+            <view class="form-row"><input class="form-input" v-model="editForm.email" placeholder="选填,用于接收电子发票" placeholder-class="input-placeholder" /></view>
+          </view>
+
+          <view class="form-group form-switch">
+            <view class="form-label">设为默认抬头</view>
+            <view class="switch" :class="{ on: editForm.isDefault }" @click="editForm.isDefault = !editForm.isDefault"><view class="switch-dot"></view></view>
+          </view>
+        </view>
+
+        <view class="modal-footer">
+          <view class="btn-cancel" @click="closeForm">取消</view>
+          <view class="btn-save" :class="{ loading: saving }" @click="saveTitle">
+            <text v-if="!saving">保存</text>
+            <text v-else>保存中</text>
+          </view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { onShow } from "@dcloudio/uni-app";
+import { ref, reactive } from "vue";
+import { fetchTitleList, createTitle, updateTitle, deleteTitle, setDefaultTitle } from "@/api";
+
+interface InvoiceTitleItem {
+  id: number; userId: number; invoiceType: string; invoiceTitle: string;
+  taxId?: string; address?: string; telephone?: string; phone?: string;
+  bankName?: string; bankAccount?: string; email?: string; isDefault?: boolean;
+}
+
+interface EditForm {
+  invoiceType: string; invoiceTitle: string; taxId: string; address: string;
+  phone: string; bankName: string; bankAccount: string; email: string; isDefault: boolean;
+}
+
+// --- swipe ---
+const swipedIdx = ref(-1);
+const swipeX = ref(0);
+const touchStartX = ref(0);
+const touchMoved = ref(false);
+
+function rpxToPx(rpx: number) {
+  return rpx * (uni.getSystemInfoSync().windowWidth / 750);
+}
+
+function closeSwipe() { swipeX.value = 0; swipedIdx.value = -1; }
+
+// layout constants (px)
+const itemWidth = uni.getSystemInfoSync().windowWidth - rpxToPx(40);
+const btnWidth = rpxToPx(160);
+
+function trackStyle(idx: number) {
+  const x = swipedIdx.value === idx ? swipeX.value : 0;
+  return { transform: 'translateX(' + x + 'px)', width: (itemWidth + btnWidth) + 'px' };
+}
+
+function onTouchStart(e: { touches: Array<{ clientX: number }> }, idx: number) {
+  if (swipedIdx.value !== idx && swipedIdx.value !== -1) closeSwipe();
+  touchMoved.value = false;
+  touchStartX.value = e.touches[0].clientX;
+  swipeX.value = swipedIdx.value === idx ? -btnWidth : 0;
+}
+
+function onTouchMove(e: { touches: Array<{ clientX: number }> }, idx: number) {
+  const dx = e.touches[0].clientX - touchStartX.value;
+  if (!touchMoved.value && Math.abs(dx) > 8) touchMoved.value = true;
+  if (!touchMoved.value) return;
+  const base = swipedIdx.value === idx ? -btnWidth : 0;
+  swipeX.value = Math.max(-btnWidth, Math.min(0, base + dx));
+}
+
+function onTouchEnd(idx: number) {
+  if (!touchMoved.value) { closeSwipe(); return; }
+  if (swipeX.value < -btnWidth * 0.4) { swipeX.value = -btnWidth; swipedIdx.value = idx; }
+  else closeSwipe();
+}
+
+// --- state ---
+const titleList = ref<InvoiceTitleItem[]>([]);
+const showForm = ref(false);
+const editId = ref(0);
+const saving = ref(false);
+const maxModalHeight = ref(600);
+
+const editForm = reactive<EditForm>({
+  invoiceType: "ORGANIZATION", invoiceTitle: "", taxId: "", address: "",
+  phone: "", bankName: "", bankAccount: "", email: "", isDefault: false,
+});
+
+const loadList = () => { fetchTitleList().then((res: InvoiceTitleItem[]) => { titleList.value = res || []; if (titleList.value.length && !hintShown) playHint(); }).catch(() => {}); };
+
+const hintShown = ref(false);
+
+function playHint() {
+  hintShown.value = true;
+  swipedIdx.value = 0;
+  swipeX.value = 0;
+  // trigger transition in
+  setTimeout(() => { swipeX.value = -btnWidth; }, 100);
+  // hold
+  setTimeout(() => {
+    // snap back
+    swipeX.value = 0;
+    setTimeout(() => { swipedIdx.value = -1; }, 250);
+  }, 1600);
+}
+const openForm = () => { resetForm(); showForm.value = true; calcModalHeight(); };
+const closeForm = () => { if (!saving.value) showForm.value = false; };
+const calcModalHeight = () => { maxModalHeight.value = (uni.getSystemInfoSync().windowHeight || 800) * 0.7; };
+
+const resetForm = () => {
+  editId.value = 0; editForm.invoiceType = "ORGANIZATION"; editForm.invoiceTitle = "";
+  editForm.taxId = ""; editForm.address = ""; editForm.phone = ""; editForm.bankName = "";
+  editForm.bankAccount = ""; editForm.email = ""; editForm.isDefault = false;
+};
+
+const editTitle = (item: InvoiceTitleItem) => {
+  editId.value = item.id; editForm.invoiceType = item.invoiceType || "ORGANIZATION";
+  editForm.invoiceTitle = item.invoiceTitle || ""; editForm.taxId = item.taxId || "";
+  editForm.address = item.address || ""; editForm.phone = item.phone || item.telephone || "";
+  editForm.bankName = item.bankName || ""; editForm.bankAccount = item.bankAccount || "";
+  editForm.email = item.email || ""; editForm.isDefault = !!item.isDefault;
+  showForm.value = true; calcModalHeight(); closeSwipe();
+};
+
+const saveTitle = () => {
+  if (!editForm.invoiceTitle.trim()) { uni.showToast({ title: "请填写抬头名称", icon: "none" }); return; }
+  if (editForm.invoiceType === "ORGANIZATION" && !editForm.taxId.trim()) { uni.showToast({ title: "企业抬头请填写税号", icon: "none" }); return; }
+  saving.value = true;
+  const data: Record<string, unknown> = { ...editForm };
+  const req = editId.value ? updateTitle(editId.value, data) : createTitle(data);
+  req.then(() => { saving.value = false; showForm.value = false; resetForm(); loadList(); uni.showToast({ title: editId.value ? "修改成功" : "已添加", icon: "success" }); })
+    .catch((err: { errMsg?: string }) => { saving.value = false; uni.showModal({ content: err.errMsg || "操作失败,请重试" }); });
+};
+
+const removeTitle = (id: number) => {
+  closeSwipe();
+  uni.showModal({ title: "确认删除", content: "删除后不可恢复,确定删除此抬头吗?", confirmText: "删除", confirmColor: "#e74c3c",
+    success: (res: { confirm: boolean }) => { if (res.confirm) { deleteTitle(id).then(() => { loadList(); uni.showToast({ title: "已删除", icon: "success" }); }).catch((err: { errMsg?: string }) => uni.showToast({ title: err.errMsg || "删除失败", icon: "none" })); } },
+  });
+};
+
+const setDefault = (id: number) => { setDefaultTitle(id).then(() => { loadList(); uni.showToast({ title: "已设为默认", icon: "success" }); }).catch((err: { errMsg?: string }) => uni.showToast({ title: err.errMsg || "操作失败", icon: "none" })); };
+
+onShow(() => { loadList(); });
+</script>
+
+<style lang="scss">
+// === Page ===
+.page { min-height: 100vh; background-color: #f6f7fa; padding: 20rpx 20rpx 140rpx; }
+
+// === Block ===
+.block { background: #fff; border-radius: 20rpx; overflow: hidden; }
+
+// === Swipe ===
+.swipe-clip {
+  overflow: hidden;
+
+  &:not(:last-child) {
+    border-bottom: 1rpx solid rgba(0, 0, 0, 0.06);
+  }
+}
+
+.swipe-track {
+  display: flex;
+  flex-wrap: nowrap;
+  transition: transform 0.2s ease-out;
+  background: #fff;
+
+  &.default {
+    background: linear-gradient(135deg, rgba(65, 157, 149, 0.03) 0%, rgba(65, 157, 149, 0.01) 100%);
+  }
+}
+
+.item-content {
+  flex-shrink: 0;
+  padding: 28rpx 28rpx;
+  box-sizing: border-box;
+  background: #fff;
+
+  .swipe-track.default & {
+    background: transparent;
+  }
+}
+
+.item-btns {
+  flex-shrink: 0;
+  display: flex;
+}
+
+.action-btn {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex: 1;
+  font-size: 26rpx;
+  color: #fff;
+}
+
+.edit-btn { background: #419D95; }
+.delete-btn { background: #e74c3c; }
+
+// === Item content ===
+.item-header { display: flex; align-items: flex-start; justify-content: space-between; }
+.item-title-row { display: flex; align-items: center; flex: 1; min-width: 0; }
+.title-name { font-size: 30rpx; font-weight: 600; color: #1a1a1a; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
+
+.type-tag { font-size: 20rpx; padding: 4rpx 12rpx; border-radius: 6rpx; margin-left: 12rpx; flex-shrink: 0;
+  &.tag-org { background: rgba(65, 157, 149, 0.1); color: #419D95; }
+  &.tag-personal { background: rgba(255, 153, 0, 0.1); color: #cc7a00; }
+}
+
+.default-tag { font-size: 20rpx; padding: 4rpx 12rpx; border-radius: 6rpx; margin-left: 8rpx; background: rgba(65, 157, 149, 0.12); color: #419D95; flex-shrink: 0; }
+.item-detail { font-size: 24rpx; color: #999; margin-top: 10rpx; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.item-footer { margin-top: 16rpx; }
+
+.set-default-btn { display: inline-block; font-size: 22rpx; color: #419D95; padding: 6rpx 20rpx; border: 1rpx solid rgba(65, 157, 149, 0.4); border-radius: 20rpx; }
+
+// === Empty ===
+.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding-top: 240rpx; }
+.empty-title { font-size: 28rpx; color: #999; margin-top: 24rpx; }
+.empty-desc { font-size: 24rpx; color: #ccc; margin-top: 10rpx; }
+
+// === Add button ===
+.add-row { position: fixed; bottom: 0; left: 0; right: 0; padding: 16rpx 0; padding-bottom: calc(16rpx + env(safe-area-inset-bottom)); display: flex; justify-content: center; }
+.add-btn { display: flex; align-items: center; justify-content: center; width: 400rpx; height: 80rpx; background: #419D95; color: #fff; font-size: 32rpx; font-weight: 500; border-radius: 40rpx; }
+
+// === Modal ===
+.modal-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0); z-index: 1000; display: flex; align-items: flex-end; transition: background 0.25s ease-out; &.visible { background: rgba(0,0,0,0.45); } }
+.modal-content { width: 100%; max-width: 100vw; background: #fff; border-radius: 32rpx 32rpx 0 0; transform: translateY(100%); transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); overflow: hidden; &.visible { transform: translateY(0); } }
+
+.modal-handle { display: flex; justify-content: center; padding: 16rpx 0 4rpx; }
+.handle-bar { width: 56rpx; height: 6rpx; border-radius: 3rpx; background: rgba(0,0,0,0.15); }
+
+.modal-header { display: flex; align-items: center; justify-content: center; padding: 8rpx 30rpx 20rpx; position: relative; border-bottom: 1rpx solid rgba(0,0,0,0.05); }
+.modal-title { font-size: 32rpx; font-weight: 600; color: #1a1a1a; }
+.modal-close { position: absolute; right: 30rpx; top: 50%; transform: translateY(-50%); padding: 12rpx; }
+
+.modal-body { padding: 8rpx 30rpx 0; overflow-y: auto; overflow-x: hidden; box-sizing: border-box; }
+
+.modal-footer { display: flex; padding: 20rpx 30rpx; padding-bottom: calc(20rpx + env(safe-area-inset-bottom)); gap: 20rpx; border-top: 1rpx solid rgba(0,0,0,0.05); }
+.btn-cancel { width: 160rpx; height: 80rpx; display: flex; align-items: center; justify-content: center; font-size: 28rpx; color: #419D95; background: rgba(65, 157, 149, 0.08); border-radius: 40rpx; flex-shrink: 0; }
+.btn-save { flex: 1; height: 80rpx; display: flex; align-items: center; justify-content: center; font-size: 28rpx; font-weight: 500; color: #fff; background: #419D95; border-radius: 40rpx; &.loading { opacity: 0.7; } }
+
+// === Forms ===
+.form-section-label { font-size: 22rpx; color: #999; padding: 16rpx 0 8rpx; margin-bottom: 4rpx; }
+
+.form-group { margin-bottom: 20rpx; width: 100%; overflow: hidden; box-sizing: border-box; }
+.form-label { font-size: 26rpx; color: #333; margin-bottom: 10rpx; }
+.required { color: #e74c3c; margin-left: 4rpx; }
+.form-row { display: flex; overflow: hidden; }
+.form-input { flex: 1; height: 80rpx; padding: 0 20rpx; margin: 0; background: #f6f7fa; border: 1rpx solid #eee; border-radius: 12rpx; font-size: 28rpx; color: #1a1a1a; box-sizing: border-box; }
+.input-placeholder { color: #bbb; }
+.type-options { display: flex; }
+.type-opt { display: flex; align-items: center; justify-content: center; font-size: 26rpx; color: #888; padding: 10rpx 28rpx; border: 1rpx solid #e0e0e0; border-radius: 8rpx; margin-right: 16rpx; transition: all 0.15s; &.active { border-color: #419D95; background: rgba(65, 157, 149, 0.06); color: #419D95; } }
+.form-switch { display: flex; align-items: center; justify-content: space-between; .form-label { margin-bottom: 0; } }
+.switch { width: 72rpx; height: 40rpx; background: #d0d0d0; border-radius: 24rpx; position: relative; transition: background 0.2s; &.on { background: #419D95; } }
+.switch-dot { width: 32rpx; height: 32rpx; background: #fff; border-radius: 50%; position: absolute; top: 4rpx; left: 4rpx; transition: transform 0.2s; .switch.on & { transform: translateX(32rpx); } }
+</style>

+ 14 - 0
charge-front/src/pages.json

@@ -144,6 +144,13 @@
             "navigationBarTitleText": "我的钱包",
             "enablePullDownRefresh": true
           }
+        },
+        {
+          "path": "invoice-title",
+          "style": {
+            "navigationStyle": "default",
+            "navigationBarTitleText": "发票抬头管理"
+          }
         }
       ]
     },
@@ -166,6 +173,13 @@
             "navigationBarBackgroundColor": "#F6F7FA"
           }
         },
+        {
+          "path": "invoice/invoice-form",
+          "style": {
+            "navigationStyle": "default",
+            "navigationBarTitleText": "填写发票信息"
+          }
+        },
         {
           "path": "activity/activity",
           "style": {

+ 30 - 6
charge-front/src/pages/user/user.vue

@@ -16,7 +16,7 @@
         <view class="main flex-shrink">
           <view
             class="avatar"
-            @click="toPage(2)"
+            @click="toPage(3)"
             :style="{
               'background-image': `url(${user.avatar})`,
             }"
@@ -48,7 +48,10 @@
             "
             @click="toPage(index)"
           >
-            <image :src="'/static/images/user/'+index+'.png'"></image>
+            <view class="menu-icon">
+              <uni-icons v-if="item.iconType" :type="item.iconType" size="26" color="rgba(0,0,0,0.65)"></uni-icons>
+              <image v-else :src="item.icon" mode="aspectFit"></image>
+            </view>
             <view>{{ item.title }}</view>
             <view class="ml-auto">
               <uni-icons
@@ -78,30 +81,42 @@ const menu = ref([
   {
     title: "我的卡包",
     path: "/pages-charge/card/card",
+    icon: "/static/images/user/0.png",
   },
   {
     title: "充电订单",
     path: "/pages-charge/orders/orders",
+    icon: "/static/images/user/1.png",
+  },
+  {
+    title: "发票抬头",
+    path: "/pages-user/invoice-title",
+    iconType: "compose",
   },
   {
     title: "我的收藏",
     path: "/pages-user/collect/collect",
+    icon: "/static/images/user/2.png",
   },
   {
     title: "个人信息",
     path: "/pages-user/profile/profile",
+    icon: "/static/images/user/3.png",
   },
   {
     title: "联系我们",
     path: "",
+    icon: "/static/images/user/4.png",
   },
   {
     title: "常见问题",
     path: "/pages-common/faq/faq",
+    icon: "/static/images/user/5.png",
   },
   {
     title: "电站列表",
     path: "/pages/list/list",
+    icon: "/static/images/user/1.png",
   },
 ]);
 const toPage = (index: number) => {
@@ -111,15 +126,15 @@ const toPage = (index: number) => {
     });
     return;
   }
-  if (index === 4) {
+  const item = menu.value[index];
+  if (!item.path) {
     uni.makePhoneCall({
       phoneNumber: service.value,
     });
     return;
   }
-  const item = menu.value[index];
   uni.navigateTo({
-    url: item.path + (index === 5 ? `?service=${service.value}` : ""),
+    url: item.path + (item.title === "常见问题" ? `?service=${service.value}` : ""),
   });
 };
 
@@ -218,10 +233,19 @@ onShow(() => {
     height: 120rpx;
     border-bottom: 1rpx solid rgba(0, 0, 0, 0.1);
 
-    image {
+    .menu-icon {
       width: 52rpx;
       height: 52rpx;
       margin-right: 20rpx;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      flex-shrink: 0;
+
+      image {
+        width: 52rpx;
+        height: 52rpx;
+      }
     }
 
     &:last-child {

+ 1 - 1
charge-front/src/utils/constant.ts

@@ -13,6 +13,6 @@ isDevelopment = env === "develop" || env === "trial";
 // #endif
 
 export const domain = isDevelopment ? "localhost:8088" : "www.kuaiyuman.cn";
-export const host = `https://${domain}/api`;
+export const host = `http://${domain}/api`;
 
 export const isDebug = false

+ 0 - 5
entity/src/main/java/com/kym/entity/miniapp/InvoiceTitle.java

@@ -30,11 +30,6 @@ public class InvoiceTitle extends BaseEntity {
      */
     private String email;
 
-    /**
-     * 电话
-     */
-    private String phone;
-
     /**
      * 发票类型:INDIVIDUAL-个人 ORGANIZATION-企业
      */

+ 3 - 3
mapper/src/main/resources/mappers/miniapp/InvoiceTitleMapper.xml

@@ -6,19 +6,19 @@
     <resultMap id="BaseResultMap" type="com.kym.entity.miniapp.InvoiceTitle">
         <result column="user_id" property="userId" />
         <result column="email" property="email" />
-        <result column="phone" property="phone" />
+        <result column="telephone" property="telephone" />
         <result column="invoice_type" property="invoiceType" />
         <result column="invoice_title" property="invoiceTitle" />
         <result column="tax_id" property="taxId" />
         <result column="address" property="address" />
         <result column="bank_name" property="bankName" />
         <result column="bank_account" property="bankAccount" />
+        <result column="is_default" property="isDefault" />
         <result column="remark" property="remark" />
     </resultMap>
 
-    <!-- 通用查询结果列 -->
     <sql id="Base_Column_List">
-        id,user_id, email, phone, invoice_type, invoice_title, tax_id, address, bank_name, bank_account, remark,create_time,update_time
+        id,user_id,email,telephone,invoice_type,invoice_title,tax_id,address,bank_name,bank_account,is_default,remark,create_time,update_time
     </sql>
 
 </mapper>

+ 6 - 0
miniapp/src/main/java/com/kym/miniapp/controller/InvoiceTitleController.java

@@ -58,6 +58,12 @@ public class InvoiceTitleController {
         return R.success();
     }
 
+    @PostMapping("/{id}/delete")
+    R<?> deleteByPost(@PathVariable Long id) {
+        invoiceTitleService.removeById(id);
+        return R.success();
+    }
+
     @PutMapping("/{id}/default")
     R<?> setDefault(@PathVariable Long id) {
         var title = invoiceTitleService.getById(id);

+ 1 - 1
service/src/main/java/com/kym/service/miniapp/impl/InvoiceServiceImpl.java

@@ -134,7 +134,7 @@ public class InvoiceServiceImpl extends MPJBaseServiceImpl<InvoiceMapper, Invoic
                 var title = new InvoiceTitle()
                         .setUserId(userId)
                         .setEmail(params.getEmail())
-                        .setPhone(params.getPhone())
+                        .setTelephone(params.getPhone())
                         .setInvoiceType(params.getInvoiceType())
                         .setInvoiceTitle(params.getInvoiceTitle())
                         .setTaxId(params.getTaxId())