Refactor code structure for improved readability and maintainability
This commit is contained in:
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -149,4 +149,4 @@ TypeScript 文档:https://www.typescriptlang.org/
|
|||||||
Capacitor 文档:https://capacitorjs.com/docs/getting-started
|
Capacitor 文档:https://capacitorjs.com/docs/getting-started
|
||||||
TailwindCSS 文档:https://tailwindcss.com/docs/installation/using-vite
|
TailwindCSS 文档:https://tailwindcss.com/docs/installation/using-vite
|
||||||
TradingView Charting Library 文档:https://www.tradingview.com/widget-docs/getting-started/#getting-started
|
TradingView Charting Library 文档:https://www.tradingview.com/widget-docs/getting-started/#getting-started
|
||||||
PWA 文档:https://vite-pwa-org.netlify.app/
|
PWA 文档:https://vite-pwa-org.netlify.app/
|
||||||
|
|||||||
@@ -20,16 +20,16 @@
|
|||||||
|
|
||||||
```vue
|
```vue
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue'
|
import { onMounted } from "vue";
|
||||||
|
|
||||||
const { checkAndPromptUpdate } = useAppUpdate()
|
const { checkAndPromptUpdate } = useAppUpdate();
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// 应用启动 3 秒后检查更新
|
// 应用启动 3 秒后检查更新
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
checkAndPromptUpdate()
|
checkAndPromptUpdate();
|
||||||
}, 3000)
|
}, 3000);
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -38,6 +38,18 @@ onMounted(async () => {
|
|||||||
在设置页面添加"检查更新"按钮:
|
在设置页面添加"检查更新"按钮:
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
const {
|
||||||
|
isChecking,
|
||||||
|
currentVersion,
|
||||||
|
checkAndPromptUpdate,
|
||||||
|
} = useAppUpdate();
|
||||||
|
|
||||||
|
async function handleCheckUpdate() {
|
||||||
|
await checkAndPromptUpdate();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ion-page>
|
<ion-page>
|
||||||
<ion-header>
|
<ion-header>
|
||||||
@@ -59,18 +71,6 @@ onMounted(async () => {
|
|||||||
</ion-content>
|
</ion-content>
|
||||||
</ion-page>
|
</ion-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const {
|
|
||||||
isChecking,
|
|
||||||
currentVersion,
|
|
||||||
checkAndPromptUpdate,
|
|
||||||
} = useAppUpdate()
|
|
||||||
|
|
||||||
async function handleCheckUpdate() {
|
|
||||||
await checkAndPromptUpdate()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 自定义更新提示
|
### 3. 自定义更新提示
|
||||||
@@ -86,19 +86,20 @@ const {
|
|||||||
updateMessage,
|
updateMessage,
|
||||||
checkForUpdate,
|
checkForUpdate,
|
||||||
openStoreUpdate,
|
openStoreUpdate,
|
||||||
} = useAppUpdate()
|
} = useAppUpdate();
|
||||||
|
|
||||||
async function checkUpdate() {
|
async function checkUpdate() {
|
||||||
const result = await checkForUpdate()
|
const result = await checkForUpdate();
|
||||||
|
|
||||||
if (result.hasUpdate) {
|
if (result.hasUpdate) {
|
||||||
// 自定义提示逻辑
|
// 自定义提示逻辑
|
||||||
if (result.forceUpdate) {
|
if (result.forceUpdate) {
|
||||||
// 强制更新 - 阻止用户继续使用
|
// 强制更新 - 阻止用户继续使用
|
||||||
showForceUpdateModal()
|
showForceUpdateModal();
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
// 可选更新 - 显示提示但允许跳过
|
// 可选更新 - 显示提示但允许跳过
|
||||||
showOptionalUpdateToast()
|
showOptionalUpdateToast();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,26 +109,28 @@ async function checkUpdate() {
|
|||||||
### 4. 显示版本信息
|
### 4. 显示版本信息
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from "vue";
|
||||||
|
|
||||||
|
const { currentVersion, hasUpdate, getCurrentVersion, checkForUpdate } = useAppUpdate();
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await getCurrentVersion();
|
||||||
|
await checkForUpdate();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ion-item>
|
<ion-item>
|
||||||
<ion-label>
|
<ion-label>
|
||||||
<h2>应用版本</h2>
|
<h2>应用版本</h2>
|
||||||
<p>{{ currentVersion || '获取中...' }}</p>
|
<p>{{ currentVersion || '获取中...' }}</p>
|
||||||
</ion-label>
|
</ion-label>
|
||||||
<ion-badge v-if="hasUpdate" color="danger">有更新</ion-badge>
|
<ion-badge v-if="hasUpdate" color="danger">
|
||||||
|
有更新
|
||||||
|
</ion-badge>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { onMounted } from 'vue'
|
|
||||||
|
|
||||||
const { currentVersion, hasUpdate, getCurrentVersion, checkForUpdate } = useAppUpdate()
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await getCurrentVersion()
|
|
||||||
await checkForUpdate()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## API 参考
|
## API 参考
|
||||||
|
|||||||
@@ -51,23 +51,23 @@ curl https://your-domain.com/version.json
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Node.js / Elysia 示例
|
// Node.js / Elysia 示例
|
||||||
app.get('/api/app/version', async (ctx) => {
|
app.get("/api/app/version", async (ctx) => {
|
||||||
// 从前端静态资源读取版本
|
// 从前端静态资源读取版本
|
||||||
const response = await fetch('https://your-frontend-domain.com/version.json')
|
const response = await fetch("https://your-frontend-domain.com/version.json");
|
||||||
const frontendVersion = await response.json()
|
const frontendVersion = await response.json();
|
||||||
|
|
||||||
const { platform, currentVersion } = ctx.query
|
const { platform, currentVersion } = ctx.query;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version: frontendVersion.version,
|
version: frontendVersion.version,
|
||||||
forceUpdate: compareVersion(currentVersion, frontendVersion.version) < 0,
|
forceUpdate: compareVersion(currentVersion, frontendVersion.version) < 0,
|
||||||
updateMessage: '修复了一些问题',
|
updateMessage: "修复了一些问题",
|
||||||
updateUrl: platform === 'ios'
|
updateUrl: platform === "ios"
|
||||||
? 'https://apps.apple.com/app/xxx'
|
? "https://apps.apple.com/app/xxx"
|
||||||
: 'https://play.google.com/store/apps/details?id=xxx',
|
: "https://play.google.com/store/apps/details?id=xxx",
|
||||||
minSupportVersion: '0.0.1',
|
minSupportVersion: "0.0.1",
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
**方式 2:部署时同步到后端**
|
**方式 2:部署时同步到后端**
|
||||||
@@ -88,18 +88,18 @@ app.get('/api/app/version', async (ctx) => {
|
|||||||
后端直接读取本地文件:
|
后端直接读取本地文件:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import fs from 'fs'
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
app.get("/api/app/version", async (ctx) => {
|
||||||
|
const versionFile = fs.readFileSync("/app/frontend-version.json", "utf-8");
|
||||||
|
const { version } = JSON.parse(versionFile);
|
||||||
|
|
||||||
app.get('/api/app/version', async (ctx) => {
|
|
||||||
const versionFile = fs.readFileSync('/app/frontend-version.json', 'utf-8')
|
|
||||||
const { version } = JSON.parse(versionFile)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version,
|
version,
|
||||||
forceUpdate: false,
|
forceUpdate: false,
|
||||||
// ...
|
// ...
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -112,7 +112,7 @@ app.get('/api/app/version', async (ctx) => {
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"version": "1.2.3" // 只改这里
|
"version": "1.2.3" // 只改这里
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -162,27 +162,27 @@ versionName "1.2.3" // 与 package.json 保持一致
|
|||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import fs from 'fs'
|
import { execSync } from "node:child_process";
|
||||||
import { execSync } from 'child_process'
|
import fs from "node:fs";
|
||||||
|
|
||||||
const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf-8'))
|
const packageJson = JSON.parse(fs.readFileSync("./package.json", "utf-8"));
|
||||||
const version = packageJson.version
|
const version = packageJson.version;
|
||||||
|
|
||||||
console.log(`Syncing version ${version} to native projects...`)
|
console.log(`Syncing version ${version} to native projects...`);
|
||||||
|
|
||||||
// iOS
|
// iOS
|
||||||
const infoPlist = './ios/App/App/Info.plist'
|
const infoPlist = "./ios/App/App/Info.plist";
|
||||||
let plistContent = fs.readFileSync(infoPlist, 'utf-8')
|
let plistContent = fs.readFileSync(infoPlist, "utf-8");
|
||||||
plistContent = plistContent.replace(
|
plistContent = plistContent.replace(
|
||||||
/<key>CFBundleShortVersionString<\/key>\s*<string>.*?<\/string>/,
|
/<key>CFBundleShortVersionString<\/key>\s*<string>.*?<\/string>/,
|
||||||
`<key>CFBundleShortVersionString</key>\n\t<string>${version}</string>`
|
`<key>CFBundleShortVersionString</key>\n\t<string>${version}</string>`
|
||||||
)
|
);
|
||||||
fs.writeFileSync(infoPlist, plistContent)
|
fs.writeFileSync(infoPlist, plistContent);
|
||||||
|
|
||||||
// Android (需要安装 gradle 解析库或手动更新)
|
// Android (需要安装 gradle 解析库或手动更新)
|
||||||
console.log('Please manually update android/app/build.gradle versionName')
|
console.log("Please manually update android/app/build.gradle versionName");
|
||||||
|
|
||||||
console.log('✓ Version synced!')
|
console.log("✓ Version synced!");
|
||||||
```
|
```
|
||||||
|
|
||||||
在 `package.json` 中添加脚本:
|
在 `package.json` 中添加脚本:
|
||||||
@@ -237,24 +237,24 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: "18"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: pnpm run build
|
run: pnpm run build
|
||||||
env:
|
env:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
|
||||||
- name: Deploy to server
|
- name: Deploy to server
|
||||||
run: |
|
run: |
|
||||||
rsync -avz dist/ user@server:/var/www/html/
|
rsync -avz dist/ user@server:/var/www/html/
|
||||||
|
|
||||||
- name: Notify backend
|
- name: Notify backend
|
||||||
run: |
|
run: |
|
||||||
curl -X POST https://your-backend.com/api/webhook/frontend-deployed \
|
curl -X POST https://your-backend.com/api/webhook/frontend-deployed \
|
||||||
@@ -284,107 +284,108 @@ jobs:
|
|||||||
|
|
||||||
**一次配置,永久受益!** 🎉
|
**一次配置,永久受益!** 🎉
|
||||||
|
|
||||||
|
|
||||||
## 后端 API 实现示例 - 版本检查接口
|
## 后端 API 实现示例 - 版本检查接口
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { Elysia, t } from 'elysia'
|
// 方案二:从本地文件读取(适用于前后端部署在同一服务器)
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
import { Elysia, t } from "elysia";
|
||||||
|
|
||||||
// 版本比较工具函数
|
// 版本比较工具函数
|
||||||
function compareVersion(version1: string, version2: string): number {
|
function compareVersion(version1: string, version2: string): number {
|
||||||
const v1Parts = version1.split('.').map(Number)
|
const v1Parts = version1.split(".").map(Number);
|
||||||
const v2Parts = version2.split('.').map(Number)
|
const v2Parts = version2.split(".").map(Number);
|
||||||
const maxLength = Math.max(v1Parts.length, v2Parts.length)
|
const maxLength = Math.max(v1Parts.length, v2Parts.length);
|
||||||
|
|
||||||
for (let i = 0; i < maxLength; i++) {
|
for (let i = 0; i < maxLength; i++) {
|
||||||
const v1Part = v1Parts[i] || 0
|
const v1Part = v1Parts[i] || 0;
|
||||||
const v2Part = v2Parts[i] || 0
|
const v2Part = v2Parts[i] || 0;
|
||||||
|
|
||||||
if (v1Part < v2Part) return -1
|
if (v1Part < v2Part)
|
||||||
if (v1Part > v2Part) return 1
|
return -1;
|
||||||
|
if (v1Part > v2Part)
|
||||||
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 方案一:从前端静态资源读取版本(推荐)
|
// 方案一:从前端静态资源读取版本(推荐)
|
||||||
async function getFrontendVersionFromURL(): Promise<{
|
async function getFrontendVersionFromURL(): Promise<{
|
||||||
version: string
|
version: string;
|
||||||
buildTime: string
|
buildTime: string;
|
||||||
gitCommit: string
|
gitCommit: string;
|
||||||
environment: string
|
environment: string;
|
||||||
}> {
|
}> {
|
||||||
const response = await fetch('https://your-frontend-domain.com/version.json')
|
const response = await fetch("https://your-frontend-domain.com/version.json");
|
||||||
return await response.json()
|
return await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 方案二:从本地文件读取(适用于前后端部署在同一服务器)
|
|
||||||
import fs from 'fs'
|
|
||||||
|
|
||||||
function getFrontendVersionFromFile(): {
|
function getFrontendVersionFromFile(): {
|
||||||
version: string
|
version: string;
|
||||||
buildTime: string
|
buildTime: string;
|
||||||
gitCommit: string
|
gitCommit: string;
|
||||||
environment: string
|
environment: string;
|
||||||
} {
|
} {
|
||||||
const content = fs.readFileSync('/app/frontend-version.json', 'utf-8')
|
const content = fs.readFileSync("/app/frontend-version.json", "utf-8");
|
||||||
return JSON.parse(content)
|
return JSON.parse(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 应用商店链接配置
|
// 应用商店链接配置
|
||||||
const APP_STORE_URLS = {
|
const APP_STORE_URLS = {
|
||||||
ios: 'https://apps.apple.com/app/id123456789',
|
ios: "https://apps.apple.com/app/id123456789",
|
||||||
android: 'https://play.google.com/store/apps/details?id=riwa.ionic.app',
|
android: "https://play.google.com/store/apps/details?id=riwa.ionic.app",
|
||||||
}
|
};
|
||||||
|
|
||||||
// 版本策略配置(可存储在数据库)
|
// 版本策略配置(可存储在数据库)
|
||||||
const VERSION_POLICIES = {
|
const VERSION_POLICIES = {
|
||||||
minSupportVersion: '0.0.1', // 最低支持版本
|
minSupportVersion: "0.0.1", // 最低支持版本
|
||||||
forceUpdateVersions: ['0.0.1'], // 需要强制更新的版本列表
|
forceUpdateVersions: ["0.0.1"], // 需要强制更新的版本列表
|
||||||
updateMessages: {
|
updateMessages: {
|
||||||
'zh-CN': '修复了一些问题并优化了性能',
|
"zh-CN": "修复了一些问题并优化了性能",
|
||||||
'en-US': 'Bug fixes and performance improvements',
|
"en-US": "Bug fixes and performance improvements",
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
// Elysia 路由定义
|
// Elysia 路由定义
|
||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
.get(
|
.get(
|
||||||
'/api/app/version',
|
"/api/app/version",
|
||||||
async ({ query, headers }) => {
|
async ({ query, headers }) => {
|
||||||
const { platform, currentVersion } = query
|
const { platform, currentVersion } = query;
|
||||||
const lang = headers['accept-language']?.split(',')[0] || 'en-US'
|
const lang = headers["accept-language"]?.split(",")[0] || "en-US";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取前端版本信息
|
// 获取前端版本信息
|
||||||
const frontendVersion = await getFrontendVersionFromURL()
|
const frontendVersion = await getFrontendVersionFromURL();
|
||||||
// 或使用本地文件: const frontendVersion = getFrontendVersionFromFile()
|
// 或使用本地文件: const frontendVersion = getFrontendVersionFromFile()
|
||||||
|
|
||||||
// 判断是否需要更新
|
// 判断是否需要更新
|
||||||
const hasUpdate = compareVersion(currentVersion, frontendVersion.version) < 0
|
const hasUpdate = compareVersion(currentVersion, frontendVersion.version) < 0;
|
||||||
|
|
||||||
// 判断是否强制更新
|
// 判断是否强制更新
|
||||||
let forceUpdate = VERSION_POLICIES.forceUpdateVersions.includes(currentVersion)
|
let forceUpdate = VERSION_POLICIES.forceUpdateVersions.includes(currentVersion);
|
||||||
|
|
||||||
// 检查是否低于最低支持版本
|
// 检查是否低于最低支持版本
|
||||||
if (compareVersion(currentVersion, VERSION_POLICIES.minSupportVersion) < 0) {
|
if (compareVersion(currentVersion, VERSION_POLICIES.minSupportVersion) < 0) {
|
||||||
forceUpdate = true
|
forceUpdate = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取更新链接
|
// 获取更新链接
|
||||||
const updateUrl = platform === 'ios'
|
const updateUrl = platform === "ios"
|
||||||
? APP_STORE_URLS.ios
|
? APP_STORE_URLS.ios
|
||||||
: platform === 'android'
|
: platform === "android"
|
||||||
? APP_STORE_URLS.android
|
? APP_STORE_URLS.android
|
||||||
: ''
|
: "";
|
||||||
|
|
||||||
// 获取更新说明(多语言)
|
// 获取更新说明(多语言)
|
||||||
const updateMessage = VERSION_POLICIES.updateMessages[lang]
|
const updateMessage = VERSION_POLICIES.updateMessages[lang]
|
||||||
|| VERSION_POLICIES.updateMessages['en-US']
|
|| VERSION_POLICIES.updateMessages["en-US"];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version: frontendVersion.version,
|
version: frontendVersion.version,
|
||||||
buildNumber: parseInt(frontendVersion.version.replace(/\./g, '')),
|
buildNumber: Number.parseInt(frontendVersion.version.replace(/\./g, "")),
|
||||||
buildTime: frontendVersion.buildTime,
|
buildTime: frontendVersion.buildTime,
|
||||||
gitCommit: frontendVersion.gitCommit,
|
gitCommit: frontendVersion.gitCommit,
|
||||||
forceUpdate,
|
forceUpdate,
|
||||||
@@ -392,26 +393,27 @@ const app = new Elysia()
|
|||||||
updateUrl,
|
updateUrl,
|
||||||
minSupportVersion: VERSION_POLICIES.minSupportVersion,
|
minSupportVersion: VERSION_POLICIES.minSupportVersion,
|
||||||
releaseNotes: [
|
releaseNotes: [
|
||||||
'修复了已知问题',
|
"修复了已知问题",
|
||||||
'优化了应用性能',
|
"优化了应用性能",
|
||||||
'改进了用户界面',
|
"改进了用户界面",
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
} catch (error) {
|
}
|
||||||
console.error('Failed to get frontend version:', error)
|
catch (error) {
|
||||||
|
console.error("Failed to get frontend version:", error);
|
||||||
|
|
||||||
// 降级处理:返回当前版本,不强制更新
|
// 降级处理:返回当前版本,不强制更新
|
||||||
return {
|
return {
|
||||||
version: currentVersion,
|
version: currentVersion,
|
||||||
forceUpdate: false,
|
forceUpdate: false,
|
||||||
updateMessage: '',
|
updateMessage: "",
|
||||||
updateUrl: '',
|
updateUrl: "",
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
query: t.Object({
|
query: t.Object({
|
||||||
platform: t.Union([t.Literal('ios'), t.Literal('android'), t.Literal('web')]),
|
platform: t.Union([t.Literal("ios"), t.Literal("android"), t.Literal("web")]),
|
||||||
currentVersion: t.String(),
|
currentVersion: t.String(),
|
||||||
}),
|
}),
|
||||||
response: t.Object({
|
response: t.Object({
|
||||||
@@ -426,13 +428,13 @@ const app = new Elysia()
|
|||||||
releaseNotes: t.Optional(t.Array(t.String())),
|
releaseNotes: t.Optional(t.Array(t.String())),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
|
|
||||||
export default app
|
export default app;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用示例:
|
* 使用示例:
|
||||||
*
|
*
|
||||||
* 1. 启动后端服务
|
* 1. 启动后端服务
|
||||||
* 2. 前端请求:GET /api/app/version?platform=ios¤tVersion=0.0.1
|
* 2. 前端请求:GET /api/app/version?platform=ios¤tVersion=0.0.1
|
||||||
* 3. 后端响应:
|
* 3. 后端响应:
|
||||||
@@ -448,9 +450,9 @@ export default app
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 数据库存储方案(可选)
|
* 数据库存储方案(可选)
|
||||||
*
|
*
|
||||||
* 如果需要更灵活的版本策略管理,可以将配置存储在数据库:
|
* 如果需要更灵活的版本策略管理,可以将配置存储在数据库:
|
||||||
*
|
*
|
||||||
* CREATE TABLE app_versions (
|
* CREATE TABLE app_versions (
|
||||||
* id SERIAL PRIMARY KEY,
|
* id SERIAL PRIMARY KEY,
|
||||||
* platform VARCHAR(20) NOT NULL,
|
* platform VARCHAR(20) NOT NULL,
|
||||||
@@ -463,12 +465,11 @@ export default app
|
|||||||
* release_notes JSONB,
|
* release_notes JSONB,
|
||||||
* created_at TIMESTAMP DEFAULT NOW()
|
* created_at TIMESTAMP DEFAULT NOW()
|
||||||
* );
|
* );
|
||||||
*
|
*
|
||||||
* 然后从数据库查询版本策略:
|
* 然后从数据库查询版本策略:
|
||||||
* const policy = await db.query(
|
* const policy = await db.query(
|
||||||
* 'SELECT * FROM app_versions WHERE platform = $1 ORDER BY created_at DESC LIMIT 1',
|
* 'SELECT * FROM app_versions WHERE platform = $1 ORDER BY created_at DESC LIMIT 1',
|
||||||
* [platform]
|
* [platform]
|
||||||
* )
|
* )
|
||||||
*/
|
*/
|
||||||
|
```
|
||||||
```
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"@ionic/vue-router": "^8.7.11",
|
"@ionic/vue-router": "^8.7.11",
|
||||||
"@riwa/api-types": "http://192.168.1.7:9527/api/riwa-eden-0.0.118.tgz",
|
"@riwa/api-types": "http://192.168.1.7:9527/api/riwa-eden-0.0.118.tgz",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@vee-validate/yup": "^4.15.1",
|
"@vee-validate/zod": "^4.15.1",
|
||||||
"@vueuse/core": "^14.1.0",
|
"@vueuse/core": "^14.1.0",
|
||||||
"@vueuse/integrations": "^14.1.0",
|
"@vueuse/integrations": "^14.1.0",
|
||||||
"@vueuse/router": "^14.1.0",
|
"@vueuse/router": "^14.1.0",
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
"vue": "^3.5.25",
|
"vue": "^3.5.25",
|
||||||
"vue-i18n": "^11.2.2",
|
"vue-i18n": "^11.2.2",
|
||||||
"vue-router": "^4.6.3",
|
"vue-router": "^4.6.3",
|
||||||
"yup": "^1.7.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@antfu/eslint-config": "^6.6.1",
|
"@antfu/eslint-config": "^6.6.1",
|
||||||
|
|||||||
@@ -50,19 +50,19 @@ pnpm generate
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export const currentVersion: AppVersion = {
|
export const currentVersion: AppVersion = {
|
||||||
version: '1.0.0',
|
version: "1.0.0",
|
||||||
buildNumber: '100',
|
buildNumber: "100",
|
||||||
releaseDate: '2025-12-30',
|
releaseDate: "2025-12-30",
|
||||||
releaseNotes: {
|
releaseNotes: {
|
||||||
'zh-CN': ['更新内容...'],
|
"zh-CN": ["更新内容..."],
|
||||||
'en-US': ['What\'s new...'],
|
"en-US": ["What's new..."],
|
||||||
},
|
},
|
||||||
downloads: {
|
downloads: {
|
||||||
ios: 'https://example.com/app.ipa',
|
ios: "https://example.com/app.ipa",
|
||||||
android: 'https://example.com/app.apk',
|
android: "https://example.com/app.apk",
|
||||||
h5: 'https://app.example.com',
|
h5: "https://app.example.com",
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
### 接入真实 API
|
### 接入真实 API
|
||||||
@@ -81,9 +81,9 @@ Nuxt UI 使用 TailwindCSS 4,可在 `nuxt.config.ts` 中配置:
|
|||||||
```typescript
|
```typescript
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
colorMode: {
|
colorMode: {
|
||||||
preference: 'system', // 'light' | 'dark' | 'system'
|
preference: "system", // 'light' | 'dark' | 'system'
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export default defineAppConfig({
|
export default defineAppConfig({
|
||||||
ui: {
|
ui: {
|
||||||
colors: {
|
colors: {
|
||||||
primary: 'blue',
|
primary: "blue",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -6,4 +6,3 @@
|
|||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</UApp>
|
</UApp>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,8 @@
|
|||||||
|
|
||||||
/* 发光脉冲效果 */
|
/* 发光脉冲效果 */
|
||||||
@keyframes glow-pulse {
|
@keyframes glow-pulse {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
box-shadow: 0 0 20px rgba(99, 102, 241, 0.3);
|
box-shadow: 0 0 20px rgba(99, 102, 241, 0.3);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
@@ -96,7 +97,8 @@
|
|||||||
|
|
||||||
/* 悬浮动画 */
|
/* 悬浮动画 */
|
||||||
@keyframes float {
|
@keyframes float {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
@@ -208,7 +210,8 @@
|
|||||||
|
|
||||||
/* 脉冲缩放动画 */
|
/* 脉冲缩放动画 */
|
||||||
@keyframes pulse-scale {
|
@keyframes pulse-scale {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
@@ -250,7 +253,8 @@
|
|||||||
|
|
||||||
/* 微妙的悬浮动画 */
|
/* 微妙的悬浮动画 */
|
||||||
@keyframes float-subtle {
|
@keyframes float-subtle {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
transform: translateY(0) translateZ(0);
|
transform: translateY(0) translateZ(0);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
@@ -284,7 +288,8 @@
|
|||||||
|
|
||||||
/* 发光脉冲动画 */
|
/* 发光脉冲动画 */
|
||||||
@keyframes pulse-glow {
|
@keyframes pulse-glow {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
@@ -298,7 +303,8 @@
|
|||||||
|
|
||||||
/* 微妙的脉冲动画 */
|
/* 微妙的脉冲动画 */
|
||||||
@keyframes pulse-subtle {
|
@keyframes pulse-subtle {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
@@ -362,7 +368,8 @@
|
|||||||
|
|
||||||
/* 震动效果 */
|
/* 震动效果 */
|
||||||
@keyframes shake {
|
@keyframes shake {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
25% {
|
25% {
|
||||||
@@ -401,7 +408,8 @@
|
|||||||
|
|
||||||
/* 呼吸灯效果 */
|
/* 呼吸灯效果 */
|
||||||
@keyframes breathe {
|
@keyframes breathe {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,59 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { t } = useI18n()
|
const { t } = useI18n();
|
||||||
const { canInstall, isInstalled, install } = usePWAInstall()
|
const { canInstall, isInstalled, install } = usePWAInstall();
|
||||||
const installing = ref(false)
|
const installing = ref(false);
|
||||||
const dismissed = ref(false)
|
const dismissed = ref(false);
|
||||||
|
|
||||||
// 从 localStorage 读取是否已关闭,但只在未安装状态下有效
|
// 从 localStorage 读取是否已关闭,但只在未安装状态下有效
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 如果应用未安装,检查用户是否之前关闭过横幅
|
// 如果应用未安装,检查用户是否之前关闭过横幅
|
||||||
if (!isInstalled.value) {
|
if (!isInstalled.value) {
|
||||||
dismissed.value = localStorage.getItem('pwa-banner-dismissed') === 'true'
|
dismissed.value = localStorage.getItem("pwa-banner-dismissed") === "true";
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// 如果应用已安装,清除关闭记录(为了卸载后能再次提示)
|
// 如果应用已安装,清除关闭记录(为了卸载后能再次提示)
|
||||||
localStorage.removeItem('pwa-banner-dismissed')
|
localStorage.removeItem("pwa-banner-dismissed");
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
// 监听安装状态变化
|
// 监听安装状态变化
|
||||||
watch(isInstalled, (newValue) => {
|
watch(isInstalled, (newValue) => {
|
||||||
if (newValue) {
|
if (newValue) {
|
||||||
// 应用安装后,清除关闭记录
|
// 应用安装后,清除关闭记录
|
||||||
localStorage.removeItem('pwa-banner-dismissed')
|
localStorage.removeItem("pwa-banner-dismissed");
|
||||||
dismissed.value = false
|
dismissed.value = false;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
// 监听 canInstall 变化(卸载后会重新触发 beforeinstallprompt)
|
// 监听 canInstall 变化(卸载后会重新触发 beforeinstallprompt)
|
||||||
watch(canInstall, (newValue) => {
|
watch(canInstall, (newValue) => {
|
||||||
if (newValue && !isInstalled.value) {
|
if (newValue && !isInstalled.value) {
|
||||||
// 如果可以安装且未安装,清除之前的关闭记录
|
// 如果可以安装且未安装,清除之前的关闭记录
|
||||||
// 这样卸载后再次访问就会重新显示横幅
|
// 这样卸载后再次访问就会重新显示横幅
|
||||||
localStorage.removeItem('pwa-banner-dismissed')
|
localStorage.removeItem("pwa-banner-dismissed");
|
||||||
dismissed.value = false
|
dismissed.value = false;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
async function handleInstall() {
|
async function handleInstall() {
|
||||||
installing.value = true
|
installing.value = true;
|
||||||
try {
|
try {
|
||||||
const success = await install()
|
const success = await install();
|
||||||
if (success) {
|
if (success) {
|
||||||
console.log('PWA 安装成功')
|
console.log("PWA 安装成功");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
installing.value = false
|
installing.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function dismissBanner() {
|
function dismissBanner() {
|
||||||
dismissed.value = true
|
dismissed.value = true;
|
||||||
localStorage.setItem('pwa-banner-dismissed', 'true')
|
localStorage.setItem("pwa-banner-dismissed", "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
const showBanner = computed(() => canInstall.value && !isInstalled.value && !dismissed.value)
|
const showBanner = computed(() => canInstall.value && !isInstalled.value && !dismissed.value);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -73,7 +73,7 @@ const showBanner = computed(() => canInstall.value && !isInstalled.value && !dis
|
|||||||
<div class="absolute inset-0 bg-linear-to-br from-primary-50/80 via-blue-50/50 to-purple-50/80 dark:from-primary-950/50 dark:via-blue-950/30 dark:to-purple-950/50" />
|
<div class="absolute inset-0 bg-linear-to-br from-primary-50/80 via-blue-50/50 to-purple-50/80 dark:from-primary-950/50 dark:via-blue-950/30 dark:to-purple-950/50" />
|
||||||
<div class="absolute top-0 right-0 w-64 h-64 bg-primary-500/10 rounded-full blur-3xl" />
|
<div class="absolute top-0 right-0 w-64 h-64 bg-primary-500/10 rounded-full blur-3xl" />
|
||||||
<div class="absolute bottom-0 left-0 w-64 h-64 bg-purple-500/10 rounded-full blur-3xl" />
|
<div class="absolute bottom-0 left-0 w-64 h-64 bg-purple-500/10 rounded-full blur-3xl" />
|
||||||
|
|
||||||
<!-- 内容 -->
|
<!-- 内容 -->
|
||||||
<div class="relative z-10 flex items-center gap-4">
|
<div class="relative z-10 flex items-center gap-4">
|
||||||
<!-- 图标 -->
|
<!-- 图标 -->
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { t } = useI18n()
|
const { t } = useI18n();
|
||||||
const { canInstall, isInstalled, install } = usePWAInstall()
|
const { canInstall, isInstalled, install } = usePWAInstall();
|
||||||
const installing = ref(false)
|
const installing = ref(false);
|
||||||
|
|
||||||
async function handleInstall() {
|
async function handleInstall() {
|
||||||
installing.value = true
|
installing.value = true;
|
||||||
try {
|
try {
|
||||||
const success = await install()
|
const success = await install();
|
||||||
if (success) {
|
if (success) {
|
||||||
// 可以显示成功提示
|
// 可以显示成功提示
|
||||||
console.log('PWA 安装成功')
|
console.log("PWA 安装成功");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
installing.value = false
|
installing.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,52 +1,52 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { onMounted, ref } from "vue";
|
||||||
|
|
||||||
export function usePWAInstall() {
|
export function usePWAInstall() {
|
||||||
const deferredPrompt = ref<any>(null)
|
const deferredPrompt = ref<any>(null);
|
||||||
const canInstall = ref(false)
|
const canInstall = ref(false);
|
||||||
const isInstalled = ref(false)
|
const isInstalled = ref(false);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 检查是否已安装
|
// 检查是否已安装
|
||||||
if (window.matchMedia('(display-mode: standalone)').matches) {
|
if (window.matchMedia("(display-mode: standalone)").matches) {
|
||||||
isInstalled.value = true
|
isInstalled.value = true;
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听安装提示事件
|
// 监听安装提示事件
|
||||||
window.addEventListener('beforeinstallprompt', (e) => {
|
window.addEventListener("beforeinstallprompt", (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
deferredPrompt.value = e
|
deferredPrompt.value = e;
|
||||||
canInstall.value = true
|
canInstall.value = true;
|
||||||
})
|
});
|
||||||
|
|
||||||
// 监听安装成功事件
|
// 监听安装成功事件
|
||||||
window.addEventListener('appinstalled', () => {
|
window.addEventListener("appinstalled", () => {
|
||||||
deferredPrompt.value = null
|
deferredPrompt.value = null;
|
||||||
canInstall.value = false
|
canInstall.value = false;
|
||||||
isInstalled.value = true
|
isInstalled.value = true;
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
async function install() {
|
async function install() {
|
||||||
if (!deferredPrompt.value) {
|
if (!deferredPrompt.value) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deferredPrompt.value.prompt()
|
await deferredPrompt.value.prompt();
|
||||||
const { outcome } = await deferredPrompt.value.userChoice
|
const { outcome } = await deferredPrompt.value.userChoice;
|
||||||
|
|
||||||
if (outcome === 'accepted') {
|
if (outcome === "accepted") {
|
||||||
deferredPrompt.value = null
|
deferredPrompt.value = null;
|
||||||
canInstall.value = false
|
canInstall.value = false;
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
console.error('安装失败:', error)
|
console.error("安装失败:", error);
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,5 +54,5 @@ export function usePWAInstall() {
|
|||||||
canInstall,
|
canInstall,
|
||||||
isInstalled,
|
isInstalled,
|
||||||
install,
|
install,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
import type { Platform } from '~/types'
|
import type { Platform } from "~/types";
|
||||||
|
|
||||||
export function usePlatformDetection() {
|
export function usePlatformDetection() {
|
||||||
const platform = useState<Platform>('platform', () => 'unknown')
|
const platform = useState<Platform>("platform", () => "unknown");
|
||||||
|
|
||||||
function detectPlatform(): Platform {
|
function detectPlatform(): Platform {
|
||||||
if (import.meta.server)
|
if (import.meta.server)
|
||||||
return 'unknown'
|
return "unknown";
|
||||||
|
|
||||||
const ua = navigator.userAgent.toLowerCase()
|
const ua = navigator.userAgent.toLowerCase();
|
||||||
|
|
||||||
if (/iphone|ipad|ipod/.test(ua))
|
if (/iphone|ipad|ipod/.test(ua))
|
||||||
return 'ios'
|
return "ios";
|
||||||
else if (/android/.test(ua))
|
else if (/android/.test(ua))
|
||||||
return 'android'
|
return "android";
|
||||||
else if (/windows|macintosh|linux/.test(ua))
|
else if (/windows|macintosh|linux/.test(ua))
|
||||||
return 'desktop'
|
return "desktop";
|
||||||
|
|
||||||
return 'unknown'
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
platform.value = detectPlatform()
|
platform.value = detectPlatform();
|
||||||
})
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
platform: readonly(platform),
|
platform: readonly(platform),
|
||||||
isIOS: computed(() => platform.value === 'ios'),
|
isIOS: computed(() => platform.value === "ios"),
|
||||||
isAndroid: computed(() => platform.value === 'android'),
|
isAndroid: computed(() => platform.value === "android"),
|
||||||
isDesktop: computed(() => platform.value === 'desktop'),
|
isDesktop: computed(() => platform.value === "desktop"),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,34 +1,34 @@
|
|||||||
import type { AppVersion, DownloadStats } from '~/types'
|
import type { AppVersion, DownloadStats } from "~/types";
|
||||||
|
|
||||||
// 当前版本信息
|
// 当前版本信息
|
||||||
export const currentVersion: AppVersion = {
|
export const currentVersion: AppVersion = {
|
||||||
version: '1.0.0',
|
version: "1.0.0",
|
||||||
buildNumber: '100',
|
buildNumber: "100",
|
||||||
releaseDate: '2025-12-30',
|
releaseDate: "2025-12-30",
|
||||||
releaseNotes: {
|
releaseNotes: {
|
||||||
'zh-CN': [
|
"zh-CN": [
|
||||||
'🎉 首次发布',
|
"🎉 首次发布",
|
||||||
'✨ 全新的用户界面设计',
|
"✨ 全新的用户界面设计",
|
||||||
'🔐 增强的安全特性',
|
"🔐 增强的安全特性",
|
||||||
'⚡ 性能优化,响应速度提升 30%',
|
"⚡ 性能优化,响应速度提升 30%",
|
||||||
'🌍 支持多语言切换',
|
"🌍 支持多语言切换",
|
||||||
'🌙 深色模式支持',
|
"🌙 深色模式支持",
|
||||||
],
|
],
|
||||||
'en-US': [
|
"en-US": [
|
||||||
'🎉 Initial Release',
|
"🎉 Initial Release",
|
||||||
'✨ Brand new user interface',
|
"✨ Brand new user interface",
|
||||||
'🔐 Enhanced security features',
|
"🔐 Enhanced security features",
|
||||||
'⚡ Performance optimization, 30% faster response',
|
"⚡ Performance optimization, 30% faster response",
|
||||||
'🌍 Multi-language support',
|
"🌍 Multi-language support",
|
||||||
'🌙 Dark mode support',
|
"🌙 Dark mode support",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
downloads: {
|
downloads: {
|
||||||
ios: 'https://example.com/riwa-ios-1.0.0.ipa',
|
ios: "https://example.com/riwa-ios-1.0.0.ipa",
|
||||||
android: 'https://example.com/riwa-android-1.0.0.apk',
|
android: "https://example.com/riwa-android-1.0.0.apk",
|
||||||
h5: 'http://localhost:5173',
|
h5: "http://localhost:5173",
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
// 模拟下载统计数据
|
// 模拟下载统计数据
|
||||||
export const mockDownloadStats: DownloadStats = {
|
export const mockDownloadStats: DownloadStats = {
|
||||||
@@ -36,4 +36,4 @@ export const mockDownloadStats: DownloadStats = {
|
|||||||
today: 156,
|
today: 156,
|
||||||
ios: 7234,
|
ios: 7234,
|
||||||
android: 5346,
|
android: 5346,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ services:
|
|||||||
# nginx conf
|
# nginx conf
|
||||||
- ./nginx.conf:/etc/nginx/nginx.conf
|
- ./nginx.conf:/etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
|
||||||
# version: '3.8'
|
# version: '3.8'
|
||||||
|
|
||||||
# services:
|
# services:
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import antfu from '@antfu/eslint-config'
|
import antfu from "@antfu/eslint-config";
|
||||||
import withNuxt from './.nuxt/eslint.config.mjs'
|
import withNuxt from "./.nuxt/eslint.config.mjs";
|
||||||
|
|
||||||
export default withNuxt(
|
export default withNuxt(
|
||||||
antfu({
|
antfu({
|
||||||
formatters: true,
|
formatters: true,
|
||||||
}),
|
}),
|
||||||
)
|
);
|
||||||
|
|||||||
@@ -1,49 +1,49 @@
|
|||||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
modules: [
|
modules: [
|
||||||
'@nuxt/ui',
|
"@nuxt/ui",
|
||||||
'@nuxtjs/i18n',
|
"@nuxtjs/i18n",
|
||||||
'@nuxt/eslint',
|
"@nuxt/eslint",
|
||||||
'@vite-pwa/nuxt',
|
"@vite-pwa/nuxt",
|
||||||
],
|
],
|
||||||
|
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: true },
|
||||||
|
|
||||||
pwa: {
|
pwa: {
|
||||||
registerType: 'autoUpdate',
|
registerType: "autoUpdate",
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'Riwa应用商店',
|
name: "Riwa应用商店",
|
||||||
short_name: 'Riwa应用商店',
|
short_name: "Riwa应用商店",
|
||||||
description: 'Riwa App 下载 - iOS, Android, H5',
|
description: "Riwa App 下载 - iOS, Android, H5",
|
||||||
theme_color: '#3b82f6',
|
theme_color: "#3b82f6",
|
||||||
background_color: '#ffffff',
|
background_color: "#ffffff",
|
||||||
display: 'standalone',
|
display: "standalone",
|
||||||
scope: '/',
|
scope: "/",
|
||||||
start_url: '/',
|
start_url: "/",
|
||||||
icons: [
|
icons: [
|
||||||
{
|
{
|
||||||
src: '/favicon.svg',
|
src: "/favicon.svg",
|
||||||
sizes: '512x512',
|
sizes: "512x512",
|
||||||
type: 'image/svg+xml',
|
type: "image/svg+xml",
|
||||||
purpose: 'any',
|
purpose: "any",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: '/favicon.svg',
|
src: "/favicon.svg",
|
||||||
sizes: '192x192',
|
sizes: "192x192",
|
||||||
type: 'image/svg+xml',
|
type: "image/svg+xml",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
workbox: {
|
workbox: {
|
||||||
navigateFallback: '/',
|
navigateFallback: "/",
|
||||||
globPatterns: ['**/*.{js,css,html,png,svg,ico}'],
|
globPatterns: ["**/*.{js,css,html,png,svg,ico}"],
|
||||||
cleanupOutdatedCaches: true,
|
cleanupOutdatedCaches: true,
|
||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
{
|
{
|
||||||
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
|
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
|
||||||
handler: 'CacheFirst',
|
handler: "CacheFirst",
|
||||||
options: {
|
options: {
|
||||||
cacheName: 'google-fonts-cache',
|
cacheName: "google-fonts-cache",
|
||||||
expiration: {
|
expiration: {
|
||||||
maxEntries: 10,
|
maxEntries: 10,
|
||||||
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
|
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
|
||||||
@@ -55,9 +55,9 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
|
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
|
||||||
handler: 'CacheFirst',
|
handler: "CacheFirst",
|
||||||
options: {
|
options: {
|
||||||
cacheName: 'gstatic-fonts-cache',
|
cacheName: "gstatic-fonts-cache",
|
||||||
expiration: {
|
expiration: {
|
||||||
maxEntries: 10,
|
maxEntries: 10,
|
||||||
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
|
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
|
||||||
@@ -75,81 +75,81 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
devOptions: {
|
devOptions: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
type: 'module',
|
type: "module",
|
||||||
},
|
},
|
||||||
injectManifest: {
|
injectManifest: {
|
||||||
globPatterns: ['**/*.{js,css,html,png,svg,ico}'],
|
globPatterns: ["**/*.{js,css,html,png,svg,ico}"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
css: [
|
css: [
|
||||||
'~/assets/css/main.css',
|
"~/assets/css/main.css",
|
||||||
'~/assets/css/animations.css',
|
"~/assets/css/animations.css",
|
||||||
],
|
],
|
||||||
|
|
||||||
colorMode: {
|
colorMode: {
|
||||||
preference: 'light',
|
preference: "light",
|
||||||
},
|
},
|
||||||
|
|
||||||
i18n: {
|
i18n: {
|
||||||
defaultLocale: 'zh-CN',
|
defaultLocale: "zh-CN",
|
||||||
locales: [
|
locales: [
|
||||||
{
|
{
|
||||||
code: 'zh-CN',
|
code: "zh-CN",
|
||||||
name: '简体中文',
|
name: "简体中文",
|
||||||
file: 'zh-CN.json',
|
file: "zh-CN.json",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: 'en-US',
|
code: "en-US",
|
||||||
name: 'English',
|
name: "English",
|
||||||
file: 'en-US.json',
|
file: "en-US.json",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
strategy: 'no_prefix',
|
strategy: "no_prefix",
|
||||||
detectBrowserLanguage: {
|
detectBrowserLanguage: {
|
||||||
useCookie: true,
|
useCookie: true,
|
||||||
cookieKey: 'i18n_locale',
|
cookieKey: "i18n_locale",
|
||||||
redirectOn: 'root',
|
redirectOn: "root",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
app: {
|
app: {
|
||||||
head: {
|
head: {
|
||||||
charset: 'utf-8',
|
charset: "utf-8",
|
||||||
viewport: 'width=device-width, initial-scale=1',
|
viewport: "width=device-width, initial-scale=1",
|
||||||
title: 'Riwa App 下载',
|
title: "Riwa App 下载",
|
||||||
meta: [
|
meta: [
|
||||||
{ name: 'description', content: 'Riwa App 下载 - iOS, Android, H5' },
|
{ name: "description", content: "Riwa App 下载 - iOS, Android, H5" },
|
||||||
],
|
],
|
||||||
link: [
|
link: [
|
||||||
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
|
{ rel: "icon", type: "image/svg+xml", href: "/favicon.svg" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
devServer:{
|
devServer: {
|
||||||
host: '0.0.0.0'
|
host: "0.0.0.0",
|
||||||
},
|
},
|
||||||
|
|
||||||
nitro: {
|
nitro: {
|
||||||
prerender: {
|
prerender: {
|
||||||
routes: ['/'],
|
routes: ["/"],
|
||||||
crawlLinks: true,
|
crawlLinks: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
hooks: {
|
hooks: {
|
||||||
async 'nitro:config'(nitroConfig) {
|
"nitro:config": async function (nitroConfig) {
|
||||||
// 预渲染所有应用详情页
|
// 预渲染所有应用详情页
|
||||||
const { apps } = await import('./data/apps')
|
const { apps } = await import("./data/apps");
|
||||||
const routes = apps.map(app => `/apps/${app.id}`)
|
const routes = apps.map(app => `/apps/${app.id}`);
|
||||||
nitroConfig.prerender = nitroConfig.prerender || {}
|
nitroConfig.prerender = nitroConfig.prerender || {};
|
||||||
nitroConfig.prerender.routes = [
|
nitroConfig.prerender.routes = [
|
||||||
...(nitroConfig.prerender.routes || []),
|
...(nitroConfig.prerender.routes || []),
|
||||||
...routes,
|
...routes,
|
||||||
]
|
];
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
compatibilityDate: '2025-12-30',
|
compatibilityDate: "2025-12-30",
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@riwa/distribute",
|
"name": "@riwa/distribute",
|
||||||
"version": "1.0.0",
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nuxt dev",
|
"dev": "nuxt dev",
|
||||||
|
|||||||
@@ -1,72 +1,72 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AppInfo } from '~/types'
|
import type { AppInfo } from "~/types";
|
||||||
import { apps } from '~/data/apps'
|
import { apps } from "~/data/apps";
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute();
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n();
|
||||||
|
|
||||||
// 直接从数据文件获取应用详情
|
// 直接从数据文件获取应用详情
|
||||||
const app = computed(() => apps.find(a => a.id === route.params.id))
|
const app = computed(() => apps.find(a => a.id === route.params.id));
|
||||||
|
|
||||||
// 如果应用不存在,跳转回首页
|
// 如果应用不存在,跳转回首页
|
||||||
if (!app.value) {
|
if (!app.value) {
|
||||||
navigateTo('/')
|
navigateTo("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下载处理
|
// 下载处理
|
||||||
async function handleDownload(type: 'ios' | 'android' | 'h5') {
|
async function handleDownload(type: "ios" | "android" | "h5") {
|
||||||
if (!app.value) {
|
if (!app.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = app.value.downloads[type]
|
const url = app.value.downloads[type];
|
||||||
|
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'h5') {
|
if (type === "h5") {
|
||||||
navigateTo(url, { external: true, open: { target: '_blank' } })
|
navigateTo(url, { external: true, open: { target: "_blank" } });
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
navigateTo(url, { external: true })
|
navigateTo(url, { external: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
await $fetch(`/api/track/${type}`, {
|
await $fetch(`/api/track/${type}`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: { appId: app.value.id },
|
body: { appId: app.value.id },
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回首页
|
// 返回首页
|
||||||
function goBack() {
|
function goBack() {
|
||||||
router.back()
|
router.back();
|
||||||
}
|
}
|
||||||
|
|
||||||
// SEO
|
// SEO
|
||||||
useHead({
|
useHead({
|
||||||
title: app.value ? `${app.value.name} - Riwa App Store` : 'Riwa App Store',
|
title: app.value ? `${app.value.name} - Riwa App Store` : "Riwa App Store",
|
||||||
meta: [
|
meta: [
|
||||||
{
|
{
|
||||||
name: 'description',
|
name: "description",
|
||||||
content: app.value?.shortDescription[locale.value as 'zh-CN' | 'en-US'] || '',
|
content: app.value?.shortDescription[locale.value as "zh-CN" | "en-US"] || "",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="app" class="min-h-screen relative overflow-hidden bg-gradient-to-br from-gray-50 via-blue-50/30 to-purple-50/30 dark:from-gray-950 dark:via-blue-950/20 dark:to-purple-950/20">
|
<div v-if="app" class="min-h-screen relative overflow-hidden bg-gradient-to-br from-gray-50 via-blue-50/30 to-purple-50/30 dark:from-gray-950 dark:via-blue-950/20 dark:to-purple-950/20">
|
||||||
<!-- 科技感网格背景 -->
|
<!-- 科技感网格背景 -->
|
||||||
<div class="fixed inset-0 opacity-30 dark:opacity-20 pointer-events-none">
|
<div class="fixed inset-0 opacity-30 dark:opacity-20 pointer-events-none">
|
||||||
<div class="absolute inset-0" style="background-image: linear-gradient(rgba(99, 102, 241, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(99, 102, 241, 0.1) 1px, transparent 1px); background-size: 50px 50px;"></div>
|
<div class="absolute inset-0" style="background-image: linear-gradient(rgba(99, 102, 241, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(99, 102, 241, 0.1) 1px, transparent 1px); background-size: 50px 50px;" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 动态发光球体背景 -->
|
<!-- 动态发光球体背景 -->
|
||||||
<div class="fixed inset-0 pointer-events-none overflow-hidden">
|
<div class="fixed inset-0 pointer-events-none overflow-hidden">
|
||||||
<div class="absolute top-1/4 -left-48 w-96 h-96 bg-primary-500/20 rounded-full blur-3xl animate-pulse"></div>
|
<div class="absolute top-1/4 -left-48 w-96 h-96 bg-primary-500/20 rounded-full blur-3xl animate-pulse" />
|
||||||
<div class="absolute bottom-1/4 -right-48 w-96 h-96 bg-purple-500/20 rounded-full blur-3xl animate-pulse" style="animation-delay: 1s;"></div>
|
<div class="absolute bottom-1/4 -right-48 w-96 h-96 bg-purple-500/20 rounded-full blur-3xl animate-pulse" style="animation-delay: 1s;" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UContainer class="relative z-10">
|
<UContainer class="relative z-10">
|
||||||
@@ -91,10 +91,10 @@ useHead({
|
|||||||
<!-- App Header -->
|
<!-- App Header -->
|
||||||
<div class="flex items-start gap-6 mb-8 group">
|
<div class="flex items-start gap-6 mb-8 group">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute -inset-2 bg-gradient-to-r from-primary-500 via-purple-500 to-blue-500 rounded-3xl opacity-50 blur-xl group-hover:opacity-100 transition-all duration-500 animate-pulse"></div>
|
<div class="absolute -inset-2 bg-gradient-to-r from-primary-500 via-purple-500 to-blue-500 rounded-3xl opacity-50 blur-xl group-hover:opacity-100 transition-all duration-500 animate-pulse" />
|
||||||
<div class="size-24 rounded-3xl bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-bold text-4xl shrink-0 shadow-2xl shadow-blue-500/50 relative overflow-hidden group-hover:scale-110 transition-all duration-500 p-3">
|
<div class="size-24 rounded-3xl bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-bold text-4xl shrink-0 shadow-2xl shadow-blue-500/50 relative overflow-hidden group-hover:scale-110 transition-all duration-500 p-3">
|
||||||
<div class="absolute inset-0 bg-gradient-to-tr from-white/0 via-white/30 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700"></div>
|
<div class="absolute inset-0 bg-gradient-to-tr from-white/0 via-white/30 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700" />
|
||||||
<img :src="app.icon" :alt="app.name" class="size-full object-contain relative z-10 rounded-2xl" />
|
<img :src="app.icon" :alt="app.name" class="size-full object-contain relative z-10 rounded-2xl">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -115,7 +115,7 @@ useHead({
|
|||||||
<!-- Download Buttons -->
|
<!-- Download Buttons -->
|
||||||
<div class="grid md:grid-cols-3 gap-4 mb-8">
|
<div class="grid md:grid-cols-3 gap-4 mb-8">
|
||||||
<div v-if="app.downloads.ios" class="relative group">
|
<div v-if="app.downloads.ios" class="relative group">
|
||||||
<div class="absolute -inset-1 bg-gradient-to-r from-blue-500 to-cyan-500 rounded-xl opacity-0 group-hover:opacity-100 blur transition-all duration-500"></div>
|
<div class="absolute -inset-1 bg-gradient-to-r from-blue-500 to-cyan-500 rounded-xl opacity-0 group-hover:opacity-100 blur transition-all duration-500" />
|
||||||
<UButton
|
<UButton
|
||||||
icon="i-heroicons-device-phone-mobile"
|
icon="i-heroicons-device-phone-mobile"
|
||||||
size="xl"
|
size="xl"
|
||||||
@@ -123,16 +123,18 @@ useHead({
|
|||||||
class="relative transition-all duration-300 hover:shadow-2xl hover:shadow-blue-500/50 hover:-translate-y-2"
|
class="relative transition-all duration-300 hover:shadow-2xl hover:shadow-blue-500/50 hover:-translate-y-2"
|
||||||
@click="handleDownload('ios')"
|
@click="handleDownload('ios')"
|
||||||
>
|
>
|
||||||
<div class="text-left w-full">
|
<div class="text-left w-full">
|
||||||
<div class="font-semibold text-base">iOS</div>
|
<div class="font-semibold text-base">
|
||||||
<div v-if="app.size?.ios" class="text-xs opacity-80">
|
iOS
|
||||||
{{ app.size.ios }}
|
</div>
|
||||||
|
<div v-if="app.size?.ios" class="text-xs opacity-80">
|
||||||
|
{{ app.size.ios }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="app.downloads.android" class="relative group">
|
<div v-if="app.downloads.android" class="relative group">
|
||||||
<div class="absolute -inset-1 bg-gradient-to-r from-blue-500 to-cyan-500 rounded-xl opacity-0 group-hover:opacity-100 blur transition-all duration-500"></div>
|
<div class="absolute -inset-1 bg-gradient-to-r from-blue-500 to-cyan-500 rounded-xl opacity-0 group-hover:opacity-100 blur transition-all duration-500" />
|
||||||
<UButton
|
<UButton
|
||||||
icon="i-heroicons-device-tablet"
|
icon="i-heroicons-device-tablet"
|
||||||
size="xl"
|
size="xl"
|
||||||
@@ -140,16 +142,18 @@ useHead({
|
|||||||
class="relative transition-all duration-300 hover:shadow-2xl hover:shadow-blue-500/50 hover:-translate-y-2"
|
class="relative transition-all duration-300 hover:shadow-2xl hover:shadow-blue-500/50 hover:-translate-y-2"
|
||||||
@click="handleDownload('android')"
|
@click="handleDownload('android')"
|
||||||
>
|
>
|
||||||
<div class="text-left w-full">
|
<div class="text-left w-full">
|
||||||
<div class="font-semibold text-base">Android</div>
|
<div class="font-semibold text-base">
|
||||||
<div v-if="app.size?.android" class="text-xs opacity-80">
|
Android
|
||||||
{{ app.size.android }}
|
</div>
|
||||||
|
<div v-if="app.size?.android" class="text-xs opacity-80">
|
||||||
|
{{ app.size.android }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="app.downloads.h5" class="relative group">
|
<div v-if="app.downloads.h5" class="relative group">
|
||||||
<div class="absolute -inset-1 bg-gradient-to-r from-blue-500 to-cyan-500 rounded-xl opacity-0 group-hover:opacity-100 blur transition-all duration-500"></div>
|
<div class="absolute -inset-1 bg-gradient-to-r from-blue-500 to-cyan-500 rounded-xl opacity-0 group-hover:opacity-100 blur transition-all duration-500" />
|
||||||
<UButton
|
<UButton
|
||||||
icon="i-heroicons-globe-alt"
|
icon="i-heroicons-globe-alt"
|
||||||
size="xl"
|
size="xl"
|
||||||
@@ -158,7 +162,9 @@ useHead({
|
|||||||
@click="handleDownload('h5')"
|
@click="handleDownload('h5')"
|
||||||
>
|
>
|
||||||
<div class="text-left w-full">
|
<div class="text-left w-full">
|
||||||
<div class="font-semibold text-base">Web</div>
|
<div class="font-semibold text-base">
|
||||||
|
Web
|
||||||
|
</div>
|
||||||
<div class="text-xs opacity-80">
|
<div class="text-xs opacity-80">
|
||||||
PWA
|
PWA
|
||||||
</div>
|
</div>
|
||||||
@@ -170,12 +176,12 @@ useHead({
|
|||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
<div class="relative group">
|
<div class="relative group">
|
||||||
<div class="absolute -inset-0.5 bg-gradient-to-r from-primary-500 to-purple-500 rounded-xl opacity-0 group-hover:opacity-100 blur transition-all duration-500"></div>
|
<div class="absolute -inset-0.5 bg-gradient-to-r from-primary-500 to-purple-500 rounded-xl opacity-0 group-hover:opacity-100 blur transition-all duration-500" />
|
||||||
<UCard class="relative backdrop-blur-sm bg-white/90 dark:bg-gray-900/90 border border-gray-200/50 dark:border-gray-800/50 hover:shadow-xl hover:shadow-primary-500/20 transition-all duration-500 hover:-translate-y-1">
|
<UCard class="relative backdrop-blur-sm bg-white/90 dark:bg-gray-900/90 border border-gray-200/50 dark:border-gray-800/50 hover:shadow-xl hover:shadow-primary-500/20 transition-all duration-500 hover:-translate-y-1">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-3xl font-bold bg-gradient-to-r from-primary-500 to-purple-500 bg-clip-text text-transparent">
|
<div class="text-3xl font-bold bg-gradient-to-r from-primary-500 to-purple-500 bg-clip-text text-transparent">
|
||||||
{{ app.stats.total.toLocaleString() }}
|
{{ app.stats.total.toLocaleString() }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400 mt-2">
|
<div class="text-sm text-gray-600 dark:text-gray-400 mt-2">
|
||||||
{{ locale === 'zh-CN' ? '总下载' : 'Total Downloads' }}
|
{{ locale === 'zh-CN' ? '总下载' : 'Total Downloads' }}
|
||||||
</div>
|
</div>
|
||||||
@@ -183,7 +189,7 @@ useHead({
|
|||||||
</UCard>
|
</UCard>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative group">
|
<div class="relative group">
|
||||||
<div class="absolute -inset-0.5 bg-gradient-to-r from-purple-500 to-pink-500 rounded-xl opacity-0 group-hover:opacity-100 blur transition-all duration-500"></div>
|
<div class="absolute -inset-0.5 bg-gradient-to-r from-purple-500 to-pink-500 rounded-xl opacity-0 group-hover:opacity-100 blur transition-all duration-500" />
|
||||||
<UCard class="relative backdrop-blur-sm bg-white/90 dark:bg-gray-900/90 border border-gray-200/50 dark:border-gray-800/50 hover:shadow-xl hover:shadow-purple-500/20 transition-all duration-500 hover:-translate-y-1">
|
<UCard class="relative backdrop-blur-sm bg-white/90 dark:bg-gray-900/90 border border-gray-200/50 dark:border-gray-800/50 hover:shadow-xl hover:shadow-purple-500/20 transition-all duration-500 hover:-translate-y-1">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-3xl font-bold bg-gradient-to-r from-purple-500 to-pink-500 bg-clip-text text-transparent">
|
<div class="text-3xl font-bold bg-gradient-to-r from-purple-500 to-pink-500 bg-clip-text text-transparent">
|
||||||
@@ -196,7 +202,7 @@ useHead({
|
|||||||
</UCard>
|
</UCard>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative group">
|
<div class="relative group">
|
||||||
<div class="absolute -inset-0.5 bg-gradient-to-r from-blue-500 to-cyan-500 rounded-xl opacity-0 group-hover:opacity-100 blur transition-all duration-500"></div>
|
<div class="absolute -inset-0.5 bg-gradient-to-r from-blue-500 to-cyan-500 rounded-xl opacity-0 group-hover:opacity-100 blur transition-all duration-500" />
|
||||||
<UCard class="relative backdrop-blur-sm bg-white/90 dark:bg-gray-900/90 border border-gray-200/50 dark:border-gray-800/50 hover:shadow-xl hover:shadow-blue-500/20 transition-all duration-500 hover:-translate-y-1">
|
<UCard class="relative backdrop-blur-sm bg-white/90 dark:bg-gray-900/90 border border-gray-200/50 dark:border-gray-800/50 hover:shadow-xl hover:shadow-blue-500/20 transition-all duration-500 hover:-translate-y-1">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-3xl font-bold bg-gradient-to-r from-blue-500 to-cyan-500 bg-clip-text text-transparent">
|
<div class="text-3xl font-bold bg-gradient-to-r from-blue-500 to-cyan-500 bg-clip-text text-transparent">
|
||||||
@@ -209,7 +215,7 @@ useHead({
|
|||||||
</UCard>
|
</UCard>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative group">
|
<div class="relative group">
|
||||||
<div class="absolute -inset-0.5 bg-gradient-to-r from-indigo-500 to-purple-500 rounded-xl opacity-0 group-hover:opacity-100 blur transition-all duration-500"></div>
|
<div class="absolute -inset-0.5 bg-gradient-to-r from-indigo-500 to-purple-500 rounded-xl opacity-0 group-hover:opacity-100 blur transition-all duration-500" />
|
||||||
<UCard class="relative backdrop-blur-sm bg-white/90 dark:bg-gray-900/90 border border-gray-200/50 dark:border-gray-800/50 hover:shadow-xl hover:shadow-indigo-500/20 transition-all duration-500 hover:-translate-y-1">
|
<UCard class="relative backdrop-blur-sm bg-white/90 dark:bg-gray-900/90 border border-gray-200/50 dark:border-gray-800/50 hover:shadow-xl hover:shadow-indigo-500/20 transition-all duration-500 hover:-translate-y-1">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-3xl font-bold bg-gradient-to-r from-indigo-500 to-purple-500 bg-clip-text text-transparent">
|
<div class="text-3xl font-bold bg-gradient-to-r from-indigo-500 to-purple-500 bg-clip-text text-transparent">
|
||||||
@@ -225,7 +231,7 @@ useHead({
|
|||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div class="relative group mb-8">
|
<div class="relative group mb-8">
|
||||||
<div class="absolute -inset-0.5 bg-gradient-to-r from-primary-500 via-purple-500 to-blue-500 rounded-xl opacity-0 group-hover:opacity-30 blur transition-all duration-500"></div>
|
<div class="absolute -inset-0.5 bg-gradient-to-r from-primary-500 via-purple-500 to-blue-500 rounded-xl opacity-0 group-hover:opacity-30 blur transition-all duration-500" />
|
||||||
<UCard class="relative backdrop-blur-sm bg-white/90 dark:bg-gray-900/90 border border-gray-200/50 dark:border-gray-800/50 hover:shadow-xl hover:shadow-primary-500/10 transition-all duration-500">
|
<UCard class="relative backdrop-blur-sm bg-white/90 dark:bg-gray-900/90 border border-gray-200/50 dark:border-gray-800/50 hover:shadow-xl hover:shadow-primary-500/10 transition-all duration-500">
|
||||||
<h3 class="text-xl font-semibold bg-gradient-to-r from-gray-900 via-primary-600 to-purple-600 dark:from-white dark:via-primary-400 dark:to-purple-400 bg-clip-text text-transparent mb-4">
|
<h3 class="text-xl font-semibold bg-gradient-to-r from-gray-900 via-primary-600 to-purple-600 dark:from-white dark:via-primary-400 dark:to-purple-400 bg-clip-text text-transparent mb-4">
|
||||||
{{ locale === 'zh-CN' ? '应用介绍' : 'Description' }}
|
{{ locale === 'zh-CN' ? '应用介绍' : 'Description' }}
|
||||||
@@ -238,11 +244,11 @@ useHead({
|
|||||||
|
|
||||||
<!-- What's New -->
|
<!-- What's New -->
|
||||||
<div class="relative group">
|
<div class="relative group">
|
||||||
<div class="absolute -inset-0.5 bg-gradient-to-r from-purple-500 via-pink-500 to-rose-500 rounded-xl opacity-0 group-hover:opacity-30 blur transition-all duration-500"></div>
|
<div class="absolute -inset-0.5 bg-gradient-to-r from-purple-500 via-pink-500 to-rose-500 rounded-xl opacity-0 group-hover:opacity-30 blur transition-all duration-500" />
|
||||||
<UCard class="relative backdrop-blur-sm bg-white/90 dark:bg-gray-900/90 border border-gray-200/50 dark:border-gray-800/50 hover:shadow-xl hover:shadow-purple-500/10 transition-all duration-500">
|
<UCard class="relative backdrop-blur-sm bg-white/90 dark:bg-gray-900/90 border border-gray-200/50 dark:border-gray-800/50 hover:shadow-xl hover:shadow-purple-500/10 transition-all duration-500">
|
||||||
<h3 class="text-xl font-semibold bg-gradient-to-r from-gray-900 via-purple-600 to-pink-600 dark:from-white dark:via-purple-400 dark:to-pink-400 bg-clip-text text-transparent mb-4">
|
<h3 class="text-xl font-semibold bg-gradient-to-r from-gray-900 via-purple-600 to-pink-600 dark:from-white dark:via-purple-400 dark:to-pink-400 bg-clip-text text-transparent mb-4">
|
||||||
{{ locale === 'zh-CN' ? '更新内容' : "What's New" }}
|
{{ locale === 'zh-CN' ? '更新内容' : "What's New" }}
|
||||||
</h3>
|
</h3>
|
||||||
<ul class="space-y-3">
|
<ul class="space-y-3">
|
||||||
<li
|
<li
|
||||||
v-for="(note, index) in app.releaseNotes[locale as 'zh-CN' | 'en-US']"
|
v-for="(note, index) in app.releaseNotes[locale as 'zh-CN' | 'en-US']"
|
||||||
|
|||||||
@@ -1,104 +1,104 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AppInfo } from '~/types'
|
import type { AppInfo } from "~/types";
|
||||||
|
|
||||||
import { apps as appsData, categories as categoriesData } from '~/data/apps'
|
import { apps as appsData, categories as categoriesData } from "~/data/apps";
|
||||||
|
|
||||||
const { t, locale, setLocale } = useI18n()
|
const { t, locale, setLocale } = useI18n();
|
||||||
const colorMode = useColorMode()
|
const colorMode = useColorMode();
|
||||||
|
|
||||||
// 直接使用数据文件
|
// 直接使用数据文件
|
||||||
const apps = computed(() => appsData)
|
const apps = computed(() => appsData);
|
||||||
const categories = computed(() => categoriesData)
|
const categories = computed(() => categoriesData);
|
||||||
|
|
||||||
// 当前选中的分类
|
// 当前选中的分类
|
||||||
const selectedCategory = ref('all')
|
const selectedCategory = ref("all");
|
||||||
|
|
||||||
// 搜索关键词
|
// 搜索关键词
|
||||||
const searchKeyword = ref('')
|
const searchKeyword = ref("");
|
||||||
|
|
||||||
// 过滤后的应用列表
|
// 过滤后的应用列表
|
||||||
const filteredApps = computed(() => {
|
const filteredApps = computed(() => {
|
||||||
let result = apps.value
|
let result = apps.value;
|
||||||
|
|
||||||
// 按分类过滤
|
// 按分类过滤
|
||||||
if (selectedCategory.value !== 'all') {
|
if (selectedCategory.value !== "all") {
|
||||||
result = result.filter(app => app.category === selectedCategory.value)
|
result = result.filter(app => app.category === selectedCategory.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按搜索关键词过滤
|
// 按搜索关键词过滤
|
||||||
if (searchKeyword.value.trim()) {
|
if (searchKeyword.value.trim()) {
|
||||||
const keyword = searchKeyword.value.toLowerCase()
|
const keyword = searchKeyword.value.toLowerCase();
|
||||||
result = result.filter(app =>
|
result = result.filter(app =>
|
||||||
app.name.toLowerCase().includes(keyword)
|
app.name.toLowerCase().includes(keyword)
|
||||||
|| app.shortDescription[locale.value as 'zh-CN' | 'en-US'].toLowerCase().includes(keyword),
|
|| app.shortDescription[locale.value as "zh-CN" | "en-US"].toLowerCase().includes(keyword),
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result;
|
||||||
})
|
});
|
||||||
|
|
||||||
// 切换语言
|
// 切换语言
|
||||||
function toggleLanguage() {
|
function toggleLanguage() {
|
||||||
setLocale(locale.value === 'zh-CN' ? 'en-US' : 'zh-CN')
|
setLocale(locale.value === "zh-CN" ? "en-US" : "zh-CN");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打开应用详情
|
// 打开应用详情
|
||||||
function openAppDetail(app: AppInfo) {
|
function openAppDetail(app: AppInfo) {
|
||||||
navigateTo(`/apps/${app.id}`)
|
navigateTo(`/apps/${app.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下载处理
|
// 下载处理
|
||||||
async function handleDownload(app: AppInfo, type: 'ios' | 'android' | 'h5', event?: MouseEvent | TouchEvent) {
|
async function handleDownload(app: AppInfo, type: "ios" | "android" | "h5", event?: MouseEvent | TouchEvent) {
|
||||||
const url = app.downloads[type]
|
const url = app.downloads[type];
|
||||||
|
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'h5') {
|
if (type === "h5") {
|
||||||
navigateTo(url, { external: true, open: { target: '_blank' } })
|
navigateTo(url, { external: true, open: { target: "_blank" } });
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
navigateTo(url, { external: true })
|
navigateTo(url, { external: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
await $fetch(`/api/track/${type}`, {
|
await $fetch(`/api/track/${type}`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: { appId: app.id },
|
body: { appId: app.id },
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDark = computed(() => colorMode.value === 'dark')
|
const isDark = computed(() => colorMode.value === "dark");
|
||||||
|
|
||||||
// SEO
|
// SEO
|
||||||
useHead({
|
useHead({
|
||||||
title: locale.value === 'zh-CN' ? 'Riwa 应用商店' : 'Riwa App Store',
|
title: locale.value === "zh-CN" ? "Riwa 应用商店" : "Riwa App Store",
|
||||||
meta: [
|
meta: [
|
||||||
{
|
{
|
||||||
name: 'description',
|
name: "description",
|
||||||
content: locale.value === 'zh-CN'
|
content: locale.value === "zh-CN"
|
||||||
? '下载 Riwa 系列应用,包括 Riwa 主应用、Riwa 钱包和 Riwa 聊天等'
|
? "下载 Riwa 系列应用,包括 Riwa 主应用、Riwa 钱包和 Riwa 聊天等"
|
||||||
: 'Download Riwa apps including Riwa main app, Riwa Wallet, and Riwa Chat',
|
: "Download Riwa apps including Riwa main app, Riwa Wallet, and Riwa Chat",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen relative overflow-hidden bg-linear-to-br from-gray-50 via-blue-50/30 to-purple-50/30 dark:from-gray-950 dark:via-blue-950/20 dark:to-purple-950/20">
|
<div class="min-h-screen relative overflow-hidden bg-linear-to-br from-gray-50 via-blue-50/30 to-purple-50/30 dark:from-gray-950 dark:via-blue-950/20 dark:to-purple-950/20">
|
||||||
<!-- 科技感网格背景 -->
|
<!-- 科技感网格背景 -->
|
||||||
<div class="fixed inset-0 opacity-30 dark:opacity-20 pointer-events-none">
|
<div class="fixed inset-0 opacity-30 dark:opacity-20 pointer-events-none">
|
||||||
<div class="absolute inset-0 animate-pulse-subtle" style="background-image: linear-gradient(rgba(99, 102, 241, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(99, 102, 241, 0.1) 1px, transparent 1px); background-size: 50px 50px;"></div>
|
<div class="absolute inset-0 animate-pulse-subtle" style="background-image: linear-gradient(rgba(99, 102, 241, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(99, 102, 241, 0.1) 1px, transparent 1px); background-size: 50px 50px;" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 动态发光球体背景 -->
|
<!-- 动态发光球体背景 -->
|
||||||
<div class="fixed inset-0 pointer-events-none overflow-hidden">
|
<div class="fixed inset-0 pointer-events-none overflow-hidden">
|
||||||
<div class="absolute top-1/4 -left-48 w-96 h-96 bg-primary-500/20 rounded-full blur-3xl animate-breathe"></div>
|
<div class="absolute top-1/4 -left-48 w-96 h-96 bg-primary-500/20 rounded-full blur-3xl animate-breathe" />
|
||||||
<div class="absolute bottom-1/4 -right-48 w-96 h-96 bg-purple-500/20 rounded-full blur-3xl animate-breathe" style="animation-delay: 1s;"></div>
|
<div class="absolute bottom-1/4 -right-48 w-96 h-96 bg-purple-500/20 rounded-full blur-3xl animate-breathe" style="animation-delay: 1s;" />
|
||||||
<div class="absolute top-1/2 left-1/2 w-96 h-96 bg-blue-500/10 rounded-full blur-3xl animate-breathe" style="animation-delay: 2s;"></div>
|
<div class="absolute top-1/2 left-1/2 w-96 h-96 bg-blue-500/10 rounded-full blur-3xl animate-breathe" style="animation-delay: 2s;" />
|
||||||
<!-- 额外的动态光球 -->
|
<!-- 额外的动态光球 -->
|
||||||
<div class="absolute top-1/3 right-1/4 w-64 h-64 bg-cyan-500/15 rounded-full blur-2xl animate-float" style="animation-delay: 0.5s;"></div>
|
<div class="absolute top-1/3 right-1/4 w-64 h-64 bg-cyan-500/15 rounded-full blur-2xl animate-float" style="animation-delay: 0.5s;" />
|
||||||
<div class="absolute bottom-1/3 left-1/3 w-72 h-72 bg-indigo-500/15 rounded-full blur-2xl animate-float" style="animation-delay: 1.5s;"></div>
|
<div class="absolute bottom-1/3 left-1/3 w-72 h-72 bg-indigo-500/15 rounded-full blur-2xl animate-float" style="animation-delay: 1.5s;" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
@@ -107,8 +107,8 @@ useHead({
|
|||||||
<div class="flex items-center justify-between py-4">
|
<div class="flex items-center justify-between py-4">
|
||||||
<div class="flex items-center gap-3 group">
|
<div class="flex items-center gap-3 group">
|
||||||
<div class="size-10 rounded-xl bg-linear-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-bold text-xl relative overflow-hidden shadow-lg shadow-blue-500/50">
|
<div class="size-10 rounded-xl bg-linear-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-bold text-xl relative overflow-hidden shadow-lg shadow-blue-500/50">
|
||||||
<div class="absolute inset-0 bg-linear-to-tr from-white/0 via-white/20 to-white/0 -translate-x-full group-hover:translate-x-full transition-transform duration-700"></div>
|
<div class="absolute inset-0 bg-linear-to-tr from-white/0 via-white/20 to-white/0 -translate-x-full group-hover:translate-x-full transition-transform duration-700" />
|
||||||
<div class="absolute -inset-1 bg-blue-400/30 rounded-xl blur-md opacity-0 group-hover:opacity-100 animate-pulse-ring"></div>
|
<div class="absolute -inset-1 bg-blue-400/30 rounded-xl blur-md opacity-0 group-hover:opacity-100 animate-pulse-ring" />
|
||||||
<span class="relative z-10">R</span>
|
<span class="relative z-10">R</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-xl font-bold bg-linear-to-r from-gray-900 via-primary-600 to-purple-600 dark:from-white dark:via-primary-400 dark:to-purple-400 bg-clip-text text-transparent animate-gradient">
|
<h1 class="text-xl font-bold bg-linear-to-r from-gray-900 via-primary-600 to-purple-600 dark:from-white dark:via-primary-400 dark:to-purple-400 bg-clip-text text-transparent animate-gradient">
|
||||||
@@ -180,30 +180,30 @@ useHead({
|
|||||||
class="cursor-pointer backdrop-blur-sm bg-white/90 dark:bg-gray-900/90 border border-gray-200/50 dark:border-gray-800/50 duration-500 hover:shadow-2xl hover:shadow-primary-500/20 hover:-translate-y-2 hover:rotate-y-2 active:scale-95 active:shadow-lg active:shadow-primary-500/40 relative overflow-hidden"
|
class="cursor-pointer backdrop-blur-sm bg-white/90 dark:bg-gray-900/90 border border-gray-200/50 dark:border-gray-800/50 duration-500 hover:shadow-2xl hover:shadow-primary-500/20 hover:-translate-y-2 hover:rotate-y-2 active:scale-95 active:shadow-lg active:shadow-primary-500/40 relative overflow-hidden"
|
||||||
@click="openAppDetail(app)"
|
@click="openAppDetail(app)"
|
||||||
>
|
>
|
||||||
<!-- 内部发光效果 -->
|
<!-- 内部发光效果 -->
|
||||||
<div class="absolute inset-0 bg-linear-to-br from-primary-500/5 to-purple-500/5"></div>
|
<div class="absolute inset-0 bg-linear-to-br from-primary-500/5 to-purple-500/5" />
|
||||||
|
|
||||||
<div class="flex items-start gap-4 relative z-10">
|
<div class="flex items-start gap-4 relative z-10">
|
||||||
<!-- App Icon -->
|
<!-- App Icon -->
|
||||||
<div class="size-16 rounded-2xl bg-linear-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-bold text-2xl shrink-0 shadow-lg shadow-blue-500/50 relative overflow-hidden group-hover:shadow-2xl group-hover:shadow-blue-500/60 transition-all duration-500 group-hover:scale-110 group-hover:rotate-3 group-active:scale-105 group-active:rotate-1 p-2">
|
<div class="size-16 rounded-2xl bg-linear-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-bold text-2xl shrink-0 shadow-lg shadow-blue-500/50 relative overflow-hidden group-hover:shadow-2xl group-hover:shadow-blue-500/60 transition-all duration-500 group-hover:scale-110 group-hover:rotate-3 group-active:scale-105 group-active:rotate-1 p-2">
|
||||||
<!-- 动态发光效果 -->
|
<!-- 动态发光效果 -->
|
||||||
<div class="absolute inset-0 bg-linear-to-tr from-white/0 via-white/30 to-white/0 -translate-x-full group-hover:translate-x-full group-active:translate-x-[50%] transition-transform duration-700"></div>
|
<div class="absolute inset-0 bg-linear-to-tr from-white/0 via-white/30 to-white/0 -translate-x-full group-hover:translate-x-full group-active:translate-x-[50%] transition-transform duration-700" />
|
||||||
<img :src="app.icon" :alt="app.name" class="size-full object-contain relative z-10 rounded-lg transition-all duration-300 group-active:scale-95 group-hover:-rotate-3" />
|
<img :src="app.icon" :alt="app.name" class="size-full object-contain relative z-10 rounded-lg transition-all duration-300 group-active:scale-95 group-hover:-rotate-3">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- App Info -->
|
<!-- App Info -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<h3 class="font-bold text-lg text-gray-900 dark:text-white truncate group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors duration-300">
|
<h3 class="font-bold text-lg text-gray-900 dark:text-white truncate group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors duration-300">
|
||||||
{{ app.name }}
|
{{ app.name }}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 mt-1 transition-colors duration-300 group-hover:text-gray-700 dark:group-hover:text-gray-300">
|
<p class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 mt-1 transition-colors duration-300 group-hover:text-gray-700 dark:group-hover:text-gray-300">
|
||||||
{{ app.shortDescription[locale as 'zh-CN' | 'en-US'] }}
|
{{ app.shortDescription[locale as 'zh-CN' | 'en-US'] }}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex items-center gap-3 mt-3 text-xs text-gray-500 dark:text-gray-500 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors duration-300">
|
<div class="flex items-center gap-3 mt-3 text-xs text-gray-500 dark:text-gray-500 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors duration-300">
|
||||||
<span class="animate-pulse-subtle">v{{ app.version }}</span>
|
<span class="animate-pulse-subtle">v{{ app.version }}</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span class="font-medium">{{ app.stats.total.toLocaleString() }} {{ locale === 'zh-CN' ? '次下载' : 'downloads' }}</span>
|
<span class="font-medium">{{ app.stats.total.toLocaleString() }} {{ locale === 'zh-CN' ? '次下载' : 'downloads' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -217,8 +217,7 @@ useHead({
|
|||||||
block
|
block
|
||||||
class="transition-all duration-300 hover:shadow-lg hover:shadow-blue-500/50 hover:-translate-y-1 active:scale-95 active:shadow-xl active:shadow-blue-500/60 relative overflow-hidden group/btn"
|
class="transition-all duration-300 hover:shadow-lg hover:shadow-blue-500/50 hover:-translate-y-1 active:scale-95 active:shadow-xl active:shadow-blue-500/60 relative overflow-hidden group/btn"
|
||||||
@click.stop="(e) => handleDownload(app, 'ios', e)"
|
@click.stop="(e) => handleDownload(app, 'ios', e)"
|
||||||
>
|
/>
|
||||||
</UButton>
|
|
||||||
<UButton
|
<UButton
|
||||||
v-if="app.downloads.android"
|
v-if="app.downloads.android"
|
||||||
icon="i-heroicons-device-tablet"
|
icon="i-heroicons-device-tablet"
|
||||||
@@ -227,8 +226,7 @@ useHead({
|
|||||||
block
|
block
|
||||||
class="transition-all duration-300 hover:shadow-lg hover:shadow-blue-500/50 hover:-translate-y-1 active:scale-95 active:shadow-xl active:shadow-blue-500/60 relative overflow-hidden group/btn"
|
class="transition-all duration-300 hover:shadow-lg hover:shadow-blue-500/50 hover:-translate-y-1 active:scale-95 active:shadow-xl active:shadow-blue-500/60 relative overflow-hidden group/btn"
|
||||||
@click.stop="(e) => handleDownload(app, 'android', e)"
|
@click.stop="(e) => handleDownload(app, 'android', e)"
|
||||||
>
|
/>
|
||||||
</UButton>
|
|
||||||
<UButton
|
<UButton
|
||||||
v-if="app.downloads.h5"
|
v-if="app.downloads.h5"
|
||||||
icon="i-heroicons-globe-alt"
|
icon="i-heroicons-globe-alt"
|
||||||
@@ -237,8 +235,7 @@ useHead({
|
|||||||
block
|
block
|
||||||
class="transition-all duration-300 hover:shadow-lg hover:shadow-blue-500/50 hover:-translate-y-1 active:scale-95 active:shadow-xl active:shadow-blue-500/60 relative overflow-hidden group/btn"
|
class="transition-all duration-300 hover:shadow-lg hover:shadow-blue-500/50 hover:-translate-y-1 active:scale-95 active:shadow-xl active:shadow-blue-500/60 relative overflow-hidden group/btn"
|
||||||
@click.stop="(e) => handleDownload(app, 'h5', e)"
|
@click.stop="(e) => handleDownload(app, 'h5', e)"
|
||||||
>
|
/>
|
||||||
</UButton>
|
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
</div>
|
</div>
|
||||||
@@ -262,4 +259,4 @@ useHead({
|
|||||||
</UContainer>
|
</UContainer>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { getAllApps, categories } from '~/data/apps'
|
import { categories, getAllApps } from "~/data/apps";
|
||||||
|
|
||||||
export default defineEventHandler(() => {
|
export default defineEventHandler(() => {
|
||||||
return {
|
return {
|
||||||
apps: getAllApps(),
|
apps: getAllApps(),
|
||||||
categories,
|
categories,
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import { getAppById } from '~/data/apps'
|
import { getAppById } from "~/data/apps";
|
||||||
|
|
||||||
export default defineEventHandler((event) => {
|
export default defineEventHandler((event) => {
|
||||||
const id = getRouterParam(event, 'id')
|
const id = getRouterParam(event, "id");
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
message: 'App ID is required',
|
message: "App ID is required",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = getAppById(id)
|
const app = getAppById(id);
|
||||||
|
|
||||||
if (!app) {
|
if (!app) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 404,
|
statusCode: 404,
|
||||||
message: 'App not found',
|
message: "App not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return app
|
return app;
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export default defineEventHandler(async () => {
|
export default defineEventHandler(async () => {
|
||||||
return await fetchDownloadStats()
|
return await fetchDownloadStats();
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const platform = getRouterParam(event, 'platform') as 'ios' | 'android' | 'h5'
|
const platform = getRouterParam(event, "platform") as "ios" | "android" | "h5";
|
||||||
const body = await readBody(event)
|
const body = await readBody(event);
|
||||||
const appId = body?.appId
|
const appId = body?.appId;
|
||||||
|
|
||||||
if (!['ios', 'android', 'h5'].includes(platform)) {
|
if (!["ios", "android", "h5"].includes(platform)) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
message: 'Invalid platform',
|
message: "Invalid platform",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await trackDownload(platform)
|
await trackDownload(platform);
|
||||||
|
|
||||||
return { success: true, appId, platform }
|
return { success: true, appId, platform };
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { currentVersion } from '~/data/versions'
|
import { currentVersion } from "~/data/versions";
|
||||||
|
|
||||||
export default defineEventHandler(() => {
|
export default defineEventHandler(() => {
|
||||||
return currentVersion
|
return currentVersion;
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import type { DownloadStats } from '~/types'
|
import type { DownloadStats } from "~/types";
|
||||||
import { mockDownloadStats } from '~/data/versions'
|
import { mockDownloadStats } from "~/data/versions";
|
||||||
|
|
||||||
// 获取下载统计(可替换为真实 API)
|
// 获取下载统计(可替换为真实 API)
|
||||||
export async function fetchDownloadStats(): Promise<DownloadStats> {
|
export async function fetchDownloadStats(): Promise<DownloadStats> {
|
||||||
// 模拟 API 延迟
|
// 模拟 API 延迟
|
||||||
await new Promise(resolve => setTimeout(resolve, 500))
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
return mockDownloadStats
|
return mockDownloadStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 记录下载事件(可替换为真实 API)
|
// 记录下载事件(可替换为真实 API)
|
||||||
export async function trackDownload(platform: 'ios' | 'android' | 'h5'): Promise<void> {
|
export async function trackDownload(platform: "ios" | "android" | "h5"): Promise<void> {
|
||||||
// 模拟 API 延迟
|
// 模拟 API 延迟
|
||||||
await new Promise(resolve => setTimeout(resolve, 200))
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
console.log(`Download tracked: ${platform}`)
|
console.log(`Download tracked: ${platform}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +1,65 @@
|
|||||||
export interface AppInfo {
|
export interface AppInfo {
|
||||||
id: string
|
id: string;
|
||||||
name: string
|
name: string;
|
||||||
icon: string
|
icon: string;
|
||||||
shortDescription: {
|
shortDescription: {
|
||||||
'zh-CN': string
|
"zh-CN": string;
|
||||||
'en-US': string
|
"en-US": string;
|
||||||
}
|
};
|
||||||
description: {
|
description: {
|
||||||
'zh-CN': string
|
"zh-CN": string;
|
||||||
'en-US': string
|
"en-US": string;
|
||||||
}
|
};
|
||||||
category: string
|
category: string;
|
||||||
version: string
|
version: string;
|
||||||
buildNumber: string
|
buildNumber: string;
|
||||||
releaseDate: string
|
releaseDate: string;
|
||||||
releaseNotes: {
|
releaseNotes: {
|
||||||
'zh-CN': string[]
|
"zh-CN": string[];
|
||||||
'en-US': string[]
|
"en-US": string[];
|
||||||
}
|
};
|
||||||
downloads: {
|
downloads: {
|
||||||
ios?: string
|
ios?: string;
|
||||||
android?: string
|
android?: string;
|
||||||
h5?: string
|
h5?: string;
|
||||||
}
|
};
|
||||||
screenshots?: string[]
|
screenshots?: string[];
|
||||||
size?: {
|
size?: {
|
||||||
ios?: string
|
ios?: string;
|
||||||
android?: string
|
android?: string;
|
||||||
}
|
};
|
||||||
stats: DownloadStats
|
stats: DownloadStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppVersion {
|
export interface AppVersion {
|
||||||
version: string
|
version: string;
|
||||||
buildNumber: string
|
buildNumber: string;
|
||||||
releaseDate: string
|
releaseDate: string;
|
||||||
releaseNotes: {
|
releaseNotes: {
|
||||||
'zh-CN': string[]
|
"zh-CN": string[];
|
||||||
'en-US': string[]
|
"en-US": string[];
|
||||||
}
|
};
|
||||||
downloads: {
|
downloads: {
|
||||||
ios: string
|
ios: string;
|
||||||
android: string
|
android: string;
|
||||||
h5: string
|
h5: string;
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadStats {
|
export interface DownloadStats {
|
||||||
total: number
|
total: number;
|
||||||
today: number
|
today: number;
|
||||||
ios: number
|
ios: number;
|
||||||
android: number
|
android: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Platform = 'ios' | 'android' | 'desktop' | 'unknown'
|
export type Platform = "ios" | "android" | "desktop" | "unknown";
|
||||||
|
|
||||||
export interface AppCategory {
|
export interface AppCategory {
|
||||||
id: string
|
id: string;
|
||||||
name: {
|
name: {
|
||||||
'zh-CN': string
|
"zh-CN": string;
|
||||||
'en-US': string
|
"en-US": string;
|
||||||
}
|
};
|
||||||
icon?: string
|
icon?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
48
pnpm-lock.yaml
generated
48
pnpm-lock.yaml
generated
@@ -62,9 +62,9 @@ importers:
|
|||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.1.18
|
specifier: ^4.1.18
|
||||||
version: 4.1.18(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))
|
version: 4.1.18(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))
|
||||||
'@vee-validate/yup':
|
'@vee-validate/zod':
|
||||||
specifier: ^4.15.1
|
specifier: ^4.15.1
|
||||||
version: 4.15.1(vue@3.5.25(typescript@5.9.3))(yup@1.7.1)
|
version: 4.15.1(vue@3.5.25(typescript@5.9.3))(zod@3.25.76)
|
||||||
'@vueuse/core':
|
'@vueuse/core':
|
||||||
specifier: ^14.1.0
|
specifier: ^14.1.0
|
||||||
version: 14.1.0(vue@3.5.25(typescript@5.9.3))
|
version: 14.1.0(vue@3.5.25(typescript@5.9.3))
|
||||||
@@ -119,9 +119,9 @@ importers:
|
|||||||
vue-router:
|
vue-router:
|
||||||
specifier: ^4.6.3
|
specifier: ^4.6.3
|
||||||
version: 4.6.3(vue@3.5.25(typescript@5.9.3))
|
version: 4.6.3(vue@3.5.25(typescript@5.9.3))
|
||||||
yup:
|
zod:
|
||||||
specifier: ^1.7.1
|
specifier: ^3.24.1
|
||||||
version: 1.7.1
|
version: 3.25.76
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@antfu/eslint-config':
|
'@antfu/eslint-config':
|
||||||
specifier: ^6.6.1
|
specifier: ^6.6.1
|
||||||
@@ -3692,10 +3692,10 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@vee-validate/yup@4.15.1':
|
'@vee-validate/zod@4.15.1':
|
||||||
resolution: {integrity: sha512-+u6lI1IZftjHphj+mTCPJRruwBBwv1IKKCI1EFm6ipQroAPibkS5M8UNX+yeVYG5++ix6m1rsv4/SJvJJQTWJg==}
|
resolution: {integrity: sha512-329Z4TDBE5Vx0FdbA8S4eR9iGCFFUNGbxjpQ20ff5b5wGueScjocUIx9JHPa79LTG06RnlUR4XogQsjN4tecKA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
yup: ^1.3.2
|
zod: ^3.24.0
|
||||||
|
|
||||||
'@vercel/nft@0.30.4':
|
'@vercel/nft@0.30.4':
|
||||||
resolution: {integrity: sha512-wE6eAGSXScra60N2l6jWvNtVK0m+sh873CpfZW4KI2v8EHuUQp+mSEi4T+IcdPCSEDgCdAS/7bizbhQlkjzrSA==}
|
resolution: {integrity: sha512-wE6eAGSXScra60N2l6jWvNtVK0m+sh873CpfZW4KI2v8EHuUQp+mSEi4T+IcdPCSEDgCdAS/7bizbhQlkjzrSA==}
|
||||||
@@ -9264,6 +9264,9 @@ packages:
|
|||||||
zod@3.22.3:
|
zod@3.22.3:
|
||||||
resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==}
|
resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==}
|
||||||
|
|
||||||
|
zod@3.25.76:
|
||||||
|
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||||
|
|
||||||
zod@4.1.13:
|
zod@4.1.13:
|
||||||
resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==}
|
resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==}
|
||||||
|
|
||||||
@@ -10036,7 +10039,7 @@ snapshots:
|
|||||||
'@babel/helper-string-parser': 7.27.1
|
'@babel/helper-string-parser': 7.27.1
|
||||||
'@babel/helper-validator-identifier': 7.28.5
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
|
|
||||||
'@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.13))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)':
|
'@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@3.25.76))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/utils': 0.3.0
|
'@better-auth/utils': 0.3.0
|
||||||
'@better-fetch/fetch': 1.1.18
|
'@better-fetch/fetch': 1.1.18
|
||||||
@@ -10047,9 +10050,9 @@ snapshots:
|
|||||||
nanostores: 1.1.0
|
nanostores: 1.1.0
|
||||||
zod: 4.1.13
|
zod: 4.1.13
|
||||||
|
|
||||||
'@better-auth/telemetry@1.4.6(@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.13))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))':
|
'@better-auth/telemetry@1.4.6(@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@3.25.76))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.13))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)
|
'@better-auth/core': 1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@3.25.76))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)
|
||||||
'@better-auth/utils': 0.3.0
|
'@better-auth/utils': 0.3.0
|
||||||
'@better-fetch/fetch': 1.1.18
|
'@better-fetch/fetch': 1.1.18
|
||||||
|
|
||||||
@@ -12957,11 +12960,11 @@ snapshots:
|
|||||||
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@vee-validate/yup@4.15.1(vue@3.5.25(typescript@5.9.3))(yup@1.7.1)':
|
'@vee-validate/zod@4.15.1(vue@3.5.25(typescript@5.9.3))(zod@3.25.76)':
|
||||||
dependencies:
|
dependencies:
|
||||||
type-fest: 4.41.0
|
type-fest: 4.41.0
|
||||||
vee-validate: 4.15.1(vue@3.5.25(typescript@5.9.3))
|
vee-validate: 4.15.1(vue@3.5.25(typescript@5.9.3))
|
||||||
yup: 1.7.1
|
zod: 3.25.76
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
@@ -13584,8 +13587,8 @@ snapshots:
|
|||||||
|
|
||||||
better-auth@1.4.6(vue@3.5.25(typescript@5.9.3)):
|
better-auth@1.4.6(vue@3.5.25(typescript@5.9.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.13))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)
|
'@better-auth/core': 1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@3.25.76))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)
|
||||||
'@better-auth/telemetry': 1.4.6(@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.13))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))
|
'@better-auth/telemetry': 1.4.6(@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@3.25.76))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))
|
||||||
'@better-auth/utils': 0.3.0
|
'@better-auth/utils': 0.3.0
|
||||||
'@better-fetch/fetch': 1.1.18
|
'@better-fetch/fetch': 1.1.18
|
||||||
'@noble/ciphers': 2.1.1
|
'@noble/ciphers': 2.1.1
|
||||||
@@ -17476,7 +17479,8 @@ snapshots:
|
|||||||
kleur: 3.0.3
|
kleur: 3.0.3
|
||||||
sisteransi: 1.0.5
|
sisteransi: 1.0.5
|
||||||
|
|
||||||
property-expr@2.0.6: {}
|
property-expr@2.0.6:
|
||||||
|
optional: true
|
||||||
|
|
||||||
prosemirror-changeset@2.3.1:
|
prosemirror-changeset@2.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -18408,7 +18412,8 @@ snapshots:
|
|||||||
|
|
||||||
through@2.3.8: {}
|
through@2.3.8: {}
|
||||||
|
|
||||||
tiny-case@1.0.3: {}
|
tiny-case@1.0.3:
|
||||||
|
optional: true
|
||||||
|
|
||||||
tiny-inflate@1.0.3: {}
|
tiny-inflate@1.0.3: {}
|
||||||
|
|
||||||
@@ -18464,7 +18469,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
eslint-visitor-keys: 3.4.3
|
eslint-visitor-keys: 3.4.3
|
||||||
|
|
||||||
toposort@2.0.2: {}
|
toposort@2.0.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
tosource@2.0.0-alpha.3: {}
|
tosource@2.0.0-alpha.3: {}
|
||||||
|
|
||||||
@@ -18523,7 +18529,8 @@ snapshots:
|
|||||||
|
|
||||||
type-fest@0.8.1: {}
|
type-fest@0.8.1: {}
|
||||||
|
|
||||||
type-fest@2.19.0: {}
|
type-fest@2.19.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
type-fest@4.41.0: {}
|
type-fest@4.41.0: {}
|
||||||
|
|
||||||
@@ -19493,6 +19500,7 @@ snapshots:
|
|||||||
tiny-case: 1.0.3
|
tiny-case: 1.0.3
|
||||||
toposort: 2.0.2
|
toposort: 2.0.2
|
||||||
type-fest: 2.19.0
|
type-fest: 2.19.0
|
||||||
|
optional: true
|
||||||
|
|
||||||
zip-stream@6.0.1:
|
zip-stream@6.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -19502,6 +19510,8 @@ snapshots:
|
|||||||
|
|
||||||
zod@3.22.3: {}
|
zod@3.22.3: {}
|
||||||
|
|
||||||
|
zod@3.25.76: {}
|
||||||
|
|
||||||
zod@4.1.13: {}
|
zod@4.1.13: {}
|
||||||
|
|
||||||
zwitch@2.0.4: {}
|
zwitch@2.0.4: {}
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
|
catalogMode: prefer
|
||||||
|
shellEmulator: true
|
||||||
|
trustPolicy: no-downgrade
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
- "packages/*"
|
- "packages/*"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { PhoneCountry } from "./type";
|
import type { PhoneCountry } from "./type";
|
||||||
import { toTypedSchema } from "@vee-validate/yup";
|
import { toTypedSchema } from "@vee-validate/zod";
|
||||||
import { emailOTPClient, phoneNumberClient, usernameClient } from "better-auth/client/plugins";
|
import { emailOTPClient, phoneNumberClient, usernameClient } from "better-auth/client/plugins";
|
||||||
import { createAuthClient } from "better-auth/vue";
|
import { createAuthClient } from "better-auth/vue";
|
||||||
import * as yup from "yup";
|
import { z } from "zod";
|
||||||
import CircleFlagsCnHk from "~icons/circle-flags/cn-hk";
|
import CircleFlagsCnHk from "~icons/circle-flags/cn-hk";
|
||||||
import CircleFlagsEnUs from "~icons/circle-flags/en-us";
|
import CircleFlagsEnUs from "~icons/circle-flags/en-us";
|
||||||
import CircleFlagsTw from "~icons/circle-flags/tw";
|
import CircleFlagsTw from "~icons/circle-flags/tw";
|
||||||
@@ -23,15 +23,15 @@ export const authClient = createAuthClient({
|
|||||||
plugins: [emailOTPClient(), phoneNumberClient(), usernameClient()],
|
plugins: [emailOTPClient(), phoneNumberClient(), usernameClient()],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const emailSchema = toTypedSchema(yup.object({
|
export const emailSchema = toTypedSchema(z.object({
|
||||||
email: yup
|
email: z
|
||||||
.string()
|
.string({ message: i18n.global.t("auth.login.validation.emailRequired") })
|
||||||
.required(i18n.global.t("auth.login.validation.emailRequired"))
|
.min(1, i18n.global.t("auth.login.validation.emailRequired"))
|
||||||
.email(i18n.global.t("auth.login.validation.emailInvalid")),
|
.email(i18n.global.t("auth.login.validation.emailInvalid")),
|
||||||
otp: yup
|
otp: z
|
||||||
.string()
|
.string({ message: i18n.global.t("auth.login.validation.otpRequired") })
|
||||||
.required(i18n.global.t("auth.login.validation.otpRequired"))
|
.min(1, i18n.global.t("auth.login.validation.otpRequired"))
|
||||||
.matches(/^\d{6}$/, i18n.global.t("auth.login.validation.otpInvalid")),
|
.regex(/^\d{6}$/, i18n.global.t("auth.login.validation.otpInvalid")),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const countries: PhoneCountry[] = [
|
export const countries: PhoneCountry[] = [
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
.unselectable {
|
.unselectable {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
--moz-user-select: none;
|
--moz-user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@config "../../tailwind.config.ts";
|
@config "../../tailwind.config.ts";
|
||||||
@import "./common.css";
|
@import "./common.css";
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { GenericObject } from "vee-validate";
|
|||||||
import type { EmailVerifyClient } from "@/api/types";
|
import type { EmailVerifyClient } from "@/api/types";
|
||||||
import { toastController } from "@ionic/vue";
|
import { toastController } from "@ionic/vue";
|
||||||
import { Field, Form } from "vee-validate";
|
import { Field, Form } from "vee-validate";
|
||||||
import * as yup from "yup";
|
import { z } from "zod";
|
||||||
import { authClient, emailSchema } from "@/auth";
|
import { authClient, emailSchema } from "@/auth";
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -19,7 +19,7 @@ const canResend = computed(() => countdown.value === 0 && !isSending.value);
|
|||||||
const email = ref("");
|
const email = ref("");
|
||||||
const emailError = ref("");
|
const emailError = ref("");
|
||||||
|
|
||||||
let timer: number | null = null;
|
let timer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
function startCountdown() {
|
function startCountdown() {
|
||||||
countdown.value = 60;
|
countdown.value = 60;
|
||||||
@@ -42,7 +42,7 @@ async function sendOtp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await yup.string().email().validate(emailValue);
|
await z.string().email().parseAsync(emailValue);
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
emailError.value = t("auth.login.validation.emailInvalid");
|
emailError.value = t("auth.login.validation.emailInvalid");
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import type { GenericObject } from "vee-validate";
|
|||||||
import type { PhoneNumberVerifyClient } from "@/api/types";
|
import type { PhoneNumberVerifyClient } from "@/api/types";
|
||||||
import type { PhoneCountry } from "@/auth/type";
|
import type { PhoneCountry } from "@/auth/type";
|
||||||
import { toastController } from "@ionic/vue";
|
import { toastController } from "@ionic/vue";
|
||||||
import { toTypedSchema } from "@vee-validate/yup";
|
import { toTypedSchema } from "@vee-validate/zod";
|
||||||
import { chevronDown } from "ionicons/icons";
|
import { chevronDown } from "ionicons/icons";
|
||||||
import { Field, Form } from "vee-validate";
|
import { Field, Form } from "vee-validate";
|
||||||
import * as yup from "yup";
|
import { z } from "zod";
|
||||||
import { authClient, countries } from "@/auth";
|
import { authClient, countries } from "@/auth";
|
||||||
import Country from "./country.vue";
|
import Country from "./country.vue";
|
||||||
|
|
||||||
@@ -42,19 +42,18 @@ function validatePhoneNumber(phone: string): boolean {
|
|||||||
return currentCountry.value.pattern.test(phone);
|
return currentCountry.value.pattern.test(phone);
|
||||||
}
|
}
|
||||||
|
|
||||||
const schema = computed(() => toTypedSchema(yup.object({
|
const schema = computed(() => toTypedSchema(z.object({
|
||||||
phoneNumber: yup
|
phoneNumber: z
|
||||||
.string()
|
.string({ message: t("auth.login.validation.phoneNumberRequired") })
|
||||||
.required(t("auth.login.validation.phoneNumberRequired"))
|
.min(1, t("auth.login.validation.phoneNumberRequired"))
|
||||||
.test(
|
.refine(
|
||||||
"phone-format",
|
value => validatePhoneNumber(value),
|
||||||
t("auth.login.validation.phoneNumberInvalid"),
|
t("auth.login.validation.phoneNumberInvalid"),
|
||||||
value => !value || validatePhoneNumber(value),
|
|
||||||
),
|
),
|
||||||
code: yup
|
code: z
|
||||||
.string()
|
.string({ message: t("auth.login.validation.codeRequired") })
|
||||||
.required(t("auth.login.validation.codeRequired"))
|
.min(1, t("auth.login.validation.codeRequired"))
|
||||||
.matches(/^\d{6}$/, t("auth.login.validation.codeInvalid")),
|
.regex(/^\d{6}$/, t("auth.login.validation.codeInvalid")),
|
||||||
})));
|
})));
|
||||||
|
|
||||||
function startCountdown() {
|
function startCountdown() {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang='ts' setup>
|
<script lang='ts' setup>
|
||||||
import type { GenericObject } from "vee-validate";
|
import type { GenericObject } from "vee-validate";
|
||||||
import type { RwaIssuanceCategoriesData, RwaIssuanceProductBody } from "@/api/types";
|
import type { RwaIssuanceCategoriesData, RwaIssuanceProductBody } from "@/api/types";
|
||||||
import { toTypedSchema } from "@vee-validate/yup";
|
import { toTypedSchema } from "@vee-validate/zod";
|
||||||
import { ErrorMessage, Field, Form } from "vee-validate";
|
import { ErrorMessage, Field, Form } from "vee-validate";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import * as yup from "yup";
|
import { z } from "zod";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
initialData: RwaIssuanceProductBody["product"];
|
initialData: RwaIssuanceProductBody["product"];
|
||||||
@@ -16,10 +16,10 @@ const emit = defineEmits<{
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const schema = toTypedSchema(
|
const schema = toTypedSchema(
|
||||||
yup.object({
|
z.object({
|
||||||
name: yup.string().required(t("asset.issue.apply.validation.nameRequired")),
|
name: z.string({ message: t("asset.issue.apply.validation.nameRequired") }).min(1, t("asset.issue.apply.validation.nameRequired")),
|
||||||
code: yup.string().required(t("asset.issue.apply.validation.codeRequired")),
|
code: z.string({ message: t("asset.issue.apply.validation.codeRequired") }).min(1, t("asset.issue.apply.validation.codeRequired")),
|
||||||
categoryId: yup.string().required(t("asset.issue.apply.validation.categoryRequired")),
|
categoryId: z.string({ message: t("asset.issue.apply.validation.categoryRequired") }).min(1, t("asset.issue.apply.validation.categoryRequired")),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script lang='ts' setup>
|
<script lang='ts' setup>
|
||||||
import type { GenericObject } from "vee-validate";
|
import type { GenericObject } from "vee-validate";
|
||||||
import type { RwaIssuanceProductBody } from "@/api/types";
|
import type { RwaIssuanceProductBody } from "@/api/types";
|
||||||
import { toTypedSchema } from "@vee-validate/yup";
|
import { toTypedSchema } from "@vee-validate/zod";
|
||||||
import { addOutline, removeOutline } from "ionicons/icons";
|
import { addOutline, removeOutline } from "ionicons/icons";
|
||||||
import { ErrorMessage, Field, FieldArray, Form } from "vee-validate";
|
import { ErrorMessage, Field, FieldArray, Form } from "vee-validate";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import * as yup from "yup";
|
import { z } from "zod";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
initialData: RwaIssuanceProductBody["editions"];
|
initialData: RwaIssuanceProductBody["editions"];
|
||||||
@@ -35,45 +35,40 @@ const launchDate = ref(new Date().toISOString());
|
|||||||
const subscriptionStartDate = ref(new Date().toISOString());
|
const subscriptionStartDate = ref(new Date().toISOString());
|
||||||
const subscriptionEndDate = ref(new Date().toISOString());
|
const subscriptionEndDate = ref(new Date().toISOString());
|
||||||
|
|
||||||
const schema = toTypedSchema(yup.object({
|
const schema = toTypedSchema(z.object({
|
||||||
editions: yup.array().of(
|
editions: z.array(
|
||||||
yup.object({
|
z.object({
|
||||||
editionName: yup.string().required(t("asset.issue.apply.validation.editionNameRequired")),
|
editionName: z.string({ message: t("asset.issue.apply.validation.editionNameRequired") }).min(1, t("asset.issue.apply.validation.editionNameRequired")),
|
||||||
launchDate: yup.string()
|
launchDate: z.string({ message: t("asset.issue.apply.validation.launchDateRequired") })
|
||||||
.required(t("asset.issue.apply.validation.launchDateRequired"))
|
.min(1, t("asset.issue.apply.validation.launchDateRequired"))
|
||||||
.test("not-past", t("asset.issue.apply.validation.launchDateNotPast"), (value) => {
|
.refine(
|
||||||
if (!value)
|
value => new Date(value) >= new Date(now.value.toDateString()),
|
||||||
return true;
|
t("asset.issue.apply.validation.launchDateNotPast"),
|
||||||
return new Date(value) >= new Date(now.value.toDateString());
|
)
|
||||||
})
|
.refine(
|
||||||
.test("before-subscription", t("asset.issue.apply.validation.launchBeforeSubscription"), (value) => {
|
value => !subscriptionStartDate.value || new Date(value) < new Date(subscriptionStartDate.value),
|
||||||
if (!value || !subscriptionStartDate.value)
|
t("asset.issue.apply.validation.launchBeforeSubscription"),
|
||||||
return true;
|
),
|
||||||
return new Date(value) < new Date(subscriptionStartDate.value);
|
subscriptionStartDate: z.string({ message: t("asset.issue.apply.validation.subscriptionStartDateRequired") })
|
||||||
}),
|
.min(1, t("asset.issue.apply.validation.subscriptionStartDateRequired"))
|
||||||
subscriptionStartDate: yup.string()
|
.refine(
|
||||||
.required(t("asset.issue.apply.validation.subscriptionStartDateRequired"))
|
value => new Date(value) >= new Date(now.value.toDateString()),
|
||||||
.test("not-past", t("asset.issue.apply.validation.subscriptionStartDateNotPast"), (value) => {
|
t("asset.issue.apply.validation.subscriptionStartDateNotPast"),
|
||||||
if (!value)
|
)
|
||||||
return true;
|
.refine(
|
||||||
return new Date(value) >= new Date(now.value.toDateString());
|
value => !launchDate.value || new Date(value) > new Date(launchDate.value),
|
||||||
})
|
t("asset.issue.apply.validation.subscriptionAfterLaunch"),
|
||||||
.test("after-launch", t("asset.issue.apply.validation.subscriptionAfterLaunch"), (value) => {
|
),
|
||||||
if (!value || !launchDate.value)
|
subscriptionEndDate: z.string({ message: t("asset.issue.apply.validation.subscriptionEndDateRequired") })
|
||||||
return true;
|
.min(1, t("asset.issue.apply.validation.subscriptionEndDateRequired"))
|
||||||
return new Date(value) > new Date(launchDate.value);
|
.refine(
|
||||||
}),
|
value => !subscriptionStartDate.value || new Date(value) > new Date(subscriptionStartDate.value),
|
||||||
subscriptionEndDate: yup.string()
|
t("asset.issue.apply.validation.subscriptionEndAfterStart"),
|
||||||
.required(t("asset.issue.apply.validation.subscriptionEndDateRequired"))
|
),
|
||||||
.test("after-start", t("asset.issue.apply.validation.subscriptionEndAfterStart"), (value) => {
|
perUserLimit: z.string({ message: t("asset.issue.apply.validation.perUserLimitRequired") }).min(1, t("asset.issue.apply.validation.perUserLimitRequired")),
|
||||||
if (!value || !subscriptionStartDate.value)
|
totalSupply: z.string({ message: t("asset.issue.apply.validation.totalSupplyRequired") }).min(1, t("asset.issue.apply.validation.totalSupplyRequired")),
|
||||||
return true;
|
unitPrice: z.string({ message: t("asset.issue.apply.validation.unitPriceRequired") }).min(1, t("asset.issue.apply.validation.unitPriceRequired")),
|
||||||
return new Date(value) > new Date(subscriptionStartDate.value);
|
dividendRate: z.string({ message: t("asset.issue.apply.validation.dividendRateRequired") }).min(1, t("asset.issue.apply.validation.dividendRateRequired")),
|
||||||
}),
|
|
||||||
perUserLimit: yup.string().required(t("asset.issue.apply.validation.perUserLimitRequired")),
|
|
||||||
totalSupply: yup.string().required(t("asset.issue.apply.validation.totalSupplyRequired")),
|
|
||||||
unitPrice: yup.string().required(t("asset.issue.apply.validation.unitPriceRequired")),
|
|
||||||
dividendRate: yup.string().required(t("asset.issue.apply.validation.dividendRateRequired")),
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang='ts' setup>
|
<script lang='ts' setup>
|
||||||
import type { GenericObject } from "vee-validate";
|
import type { GenericObject } from "vee-validate";
|
||||||
import { SelectChangeEventDetail, toastController } from "@ionic/vue";
|
import { SelectChangeEventDetail, toastController } from "@ionic/vue";
|
||||||
import { toTypedSchema } from "@vee-validate/yup";
|
import { toTypedSchema } from "@vee-validate/zod";
|
||||||
import { informationCircle, shieldCheckmark } from "ionicons/icons";
|
import { informationCircle, shieldCheckmark } from "ionicons/icons";
|
||||||
import { ErrorMessage, Field, Form } from "vee-validate";
|
import { ErrorMessage, Field, Form } from "vee-validate";
|
||||||
import * as yup from "yup";
|
import { z } from "zod";
|
||||||
import { client, safeClient } from "@/api";
|
import { client, safeClient } from "@/api";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -17,12 +17,12 @@ const formInst = useTemplateRef<FormInstance>("formInst");
|
|||||||
|
|
||||||
// 表单验证 Schema
|
// 表单验证 Schema
|
||||||
const schema = toTypedSchema(
|
const schema = toTypedSchema(
|
||||||
yup.object({
|
z.object({
|
||||||
bankName: yup.string().required(t("bankCard.form.validation.bankRequired")),
|
bankName: z.string({ message: t("bankCard.form.validation.bankRequired") }).min(1, t("bankCard.form.validation.bankRequired")),
|
||||||
accountNumber: yup
|
accountNumber: z
|
||||||
.string()
|
.string({ message: t("bankCard.form.validation.accountNumberRequired") })
|
||||||
.required(t("bankCard.form.validation.accountNumberRequired")),
|
.min(1, t("bankCard.form.validation.accountNumberRequired")),
|
||||||
accountName: yup.string().required(t("bankCard.form.validation.accountNameRequired")),
|
accountName: z.string({ message: t("bankCard.form.validation.accountNameRequired") }).min(1, t("bankCard.form.validation.accountNameRequired")),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,130 @@
|
|||||||
<script lang='ts' setup>
|
<script lang='ts' setup>
|
||||||
|
import type { SpotOrderBody } from "@/api/types";
|
||||||
|
import { modalController, toastController } from "@ionic/vue";
|
||||||
|
import { closeOutline } from "ionicons/icons";
|
||||||
|
import { client, safeClient } from "@/api";
|
||||||
|
import { tradeWayConfig } from "../config";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
form: SpotOrderBody & { amount: string };
|
||||||
|
}>();
|
||||||
|
const currentTradeWay = computed(() => {
|
||||||
|
return tradeWayConfig.find(item => item.value === props.form.orderType);
|
||||||
|
});
|
||||||
|
|
||||||
|
function onClose() {
|
||||||
|
modalController.dismiss();
|
||||||
|
}
|
||||||
|
async function onConfirm() {
|
||||||
|
await safeClient(client.api.spot_order.create.post({
|
||||||
|
orderType: props.form.orderType,
|
||||||
|
quantity: props.form.quantity,
|
||||||
|
side: props.form.side,
|
||||||
|
symbol: props.form.symbol,
|
||||||
|
memo: props.form.memo,
|
||||||
|
price: props.form.price,
|
||||||
|
}));
|
||||||
|
const toast = await toastController.create({
|
||||||
|
message: "订单提交成功",
|
||||||
|
duration: 2000,
|
||||||
|
position: "top",
|
||||||
|
color: "success",
|
||||||
|
});
|
||||||
|
await toast.present();
|
||||||
|
modalController.dismiss();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
Hello world
|
<div class="ion-padding h-80">
|
||||||
|
<div class="flex justify-between items-center mb-5">
|
||||||
|
<div class="font-semibold">
|
||||||
|
下单确认
|
||||||
|
</div>
|
||||||
|
<ion-icon :icon="closeOutline" class="text-2xl" @click="onClose" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<div class="text-sm">
|
||||||
|
{{ form.symbol }}
|
||||||
|
</div>
|
||||||
|
<ui-tag size="mini" :type="form.side === 'buy' ? 'success' : 'danger'">
|
||||||
|
{{ form.side === 'buy' ? '买入' : '卖出' }}
|
||||||
|
</ui-tag>
|
||||||
|
</div>
|
||||||
|
<template v-if="form.orderType === 'limit'">
|
||||||
|
<div class="cell">
|
||||||
|
<div class="label">
|
||||||
|
委托价格
|
||||||
|
</div>
|
||||||
|
<div class="value">
|
||||||
|
{{ form.price }} USDT
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<div class="label">
|
||||||
|
数量
|
||||||
|
</div>
|
||||||
|
<div class="value">
|
||||||
|
{{ form.quantity }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<div class="label">
|
||||||
|
金额
|
||||||
|
</div>
|
||||||
|
<div class="value">
|
||||||
|
{{ form.amount }} USDT
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<div class="label">
|
||||||
|
类型
|
||||||
|
</div>
|
||||||
|
<div class="value">
|
||||||
|
{{ currentTradeWay?.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="form.orderType === 'market'">
|
||||||
|
<div class="cell">
|
||||||
|
<div class="label">
|
||||||
|
委托价格
|
||||||
|
</div>
|
||||||
|
<div class="value">
|
||||||
|
{{ form.price }} USDT
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<div class="label">
|
||||||
|
数量
|
||||||
|
</div>
|
||||||
|
<div class="value">
|
||||||
|
{{ form.quantity }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<ion-button expand="block" color="success" @click="onConfirm">
|
||||||
|
确认下单
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang='css' scoped></style>
|
<style lang='css' scoped>
|
||||||
|
@reference "tailwindcss";
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
@apply flex justify-between items-center py-1;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
@apply text-sm text-(--ion-text-color-step-400);
|
||||||
|
}
|
||||||
|
.value {
|
||||||
|
@apply text-sm font-semibold;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { caretDownOutline } from "ionicons/icons";
|
|||||||
import { tradeWayConfig } from "../config";
|
import { tradeWayConfig } from "../config";
|
||||||
|
|
||||||
const model = defineModel({ type: Object as PropType<SpotOrderBody>, required: true });
|
const model = defineModel({ type: Object as PropType<SpotOrderBody>, required: true });
|
||||||
|
const currentTradeWay = computed(() => {
|
||||||
|
return tradeWayConfig.find(item => item.value === model.value.orderType);
|
||||||
|
});
|
||||||
|
|
||||||
function onSelectTradeWay(item: TradeWayConfig) {
|
function onSelectTradeWay(item: TradeWayConfig) {
|
||||||
model.value.orderType = item.value;
|
model.value.orderType = item.value;
|
||||||
@@ -17,7 +20,7 @@ function onSelectTradeWay(item: TradeWayConfig) {
|
|||||||
<template>
|
<template>
|
||||||
<div id="open-modal" class="bg-faint flex items-center justify-between px-4 py-2 rounded-md">
|
<div id="open-modal" class="bg-faint flex items-center justify-between px-4 py-2 rounded-md">
|
||||||
<div class="text-xs font-medium text-text-300">
|
<div class="text-xs font-medium text-text-300">
|
||||||
市场
|
{{ currentTradeWay?.name }}
|
||||||
</div>
|
</div>
|
||||||
<ion-icon :icon="caretDownOutline" />
|
<ion-icon :icon="caretDownOutline" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as yup from "yup";
|
import { z } from "zod";
|
||||||
|
|
||||||
export enum TradeWayValueEnum {
|
export enum TradeWayValueEnum {
|
||||||
LIMIT = "limit",
|
LIMIT = "limit",
|
||||||
@@ -21,28 +21,35 @@ export const tradeWayConfig: TradeWayConfig[] = [
|
|||||||
description: "以指定价格买入或卖出",
|
description: "以指定价格买入或卖出",
|
||||||
icon: "hugeicons:trade-up",
|
icon: "hugeicons:trade-up",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "市价委托",
|
||||||
|
value: "market",
|
||||||
|
description: "以市场价格买入或卖出",
|
||||||
|
icon: "hugeicons:trade-down",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const confirmOrderSchema = yup.object({
|
export const confirmOrderSchema = z.object({
|
||||||
price: yup.number().when("way", {
|
quantity: z.coerce.number({ message: "请输入有效的数量" }).gt(0, "数量必须大于0"),
|
||||||
is: TradeWayValueEnum.LIMIT !== undefined,
|
price: z.coerce.number({ message: "请输入有效的价格" }).gt(0, "价格必须大于0").optional().or(z.coerce.number().optional()),
|
||||||
then: yup
|
orderType: z.enum([TradeWayValueEnum.LIMIT, TradeWayValueEnum.MARKET], {
|
||||||
.number()
|
message: "请选择有效的交易方式",
|
||||||
.typeError("请输入有效的价格")
|
}) as z.ZodType<TradeWayValue>,
|
||||||
.required("价格为必填项")
|
}).refine(
|
||||||
.moreThan(0, "价格必须大于0"),
|
(data) => {
|
||||||
otherwise: yup.number().notRequired(),
|
if (data.orderType === TradeWayValueEnum.LIMIT) {
|
||||||
}),
|
return data.price !== undefined && data.price > 0;
|
||||||
amount: yup
|
}
|
||||||
.number()
|
return true;
|
||||||
.typeError("请输入有效的数量")
|
},
|
||||||
.required("数量为必填项")
|
{
|
||||||
.moreThan(0, "数量必须大于0"),
|
message: "价格为必填项",
|
||||||
way: yup
|
path: ["price"],
|
||||||
.mixed<TradeWayValue>()
|
},
|
||||||
.oneOf(
|
);
|
||||||
Object.values(TradeWayValueEnum),
|
|
||||||
"请选择有效的交易方式",
|
export const confirmOrderSubmitSchema = confirmOrderSchema.transform(data => ({
|
||||||
)
|
...data,
|
||||||
.required("交易方式为必填项"),
|
quantity: data.quantity.toString(),
|
||||||
});
|
price: data.price?.toString() ?? "",
|
||||||
|
}));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import type { ChartingLibraryWidgetOptions } from "#/charting_library";
|
import type { ChartingLibraryWidgetOptions } from "#/charting_library";
|
||||||
import type { SpotOrderBody } from "@/api/types";
|
import type { SpotOrderBody } from "@/api/types";
|
||||||
import type { TradingViewInst } from "@/tradingview/index";
|
import type { TradingViewInst } from "@/tradingview/index";
|
||||||
|
import type { ModalInstance } from "@/utils";
|
||||||
import { modalController } from "@ionic/vue";
|
import { modalController } from "@ionic/vue";
|
||||||
import { useRouteQuery } from "@vueuse/router";
|
import { useRouteQuery } from "@vueuse/router";
|
||||||
import { caretDownOutline, ellipsisHorizontal } from "ionicons/icons";
|
import { caretDownOutline, ellipsisHorizontal } from "ionicons/icons";
|
||||||
@@ -9,28 +10,32 @@ import MaterialSymbolsCandlestickChartOutline from "~icons/material-symbols/cand
|
|||||||
import { client, safeClient } from "@/api";
|
import { client, safeClient } from "@/api";
|
||||||
import { TradeTypeEnum } from "@/api/enum";
|
import { TradeTypeEnum } from "@/api/enum";
|
||||||
import { TradingViewChart } from "@/tradingview/index";
|
import { TradingViewChart } from "@/tradingview/index";
|
||||||
|
import ConfirmOrder from "./components/confirm-order.vue";
|
||||||
import OrdersPanel from "./components/orders-panel.vue";
|
import OrdersPanel from "./components/orders-panel.vue";
|
||||||
import TradePairsModal from "./components/trade-pairs-modal.vue";
|
import TradePairsModal from "./components/trade-pairs-modal.vue";
|
||||||
import TradeSwitch from "./components/trade-switch.vue";
|
import TradeSwitch from "./components/trade-switch.vue";
|
||||||
import TradeWay from "./components/trade-way.vue";
|
import TradeWay from "./components/trade-way.vue";
|
||||||
import { confirmOrderSchema, TradeWayValueEnum } from "./config";
|
import { confirmOrderSubmitSchema, TradeWayValueEnum } from "./config";
|
||||||
|
|
||||||
|
const { data } = await safeClient(client.api.trading_pairs.get({ query: { limit: 1 } }));
|
||||||
const mode = useRouteQuery<TradeTypeEnum>("mode", TradeTypeEnum.BUY);
|
const mode = useRouteQuery<TradeTypeEnum>("mode", TradeTypeEnum.BUY);
|
||||||
const symbol = useRouteQuery<string>("symbol", "BTCUSD");
|
const symbol = useRouteQuery<string>("symbol", data.value?.data[0].symbol);
|
||||||
|
|
||||||
const tradingviewOptions: Partial<ChartingLibraryWidgetOptions> = {
|
const tradingviewOptions: Partial<ChartingLibraryWidgetOptions> = {
|
||||||
disabled_features: [
|
disabled_features: [
|
||||||
"create_volume_indicator_by_default",
|
"create_volume_indicator_by_default",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const tradingViewInst = useTemplateRef<TradingViewInst>("tradingViewInst");
|
const tradingViewInst = useTemplateRef<TradingViewInst>("tradingViewInst");
|
||||||
const [form] = useResetRef<SpotOrderBody>({
|
const confirmModalInst = useTemplateRef<ModalInstance>("confirmModalInst");
|
||||||
|
|
||||||
|
const [form] = useResetRef<SpotOrderBody & { amount: string }>({
|
||||||
orderType: TradeWayValueEnum.LIMIT,
|
orderType: TradeWayValueEnum.LIMIT,
|
||||||
quantity: "",
|
quantity: "",
|
||||||
side: mode.value,
|
side: mode.value,
|
||||||
symbol: symbol.value,
|
symbol: symbol.value,
|
||||||
memo: "",
|
memo: "",
|
||||||
price: "",
|
price: "",
|
||||||
|
amount: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
async function openTradePairs() {
|
async function openTradePairs() {
|
||||||
@@ -40,22 +45,38 @@ async function openTradePairs() {
|
|||||||
initialBreakpoint: 0.95,
|
initialBreakpoint: 0.95,
|
||||||
handle: true,
|
handle: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
await modal.present();
|
await modal.present();
|
||||||
|
|
||||||
const { data: result } = await modal.onWillDismiss<string>();
|
const { data: result } = await modal.onWillDismiss<string>();
|
||||||
|
result && (symbol.value = result);
|
||||||
if (result) {
|
}
|
||||||
symbol.value = result;
|
function handleChangeQuantity(event) {
|
||||||
|
const val = (event.target as HTMLInputElement).value;
|
||||||
|
if (val && form.value.price) {
|
||||||
|
const amount = Number(val) * Number(form.value.price);
|
||||||
|
form.value.amount = amount.toString();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
form.value.amount = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function handleChangeAmount(event) {
|
||||||
function handleSubmit() {
|
const val = (event.target as HTMLInputElement).value;
|
||||||
confirmOrderSchema.validate(form.value).then(() => {
|
if (val && form.value.price) {
|
||||||
console.log("submit successfully");
|
const quantity = Number(val) / Number(form.value.price);
|
||||||
}).catch((err) => {
|
form.value.quantity = quantity.toString();
|
||||||
console.log("submit failed:", err);
|
}
|
||||||
});
|
else {
|
||||||
|
form.value.quantity = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function handleSubmit() {
|
||||||
|
try {
|
||||||
|
await confirmOrderSubmitSchema.parseAsync(form.value);
|
||||||
|
confirmModalInst.value?.$el.present();
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("订单验证失败:", err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -90,21 +111,37 @@ function handleSubmit() {
|
|||||||
<TradeSwitch v-model:active="mode" @update:active="val => form.side = val" />
|
<TradeSwitch v-model:active="mode" @update:active="val => form.side = val" />
|
||||||
<TradeWay v-model="form" />
|
<TradeWay v-model="form" />
|
||||||
<template v-if="form.orderType === 'limit'">
|
<template v-if="form.orderType === 'limit'">
|
||||||
<ion-input v-model="form.price" label="价格" class="count" inputmode="decimal" type="number" placeholder="请输入价格(USDT)" />
|
<ion-input v-model="form.price" label="价格" class="count" inputmode="decimal" type="number" placeholder="请输入价格">
|
||||||
|
<span slot="end">USDT</span>
|
||||||
|
</ion-input>
|
||||||
|
<ion-input v-model="form.quantity" label="数量" class="count" inputmode="decimal" type="number" placeholder="请输入交易数量" @ion-input="handleChangeQuantity">
|
||||||
|
<span slot="end">{{ symbol }}</span>
|
||||||
|
</ion-input>
|
||||||
|
<ion-input v-model="form.amount" label="金额" class="count" inputmode="decimal" type="number" placeholder="请输入交易金额" @ion-input="handleChangeAmount">
|
||||||
|
<span slot="end">USDT</span>
|
||||||
|
</ion-input>
|
||||||
</template>
|
</template>
|
||||||
<ion-input v-model="form.quantity" label="数量" class="count" inputmode="decimal" type="number" placeholder="请输入交易数量">
|
<template v-else-if="form.orderType === 'market'">
|
||||||
<span slot="end">{{ symbol }}</span>
|
<ion-input v-model="form.price" label="价格" class="count" inputmode="decimal" type="number" placeholder="请输入价格">
|
||||||
</ion-input>
|
<span slot="end">USDT</span>
|
||||||
<ion-input v-model="form.price" label="金额" class="count" inputmode="decimal" type="number" placeholder="请输入交易金额">
|
</ion-input>
|
||||||
<span slot="end">USDT</span>
|
<ion-input v-model="form.quantity" label="数量" class="count" inputmode="decimal" type="number" placeholder="请输入交易数量" @ion-input="handleChangeQuantity">
|
||||||
</ion-input>
|
<span slot="end">{{ symbol }}</span>
|
||||||
|
</ion-input>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- <ion-range class="range" aria-label="Range with ticks" :pin="true" :ticks="true" :snaps="true" :min="0" :max="5" /> -->
|
<!-- <ion-range class="range" aria-label="Range with ticks" :pin="true" :ticks="true" :snaps="true" :min="0" :max="5" /> -->
|
||||||
<ion-button expand="block" size="small" shape="round" :color="mode === TradeTypeEnum.BUY ? 'success' : 'danger'" @click="handleSubmit">
|
<ion-button expand="block" size="small" shape="round" :color="mode === TradeTypeEnum.BUY ? 'success' : 'danger'" @click="handleSubmit">
|
||||||
{{ mode === TradeTypeEnum.BUY ? '买入' : '卖出' }}
|
{{ mode === TradeTypeEnum.BUY ? '买入' : '卖出' }}
|
||||||
</ion-button>
|
</ion-button>
|
||||||
|
|
||||||
|
<ion-modal ref="confirmModalInst" class="confirm-modal" :breakpoints="[0, 1]" :initial-breakpoint="1" :handle="false">
|
||||||
|
<ConfirmOrder :form="form" />
|
||||||
|
</ion-modal>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-2" />
|
<div class="col-span-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 px-4 pb-4">
|
<div class="mt-6 px-4 pb-4">
|
||||||
<OrdersPanel />
|
<OrdersPanel />
|
||||||
</div>
|
</div>
|
||||||
@@ -145,4 +182,7 @@ ion-range.range::part(tick-active) {
|
|||||||
top: 18px;
|
top: 18px;
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
}
|
}
|
||||||
|
.confirm-modal {
|
||||||
|
--height: auto;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { GenericObject } from "vee-validate";
|
|||||||
import { toastController } from "@ionic/vue";
|
import { toastController } from "@ionic/vue";
|
||||||
import { arrowBackOutline } from "ionicons/icons";
|
import { arrowBackOutline } from "ionicons/icons";
|
||||||
import { Field, Form } from "vee-validate";
|
import { Field, Form } from "vee-validate";
|
||||||
import * as yup from "yup";
|
import { z } from "zod";
|
||||||
import { safeClient } from "@/api";
|
import { safeClient } from "@/api";
|
||||||
import { authClient, emailSchema } from "@/auth";
|
import { authClient, emailSchema } from "@/auth";
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ const isSending = ref(false);
|
|||||||
const canResend = computed(() => countdown.value === 0 && !isSending.value);
|
const canResend = computed(() => countdown.value === 0 && !isSending.value);
|
||||||
const emailError = ref("");
|
const emailError = ref("");
|
||||||
|
|
||||||
let timer: number | null = null;
|
let timer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
function startCountdown() {
|
function startCountdown() {
|
||||||
countdown.value = 60;
|
countdown.value = 60;
|
||||||
@@ -40,7 +40,7 @@ async function sendOtp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await yup.string().email().validate(emailValue);
|
await z.string().email().parseAsync(emailValue);
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
emailError.value = t("auth.login.validation.emailInvalid");
|
emailError.value = t("auth.login.validation.emailInvalid");
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { FormInstance } from "@/utils";
|
|||||||
import { loadingController, toastController } from "@ionic/vue";
|
import { loadingController, toastController } from "@ionic/vue";
|
||||||
import { swapVerticalOutline } from "ionicons/icons";
|
import { swapVerticalOutline } from "ionicons/icons";
|
||||||
import { ErrorMessage, Field, Form } from "vee-validate";
|
import { ErrorMessage, Field, Form } from "vee-validate";
|
||||||
import * as yup from "yup";
|
import { z } from "zod";
|
||||||
import { client, safeClient } from "@/api";
|
import { client, safeClient } from "@/api";
|
||||||
import { AssetCodeEnum } from "@/api/enum";
|
import { AssetCodeEnum } from "@/api/enum";
|
||||||
import { getCryptoIcon } from "@/config/crypto";
|
import { getCryptoIcon } from "@/config/crypto";
|
||||||
@@ -43,15 +43,15 @@ const availableBalance = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 验证规则
|
// 验证规则
|
||||||
const schema = computed(() => yup.object({
|
const schema = computed(() => z.object({
|
||||||
assetCode: yup.string().required(t("transfer.assetCodeRequired")),
|
assetCode: z.string({ message: t("transfer.assetCodeRequired") }).min(1, t("transfer.assetCodeRequired")),
|
||||||
amount: yup
|
amount: z
|
||||||
.string()
|
.string({ message: t("transfer.amountRequired") })
|
||||||
.required(t("transfer.amountRequired"))
|
.min(1, t("transfer.amountRequired"))
|
||||||
.test("min", t("transfer.amountMinError"), value => Number(value) > 0)
|
.refine(value => Number(value) > 0, t("transfer.amountMinError"))
|
||||||
.test("max", t("transfer.amountMaxError", { amount: availableBalance.value }), value => Number(value) <= Number(availableBalance.value)),
|
.refine(value => Number(value) <= Number(availableBalance.value), t("transfer.amountMaxError", { amount: availableBalance.value })),
|
||||||
fromAccount: yup.string().required(t("transfer.fromAccountRequired")),
|
fromAccount: z.string({ message: t("transfer.fromAccountRequired") }).min(1, t("transfer.fromAccountRequired")),
|
||||||
toAccount: yup.string().required(t("transfer.toAccountRequired")),
|
toAccount: z.string({ message: t("transfer.toAccountRequired") }).min(1, t("transfer.toAccountRequired")),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 交换账户
|
// 交换账户
|
||||||
|
|||||||
@@ -1,43 +1,70 @@
|
|||||||
import { toTypedSchema } from "@vee-validate/yup";
|
import { toTypedSchema } from "@vee-validate/zod";
|
||||||
import * as yup from "yup";
|
import { z } from "zod";
|
||||||
import { WithdrawMethodEnum } from "@/api/enum";
|
import { WithdrawMethodEnum } from "@/api/enum";
|
||||||
|
|
||||||
export function createWithdrawSchema(t: (key: string, params?: any) => string, maxAmount: string) {
|
export function createWithdrawSchema(t: (key: string, params?: any) => string, maxAmount: string) {
|
||||||
return toTypedSchema(
|
return toTypedSchema(
|
||||||
yup.object({
|
z.object({
|
||||||
assetCode: yup.string().required(t("withdraw.validation.assetCodeRequired")),
|
assetCode: z.string({ message: t("withdraw.validation.assetCodeRequired") }).min(1, t("withdraw.validation.assetCodeRequired")),
|
||||||
amount: yup
|
amount: z
|
||||||
.string()
|
.string({ message: t("withdraw.validation.amountRequired") })
|
||||||
.required(t("withdraw.validation.amountRequired"))
|
.min(1, t("withdraw.validation.amountRequired"))
|
||||||
.test("is-number", t("withdraw.validation.amountInvalid"), (value) => {
|
.refine(
|
||||||
return /^\d+(?:\.\d+)?$/.test(value || "");
|
value => /^\d+(?:\.\d+)?$/.test(value),
|
||||||
})
|
t("withdraw.validation.amountInvalid"),
|
||||||
.test("max-amount", t("withdraw.validation.amountExceedsBalance"), (value) => {
|
)
|
||||||
if (!value || maxAmount === "0")
|
.refine(
|
||||||
return false;
|
(value) => {
|
||||||
return Number.parseFloat(value) <= Number.parseFloat(maxAmount);
|
if (maxAmount === "0")
|
||||||
})
|
return false;
|
||||||
.test("min-amount", t("withdraw.validation.amountTooSmall"), (value) => {
|
return Number.parseFloat(value) <= Number.parseFloat(maxAmount);
|
||||||
if (!value)
|
},
|
||||||
return false;
|
t("withdraw.validation.amountExceedsBalance"),
|
||||||
return Number.parseFloat(value) > 0;
|
)
|
||||||
}),
|
.refine(
|
||||||
withdrawMethod: yup.string().required(t("withdraw.validation.methodRequired")),
|
value => Number.parseFloat(value) > 0,
|
||||||
bankAccountId: yup.string().when("withdrawMethod", {
|
t("withdraw.validation.amountTooSmall"),
|
||||||
is: WithdrawMethodEnum.BANK,
|
),
|
||||||
then: schema => schema.required(t("withdraw.validation.bankAccountRequired")),
|
withdrawMethod: z.string({ message: t("withdraw.validation.methodRequired") }).min(1, t("withdraw.validation.methodRequired")),
|
||||||
otherwise: schema => schema.optional(),
|
bankAccountId: z.string().optional(),
|
||||||
}),
|
chain: z.string().optional(),
|
||||||
chain: yup.string().when("withdrawMethod", {
|
toAddress: z.string().optional(),
|
||||||
is: WithdrawMethodEnum.CRYPTO,
|
})
|
||||||
then: schema => schema.required(t("withdraw.validation.chainRequired")),
|
.refine(
|
||||||
otherwise: schema => schema.optional(),
|
(data) => {
|
||||||
}),
|
if (data.withdrawMethod === WithdrawMethodEnum.BANK) {
|
||||||
toAddress: yup.string().when("withdrawMethod", {
|
return !!data.bankAccountId;
|
||||||
is: WithdrawMethodEnum.CRYPTO,
|
}
|
||||||
then: schema => schema.required(t("withdraw.validation.addressRequired")).min(10, t("withdraw.validation.addressTooShort")),
|
return true;
|
||||||
otherwise: schema => schema.optional(),
|
},
|
||||||
}),
|
{
|
||||||
}),
|
message: t("withdraw.validation.bankAccountRequired"),
|
||||||
|
path: ["bankAccountId"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.withdrawMethod === WithdrawMethodEnum.CRYPTO) {
|
||||||
|
return !!data.chain;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: t("withdraw.validation.chainRequired"),
|
||||||
|
path: ["chain"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.withdrawMethod === WithdrawMethodEnum.CRYPTO) {
|
||||||
|
return !!data.toAddress && data.toAddress.length >= 10;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: t("withdraw.validation.addressRequired"),
|
||||||
|
path: ["toAddress"],
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
52137
types/charting_library.d.ts
vendored
52137
types/charting_library.d.ts
vendored
File diff suppressed because it is too large
Load Diff
2059
types/datafeeds.d.ts
vendored
2059
types/datafeeds.d.ts
vendored
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user