Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -20,16 +20,16 @@
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { onMounted } from "vue";
|
||||
|
||||
const { checkAndPromptUpdate } = useAppUpdate()
|
||||
const { checkAndPromptUpdate } = useAppUpdate();
|
||||
|
||||
onMounted(async () => {
|
||||
// 应用启动 3 秒后检查更新
|
||||
setTimeout(() => {
|
||||
checkAndPromptUpdate()
|
||||
}, 3000)
|
||||
})
|
||||
checkAndPromptUpdate();
|
||||
}, 3000);
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
@@ -38,6 +38,18 @@ onMounted(async () => {
|
||||
在设置页面添加"检查更新"按钮:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const {
|
||||
isChecking,
|
||||
currentVersion,
|
||||
checkAndPromptUpdate,
|
||||
} = useAppUpdate();
|
||||
|
||||
async function handleCheckUpdate() {
|
||||
await checkAndPromptUpdate();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ion-page>
|
||||
<ion-header>
|
||||
@@ -59,18 +71,6 @@ onMounted(async () => {
|
||||
</ion-content>
|
||||
</ion-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const {
|
||||
isChecking,
|
||||
currentVersion,
|
||||
checkAndPromptUpdate,
|
||||
} = useAppUpdate()
|
||||
|
||||
async function handleCheckUpdate() {
|
||||
await checkAndPromptUpdate()
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 3. 自定义更新提示
|
||||
@@ -86,19 +86,20 @@ const {
|
||||
updateMessage,
|
||||
checkForUpdate,
|
||||
openStoreUpdate,
|
||||
} = useAppUpdate()
|
||||
} = useAppUpdate();
|
||||
|
||||
async function checkUpdate() {
|
||||
const result = await checkForUpdate()
|
||||
const result = await checkForUpdate();
|
||||
|
||||
if (result.hasUpdate) {
|
||||
// 自定义提示逻辑
|
||||
if (result.forceUpdate) {
|
||||
// 强制更新 - 阻止用户继续使用
|
||||
showForceUpdateModal()
|
||||
} else {
|
||||
showForceUpdateModal();
|
||||
}
|
||||
else {
|
||||
// 可选更新 - 显示提示但允许跳过
|
||||
showOptionalUpdateToast()
|
||||
showOptionalUpdateToast();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,26 +109,28 @@ async function checkUpdate() {
|
||||
### 4. 显示版本信息
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue";
|
||||
|
||||
const { currentVersion, hasUpdate, getCurrentVersion, checkForUpdate } = useAppUpdate();
|
||||
|
||||
onMounted(async () => {
|
||||
await getCurrentVersion();
|
||||
await checkForUpdate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>应用版本</h2>
|
||||
<p>{{ currentVersion || '获取中...' }}</p>
|
||||
</ion-label>
|
||||
<ion-badge v-if="hasUpdate" color="danger">有更新</ion-badge>
|
||||
<ion-badge v-if="hasUpdate" color="danger">
|
||||
有更新
|
||||
</ion-badge>
|
||||
</ion-item>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
const { currentVersion, hasUpdate, getCurrentVersion, checkForUpdate } = useAppUpdate()
|
||||
|
||||
onMounted(async () => {
|
||||
await getCurrentVersion()
|
||||
await checkForUpdate()
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## API 参考
|
||||
|
||||
@@ -51,23 +51,23 @@ curl https://your-domain.com/version.json
|
||||
|
||||
```typescript
|
||||
// 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 frontendVersion = await response.json()
|
||||
const response = await fetch("https://your-frontend-domain.com/version.json");
|
||||
const frontendVersion = await response.json();
|
||||
|
||||
const { platform, currentVersion } = ctx.query
|
||||
const { platform, currentVersion } = ctx.query;
|
||||
|
||||
return {
|
||||
version: frontendVersion.version,
|
||||
forceUpdate: compareVersion(currentVersion, frontendVersion.version) < 0,
|
||||
updateMessage: '修复了一些问题',
|
||||
updateUrl: platform === 'ios'
|
||||
? 'https://apps.apple.com/app/xxx'
|
||||
: 'https://play.google.com/store/apps/details?id=xxx',
|
||||
minSupportVersion: '0.0.1',
|
||||
}
|
||||
})
|
||||
updateMessage: "修复了一些问题",
|
||||
updateUrl: platform === "ios"
|
||||
? "https://apps.apple.com/app/xxx"
|
||||
: "https://play.google.com/store/apps/details?id=xxx",
|
||||
minSupportVersion: "0.0.1",
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
**方式 2:部署时同步到后端**
|
||||
@@ -88,18 +88,18 @@ app.get('/api/app/version', async (ctx) => {
|
||||
后端直接读取本地文件:
|
||||
|
||||
```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 {
|
||||
version,
|
||||
forceUpdate: false,
|
||||
// ...
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
@@ -162,27 +162,27 @@ versionName "1.2.3" // 与 package.json 保持一致
|
||||
|
||||
```javascript
|
||||
#!/usr/bin/env node
|
||||
import fs from 'fs'
|
||||
import { execSync } from 'child_process'
|
||||
import { execSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
|
||||
const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf-8'))
|
||||
const version = packageJson.version
|
||||
const packageJson = JSON.parse(fs.readFileSync("./package.json", "utf-8"));
|
||||
const version = packageJson.version;
|
||||
|
||||
console.log(`Syncing version ${version} to native projects...`)
|
||||
console.log(`Syncing version ${version} to native projects...`);
|
||||
|
||||
// iOS
|
||||
const infoPlist = './ios/App/App/Info.plist'
|
||||
let plistContent = fs.readFileSync(infoPlist, 'utf-8')
|
||||
const infoPlist = "./ios/App/App/Info.plist";
|
||||
let plistContent = fs.readFileSync(infoPlist, "utf-8");
|
||||
plistContent = plistContent.replace(
|
||||
/<key>CFBundleShortVersionString<\/key>\s*<string>.*?<\/string>/,
|
||||
`<key>CFBundleShortVersionString</key>\n\t<string>${version}</string>`
|
||||
)
|
||||
fs.writeFileSync(infoPlist, plistContent)
|
||||
);
|
||||
fs.writeFileSync(infoPlist, plistContent);
|
||||
|
||||
// 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` 中添加脚本:
|
||||
@@ -241,7 +241,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: "18"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
@@ -284,107 +284,108 @@ jobs:
|
||||
|
||||
**一次配置,永久受益!** 🎉
|
||||
|
||||
|
||||
## 后端 API 实现示例 - 版本检查接口
|
||||
|
||||
```ts
|
||||
import { Elysia, t } from 'elysia'
|
||||
// 方案二:从本地文件读取(适用于前后端部署在同一服务器)
|
||||
import fs from "node:fs";
|
||||
|
||||
import { Elysia, t } from "elysia";
|
||||
|
||||
// 版本比较工具函数
|
||||
function compareVersion(version1: string, version2: string): number {
|
||||
const v1Parts = version1.split('.').map(Number)
|
||||
const v2Parts = version2.split('.').map(Number)
|
||||
const maxLength = Math.max(v1Parts.length, v2Parts.length)
|
||||
const v1Parts = version1.split(".").map(Number);
|
||||
const v2Parts = version2.split(".").map(Number);
|
||||
const maxLength = Math.max(v1Parts.length, v2Parts.length);
|
||||
|
||||
for (let i = 0; i < maxLength; i++) {
|
||||
const v1Part = v1Parts[i] || 0
|
||||
const v2Part = v2Parts[i] || 0
|
||||
const v1Part = v1Parts[i] || 0;
|
||||
const v2Part = v2Parts[i] || 0;
|
||||
|
||||
if (v1Part < v2Part) return -1
|
||||
if (v1Part > v2Part) return 1
|
||||
if (v1Part < v2Part)
|
||||
return -1;
|
||||
if (v1Part > v2Part)
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 方案一:从前端静态资源读取版本(推荐)
|
||||
async function getFrontendVersionFromURL(): Promise<{
|
||||
version: string
|
||||
buildTime: string
|
||||
gitCommit: string
|
||||
environment: string
|
||||
version: string;
|
||||
buildTime: string;
|
||||
gitCommit: string;
|
||||
environment: string;
|
||||
}> {
|
||||
const response = await fetch('https://your-frontend-domain.com/version.json')
|
||||
return await response.json()
|
||||
const response = await fetch("https://your-frontend-domain.com/version.json");
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// 方案二:从本地文件读取(适用于前后端部署在同一服务器)
|
||||
import fs from 'fs'
|
||||
|
||||
function getFrontendVersionFromFile(): {
|
||||
version: string
|
||||
buildTime: string
|
||||
gitCommit: string
|
||||
environment: string
|
||||
version: string;
|
||||
buildTime: string;
|
||||
gitCommit: string;
|
||||
environment: string;
|
||||
} {
|
||||
const content = fs.readFileSync('/app/frontend-version.json', 'utf-8')
|
||||
return JSON.parse(content)
|
||||
const content = fs.readFileSync("/app/frontend-version.json", "utf-8");
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
// 应用商店链接配置
|
||||
const APP_STORE_URLS = {
|
||||
ios: 'https://apps.apple.com/app/id123456789',
|
||||
android: 'https://play.google.com/store/apps/details?id=riwa.ionic.app',
|
||||
}
|
||||
ios: "https://apps.apple.com/app/id123456789",
|
||||
android: "https://play.google.com/store/apps/details?id=riwa.ionic.app",
|
||||
};
|
||||
|
||||
// 版本策略配置(可存储在数据库)
|
||||
const VERSION_POLICIES = {
|
||||
minSupportVersion: '0.0.1', // 最低支持版本
|
||||
forceUpdateVersions: ['0.0.1'], // 需要强制更新的版本列表
|
||||
minSupportVersion: "0.0.1", // 最低支持版本
|
||||
forceUpdateVersions: ["0.0.1"], // 需要强制更新的版本列表
|
||||
updateMessages: {
|
||||
'zh-CN': '修复了一些问题并优化了性能',
|
||||
'en-US': 'Bug fixes and performance improvements',
|
||||
"zh-CN": "修复了一些问题并优化了性能",
|
||||
"en-US": "Bug fixes and performance improvements",
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
// Elysia 路由定义
|
||||
const app = new Elysia()
|
||||
.get(
|
||||
'/api/app/version',
|
||||
"/api/app/version",
|
||||
async ({ query, headers }) => {
|
||||
const { platform, currentVersion } = query
|
||||
const lang = headers['accept-language']?.split(',')[0] || 'en-US'
|
||||
const { platform, currentVersion } = query;
|
||||
const lang = headers["accept-language"]?.split(",")[0] || "en-US";
|
||||
|
||||
try {
|
||||
// 获取前端版本信息
|
||||
const frontendVersion = await getFrontendVersionFromURL()
|
||||
const frontendVersion = await getFrontendVersionFromURL();
|
||||
// 或使用本地文件: 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) {
|
||||
forceUpdate = true
|
||||
forceUpdate = true;
|
||||
}
|
||||
|
||||
// 获取更新链接
|
||||
const updateUrl = platform === 'ios'
|
||||
const updateUrl = platform === "ios"
|
||||
? APP_STORE_URLS.ios
|
||||
: platform === 'android'
|
||||
: platform === "android"
|
||||
? APP_STORE_URLS.android
|
||||
: ''
|
||||
: "";
|
||||
|
||||
// 获取更新说明(多语言)
|
||||
const updateMessage = VERSION_POLICIES.updateMessages[lang]
|
||||
|| VERSION_POLICIES.updateMessages['en-US']
|
||||
|| VERSION_POLICIES.updateMessages["en-US"];
|
||||
|
||||
return {
|
||||
version: frontendVersion.version,
|
||||
buildNumber: parseInt(frontendVersion.version.replace(/\./g, '')),
|
||||
buildNumber: Number.parseInt(frontendVersion.version.replace(/\./g, "")),
|
||||
buildTime: frontendVersion.buildTime,
|
||||
gitCommit: frontendVersion.gitCommit,
|
||||
forceUpdate,
|
||||
@@ -392,26 +393,27 @@ const app = new Elysia()
|
||||
updateUrl,
|
||||
minSupportVersion: VERSION_POLICIES.minSupportVersion,
|
||||
releaseNotes: [
|
||||
'修复了已知问题',
|
||||
'优化了应用性能',
|
||||
'改进了用户界面',
|
||||
"修复了已知问题",
|
||||
"优化了应用性能",
|
||||
"改进了用户界面",
|
||||
],
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get frontend version:', error)
|
||||
catch (error) {
|
||||
console.error("Failed to get frontend version:", error);
|
||||
|
||||
// 降级处理:返回当前版本,不强制更新
|
||||
return {
|
||||
version: currentVersion,
|
||||
forceUpdate: false,
|
||||
updateMessage: '',
|
||||
updateUrl: '',
|
||||
}
|
||||
updateMessage: "",
|
||||
updateUrl: "",
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
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(),
|
||||
}),
|
||||
response: t.Object({
|
||||
@@ -426,9 +428,9 @@ const app = new Elysia()
|
||||
releaseNotes: t.Optional(t.Array(t.String())),
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export default app
|
||||
export default app;
|
||||
|
||||
/**
|
||||
* 使用示例:
|
||||
@@ -470,5 +472,4 @@ export default app
|
||||
* [platform]
|
||||
* )
|
||||
*/
|
||||
|
||||
```
|
||||
@@ -36,7 +36,7 @@
|
||||
"@ionic/vue-router": "^8.7.11",
|
||||
"@riwa/api-types": "http://192.168.1.7:9527/api/riwa-eden-0.0.118.tgz",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@vee-validate/yup": "^4.15.1",
|
||||
"@vee-validate/zod": "^4.15.1",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"@vueuse/integrations": "^14.1.0",
|
||||
"@vueuse/router": "^14.1.0",
|
||||
@@ -55,7 +55,7 @@
|
||||
"vue": "^3.5.25",
|
||||
"vue-i18n": "^11.2.2",
|
||||
"vue-router": "^4.6.3",
|
||||
"yup": "^1.7.1"
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^6.6.1",
|
||||
|
||||
@@ -50,19 +50,19 @@ pnpm generate
|
||||
|
||||
```typescript
|
||||
export const currentVersion: AppVersion = {
|
||||
version: '1.0.0',
|
||||
buildNumber: '100',
|
||||
releaseDate: '2025-12-30',
|
||||
version: "1.0.0",
|
||||
buildNumber: "100",
|
||||
releaseDate: "2025-12-30",
|
||||
releaseNotes: {
|
||||
'zh-CN': ['更新内容...'],
|
||||
'en-US': ['What\'s new...'],
|
||||
"zh-CN": ["更新内容..."],
|
||||
"en-US": ["What's new..."],
|
||||
},
|
||||
downloads: {
|
||||
ios: 'https://example.com/app.ipa',
|
||||
android: 'https://example.com/app.apk',
|
||||
h5: 'https://app.example.com',
|
||||
ios: "https://example.com/app.ipa",
|
||||
android: "https://example.com/app.apk",
|
||||
h5: "https://app.example.com",
|
||||
},
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 接入真实 API
|
||||
@@ -81,9 +81,9 @@ Nuxt UI 使用 TailwindCSS 4,可在 `nuxt.config.ts` 中配置:
|
||||
```typescript
|
||||
export default defineNuxtConfig({
|
||||
colorMode: {
|
||||
preference: 'system', // 'light' | 'dark' | 'system'
|
||||
preference: "system", // 'light' | 'dark' | 'system'
|
||||
},
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
colors: {
|
||||
primary: 'blue',
|
||||
primary: "blue",
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
@@ -6,4 +6,3 @@
|
||||
<NuxtPage />
|
||||
</UApp>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -64,7 +64,8 @@
|
||||
|
||||
/* 发光脉冲效果 */
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 20px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
50% {
|
||||
@@ -96,7 +97,8 @@
|
||||
|
||||
/* 悬浮动画 */
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
@@ -208,7 +210,8 @@
|
||||
|
||||
/* 脉冲缩放动画 */
|
||||
@keyframes pulse-scale {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
@@ -250,7 +253,8 @@
|
||||
|
||||
/* 微妙的悬浮动画 */
|
||||
@keyframes float-subtle {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) translateZ(0);
|
||||
}
|
||||
50% {
|
||||
@@ -284,7 +288,8 @@
|
||||
|
||||
/* 发光脉冲动画 */
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
@@ -298,7 +303,8 @@
|
||||
|
||||
/* 微妙的脉冲动画 */
|
||||
@keyframes pulse-subtle {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
@@ -362,7 +368,8 @@
|
||||
|
||||
/* 震动效果 */
|
||||
@keyframes shake {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
25% {
|
||||
@@ -401,7 +408,8 @@
|
||||
|
||||
/* 呼吸灯效果 */
|
||||
@keyframes breathe {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
@@ -1,59 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
const { canInstall, isInstalled, install } = usePWAInstall()
|
||||
const installing = ref(false)
|
||||
const dismissed = ref(false)
|
||||
const { t } = useI18n();
|
||||
const { canInstall, isInstalled, install } = usePWAInstall();
|
||||
const installing = ref(false);
|
||||
const dismissed = ref(false);
|
||||
|
||||
// 从 localStorage 读取是否已关闭,但只在未安装状态下有效
|
||||
onMounted(() => {
|
||||
// 如果应用未安装,检查用户是否之前关闭过横幅
|
||||
if (!isInstalled.value) {
|
||||
dismissed.value = localStorage.getItem('pwa-banner-dismissed') === 'true'
|
||||
dismissed.value = localStorage.getItem("pwa-banner-dismissed") === "true";
|
||||
}
|
||||
else {
|
||||
// 如果应用已安装,清除关闭记录(为了卸载后能再次提示)
|
||||
localStorage.removeItem('pwa-banner-dismissed')
|
||||
localStorage.removeItem("pwa-banner-dismissed");
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// 监听安装状态变化
|
||||
watch(isInstalled, (newValue) => {
|
||||
if (newValue) {
|
||||
// 应用安装后,清除关闭记录
|
||||
localStorage.removeItem('pwa-banner-dismissed')
|
||||
dismissed.value = false
|
||||
localStorage.removeItem("pwa-banner-dismissed");
|
||||
dismissed.value = false;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// 监听 canInstall 变化(卸载后会重新触发 beforeinstallprompt)
|
||||
watch(canInstall, (newValue) => {
|
||||
if (newValue && !isInstalled.value) {
|
||||
// 如果可以安装且未安装,清除之前的关闭记录
|
||||
// 这样卸载后再次访问就会重新显示横幅
|
||||
localStorage.removeItem('pwa-banner-dismissed')
|
||||
dismissed.value = false
|
||||
localStorage.removeItem("pwa-banner-dismissed");
|
||||
dismissed.value = false;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
async function handleInstall() {
|
||||
installing.value = true
|
||||
installing.value = true;
|
||||
try {
|
||||
const success = await install()
|
||||
const success = await install();
|
||||
if (success) {
|
||||
console.log('PWA 安装成功')
|
||||
console.log("PWA 安装成功");
|
||||
}
|
||||
}
|
||||
finally {
|
||||
installing.value = false
|
||||
installing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function dismissBanner() {
|
||||
dismissed.value = true
|
||||
localStorage.setItem('pwa-banner-dismissed', 'true')
|
||||
dismissed.value = 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>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
const { canInstall, isInstalled, install } = usePWAInstall()
|
||||
const installing = ref(false)
|
||||
const { t } = useI18n();
|
||||
const { canInstall, isInstalled, install } = usePWAInstall();
|
||||
const installing = ref(false);
|
||||
|
||||
async function handleInstall() {
|
||||
installing.value = true
|
||||
installing.value = true;
|
||||
try {
|
||||
const success = await install()
|
||||
const success = await install();
|
||||
if (success) {
|
||||
// 可以显示成功提示
|
||||
console.log('PWA 安装成功')
|
||||
console.log("PWA 安装成功");
|
||||
}
|
||||
}
|
||||
finally {
|
||||
installing.value = false
|
||||
installing.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,52 +1,52 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onMounted, ref } from "vue";
|
||||
|
||||
export function usePWAInstall() {
|
||||
const deferredPrompt = ref<any>(null)
|
||||
const canInstall = ref(false)
|
||||
const isInstalled = ref(false)
|
||||
const deferredPrompt = ref<any>(null);
|
||||
const canInstall = ref(false);
|
||||
const isInstalled = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
// 检查是否已安装
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) {
|
||||
isInstalled.value = true
|
||||
return
|
||||
if (window.matchMedia("(display-mode: standalone)").matches) {
|
||||
isInstalled.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 监听安装提示事件
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault()
|
||||
deferredPrompt.value = e
|
||||
canInstall.value = true
|
||||
})
|
||||
window.addEventListener("beforeinstallprompt", (e) => {
|
||||
e.preventDefault();
|
||||
deferredPrompt.value = e;
|
||||
canInstall.value = true;
|
||||
});
|
||||
|
||||
// 监听安装成功事件
|
||||
window.addEventListener('appinstalled', () => {
|
||||
deferredPrompt.value = null
|
||||
canInstall.value = false
|
||||
isInstalled.value = true
|
||||
})
|
||||
})
|
||||
window.addEventListener("appinstalled", () => {
|
||||
deferredPrompt.value = null;
|
||||
canInstall.value = false;
|
||||
isInstalled.value = true;
|
||||
});
|
||||
});
|
||||
|
||||
async function install() {
|
||||
if (!deferredPrompt.value) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await deferredPrompt.value.prompt()
|
||||
const { outcome } = await deferredPrompt.value.userChoice
|
||||
await deferredPrompt.value.prompt();
|
||||
const { outcome } = await deferredPrompt.value.userChoice;
|
||||
|
||||
if (outcome === 'accepted') {
|
||||
deferredPrompt.value = null
|
||||
canInstall.value = false
|
||||
return true
|
||||
if (outcome === "accepted") {
|
||||
deferredPrompt.value = null;
|
||||
canInstall.value = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
catch (error) {
|
||||
console.error('安装失败:', error)
|
||||
return false
|
||||
console.error("安装失败:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,5 +54,5 @@ export function usePWAInstall() {
|
||||
canInstall,
|
||||
isInstalled,
|
||||
install,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import type { Platform } from '~/types'
|
||||
import type { Platform } from "~/types";
|
||||
|
||||
export function usePlatformDetection() {
|
||||
const platform = useState<Platform>('platform', () => 'unknown')
|
||||
const platform = useState<Platform>("platform", () => "unknown");
|
||||
|
||||
function detectPlatform(): Platform {
|
||||
if (import.meta.server)
|
||||
return 'unknown'
|
||||
return "unknown";
|
||||
|
||||
const ua = navigator.userAgent.toLowerCase()
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
|
||||
if (/iphone|ipad|ipod/.test(ua))
|
||||
return 'ios'
|
||||
return "ios";
|
||||
else if (/android/.test(ua))
|
||||
return 'android'
|
||||
return "android";
|
||||
else if (/windows|macintosh|linux/.test(ua))
|
||||
return 'desktop'
|
||||
return "desktop";
|
||||
|
||||
return 'unknown'
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
platform.value = detectPlatform()
|
||||
})
|
||||
platform.value = detectPlatform();
|
||||
});
|
||||
|
||||
return {
|
||||
platform: readonly(platform),
|
||||
isIOS: computed(() => platform.value === 'ios'),
|
||||
isAndroid: computed(() => platform.value === 'android'),
|
||||
isDesktop: computed(() => platform.value === 'desktop'),
|
||||
}
|
||||
isIOS: computed(() => platform.value === "ios"),
|
||||
isAndroid: computed(() => platform.value === "android"),
|
||||
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 = {
|
||||
version: '1.0.0',
|
||||
buildNumber: '100',
|
||||
releaseDate: '2025-12-30',
|
||||
version: "1.0.0",
|
||||
buildNumber: "100",
|
||||
releaseDate: "2025-12-30",
|
||||
releaseNotes: {
|
||||
'zh-CN': [
|
||||
'🎉 首次发布',
|
||||
'✨ 全新的用户界面设计',
|
||||
'🔐 增强的安全特性',
|
||||
'⚡ 性能优化,响应速度提升 30%',
|
||||
'🌍 支持多语言切换',
|
||||
'🌙 深色模式支持',
|
||||
"zh-CN": [
|
||||
"🎉 首次发布",
|
||||
"✨ 全新的用户界面设计",
|
||||
"🔐 增强的安全特性",
|
||||
"⚡ 性能优化,响应速度提升 30%",
|
||||
"🌍 支持多语言切换",
|
||||
"🌙 深色模式支持",
|
||||
],
|
||||
'en-US': [
|
||||
'🎉 Initial Release',
|
||||
'✨ Brand new user interface',
|
||||
'🔐 Enhanced security features',
|
||||
'⚡ Performance optimization, 30% faster response',
|
||||
'🌍 Multi-language support',
|
||||
'🌙 Dark mode support',
|
||||
"en-US": [
|
||||
"🎉 Initial Release",
|
||||
"✨ Brand new user interface",
|
||||
"🔐 Enhanced security features",
|
||||
"⚡ Performance optimization, 30% faster response",
|
||||
"🌍 Multi-language support",
|
||||
"🌙 Dark mode support",
|
||||
],
|
||||
},
|
||||
downloads: {
|
||||
ios: 'https://example.com/riwa-ios-1.0.0.ipa',
|
||||
android: 'https://example.com/riwa-android-1.0.0.apk',
|
||||
h5: 'http://localhost:5173',
|
||||
ios: "https://example.com/riwa-ios-1.0.0.ipa",
|
||||
android: "https://example.com/riwa-android-1.0.0.apk",
|
||||
h5: "http://localhost:5173",
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
// 模拟下载统计数据
|
||||
export const mockDownloadStats: DownloadStats = {
|
||||
@@ -36,4 +36,4 @@ export const mockDownloadStats: DownloadStats = {
|
||||
today: 156,
|
||||
ios: 7234,
|
||||
android: 5346,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -14,7 +14,6 @@ services:
|
||||
# nginx conf
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf
|
||||
|
||||
|
||||
# version: '3.8'
|
||||
|
||||
# services:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import antfu from '@antfu/eslint-config'
|
||||
import withNuxt from './.nuxt/eslint.config.mjs'
|
||||
import antfu from "@antfu/eslint-config";
|
||||
import withNuxt from "./.nuxt/eslint.config.mjs";
|
||||
|
||||
export default withNuxt(
|
||||
antfu({
|
||||
formatters: true,
|
||||
}),
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,49 +1,49 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
modules: [
|
||||
'@nuxt/ui',
|
||||
'@nuxtjs/i18n',
|
||||
'@nuxt/eslint',
|
||||
'@vite-pwa/nuxt',
|
||||
"@nuxt/ui",
|
||||
"@nuxtjs/i18n",
|
||||
"@nuxt/eslint",
|
||||
"@vite-pwa/nuxt",
|
||||
],
|
||||
|
||||
devtools: { enabled: true },
|
||||
|
||||
pwa: {
|
||||
registerType: 'autoUpdate',
|
||||
registerType: "autoUpdate",
|
||||
manifest: {
|
||||
name: 'Riwa应用商店',
|
||||
short_name: 'Riwa应用商店',
|
||||
description: 'Riwa App 下载 - iOS, Android, H5',
|
||||
theme_color: '#3b82f6',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
scope: '/',
|
||||
start_url: '/',
|
||||
name: "Riwa应用商店",
|
||||
short_name: "Riwa应用商店",
|
||||
description: "Riwa App 下载 - iOS, Android, H5",
|
||||
theme_color: "#3b82f6",
|
||||
background_color: "#ffffff",
|
||||
display: "standalone",
|
||||
scope: "/",
|
||||
start_url: "/",
|
||||
icons: [
|
||||
{
|
||||
src: '/favicon.svg',
|
||||
sizes: '512x512',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'any',
|
||||
src: "/favicon.svg",
|
||||
sizes: "512x512",
|
||||
type: "image/svg+xml",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: '/favicon.svg',
|
||||
sizes: '192x192',
|
||||
type: 'image/svg+xml',
|
||||
src: "/favicon.svg",
|
||||
sizes: "192x192",
|
||||
type: "image/svg+xml",
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
navigateFallback: '/',
|
||||
globPatterns: ['**/*.{js,css,html,png,svg,ico}'],
|
||||
navigateFallback: "/",
|
||||
globPatterns: ["**/*.{js,css,html,png,svg,ico}"],
|
||||
cleanupOutdatedCaches: true,
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: 'google-fonts-cache',
|
||||
cacheName: "google-fonts-cache",
|
||||
expiration: {
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
|
||||
@@ -55,9 +55,9 @@ export default defineNuxtConfig({
|
||||
},
|
||||
{
|
||||
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: 'gstatic-fonts-cache',
|
||||
cacheName: "gstatic-fonts-cache",
|
||||
expiration: {
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
|
||||
@@ -75,81 +75,81 @@ export default defineNuxtConfig({
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
type: 'module',
|
||||
type: "module",
|
||||
},
|
||||
injectManifest: {
|
||||
globPatterns: ['**/*.{js,css,html,png,svg,ico}'],
|
||||
globPatterns: ["**/*.{js,css,html,png,svg,ico}"],
|
||||
},
|
||||
},
|
||||
|
||||
css: [
|
||||
'~/assets/css/main.css',
|
||||
'~/assets/css/animations.css',
|
||||
"~/assets/css/main.css",
|
||||
"~/assets/css/animations.css",
|
||||
],
|
||||
|
||||
colorMode: {
|
||||
preference: 'light',
|
||||
preference: "light",
|
||||
},
|
||||
|
||||
i18n: {
|
||||
defaultLocale: 'zh-CN',
|
||||
defaultLocale: "zh-CN",
|
||||
locales: [
|
||||
{
|
||||
code: 'zh-CN',
|
||||
name: '简体中文',
|
||||
file: 'zh-CN.json',
|
||||
code: "zh-CN",
|
||||
name: "简体中文",
|
||||
file: "zh-CN.json",
|
||||
},
|
||||
{
|
||||
code: 'en-US',
|
||||
name: 'English',
|
||||
file: 'en-US.json',
|
||||
code: "en-US",
|
||||
name: "English",
|
||||
file: "en-US.json",
|
||||
},
|
||||
],
|
||||
strategy: 'no_prefix',
|
||||
strategy: "no_prefix",
|
||||
detectBrowserLanguage: {
|
||||
useCookie: true,
|
||||
cookieKey: 'i18n_locale',
|
||||
redirectOn: 'root',
|
||||
cookieKey: "i18n_locale",
|
||||
redirectOn: "root",
|
||||
},
|
||||
},
|
||||
|
||||
app: {
|
||||
head: {
|
||||
charset: 'utf-8',
|
||||
viewport: 'width=device-width, initial-scale=1',
|
||||
title: 'Riwa App 下载',
|
||||
charset: "utf-8",
|
||||
viewport: "width=device-width, initial-scale=1",
|
||||
title: "Riwa App 下载",
|
||||
meta: [
|
||||
{ name: 'description', content: 'Riwa App 下载 - iOS, Android, H5' },
|
||||
{ name: "description", content: "Riwa App 下载 - iOS, Android, H5" },
|
||||
],
|
||||
link: [
|
||||
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
|
||||
{ rel: "icon", type: "image/svg+xml", href: "/favicon.svg" },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
devServer: {
|
||||
host: '0.0.0.0'
|
||||
host: "0.0.0.0",
|
||||
},
|
||||
|
||||
nitro: {
|
||||
prerender: {
|
||||
routes: ['/'],
|
||||
routes: ["/"],
|
||||
crawlLinks: true,
|
||||
},
|
||||
},
|
||||
|
||||
hooks: {
|
||||
async 'nitro:config'(nitroConfig) {
|
||||
"nitro:config": async function (nitroConfig) {
|
||||
// 预渲染所有应用详情页
|
||||
const { apps } = await import('./data/apps')
|
||||
const routes = apps.map(app => `/apps/${app.id}`)
|
||||
nitroConfig.prerender = nitroConfig.prerender || {}
|
||||
const { apps } = await import("./data/apps");
|
||||
const routes = apps.map(app => `/apps/${app.id}`);
|
||||
nitroConfig.prerender = nitroConfig.prerender || {};
|
||||
nitroConfig.prerender.routes = [
|
||||
...(nitroConfig.prerender.routes || []),
|
||||
...routes,
|
||||
]
|
||||
];
|
||||
},
|
||||
},
|
||||
|
||||
compatibilityDate: '2025-12-30',
|
||||
})
|
||||
compatibilityDate: "2025-12-30",
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@riwa/distribute",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nuxt dev",
|
||||
|
||||
@@ -1,72 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import type { AppInfo } from '~/types'
|
||||
import { apps } from '~/data/apps'
|
||||
import type { AppInfo } from "~/types";
|
||||
import { apps } from "~/data/apps";
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t, locale } = useI18n()
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
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) {
|
||||
navigateTo('/')
|
||||
navigateTo("/");
|
||||
}
|
||||
|
||||
// 下载处理
|
||||
async function handleDownload(type: 'ios' | 'android' | 'h5') {
|
||||
async function handleDownload(type: "ios" | "android" | "h5") {
|
||||
if (!app.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const url = app.value.downloads[type]
|
||||
const url = app.value.downloads[type];
|
||||
|
||||
if (!url) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'h5') {
|
||||
navigateTo(url, { external: true, open: { target: '_blank' } })
|
||||
if (type === "h5") {
|
||||
navigateTo(url, { external: true, open: { target: "_blank" } });
|
||||
}
|
||||
else {
|
||||
navigateTo(url, { external: true })
|
||||
navigateTo(url, { external: true });
|
||||
}
|
||||
|
||||
await $fetch(`/api/track/${type}`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
body: { appId: app.value.id },
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// 返回首页
|
||||
function goBack() {
|
||||
router.back()
|
||||
router.back();
|
||||
}
|
||||
|
||||
// SEO
|
||||
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: [
|
||||
{
|
||||
name: 'description',
|
||||
content: app.value?.shortDescription[locale.value as 'zh-CN' | 'en-US'] || '',
|
||||
name: "description",
|
||||
content: app.value?.shortDescription[locale.value as "zh-CN" | "en-US"] || "",
|
||||
},
|
||||
],
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<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 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 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 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 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>
|
||||
|
||||
<UContainer class="relative z-10">
|
||||
@@ -91,10 +91,10 @@ useHead({
|
||||
<!-- App Header -->
|
||||
<div class="flex items-start gap-6 mb-8 group">
|
||||
<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="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>
|
||||
<img :src="app.icon" :alt="app.name" class="size-full object-contain relative z-10 rounded-2xl" />
|
||||
<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">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
@@ -115,7 +115,7 @@ useHead({
|
||||
<!-- Download Buttons -->
|
||||
<div class="grid md:grid-cols-3 gap-4 mb-8">
|
||||
<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
|
||||
icon="i-heroicons-device-phone-mobile"
|
||||
size="xl"
|
||||
@@ -124,7 +124,9 @@ useHead({
|
||||
@click="handleDownload('ios')"
|
||||
>
|
||||
<div class="text-left w-full">
|
||||
<div class="font-semibold text-base">iOS</div>
|
||||
<div class="font-semibold text-base">
|
||||
iOS
|
||||
</div>
|
||||
<div v-if="app.size?.ios" class="text-xs opacity-80">
|
||||
{{ app.size.ios }}
|
||||
</div>
|
||||
@@ -132,7 +134,7 @@ useHead({
|
||||
</UButton>
|
||||
</div>
|
||||
<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
|
||||
icon="i-heroicons-device-tablet"
|
||||
size="xl"
|
||||
@@ -141,7 +143,9 @@ useHead({
|
||||
@click="handleDownload('android')"
|
||||
>
|
||||
<div class="text-left w-full">
|
||||
<div class="font-semibold text-base">Android</div>
|
||||
<div class="font-semibold text-base">
|
||||
Android
|
||||
</div>
|
||||
<div v-if="app.size?.android" class="text-xs opacity-80">
|
||||
{{ app.size.android }}
|
||||
</div>
|
||||
@@ -149,7 +153,7 @@ useHead({
|
||||
</UButton>
|
||||
</div>
|
||||
<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
|
||||
icon="i-heroicons-globe-alt"
|
||||
size="xl"
|
||||
@@ -158,7 +162,9 @@ useHead({
|
||||
@click="handleDownload('h5')"
|
||||
>
|
||||
<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">
|
||||
PWA
|
||||
</div>
|
||||
@@ -170,7 +176,7 @@ useHead({
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<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">
|
||||
<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">
|
||||
@@ -183,7 +189,7 @@ useHead({
|
||||
</UCard>
|
||||
</div>
|
||||
<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">
|
||||
<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">
|
||||
@@ -196,7 +202,7 @@ useHead({
|
||||
</UCard>
|
||||
</div>
|
||||
<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">
|
||||
<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">
|
||||
@@ -209,7 +215,7 @@ useHead({
|
||||
</UCard>
|
||||
</div>
|
||||
<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">
|
||||
<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">
|
||||
@@ -225,7 +231,7 @@ useHead({
|
||||
|
||||
<!-- Description -->
|
||||
<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">
|
||||
<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' }}
|
||||
@@ -238,7 +244,7 @@ useHead({
|
||||
|
||||
<!-- What's New -->
|
||||
<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">
|
||||
<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" }}
|
||||
|
||||
@@ -1,104 +1,104 @@
|
||||
<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 colorMode = useColorMode()
|
||||
const { t, locale, setLocale } = useI18n();
|
||||
const colorMode = useColorMode();
|
||||
|
||||
// 直接使用数据文件
|
||||
const apps = computed(() => appsData)
|
||||
const categories = computed(() => categoriesData)
|
||||
const apps = computed(() => appsData);
|
||||
const categories = computed(() => categoriesData);
|
||||
|
||||
// 当前选中的分类
|
||||
const selectedCategory = ref('all')
|
||||
const selectedCategory = ref("all");
|
||||
|
||||
// 搜索关键词
|
||||
const searchKeyword = ref('')
|
||||
const searchKeyword = ref("");
|
||||
|
||||
// 过滤后的应用列表
|
||||
const filteredApps = computed(() => {
|
||||
let result = apps.value
|
||||
let result = apps.value;
|
||||
|
||||
// 按分类过滤
|
||||
if (selectedCategory.value !== 'all') {
|
||||
result = result.filter(app => app.category === selectedCategory.value)
|
||||
if (selectedCategory.value !== "all") {
|
||||
result = result.filter(app => app.category === selectedCategory.value);
|
||||
}
|
||||
|
||||
// 按搜索关键词过滤
|
||||
if (searchKeyword.value.trim()) {
|
||||
const keyword = searchKeyword.value.toLowerCase()
|
||||
const keyword = searchKeyword.value.toLowerCase();
|
||||
result = result.filter(app =>
|
||||
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() {
|
||||
setLocale(locale.value === 'zh-CN' ? 'en-US' : 'zh-CN')
|
||||
setLocale(locale.value === "zh-CN" ? "en-US" : "zh-CN");
|
||||
}
|
||||
|
||||
// 打开应用详情
|
||||
function openAppDetail(app: AppInfo) {
|
||||
navigateTo(`/apps/${app.id}`)
|
||||
navigateTo(`/apps/${app.id}`);
|
||||
}
|
||||
|
||||
// 下载处理
|
||||
async function handleDownload(app: AppInfo, type: 'ios' | 'android' | 'h5', event?: MouseEvent | TouchEvent) {
|
||||
const url = app.downloads[type]
|
||||
async function handleDownload(app: AppInfo, type: "ios" | "android" | "h5", event?: MouseEvent | TouchEvent) {
|
||||
const url = app.downloads[type];
|
||||
|
||||
if (!url) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'h5') {
|
||||
navigateTo(url, { external: true, open: { target: '_blank' } })
|
||||
if (type === "h5") {
|
||||
navigateTo(url, { external: true, open: { target: "_blank" } });
|
||||
}
|
||||
else {
|
||||
navigateTo(url, { external: true })
|
||||
navigateTo(url, { external: true });
|
||||
}
|
||||
|
||||
await $fetch(`/api/track/${type}`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
body: { appId: app.id },
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
const isDark = computed(() => colorMode.value === 'dark')
|
||||
const isDark = computed(() => colorMode.value === "dark");
|
||||
|
||||
// SEO
|
||||
useHead({
|
||||
title: locale.value === 'zh-CN' ? 'Riwa 应用商店' : 'Riwa App Store',
|
||||
title: locale.value === "zh-CN" ? "Riwa 应用商店" : "Riwa App Store",
|
||||
meta: [
|
||||
{
|
||||
name: 'description',
|
||||
content: locale.value === 'zh-CN'
|
||||
? '下载 Riwa 系列应用,包括 Riwa 主应用、Riwa 钱包和 Riwa 聊天等'
|
||||
: 'Download Riwa apps including Riwa main app, Riwa Wallet, and Riwa Chat',
|
||||
name: "description",
|
||||
content: locale.value === "zh-CN"
|
||||
? "下载 Riwa 系列应用,包括 Riwa 主应用、Riwa 钱包和 Riwa 聊天等"
|
||||
: "Download Riwa apps including Riwa main app, Riwa Wallet, and Riwa Chat",
|
||||
},
|
||||
],
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<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="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 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 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 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/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 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 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 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>
|
||||
|
||||
<!-- Header -->
|
||||
@@ -107,8 +107,8 @@ useHead({
|
||||
<div class="flex items-center justify-between py-4">
|
||||
<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="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-1 bg-blue-400/30 rounded-xl blur-md opacity-0 group-hover:opacity-100 animate-pulse-ring"></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" />
|
||||
<span class="relative z-10">R</span>
|
||||
</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">
|
||||
@@ -181,14 +181,14 @@ useHead({
|
||||
@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">
|
||||
<!-- 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="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>
|
||||
<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 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">
|
||||
</div>
|
||||
|
||||
<!-- App Info -->
|
||||
@@ -217,8 +217,7 @@ useHead({
|
||||
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"
|
||||
@click.stop="(e) => handleDownload(app, 'ios', e)"
|
||||
>
|
||||
</UButton>
|
||||
/>
|
||||
<UButton
|
||||
v-if="app.downloads.android"
|
||||
icon="i-heroicons-device-tablet"
|
||||
@@ -227,8 +226,7 @@ useHead({
|
||||
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"
|
||||
@click.stop="(e) => handleDownload(app, 'android', e)"
|
||||
>
|
||||
</UButton>
|
||||
/>
|
||||
<UButton
|
||||
v-if="app.downloads.h5"
|
||||
icon="i-heroicons-globe-alt"
|
||||
@@ -237,8 +235,7 @@ useHead({
|
||||
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"
|
||||
@click.stop="(e) => handleDownload(app, 'h5', e)"
|
||||
>
|
||||
</UButton>
|
||||
/>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getAllApps, categories } from '~/data/apps'
|
||||
import { categories, getAllApps } from "~/data/apps";
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
return {
|
||||
apps: getAllApps(),
|
||||
categories,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { getAppById } from '~/data/apps'
|
||||
import { getAppById } from "~/data/apps";
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const id = getRouterParam(event, 'id')
|
||||
const id = getRouterParam(event, "id");
|
||||
|
||||
if (!id) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'App ID is required',
|
||||
})
|
||||
message: "App ID is required",
|
||||
});
|
||||
}
|
||||
|
||||
const app = getAppById(id)
|
||||
const app = getAppById(id);
|
||||
|
||||
if (!app) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: 'App not found',
|
||||
})
|
||||
message: "App not found",
|
||||
});
|
||||
}
|
||||
|
||||
return app
|
||||
})
|
||||
return app;
|
||||
});
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export default defineEventHandler(async () => {
|
||||
return await fetchDownloadStats()
|
||||
})
|
||||
return await fetchDownloadStats();
|
||||
});
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const platform = getRouterParam(event, 'platform') as 'ios' | 'android' | 'h5'
|
||||
const body = await readBody(event)
|
||||
const appId = body?.appId
|
||||
const platform = getRouterParam(event, "platform") as "ios" | "android" | "h5";
|
||||
const body = await readBody(event);
|
||||
const appId = body?.appId;
|
||||
|
||||
if (!['ios', 'android', 'h5'].includes(platform)) {
|
||||
if (!["ios", "android", "h5"].includes(platform)) {
|
||||
throw createError({
|
||||
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(() => {
|
||||
return currentVersion
|
||||
})
|
||||
return currentVersion;
|
||||
});
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { DownloadStats } from '~/types'
|
||||
import { mockDownloadStats } from '~/data/versions'
|
||||
import type { DownloadStats } from "~/types";
|
||||
import { mockDownloadStats } from "~/data/versions";
|
||||
|
||||
// 获取下载统计(可替换为真实 API)
|
||||
export async function fetchDownloadStats(): Promise<DownloadStats> {
|
||||
// 模拟 API 延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
return mockDownloadStats
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
return mockDownloadStats;
|
||||
}
|
||||
|
||||
// 记录下载事件(可替换为真实 API)
|
||||
export async function trackDownload(platform: 'ios' | 'android' | 'h5'): Promise<void> {
|
||||
export async function trackDownload(platform: "ios" | "android" | "h5"): Promise<void> {
|
||||
// 模拟 API 延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
console.log(`Download tracked: ${platform}`)
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
console.log(`Download tracked: ${platform}`);
|
||||
}
|
||||
|
||||
@@ -1,65 +1,65 @@
|
||||
export interface AppInfo {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
shortDescription: {
|
||||
'zh-CN': string
|
||||
'en-US': string
|
||||
}
|
||||
"zh-CN": string;
|
||||
"en-US": string;
|
||||
};
|
||||
description: {
|
||||
'zh-CN': string
|
||||
'en-US': string
|
||||
}
|
||||
category: string
|
||||
version: string
|
||||
buildNumber: string
|
||||
releaseDate: string
|
||||
"zh-CN": string;
|
||||
"en-US": string;
|
||||
};
|
||||
category: string;
|
||||
version: string;
|
||||
buildNumber: string;
|
||||
releaseDate: string;
|
||||
releaseNotes: {
|
||||
'zh-CN': string[]
|
||||
'en-US': string[]
|
||||
}
|
||||
"zh-CN": string[];
|
||||
"en-US": string[];
|
||||
};
|
||||
downloads: {
|
||||
ios?: string
|
||||
android?: string
|
||||
h5?: string
|
||||
}
|
||||
screenshots?: string[]
|
||||
ios?: string;
|
||||
android?: string;
|
||||
h5?: string;
|
||||
};
|
||||
screenshots?: string[];
|
||||
size?: {
|
||||
ios?: string
|
||||
android?: string
|
||||
}
|
||||
stats: DownloadStats
|
||||
ios?: string;
|
||||
android?: string;
|
||||
};
|
||||
stats: DownloadStats;
|
||||
}
|
||||
|
||||
export interface AppVersion {
|
||||
version: string
|
||||
buildNumber: string
|
||||
releaseDate: string
|
||||
version: string;
|
||||
buildNumber: string;
|
||||
releaseDate: string;
|
||||
releaseNotes: {
|
||||
'zh-CN': string[]
|
||||
'en-US': string[]
|
||||
}
|
||||
"zh-CN": string[];
|
||||
"en-US": string[];
|
||||
};
|
||||
downloads: {
|
||||
ios: string
|
||||
android: string
|
||||
h5: string
|
||||
}
|
||||
ios: string;
|
||||
android: string;
|
||||
h5: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DownloadStats {
|
||||
total: number
|
||||
today: number
|
||||
ios: number
|
||||
android: number
|
||||
total: number;
|
||||
today: number;
|
||||
ios: number;
|
||||
android: number;
|
||||
}
|
||||
|
||||
export type Platform = 'ios' | 'android' | 'desktop' | 'unknown'
|
||||
export type Platform = "ios" | "android" | "desktop" | "unknown";
|
||||
|
||||
export interface AppCategory {
|
||||
id: string
|
||||
id: string;
|
||||
name: {
|
||||
'zh-CN': string
|
||||
'en-US': string
|
||||
}
|
||||
icon?: string
|
||||
"zh-CN": string;
|
||||
"en-US": string;
|
||||
};
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
48
pnpm-lock.yaml
generated
48
pnpm-lock.yaml
generated
@@ -62,9 +62,9 @@ importers:
|
||||
'@tailwindcss/vite':
|
||||
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))
|
||||
'@vee-validate/yup':
|
||||
'@vee-validate/zod':
|
||||
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':
|
||||
specifier: ^14.1.0
|
||||
version: 14.1.0(vue@3.5.25(typescript@5.9.3))
|
||||
@@ -119,9 +119,9 @@ importers:
|
||||
vue-router:
|
||||
specifier: ^4.6.3
|
||||
version: 4.6.3(vue@3.5.25(typescript@5.9.3))
|
||||
yup:
|
||||
specifier: ^1.7.1
|
||||
version: 1.7.1
|
||||
zod:
|
||||
specifier: ^3.24.1
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@antfu/eslint-config':
|
||||
specifier: ^6.6.1
|
||||
@@ -3692,10 +3692,10 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@vee-validate/yup@4.15.1':
|
||||
resolution: {integrity: sha512-+u6lI1IZftjHphj+mTCPJRruwBBwv1IKKCI1EFm6ipQroAPibkS5M8UNX+yeVYG5++ix6m1rsv4/SJvJJQTWJg==}
|
||||
'@vee-validate/zod@4.15.1':
|
||||
resolution: {integrity: sha512-329Z4TDBE5Vx0FdbA8S4eR9iGCFFUNGbxjpQ20ff5b5wGueScjocUIx9JHPa79LTG06RnlUR4XogQsjN4tecKA==}
|
||||
peerDependencies:
|
||||
yup: ^1.3.2
|
||||
zod: ^3.24.0
|
||||
|
||||
'@vercel/nft@0.30.4':
|
||||
resolution: {integrity: sha512-wE6eAGSXScra60N2l6jWvNtVK0m+sh873CpfZW4KI2v8EHuUQp+mSEi4T+IcdPCSEDgCdAS/7bizbhQlkjzrSA==}
|
||||
@@ -9264,6 +9264,9 @@ packages:
|
||||
zod@3.22.3:
|
||||
resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==}
|
||||
|
||||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
zod@4.1.13:
|
||||
resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==}
|
||||
|
||||
@@ -10036,7 +10039,7 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@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:
|
||||
'@better-auth/utils': 0.3.0
|
||||
'@better-fetch/fetch': 1.1.18
|
||||
@@ -10047,9 +10050,9 @@ snapshots:
|
||||
nanostores: 1.1.0
|
||||
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:
|
||||
'@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-fetch/fetch': 1.1.18
|
||||
|
||||
@@ -12957,11 +12960,11 @@ snapshots:
|
||||
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
||||
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:
|
||||
type-fest: 4.41.0
|
||||
vee-validate: 4.15.1(vue@3.5.25(typescript@5.9.3))
|
||||
yup: 1.7.1
|
||||
zod: 3.25.76
|
||||
transitivePeerDependencies:
|
||||
- vue
|
||||
|
||||
@@ -13584,8 +13587,8 @@ snapshots:
|
||||
|
||||
better-auth@1.4.6(vue@3.5.25(typescript@5.9.3)):
|
||||
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/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/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@3.25.76))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))
|
||||
'@better-auth/utils': 0.3.0
|
||||
'@better-fetch/fetch': 1.1.18
|
||||
'@noble/ciphers': 2.1.1
|
||||
@@ -17476,7 +17479,8 @@ snapshots:
|
||||
kleur: 3.0.3
|
||||
sisteransi: 1.0.5
|
||||
|
||||
property-expr@2.0.6: {}
|
||||
property-expr@2.0.6:
|
||||
optional: true
|
||||
|
||||
prosemirror-changeset@2.3.1:
|
||||
dependencies:
|
||||
@@ -18408,7 +18412,8 @@ snapshots:
|
||||
|
||||
through@2.3.8: {}
|
||||
|
||||
tiny-case@1.0.3: {}
|
||||
tiny-case@1.0.3:
|
||||
optional: true
|
||||
|
||||
tiny-inflate@1.0.3: {}
|
||||
|
||||
@@ -18464,7 +18469,8 @@ snapshots:
|
||||
dependencies:
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
toposort@2.0.2: {}
|
||||
toposort@2.0.2:
|
||||
optional: true
|
||||
|
||||
tosource@2.0.0-alpha.3: {}
|
||||
|
||||
@@ -18523,7 +18529,8 @@ snapshots:
|
||||
|
||||
type-fest@0.8.1: {}
|
||||
|
||||
type-fest@2.19.0: {}
|
||||
type-fest@2.19.0:
|
||||
optional: true
|
||||
|
||||
type-fest@4.41.0: {}
|
||||
|
||||
@@ -19493,6 +19500,7 @@ snapshots:
|
||||
tiny-case: 1.0.3
|
||||
toposort: 2.0.2
|
||||
type-fest: 2.19.0
|
||||
optional: true
|
||||
|
||||
zip-stream@6.0.1:
|
||||
dependencies:
|
||||
@@ -19502,6 +19510,8 @@ snapshots:
|
||||
|
||||
zod@3.22.3: {}
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zod@4.1.13: {}
|
||||
|
||||
zwitch@2.0.4: {}
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
catalogMode: prefer
|
||||
shellEmulator: true
|
||||
trustPolicy: no-downgrade
|
||||
|
||||
packages:
|
||||
- "packages/*"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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 { createAuthClient } from "better-auth/vue";
|
||||
import * as yup from "yup";
|
||||
import { z } from "zod";
|
||||
import CircleFlagsCnHk from "~icons/circle-flags/cn-hk";
|
||||
import CircleFlagsEnUs from "~icons/circle-flags/en-us";
|
||||
import CircleFlagsTw from "~icons/circle-flags/tw";
|
||||
@@ -23,15 +23,15 @@ export const authClient = createAuthClient({
|
||||
plugins: [emailOTPClient(), phoneNumberClient(), usernameClient()],
|
||||
});
|
||||
|
||||
export const emailSchema = toTypedSchema(yup.object({
|
||||
email: yup
|
||||
.string()
|
||||
.required(i18n.global.t("auth.login.validation.emailRequired"))
|
||||
export const emailSchema = toTypedSchema(z.object({
|
||||
email: z
|
||||
.string({ message: i18n.global.t("auth.login.validation.emailRequired") })
|
||||
.min(1, i18n.global.t("auth.login.validation.emailRequired"))
|
||||
.email(i18n.global.t("auth.login.validation.emailInvalid")),
|
||||
otp: yup
|
||||
.string()
|
||||
.required(i18n.global.t("auth.login.validation.otpRequired"))
|
||||
.matches(/^\d{6}$/, i18n.global.t("auth.login.validation.otpInvalid")),
|
||||
otp: z
|
||||
.string({ message: i18n.global.t("auth.login.validation.otpRequired") })
|
||||
.min(1, i18n.global.t("auth.login.validation.otpRequired"))
|
||||
.regex(/^\d{6}$/, i18n.global.t("auth.login.validation.otpInvalid")),
|
||||
}));
|
||||
|
||||
export const countries: PhoneCountry[] = [
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { GenericObject } from "vee-validate";
|
||||
import type { EmailVerifyClient } from "@/api/types";
|
||||
import { toastController } from "@ionic/vue";
|
||||
import { Field, Form } from "vee-validate";
|
||||
import * as yup from "yup";
|
||||
import { z } from "zod";
|
||||
import { authClient, emailSchema } from "@/auth";
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -19,7 +19,7 @@ const canResend = computed(() => countdown.value === 0 && !isSending.value);
|
||||
const email = ref("");
|
||||
const emailError = ref("");
|
||||
|
||||
let timer: number | null = null;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
|
||||
function startCountdown() {
|
||||
countdown.value = 60;
|
||||
@@ -42,7 +42,7 @@ async function sendOtp() {
|
||||
}
|
||||
|
||||
try {
|
||||
await yup.string().email().validate(emailValue);
|
||||
await z.string().email().parseAsync(emailValue);
|
||||
}
|
||||
catch {
|
||||
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 { PhoneCountry } from "@/auth/type";
|
||||
import { toastController } from "@ionic/vue";
|
||||
import { toTypedSchema } from "@vee-validate/yup";
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import { chevronDown } from "ionicons/icons";
|
||||
import { Field, Form } from "vee-validate";
|
||||
import * as yup from "yup";
|
||||
import { z } from "zod";
|
||||
import { authClient, countries } from "@/auth";
|
||||
import Country from "./country.vue";
|
||||
|
||||
@@ -42,19 +42,18 @@ function validatePhoneNumber(phone: string): boolean {
|
||||
return currentCountry.value.pattern.test(phone);
|
||||
}
|
||||
|
||||
const schema = computed(() => toTypedSchema(yup.object({
|
||||
phoneNumber: yup
|
||||
.string()
|
||||
.required(t("auth.login.validation.phoneNumberRequired"))
|
||||
.test(
|
||||
"phone-format",
|
||||
const schema = computed(() => toTypedSchema(z.object({
|
||||
phoneNumber: z
|
||||
.string({ message: t("auth.login.validation.phoneNumberRequired") })
|
||||
.min(1, t("auth.login.validation.phoneNumberRequired"))
|
||||
.refine(
|
||||
value => validatePhoneNumber(value),
|
||||
t("auth.login.validation.phoneNumberInvalid"),
|
||||
value => !value || validatePhoneNumber(value),
|
||||
),
|
||||
code: yup
|
||||
.string()
|
||||
.required(t("auth.login.validation.codeRequired"))
|
||||
.matches(/^\d{6}$/, t("auth.login.validation.codeInvalid")),
|
||||
code: z
|
||||
.string({ message: t("auth.login.validation.codeRequired") })
|
||||
.min(1, t("auth.login.validation.codeRequired"))
|
||||
.regex(/^\d{6}$/, t("auth.login.validation.codeInvalid")),
|
||||
})));
|
||||
|
||||
function startCountdown() {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang='ts' setup>
|
||||
import type { GenericObject } from "vee-validate";
|
||||
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 { useI18n } from "vue-i18n";
|
||||
import * as yup from "yup";
|
||||
import { z } from "zod";
|
||||
|
||||
const props = defineProps<{
|
||||
initialData: RwaIssuanceProductBody["product"];
|
||||
@@ -16,10 +16,10 @@ const emit = defineEmits<{
|
||||
const { t } = useI18n();
|
||||
|
||||
const schema = toTypedSchema(
|
||||
yup.object({
|
||||
name: yup.string().required(t("asset.issue.apply.validation.nameRequired")),
|
||||
code: yup.string().required(t("asset.issue.apply.validation.codeRequired")),
|
||||
categoryId: yup.string().required(t("asset.issue.apply.validation.categoryRequired")),
|
||||
z.object({
|
||||
name: z.string({ message: t("asset.issue.apply.validation.nameRequired") }).min(1, t("asset.issue.apply.validation.nameRequired")),
|
||||
code: z.string({ message: t("asset.issue.apply.validation.codeRequired") }).min(1, t("asset.issue.apply.validation.codeRequired")),
|
||||
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>
|
||||
import type { GenericObject } from "vee-validate";
|
||||
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 { ErrorMessage, Field, FieldArray, Form } from "vee-validate";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import * as yup from "yup";
|
||||
import { z } from "zod";
|
||||
|
||||
const props = defineProps<{
|
||||
initialData: RwaIssuanceProductBody["editions"];
|
||||
@@ -35,45 +35,40 @@ const launchDate = ref(new Date().toISOString());
|
||||
const subscriptionStartDate = ref(new Date().toISOString());
|
||||
const subscriptionEndDate = ref(new Date().toISOString());
|
||||
|
||||
const schema = toTypedSchema(yup.object({
|
||||
editions: yup.array().of(
|
||||
yup.object({
|
||||
editionName: yup.string().required(t("asset.issue.apply.validation.editionNameRequired")),
|
||||
launchDate: yup.string()
|
||||
.required(t("asset.issue.apply.validation.launchDateRequired"))
|
||||
.test("not-past", t("asset.issue.apply.validation.launchDateNotPast"), (value) => {
|
||||
if (!value)
|
||||
return true;
|
||||
return new Date(value) >= new Date(now.value.toDateString());
|
||||
})
|
||||
.test("before-subscription", t("asset.issue.apply.validation.launchBeforeSubscription"), (value) => {
|
||||
if (!value || !subscriptionStartDate.value)
|
||||
return true;
|
||||
return new Date(value) < new Date(subscriptionStartDate.value);
|
||||
}),
|
||||
subscriptionStartDate: yup.string()
|
||||
.required(t("asset.issue.apply.validation.subscriptionStartDateRequired"))
|
||||
.test("not-past", t("asset.issue.apply.validation.subscriptionStartDateNotPast"), (value) => {
|
||||
if (!value)
|
||||
return true;
|
||||
return new Date(value) >= new Date(now.value.toDateString());
|
||||
})
|
||||
.test("after-launch", t("asset.issue.apply.validation.subscriptionAfterLaunch"), (value) => {
|
||||
if (!value || !launchDate.value)
|
||||
return true;
|
||||
return new Date(value) > new Date(launchDate.value);
|
||||
}),
|
||||
subscriptionEndDate: yup.string()
|
||||
.required(t("asset.issue.apply.validation.subscriptionEndDateRequired"))
|
||||
.test("after-start", t("asset.issue.apply.validation.subscriptionEndAfterStart"), (value) => {
|
||||
if (!value || !subscriptionStartDate.value)
|
||||
return true;
|
||||
return new Date(value) > new Date(subscriptionStartDate.value);
|
||||
}),
|
||||
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")),
|
||||
const schema = toTypedSchema(z.object({
|
||||
editions: z.array(
|
||||
z.object({
|
||||
editionName: z.string({ message: t("asset.issue.apply.validation.editionNameRequired") }).min(1, t("asset.issue.apply.validation.editionNameRequired")),
|
||||
launchDate: z.string({ message: t("asset.issue.apply.validation.launchDateRequired") })
|
||||
.min(1, t("asset.issue.apply.validation.launchDateRequired"))
|
||||
.refine(
|
||||
value => new Date(value) >= new Date(now.value.toDateString()),
|
||||
t("asset.issue.apply.validation.launchDateNotPast"),
|
||||
)
|
||||
.refine(
|
||||
value => !subscriptionStartDate.value || new Date(value) < new Date(subscriptionStartDate.value),
|
||||
t("asset.issue.apply.validation.launchBeforeSubscription"),
|
||||
),
|
||||
subscriptionStartDate: z.string({ message: t("asset.issue.apply.validation.subscriptionStartDateRequired") })
|
||||
.min(1, t("asset.issue.apply.validation.subscriptionStartDateRequired"))
|
||||
.refine(
|
||||
value => new Date(value) >= new Date(now.value.toDateString()),
|
||||
t("asset.issue.apply.validation.subscriptionStartDateNotPast"),
|
||||
)
|
||||
.refine(
|
||||
value => !launchDate.value || new Date(value) > new Date(launchDate.value),
|
||||
t("asset.issue.apply.validation.subscriptionAfterLaunch"),
|
||||
),
|
||||
subscriptionEndDate: z.string({ message: t("asset.issue.apply.validation.subscriptionEndDateRequired") })
|
||||
.min(1, t("asset.issue.apply.validation.subscriptionEndDateRequired"))
|
||||
.refine(
|
||||
value => !subscriptionStartDate.value || new Date(value) > new Date(subscriptionStartDate.value),
|
||||
t("asset.issue.apply.validation.subscriptionEndAfterStart"),
|
||||
),
|
||||
perUserLimit: z.string({ message: t("asset.issue.apply.validation.perUserLimitRequired") }).min(1, t("asset.issue.apply.validation.perUserLimitRequired")),
|
||||
totalSupply: z.string({ message: t("asset.issue.apply.validation.totalSupplyRequired") }).min(1, t("asset.issue.apply.validation.totalSupplyRequired")),
|
||||
unitPrice: z.string({ message: t("asset.issue.apply.validation.unitPriceRequired") }).min(1, t("asset.issue.apply.validation.unitPriceRequired")),
|
||||
dividendRate: z.string({ message: t("asset.issue.apply.validation.dividendRateRequired") }).min(1, t("asset.issue.apply.validation.dividendRateRequired")),
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang='ts' setup>
|
||||
import type { GenericObject } from "vee-validate";
|
||||
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 { ErrorMessage, Field, Form } from "vee-validate";
|
||||
import * as yup from "yup";
|
||||
import { z } from "zod";
|
||||
import { client, safeClient } from "@/api";
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -17,12 +17,12 @@ const formInst = useTemplateRef<FormInstance>("formInst");
|
||||
|
||||
// 表单验证 Schema
|
||||
const schema = toTypedSchema(
|
||||
yup.object({
|
||||
bankName: yup.string().required(t("bankCard.form.validation.bankRequired")),
|
||||
accountNumber: yup
|
||||
.string()
|
||||
.required(t("bankCard.form.validation.accountNumberRequired")),
|
||||
accountName: yup.string().required(t("bankCard.form.validation.accountNameRequired")),
|
||||
z.object({
|
||||
bankName: z.string({ message: t("bankCard.form.validation.bankRequired") }).min(1, t("bankCard.form.validation.bankRequired")),
|
||||
accountNumber: z
|
||||
.string({ message: t("bankCard.form.validation.accountNumberRequired") })
|
||||
.min(1, t("bankCard.form.validation.accountNumberRequired")),
|
||||
accountName: z.string({ message: t("bankCard.form.validation.accountNameRequired") }).min(1, t("bankCard.form.validation.accountNameRequired")),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,9 +1,130 @@
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<style lang='css' scoped></style>
|
||||
<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>
|
||||
|
||||
<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";
|
||||
|
||||
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) {
|
||||
model.value.orderType = item.value;
|
||||
@@ -17,7 +20,7 @@ function onSelectTradeWay(item: TradeWayConfig) {
|
||||
<template>
|
||||
<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">
|
||||
市场
|
||||
{{ currentTradeWay?.name }}
|
||||
</div>
|
||||
<ion-icon :icon="caretDownOutline" />
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as yup from "yup";
|
||||
import { z } from "zod";
|
||||
|
||||
export enum TradeWayValueEnum {
|
||||
LIMIT = "limit",
|
||||
@@ -21,28 +21,35 @@ export const tradeWayConfig: TradeWayConfig[] = [
|
||||
description: "以指定价格买入或卖出",
|
||||
icon: "hugeicons:trade-up",
|
||||
},
|
||||
{
|
||||
name: "市价委托",
|
||||
value: "market",
|
||||
description: "以市场价格买入或卖出",
|
||||
icon: "hugeicons:trade-down",
|
||||
},
|
||||
];
|
||||
|
||||
export const confirmOrderSchema = yup.object({
|
||||
price: yup.number().when("way", {
|
||||
is: TradeWayValueEnum.LIMIT !== undefined,
|
||||
then: yup
|
||||
.number()
|
||||
.typeError("请输入有效的价格")
|
||||
.required("价格为必填项")
|
||||
.moreThan(0, "价格必须大于0"),
|
||||
otherwise: yup.number().notRequired(),
|
||||
}),
|
||||
amount: yup
|
||||
.number()
|
||||
.typeError("请输入有效的数量")
|
||||
.required("数量为必填项")
|
||||
.moreThan(0, "数量必须大于0"),
|
||||
way: yup
|
||||
.mixed<TradeWayValue>()
|
||||
.oneOf(
|
||||
Object.values(TradeWayValueEnum),
|
||||
"请选择有效的交易方式",
|
||||
)
|
||||
.required("交易方式为必填项"),
|
||||
});
|
||||
export const confirmOrderSchema = z.object({
|
||||
quantity: z.coerce.number({ message: "请输入有效的数量" }).gt(0, "数量必须大于0"),
|
||||
price: z.coerce.number({ message: "请输入有效的价格" }).gt(0, "价格必须大于0").optional().or(z.coerce.number().optional()),
|
||||
orderType: z.enum([TradeWayValueEnum.LIMIT, TradeWayValueEnum.MARKET], {
|
||||
message: "请选择有效的交易方式",
|
||||
}) as z.ZodType<TradeWayValue>,
|
||||
}).refine(
|
||||
(data) => {
|
||||
if (data.orderType === TradeWayValueEnum.LIMIT) {
|
||||
return data.price !== undefined && data.price > 0;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "价格为必填项",
|
||||
path: ["price"],
|
||||
},
|
||||
);
|
||||
|
||||
export const confirmOrderSubmitSchema = confirmOrderSchema.transform(data => ({
|
||||
...data,
|
||||
quantity: data.quantity.toString(),
|
||||
price: data.price?.toString() ?? "",
|
||||
}));
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { ChartingLibraryWidgetOptions } from "#/charting_library";
|
||||
import type { SpotOrderBody } from "@/api/types";
|
||||
import type { TradingViewInst } from "@/tradingview/index";
|
||||
import type { ModalInstance } from "@/utils";
|
||||
import { modalController } from "@ionic/vue";
|
||||
import { useRouteQuery } from "@vueuse/router";
|
||||
import { caretDownOutline, ellipsisHorizontal } from "ionicons/icons";
|
||||
@@ -9,28 +10,32 @@ import MaterialSymbolsCandlestickChartOutline from "~icons/material-symbols/cand
|
||||
import { client, safeClient } from "@/api";
|
||||
import { TradeTypeEnum } from "@/api/enum";
|
||||
import { TradingViewChart } from "@/tradingview/index";
|
||||
import ConfirmOrder from "./components/confirm-order.vue";
|
||||
import OrdersPanel from "./components/orders-panel.vue";
|
||||
import TradePairsModal from "./components/trade-pairs-modal.vue";
|
||||
import TradeSwitch from "./components/trade-switch.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 symbol = useRouteQuery<string>("symbol", "BTCUSD");
|
||||
|
||||
const symbol = useRouteQuery<string>("symbol", data.value?.data[0].symbol);
|
||||
const tradingviewOptions: Partial<ChartingLibraryWidgetOptions> = {
|
||||
disabled_features: [
|
||||
"create_volume_indicator_by_default",
|
||||
],
|
||||
};
|
||||
const tradingViewInst = useTemplateRef<TradingViewInst>("tradingViewInst");
|
||||
const [form] = useResetRef<SpotOrderBody>({
|
||||
const confirmModalInst = useTemplateRef<ModalInstance>("confirmModalInst");
|
||||
|
||||
const [form] = useResetRef<SpotOrderBody & { amount: string }>({
|
||||
orderType: TradeWayValueEnum.LIMIT,
|
||||
quantity: "",
|
||||
side: mode.value,
|
||||
symbol: symbol.value,
|
||||
memo: "",
|
||||
price: "",
|
||||
amount: "",
|
||||
});
|
||||
|
||||
async function openTradePairs() {
|
||||
@@ -40,22 +45,38 @@ async function openTradePairs() {
|
||||
initialBreakpoint: 0.95,
|
||||
handle: true,
|
||||
});
|
||||
|
||||
await modal.present();
|
||||
|
||||
const { data: result } = await modal.onWillDismiss<string>();
|
||||
|
||||
if (result) {
|
||||
symbol.value = result;
|
||||
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 handleSubmit() {
|
||||
confirmOrderSchema.validate(form.value).then(() => {
|
||||
console.log("submit successfully");
|
||||
}).catch((err) => {
|
||||
console.log("submit failed:", err);
|
||||
});
|
||||
function handleChangeAmount(event) {
|
||||
const val = (event.target as HTMLInputElement).value;
|
||||
if (val && form.value.price) {
|
||||
const quantity = Number(val) / Number(form.value.price);
|
||||
form.value.quantity = quantity.toString();
|
||||
}
|
||||
else {
|
||||
form.value.quantity = "";
|
||||
}
|
||||
}
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
await confirmOrderSubmitSchema.parseAsync(form.value);
|
||||
confirmModalInst.value?.$el.present();
|
||||
}
|
||||
catch (err) {
|
||||
console.error("订单验证失败:", err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -90,21 +111,37 @@ function handleSubmit() {
|
||||
<TradeSwitch v-model:active="mode" @update:active="val => form.side = val" />
|
||||
<TradeWay v-model="form" />
|
||||
<template v-if="form.orderType === 'limit'">
|
||||
<ion-input v-model="form.price" label="价格" class="count" inputmode="decimal" type="number" placeholder="请输入价格(USDT)" />
|
||||
</template>
|
||||
<ion-input v-model="form.quantity" label="数量" class="count" inputmode="decimal" type="number" placeholder="请输入交易数量">
|
||||
<span slot="end">{{ symbol }}</span>
|
||||
</ion-input>
|
||||
<ion-input v-model="form.price" label="金额" class="count" inputmode="decimal" type="number" placeholder="请输入交易金额">
|
||||
<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 v-else-if="form.orderType === 'market'">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<!-- <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">
|
||||
{{ mode === TradeTypeEnum.BUY ? '买入' : '卖出' }}
|
||||
</ion-button>
|
||||
|
||||
<ion-modal ref="confirmModalInst" class="confirm-modal" :breakpoints="[0, 1]" :initial-breakpoint="1" :handle="false">
|
||||
<ConfirmOrder :form="form" />
|
||||
</ion-modal>
|
||||
</div>
|
||||
<div class="col-span-2" />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 px-4 pb-4">
|
||||
<OrdersPanel />
|
||||
</div>
|
||||
@@ -145,4 +182,7 @@ ion-range.range::part(tick-active) {
|
||||
top: 18px;
|
||||
border-radius: 100%;
|
||||
}
|
||||
.confirm-modal {
|
||||
--height: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { GenericObject } from "vee-validate";
|
||||
import { toastController } from "@ionic/vue";
|
||||
import { arrowBackOutline } from "ionicons/icons";
|
||||
import { Field, Form } from "vee-validate";
|
||||
import * as yup from "yup";
|
||||
import { z } from "zod";
|
||||
import { safeClient } from "@/api";
|
||||
import { authClient, emailSchema } from "@/auth";
|
||||
|
||||
@@ -17,7 +17,7 @@ const isSending = ref(false);
|
||||
const canResend = computed(() => countdown.value === 0 && !isSending.value);
|
||||
const emailError = ref("");
|
||||
|
||||
let timer: number | null = null;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
|
||||
function startCountdown() {
|
||||
countdown.value = 60;
|
||||
@@ -40,7 +40,7 @@ async function sendOtp() {
|
||||
}
|
||||
|
||||
try {
|
||||
await yup.string().email().validate(emailValue);
|
||||
await z.string().email().parseAsync(emailValue);
|
||||
}
|
||||
catch {
|
||||
emailError.value = t("auth.login.validation.emailInvalid");
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { FormInstance } from "@/utils";
|
||||
import { loadingController, toastController } from "@ionic/vue";
|
||||
import { swapVerticalOutline } from "ionicons/icons";
|
||||
import { ErrorMessage, Field, Form } from "vee-validate";
|
||||
import * as yup from "yup";
|
||||
import { z } from "zod";
|
||||
import { client, safeClient } from "@/api";
|
||||
import { AssetCodeEnum } from "@/api/enum";
|
||||
import { getCryptoIcon } from "@/config/crypto";
|
||||
@@ -43,15 +43,15 @@ const availableBalance = computed(() => {
|
||||
});
|
||||
|
||||
// 验证规则
|
||||
const schema = computed(() => yup.object({
|
||||
assetCode: yup.string().required(t("transfer.assetCodeRequired")),
|
||||
amount: yup
|
||||
.string()
|
||||
.required(t("transfer.amountRequired"))
|
||||
.test("min", t("transfer.amountMinError"), value => Number(value) > 0)
|
||||
.test("max", t("transfer.amountMaxError", { amount: availableBalance.value }), value => Number(value) <= Number(availableBalance.value)),
|
||||
fromAccount: yup.string().required(t("transfer.fromAccountRequired")),
|
||||
toAccount: yup.string().required(t("transfer.toAccountRequired")),
|
||||
const schema = computed(() => z.object({
|
||||
assetCode: z.string({ message: t("transfer.assetCodeRequired") }).min(1, t("transfer.assetCodeRequired")),
|
||||
amount: z
|
||||
.string({ message: t("transfer.amountRequired") })
|
||||
.min(1, t("transfer.amountRequired"))
|
||||
.refine(value => Number(value) > 0, t("transfer.amountMinError"))
|
||||
.refine(value => Number(value) <= Number(availableBalance.value), t("transfer.amountMaxError", { amount: availableBalance.value })),
|
||||
fromAccount: z.string({ message: t("transfer.fromAccountRequired") }).min(1, t("transfer.fromAccountRequired")),
|
||||
toAccount: z.string({ message: t("transfer.toAccountRequired") }).min(1, t("transfer.toAccountRequired")),
|
||||
}));
|
||||
|
||||
// 交换账户
|
||||
|
||||
@@ -1,43 +1,70 @@
|
||||
import { toTypedSchema } from "@vee-validate/yup";
|
||||
import * as yup from "yup";
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import { z } from "zod";
|
||||
import { WithdrawMethodEnum } from "@/api/enum";
|
||||
|
||||
export function createWithdrawSchema(t: (key: string, params?: any) => string, maxAmount: string) {
|
||||
return toTypedSchema(
|
||||
yup.object({
|
||||
assetCode: yup.string().required(t("withdraw.validation.assetCodeRequired")),
|
||||
amount: yup
|
||||
.string()
|
||||
.required(t("withdraw.validation.amountRequired"))
|
||||
.test("is-number", t("withdraw.validation.amountInvalid"), (value) => {
|
||||
return /^\d+(?:\.\d+)?$/.test(value || "");
|
||||
})
|
||||
.test("max-amount", t("withdraw.validation.amountExceedsBalance"), (value) => {
|
||||
if (!value || maxAmount === "0")
|
||||
z.object({
|
||||
assetCode: z.string({ message: t("withdraw.validation.assetCodeRequired") }).min(1, t("withdraw.validation.assetCodeRequired")),
|
||||
amount: z
|
||||
.string({ message: t("withdraw.validation.amountRequired") })
|
||||
.min(1, t("withdraw.validation.amountRequired"))
|
||||
.refine(
|
||||
value => /^\d+(?:\.\d+)?$/.test(value),
|
||||
t("withdraw.validation.amountInvalid"),
|
||||
)
|
||||
.refine(
|
||||
(value) => {
|
||||
if (maxAmount === "0")
|
||||
return false;
|
||||
return Number.parseFloat(value) <= Number.parseFloat(maxAmount);
|
||||
},
|
||||
t("withdraw.validation.amountExceedsBalance"),
|
||||
)
|
||||
.refine(
|
||||
value => Number.parseFloat(value) > 0,
|
||||
t("withdraw.validation.amountTooSmall"),
|
||||
),
|
||||
withdrawMethod: z.string({ message: t("withdraw.validation.methodRequired") }).min(1, t("withdraw.validation.methodRequired")),
|
||||
bankAccountId: z.string().optional(),
|
||||
chain: z.string().optional(),
|
||||
toAddress: z.string().optional(),
|
||||
})
|
||||
.test("min-amount", t("withdraw.validation.amountTooSmall"), (value) => {
|
||||
if (!value)
|
||||
return false;
|
||||
return Number.parseFloat(value) > 0;
|
||||
}),
|
||||
withdrawMethod: yup.string().required(t("withdraw.validation.methodRequired")),
|
||||
bankAccountId: yup.string().when("withdrawMethod", {
|
||||
is: WithdrawMethodEnum.BANK,
|
||||
then: schema => schema.required(t("withdraw.validation.bankAccountRequired")),
|
||||
otherwise: schema => schema.optional(),
|
||||
}),
|
||||
chain: yup.string().when("withdrawMethod", {
|
||||
is: WithdrawMethodEnum.CRYPTO,
|
||||
then: schema => schema.required(t("withdraw.validation.chainRequired")),
|
||||
otherwise: schema => schema.optional(),
|
||||
}),
|
||||
toAddress: yup.string().when("withdrawMethod", {
|
||||
is: WithdrawMethodEnum.CRYPTO,
|
||||
then: schema => schema.required(t("withdraw.validation.addressRequired")).min(10, t("withdraw.validation.addressTooShort")),
|
||||
otherwise: schema => schema.optional(),
|
||||
}),
|
||||
}),
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.withdrawMethod === WithdrawMethodEnum.BANK) {
|
||||
return !!data.bankAccountId;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
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"],
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
2187
types/charting_library.d.ts
vendored
2187
types/charting_library.d.ts
vendored
File diff suppressed because it is too large
Load Diff
69
types/datafeeds.d.ts
vendored
69
types/datafeeds.d.ts
vendored
@@ -9,7 +9,7 @@ declare const enum SearchInitiationPoint {
|
||||
SymbolSearch = "symbolSearch",
|
||||
Watchlist = "watchlist",
|
||||
Compare = "compare",
|
||||
IndicatorInputs = "indicatorInputs"
|
||||
IndicatorInputs = "indicatorInputs",
|
||||
}
|
||||
/**
|
||||
* This is the generic type useful for declaring a nominal type,
|
||||
@@ -24,14 +24,15 @@ declare const enum SearchInitiationPoint {
|
||||
* @example
|
||||
* type TagName = Nominal<string, 'TagName'>;
|
||||
*/
|
||||
declare type Nominal<T, Name extends string> = T & { /* eslint-disable-next-line jsdoc/require-jsdoc */
|
||||
declare type Nominal<T, Name extends string> = T & {
|
||||
[Symbol.species]: Name;
|
||||
};
|
||||
/**
|
||||
* Bar data point
|
||||
*/
|
||||
interface Bar {
|
||||
/** Bar time.
|
||||
/**
|
||||
* Bar time.
|
||||
* Amount of **milliseconds** since Unix epoch start in **UTC** timezone.
|
||||
* `time` for daily, weekly, and monthly bars is expected to be a trading day (not session start day) at 00:00 UTC.
|
||||
* The library adjusts time according to `session` from {@link LibrarySymbolInfo}.
|
||||
@@ -273,7 +274,7 @@ interface IDatafeedChartApi {
|
||||
* @param onDataCallback Callback function containing an array of marks
|
||||
* @param resolution Resolution of the symbol
|
||||
*/
|
||||
getMarks?(symbolInfo: LibrarySymbolInfo, from: number, to: number, onDataCallback: GetMarksCallback<Mark>, resolution: ResolutionString): void;
|
||||
getMarks?: (symbolInfo: LibrarySymbolInfo, from: number, to: number, onDataCallback: GetMarksCallback<Mark>, resolution: ResolutionString) => void;
|
||||
/**
|
||||
* The library calls this function to get timescale marks for visible bars range.
|
||||
* The library assumes that you will call `onDataCallback` only once per `getTimescaleMarks` call.
|
||||
@@ -286,7 +287,7 @@ interface IDatafeedChartApi {
|
||||
* @param onDataCallback Callback function containing an array of marks
|
||||
* @param resolution Resolution of the symbol
|
||||
*/
|
||||
getTimescaleMarks?(symbolInfo: LibrarySymbolInfo, from: number, to: number, onDataCallback: GetMarksCallback<TimescaleMark>, resolution: ResolutionString): void;
|
||||
getTimescaleMarks?: (symbolInfo: LibrarySymbolInfo, from: number, to: number, onDataCallback: GetMarksCallback<TimescaleMark>, resolution: ResolutionString) => void;
|
||||
/**
|
||||
* This function is called if the `supports_time` configuration flag is `true` when the chart needs to know the server time.
|
||||
* The library expects a callback to be called once.
|
||||
@@ -295,7 +296,7 @@ interface IDatafeedChartApi {
|
||||
* `getServerTime` is used to display countdown on the price scale.
|
||||
* Note that the countdown can be displayed only for [intraday](https://www.tradingview.com/charting-library-docs/latest/core_concepts/Resolution#resolution-in-minutes-intraday) resolutions.
|
||||
*/
|
||||
getServerTime?(callback: ServerTimeCallback): void;
|
||||
getServerTime?: (callback: ServerTimeCallback) => void;
|
||||
/**
|
||||
* Provides a list of symbols that match the user's search query.
|
||||
*
|
||||
@@ -305,7 +306,7 @@ interface IDatafeedChartApi {
|
||||
* @param onResult Callback function that returns an array of results ({@link SearchSymbolResultItem}) or empty array if no symbols found
|
||||
* @param searchSource The source of the search ({@link SearchInitiationPoint}).
|
||||
*/
|
||||
searchSymbols(userInput: string, exchange: string, symbolType: string, onResult: SearchSymbolsCallback, searchSource?: SearchInitiationPoint): void;
|
||||
searchSymbols: (userInput: string, exchange: string, symbolType: string, onResult: SearchSymbolsCallback, searchSource?: SearchInitiationPoint) => void;
|
||||
/**
|
||||
* The library will call this function when it needs to get SymbolInfo by symbol name.
|
||||
*
|
||||
@@ -314,7 +315,7 @@ interface IDatafeedChartApi {
|
||||
* @param onError Callback function whose only argument is a text error message
|
||||
* @param extension An optional object with additional parameters
|
||||
*/
|
||||
resolveSymbol(symbolName: string, onResolve: ResolveCallback, onError: DatafeedErrorCallback, extension?: SymbolResolveExtension): void;
|
||||
resolveSymbol: (symbolName: string, onResolve: ResolveCallback, onError: DatafeedErrorCallback, extension?: SymbolResolveExtension) => void;
|
||||
/**
|
||||
* This function is called when the chart needs a history fragment defined by dates range.
|
||||
*
|
||||
@@ -324,7 +325,7 @@ interface IDatafeedChartApi {
|
||||
* @param onResult Callback function for historical data
|
||||
* @param onError Callback function whose only argument is a text error message. If using special characters, please consider `encodeURIComponent`.
|
||||
*/
|
||||
getBars(symbolInfo: LibrarySymbolInfo, resolution: ResolutionString, periodParams: PeriodParams, onResult: HistoryCallback, onError: DatafeedErrorCallback): void;
|
||||
getBars: (symbolInfo: LibrarySymbolInfo, resolution: ResolutionString, periodParams: PeriodParams, onResult: HistoryCallback, onError: DatafeedErrorCallback) => void;
|
||||
/**
|
||||
* The library calls this function when it wants to receive real-time updates for a symbol.
|
||||
* The library assumes that you will call the callback provided by the `onTick` parameter every time you want to update the most recent bar or to add a new one.
|
||||
@@ -335,13 +336,13 @@ interface IDatafeedChartApi {
|
||||
* @param listenerGuid
|
||||
* @param onResetCacheNeededCallback Function to be executed when bar data has changed
|
||||
*/
|
||||
subscribeBars(symbolInfo: LibrarySymbolInfo, resolution: ResolutionString, onTick: SubscribeBarsCallback, listenerGuid: string, onResetCacheNeededCallback: () => void): void;
|
||||
subscribeBars: (symbolInfo: LibrarySymbolInfo, resolution: ResolutionString, onTick: SubscribeBarsCallback, listenerGuid: string, onResetCacheNeededCallback: () => void) => void;
|
||||
/**
|
||||
* The library calls this function when it doesn't want to receive updates anymore.
|
||||
*
|
||||
* @param listenerGuid id to unsubscribe from
|
||||
*/
|
||||
unsubscribeBars(listenerGuid: string): void;
|
||||
unsubscribeBars: (listenerGuid: string) => void;
|
||||
/**
|
||||
* The library calls this function when it wants to receive real-time symbol data in the [Depth of Market](https://www.tradingview.com/charting-library-docs/latest/trading_terminal/depth-of-market) (DOM) widget.
|
||||
* Note that you should set the {@link BrokerConfigFlags.supportLevel2Data} configuration flag to `true`.
|
||||
@@ -350,14 +351,14 @@ interface IDatafeedChartApi {
|
||||
* @param callback A function returning an object to update DOM data
|
||||
* @returns A unique identifier that will be used to unsubscribe from the data
|
||||
*/
|
||||
subscribeDepth?(symbol: string, callback: DOMCallback): string;
|
||||
subscribeDepth?: (symbol: string, callback: DOMCallback) => string;
|
||||
/**
|
||||
* The library calls this function to stop receiving real-time updates for the [Depth of Market](https://www.tradingview.com/charting-library-docs/latest/trading_terminal/depth-of-market) listener.
|
||||
* Note that you should set the {@link BrokerConfigFlags.supportLevel2Data} configuration flag to `true`.
|
||||
*
|
||||
* @param subscriberUID A string returned by `subscribeDepth`
|
||||
*/
|
||||
unsubscribeDepth?(subscriberUID: string): void;
|
||||
unsubscribeDepth?: (subscriberUID: string) => void;
|
||||
/**
|
||||
* The library calls this function to get the resolution that will be used to calculate the Volume Profile Visible Range indicator.
|
||||
*
|
||||
@@ -371,7 +372,7 @@ interface IDatafeedChartApi {
|
||||
* @param symbolInfo A Symbol object
|
||||
* @returns A resolution
|
||||
*/
|
||||
getVolumeProfileResolutionForPeriod?(currentResolution: ResolutionString, from: number, to: number, symbolInfo: LibrarySymbolInfo): ResolutionString;
|
||||
getVolumeProfileResolutionForPeriod?: (currentResolution: ResolutionString, from: number, to: number, symbolInfo: LibrarySymbolInfo) => ResolutionString;
|
||||
}
|
||||
/** Quotes datafeed API */
|
||||
interface IDatafeedQuotesApi {
|
||||
@@ -382,7 +383,7 @@ interface IDatafeedQuotesApi {
|
||||
* @param {QuotesCallback} onDataCallback - callback to return the requested data.
|
||||
* @param {QuotesErrorCallback} onErrorCallback - callback for responding with an error.
|
||||
*/
|
||||
getQuotes(symbols: string[], onDataCallback: QuotesCallback, onErrorCallback: QuotesErrorCallback): void;
|
||||
getQuotes: (symbols: string[], onDataCallback: QuotesCallback, onErrorCallback: QuotesErrorCallback) => void;
|
||||
/**
|
||||
* Trading Platform calls this function when it wants to receive real-time quotes for a symbol.
|
||||
* The library assumes that you will call `onRealtimeCallback` every time you want to update the quotes.
|
||||
@@ -391,13 +392,13 @@ interface IDatafeedQuotesApi {
|
||||
* @param {QuotesCallback} onRealtimeCallback - callback to send realtime quote data updates
|
||||
* @param {string} listenerGUID - unique identifier of the listener
|
||||
*/
|
||||
subscribeQuotes(symbols: string[], fastSymbols: string[], onRealtimeCallback: QuotesCallback, listenerGUID: string): void;
|
||||
subscribeQuotes: (symbols: string[], fastSymbols: string[], onRealtimeCallback: QuotesCallback, listenerGUID: string) => void;
|
||||
/**
|
||||
* Trading Platform calls this function when it doesn't want to receive updates for this listener anymore.
|
||||
* `listenerGUID` will be the same object that the Library passed to `subscribeQuotes` before.
|
||||
* @param {string} listenerGUID - unique identifier of the listener
|
||||
*/
|
||||
unsubscribeQuotes(listenerGUID: string): void;
|
||||
unsubscribeQuotes: (listenerGUID: string) => void;
|
||||
}
|
||||
interface IExternalDatafeed {
|
||||
/**
|
||||
@@ -406,7 +407,7 @@ interface IExternalDatafeed {
|
||||
*
|
||||
* @param {OnReadyCallback} callback - callback to return your datafeed configuration ({@link DatafeedConfiguration}) to the library.
|
||||
*/
|
||||
onReady(callback: OnReadyCallback): void;
|
||||
onReady: (callback: OnReadyCallback) => void;
|
||||
}
|
||||
interface LibrarySubsessionInfo {
|
||||
/**
|
||||
@@ -414,15 +415,15 @@ interface LibrarySubsessionInfo {
|
||||
*
|
||||
* @example "Regular Trading Hours"
|
||||
*/
|
||||
description: string;
|
||||
"description": string;
|
||||
/**
|
||||
* Subsession ID.
|
||||
*/
|
||||
id: LibrarySessionId;
|
||||
"id": LibrarySessionId;
|
||||
/**
|
||||
* Session string. See {@link LibrarySymbolInfo.session}.
|
||||
*/
|
||||
session: string;
|
||||
"session": string;
|
||||
/**
|
||||
* Session corrections string. See {@link LibrarySymbolInfo.corrections}.
|
||||
*/
|
||||
@@ -659,9 +660,9 @@ interface LibrarySymbolInfo {
|
||||
/**
|
||||
* The boolean value showing whether or not seconds bars for this symbol can be built from ticks. Only available in Trading Platform.
|
||||
*
|
||||
* * {@link LibrarySymbolInfo.has_seconds} must also be `true`
|
||||
* * {@link LibrarySymbolInfo.has_ticks} must also be `true`
|
||||
* * {@link LibrarySymbolInfo.seconds_multipliers} must be an empty array or only contain multipliers that the datafeed provides itself
|
||||
* {@link LibrarySymbolInfo.has_seconds} must also be `true`
|
||||
* {@link LibrarySymbolInfo.has_ticks} must also be `true`
|
||||
* {@link LibrarySymbolInfo.seconds_multipliers} must be an empty array or only contain multipliers that the datafeed provides itself
|
||||
*
|
||||
* The library builds resolutions from ticks only if there are no seconds resolutions from the datafeed or the provided resolutions are larger then the required one. For example, the datafeed provides `3S` resolution. In this case, the library can build only `1S` or `2S` resolutions from ticks. Resolutions such as `15S` will be build with seconds bars.
|
||||
* @default false
|
||||
@@ -827,10 +828,10 @@ interface LibrarySymbolInfo {
|
||||
* - `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4...`
|
||||
*/
|
||||
logo_urls?: [
|
||||
string
|
||||
string,
|
||||
] | [
|
||||
string,
|
||||
string
|
||||
string,
|
||||
];
|
||||
/**
|
||||
* URL of image to be displayed as the logo for the exchange. The `show_exchange_logos` featureset needs to be enabled for this to be visible in the UI.
|
||||
@@ -1000,10 +1001,10 @@ interface SearchSymbolResultItem {
|
||||
* - `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4...`
|
||||
*/
|
||||
logo_urls?: [
|
||||
string
|
||||
string,
|
||||
] | [
|
||||
string,
|
||||
string
|
||||
string,
|
||||
];
|
||||
/**
|
||||
* URL of image to be displayed as the logo for the exchange. The `show_exchange_logos` featureset needs to be enabled for this to be visible in the UI.
|
||||
@@ -1145,17 +1146,15 @@ interface UdfResponse {
|
||||
s: string;
|
||||
}
|
||||
interface UdfErrorResponse {
|
||||
s: 'error';
|
||||
s: "error";
|
||||
errmsg: string;
|
||||
}
|
||||
|
||||
interface IRequester {
|
||||
sendRequest<T extends UdfResponse>(datafeedUrl: string, urlPath: string, params?: RequestParams): Promise<T | UdfErrorResponse>;
|
||||
sendRequest<T>(datafeedUrl: string, urlPath: string, params?: RequestParams): Promise<T>;
|
||||
sendRequest<T>(datafeedUrl: string, urlPath: string, params?: RequestParams): Promise<T>;
|
||||
sendRequest: (<T extends UdfResponse>(datafeedUrl: string, urlPath: string, params?: RequestParams) => Promise<T | UdfErrorResponse>) & (<T>(datafeedUrl: string, urlPath: string, params?: RequestParams) => Promise<T>) & (<T>(datafeedUrl: string, urlPath: string, params?: RequestParams) => Promise<T>);
|
||||
}
|
||||
|
||||
type PeriodParamsWithOptionalCountback = Omit<PeriodParams, 'countBack'> & {
|
||||
type PeriodParamsWithOptionalCountback = Omit<PeriodParams, "countBack"> & {
|
||||
countBack?: number;
|
||||
};
|
||||
interface LimitedResponseConfiguration {
|
||||
@@ -1172,11 +1171,11 @@ interface LimitedResponseConfiguration {
|
||||
* response then `expectedOrder` specifies whether the server
|
||||
* will send the latest (newest) or earliest (older) data first.
|
||||
*/
|
||||
expectedOrder: 'latestFirst' | 'earliestFirst';
|
||||
expectedOrder: "latestFirst" | "earliestFirst";
|
||||
}
|
||||
|
||||
interface IQuotesProvider {
|
||||
getQuotes(symbols: string[]): Promise<QuoteData[]>;
|
||||
getQuotes: (symbols: string[]) => Promise<QuoteData[]>;
|
||||
}
|
||||
|
||||
interface UdfCompatibleConfiguration extends DatafeedConfiguration {
|
||||
|
||||
Reference in New Issue
Block a user