Procházet zdrojové kódy

完成发票页和详情页面

yuanmingze před 3 měsíci
rodič
revize
65fed72dec

+ 5 - 0
components.d.ts

@@ -16,6 +16,11 @@ declare module 'vue' {
     VanButton: typeof import('vant/es')['Button']
     VanCellGroup: typeof import('vant/es')['CellGroup']
     VanField: typeof import('vant/es')['Field']
+    VanIcon: typeof import('vant/es')['Icon']
     VanNavBar: typeof import('vant/es')['NavBar']
+    VanSkeleton: typeof import('vant/es')['Skeleton']
+    VanSkeletonParagraph: typeof import('vant/es')['SkeletonParagraph']
+    VanStep: typeof import('vant/es')['Step']
+    VanSteps: typeof import('vant/es')['Steps']
   }
 }

+ 7 - 4
index.html

@@ -1,9 +1,12 @@
-<!DOCTYPE html>
+<!doctype html>
 <html lang="">
   <head>
-    <meta charset="UTF-8">
-    <link rel="icon" href="/favicon.ico">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <meta charset="UTF-8" />
+    <link rel="icon" href="/favicon.ico" />
+    <meta
+      name="viewport"
+      content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no"
+    />
     <title>Vite App</title>
   </head>
   <body>

+ 12 - 12
postcss.config.js

@@ -4,18 +4,18 @@ import pxToViewport from 'postcss-px-to-viewport-8-plugin'
 export default {
   plugins: [
     pxToViewport({
-      unitToConvert: 'px', // 要转换的单位
-      viewportWidth: 750, // 设计稿宽度(通常为 750)
-      unitPrecision: 6, // 转换后保留的小数位数
-      propList: ['*'], // 需要转换的属性(全部)
-      viewportUnit: 'vw', // 转换成的视窗单位
-      fontViewportUnit: 'vw', // 字体也使用 vw
-      selectorBlackList: ['ignore', 'van-'], // 忽略的选择器,匹配类名
-      minPixelValue: 1, // 小于或等于1px的不转换
-      mediaQuery: false, // 是否转换 media queries 中的 px
-      replace: true, // 是否直接替换属性值,而不是追加
-      exclude: [/node_modules/], // 忽略文件夹(例如 node_modules)
-      landscape: false, // 是否处理横屏
+      unitToConvert: 'px',
+      viewportWidth: 375, // ✅ 这里建议改成 375,更贴近主流 H5 设计稿
+      unitPrecision: 6,
+      propList: ['*'],
+      viewportUnit: 'vw',
+      fontViewportUnit: 'vw',
+      selectorBlackList: ['ignore', /^van-/], // ✅ 用正则更稳妥
+      minPixelValue: 1,
+      mediaQuery: false,
+      replace: true,
+      exclude: [/node_modules/],
+      landscape: false,
     }),
   ],
 }

+ 7 - 3
src/router/index.ts

@@ -1,6 +1,7 @@
 // src/router/index.ts
 import { createRouter, createWebHistory } from 'vue-router'
 import { routes } from './routes'
+import { useUserStoreWithOut } from '@/stores/modules/user'
 
 // 创建 Router 实例
 const router = createRouter({
@@ -10,11 +11,14 @@ const router = createRouter({
 
 // 路由守卫
 router.beforeEach((to, from, next) => {
-  const token = localStorage.getItem('token') // 判断登录状态
+  const userStore = useUserStoreWithOut()
+  const token = userStore.access_token
 
   if (to.meta.requiresAuth && !token) {
-    // 没登录跳登录页
-    next({ path: '/login', query: { redirect: to.fullPath } })
+    // 未登录时跳转登录页
+    next({
+      path: '/login',
+    })
   } else {
     next()
   }

+ 12 - 0
src/router/routes.ts

@@ -18,6 +18,18 @@ export const routes: RouteRecordRaw[] = [
     component: () => import('@/views/agreement/index.vue'),
     meta: { title: '协议', requiresAuth: false },
   },
+  {
+    path: '/invoice-information',
+    name: 'InvoiceInformation',
+    component: () => import('@/views/invoice-information/index.vue'),
+    meta: { title: '自然人开票', requiresAuth: true },
+  },
+  {
+    path: '/invoice-information/detail',
+    name: 'InvoiceInformationDetail',
+    component: () => import('@/views/invoice-information/detail.vue'),
+    meta: { title: '应缴税信息', requiresAuth: true },
+  },
 
   {
     path: '/:pathMatch(.*)*',

+ 16 - 0
src/services/modules/invoiceInformation/index.ts

@@ -0,0 +1,16 @@
+import http from '../../index'
+import type { GetConfirmInvoiceInfoRequest, InvoiceTaxDetailResponse } from './type.d'
+
+export const getConfirmInvoiceInfoApi = (data: GetConfirmInvoiceInfoRequest) => {
+  return http.get({
+    url: '/admin/invoice-order/get-confirm-invoice-info',
+    params: data,
+  })
+}
+
+export const getInvoiceTaxApi = (data: GetConfirmInvoiceInfoRequest) => {
+  return http.get<InvoiceTaxDetailResponse>({
+    url: '/admin/invoice-order/get-invoice-tax',
+    params: data,
+  })
+}

+ 47 - 0
src/services/modules/invoiceInformation/type.d.ts

@@ -0,0 +1,47 @@
+/**
+ * 通用接口返回结构
+ */
+export interface ApiResponse<T> {
+  code?: string | number
+  msg?: string
+  data?: T
+}
+
+/**
+ * 获取税费信息请求参数
+ */
+export interface GetConfirmInvoiceInfoRequest {
+  invoiceOrderId: string
+}
+
+/**
+ * 税费详情返回结构
+ */
+export interface InvoiceTaxDetailResponse {
+  detailItems?: InvoiceTaxDetailItem[]
+  totalAmount?: string
+  totalAmountDisplay?: string
+  [property: string]: any
+}
+
+/**
+ * 单项税费明细条目
+ */
+export interface InvoiceTaxDetailItem {
+  amount?: string
+  categoryCode?: string
+  categoryName?: string
+  endDate?: string
+  originAmount?: string
+  punishAmount?: string
+  startDate?: string
+  taxCode?: string
+  taxName?: string
+  validPeriod?: string
+  [property: string]: any
+}
+
+/**
+ * 最终接口返回包装
+ */
+export type InvoiceTaxDetailApiResponse = ApiResponse<InvoiceTaxDetailResponse>

+ 6 - 0
src/services/request/index.ts

@@ -196,6 +196,12 @@ const onResponse = (response: AxiosResponse<ServiceResponse>) => {
     return res
   }
 
+  const url = response.config.url || ''
+  // 针对登录接口单独处理错误提示
+  if (url.includes('/auth/login/etssms')) {
+    return res
+  }
+
   if (code === 1 || !SUCCESS_CODE_WHITE_LIST.includes(code)) {
     const message = msg || '业务逻辑错误'
     if (customConfig.silent !== true) {

+ 9 - 21
src/stores/modules/user.ts

@@ -1,44 +1,32 @@
 import { defineStore } from 'pinia'
 import { store } from '@/stores'
 
-interface MenuItem {
-  path: string
-  name: string
-  parentId: string
-  meta: {
-    menuName?: string
-    isKeepAlive?: boolean
-    iframe?: boolean
-  }
-  children?: MenuItem[]
-}
-
 interface UserState {
   access_token: string
   tenant_id: string
-  menu: MenuItem[]
 }
 
 export const useUserStore = defineStore('user', {
   state: (): UserState => ({
     access_token: '',
     tenant_id: '1',
-    menu: [],
   }),
+
   actions: {
-    login(token: string) {
+    setAccessToken(token: string) {
       this.access_token = token
     },
     LogOut() {
       this.access_token = ''
-      this.menu = []
-    },
-    async GetMenu() {
-      return new Promise<MenuItem[]>((resolve) => {
-        setTimeout(() => resolve(this.menu), 200)
-      })
     },
   },
+
+  // ✅ 兼容 Pinia v3 + Persistedstate v4.5 正确写法
+  persist: {
+    key: 'user-store',
+    storage: localStorage,
+    pick: ['access_token', 'tenant_id'],
+  },
 })
 
 export function useUserStoreWithOut() {

+ 0 - 0
src/styles/index.scss


+ 0 - 1
src/views/agreement/index.vue

@@ -2,7 +2,6 @@
   <div class="agreement">
     <van-nav-bar fixed :title="title" left-arrow @click-left="onClickLeft" placeholder />
     <div class="agreementContent">
-      <!-- ✅ 改这里:绑定 currentComponent -->
       <component :is="currentComponent" />
     </div>
   </div>

+ 272 - 0
src/views/invoice-information/detail.vue

@@ -0,0 +1,272 @@
+<template>
+  <div class="tax-page">
+    <!-- 顶部导航 -->
+    <van-nav-bar title="应缴税信息" fixed placeholder left-arrow @click-left="onBack" />
+
+    <div class="page-content">
+      <!-- 骨架屏 -->
+      <div v-if="loading" class="skeleton">
+        <div v-for="n in 4" :key="n" class="skeleton-card shimmer"></div>
+      </div>
+
+      <!-- 有数据 -->
+      <div v-else-if="taxList.length" class="card">
+        <h3 class="card-title">应纳税费信息</h3>
+
+        <div v-for="(item, index) in taxList" :key="index" class="tax-item">
+          <div class="tax-header" @click="toggle(index)">
+            <div class="tax-name">{{ item.taxName || '未知税种' }}</div>
+            <div class="tax-amount">
+              {{ item.amount || '0.00' }}
+              <van-icon
+                :name="item.open ? 'arrow-up' : 'arrow-down'"
+                class="arrow"
+                :style="{ transform: item.open ? 'rotate(180deg)' : 'rotate(0deg)' }"
+              />
+            </div>
+          </div>
+
+          <!-- 展开详情 -->
+          <transition name="expand">
+            <div v-show="item.open" class="tax-detail">
+              <div class="tax-row">
+                <span class="label">应纳税额</span>
+                <span class="value">{{ item.originAmount || '-' }}</span>
+              </div>
+              <div class="tax-row">
+                <span class="label">减免税额</span>
+                <span class="value">{{ item.deductionAmount || '-' }}</span>
+              </div>
+            </div>
+          </transition>
+        </div>
+      </div>
+
+      <!-- 无数据 -->
+      <div v-else class="empty">
+        暂无税费数据
+        <van-button
+          size="small"
+          type="primary"
+          plain
+          style="margin-top: 3vw"
+          @click="getInvoiceTax"
+        >
+          重新加载
+        </van-button>
+      </div>
+    </div>
+
+    <!-- 底部栏 -->
+    <footer class="footer" v-if="!loading">
+      <span>应补(退)税额</span>
+      <span class="amount">¥ {{ totalAmount }}</span>
+    </footer>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted } from 'vue'
+import { showToast } from 'vant'
+import { getInvoiceTaxApi } from '@/services/modules/invoiceInformation'
+import type {
+  GetConfirmInvoiceInfoRequest,
+  InvoiceTaxDetailApiResponse,
+  InvoiceTaxDetailItem,
+} from '@/services/modules/invoiceInformation/type.d.ts'
+
+const loading = ref(true)
+const taxList = ref<(InvoiceTaxDetailItem & { open: boolean })[]>([])
+const totalAmount = ref('0.00')
+
+const params = reactive<GetConfirmInvoiceInfoRequest>({
+  invoiceOrderId: '12345',
+})
+
+const toggle = (index: number) => {
+  const item = taxList.value[index]
+  if (!item) return
+  item.open = !item.open
+}
+
+const onBack = () => history.back()
+
+const getInvoiceTax = async () => {
+  loading.value = true
+  try {
+    const res: InvoiceTaxDetailApiResponse = await getInvoiceTaxApi(params)
+    if ((res.code === 0 || res.code === '0') && res.data) {
+      const detailItems: InvoiceTaxDetailItem[] = res.data.detailItems ?? []
+      if (detailItems.length > 0) {
+        taxList.value = detailItems.map((item: InvoiceTaxDetailItem) => ({
+          ...item,
+          open: true, // 默认展开
+        }))
+        totalAmount.value = res.data.totalAmount || '0.00'
+      } else {
+        taxList.value = []
+        showToast('未查询到税费明细')
+      }
+    } else {
+      taxList.value = []
+      showToast(res?.msg || '获取税费信息失败')
+    }
+  } catch (err) {
+    console.error('请求失败', err)
+    showToast('请求失败,请检查网络或稍后再试')
+    taxList.value = []
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(() => {
+  getInvoiceTax()
+})
+</script>
+
+<style scoped>
+.tax-page {
+  min-height: 100vh;
+  background: #f6f7fb;
+  font-size: 3.6vw;
+  color: #333;
+  display: flex;
+  flex-direction: column;
+}
+
+.van-nav-bar {
+  background-color: #fff;
+  border-bottom: 1px solid #f2f2f2;
+  font-weight: 600;
+}
+/* 修改左侧返回图标颜色 */
+::v-deep(.van-icon-arrow-left) {
+  color: #ff8036; /* 自定义颜色 */
+  font-size: 20px; /* 调整图标大小 */
+}
+
+.page-content {
+  padding: 4vw;
+  flex: 1;
+}
+
+/* 骨架屏 */
+.skeleton {
+  display: flex;
+  flex-direction: column;
+  gap: 3vw;
+}
+.skeleton-card {
+  height: 22vw;
+  background: linear-gradient(90deg, #eee 25%, #f8f8f8 50%, #eee 75%);
+  background-size: 200% 100%;
+  border-radius: 3vw;
+}
+.shimmer {
+  animation: shimmer 1.5s infinite linear;
+}
+@keyframes shimmer {
+  0% {
+    background-position: -200% 0;
+  }
+  100% {
+    background-position: 200% 0;
+  }
+}
+
+/* 卡片 */
+.card {
+  background: #fff;
+  border-radius: 3vw;
+  padding: 4vw;
+  box-shadow: 0 1vw 3vw rgba(0, 0, 0, 0.05);
+}
+.card-title {
+  font-weight: 600;
+  font-size: 4.2vw;
+  margin-bottom: 3vw;
+  border-left: 1vw solid #ff6f00;
+  padding-left: 2vw;
+}
+
+/* 税项 */
+.tax-item + .tax-item {
+  border-top: 0.3vw solid #f0f0f0;
+}
+.tax-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 3vw 0;
+  font-size: 4vw;
+  color: #222;
+  cursor: pointer;
+}
+.tax-amount {
+  display: flex;
+  align-items: center;
+  gap: 1vw;
+  font-weight: 600;
+}
+.arrow {
+  font-size: 3.2vw;
+  color: #999;
+  transition: transform 0.3s ease;
+}
+
+/* 展开详情 */
+.tax-detail {
+  overflow: hidden;
+}
+.tax-row {
+  display: flex;
+  justify-content: space-between;
+  color: #999;
+  font-size: 3.4vw;
+  padding: 1.5vw 0;
+  padding-left: 2vw;
+}
+
+/* 动画 */
+.expand-enter-active,
+.expand-leave-active {
+  transition:
+    max-height 0.35s ease,
+    opacity 0.3s ease;
+}
+.expand-enter-from,
+.expand-leave-to {
+  max-height: 0;
+  opacity: 0;
+}
+.expand-enter-to,
+.expand-leave-from {
+  max-height: 200vw;
+  opacity: 1;
+}
+
+/* 底部栏 */
+.footer {
+  height: 14vw;
+  background: #fff;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 5vw;
+  font-size: 4vw;
+  border-top: 0.3vw solid #eee;
+}
+.footer .amount {
+  color: #e94e1b;
+  font-weight: 600;
+}
+
+/* 空数据 */
+.empty {
+  text-align: center;
+  color: #aaa;
+  padding: 20vw 0;
+  font-size: 3.8vw;
+}
+</style>

+ 313 - 0
src/views/invoice-information/index.vue

@@ -0,0 +1,313 @@
+<template>
+  <div class="invoice-page">
+    <!-- 顶部导航 -->
+    <van-nav-bar title="自然人开票" fixed placeholder />
+
+    <div class="page-content">
+      <!-- 骨架屏:加载中显示 -->
+      <template v-if="loading">
+        <div class="card" v-for="i in 4" :key="i">
+          <div class="card-header">
+            <div class="dot-skeleton"></div>
+            <div class="title-skeleton"></div>
+          </div>
+
+          <div class="card-body">
+            <div class="info-skeleton" v-for="j in 3" :key="j">
+              <div class="label-skeleton"></div>
+              <div class="value-skeleton"></div>
+            </div>
+          </div>
+        </div>
+      </template>
+
+      <!-- 加载完成后显示真实内容 -->
+      <template v-else>
+        <!-- 卡片:申请人信息 -->
+        <div class="card">
+          <div class="card-header">
+            <span class="dot"></span>
+            <span class="title">申请人信息</span>
+          </div>
+          <div class="card-body">
+            <div class="info-item">
+              <span class="label">姓名</span>
+              <span class="value">{{ invoiceInfo?.sellerName }}</span>
+            </div>
+            <div class="info-item">
+              <span class="label">身份证号</span>
+              <span class="value">{{ invoiceInfo?.sellerId }}</span>
+            </div>
+          </div>
+        </div>
+
+        <!-- 发票抬头 -->
+        <div class="card">
+          <div class="card-header">
+            <span class="dot"></span>
+            <span class="title">发票抬头</span>
+          </div>
+          <div class="card-body">
+            <div class="info-item">
+              <span class="label">公司名称</span>
+              <span class="value">{{ invoiceInfo?.buyerName }}</span>
+            </div>
+            <div class="info-item">
+              <span class="label">统一信用代码</span>
+              <span class="value">{{ invoiceInfo?.buyerId }}</span>
+            </div>
+          </div>
+        </div>
+
+        <!-- 发票信息 -->
+        <div class="card">
+          <div class="card-header">
+            <span class="dot"></span>
+            <span class="title">发票信息</span>
+          </div>
+          <div class="card-body">
+            <div class="info-item">
+              <span class="label">应税发生地</span>
+              <span class="value">{{
+                invoiceInfo?.province + ' ' + invoiceInfo?.city + ' ' + invoiceInfo?.district
+              }}</span>
+            </div>
+            <div class="info-item">
+              <span class="label">发票类别</span>
+              <span class="value">{{ invoiceInfo?.category }}</span>
+            </div>
+            <div class="info-item">
+              <span class="label">税前金额</span>
+              <span class="value highlight">505.00</span>
+            </div>
+          </div>
+        </div>
+
+        <!-- 缴税信息 -->
+        <div class="card">
+          <div class="card-header">
+            <span class="dot"></span>
+            <span class="title">缴税信息</span>
+          </div>
+          <div class="card-body">
+            <div class="info-item">
+              <span class="label">应缴税信息</span>
+              <span class="value link" @click="toDetail">查看详情 ></span>
+            </div>
+          </div>
+        </div>
+      </template>
+    </div>
+
+    <!-- 底部操作按钮 -->
+    <div class="bottom-bar">
+      <van-button
+        type="primary"
+        block
+        round
+        color="linear-gradient(90deg, #ff7a00, #ffa94d)"
+        class="next-btn"
+      >
+        下一步
+      </van-button>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useRouter } from 'vue-router'
+import { getConfirmInvoiceInfoApi } from '@/services/modules/invoiceInformation'
+import type { GetConfirmInvoiceInfoRequest } from '@/services/modules/invoiceInformation/type.d.ts'
+import { reactive, onMounted, ref } from 'vue'
+
+const router = useRouter()
+const loading = ref(true)
+const invoiceInfo = ref<any>({})
+
+const params = reactive<GetConfirmInvoiceInfoRequest>({
+  invoiceOrderId: '12345',
+})
+
+const getConfirmInvoiceInfo = async () => {
+  try {
+    loading.value = true
+    const res = await getConfirmInvoiceInfoApi(params)
+    if (res.code === 0) {
+      invoiceInfo.value = res.data
+    }
+  } catch (err) {
+    console.error('获取发票信息失败', err)
+  } finally {
+    loading.value = false
+  }
+}
+
+const toDetail = () => {
+  router.push({
+    path: '/invoice-information/detail',
+  })
+}
+onMounted(() => {
+  getConfirmInvoiceInfo()
+})
+</script>
+
+<style scoped lang="scss">
+.invoice-page {
+  background: #f5f6f8;
+  min-height: 100dvh;
+  font-family:
+    -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'PingFang SC', 'Microsoft YaHei',
+    sans-serif;
+  font-size: 3.8vw;
+
+  .page-content {
+    padding: 4vw 3.5vw;
+    padding-bottom: 15vw;
+
+    /* ---------- 卡片通用样式 ---------- */
+    .card {
+      background: #fff;
+      border-radius: 14px;
+      margin-bottom: 5vw;
+      padding: 4vw 3.5vw;
+      box-shadow:
+        0 4px 12px rgba(0, 0, 0, 0.06),
+        0 1px 3px rgba(0, 0, 0, 0.04);
+      transition: all 0.3s ease;
+
+      /* ---------- 骨架屏 Header ---------- */
+      .card-header {
+        display: flex;
+        align-items: center;
+        margin-bottom: 3vw;
+
+        .dot {
+          width: 6px;
+          height: 28px;
+          background: #ff7a00;
+          border-radius: 4px;
+          margin-right: 2vw;
+        }
+
+        .dot-skeleton {
+          width: 6px;
+          height: 28px;
+          border-radius: 4px;
+          margin-right: 2vw;
+          background: linear-gradient(90deg, #f2f2f2 25%, #e4e4e4 50%, #f2f2f2 75%);
+          background-size: 200% 100%;
+          animation: skeleton-loading 1.2s ease-in-out infinite;
+        }
+
+        .title {
+          font-size: 4vw;
+          font-weight: 600;
+          color: #333;
+        }
+
+        .title-skeleton {
+          flex: 1;
+          height: 22px;
+          border-radius: 6px;
+          background: linear-gradient(90deg, #f2f2f2 25%, #e4e4e4 50%, #f2f2f2 75%);
+          background-size: 200% 100%;
+          animation: skeleton-loading 1.2s ease-in-out infinite;
+        }
+      }
+
+      /* ---------- 骨架屏 Body ---------- */
+      .card-body {
+        display: flex;
+        flex-direction: column;
+        gap: 3vw; // 🟢 关键间距:解决挤在一起的问题
+
+        .info-skeleton {
+          display: flex;
+          justify-content: space-between;
+          align-items: center;
+          gap: 4vw;
+
+          .label-skeleton {
+            flex: 0 0 30%;
+            height: 18px;
+            border-radius: 6px;
+            background: linear-gradient(90deg, #f2f2f2 25%, #e4e4e4 50%, #f2f2f2 75%);
+            background-size: 200% 100%;
+            animation: skeleton-loading 1.2s ease-in-out infinite;
+          }
+
+          .value-skeleton {
+            flex: 1;
+            height: 18px;
+            border-radius: 6px;
+            background: linear-gradient(90deg, #f2f2f2 25%, #e4e4e4 50%, #f2f2f2 75%);
+            background-size: 200% 100%;
+            animation: skeleton-loading 1.2s ease-in-out infinite;
+          }
+        }
+
+        /* 真实内容样式 */
+        .info-item {
+          display: flex;
+          justify-content: space-between;
+          align-items: flex-start;
+          font-size: 3.8vw;
+          color: #555;
+          line-height: 1.6;
+
+          .label {
+            flex: 0 0 28vw;
+            color: #888;
+          }
+
+          .value {
+            flex: 1;
+            text-align: right;
+            color: #222;
+            word-break: break-all;
+          }
+
+          .highlight {
+            color: #ff7a00;
+            font-weight: 600;
+          }
+
+          .link {
+            color: #ff7a00;
+            cursor: pointer;
+            font-weight: 500;
+          }
+        }
+      }
+    }
+  }
+
+  /* ---------- 底部按钮 ---------- */
+  .bottom-bar {
+    position: fixed;
+    bottom: 4vw;
+    left: 0;
+    right: 0;
+    width: 100%;
+
+    .next-btn {
+      font-size: 4.2vw;
+      width: 90%;
+      margin: 0 auto;
+      height: 12vw;
+      box-shadow: 0 4px 10px rgba(255, 128, 54, 0.25);
+    }
+  }
+
+  /* ---------- 骨架屏动画 ---------- */
+  @keyframes skeleton-loading {
+    0% {
+      background-position: 200% 0;
+    }
+    100% {
+      background-position: -200% 0;
+    }
+  }
+}
+</style>

+ 99 - 21
src/views/login/index.vue

@@ -1,30 +1,43 @@
 <template>
   <div class="login">
     <van-nav-bar title="自然人开票" />
+
     <div class="login-content">
-      <div class="text">xxx你好,欢迎您参加医疗峰会项目,请登录系统开局发票</div>
+      <div class="text">xxx你好,欢迎您参加医疗峰会项目,请登录系统开具发票</div>
+
       <div class="bold-text">已注册电子税务局</div>
+
       <div class="form">
+        <!-- 手机号输入 -->
         <van-cell-group inset class="grop">
           <van-field
             class="input"
             v-model="formData.mobile"
-            placeholder="请输入本人注册手机号"
+            placeholder="请输入手机号"
             clearable
+            type="tel"
+            maxlength="30"
+            @update:model-value="onInputMobile"
           />
         </van-cell-group>
+
+        <!-- 验证码输入 + 按钮 -->
         <van-cell-group inset class="grop">
           <van-field
             class="input code-input"
-            v-model="formData.mobile"
+            v-model="formData.code"
             placeholder="请输入验证码"
             clearable
-          >
-          </van-field>
-          <button class="code-btn">
-            {{ codeBtnText }}
+            type="tel"
+            maxlength="6"
+            @update:model-value="onInputCode"
+          />
+          <button class="code-btn" :disabled="isCounting || !formData.mobile" @click="sendCode">
+            {{ isCounting ? countDown + 's后重发' : '获取验证码' }}
           </button>
         </van-cell-group>
+
+        <!-- 同意协议 -->
         <div class="agree">
           <label>
             <input type="checkbox" v-model="agree" />
@@ -33,43 +46,110 @@
             <a @click="toAgreement('privacy')">《隐私权政策》</a>
           </label>
         </div>
-        <div class="login-btn btn">登录</div>
+
+        <!-- 登录按钮 -->
+        <div class="login-btn btn" @click="login">登录</div>
       </div>
+
       <div class="bold-text">未注册电子税务局</div>
       <div class="register-btn btn">立即注册</div>
+
       <div class="footer">技术支持:票易云(北京)科技有限公司</div>
     </div>
   </div>
 </template>
 
 <script setup lang="ts">
-import { loginEtssmsApi } from '@/services/modules/login'
-import { onMounted, ref, reactive } from 'vue'
+import { ref, reactive, onMounted } from 'vue'
 import { useRouter } from 'vue-router'
+import { showFailToast, showToast } from 'vant'
+import { loginEtssmsApi } from '@/services/modules/login'
+import type { LoginEtssmsRequest } from '@/services/modules/login/type.d'
+import { useUserStore } from '@/stores/modules/user'
+
+const userStore = useUserStore()
 
 const router = useRouter()
 
-const formData = reactive({
+/* 表单数据 */
+const formData = reactive<LoginEtssmsRequest>({
   mobile: '13800138001',
   code: '5657',
 })
 
-const codeBtnText = ref('获取验证码')
+/* 限制手机号只能输入数字,最多 30 位 */
+const onInputMobile = (val: string) => {
+  formData.mobile = val.replace(/\D/g, '').slice(0, 30)
+}
+
+/* 限制验证码只能输入数字,最多 6 位 */
+const onInputCode = (val: string) => {
+  formData.code = val.replace(/\D/g, '').slice(0, 6)
+}
+
+/* 是否同意协议 */
 const agree = ref(false)
 
-onMounted(() => {
-  // login()
-})
+/* 验证码按钮逻辑 */
+const isCounting = ref(false)
+const countDown = ref(60)
+let timer: number | undefined
+
+/* 发送验证码 */
+const sendCode = () => {
+  if (!/^1\d{10}$/.test(formData.mobile)) {
+    showToast('请输入正确的手机号')
+    return
+  }
+
+  showToast('验证码已发送')
+  startCountDown()
+}
+
+/* 倒计时逻辑 */
+const startCountDown = () => {
+  isCounting.value = true
+  countDown.value = 60
+  timer = window.setInterval(() => {
+    countDown.value--
+    if (countDown.value <= 0) {
+      isCounting.value = false
+      clearInterval(timer)
+    }
+  }, 1000)
+}
+
+/* 登录逻辑 */
 const login = async () => {
-  const res = await loginEtssmsApi(formData)
+  if (!formData.mobile || !formData.code) {
+    showFailToast('请输入手机号和验证码')
+    return
+  }
+
+  if (!agree.value) {
+    showFailToast('请勾选阅读并同意')
+    return
+  }
+
+  const res: any = await loginEtssmsApi(formData)
+  if (res?.access_token) {
+    userStore.setAccessToken(res.access_token)
+    router.replace({ path: '/invoice-information' })
+  }
+  console.log('登录结果:', res)
 }
 
+/* 协议跳转 */
 const toAgreement = (type: string) => {
   router.push({
     path: '/agreement',
     query: { type },
   })
 }
+
+onMounted(() => {
+  // 可在此自动聚焦手机号输入或初始化逻辑
+})
 </script>
 
 <style lang="scss" scoped>
@@ -91,9 +171,7 @@ const toAgreement = (type: string) => {
     padding: 6vw 6vw 12vw;
     display: flex;
     flex-direction: column;
-    justify-content: flex-start;
     align-items: center;
-    position: relative;
 
     .text {
       width: 90%;
@@ -115,12 +193,12 @@ const toAgreement = (type: string) => {
     }
 
     .form {
-      width: 100%;
+      width: 96%;
+      margin-top: 20px;
       background: #fff;
       border-radius: 3vw;
       box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
       padding: 6vw 4vw;
-      box-sizing: border-box;
       margin-bottom: 8vw;
 
       .grop {
@@ -138,7 +216,6 @@ const toAgreement = (type: string) => {
         font-size: 3.8vw;
         background: #fff;
         transition: 0.3s;
-        height: 10vw;
         &:focus {
           border-color: #fe783d;
           box-shadow: 0 0 8px rgba(254, 120, 61, 0.3);
@@ -175,6 +252,7 @@ const toAgreement = (type: string) => {
         line-height: 5vw;
         margin: 4vw 0;
         color: #666;
+        padding-left: 50px;
         label {
           display: flex;
           align-items: center;

+ 1 - 1
tsconfig.app.json

@@ -4,7 +4,7 @@
   "exclude": ["src/**/__tests__/*"],
   "compilerOptions": {
     "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
-
+    "types": ["pinia-plugin-persistedstate"],
     "paths": {
       "@/*": ["./src/*"]
     }