feat: initialize merchant payment H5

This commit is contained in:
dzc committed 2026-10-20 13:45:05 +08:00
commit 8327e335df
44 files changed
+17171

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
# 后端请求前缀,开发环境通过 Vite 同源代理。
VITE_API_URL=/api
# 是否为微服务;false 对接 Boot 单体服务,移除接口的 /third 前缀。
VITE_IS_MICRO=true
# 开发环境后端地址。
VITE_APP_DEV_SERVICE=https://dzc-test-1.fy.takin.cc
# 生产环境后端地址;配置为空时由线上部署提供同源接口。
VITE_APP_PROD_SERVICE=https://www.zianxin.com/api
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
unpackage/
.DS_Store
*.log
*.local
.idea/
.vscode/
+1
View File
@@ -0,0 +1 @@
shamefully-hoist=true
+8
View File
@@ -0,0 +1,8 @@
{
"tabWidth": 2,
"useTabs": true,
"trailingComma": "all",
"semi": false,
"singleQuote": false,
"printWidth": 140
}
+77
View File
@@ -0,0 +1,77 @@
# 商户付款 H5
uni-app Vue 3 + TypeScript + UnoCSS + tm-ui 的独立 H5 项目。工程与蓝色主题参考 `../biz_zax_app_cloud`,只注册一个付款页面,默认中文、人民币、浅色主题。
## 本地开发
要求 Node.js 20.19+、pnpm 10.13.0。
```bash
pnpm install --frozen-lockfile
pnpm dev:h5
```
浏览器访问 <http://localhost:8890/>。同一局域网内可使用本机 IP 和 8890 端口进行手机预览。
```bash
pnpm type-check
pnpm build:h5
```
`type-check` 检查业务代码与工程配置。tm-ui 采用按需引入的原始源码,保留第三方类型与样式;不对上游组件做单页定制。按团队规则,生产构建和完整测试在明确的集中验证指令后执行。
## 目录与调整入口
```text
src/
├── pages/pay/index.vue # 唯一页面:商户、金额与付款交互
├── sheep/
│ ├── components/payment-keyboard.vue # 四列常驻键盘
│ ├── config/merchant.ts # 仅供设计参考的示例数据,真实页面不引用
│ ├── config/theme.ts # UnoCSS、CSS 与 tm-ui 共享主题
│ ├── hooks/use-payment-amount.ts # 金额字符串输入规则
│ ├── scss/global.scss # 全局字体与 H5 视口适配
│ └── stores/index.ts # tm-ui 主题使用的 Pinia
└── uni_modules/tm-ui/ # 参考项目同版 3.2.0 组件及必要依赖
```
- 商户名称与标识通过匿名商户接口读取;`src/sheep/config/merchant.ts` 中的示例仅保留作设计参考,真实页面不引用。标识缺省或加载失败时展示统一店铺图形。
- 修改主题:编辑 `src/sheep/config/theme.ts`。页面基础色在 `pages.json`、`uni.scss` 和 HTML 的 theme-color 中保留一致的框架配置。
- 页面采用 Hash 路由,唯一页面为 `/pages/pay/index`。部署子路径统一在 `src/manifest.json` 的 `h5.router.base` 中修改,默认 `/`,避免在 Vite 中重复设置路径前缀。
- 接入 third 服务的匿名商户查询、线下收款、OAuth 回跳和真实支付。无签名入口或短期收银票据时展示错误,不回退到示例商户。
## 金额与 H5 交互
- 使用字符串保存元单位金额,保留 `0.` 和 `1.20` 等输入过程,不进行浮点金额运算;支持两位小数、前导零处理和逐位删除。
- 付款键对空金额、零金额和超过 9,999.99 元的金额禁用;提交前保存稳定请求号,刷新或网络失败后复用原请求。
- 金额框为可聚焦的只读展示,避免系统软键盘和常驻键盘重叠。物理键盘支持数字、小数点、退格和 Enter;Tab、组合快捷键及数字按钮自身的键盘激活不被拦截。
- 内容最大宽度 480px,rpx 同步限制放大;使用 `100dvh` 并提供 `100vh` 回退,键盘位于独立的底部区域。页面直接展示商户和金额,无顶部标题。
- 整页锁定在可视高度,常见竖屏不产生纵向滚动;短屏压缩卡片间距,极短横屏保留内容区内部滑动。所有内部滚动条均隐藏,长金额仍可横向查看。
- 支持安全区、长商户名称、长金额横向滚动、可见焦点与减少动态效果偏好;不禁止浏览器缩放。
## 第三方来源
tm-ui `3.2.0` 从参考项目的 `src/uni_modules/tm-ui` 原样引入 `tm-sheet`、`tm-icon` 及其主题、工具、类型、CSS 和图标资源,保留上游说明与版本信息。数字键盘使用 uni-app 自带按钮,维持明确的禁用状态和 H5 键盘语义。
依赖基于参考项目的 pnpm 锁文件解析版本;只保留本项目需要的 H5 依赖,不包含 App 原生配置、服务地址或应用凭据。
## 接口与部署
项目只使用根目录一个 `.env`,变量命名与参考 App 一致:`VITE_API_URL` 为开发请求前缀,`VITE_APP_DEV_SERVICE` 为开发后端地址,`VITE_APP_PROD_SERVICE` 为生产后端地址,`VITE_IS_MICRO` 控制是否保留微服务前缀。修改后重启 `pnpm dev:h5`。这里不配置登录 Token、签名密钥或支付密钥。
开发模式下,浏览器请求 `VITE_API_URL`(默认 `/api`),由 `vite.config.ts` 转发到 `VITE_APP_DEV_SERVICE`,避免跨域。例如开发地址为 `https://dzc-test-1.fy.takin.cc` 且 `VITE_IS_MICRO=true` 时,`http://localhost:8890/api/third/aggregate-merchant-pay/merchant` 转发到 `https://dzc-test-1.fy.takin.cc/third/aggregate-merchant-pay/merchant`。目标地址自带 `/api` 等基础路径时会保留,不要在请求层再次添加。
`VITE_IS_MICRO=true` 保留 `/third`;`false` 对接单体服务时移除 `/third`,代理规则共用。生产构建直接读取同一 `.env` 的 `VITE_APP_PROD_SERVICE`;若线上反向代理已提供同源接口,可将其设为空。Vite 开发代理不随生产构建发布。
- 商户码入口:`/#/pages/pay/index?id=...&tenantId=...&v=1&sign=...`,商户和租户 ID 按字符串处理。
- OAuth 回跳:`/#/pages/pay/index?tenantCode=...&ticket=...`,页面通过接口读取真实场景、固定金额和渠道参数。
- `GET /third/aggregate-merchant-pay/merchant` 读取公开商户资料及 `supportedPlatforms`(10 微信、20 支付宝);商户可以只开通其中一个渠道。
- `POST /third/aggregate-merchant-pay/create` 提交整数分金额及稳定 `requestId`;不在前端指定业务类型或渠道商户。
- `GET /third/aggregate-order-pay/context/{tenantCode}/{ticket}` 读取收银上下文;现有 `/status/{tenantCode}/{ticket}` 确认结果。
- 后端把本 H5 来源加入精确 CORS 允许列表;跨域接口必须使用 HTTPS。OAuth 回调仍由后端接收,平台密钥和 OAuth 身份不进入前端。
线下收款在提交前保留数字键盘;订单聚合及已提交的收款金额锁定。OAuth 回跳只自动调起一次,取消后可手动继续,刷新不会自动重复弹出。JSBridge 返回成功后仍查询服务端状态;未确认原单成功或关闭之前不释放金额创建新请求。
扫码客户端未在商户已开通渠道中时,在原单页展示“商户暂不支持微信/支付宝支付”及换用已开通 App 重新扫码的指引,隐藏金额与键盘。普通浏览器提示使用已支持 App 扫码;全部渠道停用时提示联系商户。可重新检查开通情况,不在当前客户端强制唤起另一个 App,也不会据此清除待确认的原付款请求。
完整后端配置、迁移、平台域名设置及发布顺序见 `../biz-api-cloud-zax/pjcloud-thirdparty/docs/aggregate-h5.md`。更换二维码使用的域名或签名密钥后需重新生成商户 URL 收款码,原 App Base64 收款码保持兼容。
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#ecf4f9" />
<meta name="color-scheme" content="light" />
<meta name="referrer" content="no-referrer" />
<title>商户收款</title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
{
"name": "biz-pay-h5",
"version": "0.1.0",
"private": true,
"description": "基于 uni-app、UnoCSS 与 tm-ui 的商户付款 H5",
"packageManager": "pnpm@10.13.0",
"engines": {
"node": ">=20.19.0"
},
"scripts": {
"dev:h5": "uni",
"build:h5": "uni build",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-5000720260410001",
"@dcloudio/uni-components": "3.0.0-5000720260410001",
"@dcloudio/uni-h5": "3.0.0-5000720260410001",
"pinia": "3.0.4",
"vue": "3.5.26"
},
"devDependencies": {
"@dcloudio/types": "3.4.28",
"@dcloudio/uni-cli-shared": "3.0.0-5000720260410001",
"@dcloudio/vite-plugin-uni": "3.0.0-5000720260410001",
"@types/node": "20.19.30",
"sass": "1.71.1",
"typescript": "4.9.5",
"unocss": "0.58.9",
"unocss-preset-weapp": "0.58.9",
"vite": "5.2.8",
"vue-tsc": "1.8.27"
}
}
+5563
View File
File diff suppressed because it is too large. Load diff
+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts">
/** 单页付款工程的应用根节点;页面与生命周期由 uni-app 管理 */
defineOptions({ name: "MerchantPaymentApp" })
</script>
<style lang="scss">
@import url("@/uni_modules/tm-ui/css/remixicon.min.css");
@import url("@/uni_modules/tm-ui/css/tmui.css");
@import "@/sheep/scss/global.scss";
</style>
+12
View File
@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
/// <reference types="@dcloudio/types" />
// tm-ui 的全局 TM 类型来自随库保留的原始声明,不在业务侧重复定义
import "./uni_modules/tm-ui/interface"
declare module "*.vue" {
import type { DefineComponent } from "vue"
/** uni-app 页面及 Easycom 组件的模块声明,具体模板类型由 vue-tsc 推导 */
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, any>
export default component
}
+22
View File
@@ -0,0 +1,22 @@
import { createSSRApp } from "vue"
import "uno.css"
import App from "./App.vue"
import tmUi from "./uni_modules/tm-ui"
import { colors } from "./uni_modules/tm-ui/libs/colors"
import { useTmConfig } from "./uni_modules/tm-ui/libs/config"
import pinia from "@@/stores"
import { themeColors } from "@@/config/theme"
/** 创建 uni-app 应用,并在页面挂载前将 tm-ui 和 UnoCSS 对齐到参考项目的主题蓝 */
export function createApp() {
/** 当前应用实例;每次框架创建应用时独立注册插件 */
const app = createSSRApp(App)
app.use(pinia)
app.use(tmUi)
colors.set("primary", themeColors.primary)
/** tm-ui 的主题状态依赖 Pinia,必须在注册 Pinia 后访问 */
const themeStore = useTmConfig(pinia)
themeStore.config.mode = "light"
themeStore.config.color = themeColors.primary
return { app, pinia }
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "商户付款",
"appid": "",
"description": "商户信息展示与付款金额输入 H5",
"versionName": "0.1.0",
"versionCode": "100",
"transformPx": false,
"vueVersion": "3",
"h5": {
"title": "商户收款",
"router": {
"mode": "hash",
"base": "/"
}
},
"uniStatistics": { "enable": false }
}
+23
View File
@@ -0,0 +1,23 @@
{
"easycom": {
"autoscan": true,
"custom": {
"^tm-(.*)": "@/uni_modules/tm-ui/components/tm-$1/tm-$1.vue"
}
},
"pages": [
{
"path": "pages/pay/index",
"style": {
"navigationBarTitleText": "商户收款",
"disableScroll": true
}
}
],
"globalStyle": {
"navigationStyle": "custom",
"backgroundColor": "#ecf4f9",
"rpxCalcMaxDeviceWidth": 480,
"rpxCalcBaseDeviceWidth": 480
}
}
+206
View File
@@ -0,0 +1,206 @@
<template>
<view class="payment-shell flex flex-col w-full max-w-480px mx-auto overflow-hidden" @keydown="handleKeyboard">
<view class="payment-content flex-1 min-h-0 overflow-y-auto">
<view class="px-32rpx pt-24rpx pb-24rpx">
<tm-sheet :margin="[0]" :padding="[32]" :round="32" color="white" class="amount-card">
<view class="flex items-center gap-24rpx min-w-0">
<view class="merchant-logo flex-row-center size-96rpx shrink-0 rounded-28rpx bg-soft overflow-hidden" aria-hidden="true">
<image v-if="merchant?.logoPic && !merchantLogoFailed" :src="merchant.logoPic" class="size-full" mode="aspectFill" @error="handleLogoError" />
<tm-icon v-else name="store-2-line" :size="52" :color="themeColors.primary" />
</view>
<view class="flex-1 min-w-0">
<text class="block text-24rpx text-muted mb-8rpx">收款商户</text>
<text class="merchant-name block text-36rpx font-600 text-text">{{ merchant?.name || (loading ? '正在加载商户' : '收款信息不可用') }}</text>
</view>
</view>
<template v-if="merchant && !unavailable">
<view class="card-divider h-1px bg-border my-40rpx" aria-hidden="true" />
<text class="block text-28rpx font-500 text-secondary">付款金额</text>
<view id="payment-amount" class="amount-field flex items-baseline gap-12rpx mt-24rpx pb-24rpx"
role="textbox" :tabindex="editable ? 0 : -1" aria-readonly="true"
:aria-label="`付款金额,${amount || '0'} 元${editable ? ',使用下方数字键盘输入' : ',金额已锁定'}`">
<text class="text-52rpx font-500 text-text shrink-0" aria-hidden="true">¥</text>
<view id="payment-amount-scroll" class="amount-scroll flex-1 min-w-0 overflow-x-auto" aria-hidden="true">
<view class="amount-digits inline-flex items-baseline" :class="amountSizeClass">
<text class="font-600" :class="amount ? 'text-text' : 'text-placeholder'">{{ amount || '0.00' }}</text>
<view v-if="editable" class="payment-cursor bg-primary ml-8rpx shrink-0" />
</view>
</view>
</view>
<text class="amount-hint block text-24rpx text-muted mt-24rpx">{{ editable ? '请与商户确认付款金额,单笔最高 9,999.99 元' : success ? '付款结果已由服务端确认' : '金额已确认,请勿重复付款' }}</text>
</template>
<template v-if="unavailable">
<view class="card-divider h-1px bg-border my-32rpx" aria-hidden="true" />
<payment-unavailable :notice="unavailable" />
</template>
<view v-if="error" class="mt-32rpx text-28rpx text-secondary leading-relaxed" role="alert">{{ error }}</view>
<view v-else-if="feedback || loading" class="mt-32rpx text-28rpx leading-relaxed" :class="success ? 'text-primary' : 'text-secondary'" role="status" aria-live="polite">{{ feedback || '正在读取收款信息…' }}</view>
</tm-sheet>
</view>
</view>
<payment-keyboard v-if="showKeyboard && !error && !loading" class="shrink-0" :disabled="!canPay" @input="append" @delete="remove" @pay="pay" />
<view v-else class="payment-actions shrink-0 bg-surface px-32rpx pt-24rpx">
<button v-if="unavailable" class="payment-action w-full rounded-24rpx" :class="loading || busy ? 'bg-disabled text-secondary' : 'bg-primary text-white'" :disabled="loading || busy" :aria-disabled="loading || busy" role="button" :tabindex="loading || busy ? -1 : 0" @click="retry" @keydown.enter.prevent="retry" @keydown.space.prevent="retry">{{ loading ? '正在检查' : '重新检查支付方式' }}</button>
<button v-else-if="error" class="payment-action w-full bg-primary text-white rounded-24rpx" role="button" tabindex="0" @click="retry" @keydown.enter.prevent="retry" @keydown.space.prevent="retry">重新查询</button>
<template v-else>
<button class="payment-action w-full rounded-24rpx" :class="canPay ? 'bg-primary text-white' : 'bg-disabled text-secondary'" :disabled="!canPay" :aria-disabled="!canPay" role="button" :tabindex="canPay ? 0 : -1" @click="pay" @keydown.enter.prevent="pay" @keydown.space.prevent="pay">
{{ success ? '付款成功' : loading ? '加载中' : busy ? '正在发起付款' : confirming ? '付款结果确认中' : terminal ? '本次收款已结束' : '继续付款' }}
</button>
<button v-if="canRestart" class="payment-action mt-16rpx w-full bg-surface text-primary rounded-24rpx" role="button" tabindex="0" @click="restart" @keydown.enter.prevent="restart" @keydown.space.prevent="restart">{{ success ? '再付一笔' : '重新输入金额' }}</button>
</template>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue"
import PaymentKeyboard from "@@/components/payment-keyboard.vue"
import PaymentUnavailable from "@@/components/payment-unavailable.vue"
import { useMerchantPayment } from "@@/hooks/use-merchant-payment"
import { themeColors } from "@@/config/theme"
/** 页面与数字键盘共享真实收银状态,任何付款入口都经过同一个请求和金额守卫。 */
const { merchant, amount, loading, busy, error, feedback, editable, canPay, showKeyboard, canRestart, confirming, success, terminal, unavailable,
pay, restart, retry, append, remove } = useMerchantPayment()
/** 商户图片失败只切换占位标识,不影响真实商户名称和付款对象。 */
const merchantLogoFailed = ref(false)
/** 长金额按展示长度缩小字号,始终保留完整金额。 */
const amountSizeClass = computed(() => amount.value.length > 10 ? "text-56rpx" : amount.value.length > 7 ? "text-72rpx" : "text-104rpx")
/** 标识失败时使用 tm-ui 店铺图形。 */
function handleLogoError() { merchantLogoFailed.value = true }
/** 保留 Tab 和按钮原生键盘语义;锁定金额时 Hook 会拒绝数字输入。 */
function handleKeyboard(event: KeyboardEvent) {
if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey || event.isComposing) return
if (/^[0-9.]$/.test(event.key)) { event.preventDefault(); append(event.key) }
else if (event.key === "Backspace" || event.key === "Delete") { event.preventDefault(); remove() }
else if (event.key === "Enter" && event.target instanceof Element && !event.target.closest("button, uni-button")) {
event.preventDefault(); void pay()
}
}
/** 不创建可编辑 input,输入后仅滚动金额内部容器,避免唤起系统键盘或移动整页。 */
watch(amount, async () => {
await nextTick()
const viewport = document.getElementById("payment-amount-scroll")
if (viewport) viewport.scrollLeft = viewport.scrollWidth
})
/** 真实商户加载完成后才把焦点放入只读金额框。 */
watch(editable, async value => {
if (!value) return
await nextTick()
document.getElementById("payment-amount")?.focus({ preventScroll: true })
})
</script>
<style scoped lang="scss">
.payment-shell {
background: linear-gradient(180deg, var(--pay-tint) 0, var(--pay-background) 360px);
}
.payment-content {
padding-top: env(safe-area-inset-top, 0px);
padding-right: env(safe-area-inset-right, 0px);
padding-left: env(safe-area-inset-left, 0px);
overscroll-behavior: contain;
scroll-padding-block: 16px;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
&::-webkit-scrollbar {
display: none;
}
}
.payment-actions {
padding-bottom: max(20px, env(safe-area-inset-bottom, 0px));
}
.payment-action {
min-height: 52px;
padding: 14px 16px;
font-size: 17px;
line-height: 24px;
border: 0;
&::after { border: 0; }
&:focus-visible { outline: 2px solid var(--pay-strong); outline-offset: 2px; }
&:active { opacity: 0.85; }
}
.amount-card {
box-shadow: 0 8px 28px rgba(44, 83, 118, 0.035);
}
.merchant-name {
line-height: 1.45;
overflow-wrap: anywhere;
}
.amount-field {
min-height: 84px;
border-bottom: 1px solid var(--pay-border);
outline: none;
cursor: text;
&:focus-visible {
border-bottom-color: var(--pay-primary);
box-shadow: 0 1px 0 var(--pay-primary);
}
}
.amount-scroll {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.amount-digits {
min-height: 1.2em;
line-height: 1.2;
white-space: nowrap;
font-variant-numeric: tabular-nums;
letter-spacing: -0.025em;
}
.payment-cursor {
width: 2px;
height: 0.85em;
align-self: center;
border-radius: 1px;
animation: cursor-blink 1.2s step-end infinite;
}
@keyframes cursor-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
// 短屏优先压缩装饰留白,保留完整金额和至少 44px 的键盘触控区域
@media (max-height: 620px) {
.merchant-logo {
width: 36px;
height: 36px;
}
.merchant-name {
font-size: 16px;
}
.card-divider {
margin-block: 12px;
}
.amount-field {
min-height: 64px;
margin-top: 12px;
padding-bottom: 12px;
}
.amount-hint {
margin-top: 12px;
}
}
</style>
+93
View File
@@ -0,0 +1,93 @@
import { request } from "@@/utils/request"
/** 商户签名入口,长整数始终以字符串保存,由后端绑定商户和租户。 */
export interface MerchantEntry {
/** 收款商户主键。 */
id: string
/** 签名对应的租户主键。 */
tenantId: string
/** 当前协议版本,仅支持 1。 */
v: string
/** 绑定线下收款用途的服务端签名。 */
sign: string
}
/** 与后端渠道编码一致:10 微信、20 支付宝。 */
export type PaymentPlatform = "10" | "20"
/** 匿名可见的商户资料。 */
export interface MerchantInfo {
/** 商户主键,使用字符串避免精度损失。 */
id: string
/** 服务端返回的商户名称。 */
name: string
/** 商户标识,缺省时显示统一店铺图形。 */
logoPic?: string | null
/** 商户已启用的渠道,不包含商户号或配置;空列表表示暂无线上支付渠道。 */
supportedPlatforms: PaymentPlatform[]
}
/** OAuth 后的短期收银凭据,不是平台授权码。 */
export interface CashierTicket {
/** 服务端租户唯一编码。 */
tenantCode: string
/** 五分钟有效的不透明票据。 */
ticket: string
}
/** 聚合会话协议状态,沿用 Java 枚举的持久化编码。 */
export type PaymentStatus = "10" | "15" | "20" | "30" | "40" | "50" | "60"
/** 收银状态仅以服务端查单或验签回调确认。 */
export interface PaymentResult {
/** 聚合会话协议状态。 */
status: PaymentStatus
/** 已由服务端确认渠道付款成功。 */
paySuccess: boolean
/** 现有第三方商户订单号。 */
payNo?: string
}
/** H5 票据上下文,金额和场景均不可由 URL 覆盖。 */
export interface CashierContext extends PaymentResult {
/** ORDER 为已有订单,OFFLINE 为付款人输入金额的线下收款。 */
aggregateScene: "ORDER" | "OFFLINE"
/** 最小公开商户信息。 */
merchant: MerchantInfo
/** 以整数分保存的已锁定金额。 */
amount: number
/** 10 为微信,20 为支付宝。 */
selectedPlatform?: "10" | "20" | null
/** 尚可付款时提供的渠道签名参数,终态时缺省。 */
launchParams?: Record<string, unknown>
}
/** 创建结果中的跳转地址由后端固定配置产生。 */
export interface CreatedPayment {
/** 服务端聚合会话编号。 */
aggregateNo: string
/** 带随机访问令牌的既有聚合 OAuth 入口。 */
cashierUrl: string
/** 服务端支付截止时间,仅供提示,前端不能据此自行确认关单。 */
expireTime: string
}
/** 查询签名入口对应的最小商户信息。 */
export function getMerchant(entry: MerchantEntry) {
return request<MerchantInfo>("/third/aggregate-merchant-pay/merchant", "GET", { ...entry }, entry.tenantId)
}
/** 创建或恢复同一次收款意图,重试必须复用 requestId 和整数分金额。 */
export function createPayment(entry: MerchantEntry, amount: number, requestId: string) {
return request<CreatedPayment>("/third/aggregate-merchant-pay/create", "POST", { ...entry, amount, requestId }, entry.tenantId)
}
/** 读取票据绑定的商户、固定金额和渠道参数。 */
export function getCashierContext(value: CashierTicket) {
return request<CashierContext>(`/third/aggregate-order-pay/context/${encodeURIComponent(value.tenantCode)}/${encodeURIComponent(value.ticket)}`, "GET")
}
/** 读取原聚合状态接口,不能通过前端事件把流水标记为成功。 */
export function getPaymentStatus(value: CashierTicket) {
return request<PaymentResult>(`/third/aggregate-order-pay/status/${encodeURIComponent(value.tenantCode)}/${encodeURIComponent(value.ticket)}`, "GET")
}
+199
View File
@@ -0,0 +1,199 @@
<template>
<view class="payment-keyboard bg-surface" role="group" aria-label="付款金额数字键盘">
<view class="keypad-grid">
<button
v-for="key in numberKeys"
:key="key"
class="keypad-key bg-key text-text rounded-20rpx text-48rpx font-500"
:aria-label="key"
role="button"
tabindex="0"
:hover-class="'keypad-key--pressed'"
:hover-stay-time="100"
@click="emit('input', key)"
@keydown.enter.prevent="emit('input', key)"
@keydown.space.prevent="emit('input', key)"
>
{{ key }}
</button>
<button
class="keypad-key keypad-delete bg-key text-secondary rounded-20rpx"
aria-label="删除最后一位"
role="button"
tabindex="0"
hover-class="keypad-key--pressed"
:hover-stay-time="100"
@click="emit('delete')"
@keydown.enter.prevent="emit('delete')"
@keydown.space.prevent="emit('delete')"
>
<view class="flex-row-center" aria-hidden="true">
<tm-icon name="delete-back-2-line" :size="48" :color="themeColors.secondary" />
</view>
</button>
<button
class="keypad-key keypad-zero bg-key text-text rounded-20rpx text-48rpx font-500"
aria-label="0"
role="button"
tabindex="0"
hover-class="keypad-key--pressed"
:hover-stay-time="100"
@click="emit('input', '0')"
@keydown.enter.prevent="emit('input', '0')"
@keydown.space.prevent="emit('input', '0')"
>
0
</button>
<button
class="keypad-key keypad-decimal bg-key text-text rounded-20rpx text-48rpx font-500"
aria-label="小数点"
role="button"
tabindex="0"
hover-class="keypad-key--pressed"
:hover-stay-time="100"
@click="emit('input', '.')"
@keydown.enter.prevent="emit('input', '.')"
@keydown.space.prevent="emit('input', '.')"
>
.
</button>
<button
class="keypad-key keypad-pay rounded-20rpx"
:class="disabled ? 'bg-disabled text-secondary' : 'bg-primary text-white'"
:disabled="disabled"
:aria-disabled="disabled"
aria-label="付款"
role="button"
:tabindex="disabled ? -1 : 0"
hover-class="keypad-pay--pressed"
:hover-stay-time="100"
@click="handlePay"
@keydown.enter.prevent="handlePay"
@keydown.space.prevent="handlePay"
>
<view class="flex-col-center gap-12rpx pointer-events-none" aria-hidden="true">
<text class="text-40rpx font-700">付款</text>
<tm-icon name="arrow-right-line" :size="40" :color="disabled ? themeColors.secondary : 'white'" />
</view>
</button>
</view>
</view>
</template>
<script setup lang="ts">
import { themeColors } from "@@/config/theme"
/** 键盘不持有金额,也不触发请求,金额规则与付款状态由所属页面负责 */
const props = defineProps<{
/** 金额无效或提交期间禁用付款,数字输入另外由页面的金额锁定状态保护 */
disabled: boolean
}>()
/** 页面监听输入、退格和付款事件,触屏与物理键盘复用页面金额 Hook */
const emit = defineEmits<{
/** 追加一个数字或小数点,由页面校验格式 */
(event: "input", key: string): void
/** 删除最后一个字符 */
(event: "delete"): void
/** 发起一次付款意图,由页面完成金额校验与幂等请求 */
(event: "pay"): void
}>()
/** 数字区按阅读顺序排列,0 与小数点在底行单独占位 */
const numberKeys = ["1", "2", "3", "4", "5", "6", "7", "8", "9"]
/** 在组件边界保护禁用状态,避免第三方或程序化点击绕过视觉状态 */
function handlePay() {
if (!props.disabled) emit("pay")
}
</script>
<style scoped lang="scss">
.payment-keyboard {
padding: 16px 12px;
padding-right: max(12px, env(safe-area-inset-right, 0px));
padding-bottom: max(16px, env(safe-area-inset-bottom, 0px));
padding-left: max(12px, env(safe-area-inset-left, 0px));
border-top: 1px solid rgba(230, 237, 244, 0.85);
border-radius: 24px 24px 0 0;
box-shadow: 0 -8px 32px rgba(44, 83, 118, 0.035);
}
// 收银键盘的非等长按键需要明确的网格占位,避免 flex 换行改变排列
.keypad-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-rows: repeat(4, 60px);
gap: 8px;
}
.keypad-key {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-width: 0;
min-height: 44px;
height: 100%;
margin: 0;
padding: 0;
border: 0;
line-height: 1;
cursor: pointer;
user-select: none;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
transition: background-color 120ms ease, color 120ms ease;
&::after {
border: 0;
}
&:focus-visible {
outline: 2px solid var(--pay-strong);
outline-offset: 2px;
}
&:active,
&--pressed {
background: var(--pay-soft);
}
}
.keypad-delete {
grid-column: 4;
grid-row: 1;
}
.keypad-zero {
grid-column: 1 / 3;
grid-row: 4;
}
.keypad-decimal {
grid-column: 3;
grid-row: 4;
}
.keypad-pay {
grid-column: 4;
grid-row: 2 / 5;
&:active,
&--pressed {
background: var(--pay-strong);
}
&[aria-disabled="true"] {
color: var(--pay-secondary);
background: var(--pay-disabled);
cursor: default;
}
}
@supports (height: 100dvh) {
.keypad-grid {
grid-template-rows: repeat(4, clamp(48px, 7.5dvh, 64px));
}
}
</style>
@@ -0,0 +1,26 @@
<template>
<view class="flex-col-center pt-40rpx pb-24rpx text-center" role="status" aria-live="polite">
<view class="flex-row-center size-128rpx rounded-full bg-soft" aria-hidden="true">
<tm-icon name="bank-card-line" :size="64" :color="themeColors.primary" />
</view>
<text class="block mt-32rpx text-38rpx font-600 text-text leading-normal">{{ notice.title }}</text>
<text class="block mt-20rpx text-28rpx text-secondary leading-relaxed">{{ notice.description }}</text>
<view class="flex items-start gap-16rpx w-full mt-40rpx p-24rpx rounded-24rpx bg-soft text-left">
<view class="shrink-0" aria-hidden="true">
<tm-icon name="qr-scan-2-line" :size="36" :color="themeColors.strong" />
</view>
<text class="text-28rpx text-strong leading-relaxed">{{ notice.instruction }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { themeColors } from "@@/config/theme"
import type { PaymentUnavailable } from "@@/utils/payment-platform"
/** 在原付款页内展示不可用原因和换用已支持 App 的指引,不创建新的支付意图。 */
defineProps<{
/** 由当前 App、商户渠道和已绑定订单共同确定的展示状态。 */
notice: PaymentUnavailable
}>()
</script>
+12
View File
@@ -0,0 +1,12 @@
/** 静态页商户展示数据;仅用于展示,不代表后续支付接口的字段契约 */
export interface MerchantInfo {
/** 展示的商户名称,使用本地示例,可在此替换 */
name: string
/** 可选的商户标识地址;缺省或加载失败时展示店铺占位图形 */
logoUrl?: string
}
/** 首版商户示例,后续接入接口时由页面统一替换数据来源 */
export const mockMerchant: MerchantInfo = {
name: "资安信示例商户",
}
+29
View File
@@ -0,0 +1,29 @@
/** 付款页语义颜色;参考 App 的主题蓝与浅蓝背景作为视觉基线 */
export const themeColors = {
/** 品牌主色,用于付款键、光标和商户标识 */
primary: "#0094ff",
/** 同色系深蓝,用于浅色背景上的小字号文本 */
strong: "#006ec9",
/** 页面底色,与参考项目 globalStyle 保持一致 */
background: "#ecf4f9",
/** 页面上方的柔和蓝色渐变起点,仅承担背景装饰 */
tint: "#deeffc",
/** 商户和金额卡片的白色表面 */
surface: "#ffffff",
/** 商户名称、金额及数字按键的主要文字 */
text: "#263446",
/** 标签及辅助说明,确保白色表面上的可读性 */
secondary: "#536579",
/** 次要说明文字,避免参考页面的浅灰在小字号下对比不足 */
muted: "#64748b",
/** 金额占位符使用大字号,区别于已经输入的金额 */
placeholder: "#8192a6",
/** 卡片分隔线和按键边界 */
border: "#e6edf4",
/** 商户标识与键盘按压反馈的浅蓝色 */
soft: "#e6f3ff",
/** 数字键的浅灰表面,和白色键盘容器形成层次 */
key: "#f3f6fa",
/** 无效金额时付款键的填充色 */
disabled: "#d6e9f8",
} as const
+309
View File
@@ -0,0 +1,309 @@
import { computed, onMounted, onUnmounted, ref } from "vue"
import { createPayment, getCashierContext, getMerchant, getPaymentStatus } from "@@/api/payment"
import type { CashierContext, CashierTicket, MerchantEntry, MerchantInfo, PaymentResult } from "@@/api/payment"
import { amountToFen, formatFen, newPaymentRequestId, parsePaymentEntry } from "@@/utils/payment-entry"
import { invokePayment } from "@@/utils/payment-bridge"
import { paymentUnavailable, resolvePaymentPlatform } from "@@/utils/payment-platform"
import { usePaymentAmount } from "@@/hooks/use-payment-amount"
/** 本标签页的一次线下付款意图,只保存公开入口和恢复信息,不保存平台授权身份。 */
interface PendingPayment {
/** 用户扫描的签名商户入口。 */
entry: MerchantEntry
/** 已确认的整数分,提交后不能就地修改。 */
amount: number
/** 请求超时和刷新时复用的 UUID。 */
requestId: string
/** 创建成功后可继续 OAuth 的固定服务端地址。 */
cashierUrl?: string
}
/** 标签页级恢复键,不跨浏览器账号共享,也不保存商户密钥。 */
const PENDING_KEY = "merchant-pay:pending"
/** 串行轮询间隔为 4 秒,避免请求堆积及同 IP 多端触发限流。 */
const POLL_INTERVAL_MS = 4000
/** 渠道已确认终态,只有这些状态可以结束当前付款意图。 */
const TERMINAL_STATUSES = ["30", "40", "50", "60"]
/** 管理两种收银场景的授权、金额锁定、继续付款及服务端结果确认。 */
export function useMerchantPayment() {
/** 手动输入的人民币元字符串,只在未提交线下收款时可修改。 */
const input = usePaymentAmount()
/** 当前入口的真实商户,失败时不回退到示例数据。 */
const merchant = ref<MerchantInfo>()
/** 已通过格式校验的静态签名入口,真实有效性仍由后端判断。 */
const entry = ref<MerchantEntry>()
/** OAuth 回跳的短期收银票据。 */
const ticket = ref<CashierTicket>()
/** 后端固定的金额、渠道和状态。 */
const context = ref<CashierContext>()
/** 标签页内待恢复的付款意图,金额在发请求之前即锁定。 */
const pending = ref<PendingPayment>()
/** 页面首次查询或手动重试期间为 true。 */
const loading = ref(false)
/** 支付创建或桥调用期间为 true,防止重复提交。 */
const busy = ref(false)
/** 收银结果确认期间为 true,不允许重复调用支付组件。 */
const confirming = ref(false)
/** 当前加载错误,屏幕阅读器与可视提示共享文案。 */
const error = ref("")
/** 平台取消、查单和终态提示,不包含内部异常。 */
const feedback = ref("")
/** 当前扫码客户端;不可用页面只引导用户重新扫码,不尝试跨 App 强制唤起。 */
const clientPlatform = resolvePaymentPlatform(navigator.userAgent)
/** 在金额输入、会话创建及支付组件调起前统一拦截不受支持的客户端。 */
const unavailable = computed(() => {
if (!merchant.value || success.value || terminal.value) return undefined
const notice = paymentUnavailable(merchant.value.supportedPlatforms, clientPlatform, context.value?.selectedPlatform)
if (notice) return notice
// 未开通渠道的订单扫码只签发只读票据;重新开通后需再扫码进入 OAuth,不能停在无调起参数的页面。
if (context.value?.status === "10") return { title: "请重新扫码付款", description: "当前支付方式已可用,请重新扫码进入收银台。", instruction: "请使用当前 App 的“扫一扫”,重新扫描商户收款码。" }
return undefined
})
/** 串行轮询计时器,页面隐藏或销毁时清除。 */
let pollTimer: ReturnType<typeof setTimeout> | undefined
/** 取消过期查询结果,避免页面离开或重试后旧请求覆盖新状态。 */
let revision = 0
/** 防止轮询请求与页面恢复请求并行。 */
let checking = false
/** 本页面是否已经销毁。 */
let disposed = false
/** 仅未提交的签名商户入口允许数字输入。 */
const editable = computed(() => !!entry.value && !!merchant.value && !context.value && !pending.value && !loading.value && !error.value && !unavailable.value)
/** 已提交或已有订单都显示服务端/冻结金额,不能使用 URL 金额。 */
const amount = computed(() => context.value ? formatFen(context.value.amount) : pending.value ? formatFen(pending.value.amount) : input.amount.value)
/** 成功仅依据后端确认值。 */
const success = computed(() => context.value?.paySuccess === true)
/** 当前会话已结束,不再提供支付入口。 */
const terminal = computed(() => !!context.value && TERMINAL_STATUSES.includes(context.value.status))
/** 金额校验、平台就绪及请求状态共同决定按钮是否可操作。 */
const canPay = computed(() => !loading.value && !busy.value && !confirming.value && !error.value && !unavailable.value && !!merchant.value
&& !terminal.value && (context.value ? context.value.status === "20" && !!context.value.launchParams : !!pending.value || amountToFen(input.amount.value) !== undefined))
/** 已创建的线下付款和订单收银只保留底部操作区。 */
const showKeyboard = computed(() => !!entry.value && !context.value && !pending.value && !unavailable.value)
/** 成功或已确认关闭后可以重新输入;创建失败仍须重新扫码核对,避免未知渠道结果下新建支付。 */
const canRestart = computed(() => !!entry.value && !!context.value && ["30", "40", "50"].includes(context.value.status))
/** 保存付款意图失败时停止提交,避免跳转后丢失幂等请求号。 */
function savePending(value: PendingPayment) {
sessionStorage.setItem(PENDING_KEY, JSON.stringify(value))
pending.value = value
}
/** 只恢复符合格式和金额约束的本标签页数据,付款状态仍需后端确认。 */
function readPending(): PendingPayment | undefined {
try {
const stored = JSON.parse(sessionStorage.getItem(PENDING_KEY) || "null") as PendingPayment | null
if (!stored || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(stored.requestId) || amountToFen(formatFen(stored.amount)) !== stored.amount) return
const query = new URLSearchParams({ ...stored.entry }).toString()
if (parsePaymentEntry(`https://entry.invalid/#/pages/pay/index?${query}`).kind !== "merchant") return
if (stored.cashierUrl && new URL(stored.cashierUrl).protocol !== "https:") return
return stored
} catch { return }
}
/** 停止下次轮询;已经发出的请求通过 revision 取消写入。 */
function stopPolling() {
if (pollTimer) clearTimeout(pollTimer)
pollTimer = undefined
}
/** 按服务端终态更新提示,不以本机时间或 JSBridge 回调自行判定付款成功。 */
function applyStatus(result: PaymentResult) {
if (!context.value) return
context.value = { ...context.value, ...result }
if (result.paySuccess) feedback.value = "付款成功"
else if (result.status === "40") feedback.value = "交易已关闭,未完成付款"
else if (result.status === "50") feedback.value = "收款请求已过期"
else if (result.status === "60") feedback.value = "付款创建失败,请重新扫码核对"
if (TERMINAL_STATUSES.includes(result.status)) {
confirming.value = false
stopPolling()
}
}
/** 排队下一次状态查询,前一个请求结束后才会启动。 */
function queuePolling() {
stopPolling()
if (!disposed && ticket.value && !terminal.value && !document.hidden && !error.value && !unavailable.value) {
pollTimer = setTimeout(() => { void checkStatus() }, POLL_INTERVAL_MS)
}
}
/** 失败时保留原请求与金额,用户可手动重试状态查询,不能直接再建一笔交易。 */
async function checkStatus() {
if (!ticket.value || checking || disposed) return
checking = true
const current = revision
try {
const result = await getPaymentStatus(ticket.value)
if (current !== revision || disposed) return
applyStatus(result)
error.value = ""
} catch (cause) {
if (current === revision && !disposed) error.value = cause instanceof Error ? cause.message : "付款结果暂不可用,请稍后查询"
} finally {
checking = false
if (current === revision) queuePolling()
}
}
/** 调起当前已绑定的平台订单;取消后保持原单号,继续付款仍复用本单。 */
async function launch() {
const data = context.value
if (!data?.launchParams || !data.selectedPlatform || data.status !== "20" || busy.value || confirming.value || unavailable.value) return
busy.value = true
const current = revision
try {
const result = await invokePayment(data.selectedPlatform, data.launchParams)
if (disposed || current !== revision) return
confirming.value = result === "confirming"
feedback.value = result === "cancelled" ? "已取消付款,可继续付款" : result === "failed" ? "未完成付款,可继续付款" : "付款结果确认中,请勿重复付款"
await checkStatus()
} catch (cause) {
if (!disposed && current === revision) feedback.value = cause instanceof Error ? cause.message : "未能调起付款,请稍后重试"
} finally {
if (!disposed && current === revision) { busy.value = false; queuePolling() }
}
}
/** 入口加载失败始终显示错误,只有真实商户加载成功后才开放输入或支付。 */
async function load() {
const current = ++revision
stopPolling()
loading.value = true
error.value = ""
try {
const parsed = parsePaymentEntry(window.location.href)
if (parsed.kind === "merchant") {
entry.value = parsed.entry
const data = await getMerchant(parsed.entry)
if (disposed || current !== revision) return
merchant.value = data
const stored = readPending()
if (stored?.entry.id === parsed.entry.id && stored.entry.tenantId === parsed.entry.tenantId && stored.entry.sign === parsed.entry.sign) pending.value = stored
} else {
ticket.value = parsed.ticket
const data = await getCashierContext(parsed.ticket)
if (disposed || current !== revision) return
context.value = data
merchant.value = data.merchant
const stored = readPending()
if (data.aggregateScene === "OFFLINE" && stored?.entry.id === data.merchant.id) {
entry.value = stored.entry
pending.value = stored
}
applyStatus(data)
// 只在新的 OAuth 票据第一次到达时自动调起,刷新不会重复自动弹出收银台。
const marker = `merchant-pay:launched:${parsed.ticket.ticket}`
if (!unavailable.value && !sessionStorage.getItem(marker) && data.status === "20" && data.launchParams) {
sessionStorage.setItem(marker, "1")
void launch()
}
queuePolling()
}
} catch (cause) {
if (!disposed && current === revision) error.value = cause instanceof Error ? cause.message : "收款信息暂不可用"
} finally {
if (current === revision) loading.value = false
}
}
/** 先保存稳定请求号再创建会话,任何网络重试都保留原金额与 requestId。 */
async function pay() {
if (!canPay.value) return
if (context.value) { await launch(); return }
if (!entry.value) return
busy.value = true
const current = revision
try {
if (!pending.value) {
const value = amountToFen(input.amount.value)
if (value === undefined) return
savePending({ entry: entry.value, amount: value, requestId: newPaymentRequestId() })
}
const attempt = pending.value!
let url = attempt.cashierUrl
if (!url) {
const result = await createPayment(attempt.entry, attempt.amount, attempt.requestId)
if (disposed || current !== revision) return
url = result.cashierUrl
if (new URL(url).protocol !== "https:") throw new Error("付款入口不可用,请联系商户")
savePending({ ...attempt, cashierUrl: url })
}
window.location.assign(url)
} catch (cause) {
if (!disposed && current === revision) {
feedback.value = cause instanceof Error ? cause.message : "付款暂未能发起,请稍后重试"
// 商户可能在页面停留期间停用渠道;重新读取开通情况,保留原金额和请求号。
try {
const data = await getMerchant(entry.value!)
if (!disposed && current === revision) merchant.value = data
} catch { /* 查询失败时保留原请求与错误提示,不能据此释放未知支付意图。 */ }
}
} finally { if (!disposed && current === revision) busy.value = false }
}
/** 渠道提示页重新读取商户/票据上下文;已有正常付款仅查询结果,避免重新创建会话。 */
async function retry() {
if (loading.value || busy.value) return
if (ticket.value && context.value && !unavailable.value) await checkStatus()
else await load()
}
/** 只有服务端已确认成功或关闭后,才释放旧意图并返回签名商户入口。 */
function restart() {
if (!canRestart.value || !entry.value) return
sessionStorage.removeItem(PENDING_KEY)
const query = new URLSearchParams({ ...entry.value }).toString()
window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}#/pages/pay/index?${query}`)
window.location.reload()
}
/** 输入长度限制同时覆盖四位整数和两位小数,不改变冻结金额。 */
function append(key: string) {
if (!editable.value) return
const next = input.amount.value
if (/^\d$/.test(key) && !next.includes(".") && next !== "0" && next.length >= 4) return
input.append(key)
}
/** 只有未提交的线下金额允许删除。 */
function remove() { if (editable.value) input.remove() }
/** 后台恢复时先查真实状态,页面隐藏期间停止轮询。 */
function handleVisibility() {
if (document.hidden) stopPolling()
else if (ticket.value && !terminal.value) void checkStatus()
}
/** 单页 Hash 入口变化也重新校验,避免浏览器复用同一页面时沿用上一商户或金额。 */
function handleHashChange() {
busy.value = false
context.value = undefined
ticket.value = undefined
entry.value = undefined
merchant.value = undefined
pending.value = undefined
feedback.value = ""
confirming.value = false
input.clear()
void load()
}
/** 页面挂载后读取真实入口,不从示例商户配置加载任何收款对象。 */
onMounted(() => {
document.addEventListener("visibilitychange", handleVisibility)
window.addEventListener("hashchange", handleHashChange)
void load()
})
/** 释放轮询和监听,未返回的请求不能再修改页面状态。 */
onUnmounted(() => {
disposed = true; revision++; stopPolling()
document.removeEventListener("visibilitychange", handleVisibility)
window.removeEventListener("hashchange", handleHashChange)
})
return { merchant, amount, loading, busy, error, feedback, editable, canPay, showKeyboard, canRestart, confirming, success, terminal, unavailable,
pay, restart, retry, append, remove }
}
+38
View File
@@ -0,0 +1,38 @@
import { computed, readonly, ref } from "vue"
/** 人民币金额最多保留两位小数;输入过程使用字符串保留末尾的小数点与零 */
const DECIMAL_PLACES = 2
/**
* 将一个数字或小数点追加到金额尾部,无效输入返回原值
* 不进行浮点运算,也不将展示长度限制当作真实支付限额
*/
export function appendAmount(current: string, key: string): string {
if (!/^[0-9.]$/.test(key)) return current
if (key === ".") return current.includes(".") ? current : `${current || "0"}.`
if (current.includes(".") && current.split(".")[1].length >= DECIMAL_PLACES) return current
return current === "0" ? key : current + key
}
/** 页面金额状态的唯一写入入口,自定义键盘和物理键盘共享相同的输入规则 */
export function usePaymentAmount() {
/** 当前输入的金额,单位为元;空字符串表示尚未输入,生命周期与页面一致 */
const amount = ref("")
/** 输入由 appendAmount 保证格式,只需判断是否包含非零数字即可避免浮点精度问题 */
const canPay = computed(() => /[1-9]/.test(amount.value))
/** 追加一个合法字符,保留正在输入的小数状态 */
function append(key: string) {
amount.value = appendAmount(amount.value, key)
}
/** 删除最后一个字符,空金额继续保持为空 */
function remove() {
amount.value = amount.value.slice(0, -1)
}
/** 切换到另一收款入口时清空未提交金额,不影响已持久化的付款请求 */
function clear() { amount.value = "" }
return { amount: readonly(amount), canPay, append, remove, clear }
}
+64
View File
@@ -0,0 +1,64 @@
page {
height: 100%;
color: var(--pay-text);
background: var(--pay-background);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
-webkit-font-smoothing: antialiased;
}
view,
text,
button,
image {
box-sizing: border-box;
}
button {
font: inherit;
}
// #ifdef H5
html,
body,
#app,
uni-app,
uni-page,
uni-page-wrapper,
uni-page-body {
height: 100%;
margin: 0;
}
html,
body {
background: var(--pay-background);
overflow: hidden;
overscroll-behavior: none;
}
// 整页锁在浏览器可视区域,极短屏只允许卡片所在区域内部滑动且不显示滚动条
uni-app,
uni-page,
uni-page-wrapper,
uni-page-body {
overflow: hidden;
}
body {
color-scheme: light;
}
// 使用动态视口适应移动浏览器地址栏,旧浏览器保留 vh 回退
.payment-shell {
height: 100vh;
height: 100dvh;
box-shadow: 0 0 80px rgba(44, 83, 118, 0.07);
}
// #endif
// 仅对自绘输入光标关闭动画,按键状态反馈仍保留
@media (prefers-reduced-motion: reduce) {
.payment-cursor {
animation: none !important;
}
}
+6
View File
@@ -0,0 +1,6 @@
import { createPinia } from "pinia"
/** 仅承载 tm-ui 的主题状态,付款金额保留在当前页面内,不持久化 */
const pinia = createPinia()
export default pinia
+77
View File
@@ -0,0 +1,77 @@
/** 客户端回调只代表调起结果,最终付款状态必须再查询后端。 */
export type BridgeResult = "confirming" | "cancelled" | "failed"
/** 两种客户端桥的最小接口,不引入 App SDK 或其他平台能力。 */
interface PaymentBridge {
/** 支付宝 tradePay 接口。 */
call?: (name: string, params: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => void
/** 微信 getBrandWCPayRequest 接口。 */
invoke?: (name: string, params: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => void
}
/** 当前客户端注入的全局桥对象,仅在 H5 运行期访问。 */
type BridgeWindow = Window & { AlipayJSBridge?: PaymentBridge; WeixinJSBridge?: PaymentBridge }
/** 把平台回调归一化;9000/ok 仍进入服务端结果确认,不直接展示支付成功。 */
export function bridgeResult(platform: "10" | "20", result: Record<string, unknown>): BridgeResult {
if (platform === "20") {
const code = String(result.resultCode || "")
if (["9000", "8000", "6004"].includes(code)) return "confirming"
return code === "6001" ? "cancelled" : "failed"
}
const message = String(result.err_msg || "").toLowerCase()
return message.endsWith(":ok") ? "confirming" : message.endsWith(":cancel") ? "cancelled" : "failed"
}
/** 等待客户端桥就绪并调起一次;取消或失败不自动重复唤起。 */
export function invokePayment(platform: "10" | "20", params: Record<string, unknown>): Promise<BridgeResult> {
const client = navigator.userAgent.toLowerCase()
if ((platform === "10" && !client.includes("micromessenger")) || (platform === "20" && !client.includes("alipayclient"))) {
return Promise.reject(new Error("请在原微信或支付宝客户端中继续付款"))
}
return new Promise((resolve, reject) => {
/** 当前桥等待和回调仅属于本次用户调起操作。 */
const readyEvent = platform === "20" ? "AlipayJSBridgeReady" : "WeixinJSBridgeReady"
let invoked = false
let settled = false
let timer: ReturnType<typeof setTimeout>
/** 释放就绪监听和超时,避免页面反复授权后残留调起。 */
function finish(result?: BridgeResult, error?: Error) {
if (settled) return
settled = true
document.removeEventListener(readyEvent, launch)
clearTimeout(timer)
if (error) reject(error)
else resolve(result || "confirming")
}
/** 只接受当前平台的最小参数,不把后端提供的对象转换成可执行脚本。 */
function launch() {
if (invoked || settled) return
const host = window as BridgeWindow
const bridge = platform === "20" ? host.AlipayJSBridge : host.WeixinJSBridge
if (!bridge || (platform === "20" ? !bridge.call : !bridge.invoke)) return
if (
(platform === "20" && typeof params.tradeNO !== "string") ||
(platform === "10" &&
!["appId", "timeStamp", "nonceStr", "package", "signType", "paySign"].every((key) => typeof params[key] === "string"))
) {
finish(undefined, new Error("付款参数不可用,请重新扫码"))
return
}
invoked = true
clearTimeout(timer)
// 用户在平台收银台停留较久时只转入查单,不据超时认定失败或再发起一笔支付。
timer = setTimeout(() => finish("confirming"), 30000)
try {
const callback = (result: Record<string, unknown>) => finish(bridgeResult(platform, result))
if (platform === "20") bridge.call!("tradePay", { tradeNO: params.tradeNO }, callback)
else bridge.invoke!("getBrandWCPayRequest", params, callback)
} catch {
finish(undefined, new Error("支付组件调用失败,请稍后继续付款"))
}
}
timer = setTimeout(() => finish(undefined, new Error("支付组件尚未就绪,请点击继续付款")), 5000)
document.addEventListener(readyEvent, launch)
launch()
})
}
+47
View File
@@ -0,0 +1,47 @@
import type { CashierTicket, MerchantEntry } from "@@/api/payment"
/** 初版单笔上限为 9,999.99 元,与后端配置同步,单位为分。 */
export const MAX_OFFLINE_AMOUNT = 999999
/** 二维码只能是签名商户入口或短期收银票据两种形式。 */
export type PaymentEntry = { kind: "merchant"; entry: MerchantEntry } | { kind: "cashier"; ticket: CashierTicket }
/** 解析 Hash 路由参数;拒绝重复参数及混用入口,避免跳转时悄悄切换收款对象。 */
export function parsePaymentEntry(href: string): PaymentEntry {
const url = new URL(href)
const params = new URLSearchParams(url.hash.includes("?") ? url.hash.slice(url.hash.indexOf("?") + 1) : url.search)
if (Array.from(params.keys()).some(key => params.getAll(key).length !== 1)) throw new Error("收款链接无效,请重新扫码")
if (params.has("ticket") || params.has("tenantCode")) {
if (params.has("id") || params.has("sign") || params.has("tenantId")) throw new Error("收银凭据无效,请重新扫码")
const ticket = params.get("ticket") || ""
const tenantCode = params.get("tenantCode") || ""
if (!/^[A-Za-z0-9_-]{32}$/.test(ticket) || !tenantCode || tenantCode.length > 128) throw new Error("收银凭据无效,请重新扫码")
return { kind: "cashier", ticket: { ticket, tenantCode } }
}
const entry = { id: params.get("id") || "", tenantId: params.get("tenantId") || "", v: params.get("v") || "", sign: params.get("sign") || "" }
if (!/^[1-9]\d{0,18}$/.test(entry.id) || !/^[1-9]\d{0,18}$/.test(entry.tenantId)
|| entry.v !== "1" || !/^[0-9a-f]{64}$/.test(entry.sign)) throw new Error("收款链接无效,请重新扫码")
return { kind: "merchant", entry }
}
/** 字符串元精确转换为整数分,拒绝零、科学计数、超过两位小数及超限输入。 */
export function amountToFen(value: string): number | undefined {
if (!/^\d{1,4}(\.\d{0,2})?$/.test(value)) return undefined
const [whole, fraction = ""] = value.split(".")
const result = Number(whole) * 100 + Number(fraction.padEnd(2, "0"))
return Number.isSafeInteger(result) && result > 0 && result <= MAX_OFFLINE_AMOUNT ? result : undefined
}
/** 固定金额从整数分格式化,不进行浮点金额运算。 */
export function formatFen(value: number): string {
return `${Math.floor(value / 100)}.${String(value % 100).padStart(2, "0")}`
}
/** 浏览器为一次付款意图生成 UUID;网络重试复用该值。 */
export function newPaymentRequestId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16))
bytes[6] = (bytes[6] & 15) | 64
bytes[8] = (bytes[8] & 63) | 128
const hex = Array.from(bytes, value => value.toString(16).padStart(2, "0")).join("")
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
+43
View File
@@ -0,0 +1,43 @@
import type { PaymentPlatform } from "@@/api/payment"
/** 渠道名称只用于展示,后端始终依据已启用的商户配置校验实际付款。 */
const PLATFORM_NAMES: Record<PaymentPlatform, string> = { "10": "微信", "20": "支付宝" }
/** 当前客户端无法付款时展示的原因和恢复指引。 */
export interface PaymentUnavailable {
/** 说明商户未开通当前渠道,或当前浏览器无法调起支付。 */
title: string
/** 已开通的替代方式或现有付款的绑定渠道。 */
description: string
/** 用户可以实际完成的下一步操作。 */
instruction: string
}
/** 仅识别两种受支持的扫码客户端,与后端的识别顺序保持一致。 */
export function resolvePaymentPlatform(userAgent: string): PaymentPlatform | undefined {
const client = userAgent.toLowerCase()
if (client.includes("micromessenger")) return "10"
if (client.includes("alipayclient")) return "20"
return undefined
}
/** 进入输入框前检查渠道;已有子订单按原渠道恢复,避免商户配置变化掩盖待确认付款。 */
export function paymentUnavailable(supportedPlatforms: PaymentPlatform[] | undefined, client: PaymentPlatform | undefined, selected?: PaymentPlatform | null): PaymentUnavailable | undefined {
if (selected) {
if (client === selected) return undefined
const name = PLATFORM_NAMES[selected]
return { title: `请使用${name}继续付款`, description: `本次付款已在${name}中发起,请继续原来的付款。`, instruction: `请回到${name}完成付款,避免重复支付。` }
}
const platforms = Array.isArray(supportedPlatforms) ? [...new Set(supportedPlatforms.filter(value => value === "10" || value === "20"))] : []
if (!platforms.length) {
return { title: "商户暂未开通线上支付", description: "当前暂无可用的微信或支付宝支付方式。", instruction: "请联系商户确认其他收款方式。" }
}
const names = platforms.map(value => PLATFORM_NAMES[value]).join("或")
if (!client) {
return { title: `请使用${names}扫码`, description: `该商户支持${names}支付。`, instruction: `请打开${names},使用“扫一扫”扫描商户收款码。` }
}
if (!platforms.includes(client)) {
return { title: `商户暂不支持${PLATFORM_NAMES[client]}支付`, description: `该商户当前支持${names}支付。`, instruction: `请打开${names},使用“扫一扫”重新扫描商户收款码。` }
}
return undefined
}
+37
View File
@@ -0,0 +1,37 @@
/** 后端统一响应,仅公开收银接口使用,不携带登录 Token 或商户密钥。 */
interface ApiResponse<T> {
/** 0 表示业务成功,非零表示业务失败。 */
code: number | string
/** 经过后端处理的用户提示。 */
msg?: string
/** 具体接口的最小公开响应。 */
data: T
}
/** 沿用参考项目变量:开发走 VITE_API_URL 本地代理,生产读取 VITE_APP_PROD_SERVICE。 */
const apiBase = (import.meta.env.DEV ? import.meta.env.VITE_API_URL || "/api" : import.meta.env.VITE_APP_PROD_SERVICE || "").trim().replace(/\/+$/, "")
/** 统一封装匿名请求与微服务/Boot 路径,不自动重试支付创建请求。 */
export function request<T>(path: string, method: "GET" | "POST", data?: Record<string, unknown>, tenantId?: string): Promise<T> {
/** Boot 聚合服务不使用网关 third 前缀,其他部署保留前缀。 */
const endpoint = import.meta.env.VITE_IS_MICRO === "false" ? path.replace(/^\/third(?=\/)/, "") : path
return new Promise((resolve, reject) => {
uni.request({
url: apiBase + endpoint,
method,
data,
timeout: 15000,
withCredentials: false,
header: { "Content-Type": "application/json", ...(tenantId ? { "TENANT-ID": tenantId } : {}) },
success(response) {
const body = response.data as ApiResponse<T> | undefined
if (response.statusCode >= 200 && response.statusCode < 300 && body && String(body.code) === "0") {
resolve(body.data)
} else {
reject(new Error(response.statusCode === 429 ? "请求较频繁,请稍后重试" : body?.msg || "服务暂不可用,请稍后重试"))
}
},
fail() { reject(new Error("网络连接失败,请检查网络后重试")) },
})
})
}
+6
View File
@@ -0,0 +1,6 @@
// 与参考 App 的主题保持一致,供 uni-app 内置组件使用
$uni-color-primary: #0094ff;
$uni-text-color: #263446;
$uni-text-color-grey: #64748b;
$uni-bg-color: #ecf4f9;
$uni-border-color: #e6edf4;
+13
View File
@@ -0,0 +1,13 @@
## 3.2.0(2024-10-11)
* [注意]本版本不向下兼容,可以直接通过市场导入或者在插件目录右键升级到项目中
* [3.2.x文档链接](https://tmui.design/)
* 1. 本版本不向下兼容!!!!!
* 2. 不再兼容nvue平台,请谨慎升级
* 3. 本版本不是之前的3.1.x版本的升级版本,无法平移过度到本版本
* 4. 本版本为全新的tmui4.0x版本的小程序版本,风格,标准,细节与其一样,属性也大多相同,但内部是本质的不同.不可以4.0x混用
* 5. 本版本以webview app + 小程序为准兼容,并且不提供任何原生插件,如果需要原生请使用tmui4.0x来支持我.
* 6. 本版本一样是免费的,可以自由的使用,商用,任意修改不用担心版权问题.但请保留版权信息.但不得发布为公共的竞品版本,发现立即追究版权.
* 7. 本版本更新迭代比较慢,力求后续兼容鸿蒙系统及所有小程序,因此会逐步减少组件数量,但会保证组件质量,保留最基本的组件库功能.
* 8. 如果需要赞助或者捐赠,可以通过官网付款码赞助支持我的开发或者购买tmui4.0x以支持.
* 发布第一个3.2.0版本为公测版本
* 如果有遇到问题请提交bug至[Gitee](https://gitee.com/LYTB/tm-vuetify-for-vue3)
@@ -0,0 +1,171 @@
<template>
<text
@tap="onclick"
v-if="!isImg"
:class="['tmIcon', `${prefix}${iconName}`, isSpin?'tmIconSpin':'',_class]"
:style="[
{
fontSize: iconSize,
color: fontColor,
fontFamily: fontFamily,
fontStyle: 'normal',
},
rotateDeg ? { transform: `rotate(${rotateDeg})` } : {},
_style
]"
></text>
<image
v-if="isImg"
@tap="onclick"
:src="iconName"
:class="['tmIcon', isSpin?'tmIconSpin':'',_class]"
:style="[
{ width: iconSize, height: iconSize },
rotateDeg ? { transform: `rotate(${rotateDeg})` } : {},
_style
]"
></image>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { covetUniNumber } from "../../libs/tool";
import { useTmConfig } from "../../libs/config";
import { getDefaultColor } from "../../libs/colors";
/**
* @displayName 图标
* @exportName tm-icon
* @category 常用组件
* @description 图标,可以是图片地址或者字体名称图标.也可以是你自定义的图标.
* 如何自定义图标呢:你可以在iconfont网站勾选图标->添加购物车->添加项目->项目设置,勾选woff2,勾选base64->下载本地->解压得到iconfont.css
* ->复制到你的app.vue全局中即可.然后使用图标时,font-family是你的图标名称比如myicon,prefix是图标前缀比如myicon-
* @constant 平台兼容
* | H5 | uniAPP | 小程序 | version |
| --- | --- | --- | --- |
| ☑️| ☑️ | ☑️ | ☑️ | ☑️ | 1.0.0 |
*/
defineOptions({ name: "TmIcon" });
const { config } = useTmConfig();
const emits = defineEmits([
/**
* 点击事件
*/
"click",
]);
const attrs = defineProps({
_style:{
type:String,
default:""
},
_class:{
type:String,
default:""
},
/**
* 图标名称,可以是图片地址
*/
name: {
type: String,
default: "",
required: true,
},
/**
* 图标大小,默认32rpx
*/
size: {
type: [Number, String],
default: "32",
},
/**
* 图标颜色
*/
color: {
type: String,
default: "#333333",
},
/**
* 暗黑时的图标颜色
*/
darkColor: {
type: String,
default: "white",
},
/**
* 图标是否旋转
*/
spin: {
type: Boolean,
default: false,
},
/**
* 旋转角度
*/
rotate: {
type: String,
default: "",
},
/**字体图标名称,如果你要显示你自己的名称时,需要改成你自己的图标名称. */
fontFamily: {
type: String,
default: "remixicon",
},
/**图标名称前缀,默认为ri-,没有你可以为空 */
prefix: {
type: String,
default: "ri-",
},
});
const iconName = computed(() => attrs.name);
const iconSize = computed(() => covetUniNumber(attrs.size, "rpx"));
const isImg = computed(() => iconName.value.includes("."));
const isSpin = computed(() => attrs.spin);
const rotateDeg = computed(() => attrs.rotate);
const _style = computed(() => attrs._style);
const _class = computed(() => attrs._class);
const fontColor = computed(() => {
let color = attrs.color;
if (config.mode == "dark" && attrs.darkColor) {
color = attrs.darkColor;
}
return getDefaultColor(color);
});
const onclick = () => emits("click");
</script>
<script lang="ts">
export default {
options: {
styleIsolation: "apply-shared",
virtualHost: true,
addGlobalClass: true,
multipleSlots: true,
},
};
</script>
<style lang="scss" scoped>
.tmIcon {
display: inline-flex;
justify-content: center;
align-items: center;
flex-direction: row;
transition: transform 0.3s linear;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.tmIconSpin {
animation: tmIconAniSPin 1s infinite linear;
}
@keyframes tmIconAniSPin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,289 @@
<template>
<view
@click="OnClick"
class="tmSheet"
@touchstart="touchStart"
@touchcancel="touchEnd"
@touchend="touchEnd"
@mousedown="touchStart"
@mouseup="touchEnd"
@mouseleave="touchEnd"
:style="[
{
width: _width,
height: _height,
},
buttonStyle,
]"
>
<!-- @slot 默认插槽 -->
<slot></slot>
<view
v-if="_attrs.loading"
class="tmSheetLoading"
:style="{ borderRadius: buttonStyle.borderRadius }"
>
<tm-icon name="loader-line" size="50" spin color="primary"></tm-icon>
</view>
</view>
</template>
<script lang="ts" setup>
import { computed, ref } from "vue";
import { arrayNumberValid, arrayNumberValidByStyleMP, arrayNumberValidByStyleBorderColor, arrayNumberValidByStyleBorderStyle, covetUniNumber, linearValid } from "../../libs/tool";
import { useTmConfig } from "../../libs/config";
import { getDefaultColor, getDefaultColorObj, getOutlineColorObj, getTextColorObj, getThinColorObj } from "../../libs/colors";
/**
* @displayName 容器
* @exportName tm-sheet
* @category 常用组件
* @description 像一张纸张一样,用于包裹内容区域,可塑性高,可以快速用来布局.
* @constant 平台兼容
* | H5 | uniAPP | 小程序 | version |
| --- | --- | --- | --- |
| ☑️| ☑️ | ☑️ | ☑️ | ☑️ | 1.0.0 |
*/
defineOptions({ name: 'TmSheet' });
const { config } = useTmConfig()
const emits = defineEmits([
/**
* 点击事件
* @property {MouseEvent} evt 事件参数
* */
'click',
])
type PropsKeyType = keyof typeof attrs;
const attrs = defineProps({
/**
* 遵循规则:
* string或者[x]:全部边线
* [x,x]左右边线,上下边线
* [x,x,x] 左,上,右
* [x,x,x,x] 左,上,右,下
*/
borderColor: {
type: [String, Array<string>],
default: ""
},
darkBorderColor: {
type: [String, Array<string>],
default: "transparent"
},
/** 规则同borderColor */
borderWidth: {
type: [String, Number, Array<string | number>],
default: 0
},
/** 规则同borderColor */
borderStyle: {
type: [String, Array<string>],
default: "solid"
},
/** 规则同borderColor
* 左上,右上,右下,左下
*/
round: {
type: [String, Number, Array<string | number>],
default: ''
},
/**
* 遵循规则:填写0关闭,填写空值取全局
* string,number,或者[x]:全部
* [x,x]左右,上下
* [x,x,x] 左,上,右
* [x,x,x,x] 左,上,右,下
*/
margin: {
type: [String, Number, Array<string | number>],
default: ''
},
/**
* 遵循规则:填写0关闭,填写空值取全局
* string,number,或者[x]:全部
* [x,x]左右,上下
* [x,x,x] 左,上,右
* [x,x,x,x] 左,上,右,下
*/
padding: {
type: [String, Number, Array<string | number>],
default: ''
},
width: {
type: [String, Number],
default: ""
},
height: {
type: [String, Number],
default: ""
},
loading: {
type: Boolean,
default: false
},
/**
* 是否跟随主题背景色
* 下方的color失效
*/
flowTheme: {
type: Boolean,
default: false
},
/**
* 背景主题
*/
color: {
type: String,
default: "white"
},
/**
* 自定义暗黑背景
* 空值时取全局配置
*/
darkBgColor: {
type: String,
default: ""
},
/** 渐变,前面的color,bgColor,darkBgColor失效 */
linear: {
type: [Array<string>],
default: () => [],
validator: (val: string[]) => {
if (val.length == 0) return true;
let pass = val.length == 3;
if (!pass) {
console.error("tmui提醒:linear 参数格式错误,请检查,必须长度为3");
}
return pass
}
},
/**
* 数字或者字符串时自动计算投影,它会投影大小
* 数组时(必须为4),第一个x,第二为y,第三个为大小 ,第四个为投影颜色
* 空字符串时,取全局配置,如果不想投影设置为none即可.
*/
shadow: {
type: [String, Number, Array<string>],
default: '',
validator: (val: string | number | string[]) => {
if (Array.isArray(val)) {
if (val.length != 4) {
console.error("tmui提醒:shadow 第一个x,第二为y,第三个为大小 ,第四个为投影颜色");
return false;
}
}
return true;
}
}
})
const _attrs = computed(() => attrs);
const OnClick = (evt: MouseEvent) => {
if (_attrs.value.loading) return;
emits('click', evt)
}
const _width = computed(() => {
return covetUniNumber(attrs.width || 'auto')
})
const _height = computed(() => covetUniNumber(attrs.height || 'auto'))
const isHover = ref(false);
const touchStart = () => {
isHover.value = true;
}
const touchEnd = () => {
isHover.value = false;
}
const buttonStyle = computed(() => {
let style = {
borderColor: ``,
borderWidth: ``,
borderStyle: ``,
background: ``,
color: ``,
borderRadius: ``,
boxShadow: `none`,
margin: '0px',
padding: '0px'
}
let isDark = config.mode == 'dark';
let background = attrs.color
let borderColor = arrayNumberValidByStyleBorderColor(attrs.borderColor)
let borderWidth = arrayNumberValidByStyleMP(attrs.borderWidth)
let borderStyle = arrayNumberValidByStyleBorderStyle(attrs.borderStyle)
let borderRadius = arrayNumberValidByStyleMP(attrs.round || config.sheetRadius)
let margin = arrayNumberValidByStyleMP(attrs.margin === '' ? config.sheetMargin : attrs.margin)
let padding = arrayNumberValidByStyleMP(attrs.padding === '' ? config.sheetPadding : attrs.padding)
let linear = linearValid(attrs.linear)
if (attrs.flowTheme) {
background = config.color
}
if (isDark) {
background = attrs.darkBgColor || config.sheetDarkColor
}
let shadow = 'none'
if (Array.isArray(attrs.shadow)) {
shadow = attrs.shadow.join(' ')
} else if (attrs.shadow !== '' && attrs.shadow != 'none') {
let obj = getDefaultColorObj(attrs.color, attrs.color)
shadow = `0px 0px ${covetUniNumber(attrs.shadow)} ${obj.default.shadow}`
} else if (attrs.shadow === '') {
if (Array.isArray(config.sheetShadow)) {
shadow = config.sheetShadow.join(' ')
} else if (config.sheetShadow && config.sheetShadow != 'none') {
let obj = getDefaultColorObj(attrs.color, attrs.color)
shadow = `0 4px ${covetUniNumber(config.sheetShadow)} ${obj.default.shadow}`
}
}
style.background = linear || getDefaultColor(background)
style.borderColor = borderColor.join(' ')
style.borderWidth = borderWidth.join(' ')
style.borderStyle = borderStyle.join(' ')
style.borderRadius = borderRadius.join(' ')
style.margin = margin.join(' ')
style.padding = padding.join(' ')
style.boxShadow = shadow
return style;
});
</script>
<script lang="ts">
export default {
options: {
styleIsolation: "apply-shared",
virtualHost: true,
addGlobalClass: true,
multipleSlots: true,
},
};
</script>
<style lang="scss" scoped>
.tmSheet {
position: relative;
.tmSheetLoading {
display: flex;
border: 0px solid transparent;
width: 100%;
height: 100%;
position: absolute;
top: 0px;
left: 0px;
padding: 0px;
margin: 0px;
backdrop-filter: blur(3px);
background-color: rgba(125, 125, 125, 0.2);
justify-content: center;
align-items: center;
flex-direction: row;
}
}
</style>
+1
View File
@@ -0,0 +1 @@
@font-face{font-family:remixicon;src:url(data:application/font-woff2;base64,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 truncated
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["tmui.scss","tmui.css"],"names":[],"mappings":"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XE;EAME,yCAAA;ACuXJ;;AD7XE;EAME,yCAAA;AC2XJ;;ADjYE;EAME,yCAAA;AC+XJ;;ADrYE;EAME,yCAAA;ACmYJ;;ADzYE;EAME,yCAAA;ACuYJ;;AD7YE;EAME,yCAAA;AC2YJ;;ADjZE;EAME,yCAAA;AC+YJ;;ADrZE;EAME,yCAAA;ACmZJ;;ADzZE;EAME,yCAAA;ACuZJ;;AD7ZE;EAME,yCAAA;AC2ZJ;;ADjaE;EAME,yCAAA;AC+ZJ;;ADraE;EAME,yCAAA;ACmaJ;;ADzaE;EAME,yCAAA;ACuaJ;;AD7aE;EAME,yCAAA;AC2aJ;;ADjbE;EAME,yCAAA;AC+aJ;;ADrbE;EAME,yCAAA;ACmbJ;;ADzbE;EAME,yCAAA;ACubJ;;AD7bE;EAME,yCAAA;AC2bJ;;ADjcE;EAME,yCAAA;AC+bJ;;ADrcE;EAME,0CAAA;ACmcJ;;ADzcE;EAUE,0CAAA;ACmcJ;;AD7cE;EAUE,0CAAA;ACucJ;;ADjdE;EAUE,0CAAA;AC2cJ;;ADrdE;EAUE,2CAAA;AC+cJ;;ADzdE;EAUE,2CAAA;ACmdJ;;AD7dE;EAUE,2CAAA;ACudJ;;ADjeE;EAUE,2CAAA;AC2dJ;;ADreE;EAUE,2CAAA;AC+dJ;;ADzeE;EAUE,2CAAA;ACmeJ;;AD7eE;EAUE,2CAAA;ACueJ;;ADjfE;EAUE,2CAAA;AC2eJ;;ADrfE;EAUE,2CAAA;AC+eJ;;ADzfE;EAUE,2CAAA;ACmfJ;;AD7fE;EAUE,2CAAA;ACufJ;;ADjgBE;EAUE,2CAAA;AC2fJ;;ADrgBE;EAUE,2CAAA;AC+fJ;;ADzgBE;EAUE,2CAAA;ACmgBJ;;AD7gBE;EAUE,2CAAA;ACugBJ;;ADjhBE;EAUE,2CAAA;AC2gBJ;;ADrhBE;EAUE,2CAAA;AC+gBJ;;ADzhBE;EAUE,2CAAA;ACmhBJ;;AD7hBE;EAUE,2CAAA;ACuhBJ;;ADjiBE;EAUE,2CAAA;AC2hBJ;;ADriBE;EAUE,2CAAA;AC+hBJ;;ADziBE;EAUE,2CAAA;ACmiBJ;;AD7iBE;EAUE,4CAAA;ACuiBJ;;ADjjBE;EAcE,2CAAA;ACuiBJ;;ADrjBE;EAcE,2CAAA;AC2iBJ;;ADzjBE;EAcE,2CAAA;AC+iBJ;;AD7jBE;EAcE,4CAAA;ACmjBJ;;ADjkBE;EAcE,4CAAA;ACujBJ;;ADrkBE;EAcE,4CAAA;AC2jBJ;;ADzkBE;EAcE,4CAAA;AC+jBJ;;AD7kBE;EAcE,4CAAA;ACmkBLine truncated
+11
View File
@@ -0,0 +1,11 @@
export default {
/**
* tmui3.2.x
* @param app Vue
* @param options tmui3.0配置
*/
// @ts-ignore
install: (app: App, options: TM.THEME_CONFIG | null = null) => {
}
};
+148
View File
@@ -0,0 +1,148 @@
/**
* 上传的文件状态
* 0未上传,1上传中,2上传成功,3上传失败(网络错误),4已取消(终止,上到一半取消了),5超过大小
* 0 Not uploaded, 1 Uploading, 2 Upload successful, 3 Upload failed (network error), 4 Cancelled (terminated, canceled halfway), 5 Exceeds size limit,
*/
export enum TMUPLOAD_PHOTO_STATUS {
NOT = 0,
UPLOADING = 1,
UPLOAD_SUCCESS = 2,
UPLOAD_FAILED = 3,
CANCELLED = 4,
EXCEEDS_SIZE_LIMIT = 5,
}
declare global {
/**
* TMUI3.2.x类型命名空间
* @author tmzdy
* @date 2024-8-1
*/
namespace TM {
/** 按钮尺寸大小 */
export type BUTTON_SIZE = "xs" | "s" | "m" | "n" | "g"
/** 按钮或者标签的主题样式 */
export type BUTTON_THEME = "thin" | "outlined" | "text" | "normal" | "dashed"
/** 主题状态 */
export type THEME_STATUS = "warn" | "success" | "info" | "error" | "error"
/** 主题样式 */
export type THEME_MODE = "auto" | "dark" | "light"
export interface THEME_CONFIG {
/** 主题模式 */
mode: THEME_MODE,
color: string,
unit: 'rpx' | 'px',
// 默认相对字号
fontSizeBase: string | number,
// 默认相对字号fontSizeBase倍数
fontSizeScale: 1,
buttonRadius: number | string,
/** button默认的投影大小,none全局投影取消,但不影响局部值 */
buttonShadow: number | string | Array<string>,
sheetRadius: number | string | Array<string | number>,
sheetShadow: number | string | Array<string>,
sheetMargin: number | string | Array<string | number>,
sheetPadding: number | string | Array<string | number>,
//列表默认的圆角。
cellRadius: number | string | Array<string | number>,
/** 一般容器sheet的暗黑背景 */
sheetDarkColor: string,
/** 一般容器边线颜色如果你布局自己写了默认的边线色,但忘记填写组件上的暗黑线时,会取这里的值 */
sheetDarkBorderColor: string[],
/** 输入框,表单,常规灰背景待统一的二级暗黑背景 */
inputDarkColor: string,
/**输入框默认全局的亮系背景. */
inputLightColor: string,
/**输入框全局圆角 */
inputRadius: number | string | Array<string | number>,
/** 一般的表单,列表底的暗黑统一边线颜色 */
borderDarkColor: string,
/**对话框圆角 */
modalRadius: number | string,
/**形状圆角 */
switchRadius: number | string,
/**抽屉圆角 */
drawerRadius: number | string,
unRadioAndCheckBoxColor: string,
/**部分组件统一的动画差值。 */
animation: string,
}
export type tmDateDayInfoType = {
/** 年 */
year: number,
/** 月0-11 */
month: number,
/** 天 */
day: number,
/** 小时0-23 */
hours: number,
/** 分钟0-59 */
minutes: number,
/** 秒0-59 */
seconds: number,
/** 本年的第几周 */
weeks: number,
/** 本周星期几 */
week: number,
/** 本周星期几,中文名称 */
weekCn: string,
/** 格式化的日期字符串 */
date: string,
/** 是否渲染显示 */
isRender?: boolean
}
/**
* 上传的文件类型
*/
export type TMUPLOAD_PHOTO_INFO = {
id: string,
/** 文件大小 */
size?: number,
/** 文件路径 */
path: string,
progress: number,
statusText: string,
status: TMUPLOAD_PHOTO_STATUS,
response: any
}
export type FORM_RULE_TYPE = {
/** 校验正则,如果同时提供了valid,以valid为准,默认为null */
rule: RegExp|null,
/** 校验失败提示,默认提示词:请正在选择/填写 */
message: string,
/**校验函数,如果不提供,默认以min,max来判断. */
validator: null|((val: any) => boolean),
/**
* 如果校验的值类型是数字是起作用,默认为1,至少要大于0
* 如果预期是数组,那长度至少要大于0
* 如果是字符串,长度要大于0
*/
min: number,
//默认为-1最长/大值不限制
max: number,
//预期值的类型,如果不提供自动推断,不提供默认是auto
type: "string" | "number" | "array" | "boolean" | "date" | "email" | "phone"|"auto",
//必填项,是否校验本值
required: boolean
}
export type FORM_RULE = { [Property in keyof FORM_RULE_TYPE]?: FORM_RULE_TYPE[Property] } & { required: boolean }
export type FORM_RESULT_TYPE = { key: string, isPass: boolean, message: string, data: any }
export type FORM_SUBMIT_RESULT = {
/**是否校验通过 */
isPass: boolean,
/**校验不通过的第一条信息 */
firstValid: FORM_RESULT_TYPE | null,
/**校验结果集 */
result: FORM_RESULT_TYPE[]
}
}
}
export { }
+684
View File
@@ -0,0 +1,684 @@
type rgb = {
r: number,
g: number,
b: number,
a: number
}
type hsla = {
h: number,
s: number,
l: number,
a: number
}
/**
* 颜色值列表
*/
export const colors = new Map<string, string>([
['primary', '#0579FF'],
['success', '#07d73d'],
['danger', '#ff6411'],
['warn', '#ffa61e'],
['error', '#f10226'],
['info', '#F2F2F2'],
['kleinblue', '#002FA7'], // 克莱因蓝
['chinesered', '#FF0000'], // 中国红
['internationalorange', '#FF4F00'], // 国际橙
['egyptianvlue', '#1034A6'], // 埃及艳蓝
['parisviolet', '#6C3082'], // 巴黎紫
['moroccanblue', '#1256A7'], // 摩洛哥蓝
['brazilgreen', '#009B3A'], // 巴西绿
['britishracinggreen', '#004225'], // 英国赛车绿
['indianyellow', '#E3A857'], // 印度黄
['australiangold', '#FFDF00'], // 澳大利亚金
['venetianred', '#C80815'], // 威尼斯红
['majorelleblue', '#6050DC'], // 马若雷蓝
['tuscanred', '#7C3030'], // 托斯卡红
['naplesyellow', '#FADA5E'], // 那不勒斯黄
['capumortuum', '#592720'], // 死者之首
['mayablue', '#73C2FB'], // 玛雅蓝
['persianrose', '#FE28A2'], // 波斯玫瑰
['tyrianpurple', '#66023C'], // 泰尔紫
['saharasand', '#F1E788'], // 撒哈拉沙
['burmeseruby', '#B00A0A'], // 缅甸红宝石
['transparent', 'rgba(0,0,0,0)'],
['aliceblue', '#F0F8FF'],
['antiquewhite', '#FAEBD7'],
['aqua', '#00FFFF'],
['aquamarine', '#7FFFD4'],
['azure', '#F0FFFF'],
['beige', '#F5F5DC'],
['bisque', '#FFE4C4'],
['black', '#000000'],
['blanchedalmond', '#FFEBCD'],
['blue', '#0000FF'],
['blueviolet', '#8A2BE2'],
['brown', '#A52A2A'],
['burlywood', '#DEB887'],
['cadetblue', '#5F9EA0'],
['chartreuse', '#7FFF00'],
['chocolate', '#D2691E'],
['coral', '#FF7F50'],
['cornflowerblue', '#6495ED'],
['cornsilk', '#FFF8DC'],
['crimson', '#DC143C'],
['cyan', '#00FFFF'],
['darkblue', '#00008B'],
['darkcyan', '#008B8B'],
['darkgoldenrod', '#B8860B'],
['darkgray', '#A9A9A9'],
['darkgreen', '#006400'],
['darkkhaki', '#BDB76B'],
['darkmagenta', '#8B008B'],
['darkolivegreen', '#556B2F'],
['darkorange', '#FF8C00'],
['darkorchid', '#9932CC'],
['darkred', '#8B0000'],
['darksalmon', '#E9967A'],
['darkseagreen', '#8FBC8F'],
['darkslateblue', '#483D8B'],
['darkslategray', '#2F4F4F'],
['darkturquoise', '#00CED1'],
['darkviolet', '#9400D3'],
['deeppink', '#FF1493'],
['deepskyblue', '#00BFFF'],
['dimgray', '#696969'],
['dodgerblue', '#1E90FF'],
['firebrick', '#B22222'],
['floralwhite', '#FFFAF0'],
['forestgreen', '#228B22'],
['fuchsia', '#FF00FF'],
['gainsboro', '#DCDCDC'],
['ghostwhite', '#F8F8FF'],
['gold', '#FFD700'],
['goldenrod', '#DAA520'],
['gray', '#808080'],
['green', '#008000'],
['greenyellow', '#ADFF2F'],
['honeydew', '#F0FFF0'],
['hotpink', '#FF69B4'],
['indianred', '#CD5C5C'],
['indigo', '#4B0082'],
['ivory', '#FFFFF0'],
['khaki', '#F0E68C'],
['lavender', '#E6E6FA'],
['lavenderblush', '#FFF0F5'],
['lawngreen', '#7CFC00'],
['lemonchiffon', '#FFFACD'],
['lightblue', '#ADD8E6'],
['lightcoral', '#F08080'],
['lightcyan', '#E0FFFF'],
['lightgoldenrodyellow', '#FAFAD2'],
['lightgray', '#D3D3D3'],
['lightgreen', '#90EE90'],
['lightpink', '#FFB6C1'],
['lightsalmon', '#FFA07A'],
['lightseagreen', '#20B2AA'],
['lightskyblue', '#87CEFA'],
['lightslategray', '#778899'],
['lightsteelblue', '#B0C4DE'],
['lightyellow', '#FFFFE0'],
['lime', '#00FF00'],
['limegreen', '#32CD32'],
['linen', '#FAF0E6'],
['magenta', '#FF00FF'],
['maroon', '#800000'],
['mediumaquamarine', '#66CDAA'],
['mediumblue', '#0000CD'],
['mediumorchid', '#BA55D3'],
['mediumpurple', '#9370DB'],
['mediumseagreen', '#3CB371'],
['mediumslateblue', '#7B68EE'],
['mediumspringgreen', '#00FA9A'],
['mediumturquoise', '#48D1CC'],
['mediumvioletred', '#C71585'],
['midnightblue', '#191970'],
['mintcream', '#F5FFFA'],
['mistyrose', '#FFE4E1'],
['moccasin', '#FFE4B5'],
['navajowhite', '#FFDEAD'],
['navy', '#000080'],
['oldlace', '#FDF5E6'],
['olive', '#808000'],
['olivedrab', '#6B8E23'],
['orange', '#FFA500'],
['orangered', '#FF4500'],
['orchid', '#DA70D6'],
['palegoldenrod', '#EEE8AA'],
['palegreen', '#98FB98'],
['paleturquoise', '#AFEEEE'],
['palevioletred', '#DB7093'],
['papayawhip', '#FFEFD5'],
['peachpuff', '#FFDAB9'],
['peru', '#CD853F'],
['pink', '#FFC0CB'],
['plum', '#DDA0DD'],
['powderblue', '#B0E0E6'],
['purple', '#800080'],
['rebeccapurple', '#663399'],
['red', '#FF0000'],
['rosybrown', '#BC8F8F'],
['royalblue', '#4169E1'],
['saddlebrown', '#8B4513'],
['salmon', '#FA8072'],
['sandybrown', '#F4A460'],
['seagreen', '#2E8B57'],
['seashell', '#FFF5EE'],
['sienna', '#A0522D'],
['silver', '#C0C0C0'],
['skyblue', '#87CEEB'],
['slateblue', '#6A5ACD'],
['slategray', '#708090'],
['snow', '#FFFAFA'],
['springgreen', '#00FF7F'],
['steelblue', '#4682B4'],
['tan', '#D2B48C'],
['teal', '#008080'],
['thistle', '#D8BFD8'],
['tomato', '#FF6347'],
['turquoise', '#40E0D0'],
['violet', '#EE82EE'],
['wheat', '#F5DEB3'],
['white', '#FFFFFF'],
['whitesmoke', '#F5F5F5'],
['yellow', '#FFFF00'],
['yellowgreen', '#9ACD32']
])
export const toHex = (n: number): string => {
let hex = n.toString(16);
if (hex.length == 1) {
return '0' + hex;
} else {
return hex;
}
}
export function isValidColor(color: string): boolean {
// return chroma.valid(color);
const hexRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3}|[A-Fa-f0-9]{8})$/;
const rgbRegex = /^rgb\((\d{1,3}), (\d{1,3}), (\d{1,3})\)$/;
const rgbaRegex = /^rgba\((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\s*,\s*(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\s*,\s*(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\s*,\s*((1(\.0{1,2})?)|(0(\.\d{1,2})?))\)$/;
const hslRegex = /^hsl\((\d{1,3}), (\d{1,3})%, (\d{1,3})%\)$/;
const hslaRegex = /^hsla\(\s*((\d{1,2}|[1-2]\d{2}|3[0-5]\d)(\.\d+)?|\d+(\.\d+)?)\s*,\s*((\d{1,2}|\d{0,1}\d{1}\d{1}|[1-2]\d{2}|3[0-5]\d)(\.\d+)?|\d+(\.\d+)?)%\s*,\s*((\d{1,2}|\d{0,1}\d{1}\d{1}|[1-2]\d{2}|3[0-5]\d)(\.\d+)?|\d+(\.\d+)?)%\s*,\s*((1|0(\.\d{1,2})?|(\.\d{1,2})))\)$/i;
if (color == '') {
return false; // 颜色值为空
} else if (color === 'inherit' || color === 'transparent') {
return false; // 特殊颜色值
} else if (color === 'currentColor') {
return false; // currentColor 不是有效的颜色值
} else if (hexRegex.test(color) || rgbRegex.test(color) || rgbaRegex.test(color) || hslRegex.test(color) || hslaRegex.test(color)) {
return true; // 符合颜色值的格式
}
return false;
}
/**
* 获取默认的颜色值
* @param [string] 颜色名称或者16进制颜色值
*/
export function getDefaultColor(sColor: string): string {
if (sColor == "") return ""
let sc = sColor.toLocaleLowerCase().trim().replace(" ", "");
if (isValidColor(sc)) {
// 如果符合所有颜色值,进行转换为16进制。css方便统一解析。
return sc;
}
// 检测是否是颜色名称。
let sco = colors.get(sc);
if (typeof sco == 'string') return sco as string;
return colors.get("primary")!;
}
/**
* 颜色转rgb
*/
export function hexToRgb(sColors: string) {
if (sColors == "") {
return { r: 0, g: 0, b: 0, a: 0 }
}
let reg = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3}|[A-Fa-f0-9]{8})$/;
let sColor: string = sColors.toLowerCase();
if (sColor != '' && reg.test(sColor)) {
if (sColor.length == 4) {
let sColorNew = "#";
for (let i = 1; i < 4; i += 1) {
sColorNew += sColor.slice(i, i + 1).concat(sColor.slice(i, i + 1));
}
sColor = sColorNew;
}
//处理六位的颜色值
let sColorChange: number[] = [];
sColorChange.push(parseInt(sColor.substring(1, 3), 16));
sColorChange.push(parseInt(sColor.substring(3, 5), 16));
sColorChange.push(parseInt(sColor.substring(5, 7), 16));
if (sColor.length == 9) {
sColorChange.push(parseInt(sColor.substring(7, 9), 16) / 255);
}
return {
r: sColorChange[0],
g: sColorChange[1],
b: sColorChange[2],
a: sColorChange.length == 4 ? sColorChange[3] : 1
}
} else if (/^(rgb|RGB|rgba|RGBA)/.test(sColor)) {
let arr: string[] = sColor.replace(/(?:\(|\)|rgba|rgb|RGB|RGBA)*/g, "").split(",")
let p: number[] = arr.map((val: string): number => parseInt(val));
if (p.length < 3) {
return {
r: 0,
g: 0,
b: 0,
a: 1
}
}
if (p.length == 3) {
p.push(1)
}
return {
r: p[0],
g: p[1],
b: p[2],
a: arr.length == 3 ? p[3] : parseFloat(arr[3])
}
} else {
return {
r: 0,
g: 0,
b: 0,
a: 1
}
}
}
/**
* rgb转hsl
*/
export function rgbToHsl(rgb: rgb): hsla {
let r = rgb.r / 255
let g = rgb.g / 255
let b = rgb.b / 255
let a = rgb.a
var max = Math.max(r, g, b);
var min = Math.min(r, g, b);
let h = 60 * (4 + (r - g) / (max - min));
let sdy = Math.max(2 - max - min,1)
let s=(max - min) / sdy;
let l = (max + min) / 2;
if (max === r) {
h = (60 * (g - b)) / (max - min);
} else if (max === g) {
h = 60 * (2 + (b - r) / (max - min));
}
if (h < 0) {
h += 360;
}
if (max === min) {
s = 0;
} else if (l < 0.5) {
s = (max - min) / (max + min);
}
return { h: h, s: s * 100, l: l * 100, a: a };
}
/**
* hsl转rgb
*/
export function hslToRgb(hsl: hsla): rgb {
let h = hsl.h
let s = hsl.s
let l = hsl.l
let a = hsl.a
h = h / 360;
s = s / 100;
l = l / 100;
let r = l;
let g = l;
let b = l;
function hue2rgb(pxx: number, q: number, txs: number): number {
let t = txs;
let p = pxx;
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
}
if (s > 0) {
let q = l * (1 + s);
if (l >= 0.5) {
q = l + s - l * s;
}
var p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
r = Math.round(r * 255);
g = Math.round(g * 255);
b = Math.round(b * 255);
return { r, g, b, a };
}
/**
* rgb转16进制
*/
export function rgbToHex(rgb: rgb): string {
let r = rgb.r
let g = rgb.g
let b = rgb.b
let a = rgb.a
return "#" + toHex(r) + toHex(g) + toHex(b) + toHex(a * 255)
}
export function hslaToCss(hsl: hsla): string {
let rgb = hslToRgb(hsl)
return rgbToHex(rgb)
}
export function hslaToRgbCss(hsl: hsla): string {
let rgb = hslToRgb(hsl)
let r = rgb.r
let g = rgb.g
let b = rgb.b
let a = rgb.a
return `rgba(${r},${g},${b},${a})`
}
// 根据 HSLA 颜色计算亮度
export function getLuminance(color: rgb | hsla, type = "rgba"): number {
let colordefault: rgb = (type == 'rgba' ? color : hslToRgb(color as hsla)) as rgb;
const r = colordefault.r
const g = colordefault.g
const b = colordefault.b
const rs = r / 255
const gs = g / 255
const bs = b / 255
const rl = Math.pow(rs <= 0.03928 ? rs / 12.92 : ((rs + 0.055) / 1.055), 2.4);
const gl = Math.pow(gs <= 0.03928 ? gs / 12.92 : ((gs + 0.055) / 1.055), 2.4);
const bl = Math.pow(bs <= 0.03928 ? bs / 12.92 : ((bs + 0.055) / 1.055), 2.4);
return 0.2126 * rl + 0.7152 * gl + 0.0722 * bl;
}
/**
* 对颜色加深
*/
export function colorAddDeepen(sColor: string): string {
let rgb = hexToRgb(getDefaultColor(sColor));
let hsl = rgbToHsl(rgb);
let l = hsl.l - 5;
l = Math.max(0, Math.min(l, 100))
return hslaToRgbCss({ h: hsl.h, s: hsl.s, l, a: hsl.a })
}
/**
* 对颜色提亮
*/
export function colorAddBrighten(sColor: string): string {
let rgb = hexToRgb(getDefaultColor(sColor));
let hsl = rgbToHsl(rgb);
let l = hsl.l + 10;
l = Math.max(0, Math.min(l, 100))
return rgbToHex(hslToRgb({ h: hsl.h, s: hsl.s, l, a: hsl.a }))
}
/**
* 获取浅色系主题,根据颜色值计算。返回正常值和hover值。
*/
export function colorGetThint(sColor: string) {
let rgb = hexToRgb(getDefaultColor(sColor));
let hsl = rgbToHsl(rgb);
let s = hsl.s;
// let l = hsl.getNumber("l");
if (Math.ceil(s) > 0) {
s = 45
}
return {
default: rgbToHex(hslToRgb({ h: hsl.h, s, l: 93, a: hsl.a })),
hover: rgbToHex(hslToRgb({ h: hsl.h, s, l: 85, a: hsl.a }))
}
}
/**
* 获取正常主题,根据颜色值计算。返回正常值和hover值。
*/
export function colorGetHover(sColor: string) {
return {
default: sColor,
hover: colorAddDeepen(sColor)
}
}
/**
* 处理相主题色。
* @param color 需要处理的颜色值
* @param hoverColor 对应按下去的颜色默认与color相等。
* @returns UTSJSONObject
*/
export function getDefaultColorObj(color: string, hoverColor: string) {
let hsla = rgbToHsl(hexToRgb(getDefaultColor(color)));
let hoverHsla = rgbToHsl(hexToRgb(getDefaultColor(hoverColor)));
let shadow = hslaToRgbCss({ h: hsla.h, s: (hsla.s) == 0 ? 0 : 100, l: 50, a: 0.2 });
let shadowHOver = hslaToRgbCss({ h: hoverHsla.h, s: (hoverHsla.s) == 0 ? 0 : 70, l: 50, a: 0.15 });
if ((hsla.h) == 0 && (hsla.s) == 0) {
shadow = "transparent"
}
if ((hoverHsla.h) == 0 && (hoverHsla.s) == 0) {
shadowHOver = "transparent"
}
let lightnum = getLuminance(hoverHsla, 'hsla');
let o = {
default: {
background: hslaToRgbCss(hsla),
borderColor: hslaToRgbCss({ h: hsla.h, s: hsla.s, a: hsla.a, l: Math.min(Math.max(0, hsla.l - 5), hsla.l) }),
fontColor: lightnum < 0.6 && hsla.a > 0.1 ? '#ffffff' : '#000000',
shadow: shadow
},
active: {
background: hslaToRgbCss({ h: hoverHsla.h, s: hoverHsla.s, a: hoverHsla.a, l: Math.min(Math.max(0, hoverHsla.l - 10), hoverHsla.l) }),
borderColor: hslaToRgbCss({ h: hoverHsla.h, s: hoverHsla.s, a: hoverHsla.a, l: Math.max(Math.max(0, hoverHsla.l - 10), hoverHsla.l) }),
fontColor: lightnum < 0.6 && hoverHsla.a > 0.1 ? '#ffffff' : '#000000',
shadow: shadowHOver
}
}
return o;
}
/**
* 处理相主题色。
* @param color 需要处理的颜色值
* @param hoverColor 对应按下去的颜色默认与color相等。
* @returns UTSJSONObject
*/
export function getTextColorObj(color: string, hoverColor: string, isCoverDark?: boolean) {
let dark = isCoverDark == null ? false : isCoverDark
// #ifdef WEB
dark = isCoverDark == undefined ? false : dark
// #endif
let hsla = rgbToHsl(hexToRgb(getDefaultColor(color)));
let hsla2 = rgbToHsl(hexToRgb(getDefaultColor(color)));
let hoverHsla = rgbToHsl(hexToRgb(getDefaultColor(hoverColor)));
let bgcolor = hslaToRgbCss({ h: hoverHsla.h, s: hoverHsla.s, a: hoverHsla.a, l: dark == true ? 20 : 95 })
if (dark) {
let p = hsla2;
p.l = 20
p.s = (p.s) != 0 ? 0 : 20
bgcolor = hslaToRgbCss(p)
}
let o = {
default: {
background: "transparent",
borderColor: "transparent",
fontColor: getLuminance(hsla, 'hsla') < 0.3 && hsla.a > 0.1 ? hslaToRgbCss(hsla) : hslaToRgbCss({ h: hsla.h, s: hsla.s, a: hsla.a, l: 30 }),
shadow: "transparent",
},
active: {
background: bgcolor,
borderColor: "transparent",
fontColor: getLuminance(hoverHsla, 'hsla') < 0.3 && hoverHsla.a > 0.1 ? hslaToRgbCss(hoverHsla) : hslaToRgbCss({ h: hoverHsla.h, s: hoverHsla.s, a: hoverHsla.a, l: 30 }),
shadow: "transparent",
}
}
return o;
}
/**
* 处理相主题色。
* @param color 需要处理的颜色值
* @param hoverColor 对应按下去的颜色默认与color相等。
* @returns UTSJSONObject
*/
export function getThinColorObj(color: string, hoverColor: string, isCoverDark?: boolean) {
let dark = isCoverDark == null ? false : isCoverDark
// #ifdef WEB
dark = isCoverDark == undefined ? false : dark
// #endif
let hsla = rgbToHsl(hexToRgb(getDefaultColor(color)));
let hoverHsla = rgbToHsl(hexToRgb(getDefaultColor(hoverColor)));
// tmxColor.hslaToCss({ ...hsla}) : tmxColor.hslaToCss({ ...hsla,l:30})
let fontcolor = getLuminance(hsla, 'hsla') < 0.3 && hsla.a > 0.1 ? hslaToRgbCss(hsla) : hslaToRgbCss({ h: hsla.h, s: hsla.s, a: hsla.a, l: 30 })
let bordercolor = hslaToRgbCss({ h: hoverHsla.h, s: hoverHsla.s, a: hoverHsla.a, l: dark == true ? 21 : 92 })
let bgcolor = hslaToRgbCss({ h: hoverHsla.h, s: hoverHsla.s, a: hoverHsla.a, l: dark == true ? 20 : 95 })
if (dark) {
let p = hsla;
p.l = 98
fontcolor = hslaToRgbCss(p)
p.l = 20
p.s = (p.s) != 0 ? 5 : 20
bgcolor = hslaToRgbCss(p)
p.l = 22
bordercolor = hslaToRgbCss(p)
}
let o = {
default: {
background: bgcolor,
borderColor: bordercolor,
fontColor: fontcolor,
shadow: "transparent",
},
active: {
background: hslaToRgbCss({ h: hoverHsla.h, s: dark ? 10 : 50, a: hoverHsla.a, l: dark == true ? 15 : 88 }),
borderColor: hslaToRgbCss({ h: hoverHsla.h, s: hoverHsla.s, a: 0, l: dark == true ? 18 : 80 }),
fontColor: getLuminance(hoverHsla, 'hsla') < 0.3 && hoverHsla.a > 0.1 ? hslaToRgbCss(hoverHsla) : hslaToRgbCss({ h: hoverHsla.h, s: hoverHsla.s, a: hoverHsla.a, l: 64 }),
shadow: "transparent",
}
}
return o;
}
/**
* 处理相主题色。
* @param color 需要处理的颜色值
* @param hoverColor 对应按下去的颜色默认与color相等。
* @returns UTSJSONObject
*/
export function getOutlineColorObj(color: string, hoverColor: string) {
let hsla = rgbToHsl(hexToRgb(getDefaultColor(color)));
let hoverHsla = rgbToHsl(hexToRgb(getDefaultColor(hoverColor)));
// tmxColor.hslaToCss({ ...hsla}) : tmxColor.hslaToCss({ ...hsla,l:30})
let lightnum = getLuminance(hoverHsla, 'hsla');
let o = {
default: {
background: 'transparent',
borderColor: hslaToRgbCss({ h: hoverHsla.h, s: hoverHsla.s, a: 1, l: 75 }),
fontColor: lightnum < 0.6 && hsla.a > 0.1 ? hslaToRgbCss(hsla) : hslaToRgbCss({ h: hsla.h, s: hsla.s, a: hsla.a, l: 30 }),
shadow: "transparent",
},
active: {
background: 'transparent',
borderColor: hslaToRgbCss({ h: hoverHsla.h, s: hoverHsla.s, a: 1, l: 70 }),
fontColor: lightnum < 0.6 && hoverHsla.a > 0.1 ? hslaToRgbCss(hoverHsla) : hslaToRgbCss({ h: hoverHsla.h, s: hoverHsla.s, a: hoverHsla.a, l: 30 }),
shadow: "transparent",
}
}
return o;
}
/**
* 设置颜色的亮度值。29
* isCoverDark:是根据亮度值,是否要设置n的值,如果n超过了
* 指定的30就不操作。
*/
export function setTextColorLightByDark(color: string): string {
let realColor = getDefaultColor(color);
let hsla = rgbToHsl(hexToRgb(realColor))
let l = hsla.l
let s = hsla.s
if (l < 30) {
if (s > 0) {
hsla.l = 50
} else {
hsla.l = 100
}
}
return hslaToRgbCss(hsla)
}
/**
* 变动的颜色是否需要变深。如果不需要返回原始颜色
* 如果果需要自动加深。
*/
export function setBgColorLightByDark(color: string): string {
let realColor = getDefaultColor(color);
let hsla = rgbToHsl(hexToRgb(realColor))
let l = hsla.l
let s = hsla.s
if (s == 0 && l > 50) {
hsla.l = 100 - l
}
if (s > 0) {
if (l > 50) {
hsla.l = 100 - l
}
}
return hslaToRgbCss(hsla)
}
export function isBlackAndWhite(color: string): boolean {
let realColor = getDefaultColor(color);
let hsla = rgbToHsl(hexToRgb(realColor))
let s = hsla.s
return s == 0
}
+47
View File
@@ -0,0 +1,47 @@
import { defineStore } from 'pinia';
export const useTmConfig = defineStore('tmConfig', {
state: () => {
return {
config: {
mode: 'light',
unit: 'rpx',
fontSizeBase: '28',
fontSizeScale: 1,
buttonRadius: 24,
buttonShadow: 'none',
color: "primary",
sheetRadius: 24,
sheetMargin: [20, 0, 20, 20],
sheetPadding: [24],
sheetShadow: 'none',
cellRadius: 24,
sheetDarkColor: '#141414',
sheetDarkBorderColor: ["#232323"],
/** 输入框暗黑背景 */
inputDarkColor: '#272727',
inputLightColor: '#f5f5f5',
inputRadius: 24,
borderDarkColor: '#1c1c1c',
unRadioAndCheckBoxColor: "#cfcfcf",
switchRadius:64,
modalRadius: 32,
drawerRadius:32,
animation: 'cubic-bezier(.42,.38,.15,.93)'
} as TM.THEME_CONFIG
}
},
actions: {
},
});
+156
View File
@@ -0,0 +1,156 @@
import { getDefaultColor } from "./colors";
export const covetUniNumber = (n: number | string, defaultUnit = 'rpx'): string => {
if (typeof n === 'number') {
return `${n}${defaultUnit}`
}
let unit = getUnit(n, defaultUnit);
let zhi = n.match(/\d+(\.\d+)?/g);
let real = '0'
if (zhi) {
real = zhi[0]
}
if (unit === 'rpx' && defaultUnit == 'px') {
return uni.upx2px(Number(real)).toString() + unit;
}
if (unit == 'auto') return unit;
return real + unit;
}
export const getUnit = (n: string | number, defaultUnit = 'rpx'): string => {
if (typeof n === 'number') {
return defaultUnit;
}
const unit = n.match(/[a-zA-Z|%|auto]+/g);
if (!unit || !Array.isArray(unit) || unit?.length === 0) return defaultUnit;
return unit[0];
}
/**
* 随机一个uid
* @param rdix 随机因子
* @param length 取的长度
* @param isAddStr 是否限制随机结果中的长度,不允许输出长度
* @returns String
*/
export function getUid(rdix = 1, length = 12): string {
let ix = Math.floor(Math.random() * rdix * Math.floor(Math.random() * Date.now())).toString().substring(0, length);
return ix;
}
export const linearValid = (linear: string[]): string => {
if (linear.length < 3) return '';
return `linear-gradient(${linear.join(',')})`
}
export const arrayNumberValid = (border: string | number | Array<string | number>, defaultValue = '0px'): string[] => {
let zhi: string[] = Array.isArray(border) ? border.map((item: string | number): string => covetUniNumber(item)) : [covetUniNumber(border || defaultValue)];
if (zhi.length == 0) {
zhi = [covetUniNumber(defaultValue)]
} else {
let diff = 4 - zhi.length;
zhi = [...zhi, ...Array(diff).fill(zhi[0])]
}
return zhi;
}
export const arrayNumber = (border: string | number | Array<string | number>, defaultValue = '0px', len = 2): string[] => {
let zhi: string[] = Array.isArray(border) ? border.map((item: string | number): string => covetUniNumber(item)) : [covetUniNumber(border || defaultValue)];
if (zhi.length == 0) {
zhi = Array(len).fill(covetUniNumber(defaultValue))
} else {
let diff = len - zhi.length;
if (diff < 0) {
zhi = zhi.slice(0, len)
diff = 0
}
zhi = [...zhi, ...Array(diff).fill(zhi[zhi.length - 1])]
}
return zhi;
}
/**
* 用于margin,padding,border-width的校验
*/
export const arrayNumberValidByStyleMP = (border: string | number | Array<string | number>, defaultValue = '0px'): string[] => {
let zhi: string[] = Array.isArray(border) ? border.map((item: string | number): string => covetUniNumber(item)) : [covetUniNumber(border || defaultValue)];
if (zhi.length == 0) {
zhi = [covetUniNumber(defaultValue)]
} else if (zhi.length == 2) {
zhi = [zhi[1], zhi[0]]
} else if (zhi.length == 3) {
// 左,上,右
// [0,1,2]
// 上 右 下 左
zhi = [zhi[1], zhi[2], covetUniNumber(defaultValue), zhi[0]]
} else if (zhi.length == 4) {
zhi = [zhi[1], zhi[2], zhi[3], zhi[0]]
}
return zhi;
}
export const arrayNumberValidByStyleBorderColor = (border: string | Array<string>, defaultValue = 'transparent'): string[] => {
let zhi: string[] = Array.isArray(border) ? border.map((item: string): string => getDefaultColor(item)) : [getDefaultColor(border || defaultValue)];
if (zhi.length == 0) {
zhi = [getDefaultColor(defaultValue)]
} else if (zhi.length == 2) {
zhi = [zhi[1], zhi[0]]
} else if (zhi.length == 3) {
// 左,上,右
// [0,1,2]
// 上 右 下 左
zhi = [zhi[1], zhi[2], getDefaultColor(defaultValue), zhi[0]]
} else if (zhi.length == 4) {
zhi = [zhi[1], zhi[2], zhi[3], zhi[0]]
}
return zhi;
}
export const arrayNumberValidByStyleBorderStyle = (border: string | Array<string>, defaultValue = 'none'): string[] => {
let zhi: string[] = Array.isArray(border) ? border : [border || defaultValue];
if (zhi.length == 0) {
zhi = [defaultValue]
} else if (zhi.length == 2) {
zhi = [zhi[1], zhi[0]]
} else if (zhi.length == 3) {
// 左,上,右
// [0,1,2]
// 上 右 下 左
zhi = [zhi[1], zhi[2], defaultValue, zhi[0]]
} else if (zhi.length == 4) {
zhi = [zhi[1], zhi[2], zhi[3], zhi[0]]
}
return zhi;
}
/**
* 深度克隆
* @param {T} data 待大克隆复制的数据
* @return {T} any
*/
export function deepClone<T>(data: T): T {
// 对常见的“非”值,直接返回原来值
if (data === null || typeof data !== "object") {
return data;
}
if (Array.isArray(data)) {
const clone: any[] = [];
for (const item of data) {
clone.push(deepClone(item));
}
return clone as any;
}
if (data instanceof Date) {
return new Date(data.getTime()) as unknown as T;
}
if (data instanceof RegExp) {
const flags = data.flags;
return new RegExp(data.source, flags) as unknown as T;
}
if (typeof data === "function") {
return data as unknown as T;
}
const clone = {} as T;
for (const key in data) {
if (Object.prototype.hasOwnProperty.call(data, key)) {
clone[key] = deepClone(data[key]);
}
}
return clone;
}
+92
View File
@@ -0,0 +1,92 @@
{
"id": "tm-ui",
"displayName": "tmui3.2.0 vue3 typeScript nvue全平台ui组件库 ",
"version": "3.2.0",
"description": "tmui3.0 高性能、高颜值、多主题、暗黑模式vue3 ui组件库,没有比它更完美!",
"keywords": [
"ui",
"vue3",
"typeScript"
],
"repository": "https://gitee.com/LYTB/tmui-design",
"engines": {
"HBuilderX": "^3.6.18"
},
"dcloudext": {
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"npmurl": "https://www.npmjs.com/package/tmui-cli",
"type": "component-vue"
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y",
"alipay": "n"
},
"client": {
"Vue": {
"vue2": "n",
"vue3": "y"
},
"App": {
"app-vue": "y",
"app-nvue": "n"
},
"H5-mobile": {
"Safari": {
"minVersion": "10"
},
"Android Browser": {
"minVersion": "6"
},
"微信浏览器(Android)": {
"minVersion": "8"
},
"QQ浏览器(Android)": {
"minVersion": "10"
}
},
"H5-pc": {
"Chrome": "y",
"IE": "n",
"Edge": "y",
"Firefox": "y",
"Safari": "y"
},
"小程序": {
"微信": "y",
"阿里": "u",
"百度": "u",
"字节跳动": "u",
"QQ": "u",
"钉钉": "u",
"快手": "u",
"飞书": "u",
"京东": "u"
},
"快应用": {
"华为": "u",
"联盟": "u"
}
}
}
}
}
+74
View File
@@ -0,0 +1,74 @@
# TMUI 3.0 | TMUI4.0
![TMUI 3.0](https://tmui.design/images/logoGreat.png)
[![star](https://gitee.com/LYTB/tmui-design/badge/star.svg?theme=dark)](https://gitee.com/LYTB/tmui-design/stargazers) [![fork](https://gitee.com/LYTB/tmui-design/badge/fork.svg?theme=dark)](https://gitee.com/LYTB/tmui-design/members)
### 声明
**免费开源贡献**是2.0和3.0版本的初衷。
**4.0x我是收费的原生uniappx开发套伯按大量原生插件** 已经有一定的销量,有动力支撑3.0的维护。
从tmui2.0[不维护],3.1.x[不维护],tmui3.2[维护],tmui4.0x[维护]已经和大家度过了5年,感谢大家的支持。实属不容易请大家努力提交3.2.x PR功能让我合并及支持4.0x的维护。
### 各文档链接
| 2.0x vue2 js版本 | 3.0x vue3 ts版本 | 4.0x Uts版本 |
| --- | --- | --- |
| [2.0插件](https://ext.dcloud.net.cn/plugin?id=5949) | [3.0](https://ext.dcloud.net.cn/plugin?id=8372) | [4.0插件](https://ext.dcloud.net.cn/plugin?id=16369) |
### [tmui3.0开发文档](https://tmui.design/)
TMUI3.2.0继承tmui4.0x的规范及标准。目前已经发布3.2.0预览版本组件有点少,但够用。后续补齐。
#### 如果你是之前的3.1.x用户请注意:
* 1. 本版本不向下兼容!!!!!
* 2. 不再兼容nvue平台,请谨慎升级
* 3. 3.1.x及2.0.x版本已经不维护,不需要提bug到仓库,当前仅接受3.2.x的bug和维护及pr
#### 如果你是tmui2.0用户请注意:
这是vue3 setup ts版本不是vue2 js版本。请先学习相关知识。
#### 如果你是TMUI4.0x VIP/赞助用户请注意:
这是app vue,小程序版本,并非uts,它是ts写的。语法与uts是有差异的。但相对来说tmui3.2.x是继承tmui4.0x的
因此属性规范和标准是一样的(很少不一样,我尽量控制一样)。因为appvue,小程序是无dom的因此有些动画,手势没有x流畅
请自行选择用哪个版本写app。你可以用4.0x写原生app然后混合开发webview非必要页面嵌入到你的原生应用中大大提升你的开发效率,并且风格一样,可以对比大厂app做法。
作者发布了tmui-cli工具方便大家更新创建tmui项目。<br>
2022.9.7 更新了cli工具
2024.10.10 更新了 cli工具,如果想创建tmui3.0.x请务必按下面执行。
```npm
npm -g install tmui-cli
安装成功后输入:tmui 按回车
查看所有可用命令
```
[![同名自定义/tmui design](https://gitee.com/LYTB/tmui-design/widgets/widget_card.svg?colors=393222,ebdfc1,fffae5,d8ca9f,393222,a28b40)](https://gitee.com/LYTB/tmui-design)
### [注意事项](https://ext.dcloud.net.cn/plugin?name=tm-ui)
- 仅接受tmui3.2.x的bug和维护及pr
- 提出建议和bug,请访问码云代码库:[点击打开](https://gitee.com/LYTB/tmui-design)
,并反馈相关bug或者建议.
---
### [贡献者](https://tmui.design/doc/start/%E8%B4%A1%E7%8C%AE%E8%80%85.html#%E8%B4%A1%E7%8C%AE%E4%BA%BA%E5%91%98)
在伟大的无私贡献人员帮助下,使得此开源项目的可持续性发展更加的持久。他/她们理应得到展示,并为大家所知。不管是如何的贡献,哪怕很小的一部分也值得称赞!!<br>
点击图片查看更多
[![](https://vkceyugu.cdn.bspapp.com/VKCEYUGU-f5b1722f-8766-40af-a22a-acc454202a37/ef8a8962-1153-44c9-aee5-6a616996e595.jpg)](https://tmui.design/doc/start/%E8%B4%A1%E7%8C%AE%E8%80%85.html)
----
### 赞助项目,以持续更新
如果帮到了你,适当的赞助有助力作者成果上的回报,报以项目的持续更新。
![tmui3.0 赞助](https://cdn.tmui.design/public/design/payzhanzhu.jpg)
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"skipLibCheck": true,
"jsx": "preserve",
"resolveJsonModule": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"useDefineForClassFields": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@@/*": ["src/sheep/*"]
},
"lib": ["ESNext", "DOM"],
"types": ["@dcloudio/types", "node", "vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "vite.config.ts", "unocss.config.ts"],
"exclude": ["node_modules", "dist", "src/uni_modules"]
}
+23
View File
@@ -0,0 +1,23 @@
import presetWeapp from "unocss-preset-weapp"
import { extractorAttributify, transformerClass } from "unocss-preset-weapp/transformer"
import { themeColors } from "./src/sheep/config/theme"
/** 沿用参考项目的 rpx 原子类与属性转换,布局尺寸统一按 750rpx 设计宽度换算 */
const { presetWeappAttributify, transformerAttributify } = extractorAttributify()
export default {
presets: [presetWeapp({ whRpx: true }), presetWeappAttributify()],
transformers: [transformerAttributify(), transformerClass()],
theme: { colors: themeColors },
shortcuts: {
"flex-row-center": "flex items-center justify-center",
"flex-col-center": "flex flex-col items-center justify-center",
"focus-ring": "outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2",
},
preflights: [
{
/** CSS 与 UnoCSS 从同一份颜色表取值,避免卡片、键盘与 tm-ui 主题偏离 */
getCSS: () => `:root, page { ${Object.entries(themeColors).map(([key, color]) => `--pay-${key}: ${color}`).join("; ")} }`,
},
],
}
+42
View File
@@ -0,0 +1,42 @@
import path from "node:path"
import { defineConfig, loadEnv } from "vite"
import uni from "@dcloudio/vite-plugin-uni"
import UnoCSS from "unocss/vite"
/** 按当前模式读取后端地址;保留目标自带的 /api 等路径,只移除浏览器请求的代理前缀。 */
function createApiProxy(mode: string) {
/** 沿用参考项目的 .env 变量,由 Vite 按运行模式加载,开发服务器只转发到开发后端。 */
const env = loadEnv(mode, __dirname, "VITE_")
const apiTarget = (env.VITE_APP_DEV_SERVICE || "").trim().replace(/\/+$/, "")
/** 与请求层共用 .env 中的本地接口前缀,默认 /api。 */
const apiPrefix = (env.VITE_API_URL || "/api").trim().replace(/\/+$/, "")
return apiTarget ? {
[apiPrefix]: {
target: apiTarget,
changeOrigin: true,
/** 微服务的 /third 与单体服务的无前缀路径由请求层决定,代理只移除本地前缀。 */
rewrite: (requestPath: string) => requestPath.slice(apiPrefix.length),
},
} : undefined
}
/** H5 独立开发端口避开参考 App 的 8889,别名沿用其业务目录约定 */
export default defineConfig(({ mode }) => ({
plugins: [uni(), UnoCSS()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
"@@": path.resolve(__dirname, "src/sheep"),
},
},
server: {
host: "0.0.0.0",
port: 8890,
strictPort: true,
proxy: createApiProxy(mode),
},
build: {
target: "es2018",
sourcemap: false,
},
}))