Files
medical-mall/pages/mall/consumer/payment.uvue
2026-05-14 15:28:09 +08:00

1115 lines
25 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 支付页面 -->
<template>
<view class="payment-page">
<scroll-view class="payment-content" direction="vertical">
<!-- 支付成功样式头部 -->
<view class="payment-amount-header">
<text class="amount-label">支付金额</text>
<view class="amount-value-row">
<text class="amount-currency">¥</text>
<text class="amount-number">{{ amount.toFixed(2) }}</text>
</view>
<text class="order-no-text">订单号: {{ orderNo }}</text>
</view>
<!-- 支付方式 -->
<view class="methods-section-new">
<view class="section-header">
<text class="section-title">选择支付方式</text>
</view>
<view class="method-list">
<view v-for="method in paymentMethods"
:key="method.id"
class="method-item-modern"
@click="selectMethod(method)">
<view class="method-left">
<image class="method-img" :src="getMethodBrandIcon(method.id)" mode="aspectFit" />
<view class="method-info">
<text class="method-name">{{ method.name }}</text>
<text class="method-desc" v-if="method.id === 'balance'">当前余额: ¥{{ userBalance.toFixed(2) }}</text>
<text class="method-desc" v-else>{{ method.description }}</text>
</view>
</view>
<view class="method-right">
<view :class="['radio-circle', { checked: selectedMethod === method.id }]">
<view class="radio-inner" v-if="selectedMethod === method.id"></view>
</view>
</view>
</view>
</view>
</view>
<!-- 弹窗式密码输入层 -->
<view v-if="showPassword" class="password-popup-mask" @click="closePasswordPopup">
<view class="password-popup-content" @click.stop="">
<view class="popup-header">
<text class="popup-close" @click="closePasswordPopup">✕</text>
<text class="popup-title">请输入支付密码</text>
<view class="popup-placeholder"></view>
</view>
<view class="popup-amount-info">
<text class="popup-amount-label">支付金额</text>
<view class="popup-amount-row">
<text class="popup-currency">¥</text>
<text class="popup-value">{{ amount.toFixed(2) }}</text>
</view>
</view>
<view class="password-input-row">
<view v-for="(_, index) in 6"
:key="index"
class="password-box">
<view v-if="password.length > index" class="password-dot"></view>
</view>
</view>
<text class="forgot-password-link" @click="forgotPassword">忘记密码?</text>
<!-- 这里的密码键盘会被放在页面底部,但我们可以通过 CSS 控制它 -->
<!-- 移动键盘到弹窗内部 -->
<view class="password-keyboard-popup">
<view class="keyboard-grid">
<view v-for="num in 9"
:key="num"
class="keyboard-key"
@click="inputPassword(num.toString())">
<text class="key-text">{{ num }}</text>
</view>
<view class="keyboard-key"></view>
<view class="keyboard-key" @click="inputPassword('0')">
<text class="key-text">0</text>
</view>
<view class="keyboard-key" @click="deletePassword">
<text class="key-text">⌫</text>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- 底部支付按钮 -->
<view class="payment-bottom" v-if="!showPassword">
<view class="price-summary">
<text class="summary-label">需支付:</text>
<text class="summary-price">¥{{ amount.toFixed(2) }}</text>
</view>
<button class="pay-btn"
:class="{ disabled: isPaying || (selectedMethod === 'balance' && userBalance < amount) }"
@click="confirmPayment">
<text v-if="!isPaying" class="pay-text">{{ getPayButtonText() }}</text>
<text v-else class="pay-text">支付中...</text>
</button>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, watch, computed, onUnmounted } from 'vue'
import { supabaseService } from '@/utils/supabaseService.uts'
import { goToLogin } from '@/utils/utils.uts'
type PaymentMethodType = {
id: string
name: string
description: string
icon: string
enabled: boolean
}
const getOptionString = (options: UTSJSONObject, key: string): string => {
const value = options.getString(key)
if (value != null) {
return value
}
return ''
}
const orderId = ref<string>('')
const orderNo = ref<string>('')
const amount = ref<number>(0)
const paymentMethods = ref<Array<PaymentMethodType>>([])
const selectedMethod = ref<string>('wechat')
const userBalance = ref<number>(0)
const isPaying = ref<boolean>(false)
const showPassword = ref<boolean>(false)
const password = ref<string>('')
const ensureLoggedIn = (): boolean => {
const userId = supabaseService.getCurrentUserId()
if (userId == null || userId === '') {
const redirectUrl = orderId.value !== ''
? `/pages/mall/consumer/payment?orderId=${orderId.value}&amount=${amount.value}`
: '/pages/mall/consumer/payment'
goToLogin(redirectUrl)
return false
}
return true
}
// 价格相关变量
const productAmount = ref<number>(0) // 商品总价
const deliveryFee = ref<number>(0) // 运费
const discountAmount = ref<number>(0) // 优惠减免
// 加载支付方式(必须在 onMounted 之前定义)
const loadPaymentMethods = () => {
const methods: PaymentMethodType[] = [
{
id: 'wechat',
name: '微信支付',
description: '推荐安装微信5.0及以上版本使用',
icon: '💳',
enabled: true
},
{
id: 'alipay',
name: '支付宝',
description: '推荐安装支付宝10.0及以上版本使用',
icon: '💳',
enabled: true
},
{
id: 'balance',
name: '余额支付',
description: '使用账户余额支付',
icon: '💰',
enabled: true
},
{
id: 'bankcard',
name: '银行卡支付',
description: '支持储蓄卡、信用卡',
icon: '💳',
enabled: true
}
]
paymentMethods.value = methods
}
// 加载用户余额(必须在 onMounted 之前定义)
const loadUserBalance = async () => {
try {
const balance = await supabaseService.getUserBalanceNumber()
userBalance.value = balance
} catch (err) {
console.error('加载用户余额异常:', err)
userBalance.value = 0
}
}
// 计算价格明细(必须在 onMounted 之前定义)
const calculatePriceDetails = (totalAmount: number) => {
// 模拟计算各项费用
// 假设商品总价占总金额的80%运费占10%优惠减免占10%
productAmount.value = totalAmount * 0.8
deliveryFee.value = totalAmount * 0.1
discountAmount.value = totalAmount * 0.1
// 确保总和等于应付金额
const calculatedTotal = productAmount.value + deliveryFee.value - discountAmount.value
if (Math.abs(calculatedTotal - totalAmount) > 0.01) {
// 调整商品总价以匹配应付金额
productAmount.value = totalAmount + discountAmount.value - deliveryFee.value
}
}
// 更新本地存储中的订单状态(必须在 onMounted 之前定义)
const updateOrderInStorage = (targetOrderId: string, status: number) => {
try {
// 尝试从 'orders' 读取 (checkout页面写入的key)
const ordersStr = uni.getStorageSync('orders')
let orders: UTSJSONObject[] = []
if (ordersStr != null && ordersStr !== '') {
const parsed = JSON.parse(ordersStr as string)
if (Array.isArray(parsed)) {
for (let i = 0; i < parsed.length; i++) {
// 使用 JSON 序列化转换
const itemStr = JSON.stringify(parsed[i])
const itemParsed = JSON.parse(itemStr)
if (itemParsed != null) {
orders.push(itemParsed as UTSJSONObject)
}
}
}
}
let foundIndex = -1
for (let i = 0; i < orders.length; i++) {
const o = orders[i]
if (o['id'] === targetOrderId) {
foundIndex = i
break
}
}
if (foundIndex !== -1) {
orders[foundIndex]['status'] = status
orders[foundIndex]['payment_status'] = status === 2 ? 1 : 0 // 2=待发货(已支付), 1=待支付(未支付)
orders[foundIndex]['updated_at'] = new Date().toISOString()
// 确保更新的是 'orders' key
uni.setStorageSync('orders', JSON.stringify(orders))
console.log('订单状态已更新到Storage (orders):', targetOrderId, status)
} else {
// 本地缓存中没有订单数据是正常的,数据在数据库中
console.log('本地缓存中无订单数据,已忽略:', targetOrderId)
}
} catch (e) {
console.error('更新订单状态失败', e)
}
}
// 取消支付,更新订单状态(必须在 goBack 之前定义)
const cancelPayment = async () => {
try {
// 这里应该调用API更新订单状态为待支付status: 1
// 模拟更新订单状态
// 更新本地存储
updateOrderInStorage(orderId.value, 1) // 1: 待支付
// 发布订单更新事件让profile页面可以刷新数据
uni.$emit('orderUpdated', { orderId: orderId.value, status: 1 })
uni.showToast({
title: '已保存到待支付订单',
icon: 'success'
})
// 延迟返回,让用户看到提示
setTimeout(() => {
uni.navigateBack()
}, 1500)
} catch (err) {
console.error('取消支付异常:', err)
uni.showToast({
title: '操作失败',
icon: 'none'
})
}
}
// 返回(必须在 onBackPress 之前定义)
const goBack = () => {
uni.showModal({
title: '取消支付',
content: '确定要取消支付吗?取消后订单将保存到待支付订单中',
confirmText: '取消支付',
cancelText: '继续支付',
success: (res) => {
if (res.confirm) {
// 用户确认取消支付,更新订单状态为待支付
cancelPayment()
}
}
})
}
// 加载订单信息(必须在 onMounted 之前定义)
const loadOrderInfo = async () => {
try {
if (orderId.value == '') return
const order = await supabaseService.getOrderDetail(orderId.value)
if (order != null) {
// 使用 JSON 序列化转换对象
const orderStr = JSON.stringify(order)
const orderParsed = JSON.parse(orderStr)
if (orderParsed == null) {
console.error('订单数据解析失败')
return
}
const orderObj = orderParsed as UTSJSONObject
const orderNoVal = orderObj.getString('order_no')
if (orderNoVal != null) {
orderNo.value = orderNoVal
}
const totalAmount = orderObj.getNumber('total_amount')
const dbAmount = totalAmount ?? 0
if (dbAmount > 0) {
amount.value = dbAmount
}
const items = orderObj.get('items')
if (items != null && Array.isArray(items) && items.length > 0) {
// Could update product name etc if displayed
}
} else {
// Fallback or error
console.warn('Order not found in DB', orderId.value)
if (orderNo.value == '') orderNo.value = 'ORD_PENDING_' + Date.now()
}
} catch (err) {
console.error('加载订单信息异常:', err)
}
}
// 生命周期
onLoad((options) => {
if (!ensureLoggedIn()) {
return
}
if (options != null) {
const optionsObj = options as UTSJSONObject
const orderIdValue = getOptionString(optionsObj, 'orderId')
if (orderIdValue != '') {
orderId.value = orderIdValue
loadOrderInfo()
}
const amountValue = getOptionString(optionsObj, 'amount')
if (amountValue != '') {
amount.value = parseFloat(amountValue)
}
// 获取传递的价格详情
const productAmountValue = getOptionString(optionsObj, 'productAmount')
if (productAmountValue != '') {
productAmount.value = parseFloat(productAmountValue)
}
const deliveryFeeValue = getOptionString(optionsObj, 'deliveryFee')
if (deliveryFeeValue != '') {
deliveryFee.value = parseFloat(deliveryFeeValue)
}
const discountAmountValue = getOptionString(optionsObj, 'discountAmount')
if (discountAmountValue != '') {
discountAmount.value = parseFloat(discountAmountValue)
}
// 如果没有传详情,尝试根据总价估算(兼容旧逻辑,但优先使用传参)
if (productAmountValue == '' && amount.value > 0) {
calculatePriceDetails(amount.value)
}
loadPaymentMethods()
loadUserBalance()
}
})
onMounted(() => {
// onMounted 中的逻辑已移到 onLoad 中
})
// 监听返回操作(包含系统返回键和导航栏返回按钮)
onBackPress((options) => {
// 如果是通过代码主动调用 navigateBack 返回,则允许
if (options.from === 'navigateBack') {
return false
}
// 否则拦截返回,显示确认弹窗
goBack()
return true
})
// 获取当前用户ID
const getCurrentUserId = (): string => {
const userStore = uni.getStorageSync('userInfo')
if (userStore != null) {
// 使用 JSON 序列化转换
const userStr = JSON.stringify(userStore)
const userParsed = JSON.parse(userStr)
if (userParsed != null) {
const userObj = userParsed as UTSJSONObject
const id = userObj.getString('id')
if (id != null) {
return id
}
}
}
return ''
}
// 获取支付方式图标
const getMethodIcon = (methodId: string): string => {
if (methodId === 'wechat') {
return '💳'
} else if (methodId === 'alipay') {
return '💳'
} else if (methodId === 'balance') {
return '💰'
} else if (methodId === 'bankcard') {
return '💳'
}
return '💳'
}
// 获取支付品牌图片
const getMethodBrandIcon = (methodId: string): string => {
if (methodId === 'wechat') {
return '/static/logo.png' // 替换为真实的微信支付图标路径
} else if (methodId === 'alipay') {
return '/static/logo.png' // 替换为真实的支付宝图标路径
} else if (methodId === 'balance') {
return '/static/logo.png' // 替换为真实的余额支付图标路径
} else if (methodId === 'bankcard') {
return '/static/logo.png' // 替换为真实的银行卡支付图标路径
}
return '/static/logo.png'
}
// 选择支付方式
const selectMethod = (method: PaymentMethodType) => {
if (!method.enabled) {
uni.showToast({
title: '该支付方式暂不可用',
icon: 'none'
})
return
}
selectedMethod.value = method.id
// 切换方式时,除非点击支付,否则不自动弹出密码
showPassword.value = false
password.value = '' // 清空密码
}
// 关闭密码弹窗
const closePasswordPopup = () => {
showPassword.value = false
password.value = ''
}
// 获取支付按钮文本
const getPayButtonText = (): string => {
if (selectedMethod.value === 'balance' && userBalance.value < amount.value) {
return '余额不足'
}
if (selectedMethod.value === 'wechat') {
return '微信支付'
} else if (selectedMethod.value === 'alipay') {
return '支付宝支付'
} else if (selectedMethod.value === 'balance') {
return '余额支付'
} else if (selectedMethod.value === 'bankcard') {
return '银行卡支付'
}
return '确认支付'
}
// 减少商品库存
// const reduceStock = (orderId: string) => {
// Update should happen on server side during payment processing
// }
// 确认支付
const confirmPayment = async () => {
if (isPaying.value) return
if (!ensureLoggedIn()) return
// 余额支付或银行卡支付检查密码
if (selectedMethod.value === 'balance' || selectedMethod.value === 'bankcard') {
if (selectedMethod.value === 'balance' && userBalance.value < amount.value) {
uni.showToast({
title: '余额不足',
icon: 'none'
})
return
}
if (!showPassword.value) {
showPassword.value = true
password.value = ''
return
}
if (password.value.length !== 6) {
uni.showToast({
title: '请输入6位支付密码',
icon: 'none'
})
return
}
}
isPaying.value = true
uni.showLoading({ title: '支付中...' })
try {
console.log('[confirmPayment] 开始支付, orderId:', orderId.value, 'method:', selectedMethod.value)
const success = await supabaseService.payOrder(orderId.value, selectedMethod.value, amount.value)
console.log('[confirmPayment] 支付结果:', success)
if (!success) {
console.error('[confirmPayment] payOrder 返回 false')
uni.hideLoading()
uni.showToast({
title: '支付处理失败',
icon: 'none'
})
isPaying.value = false
return
}
uni.hideLoading()
updateOrderInStorage(orderId.value, 2)
uni.showToast({
title: '支付成功',
icon: 'success',
duration: 2000
})
uni.$emit('orderUpdated', { orderId: orderId.value, status: 2 })
setTimeout(() => {
uni.redirectTo({
url: `/pages/mall/consumer/payment-success?orderId=${orderId.value}`
})
}, 1500)
} catch (err) {
console.error('[confirmPayment] 支付异常:', err)
uni.hideLoading()
uni.showToast({
title: '支付失败',
icon: 'none'
})
isPaying.value = false
}
}
// 获取支付方式代码
const getPaymentMethodCode = (methodId: string): number => {
if (methodId === 'wechat') {
return 1
} else if (methodId === 'alipay') {
return 2
} else if (methodId === 'balance') {
return 3
} else if (methodId === 'bankcard') {
return 4
}
return 0
}
// 验证密码(必须在 watch 之前定义)
const verifyPassword = async () => {
// 这里应该验证支付密码,这里简单模拟
const userId = getCurrentUserId()
try {
// 模拟验证
await new Promise<void>((resolve: (value: void) => void) => {
setTimeout(() => {
resolve()
}, 500)
})
// 假设密码正确
const isCorrect = true
if (isCorrect) {
// 密码正确,继续支付
confirmPayment()
} else {
password.value = ''
uni.showToast({
title: '密码错误',
icon: 'none'
})
}
} catch (err) {
console.error('验证密码异常:', err)
}
}
// 输入密码
const inputPassword = (num: string) => {
if (password.value.length >= 6) return
password.value += num
}
// 删除密码
const deletePassword = () => {
if (password.value.length > 0) {
password.value = password.value.slice(0, -1)
}
}
// 监听密码输入
watch(password, (newPassword: string) => {
if (newPassword.length === 6) {
// 自动验证密码
verifyPassword()
}
})
// 忘记密码
const forgotPassword = () => {
uni.navigateTo({
url: '/pages/user/forgot-password'
})
}
// 在组件卸载时移除返回键监听
onUnmounted(() => {
// uni.offBackPress() 在uni-app中不需要手动移除
})
</script>
<style scoped>
.payment-page {
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
background-color: #f5f5f5;
overflow: hidden;
}
.payment-header {
background-color: #ffffff;
padding: 15px;
display: flex;
align-items: center;
border-bottom: 1px solid #e5e5e5;
}
.back-btn {
font-size: 24px;
color: #333333;
padding: 5px;
margin-right: 15px;
}
.header-title {
font-size: 18px;
font-weight: bold;
color: #333333;
}
.payment-content {
flex: 1;
/* overflow-y: auto; */
background-color: #f8f8f8;
}
.payment-amount-header {
background-color: #ffffff;
padding: 40px 15px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-bottom: 12px;
}
.amount-label {
font-size: 14px;
color: #666;
margin-bottom: 10px;
}
.amount-value-row {
display: flex;
flex-direction: row;
align-items: baseline;
margin-bottom: 10px;
}
.amount-currency {
font-size: 20px;
font-weight: bold;
color: #333;
margin-right: 4px;
}
.amount-number {
font-size: 40px;
font-weight: bold;
color: #333;
}
.order-no-text {
font-size: 13px;
color: #999;
}
.methods-section-new {
background-color: #ffffff;
margin: 0 12px;
border-radius: 12px;
padding: 15px;
}
.section-header {
margin-bottom: 15px;
}
.section-title {
font-size: 16px;
font-weight: bold;
color: #333333;
}
.method-list {
display: flex;
flex-direction: column;
}
.method-item-modern {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 16px 0;
border-bottom: 0.5px solid #f5f5f5;
}
.method-item-modern:last-child {
border-bottom: none;
}
.method-left {
display: flex;
flex-direction: row;
align-items: center;
}
.method-img {
width: 28px;
height: 28px;
margin-right: 12px;
}
.method-info {
display: flex;
flex-direction: column;
}
.method-name {
font-size: 15px;
color: #333;
}
.method-desc {
font-size: 11px;
color: #999;
margin-top: 2px;
}
.radio-circle {
width: 20px;
height: 20px;
border-radius: 10px;
border: 1px solid #ddd;
display: flex;
align-items: center;
justify-content: center;
}
.radio-circle.checked {
border-color: #ff5000;
background-color: #ff5000;
}
.radio-inner {
width: 10px;
height: 10px;
border-radius: 5px;
background-color: #ffffff;
}
.balance-section {
background-color: #ffffff;
padding: 15px;
margin-bottom: 10px;
}
.balance-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.balance-label {
font-size: 14px;
color: #333333;
}
.balance-value {
font-size: 18px;
color: #ff4757;
font-weight: bold;
}
.balance-tip {
padding: 10px;
background-color: #fff0f0;
border-radius: 5px;
}
.tip-text {
font-size: 12px;
color: #ff4757;
}
/* 密码输入弹窗 */
.password-popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
justify-content: flex-end;
z-index: 1000;
}
.password-popup-content {
background-color: #ffffff;
border-radius: 16px 16px 0 0;
padding: 20px 0; /* 减少左右内边距,让键盘撑满 */
display: flex;
flex-direction: column;
align-items: center;
animation: slideUp 0.3s ease-out;
width: 100%;
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.popup-header {
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding: 0 20px;
}
.popup-close {
font-size: 20px;
color: #999;
padding: 4px;
}
.popup-title {
font-size: 16px;
font-weight: bold;
color: #333;
}
.popup-placeholder {
width: 28px;
}
.popup-amount-info {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 30px;
}
.popup-amount-label {
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.popup-amount-row {
display: flex;
flex-direction: row;
align-items: baseline;
}
.popup-currency {
font-size: 18px;
font-weight: bold;
color: #333;
margin-right: 2px;
}
.popup-value {
font-size: 32px;
font-weight: bold;
color: #333;
}
.password-input-row {
display: flex;
flex-direction: row;
justify-content: center;
margin-bottom: 20px;
}
.password-box {
width: 45px;
height: 45px;
border: 1px solid #ddd;
border-right: none;
display: flex;
align-items: center;
justify-content: center;
background-color: #f9f9f9;
}
.password-box:first-child {
border-radius: 4px 0 0 4px;
}
.password-box:last-child {
border-right: 1px solid #ddd;
border-radius: 0 4px 4px 0;
}
.password-dot {
width: 10px;
height: 10px;
border-radius: 5px;
background-color: #000;
}
.forgot-password-link {
font-size: 13px;
color: #576b95;
margin-bottom: 20px;
}
.password-section {
/* 移除旧的样式或保持隐藏 */
display: none;
}
/* 弹窗专用键盘样式 */
.password-keyboard-popup {
width: 100%;
background-color: #f5f5f5;
padding: 6px;
padding-bottom: env(safe-area-inset-bottom);
}
/* 键盘样式优化 */
.password-keyboard {
display: none; /* 隐藏独立键盘 */
}
.keyboard-grid {
display: flex;
flex-wrap: wrap;
background-color: #f5f5f5;
}
.keyboard-key {
width: 33.33%;
background-color: #ffffff;
height: 54px;
display: flex;
align-items: center;
justify-content: center;
border: 3px solid #f5f5f5;
box-sizing: border-box;
border-radius: 8px;
}
.keyboard-key:active {
background-color: #e0e0e0;
}
.key-text {
font-size: 22px;
font-weight: 500;
color: #333333;
}
.payment-bottom {
background-color: #ffffff;
border-top: 1px solid #f0f0f0;
padding: 12px 16px;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
display: flex;
align-items: center;
justify-content: space-between;
}
.price-summary {
display: flex;
flex-direction: row;
align-items: baseline;
}
.summary-label {
font-size: 14px;
color: #333333;
margin-right: 4px;
}
.summary-price {
font-size: 24px;
color: #ff5000;
font-weight: bold;
}
.pay-btn {
background-color: #ff5000;
color: #ffffff;
padding: 0 40px;
height: 44px;
line-height: 44px;
border-radius: 22px;
font-size: 16px;
font-weight: bold;
border: none;
margin: 0;
}
.pay-btn.disabled {
background-color: #cccccc;
opacity: 0.6;
}
.password-keyboard {
background-color: #ffffff;
border-top: 1px solid #e5e5e5;
padding: 10px;
}
.keyboard-grid {
display: flex;
flex-wrap: wrap;
/* grid-template-columns: repeat(3, 1fr); uvue unsupported */
/* grid-gap: 1px; uvue unsupported */
background-color: #e5e5e5;
}
.keyboard-key {
width: 33.33%;
background-color: #ffffff;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #f5f5f5; /* mimic grid gap */
box-sizing: border-box;
}
.key-text {
font-size: 24px;
color: #333333;
}
</style>