Commit 532a824366bb118847256793fa923954f7199782

Authored by tianwu
1 parent bf9336df

收银台页面

apps/web-payment/public/fail.png 0 → 100644

3.96 KB

apps/web-payment/public/favicon.ico
No preview for this file type
apps/web-payment/public/success.png 0 → 100644

1.72 KB

apps/web-payment/src/api/payment.ts
... ... @@ -2,14 +2,25 @@ import { requestClient } from '#/api/request';
2 2  
3 3 export namespace PaymentApi {
4 4 export interface orderPaymentParams {
5   - tradeId: string;
6   - pipelineId: string;
7   - params: { openId: string };
  5 + tradeId: number | string;
  6 + pipelineId: number | string;
  7 + params: {
  8 + accountId?: number | string;
  9 + cardNo?: number | string;
  10 + openId?: number | string;
  11 + password?: number | string;
  12 + };
8 13 }
9 14 }
10 15  
  16 +// 换取openId
  17 +export async function getOpenId(pipelineId: number | string, code: string) {
  18 + return requestClient.post<any>(
  19 + `/wechat/payment/openId.do?pipelineId=${pipelineId}&code=${code}`,
  20 + );
  21 +}
11 22 /**
12   - * 订单
  23 + * 园区卡支付 微信支付共用接口
13 24 * @param data
14 25 * @returns
15 26 */
... ...
apps/web-payment/src/router/routes/core.ts
... ... @@ -39,10 +39,19 @@ const coreRoutes: RouteRecordRaw[] = [
39 39 path: '/payment',
40 40 component: () => import('#/views/payment/index.vue'),
41 41 meta: {
42   - icon: 'lucide:area-chart',
  42 + icon: '',
43 43 title: '订单支付',
44 44 },
45 45 },
  46 + {
  47 + name: 'PaymentSuccess',
  48 + path: '/paymentSuccess',
  49 + component: () => import('#/views/payment/PaySuccess.vue'),
  50 + meta: {
  51 + icon: '',
  52 + title: '支付成功',
  53 + },
  54 + },
46 55 ];
47 56  
48 57 export { coreRoutes, fallbackNotFoundRoute };
... ...
apps/web-payment/src/views/payment/component/PasswordInput.vue 0 → 100644
  1 +<script setup lang="ts">
  2 +import { nextTick, ref, watch } from 'vue';
  3 +
  4 +interface Props {
  5 + modelValue: boolean;
  6 + title?: string;
  7 + length?: number;
  8 +}
  9 +
  10 +interface Emits {
  11 + (e: 'update:modelValue', value: boolean): void;
  12 + (e: 'complete', password: string): void;
  13 + (e: 'cancel'): void;
  14 +}
  15 +
  16 +const props = withDefaults(defineProps<Props>(), {
  17 + title: '请输入支付密码',
  18 + length: 6,
  19 +});
  20 +
  21 +const emit = defineEmits<Emits>();
  22 +
  23 +// 密码输入框数组
  24 +const passwordValues = ref<string[]>(
  25 + Array.from({ length: props.length }).fill(''),
  26 +);
  27 +const inputRefs = ref<HTMLInputElement[]>([]);
  28 +const currentIndex = ref(0);
  29 +
  30 +// 设置输入框引用
  31 +const setInputRef = (el: any, index: number) => {
  32 + if (el) {
  33 + inputRefs.value[index] = el;
  34 + }
  35 +};
  36 +
  37 +// 处理输入
  38 +const handleInput = (index: number, event: Event) => {
  39 + const target = event.target as HTMLInputElement;
  40 + let value = target.value;
  41 +
  42 + // 只允许输入数字
  43 + value = value.replaceAll(/\D/g, '');
  44 +
  45 + // 只保留第一个字符
  46 + if (value.length > 1) {
  47 + value = value.charAt(0);
  48 + }
  49 +
  50 + passwordValues.value[index] = value;
  51 + target.value = value;
  52 +
  53 + // 如果输入了数字,自动聚焦到下一个输入框
  54 + if (value && index < props.length - 1) {
  55 + currentIndex.value = index + 1;
  56 + nextTick(() => {
  57 + inputRefs.value[index + 1]?.focus();
  58 + });
  59 + }
  60 +
  61 + // 检查是否已完成输入
  62 + checkComplete();
  63 +};
  64 +
  65 +// 处理键盘事件
  66 +const handleKeydown = (index: number, event: KeyboardEvent) => {
  67 + // 处理退格键
  68 + if (event.key === 'Backspace') {
  69 + event.preventDefault();
  70 +
  71 + if (passwordValues.value[index]) {
  72 + // 如果当前框有值,清空当前框
  73 + passwordValues.value[index] = '';
  74 + (event.target as HTMLInputElement).value = '';
  75 + } else if (index > 0) {
  76 + // 如果当前框没值,回退到上一个框并清空
  77 + currentIndex.value = index - 1;
  78 + passwordValues.value[index - 1] = '';
  79 + nextTick(() => {
  80 + const prevInput = inputRefs.value[index - 1];
  81 + if (prevInput) {
  82 + prevInput.value = '';
  83 + prevInput.focus();
  84 + }
  85 + });
  86 + }
  87 + }
  88 +
  89 + // 禁用左右箭头键
  90 + if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
  91 + event.preventDefault();
  92 + }
  93 +};
  94 +
  95 +// 处理粘贴事件
  96 +const handlePaste = (index: number, event: ClipboardEvent) => {
  97 + event.preventDefault();
  98 + const pasteData = event.clipboardData?.getData('text') || '';
  99 + const numbers = pasteData.replaceAll(/\D/g, '');
  100 +
  101 + if (numbers) {
  102 + // 从当前位置开始填充
  103 + for (let i = 0; i < numbers.length && index + i < props.length; i++) {
  104 + passwordValues.value[index + i] = numbers[i];
  105 + if (inputRefs.value[index + i]) {
  106 + inputRefs.value[index + i].value = numbers[i];
  107 + }
  108 + }
  109 +
  110 + // 聚焦到最后一个填充的位置或下一个空位
  111 + const nextEmptyIndex = passwordValues.value.findIndex(
  112 + (v, i) => i >= index && !v,
  113 + );
  114 + if (nextEmptyIndex === -1) {
  115 + currentIndex.value = props.length - 1;
  116 + nextTick(() => {
  117 + inputRefs.value[props.length - 1]?.focus();
  118 + });
  119 + } else {
  120 + currentIndex.value = nextEmptyIndex;
  121 + nextTick(() => {
  122 + inputRefs.value[nextEmptyIndex]?.focus();
  123 + });
  124 + }
  125 +
  126 + checkComplete();
  127 + }
  128 +};
  129 +
  130 +// 处理点击输入框 - 禁止点击跳转
  131 +const handleFocus = (index: number, event: FocusEvent) => {
  132 + event.preventDefault();
  133 + // 找到第一个空的输入框位置
  134 + const firstEmptyIndex = passwordValues.value.findIndex((v) => !v);
  135 + const targetIndex =
  136 + firstEmptyIndex === -1 ? props.length - 1 : firstEmptyIndex;
  137 +
  138 + // 如果点击的不是当前应该输入的位置,则重新聚焦到正确位置
  139 + if (index !== targetIndex) {
  140 + nextTick(() => {
  141 + inputRefs.value[targetIndex]?.focus();
  142 + });
  143 + }
  144 + currentIndex.value = targetIndex;
  145 +};
  146 +
  147 +// 检查是否完成输入
  148 +const checkComplete = () => {
  149 + const password = passwordValues.value.join('');
  150 + if (password.length === props.length) {
  151 + // 延迟一点点,让用户看到最后一个数字输入
  152 + setTimeout(() => {
  153 + emit('complete', password);
  154 + }, 100);
  155 + }
  156 +};
  157 +
  158 +// 重置密码
  159 +const reset = () => {
  160 + passwordValues.value = Array.from({ length: props.length }).fill('');
  161 + inputRefs.value.forEach((input) => {
  162 + if (input) input.value = '';
  163 + });
  164 + currentIndex.value = 0;
  165 + nextTick(() => {
  166 + inputRefs.value[0]?.focus();
  167 + });
  168 +};
  169 +
  170 +// 关闭弹窗
  171 +const handleClose = () => {
  172 + emit('update:modelValue', false);
  173 + emit('cancel');
  174 +};
  175 +
  176 +// 点击遮罩关闭
  177 +const handleMaskClick = (event: MouseEvent) => {
  178 + if (event.target === event.currentTarget) {
  179 + handleClose();
  180 + }
  181 +};
  182 +
  183 +// 监听弹窗打开,自动清空并聚焦第一个输入框
  184 +watch(
  185 + () => props.modelValue,
  186 + (newVal) => {
  187 + if (newVal) {
  188 + nextTick(() => {
  189 + reset();
  190 + });
  191 + }
  192 + },
  193 +);
  194 +
  195 +// 暴露方法给父组件
  196 +defineExpose({
  197 + reset,
  198 +});
  199 +</script>
  200 +
  201 +<template>
  202 + <Teleport to="body">
  203 + <Transition name="modal">
  204 + <div
  205 + v-if="modelValue"
  206 + class="password-modal-overlay"
  207 + @click="handleMaskClick"
  208 + >
  209 + <Transition name="slide-up">
  210 + <div v-if="modelValue" class="password-modal-content">
  211 + <!-- 头部 -->
  212 + <div class="modal-header">
  213 + <h3 class="modal-title">{{ title }}</h3>
  214 + <button class="close-btn" @click="handleClose">
  215 + <svg
  216 + width="24"
  217 + height="24"
  218 + viewBox="0 0 24 24"
  219 + fill="none"
  220 + stroke="currentColor"
  221 + stroke-width="2"
  222 + stroke-linecap="round"
  223 + stroke-linejoin="round"
  224 + >
  225 + <line x1="18" y1="6" x2="6" y2="18" />
  226 + <line x1="6" y1="6" x2="18" y2="18" />
  227 + </svg>
  228 + </button>
  229 + </div>
  230 +
  231 + <!-- 主体内容 -->
  232 + <div class="modal-body">
  233 + <div class="password-input-group">
  234 + <input
  235 + v-for="(value, index) in passwordValues"
  236 + :key="index"
  237 + :ref="(el) => setInputRef(el, index)"
  238 + type="tel"
  239 + inputmode="numeric"
  240 + maxlength="1"
  241 + class="password-input"
  242 + :class="{
  243 + active: currentIndex === index,
  244 + filled: passwordValues[index],
  245 + }"
  246 + @input="handleInput(index, $event)"
  247 + @keydown="handleKeydown(index, $event)"
  248 + @paste="handlePaste(index, $event)"
  249 + @focus="handleFocus(index, $event)"
  250 + @mousedown.prevent
  251 + />
  252 + </div>
  253 +
  254 + <div class="password-tips">
  255 + <p>为了您的资金安全,请输入支付密码</p>
  256 + </div>
  257 + </div>
  258 +
  259 + <!-- 底部 -->
  260 + <div class="modal-footer">
  261 + <button class="cancel-btn" @click="handleClose">取消</button>
  262 + </div>
  263 + </div>
  264 + </Transition>
  265 + </div>
  266 + </Transition>
  267 + </Teleport>
  268 +</template>
  269 +
  270 +<style scoped lang="scss">
  271 +// 脉动动画
  272 +@keyframes pulse {
  273 + 0%,
  274 + 100% {
  275 + box-shadow: 0 0 0 3px rgb(234 66 0 / 10%);
  276 + }
  277 +
  278 + 50% {
  279 + box-shadow: 0 0 0 6px rgb(234 66 0 / 5%);
  280 + }
  281 +}
  282 +
  283 +// 移动端优化
  284 +@media (max-width: 767px) {
  285 + .password-input-group {
  286 + gap: 6px;
  287 + }
  288 +
  289 + .password-input {
  290 + max-width: 45px;
  291 + height: 48px;
  292 + font-size: 22px;
  293 + }
  294 +}
  295 +
  296 +.modal-enter-active,
  297 +.modal-leave-active {
  298 + transition: opacity 0.3s ease;
  299 +}
  300 +
  301 +.modal-enter-from,
  302 +.modal-leave-to {
  303 + opacity: 0;
  304 +}
  305 +
  306 +// 内容区域滑入动画
  307 +.slide-up-enter-active {
  308 + transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
  309 +}
  310 +
  311 +.slide-up-leave-active {
  312 + transition: transform 0.25s cubic-bezier(0.4, 0, 1, 1);
  313 +}
  314 +
  315 +.slide-up-enter-from {
  316 + transform: translateY(100%);
  317 +}
  318 +
  319 +.slide-up-leave-to {
  320 + transform: translateY(100%);
  321 +}
  322 +
  323 +// 遮罩层
  324 +.password-modal-overlay {
  325 + position: fixed;
  326 + top: 0;
  327 + left: 0;
  328 + z-index: 9999;
  329 + display: flex;
  330 + align-items: flex-end;
  331 + justify-content: center;
  332 + width: 100%;
  333 + height: 100%;
  334 + background-color: rgb(0 0 0 / 50%);
  335 +}
  336 +
  337 +// 弹窗内容
  338 +.password-modal-content {
  339 + position: relative;
  340 + width: 100%;
  341 + max-width: 100%;
  342 + background: #fff;
  343 + border-radius: 20px 20px 0 0;
  344 + box-shadow: 0 -4px 20px rgb(0 0 0 / 10%);
  345 +}
  346 +
  347 +// 头部
  348 +.modal-header {
  349 + position: relative;
  350 + padding: 20px 20px 10px;
  351 + border-bottom: 1px solid #f0f0f0;
  352 +
  353 + .modal-title {
  354 + margin: 0;
  355 + font-size: 18px;
  356 + font-weight: 600;
  357 + color: #1f2937;
  358 + text-align: center;
  359 + }
  360 +
  361 + .close-btn {
  362 + position: absolute;
  363 + top: 20px;
  364 + right: 20px;
  365 + display: flex;
  366 + align-items: center;
  367 + justify-content: center;
  368 + width: 32px;
  369 + height: 32px;
  370 + padding: 0;
  371 + color: #909399;
  372 + cursor: pointer;
  373 + outline: none;
  374 + background: transparent;
  375 + border: none;
  376 + transition: all 0.3s ease;
  377 +
  378 + &:hover {
  379 + color: #606266;
  380 + background: #f5f5f5;
  381 + border-radius: 50%;
  382 + }
  383 +
  384 + &:active {
  385 + transform: scale(0.95);
  386 + }
  387 +
  388 + svg {
  389 + display: block;
  390 + }
  391 + }
  392 +}
  393 +
  394 +// 主体
  395 +.modal-body {
  396 + display: flex;
  397 + flex-direction: column;
  398 + align-items: center;
  399 + padding: 30px 20px 20px;
  400 +}
  401 +
  402 +.password-input-group {
  403 + display: flex;
  404 + gap: 8px;
  405 + justify-content: center;
  406 + width: 100%;
  407 + max-width: 360px;
  408 + margin-bottom: 24px;
  409 +
  410 + @media (min-width: 768px) {
  411 + gap: 12px;
  412 + max-width: 420px;
  413 + }
  414 +}
  415 +
  416 +.password-input {
  417 + flex: 1;
  418 + max-width: 50px;
  419 + height: 50px;
  420 + font-size: 24px;
  421 + font-weight: 600;
  422 + color: #1f2937;
  423 + text-align: center;
  424 + appearance: none;
  425 + cursor: pointer;
  426 + caret-color: transparent;
  427 + user-select: none;
  428 + outline: none;
  429 + background: #f9fafb;
  430 + border: 2px solid #e5e7eb;
  431 + border-radius: 12px;
  432 + transition: all 0.3s ease;
  433 +
  434 + @media (min-width: 768px) {
  435 + max-width: 60px;
  436 + height: 60px;
  437 + font-size: 28px;
  438 + }
  439 +
  440 + &:focus {
  441 + background: #fff;
  442 + border-color: #ea4200;
  443 + box-shadow: 0 0 0 3px rgb(234 66 0 / 10%);
  444 + }
  445 +
  446 + &.active {
  447 + background: #fff;
  448 + border-color: #ea4200;
  449 + animation: pulse 1.5s ease-in-out infinite;
  450 + }
  451 +
  452 + &.filled {
  453 + // 显示为星号
  454 + -webkit-text-security: disc;
  455 + text-security: disc;
  456 + font-family: text-security-disc;
  457 + background: #fff;
  458 + border-color: #ea4200;
  459 + }
  460 +
  461 + // iOS 样式重置
  462 + &::-webkit-inner-spin-button,
  463 + &::-webkit-outer-spin-button {
  464 + margin: 0;
  465 + appearance: none;
  466 + }
  467 +
  468 + // 禁用选中效果
  469 + &::selection {
  470 + background: transparent;
  471 + }
  472 +}
  473 +
  474 +.password-tips {
  475 + text-align: center;
  476 +
  477 + p {
  478 + margin: 0;
  479 + font-size: 13px;
  480 + line-height: 1.5;
  481 + color: #6b7280;
  482 + }
  483 +}
  484 +
  485 +// 底部
  486 +.modal-footer {
  487 + display: flex;
  488 + justify-content: center;
  489 + width: 100%;
  490 + padding: 0 20px 20px;
  491 + padding-bottom: calc(20px + env(safe-area-inset-bottom));
  492 +
  493 + .cancel-btn {
  494 + width: 100%;
  495 + max-width: 360px;
  496 + height: 44px;
  497 + font-size: 16px;
  498 + font-weight: 500;
  499 + color: #6b7280;
  500 + cursor: pointer;
  501 + outline: none;
  502 + background: #f3f4f6;
  503 + border: none;
  504 + border-radius: 12px;
  505 + transition: all 0.3s ease;
  506 +
  507 + &:hover {
  508 + color: #374151;
  509 + background: #e5e7eb;
  510 + }
  511 +
  512 + &:active {
  513 + transform: scale(0.98);
  514 + }
  515 + }
  516 +}
  517 +</style>
... ...
apps/web-payment/src/views/payment/index.vue
1 1 <script setup lang="ts">
2 2 import { computed, ref } from 'vue';
3   -import { useRoute } from 'vue-router';
  3 +import { useRoute, useRouter } from 'vue-router';
  4 +
  5 +import { fenToYuan } from '@vben/utils';
4 6  
5 7 import {
6 8 CreditCard,
... ... @@ -11,9 +13,10 @@ import {
11 13 import { ElButton, ElDialog, ElIcon, ElMessage, ElRadio } from 'element-plus';
12 14 import qs from 'qs';
13 15  
14   -import { listUserCards, orderInfo, orderPayment } from '#/api';
  16 +import { getOpenId, listUserCards, orderInfo, orderPayment } from '#/api';
15 17 import EnvironmentDetector from '#/composables/environmentDetector';
16 18  
  19 +import PasswordInput from './component/PasswordInput.vue';
17 20 // 类型定义
18 21 interface Card {
19 22 customerId: number | string;
... ... @@ -33,15 +36,20 @@ const detector = new EnvironmentDetector();
33 36 detector.logEnvironment();
34 37  
35 38 const route = useRoute();
  39 +const router = useRouter();
36 40 const loadLoading = ref(false);
  41 +const showPasswordDialog = ref(false);
37 42 const token = ref<string>('');
38 43 const openId = ref<string>('');
  44 +const code = ref<string>('');
39 45 const env = ref();
40 46 env.value = detector.env;
41 47  
  48 +const REDIRECT_CHANNEL_ID = 29;
  49 +
42 50 // 检查参数是否有效
43 51 const hasValidParams = computed(() => {
44   - return !!(token.value && openId.value);
  52 + return !!(token.value && code.value);
45 53 });
46 54  
47 55 const cardList = ref<Card[]>([]);
... ... @@ -55,10 +63,12 @@ const showCardDialog = ref&lt;boolean&gt;(false);
55 63 const selectedCard = ref<Card | null>(null);
56 64 const loading = ref<boolean>(false);
57 65 const paymentSuccess = ref<boolean>(false);
  66 +const payErrorDialog = ref<boolean>(false);
  67 +const errorMessage = ref<string>('支付失败');
58 68  
59 69 // 计算显示金额
60 70 const displayAmount = computed(() => {
61   - return orderInfoData.value?.amount || 0;
  71 + return fenToYuan(orderInfoData.value?.amount || 0);
62 72 });
63 73  
64 74 // 获取支付方式列表
... ... @@ -108,6 +118,20 @@ const getPaymentIcon = (channelName: string) =&gt; {
108 118 }
109 119 };
110 120  
  121 +const jumpTest = () => {
  122 + // jWeixin.miniProgram.redirectTo({
  123 + // url: `/packageA/pages/wePay/index?amount=${displayAmount.value}&businessType=3&redirect=${true}`,
  124 + // });
  125 + router.push({
  126 + path: '/paymentSuccess',
  127 + query: {
  128 + amount: displayAmount.value,
  129 + success: 'true',
  130 + payType: '园区卡支付',
  131 + },
  132 + });
  133 +};
  134 +
111 135 // 获取支付方式描述
112 136 const getPaymentDesc = (pipeline: Pipeline) => {
113 137 if (
... ... @@ -128,6 +152,19 @@ const getPaymentDesc = (pipeline: Pipeline) =&gt; {
128 152 }
129 153 };
130 154  
  155 +const loadOpenId = async () => {
  156 + try {
  157 + if (openId.value) {
  158 + payBtnShow.value = true;
  159 + return;
  160 + }
  161 + const data = await getOpenId(currentPayType.value?.pipelineId, code.value);
  162 + openId.value = data || null;
  163 + // 其他支付方式直接显示支付按钮\
  164 + payBtnShow.value = true;
  165 + } catch {}
  166 +};
  167 +
131 168 // 方法
132 169 const handlepayBtnShowClick = async (pipeline: Pipeline) => {
133 170 currentPaymentMethod.value = pipeline.pipelineId;
... ... @@ -144,8 +181,11 @@ const handlepayBtnShowClick = async (pipeline: Pipeline) =&gt; {
144 181 }
145 182 showCardDialog.value = true;
146 183 } else {
147   - // 其他支付方式直接显示支付按钮
148   - payBtnShow.value = true;
  184 + if (pipeline.channelId === REDIRECT_CHANNEL_ID) {
  185 + payBtnShow.value = true;
  186 + return;
  187 + }
  188 + await loadOpenId();
149 189 }
150 190 };
151 191  
... ... @@ -156,12 +196,73 @@ const handleCardSelect = (card: Card) =&gt; {
156 196 payBtnShow.value = true;
157 197 };
158 198  
  199 +// 处理密码输入取消
  200 +const handlePasswordCancel = () => {
  201 + showPasswordDialog.value = false;
  202 + loading.value = false;
  203 +};
  204 +
  205 +const handlePasswordComplete = async (password: string) => {
  206 + showPasswordDialog.value = false;
  207 + try {
  208 + const params = {
  209 + tradeId: orderInfoData.value.tradeId,
  210 + pipelineId: currentPayType.value.pipelineId,
  211 + params: {
  212 + accountId: selectedCard.value?.accountId,
  213 + cardNo: selectedCard.value?.cardNo,
  214 + password,
  215 + },
  216 + };
  217 +
  218 + const data = await orderPayment(params);
  219 + router.push({
  220 + path: '/paymentSuccess',
  221 + query: {
  222 + amount: displayAmount.value,
  223 + success: 'true',
  224 + payType: '园区卡支付',
  225 + },
  226 + });
  227 + } catch (error) {
  228 + errorMessage.value = error?.message || '支付失败';
  229 + payErrorDialog.value = true;
  230 + console.error(error);
  231 + } finally {
  232 + loading.value = false;
  233 + }
  234 +};
  235 +
159 236 const queryPayment = async () => {
160   - if (!openId.value || !currentPayType.value) {
  237 + if (
  238 + !(currentPayType.value.channelId === REDIRECT_CHANNEL_ID) &&
  239 + !openId.value
  240 + ) {
161 241 ElMessage.error('支付参数不完整');
162 242 return false;
163 243 }
164   -
  244 + // 跳转云商户支付
  245 + if (currentPayType.value.channelId === REDIRECT_CHANNEL_ID) {
  246 + const params = {
  247 + tradeId: orderInfoData.value.tradeId,
  248 + amount: orderInfoData.value.amount,
  249 + pipelineId: currentPayType.value.pipelineId,
  250 + goods: orderInfoData.value.goods,
  251 + payType: currentPayType.value.channelName,
  252 + redirect: true,
  253 + payee: orderInfoData.value.mchName,
  254 + redirectUrl: orderInfoData.value.redirectUrl,
  255 + };
  256 + const queryString = qs.stringify(params);
  257 + if (typeof jWeixin !== 'undefined' && jWeixin.miniProgram) {
  258 + jWeixin.miniProgram.navigateTo({
  259 + url: `/packageA/pages/wxPay/index?${queryString}`,
  260 + });
  261 + loading.value = false;
  262 + }
  263 + return false;
  264 + }
  265 + // redirect: currentPayType.value.channelId === REDIRECT_CHANNEL_ID
165 266 try {
166 267 const params = {
167 268 tradeId: orderInfoData.value.tradeId,
... ... @@ -170,19 +271,27 @@ const queryPayment = async () =&gt; {
170 271 };
171 272  
172 273 const data = await orderPayment(params);
173   - const queryString = qs.stringify(data);
  274 + const pramsData = {
  275 + ...data,
  276 + goods: orderInfoData.value.goods,
  277 + amount: orderInfoData.value.amount,
  278 + payType: currentPayType.value.channelName,
  279 + redirectUrl: orderInfoData.value.redirectUrl,
  280 + payee: orderInfoData.value.mchName,
  281 + };
  282 + const queryString = qs.stringify(pramsData);
174 283  
175 284 // 跳转到微信支付页面
176 285 if (typeof jWeixin !== 'undefined' && jWeixin.miniProgram) {
177   - jWeixin.miniProgram.redirectTo({
178   - url: `/packageA/pages/wePay/index?${queryString}`,
  286 + jWeixin.miniProgram.navigateTo({
  287 + url: `/packageA/pages/wxPay/index?${queryString}`,
179 288 });
  289 + loading.value = false;
180 290 } else {
181 291 console.warn('非微信小程序环境');
182 292 // 模拟支付成功
183 293 setTimeout(() => {
184 294 loading.value = false;
185   - paymentSuccess.value = true;
186 295 setTimeout(() => {
187 296 paymentSuccess.value = false;
188 297 resetPaymentState();
... ... @@ -216,28 +325,8 @@ const handlePay = async () =&gt; {
216 325 if (isCardPayment) {
217 326 // 园区卡支付逻辑
218 327 try {
219   - const params = {
220   - tradeId: orderInfoData.value.tradeId,
221   - pipelineId: currentPayType.value.pipelineId,
222   - params: {
223   - openId: openId.value,
224   - cardNo: selectedCard.value?.cardNo,
225   - },
226   - };
227   -
228   - const data = await orderPayment(params);
229   - loading.value = false;
230   - paymentSuccess.value = true;
231   -
232   - setTimeout(() => {
233   - paymentSuccess.value = false;
234   - resetPaymentState();
235   - }, 2000);
236   - } catch (error) {
237   - console.error('园区卡支付失败:', error);
238   - ElMessage.error('支付失败,请重试');
239   - loading.value = false;
240   - }
  328 + showPasswordDialog.value = true;
  329 + } catch {}
241 330 } else {
242 331 // 其他支付方式(微信、支付宝等)
243 332 await queryPayment();
... ... @@ -292,16 +381,17 @@ const getListUserCards = async () =&gt; {
292 381 const init = async () => {
293 382 token.value = (route.query?.token as string) || '';
294 383 openId.value = (route.query?.openId as string) || '';
295   -
296   - if (!hasValidParams.value) {
  384 + code.value = (route.query?.code as string) || '';
  385 + if (!token.value) {
297 386 return;
298 387 }
299   -
300 388 try {
301 389 loadLoading.value = true;
302 390 await getOrderInfo();
303   - } catch {
304   - ElMessage.error('加载订单信息失败');
  391 + } catch (error) {
  392 + // ElMessage.error(error?.message || '获取订单信息失败');
  393 + errorMessage.value = error?.message || '获取订单信息失败';
  394 + payErrorDialog.value = true;
305 395 } finally {
306 396 loadLoading.value = false;
307 397 }
... ... @@ -442,7 +532,6 @@ init();
442 532 </div>
443 533 </div>
444 534 </div>
445   -
446 535 <!-- 底部支付按钮 -->
447 536 <div class="payment-footer">
448 537 <ElButton
... ... @@ -480,9 +569,7 @@ init();
480 569 </div>
481 570 <div class="card-balance">
482 571 <p class="balance-label">余额</p>
483   - <p class="balance-value">
484   - ¥{{ (Number(card.amount) / 100).toFixed(2) }}
485   - </p>
  572 + <p class="balance-value">¥{{ card.amount }}</p>
486 573 </div>
487 574 </div>
488 575 <div v-if="cardList.length === 0" class="empty-card">
... ... @@ -494,6 +581,39 @@ init();
494 581 </template>
495 582 </ElDialog>
496 583  
  584 + <ElDialog
  585 + v-model="payErrorDialog"
  586 + title=""
  587 + width="90%"
  588 + :style="{ maxWidth: '500px' }"
  589 + class="card-dialog"
  590 + :close-on-click-modal="true"
  591 + >
  592 + <div class="color-[#333] items-center">
  593 + <img src="/fail.png" class="m-auto block h-[64px] w-[64px]" />
  594 + <div class="py-[20px] text-center text-[20px] font-bold">支付失败</div>
  595 + <div class="w-full overflow-y-auto text-left text-[18px] leading-6">
  596 + {{ errorMessage }}
  597 + </div>
  598 + <div class="mt-10">
  599 + <ElButton
  600 + plain
  601 + @click="payErrorDialog = false"
  602 + class="confirm-button"
  603 + >
  604 + 确 认
  605 + </ElButton>
  606 + </div>
  607 + </div>
  608 + </ElDialog>
  609 +
  610 + <!-- 支付密码输入弹窗 -->
  611 + <PasswordInput
  612 + v-model="showPasswordDialog"
  613 + @complete="handlePasswordComplete"
  614 + @cancel="handlePasswordCancel"
  615 + />
  616 +
497 617 <!-- 支付成功对话框 -->
498 618 <ElDialog
499 619 v-model="paymentSuccess"
... ... @@ -994,4 +1114,29 @@ init();
994 1114 color: #6b7280;
995 1115 }
996 1116 }
  1117 +
  1118 +.confirm-button {
  1119 + width: 100%;
  1120 + height: 42px;
  1121 + font-size: 18px;
  1122 + font-weight: 600;
  1123 + color: #ea4200;
  1124 + background: #fff;
  1125 + border: 1px solid #ea4200;
  1126 + border-radius: 21px;
  1127 +
  1128 + &:hover:not(:disabled) {
  1129 + background: #f7f7f7;
  1130 + box-shadow: 0 6px 20px rgb(234 66 0 / 40%);
  1131 + }
  1132 +
  1133 + &:active:not(:disabled) {
  1134 + transform: scale(0.98);
  1135 + }
  1136 +
  1137 + &:disabled {
  1138 + cursor: not-allowed;
  1139 + opacity: 0.5;
  1140 + }
  1141 +}
997 1142 </style>
... ...
apps/web-payment/src/views/payment/payFailed.vue 0 → 100644
apps/web-payment/src/views/payment/paySuccess.vue 0 → 100644
  1 +<script setup lang="ts">
  2 +import { onMounted, ref } from 'vue';
  3 +import { useRoute } from 'vue-router';
  4 +
  5 +import { ElButton } from 'element-plus';
  6 +
  7 +import EnvironmentDetector from '#/composables/environmentDetector';
  8 +
  9 +const route = useRoute();
  10 +const loadLoading = ref(false);
  11 +
  12 +const queryData = ref<any>({});
  13 +const detector = ref();
  14 +const init = async () => {
  15 + queryData.value = route.query || {};
  16 +};
  17 +
  18 +const handleBack = () => {
  19 + if (
  20 + typeof jWeixin !== 'undefined' &&
  21 + jWeixin.miniProgram &&
  22 + detector.value?.env.isMiniProgram
  23 + ) {
  24 + jWeixin.miniProgram.switchTab({
  25 + url: `/pages/newhome/newhome`,
  26 + });
  27 + }
  28 +};
  29 +
  30 +onMounted(() => {
  31 + init();
  32 + detector.value = new EnvironmentDetector();
  33 +});
  34 +</script>
  35 +
  36 +<template>
  37 + <div class="cashier-container" v-loading="loadLoading">
  38 + {{ detector?.env }}
  39 + <!-- 正常支付界面 -->
  40 + <div class="cashier-wrapper">
  41 + <div class="pt-[40px]">
  42 + <div class="flex flex-col items-center justify-center">
  43 + <img src="/success.png" class="w-[64px]" />
  44 + <p class="color-[#49250B] mt-2 text-[18px] font-bold">支付成功</p>
  45 + </div>
  46 + <div class="color-[#333] flex flex-col gap-4 p-[40px]">
  47 + <div class="flex justify-between">
  48 + <div>支付方式</div>
  49 + <div>{{ queryData?.payType }}</div>
  50 + </div>
  51 + <div class="flex justify-between">
  52 + <div>支付金额</div>
  53 + <div>{{ queryData?.amount }} 元</div>
  54 + </div>
  55 + <div class="mt-10 flex justify-between">
  56 + <ElButton
  57 + class="pay-button"
  58 + plain
  59 + type="primary"
  60 + size="large"
  61 + @click="handleBack"
  62 + >
  63 + 完成
  64 + </ElButton>
  65 + </div>
  66 + </div>
  67 + </div>
  68 + </div>
  69 + </div>
  70 +</template>
  71 +
  72 +<style scoped lang="scss">
  73 +.cashier-container {
  74 + min-height: 100vh;
  75 + background: linear-gradient(
  76 + to bottom,
  77 + #fff9f1 0%,
  78 + #ffe9d4 20%,
  79 + #ffe3c8 29%,
  80 + #fff 45%,
  81 + #fff 100%
  82 + );
  83 +}
  84 +
  85 +.cashier-wrapper {
  86 + display: flex;
  87 + flex-direction: column;
  88 + max-width: 500px;
  89 + min-height: 100vh;
  90 + margin: 0 auto;
  91 +}
  92 +
  93 +.pay-button {
  94 + width: 100%;
  95 + height: 42px;
  96 + font-size: 18px;
  97 + font-weight: 600;
  98 + color: #ea4200;
  99 + background: #fff;
  100 + border: 1px solid #ea4200;
  101 + border-radius: 21px;
  102 +
  103 + &:hover:not(:disabled) {
  104 + background: #f7f7f7;
  105 + box-shadow: 0 6px 20px rgb(234 66 0 / 40%);
  106 + }
  107 +
  108 + &:active:not(:disabled) {
  109 + transform: scale(0.98);
  110 + }
  111 +
  112 + &:disabled {
  113 + cursor: not-allowed;
  114 + opacity: 0.5;
  115 + }
  116 +}
  117 +</style>
... ...
apps/web-payment/vite.config.mts
... ... @@ -17,7 +17,7 @@ export default defineConfig(async () =&gt; {
17 17 changeOrigin: true,
18 18 rewrite: (path) => path.replace(/^\/api/, ''),
19 19 // mock代理目标地址
20   - target: 'http://10.28.3.24:8686',
  20 + target: 'http://10.28.3.34:8686',
21 21 ws: true,
22 22 },
23 23 },
... ...
packages/@core/base/shared/src/utils/util.ts
... ... @@ -42,3 +42,36 @@ export function getNestedValue&lt;T&gt;(obj: T, path: string): any {
42 42  
43 43 return current;
44 44 }
  45 +
  46 +/**
  47 + * 分转元 + 千分位格式化(TS 严格模式安全)
  48 + * @param amount 分
  49 + * @param decimals 小数位,默认 2
  50 + */
  51 +export function fenToYuan(
  52 + amount: bigint | number | string,
  53 + decimals: number = 2,
  54 +): string {
  55 + if (amount === null || amount === undefined || amount === '') {
  56 + return (0).toFixed(decimals);
  57 + }
  58 +
  59 + const num = Number(amount);
  60 + if (Number.isNaN(num)) {
  61 + return (0).toFixed(decimals);
  62 + }
  63 +
  64 + const isNegative = num < 0;
  65 + const yuan = Math.abs(num) / 100;
  66 +
  67 + const fixed = yuan.toFixed(decimals);
  68 +
  69 + // 👇 关键修复点
  70 + const parts = fixed.split('.');
  71 + const integer = parts[0] ?? '0';
  72 + const decimal = parts[1];
  73 +
  74 + const thousand = integer.replaceAll(/\B(?=(\d{3})+(?!\d))/g, ',');
  75 +
  76 + return `${isNegative ? '-' : ''}${thousand}${decimal ? `.${decimal}` : ''}`;
  77 +}
... ...
packages/effects/request/src/request-client/preset-interceptors.ts
... ... @@ -9,7 +9,7 @@ import axios from &#39;axios&#39;;
9 9 export const defaultResponseInterceptor = ({
10 10 codeField = 'code',
11 11 dataField = 'data',
12   - successCode = 0,
  12 + successCode = 200,
13 13 }: {
14 14 /** 响应数据中代表访问结果的字段名 */
15 15 codeField: string;
... ... @@ -131,6 +131,7 @@ export const errorMessageResponseInterceptor = (
131 131 }
132 132  
133 133 let errorMessage = '';
  134 + debugger;
134 135 const status = error?.response?.status;
135 136  
136 137 switch (status) {
... ... @@ -158,7 +159,7 @@ export const errorMessageResponseInterceptor = (
158 159 errorMessage = $t('ui.fallback.http.internalServerError');
159 160 }
160 161 }
161   - makeErrorMessage?.(errorMessage, error);
  162 + // makeErrorMessage?.(errorMessage, error);
162 163 return Promise.reject(error);
163 164 },
164 165 };
... ...